类、基本操作、模板、概念和泛型编程,这四部分内容集中展示了C++是如何在不涉及过多细节的前提下,支持抽象和资源管理的:
类:类的定义和使用,重点关注与具体类、抽象类和类层次结构有关的,基本属性、实现技术及语言特性
基本操作:C++中已定义的一些操作,如构造函数、析构函数、赋值操作等。组合使用这些规则,控制对象的生命周期,并支持简单、高效和完整的资源管理
模板:模板是一种对类型和算法中的类型进行参数化的机制。基于内置类型和用户自定义类型的计算常常表现为函数的形式,借助模板机制可将其泛化为函数模板或函数对象
概念和泛型编程:泛型编程的概念、技术和语言特性,重点关注概念的定义和使用。借助概念可以精确地刻画模板的接口并指导模板的设计。借助可变参数模板,提高接口的通用性和灵活性
以上特性,共同构成C++语言支持面向对象编程和泛型编程的语法基础。C++语言的其它内容更多集中在标准库设施及其使用方法上。
C++最核心的语言特性就是类。类(class)是一种由用户自定义的数据类型,用于在程序代码中表示某种实体。无论何时,只要程序设计者想为程序添加一个有用的想法、实体或数据集合,都应该设法把它表示为程序中的一个类。程序设计者的思想即体现在该类之中,而非仅仅存在于他的头脑、设计文档或代码注释中。对于一个程序而言,无论是从它的可读性,还是从它的正确性来衡量,构建于一组精挑细选的类之上的程序,比直接搭建在内置类型上的程序,其优势要显著得多。而且,往往库所提供的产品就是类——类库。
在C++语言中,除基本类型、操作符和流程控制语句之外的所有特性,其目标只有一个,就是帮助程序设计者定义出更好的类,并更加方便地使用它们。所谓“更好”,即更正确、更高效、更优雅、更易用、更易读、更易推断和更易维护。大多数编程技术都是依赖于某些特定类的设计与实现。程序员的需求和偏好千差万别,因此对类的支持也应该是宽泛和丰富的。以下是其中最重要的三种对类的基本支持:
很多有用的类都可以被归属到这三个类别中。其它类则可被视作这三个类别的简单变形或组合。
具体类的基本思想,就是让它们在行为和使用上,肖似甚或高于内置类型。例如,复数类型和无穷精度整数类型,与内置的int类型非常象,当然它们有自己的语义和操作集。类似的,vector和string,与内置的数组类型也非常象,但在可操作性上更胜一筹。
具体类的典型特征,就是它的成员变量是其定义的一部分。如vector,它的成员变量只不过是一个或几个指向某块用于保存数据元素的内存的指针,但这种成员变量存在于每个vector对象中。这样的对象:
可被置于堆、栈、静态存储区或其它对象中
可被直接引用,而非一定要通过指针或引用来引用
可在创建的同时,通过构造函数完整地初始化
可被拷贝或转移
具体类的成员变量可被限定为私有,就象vector那样。这意味着这部分内容确实存在,但不能在类的外部被直接访问,只能通过公有的成员函数被间接地访问。因此,一旦成员变量发生了任何明显的改动,使用者就必须重新编译整个程序。这也是让具体类尽可能接近内置类型所必须付出的代价。对于那些不经常改动的类型,以及那些为局部变量提供了足够的清晰性和效率的类型而言,这个代价是可以接受的,而且通常也是非常理想的。为了提高灵活性,可将其实际内容保存在自由存储(动态内存、堆)中,同时通过存储在对象内部的成员变量,比如指针或引用,访问它们。vector和string就是这样设计的。它们可被视为带有精致接口的资源管理器。
一个经典的“用户自定义算术类型”是Complex:
xxxxxxxxxx
1341import std;
2
3using namespace std;
4
5class Complex {
6public:
7 // 接受实部和虚部的构造函数
8 Complex(double real, double imag)
9 : m_real(real)
10 , m_imag(imag) {
11 }
12
13 // 只接受实部的构造函数
14 Complex(double real)
15 : m_real(real)
16 , m_imag(0) {
17 }
18
19 // 默认构造函数,{0,0}
20 Complex()
21 : m_real(0)
22 , m_imag(0) {
23 }
24
25 // 拷贝构造函数
26 Complex(const Complex& complex)
27 : m_real(complex.m_real)
28 , m_imag(complex.m_imag) {
29 }
30
31 // 获取实部
32 double real() const {
33 return m_real;
34 }
35
36 // 设置实部
37 void real(double real) {
38 m_real = real;
39 }
40
41 // 获取虚部
42 double imag() const {
43 return m_imag;
44 }
45
46 // 设置虚部
47 void imag(double imag) {
48 m_imag = imag;
49 }
50
51 // 加法操作符函数
52 Complex operator+(const Complex& complex) const {
53 return { m_real + complex.m_real, m_imag + complex.m_imag };
54 }
55
56 // 减法操作符函数
57 Complex operator-(const Complex& complex) const {
58 return { m_real - complex.m_real, m_imag - complex.m_imag };
59 }
60
61 // 赋值操作符函数
62 Complex& operator=(const Complex& complex) {
63 m_real = complex.m_real;
64 m_imag = complex.m_imag;
65 return *this;
66 }
67
68 // 加法复合赋值操作符函数
69 Complex& operator+=(const Complex& complex) {
70 m_real += complex.m_real;
71 m_imag += complex.m_imag;
72 return *this;
73 }
74
75 // 减法复合赋值操作符函数
76 Complex& operator-=(const Complex& complex) {
77 m_real -= complex.m_real;
78 m_imag -= complex.m_imag;
79 return *this;
80 }
81
82 // 插入输出流操作符函数
83 friend ostream& operator<<(ostream& os, const Complex& complex) {
84 return os << '{' << complex.m_real << ',' << complex.m_imag << '}';
85 }
86
87private:
88 double m_real, m_imag; // 成员变量,两个双精度浮点数,分别表示复数的实部和虚部
89};
90
91// 一元负号操作符函数
92inline Complex operator-(const Complex& complex) {
93 return Complex() - complex;
94}
95
96// 相等操作符函数
97inline bool operator==(const Complex& a, const Complex& b) {
98 return a.real() == b.real() && a.imag() == b.imag();
99}
100
101// 不等操作符函数
102inline bool operator!=(const Complex& a, const Complex& b) {
103 return !(a == b);
104}
105
106int main() {
107 // 测试四个构造函数
108 Complex c1(1, 2), c2(3), c3, c4 = c1;
109 cout << c1 << c2 << c3 << c4 << endl;
110
111 // 测试获取和设置实部和虚部的成员函数
112 c3.real(5);
113 c3.imag(6);
114 cout << '{' << c3.real() << ',' << c3.imag() << '}' << endl;
115
116 // 测试加法和减法操作符函数
117 cout << c1 + c4 << c1 - c4 << endl;
118
119 // 测试赋值操作符函数
120 c2 = c1;
121 cout << c2 << endl;
122
123 // 测试加法和减法复合赋值操作符函数
124 c1 += c4;
125 c2 -= c4;
126 cout << c1 << c2 << endl;
127
128 // 测试一元负号操作符函数
129 cout << -c1 << endl;
130
131 // 测试相等和不等操作符函数
132 cout << boolalpha << (c1 == c4 + c4) << endl;
133 cout << (c1 != c4) << endl;
134}
这是对标准库complex类的一个简化的模拟版本。类定义本身只包含访问其成员变量的操作。它的成员变量非常简单,源于约定俗成的数学常识。出于编程实践的考虑,它必须兼容60年前Fortran语言提供的版本,同时还要支持一些常规的操作符。除了满足逻辑上的要求,Complex类必须足够高效,否则没有任何实用价值。这意味着,有必要将一些简单操作实现为内联函数,在最终生成的机器代码中,象构造函数、获取和设置成员变量的函数、大量使用的各种操作符函数等,都不再以函数调用的形式出现。直接在类内部定义的成员函数,默认为内联函数,也可以在函数声明的前面加上inline关键字,将其显式指定为内联。此外,标准库的complex类还将一部分成员函数声明为constexpr,以使某些算术运算可以在编译阶段完成。
默认情况下,拷贝构造函数和拷贝赋值操作符函数,会被隐式生成。这里给出的定义并非十分必要。
不提供实参就能被调用的构造函数称为默认构造函数,它决定了对象的默认状态,即未提供任何初始化信息的缺省状态。为一个类提供默认构造函数,可有效防止因成员变量未被初始化而导致的不确定性。
在用于获取实部和虚部的成员函数中,const修饰符表示它们不会修改调用对象的内容。const成员函数既可以被const对象、指针或引用调用,也可以被非const对象、指针或引用调用,而非const成员函数只能被非const对象、指针或引用调用。例如:
xxxxxxxxxx
41Complex c{ 1, 2 };
2c.real(c.real() + 2); // 非const调非const,非const调const
3const Complex& cr{ c };
4cout << cr.real() << endl; // const只能调const
有些成员函数并不需要访问调用对象的成员变量,在类的外部,以全局函数的形式定义它们更为合适。例如:
xxxxxxxxxx
141// 一元负号操作符函数
2inline Complex operator-(const Complex& complex) {
3 return Complex() - complex;
4}
5
6// 相等操作符函数
7inline bool operator==(const Complex& a, const Complex& b) {
8 return a.real() == b.real() && a.imag() == b.imag();
9}
10
11// 不等操作符函数
12inline bool operator!=(const Complex& a, const Complex& b) {
13 return !(a == b);
14}
很多函数的参数都使用了const引用,这样既可以避免参数传递过程中对象复制的开销,又可以防止在函数中对实参对象的意外修改。
C++编译器有能力将由Complex对象和操作符组成的表达式,转换为针对操作符函数的调用,如将“c1 + c4”转换为“c1.operator+(c4)”,或将“c1 != c4”转换为“operator!=(c1, c4)”。
在使用用户自定义的操作符,即操作符重载时,应该小心谨慎,并尊重其常规的使用习惯。比如无法将斜杠(/)定义为一元操作符,也无法将加号(+)定义为对两个int型操作数做减法。编译器的既定规则,不会因操作符重载而改变。
容器是一个包含若干元素的对象。Vector类的对象就是一个容器,因此称Vector为一个容器类型。Vector作为一个容器具有许多优点:易于理解、内置了必要的约束条件、提供了带边界检查的下标访问、通过size成员函数方便用户遍历其中的元素,等等。然而,它还有一个致命的缺陷——在构造函数中通过new操作符分配的内存,没有得到释放。这显然是一个糟糕的设计,因为C++并未提供任何垃圾收集机制,任何未被手动释放的内存,都不可能再被分配给新的对象,除非终止程序或重启系统。C++不提供垃圾回收器,一方面是因为它并不总是可用,另一方面是因为精确控制资源的分配与释放,能更好地满足逻辑和性能的要求。这里迫切需要一种机制,当对象已不可用时,及时释放其所持有的动态资源,这种机制就是析构函数。例如:
xxxxxxxxxx
421class Vector {
2public:
3 // 构造函数
4 Vector(int size) {
5 if (size < 0)
6 throw length_error("Vector constructor: negative size");
7
8 m_elem = new double[size]; // 分配内存
9 m_size = size;
10
11 // 初始化元素
12 for (int i = 0; i < m_size; ++i)
13 m_elem[i] = 0;
14 }
15
16 // 析构函数
17 ~Vector() {
18 delete[] m_elem; // 释放内存
19 }
20
21 // 通过下标访问特定的元素
22 double& operator[](int i) {
23 if (i < 0 || m_size <= i)
24 throw out_of_range("Vector::operator[]");
25
26 return m_elem[i];
27 }
28
29 // 通过下标访问特定的元素
30 const double& operator[](int i) const {
31 return const_cast<Vector&>(*this)[i];
32 }
33
34 // 获取元素的数量
35 int size() const {
36 return m_size;
37 }
38
39private:
40 double* m_elem; // 指向元素序列的指针
41 int m_size; // 元素的数量
42};
析构函数的命名规则是在一个波浪线(~)后面紧跟类名。从语义上讲,它是构造函数的对偶。
Vector类的构造函数,借助new操作符,从自由存储(亦称堆或动态内存)中分配了一些内存空间。析构函数则通过“delete[]”操作符释放了该内存空间,以达到清理资源的目的。单独的一个“delete”只能释放一个对象,而“delete[]”则可以释放一个数组中的所有对象。
有关内存资源的分配与释放,并不需要Vector类的使用者插手,他们只需象使用内置类型的变量一样,使用Vector对象即可。例如:
xxxxxxxxxx
151Vector v0(10); // 程序终止时调用v0的析构函数
2
3void foo(int size) {
4 Vector v1(size);
5 cout << "... 使用v1 ..." << endl;
6
7 {
8 Vector v2(size + size);
9 cout << "... 使用v2 ..." << endl;
10 } // 语句块结束时调用v2的析构函数
11
12 Vector* v3 = new Vector(size + size + size);
13 cout << "... 使用v3 ..." << endl;
14 delete v3; // delete操作符执行时调用v3的构造函数
15} // 函数返回时调用v1的析构函数
Vector类的对象,与int或double等内置类型的变量一样,遵循相同的命名规则、作用域、生命周期,及内存空间的分配与释放策略。构造——析构函数机制,是很多优雅技术的基础,尤其是大多数C++通用资源管理技术的基础。Vector对象的内存模型如下图所示:
Vector类的构造函数负责分配内存并初始化元素,同时为该类的成员变量设定初值,析构函数则负责释放内存。这是一个典型的数据句柄模型,常用来管理在对象的生命周期内,大小会发生变化的数据。在构造函数中获取资源,然后在析构函数中释放这些资源,这种技术称为资源获取即初始化,即RAII。对象生,资源在,对象亡,资源无。借助RAII技术,可以避免所谓的“裸new操作”和“裸delete操作”。在对象以外的代码中,几乎看不到任何new和delete操作,它们完全隐藏在良好抽象的实现内部。裸new和裸delete的消失,可以使程序代码远离各种潜在的风险,尤其是资源泄漏的风险。
容器的作用是保存元素,因此需要找到一种便捷的方式将元素存入容器。目前的做法是先创建Vector容器,其中的元素都被初始化为0,然后再通过下标操作符依次为这些元素赋值。例如:
xxxxxxxxxx
31Vector vec(5);
2for (int i = 0; i < vec.size(); ++i)
3 vec[i] = i + 1;
这种做法显然不够优雅。更好的做法是:
使用列表初始化语法,一次性初始化容器中的所有元素
调用push_back公有方法,在序列末尾添加一个新元素
xxxxxxxxxx
661class Vector {
2public:
3 // 默认构造函数
4 Vector() : m_elem{ nullptr }, m_size{ 0 } {}
5
6 // 接受元素数量的构造函数
7 Vector(int size) {
8 if (size < 0)
9 throw length_error("Vector constructor: negative size");
10
11 m_elem = new double[size]; // 分配内存
12 m_size = size;
13
14 // 初始化元素
15 for (int i = 0; i < m_size; ++i)
16 m_elem[i] = 0;
17 }
18
19 // 接受初始值列表的构造函数
20 Vector(const initializer_list<double>& lst)
21 : m_elem{ new double[lst.size()] }
22 , m_size{ static_cast<int>(lst.size()) } {
23 copy(lst.begin(), lst.end(), m_elem);
24 }
25
26 // 析构函数
27 ~Vector() {
28 delete[] m_elem; // 释放内存
29 }
30
31 // 通过下标访问特定的元素
32 double& operator[](int i) {
33 if (i < 0 || m_size <= i)
34 throw out_of_range("Vector::operator[]");
35
36 return m_elem[i];
37 }
38
39 // 通过下标访问特定的元素
40 const double& operator[](int i) const {
41 return const_cast<Vector&>(*this)[i];
42 }
43
44 // 获取元素的数量
45 int size() const {
46 return m_size;
47 }
48
49 // 在序列末尾添加一个新元素
50 void push_back(double d) {
51 int size = m_size + 1;
52 double* elem = new double[size];
53
54 copy(m_elem, m_elem + m_size, elem);
55 elem[size-1] = d;
56
57 delete[] m_elem;
58
59 m_elem = elem;
60 m_size = size;
61 }
62
63private:
64 double* m_elem; // 指向元素序列的指针
65 int m_size; // 元素的数量
66};
其中,push_back用于在序列末尾添加一个新元素。例如:
xxxxxxxxxx
141// 从输入流读取数据,返回它们的和
2
3double readAndSum(istream& is) {
4 Vector vec;
5
6 for (double d; is >> d;) // 将用户输入的浮点数读入d中
7 vec.push_back(d); // 将d加到vec中
8
9 double sum = 0;
10 for (int i = 0; i < vec.size(); ++i)
11 sum += vec[i];
12
13 return sum;
14}
第一个for循环负责执行输入操作。循环的终止条件是到达文件末尾,或遇到格式错误,如输入一个无法被解释为浮点数的字符串。在此之前,每读入一个浮点数,即添加到vec的末尾。最后,vec中的元素就是用户输入的数据。这里使用for循环而非while循环的好处是,可以将d的作用域限制在循环内部。
这里的push_back函数显然不够优化,标准库vector的push_back函数,可以更高效地扩展保存元素序列的内存空间。
接受初始值列表的构造函数,以标准库类型initializer_list作为其唯一的参数。编译器能够识别它,当看到被花括号括起的初始值列表时,会自动创建一个initializer_list类型的对象,传递给该构造函数。例如:
xxxxxxxxxx
11Vector vec{ 1, 2, 3, 4, 5 }; // 用初始值列表初始化容器
在接受初始值列表的构造函数中,为了将初始值列表的大小转换成int类型,使用了static_cast。这种写法比较呆板,因为一个手写的初始值列表,其中的元素个数怎么也不可能超过一个int类型值所能表示的范围(一个32位int型整数的最大值是2'147'483'647)。但要记住,编译器的类型系统是没有判断能力的,它只知道一个变量的可能取值,而非实际取值,因此它常常会无中生有地报告一些错误或警告,然而对于程序员来说,这些警示信息迟早会发挥作用,它能防止程序陷入特别严重的错误。
static_cast本身并不负责检查所要转换的值,它假设程序员完全清楚自己的所作所为。遗憾的是,这个假设并不总能成立,因此程序员如果不确定一个值能否被正确地转换为所要求的类型,最好先确认这一点,指望编译器完成这种检查是不现实的。显式类型转换,亦称强制类型转换,本来就是为了解决某种可能存在的问题而设计的,能不用尽量不用。为了降低错误发生的概率,程序员应当尽量将未经检查的类型转换,限制在软件系统的底层。
其它显式类型转换还包括:
reinterpret_cast和bit_cast:将任意类型的对象当作字节流看待
const_cast:消除指针或引用上的const限定
善用类型系统和设计良好的标准库,在软件系统的上层,消除未经检查的类型转换。
C++11:通过花括号列表,执行统一且通用的初始化
C++11:从花括号列表到std::initializer_list的语言映射
C++11:容器的initializer_list构造函数
Complex类和Vector类之所以被称为具体类,是因为它们的实现属于定义的一部分。在这一点上,它们与内置类型非常相似。相反,抽象类把类的使用者和类的实现完全隔离开来,以接口的形式解除二者间的耦合。抽象类本身没有对应的实现,也无法被实例化为对象,只能以指针或者引用的形式出现在代码中。
Container类就是一个抽象类,其中包含一系列针对容器的操作接口。例如:
xxxxxxxxxx
81class Container {
2public:
3 virtual ~Container() {}; // 析构函数
4 virtual double& operator[](int i) = 0; // 通过下标访问特定的元素
5 virtual const double& operator[](int i) const = 0; // 通过下标访问特定的元素
6 virtual int size() const = 0; // 获取元素的数量
7 virtual void push_back(double d) = 0; // 在序列末尾添加一个新元素
8};
对包括Vector在内的每一种具体容器而言,Container类纯粹是一个接口。关键字virtual的意思是“可能在该类的派生类中被重新定义”。这种被关键字virtual声明的成员函数称为虚函数。Container类的派生类负责为其中的接口(虚函数)提供具体实现(重新定义)。“= 0”语法意在说明该虚函数是一个纯虚函数,即派生类必须给出关于它的重新定义。因此,无法单纯定义一个Container类的对象。例如:
xxxxxxxxxx
11Container con; // 错误,不能定义抽象类的对象
Container类相当于一系列接口的集合,它的派生类负责其中接口的具体实现。至少包含一个纯虚函数的类称为抽象类。Container类的正确用法类似下面这个样子:
xxxxxxxxxx
121// 从输入流读取数据,返回它们的和
2
3double readAndSum(istream& is, Container& con) {
4 for (double d; is >> d;) // 将用户输入的浮点数读入d中
5 con.push_back(d); // 将d加到con中
6
7 double sum = 0;
8 for (int i = 0; i < con.size(); ++i)
9 sum += con[i];
10
11 return sum;
12}
请注意,readAndSum函数是如何在完全不知道实现细节的前提下使用Container接口的。它使用了push_back、size和下标操作符,却根本不知道调用的是哪个类的成员函数。一个可以为其它类提供接口的类,比如Container类,被称为多态类型。
作为一个抽象类,Container类中没有构造函数,毕竟它不需要初始化数据,但它却拥有一个析构函数,该函数是一个空函数,而且也被声明为virtual。这是为了保证当通过一个Container类型的指针销毁动态创建的派生类对象时,派生类的析构函数能够被执行,以正确释放其所持有的资源。
Vector类是Container类的一个实现类,其中包含对接口的具体实现。例如:
xxxxxxxxxx
661class Vector : public Container {
2public:
3 // 默认构造函数
4 Vector() : m_elem{ nullptr }, m_size{ 0 } {}
5
6 // 接受元素数量的构造函数
7 Vector(int size) {
8 if (size < 0)
9 throw length_error("Vector constructor: negative size");
10
11 m_elem = new double[size]; // 分配内存
12 m_size = size;
13
14 // 初始化元素
15 for (int i = 0; i < m_size; ++i)
16 m_elem[i] = 0;
17 }
18
19 // 接受初始值列表的构造函数
20 Vector(const initializer_list<double>& lst)
21 : m_elem{ new double[lst.size()] }
22 , m_size{ static_cast<int>(lst.size()) } {
23 copy(lst.begin(), lst.end(), m_elem);
24 }
25
26 // 析构函数
27 ~Vector() override {
28 delete[] m_elem; // 释放内存
29 }
30
31 // 通过下标访问特定的元素
32 double& operator[](int i) override {
33 if (i < 0 || m_size <= i)
34 throw out_of_range("Vector::operator[]");
35
36 return m_elem[i];
37 }
38
39 // 通过下标访问特定的元素
40 const double& operator[](int i) const override {
41 return const_cast<Vector&>(*this)[i];
42 }
43
44 // 获取元素的数量
45 int size() const override {
46 return m_size;
47 }
48
49 // 在序列末尾添加一个新元素
50 void push_back(double d) override {
51 int size = m_size + 1;
52 double* elem = new double[size];
53
54 copy(m_elem, m_elem + m_size, elem);
55 elem[size-1] = d;
56
57 delete[] m_elem;
58
59 m_elem = elem;
60 m_size = size;
61 }
62
63private:
64 double* m_elem; // 指向元素序列的指针
65 int m_size; // 元素的数量
66};
这里的“: public”读作“公有派生自”或“是······的公有子类型”。称Vector类Container类的派生类,Container类是Vector类的基类,或称Vector类是Container类的子类,Container类是Vector类的超类。派生类继承其基类的成员,因此称派生类继承自基类,派生类和基类之间的关联关系,称为继承。
派生类Vector中的析构函数和三个成员函数——operator[]、size和push_back,覆盖了其基类Container中的对应成员。此处显式声明为override,以明确表达程序员的意图。这里的override关键字不是必须的,但显式注明有助于编译器捕捉不易察觉的错误,比如虚函数的派生类版本与基类版本原型不一致,以至无法形成有效覆盖。override关键字在复杂类层次结构中特别有用,否则很难一眼看出到底是谁覆盖了谁。
派生类Vector的析构函数~Vector,覆盖了基类Container的析构函数~Container。注意,基类Container的析构函数~Container会被派生类Vector的析构函数~Vector隐式调用。
象“readAndSum(... Container& con ...)”这样的函数,可以在完全不了解任何Container类的派生类的情况下,仅凭Container类中的接口(纯虚函数)访问一个(抽象化的)容器所提供的各种功能,如通过下标访问特定元素(operator[])、获取元素数量(size)、在序列末尾添加新元素(push_back),等等。当然实际传递给该函数的参数,必须是实现(覆盖)了Container类接口(纯虚函数)的(具体化的)容器对象,比如Vector:
xxxxxxxxxx
21Vector vec;
2cout << readAndSum(cin, vec) << endl;
readAndSum函数只知道Container类而不知道Vector类,因此对于Container接口(抽象基类)的其它实现(具体派生类),该函数同样适用。例如:
xxxxxxxxxx
201class List : public Container {
2 ...
3
4 // 析构函数
5 ~List() override { ... }
6
7 // 通过下标访问特定的元素
8 double& operator[](int i) override { ... }
9
10 // 通过下标访问特定的元素
11 const double& operator[](int i) const override { ... }
12
13 // 获取元素的数量
14 int size() const override { ... }
15
16 // 在序列末尾添加一个新元素
17 void push_back(double d) override { ... }
18
19 ...
20};
xxxxxxxxxx
21List lst;
2cout << readAndSum(cin, lst) << endl;
这里的关键在于,readAndSum函数并不清楚它的Container&类型的参数con,究竟引用的是一个Vector对象还是一个List对象。这已经不重要了,它只需知道该参数引用的是一个Container类型的容器,Container类的接口它都可以访问就足够了。因此,无论是修改了Vector或List类的实现,还是增加了新的表示具体容器的派生类,比如Deque,readAndSum函数都无需重新编译。
这种灵活性唯一的不足,就是只能通过指针或者引用,操作多态类型的对象。
C++11:通过override关键字显式指明覆盖
现在的问题是,readAndSum函数的参数con,只是一个Container类的引用,它凭什么知道,是应该调用Vector类的,还是调用List类的成员函数,operator[]、size或者push_back呢?为了做到这一点,该引用的目标对象中必须包含一些有助于它在运行时选择正确函数的信息。常见的做法是,编译器会将虚函数的名字转换为函数指针数组中的索引值,这个函数指针数组就是所谓的虚函数表(vtbl)。每个包含虚函数的类都有自己的虚函数表,该类的每个对象中都有一个指向虚函数表的指针。调用虚函数时,会根据索引在相应的虚函数表中查找所调虚函数的入口地址,完成函数调用。如下图所示:
编译器在编译readAndSum函数时,并不知道其“Container&”型参数con,究竟会在运行时引用什么对象,但它会生成一系列指令,插入到虚函数被调用的位置。该指令在运行时执行,依次完成如下动作:
从所引用对象中获取虚函数表指针
用所调用虚函数的索引,在虚函数表中查找,指向该虚函数的指针
根据虚函数指针调用虚函数,传入参数并获得返回值(如果有的话)
由此可见,编译器在编译readAndSum函数时,即使对Vector类或List类一无所知,也能生成正确的虚函数调用代码。它只需要知道Container类中每个虚函数的索引值,以及每个对象中虚函数表指针的存放位置(通常在对象的起始位置)即可。
虚函数调用的时空效率与普通函数调用非常接近:
时间效率:虚函数调用比普通函数调用慢不超过25%,后续重复调用比首次调用快
空间效率:虚函数调用的空间开销包括两部分,一个是对象中的虚函数表指针,另一个是类的虚函数表
所谓类层次结构,是指通过继承产生的一组在框架中有序排列的类。借助类层次结构,可以很方便地表达,现实世界中那些明显带有层次关系的概念。例如:
又如:
实际应用中,动辄包含上百个类的复杂层次结构,并不鲜见。上图中Shape类的代码如下:
xxxxxxxxxx
71class Shape {
2public:
3 virtual ~Shape() {} // 析构函数
4 virtual Point center() const = 0; // 获取中心
5 virtual void move(Point offset) = 0; // 移动
6 virtual void draw() const = 0; // 绘制
7};
这个接口显然是一个抽象类,对于每种形状而言,它们的具体实现各不相同,但都要提供诸如获取中心、移动、绘制等功能,因此都是Shape类的直接或间接子类。基于上述定义,能操作一组形状的通用函数可定义如下:
xxxxxxxxxx
131// 移动一组形状
2
3void moveShapes(vector<Shape*> shapes, Point offset) {
4 for (auto shape : shapes)
5 shape->move(offset);
6}
7
8// 绘制一组形状
9
10void drawShapes(vector<Shape*> shapes) {
11 for (auto shape : shapes)
12 shape->draw();
13}
要定义一种具体形状,首先必须指明它是一个Shape,即继承自Shape类,然后再定义其特有的属性和行为,包括给出各个纯虚函数的具体实现。例如:
xxxxxxxxxx
431class Circle : public Shape {
2public:
3 Circle(Point center, int radius) : m_center(center), m_radius(radius) {
4 }
5
6 Point center() const override {
7 return m_center;
8 }
9
10 void move(Point offset) override {
11 m_center += offset;
12 }
13
14 void draw() const override {
15 cout << "Circle{" << m_center << ',' << m_radius << '}';
16 };
17
18private:
19 Point m_center; // 圆心
20 int m_radius; // 半径
21};
22
23class Rectangle : public Shape {
24public:
25 Rectangle(Point lt, Point rb) : m_lt(lt), m_rb(rb) {
26 }
27
28 Point center() const override {
29 return { (m_lt.x() + m_rb.x()) / 2, (m_lt.y() + m_rb.y()) / 2 };
30 }
31
32 void move(Point offset) override {
33 m_lt += offset;
34 m_rb += offset;
35 }
36
37 void draw() const override {
38 cout << "Rectangle{" << m_lt << ',' << m_rb << '}';
39 };
40
41private:
42 Point m_lt, m_rb; // 左上角和右下角
43};
甚至可以继续派生出更多的类。例如:
xxxxxxxxxx
561class Smiley : public Circle {
2public:
3 Smiley(Point center, int radius) : Circle(center, radius), m_mouth(nullptr) {
4 }
5
6 ~Smiley() {
7 for (auto eye : m_eyes)
8 delete eye;
9
10 delete m_mouth;
11 }
12
13 void move(Point offset) override {
14 Circle::move(offset);
15
16 for (auto eye : m_eyes)
17 if (eye)
18 eye->move(offset);
19
20 if (m_mouth)
21 m_mouth->move(offset);
22 }
23
24 void draw() const override {
25 cout << "Smiley{";
26
27 Circle::draw();
28
29 for (auto eye : m_eyes)
30 if (eye) {
31 cout << ',';
32 eye->draw();
33 }
34
35 if (m_mouth) {
36 cout << ',';
37 m_mouth->draw();
38 }
39 }
40
41 void addEye(Shape* eye) {
42 m_eyes.push_back(eye);
43 }
44
45 void setMouth(Shape* mouth) {
46 m_mouth = mouth;
47 }
48
49 void wink(int n) const {
50 cout << "Wink " << n << " times" << endl;
51 }
52
53private:
54 vector<Shape*> m_eyes;
55 Shape* m_mouth;
56};
std::vector类模板的push_back成员函数负责将其参数(的副本)添加到容器(m_eyes)中。通过Smiley类的基类,即Circle类的draw成员函数,和Smiley类成员变量的draw成员函数,实现Smiley类的draw成员函数:
xxxxxxxxxx
161void draw() const override {
2 cout << "Smiley{";
3
4 Circle::draw();
5
6 for (auto eye : m_eyes)
7 if (eye) {
8 cout << ',';
9 eye->draw();
10 }
11
12 if (m_mouth) {
13 cout << ',';
14 m_mouth->draw();
15 }
16}
请注意,Shape类的析构函数是一个虚函数,Smiley类的析构函数覆盖了它。对于抽象类而言,因其派生类的对象通常都是通过基类的接口操作的,所以基类中必须有一个虚析构函数。当借助一个基类指针销毁其指向的派生类对象时,虚函数调用机制能够确保实际被调用的是派生类的析构函数。该析构函数在完成对派生类对象所持有的动态资源的释放后,会隐式调用其基类和成员类型的析构函数,保证基类子对象和成员子对象中的动态资源也能够被释放干净。
在这个简单的例子中,程序员负责在表示笑脸的对象中恰当地放置眼睛和嘴:
xxxxxxxxxx
71void addEye(Shape* eye) {
2 m_eyes.push_back(eye);
3}
4
5void setMouth(Shape* mouth) {
6 m_mouth = mouth;
7}
并在笑脸对象被销毁时,一并销毁它的眼睛和嘴:
xxxxxxxxxx
61~Smiley() {
2 for (auto eye : m_eyes)
3 delete eye;
4
5 delete m_mouth;
6}
以派生的方式定义新类,可为其添加成员变量和成员函数。这种机制固然为类的设计提供了巨大的灵活性,但同时也可能带来混淆,从而导致糟糕的设计。
类层次结构的益处主要体现在以下两个方面:
接口继承:派生类的对象可以被用在任何需要基类的地方。基类看起来就象是派生类的代言人,活都是派生类干的,可抛头露面的总是基类。Container和Shape就是很好的例子。这种层次结构中的基类通常都是抽象类
实现继承:基类提供可以简化派生类实现的数据和函数。就象Smiley类复用Circle类的构造函数和draw函数。这种层次结构中的基类通常都包含自己的成员变量和成员函数,并能独立完成一些相比派生类更简单的任务
具体类,尤其是表现形式不复杂的类,具有非常类似于内置类型的行为。通常将其定义为局部变量,可通过变量名直接访问或随意拷贝。然而,处于层次结构中的类却与此有所不同,更倾向于通过new操作符,在自由存储中为其创建对象,而后通过基类类型的指针或引用访问这些对象。例如下面的函数用于从标准输入读取一个Shape对象:
xxxxxxxxxx
371enum class Kind { Circle, Rectangle, Smiley };
2
3// 从标准输入读取一个形状
4
5Shape* readShape() {
6 cout << "Kind of shape (0-Circle/1-Rectangle/2-Smiley) or other for cancel: ";
7 int kind;
8 cin >> kind;
9
10 switch ((Kind)kind) {
11 case Kind::Circle:
12 cout << "Center and Radius: ";
13 int x, y, radius;
14 cin >> x >> y >> radius;
15 return new Circle{ {x, y}, radius };
16
17 case Kind::Rectangle:
18 cout << "Left-Top and Right-Bottom: ";
19 int l, t, r, b;
20 cin >> l >> t >> r >> b;
21 return new Rectangle{ { l, t }, { r, b } };
22
23 case Kind::Smiley:
24 cout << "Center and Radius: ";
25 cin >> x >> y >> radius;
26 Smiley* smiley = new Smiley{ {x, y}, radius };
27 cout << "Left-Eye..." << endl;
28 smiley->addEye(readShape());
29 cout << "Right-Eye..." << endl;
30 smiley->addEye(readShape());
31 cout << "Mouth..." << endl;
32 smiley->setMouth(readShape());
33 return smiley;
34 }
35
36 return nullptr;
37}
使用这些类和函数的代码如下所示:
xxxxxxxxxx
131int main() {
2 vector<Shape*> shapes;
3
4 while (auto shape = readShape())
5 shapes.push_back(shape);
6
7 drawShapes(shapes);
8 moveShapes(shapes, { 100, 100 });
9 drawShapes(shapes);
10
11 for (auto shape : shapes)
12 delete shape;
13}
这段代码从始至终都不知道它所操作的究竟是什么类型的对象。对Shape类的派生类的任何修改,或者增加更多的派生类,比如Triangle等,都不会对这段代码构成任何影响。在这段代码的最后,通过delete操作符,销毁了形状容器中的所有对象。因为Shape类的析构函数是一个虚函数,因此delete会根据shape指针的目标对象,调用相应派生类的析构函数。这一点非常关键,因为派生类可能有很多种需要释放的动态资源,比如内存、文件句柄、锁、I/O流等。派生类的析构函数在完成对这些动态资源的释放后,会隐式调用其基类的析构函数,继续释放基类子对象中的动态资源。在类层次结构中,构造的顺序是自上而下,即先执行基类构造函数中的代码,再执行派生类构造函数中的代码,而析构的顺序是自下而上,即先执行派生类析构函数中的代码,再执行基类析构函数中的代码。
如果需要将一个指向派生类对象的基类指针或引用,还原为派生类类型的指针或引用,以便访问派生类的特有成员,可以使用dynamic_cast操作符。该操作符会检查基类指针或引用的目标对象,是否确为需要转换的目标类型或其派生类。如果是,则转换成功,不是,则返回空指针(转指针)或抛出bad_cast异常(转引用)。
例如:
xxxxxxxxxx
431auto shape = readShape();
2
3if (Circle* circle = dynamic_cast<Circle*>(shape))
4 cout << "This is a circle" << endl;
5else
6 cout << "This is not a circle" << endl;
7
8if (Rectangle* rectangle = dynamic_cast<Rectangle*>(shape))
9 cout << "This is a rectangle" << endl;
10else
11 cout << "This is not a rectangle" << endl;
12
13if (Smiley* smiley = dynamic_cast<Smiley*>(shape)) {
14 cout << "This is a smiley" << endl;
15 smiley->wink(2);
16}
17else
18 cout << "This is not a smiley" << endl;
19
20try {
21 Circle& circle = dynamic_cast<Circle&>(*shape);
22 cout << "This is a circle" << endl;
23}
24catch (const bad_cast& ex) {
25 cout << "This is not a circle" << endl;
26}
27
28try {
29 Rectangle& rectangle = dynamic_cast<Rectangle&>(*shape);
30 cout << "This is a rectangle" << endl;
31}
32catch (const bad_cast& ex) {
33 cout << "This is not a rectangle" << endl;
34}
35
36try {
37 Smiley& smiley = dynamic_cast<Smiley&>(*shape);
38 cout << "This is a smiley" << endl;
39 smiley.wink(2);
40}
41catch (const bad_cast& ex) {
42 cout << "This is not a smiley" << endl;
43}
适度使用dynamic_cast能让代码显得更简洁。如果能避免使用类型信息,就能写出更加简洁有效的代码。不过在某些情况下,缺失的类型信息必须被恢复出来,尤其是当对象被传递给某些系统,而这些系统只接受基类定义的接口时。当该系统稍后传回对象以供使用时,可能不得不恢复它们的原始类型。dynamic_cast操作符的语义更象是在说“是······的一种”或者“是······的一个实例”。
分配了资源却没有释放,谓之泄漏。泄漏将导致系统无法访问相关资源,因此必须尽量避免。否则,泄漏最终将耗尽系统资源,导致系统卡顿甚至崩溃。
上面的程序中存在三处安全隐患:
Simley类的实现者,可能会忘记通过delete操作符,销毁存放在m_eyes和m_mouth成员中的眼睛和嘴
readShape函数的调用者,可能会忘记通过delete操作符,销毁该函数返回的Shape对象
shapes容器的持有者,可能会忘记通过delete操作符,销毁其中的Shape对象
从某种意义上讲,从函数中返回指向自由存储中的对象的指针是非常危险的。任何通过旧式的裸指针表达所有权的做法都应当尽量避免。例如:
xxxxxxxxxx
131bool user(int x, int y, int r) {
2 Shape* shape = new Circle{ {x, y}, r };
3 ...
4 if (x - r < 0 || y - r < 0)
5 throw BadCircle{}; // 潜在的泄漏
6 ...
7 if (x + r > screenWidth || y + y > screenHeight)
8 return false; // 潜在的泄漏
9 ...
10 delete shape; // 未必总会被执行
11 ...
12 return true;
13}
如果圆形无法完整呈现在屏幕的有效区域内,则或抛出异常,或返回失败。这时“delete shape”语句将不会被执行,从而引发内存泄漏。将new的返回值交给一个裸指针,在任何时候都是自找麻烦。
上述问题的简单解决方案,是使用标准库的unique_ptr替代裸指针。例如:
xxxxxxxxxx
491class Smiley : public Circle {
2public:
3 Smiley(Point center, int radius) : Circle(center, radius), m_mouth(nullptr) {
4 }
5
6 void move(Point offset) override {
7 Circle::move(offset);
8
9 for (auto& eye : m_eyes)
10 if (eye)
11 eye->move(offset);
12
13 if (m_mouth)
14 m_mouth->move(offset);
15 }
16
17 void draw() const override {
18 cout << "Smiley{";
19
20 Circle::draw();
21
22 for (auto& eye : m_eyes)
23 if (eye) {
24 cout << ',';
25 eye->draw();
26 }
27
28 if (m_mouth) {
29 cout << ',';
30 m_mouth->draw();
31 }
32 }
33
34 void addEye(unique_ptr<Shape> eye) {
35 m_eyes.push_back(::move(eye));
36 }
37
38 void setMouth(unique_ptr<Shape> mouth) {
39 m_mouth = ::move(mouth);
40 }
41
42 void wink(int n) const {
43 cout << "Wink " << n << " times" << endl;
44 }
45
46private:
47 vector<unique_ptr<Shape>> m_eyes;
48 unique_ptr<Shape> m_mouth;
49};
这个改变额外带来了一个令人愉快的副作用——不再需要为Smiley类提供析构函数了。编译器会隐式地生成并执行,在vector中的unique_ptr所需要的析构操作。使用unique_ptr的代码,与正确使用原始指针的代码同样高效。
考虑Shape对象的用户代码:
xxxxxxxxxx
481enum class Kind { Circle, Rectangle, Smiley };
2
3// 从标准输入读取一个形状
4
5unique_ptr<Shape> readShape() {
6 cout << "Kind of shape (0-Circle/1-Rectangle/2-Smiley) or other for cancel: ";
7 int kind;
8 cin >> kind;
9
10 switch ((Kind)kind) {
11 case Kind::Circle:
12 cout << "Center and Radius: ";
13 int x, y, radius;
14 cin >> x >> y >> radius;
15 return unique_ptr<Shape>{ new Circle{ {x, y}, radius } };
16
17 case Kind::Rectangle:
18 cout << "Left-Top and Right-Bottom: ";
19 int l, t, r, b;
20 cin >> l >> t >> r >> b;
21 return unique_ptr<Shape>{ new Rectangle{ { l, t }, { r, b } } };
22
23 case Kind::Smiley:
24 cout << "Center and Radius: ";
25 cin >> x >> y >> radius;
26 Smiley* smiley = new Smiley{ {x, y}, radius };
27 cout << "Left-Eye..." << endl;
28 smiley->addEye(readShape());
29 cout << "Right-Eye..." << endl;
30 smiley->addEye(readShape());
31 cout << "Mouth..." << endl;
32 smiley->setMouth(readShape());
33 return unique_ptr<Shape>{ smiley };
34 }
35
36 return nullptr;
37}
38
39int main() {
40 vector<unique_ptr<Shape>> shapes;
41
42 while (auto shape = readShape())
43 shapes.push_back(move(shape));
44
45 drawShapes(shapes);
46 moveShapes(shapes, { 100, 100 });
47 drawShapes(shapes);
48}
readShape函数返回的,是一个持有Shape对象地址的unique_ptr对象,而不再是Shape对象的地址本身,即裸指针,只要unique_ptr离开了它的作用域,其析构函数将负责销毁其所持有的Shape对象。注意,对unique_ptr做拷贝构造和拷贝赋值是被禁止的,但允许对它做转移构造和转移赋值。
当然,这里还需要能够接受vector<unique_ptr<Shape>>类型参数的moveShapes和drawShapes函数:
xxxxxxxxxx
151// 移动一组形状
2
3void moveShapes(vector<unique_ptr<Shape>>& shapes, Point offset) {
4 for (auto& shape : shapes)
5 shape->move(offset);
6}
7
8// 绘制一组形状
9
10void drawShapes(vector<unique_ptr<Shape>>& shapes) {
11 for (auto& shape : shapes) {
12 shape->draw();
13 cout << endl;
14 }
15}
如果有很多函数都需要接受vector<unique_ptr<Shape>>类型的参数,使用函数对象或可使代码变得更加简洁。
程序员应该直接用代码表达思想
具体类是最简单的类, 只要条件允许,与复杂类或普通数据结构相比,优先选用具体类
使用具体类表达简单的概念
在对性能要求较高的场合,优选选择具体类而不是类层次结构
定义一个构造函数,负责对象的初始化操作
只有当一个函数确实需要访问类的成员变量时,才将其作为成员函数
操作符重载的目的,就是模仿它们的常规用法
将对称的操作符(满足交换律的二元操作符)定义为非成员函数
如果一个成员函数不会改变对象的状态,则将其声明为const
如果在一个类的构造函数中动态获取了资源,那么就应该在该类的析构函数中动态释放这些资源
避免裸new和裸delete操作
使用资源句柄和RAII管理资源
对于一个表示容器的类,它至少应该有一个接受初始值列表的构造函数
如果需要把接口和实现完全独立开,则可用抽象类作为接口
通过指针或引用操作多态对象
抽象类通常不需要构造函数
类的层次结构可用于表达,具有继承层次结构的一组概念
一个类只要包含了虚函数,就应该同时再包含一个虚析构函数
在规模较大的类层次结构中,使用override关键字显式指明覆盖基类中的虚函数
在设计类的层次结构时,注意区分接口继承和实现继承
当在类的层次结构中导航已不可避免时,记得使用dynamic_cast操作符
如果无法接受,针对特定目标的类型转换失败,则将dynamic_cast作用于引用
如果可以接受,针对特定目标的类型转换失败,则将dynamic_cast作用于指针
为了防止忘记,用delete销毁new创建的对象,最好使用unique_ptr或shared_ptr