7.1 定义抽象数据类
类背后的基本思想:数据抽象 和 封装。
数据抽象:是一种依赖于 接口和实现分离 的编程技术
封装:将类的内部成员 设置成外部不可见,但提供部分接口给外面
(1) 类成员:
- 必须在类的内部声明,不能在其他地方增加成员。
- 成员可以是数据,函数,类型别名。
(2) 类的成员函数:
- 成员函数的声明 必须在类的内部;成员函数的定义 既可以在类的内部也可以在外部。
- 对象使用运算符(.) 调用成员函数。
- 默认实参:Sales_item(const std::string &book): isbn(book), units_sold(0), revenue(0.0) { }
注:
- 必须对任何 const或引用类型成员以及没有默认构造函数的类类型的任何成员 初始化。
- 定义在 类内部的函数 是隐式的inline函数。
- 每个成员函数都有一个额外的,隐含的形参this指针。
- this总是指向当前对象,因此this是一个常量指针。(class_type *const p)
- 常量成员函数:形参表后面跟const,改变了隐含的this指针的类型(从class_type *const -> const class_type *const),this指向的当前对象是常量。(如:bool same_isbn(const Sales_item &rhs) const)
注:
- return *this;可以让成员函数连续调用。(对象调用函数又返回对象本身,岂不是可以又接着继续调用函数)
(4) 非成员函数:
- 和类相关的非成员函数,定义和声明 都应该在类的外部。
- 经验:一般来说,如果非成员函数是 类接口的组成部分,则这些函数的声明 应该与类在同一个头文件内。
类的构造函数:
类通过一个或者几个特殊的成员函数 来控制其对象的初始化过程,这些函数叫做 构造函数。(构造函数是 特殊的 且 与类同名的 成员函数)
注:
默认构造函数:
- 构造函数放在类的public部分。
- 不同于其他成员函数,构造函数不能被声明成const的。(当我们创建类的一个const对象时,直到构造函数完成初始化过程,对象才能真正取得其“常量”属性。因此,构造函数在const对象的构造过程中可以向其写值)
默认构造函数:
类通过一个特殊的构造函数来控制 默认初始化过程,这个函数叫做 默认构造函数。(默认构造函数无须任何实参)编译器创建的构造函数又被称为 合成的默认构造函数。
合成的默认构造函数 按照如下规则 初始化类的数据成员:
- 如果存在 类内的初始值,则用它来初始化成员。
- 否则,默认初始化该成员。
对于一个普通的类来说,必须定义它自己的默认构造函数,原因有三:
- 编译器只有在发现 类不包含任何构造函数的情况下 才会替我们生成一个默认的构造函数。一旦我们定义了一些其他的构造函数,那么除非我们再定义 一个默认的构造函数,否则类将没有默认构造函数。
- 对于某些类来说,合成的默认构造函数可能执行错误的操作。例如:如果定义在块中的 内置类型或复合类型(比如数组和指针)的对象 被默认初始化,则它们的值将是 未定义的。
- 有的时候编译器不能为某些类 合成默认的构造函数。例如:如果类中包含一个其他类类型的成员 且这个成员的类型没有默认构造函数,那么编译器将 无法初始化该成员。对于这样的类来说,我们必须自定义默认构造函数,否则该类将没有可用的默认构造函数。
- =default的含义:如果我们需要默认的行为,那么可以通过 在参数列表后面写上 = default 来要求编译器生成构造函数 (C++11)。(其中,= default 既可以和声明一起出现在类的内部,也可以作为定义出现在类的外部。和其他函数一样,如果= default在类的内部,则默认构造函数是 内联的;如果它在类的外部,则该成员默认情况下 不是内联的)
- 构造函数初始化列表:冒号和花括号之间的代码: Sales_item() : units_sold(0), revenue(0.0) { }
注:
- 构造函数不应该轻易覆盖掉类内的初始值,除非新赋的值与原值不同。
- 如果你的编译器不支持使用类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员。
7.2 访问控制与封装
(1) 访问说明符:- public:定义在 public后面的成员在整个程序内可以被访问(public成员定义类的接口)
- private:定义在 private后面的成员可以被类的成员函数访问,但不能被使用该类的代码访问(private隐藏了类的实现细节)
(2) class和struct的区别:
class或者 struct 都可以被用于定义一个类。唯一的却别在于 访问权限。
- 使用class时,定义在第一个访问说明符之前的成员是 priavte的。
- 使用struct时,定义在第一个访问说明符之前的成员是 public的。
(3) 友元:
注: - 友元:允许特定的非成员函数访问 一个类的私有成员,友元的声明以 关键字 friend 开始。
- 友元类:如果一个类指定了友元类,则友元类的成员函数 可以访问此类包括非公有成员在内 的所有成员。
- 友元的声明仅仅指定了 访问的权限,而非一个通常意义上的函数声明。
- 友元关系不存在传递性。(朋友的朋友不一定是我的朋友)
7.3 类的其他特性
(1) 成员函数作为内联函数的 两种方法:
- 在类内部定义。
- 在类外部定义,但显示的加上inline关键字。
(2) 可变数据成员:一个可变数据成员永远不会是const,即使它是const对象的成员。因此,一个const成员函数可以改变一个可变成员的值。(mutable size_t access_ctr;)
类类型:每个类定义了唯一的类型。(对于两个类来说,即使它们的成员完全一样,这两个类也是两个不同的类型)
类类型:每个类定义了唯一的类型。(对于两个类来说,即使它们的成员完全一样,这两个类也是两个不同的类型)
(3) 类的声明:
就像可以把函数的声明和定义分离开来一样,我们也能仅仅声明类而暂时不定义它:
class Screen; // Screen 类的声明
这种声明有时被称作 前向声明,它向程序中引入了名字Screen 并且指明Sereen是一种类类型。(对于类型Screen来说,在它 声明之后定义之前 是一个不完全类型,也就是说,此时我们已知Screen是一个类类型,但是不清楚它到底包含哪些成员)
前向声明只能在非常有限的情景下使用:
注:因为只有当类全部完成后 类才算被定义,所以一个类的成员类型 不能是该类自己。然而,一旦一个类的名字出现后,它就被认为是声明过了(但尚未定义),因此类允许包含 指向它自身类型的引用或指针:
- 不能定义对象
- 可以用于定义指向这个类型的指针或引用
- 用于声明作为形参类型或函数的返回值类型
class B; // 如果注释掉会报错: 找不到标识符"B" class A { public: void fun_A(B& b); private: B *b; // 如果改成 B b; 也会报错: 使用未定义class B A *a; // 如果改成 A a; 也会报错: 使用正在定义的"A" };
7.4 类的作用域
每个类都会定义它自己的作用域。在类的作用域之外,普通的数据和函数成员 只能由对象、引用、指针使用成员访问运算符来访问;类类型成员 则使用作用域运算符来访问。
(1) 函数的返回类型通常在函数名前面,因此当成员函数定义在类的外部时 (此时返回类型是在作用域说明符之前),返回类型中使用的名字都位于类的作用域之外。
class A { public: A fun(); private: typedef A A_t; }; A::A_t A::fun( ) { // A_t前面必须有(A::),作用域运算符A::后面的语句块才在作用域内,如果A_t之前没有(A::),就不在作用域内 }
(2) 如果成员使用了外层作用域中的某个名字,而该名字代表一种类型,则类不能在之后重新定义该名字。
typedef int int_t; class A { public: int_t fun(); private: typedef double int_t; // 错误: 即使A中定义的int_t类型 与外层作用域定义的int_t一致, 但仍然是错误的(甚至有的编译器不报错) int_t a; };
经验:类型名的定义 通常出现在类的开始处,这样就能确保所有使用该类型的成员 都出现在类名的定义之后。
(3) 尽管外层的对象被隐藏掉了,但我们仍然可以用 作用域运算符 访问它。
int B = 1; class A { public: A(int b) :B(b) {} int fun() { return ::B; } // 返回全局变量B private: int B; }; int main() { A a(3); // A::B=3 int n = a.fun(); // n=1 }
建议:不要隐藏外层作用域中 可能被用到的名字。(不要同名)
7.5 构造函数再探
构造函数初始值列表:
(1) 如果成员是 const、引用、某种未提供默认构造函数的类类型,则我们必须通过构造函数初始值列表 为这些成员提供初值。class Demo { public: Demo(int t); private: int A; const int B; int& C; }; Demo::Demo(int t) { A = t; // 正确 B = t; // 错误: 不能给const赋值 C = t; // 错误: C没被初始化 } // 随着构造函数体一开始执行,初始化就完成了。我们初始化const或者引用类型的数据成员的唯一机会就是通过"构造函数初始值" Demo::Demo(int t) :A(t), B(t), C(t) { // 正确显式地初始化 引用C和const成员B }
(2) 最好让构造函数初始值的顺序 和成员声明的顺序 保持一致。(而且如果可能的话,尽量避免使用某些成员初始化其他成员)
class Demo { public: Demo(int t) :b(t), a(b) { } // 未定义: 成员的初始化顺序 与它们在类定义中的出现顺序一致: 所以先初始化a,再初始化b。因此用未初始化的b来初始化a 将产生错误! private: int a; int b; };
(3)如果一个构造函数 为所有参数都 提供了默认参数,那么它实际上也定义了默认的构造函数。(相当于默认构造函数)
class Demo { public: Demo(int i=1 ,int j=1) :a(i), b(j) { } private: int a; int b; }; int main() { Demo d; // a=1,b=1 }
委托构造函数:(C++11)
委托构造函数将自己的职责委 托给了其他构造函数。
class Demo { public: Demo(int i, double j) :a(i), b(j) { } Demo() :Demo(0, 0.0) { cout << a << " " << b << endl; } Demo(int i) :Demo(i, 0.0) { cout << a << " " << b << endl; } Demo(double i) :Demo(0, i) { cout << a << " " << b << endl; } private: int a; double b; }; int main() { Demo A; // a=0,b=0 Demo B(2); // a=2,b=0 Demo C(1.5); // a=0,b=1.5 }
默认构造函数的作用:
当对象被 默认初始化或值初始化时 自动执行默认构造函数。(类必须 包含一个默认构造函数 以便在下述情况下使用)
默认初始化在以下情况下发生:
- 当我们在块作用域内 不使用任何初始值定义一个 非静态变量或者数组时。
- 当一个类本身含有类类型的成员 且使用合成的默认构造函数时。
- 当类类型的成员 没有在构造函数初始值列表中 显式地初始化时。
- 在数组初始化的过程中 如果我们提供的初始值数量 小于数组的大小时。
- 当我们不使用初始值 定义一个局部静态变量时。
- 当我们通过书写形如T( )的表达式显式地请求值初始化时,其中T是类型名。
隐式的类型转换:
(1) 如果构造函数只接受一个实参,则它实际上定义了 转换为此类类型的 隐式转换机制。这种构造函数又叫转换构造函数。
class Demo { public: Demo(string str, int i) :a(str), b(i) { } Demo(string str) :a(str), b(0) { } string combine_a(const Demo& demo) { // 参数是const Demo&类型 return a + demo.a; } private: string a; int b; }; int main() { string str1 = "aaa", str2 = "bbb"; Demo A(str1,1); // a="aaa",b=1 string result = A.combine_a(str2); // result="aaabbb" (传递str2将会先调用 构造函数Demo(string i),从而转换为Demo类型对象,再调用combine_a函数) }
注:编译器只会自动地执行仅一步类型转换。
// 将上列str2改为"bbb" string result = A.combine_a("bbb"); // 错误:不存在从 cosnt char[4] 到 Demo 的适当构造函数("bbb"不会先转换到string,再转换到Demo)
(2) 将构造函数声明为 explicit 可以抑制构造函数定义的隐式转换
// 将上例中的Demo定义成expilic explicit Demo(string str) :a(str), b(0) { cout << a << b << endl; } string result = A.combine_a(str2); // 错误: 此时不可以调用Demo(string str)构造函数 将string类型的str2转换为Demo类型对象
(3) explicit构造函数只能用于直接初始化,不能用于拷贝形式的初始化。
explicit Demo(string str) :a(str),b(0) {} Demo A(str1); // 正确: 直接初始化 Demo B = str1; // 错误: 不能将 explicit构造函数 用于拷贝形式的初始化过程
(4) 尽管编译器不会将 explicit构造函数 用于隐式转换过程,但是可以使用这样的构造函数 显式地进行强制转换。
explicit Demo(string str) :a(str), b(0) { } A.combine_a(str2); // 错误: Demo(string str)为exlicit构造函数,无法隐式调用进行类型转换 A.combine_a(Demo(str2)); // 正确: 显式调用Demo(string str)进行类型转换 A.combine_a(static_cast(str2)) // 正确: static_cast可以使用 explicit构造函数
聚合类:
聚合类使得用户可以 直接访问其成员,并且具有特殊的 初始化语法。
满足以下所有条件的类为 聚合类:
- 所有成员都是public的。
- 没有定义任何构造函数。
- 没有类内初始值。
- 没有基类,也没有virtual函数。
使用一个花括号括起来的 成员初始值列表 进行初始化(初始值的顺序必须和声明的顺序一致)
// 聚合类示例: struct Data { int val; string s; }; class person { public: int id; string name; }; int main() { Data data1 = { 0,"aaa" }; // 正确 Data data2 = { "aaa",0 }; // 错误: 不能使用"aaa"初始化val person per1 = { 123,"bob" }; // 正确 person per2 = { 456 }; // 正确: name默认初始化为"" }
注:显式地初始化类的对象的成员 存在三个明显的缺点:
- 要求类的所有成员都是public的。
- 将正确初始化 每个对象的每个成员的重任 交给了类的用户(而非类的作者)。因为用户很容易忘掉某个初始值,或者提供一个不恰当的初始值,所以这样的初始化过程 冗长乏味且容易出错。
- 添加或删除一个成员之后,所有的初始化语句都需要更新。
字面值常量类:
数据成员都是 字面值类型 的聚合类是字面值常量类。(除了算术类型、引用和指针外,某些类也是字面值类型。) 如果一个类不是聚合类,但满足下面所有条件,则它也是一个字面值常量类:
- 数据成员都必须是字面值类型。
- 类必须至少含有一个constexpr构造函数。(constexpr函数的参数和返回值必须是字面值。)
- 如果一个数据成员含有 类内部初始值,则内置类型成员的初始值 必须是一条常量表达式;或者如果成员属于某种 类类型,则初始值必须使用 成员自己的constexpr构造函数。
- 类必须使用 析构函数的默认定义,该成员负责销毁类的对象。
constexpr构造函数:
- 通过前置constexpr关键字,就可以声明constexpr构造函数。
- 除了声明为=default或者=delete以外,constexpr构造函数的函数体一般为空。(综合 构造函数没有返回值 且 constexpr函数唯一可执行语句为返回语句,可知constexpr构造函数体一般为空)
- constexpr构造函数必须 初始化所有数据成员,初始值或者使用constexpr构造函数,或者是一条常量表达式。
- constexpr构造函数用于生成 constexpr对象、constexpr函数的参数或返回类型。
struct Point { constexpr Point(int i, int j): x(i), y(j) {} constexpr Point(): Point(0, 0) {} int x; int y; }; int main() { constexpr Point pt1 = { 1,1 }; constexpr Point pt2(2, 2); } // 这样声明以后,就可以在使用 constexpr表达式或者constexpr函数的地方 使用字面值常量类了。
7.6 类的静态成员
非static数据成员 存在于类类型的每个对象中;static数据成员 独立于该类的任意对象而存在。(每个static数据成员是 与类关联的对象,并不与该类的对象相关联)
静态成员变量 的特性:
静态类型类内初始化:
- 属于整个类所有,所有对象共享 类的静态成员变量。
- 生命期不依赖于任何对象,为程序的生命周期。
- 可以通过类名直接访问 公有静态成员变量。
- 可以通过对象名、对象指针、对象引用访问 公有静态成员变量。
- 需要在 类外单独分配空间。
- 在程序内部 位于全局数据区。
静态成员函数 的特性:
- 定义静态成员函数,直接使用static关键字修饰即可。
- 属于整个类所有,没有this指针。
- 只能直接访问静态成员变量和静态成员函数。
- 可以通过类名直接访问 类的公有静态成员函数。
- 可以通过对象名、对象指针、对象引用访问 公有静态成员函数。
struct Demo { public: static void show() ; // 定义类的公有静态成员函数 static int a; // 定义类的公有静态成员变量 private: static int counts; // 定义类的私有静态成员变量(不能在类的内部初始化静态成员,必须在类的外部定义 和初始化每个静态成员) }; int Demo::a = 0; int Demo::counts = 0; void Demo::show() { // 当在类的外部定义静态成员时,不能重复static关键字,该关键字只出现在类内部的声明语句。 cout << counts << endl; } int main() { Demo demo; Demo* demo_p = &demo; Demo& demo_t = demo; Demo::a; // 通过 作用域运算符 访问静态成员) demo.a; // 通过 Demo对象 访问公有静态成员变量 demo_p->a; // 通过 Demo对象指针 访问公有静态成员变量 demo_t.a; // 通过 Demo对象引用 访问公有静态成员变量 Demo::show(); // 通过 作用域运算符 访问公有静态成员函数 demo.show(); // 通过 Demo对象 访问公有静态成员函数 demo_p->show(); // 通过 Demo对象指针 访问公有静态成员函数 demo_t.show(); // 通过 Demo对象引用 访问公有静态成员函数 }
通常情况下,类的静态成员 不应该在类的内部初始化。然而,我们可以为静态成员提供 const整数类型的类内初始值,不过要求静态成员必须是 字面值常量类型的constexpr ,初始值必须是常量表达式。因为这些成员本身就是常量表达式,所以它们能用在所有适合于常量表达式的地方。
- static const + 整数类型 :可以在类内初始化
- static constexpr + 任何内置类型 :必须在类内初始化
struct Demo { private: static const int a1; // 正确 static const int a2 = 1; // 正确: const静态整数类型成员 可以类内初始化 static const double b1; // 正确 static const double b2 = 1.1; // 错误: static const double 不能类内初始化 static constexpr double c1; // 错误: constexpr静态数据成员必须初始化 static constexpr double c2= 3.14; // 正确 };
建议:即使一个常量静态数据成员 在类内部被初始化了,通常情况下也应该 在类的外部定义一下该成员。
静态成员能用于,而普通成员不能用于 的某些场景: - 静态数据成员的类型可以就是 它所属的类类型。而非静态数据成员则受到限制,只能声明成 它所属类的指针或引用
- 可以使用静态成员作为默认实参
struct Demo { public: void fun(Demo demo= a) {} // 正确: 静态成员可以作为默认实参,因为静态成员不属于对象 void fun(int i = m) {} // 错误: 非静态类型不能作为默认实参,因为它的值本身属于对象一部分 private: int m = 5; static Demo a; // 正确: 静态成员可以是不完全类型 Demo* b; // 正确: 指针成员可以是不完全类型 Demo c; // 错误:数据成员必须是完全类型(而这里Demo是一个不完全类型,因为定义体未完成) };