语言规则对一部分基本操作,如初始化、赋值、拷贝和转移等,遵循其约定俗成的定义,对其它基本操作,如取地址、解引用、插入和提取等,则给出了常规的定义。任何时候都不应违背这些定义,忽视这一点将是十分危险的。
构造函数、析构函数、拷贝操作和转移操作,它们在逻辑上有着千丝万缕的联系。在定义这些函数时,必须考虑到它们之间的内在联系,否则就会遇到逻辑问题或者性能问题。如果在X类的析构函数中,执行了某种特定的操作,比如释放自由存储中的内存或者释放锁,那么在该类的其它相关函数中,也必然会顾及到这种操作所带来的影响。比如对象拷贝,会否导致副本对象和源对象重复执行对同一份资源的释放或重复解锁?这组高度相关的函数如下所示:
xxxxxxxxxx
111class X {
2public:
3 X(Sometype); // 普通构造函数
4 X(); // 默认构造函数
5 X(const X&); // 拷贝构造函数
6 X& operator=(const X&); // 拷贝赋值操作符函数
7 X(X&&); // 转移构造函数
8 X& operator=(X&&); // 转移赋值操作符函数
9 ~X(); // 析构函数
10 ...
11}
普通构造函数:根据参数设置对象的初始状态,动态分配资源
默认构造函数:按缺省方式设置对象的默认初态,动态分配资源
拷贝构造函数:将对象初始化为与参数对象内容一致的副本
拷贝赋值操作符函数:清空目标对象并拷贝源对象中的内容
转移构造函数:将参数对象的内容转移到正在构造的副本对象中
转移赋值操作符函数:清空目标对象并转移源对象中的内容
析构函数:释放动态分配的资源
遇到以下五种情况,对象将被拷贝或转移:
将对象赋值给另一个对象
将对象作为另一个对象的初值
将对象作为调用函数的参数
将对象作为函数的返回值
经对象作为异常抛出
除了赋值语句会调用拷贝/转移赋值操作符函数外,其它情况均调用拷贝/转移构造函数。然而,拷贝/转移构造函数,常常被编译器优化为对目标对象的直接初始化。例如:
xxxxxxxxxx
11X make(Sometype);
xxxxxxxxxx
11X x = make(argument);
这里,编译器往往会直接在x对象上构造make函数的返回值,从而避免了一次对象的拷贝或转移。
除了用于初始化有名对象和在自由存储中动态创建的对象以外,构造函数还可用于初始化临时对象,和实现显式类型转换。
除普通构造函数外的其它六个函数,编译器都会根据需要自动生成。如果这些函数的默认实现已足够好用,则无需再显式定义它们。例如:
xxxxxxxxxx
111class Y {
2public:
3 Y(Sometype); // 普通构造函数
4 Y() = default; // 默认构造函数,由编译器自动生成
5 Y(const Y&) = default; // 拷贝构造函数,由编译器自动生成
6 Y& operator=(const Y&) = default; // 拷贝赋值操作符函数,由编译器自动生成
7 Y(Y&&) = default; // 转移构造函数,由编译器自动生成
8 Y& operator=(Y&&) = default; // 转移赋值操作符函数,由编译器自动生成
9 ~Y() = default; // 析构函数,由编译器自动生成
10 ...
11};
借助“= default”显式要求编译器自动生成相应的函数实现,这样做除了提高代码的明确性和可读性外,还可以避免因某些自定义实现的存在(比如普通构造函数),编译器可能会放弃生成相应的默认实现(比如默认构造函数)。
当类中含有指针成员时,最好通过自己编写的代码,显式指定对象间的拷贝和转移操作,否则,当编译器自动生成的默认实现,试图通过delete操作符,销毁这个指针所指向的对象时,系统将发生错误。即使不想delete某些对象,也应该在函数中指明这一点,以便于代码的阅读者理解。
好的经验法则,亦称零法则,对于基本操作,要么全部自己定义,要么全部采用默认,要么全做,要么不做,半推半就,通常都不会有什么好结果。例如:
xxxxxxxxxx
41struct Z {
2 vector<int> m_vec;
3 string m_str;
4};
xxxxxxxxxx
71void foo() {
2 Z z1; // 默认构造函数,调用vector和string的默认构造函数
3 Z z2 = z1; // 默认拷贝构造函数,调用vector和string的拷贝构造函数
4 z1 = z2; // 默认拷贝赋值操作符函数,调用vector和string的拷贝赋值操作符函数
5 Z z3 = move(z2); // 默认转移构造函数,调用vector和string的转移构造函数
6 z2 = move(z3); // 默认转移赋值操作符函数,调用vector和string的转移赋值操作符函数
7} // 默认析构函数,调用vector和string的析构函数
这里,编译器根据需要为Z类自动生成了默认构造函数、拷贝构造函数、拷贝赋值操作符函数、转移构造函数、转移赋值操作符函数和析构函数等,共计六个完成基本操作的特殊成员函数。编译器生成的默认实现,未必比自定义的差。
与“= default”相反,可以用“= delete”显式指明不生成某些基本操作函数。例如:
xxxxxxxxxx
61class Shape {
2public:
3 Shape(const Shape&) = delete; // 禁止拷贝构造
4 Shape& operator=(const Shape&) = delete; // 禁止拷贝赋值
5 ...
6};
对类层次结构中的基类禁止拷贝是有意义的。拷贝过程中的源对象和目标对象,可能分别属于不同的派生类。将一个Rectangle对象拷贝给一个Circle对象是没有意义的。
xxxxxxxxxx
31void copyShape(Shape& dst, const Shape& src) {
2 dst = src; // 错误,Shape类禁止拷贝赋值
3}
试图调用被“= delete”标记的函数,会在编译时报错。事实上,“= delete”可用于禁止包括基本操作函数在内的任意函数。
C++11:通过default和delete关键字控制(对象成员的)默认实现
可以接受单个参数的构造函数,兼具了从参数类型到类类型的类型转换功能。例如:
xxxxxxxxxx
101class Complex {
2public:
3 ...
4 // 只接受实部的构造函数
5 Complex(double real)
6 : m_real(real)
7 , m_imag(0) {
8 }
9 ...
10};
Complex类提供了一个接受单个double型参数的构造函数,该参数代表复数的实部。编译器允许下面的写法:
xxxxxxxxxx
21Complex c1 = 1.23; // {1.23,0}
2Complex c2 = c1 + 4.56; // {1.23,0} + {4.56,0} -> {5.79,0}
这里面发生了隐式类型转换。编译器会在任何需要的时候(包括但不限于初始化、赋值、传参、接收返回值、计算表达式,等等)借助该构造函数,将一个double类型的数值,隐式转换为Complex类型的对象。
这种隐式类型转换,有时是合理的,比如上面的例子,但有时则不然。例如:
xxxxxxxxxx
171class Vector : public Container {
2public:
3 ...
4 // 接受元素数量的构造函数
5 Vector(int size) {
6 if (size < 0)
7 throw length_error("Vector constructor: negative size");
8
9 m_elem = new double[size]; // 分配内存
10 m_size = size;
11
12 // 初始化元素
13 for (int i = 0; i < m_size; ++i)
14 m_elem[i] = 0;
15 }
16 ...
17};
Vector类提供了一个接受单个int型参数的构造函数,该参数代表元素的数量。编译器允许下面的写法:
xxxxxxxxxx
11Vector vec = 5;
这行代码的执行结果是得到了一个包含5个元素的Vector对象。但以这种方式,允许在一个整数和一个容器之间划等号,怎么说都会令人颇觉诡异。代码编写者的真实意图可能并非如此。标准库中的vector就禁止这种从int到vector的隐式转换。
解决这个问题的方法,就是通过explicit关键字,明确强调该构造函数所能参与的类型转换,必须是显式转换:
xxxxxxxxxx
91class Vector : public Container {
2public:
3 ...
4 // 接受元素数量的构造函数
5 explicit Vector(int size) {
6 ...
7 }
8 ...
9};
修改后的结果:
xxxxxxxxxx
51Vector vec = 5; // 错误,不能隐式地将int转换为Vector
2Vector v1(5); // 明确表达了从一个
3Vector v2 = Vector(5); // int型数据获得
4Vector v3 = (Vector)5; // Vector对象的
5Vector v4 = static_cast<Vector>(5); // 类型转换意图
关于类型转换的问题,多数时候都与Vector的情况类似,Complex只能代表很小的一部分。因此,除非有十分充分的理由,最好把每个能接受单个参数的构造函数都声明为explicit。
在类中定义成员变量时,可为其提供默认的初始值,此即成员初始值设定项。例如:
xxxxxxxxxx
161class Complex {
2public:
3 ...
4 // 只接受实部的构造函数
5 Complex(double real)
6 : m_real(real) {
7 }
8
9 // 默认构造函数
10 Complex() {
11 }
12 ...
13
14private:
15 double m_real = 0, m_imag = 0; // 成员变量,两个双精度浮点数,分别表示复数的实部和虚部
16};
这里的单参构造函数和默认构造函数,并没有为全部成员变量指定初始值。成员变量的默认初始值,在初始值设定项中给出,因此:
xxxxxxxxxx
11Complex c1(3), c2; // {3,0}{0,0}
任何没有在构造函数中获得初始值的成员变量,一律取初始值设定项中的默认初始值。这样既可以简化代码,又可以避免因忘记初始化某个成员变量,而引入不确定性缺陷。
C++11:类内成员的初始值设定项
默认情况下,无论是内置类型的对象,还是用户自定义类型的对象,都是可以拷贝的。默认的拷贝行为,就是逐个成员变量地复制。例如:
xxxxxxxxxx
61Complex c1(1, 2), c2;
2cout << c1 << c2 << endl; // {1,2}{0,0}
3
4Complex c3 = c2; // 拷贝构造
5c2 = c1; // 拷贝赋值
6cout << c2 << c3 << endl; // {1,2}{0,0}
因为拷贝构造和拷贝赋值都复制了Complex类的两个成员变量,所以c2和c1一样,c3和赋值前的c2一样。
在设计一个类时,必须考虑清楚该类的对象是否会被拷贝以及应该如何拷贝的问题。对于简单的具体类型而言,默认的逐个成员复制,通常符合拷贝操作的本意。然而对于象Vector这样的复杂类型,因其含有指向动态资源的指针型成员变量,逐个成员复制的默认拷贝方式,通常是不正确的,抽象类型更是如此。自己定义拷贝构造函数和拷贝赋值操作符函数,正是为了解决这个问题。
当一个类被作为资源句柄,即通过指针型成员变量持有并访问一个对象时,默认的逐个成员复制式的拷贝操作,通常会导致灾难性的后果。例如:
xxxxxxxxxx
71Vector v1{ 1, 2, 3, 4, 5 }, v2{ v1 };
2
3v1[1] = 20;
4v2[3] = 40;
5
6cout << v1 << endl; // (1)(20)(3)(40)(5)
7cout << v2 << endl; // (1)(20)(3)(40)(5)
逐个成员复制违反了资源句柄的约束条件。v2作为v1的副本,其m_elem和m_size成员皆从v1复制。复制的结果是使v2的m_elem成员与v1的m_elem成员保存同一块内存的地址,因此通过v2和通过v1访问的是同一组元素,修改了v1中的元素,也就等于修改了v2中的元素,反之亦然。更有甚者,在v1和v2双双离开作用域时,分别在析构函数中对各自的m_elem成员执行“delete[]”操作,即释放同一块内存,这通常会导致崩溃。
解决这个问题的最佳方案,是通过自定义的拷贝构造函数和拷贝赋值操作符函数,实现真正意义上的拷贝语义——复制内容而非复制指针。
对于Vector类而言,拷贝构造函数的任务如下:
分配资源:为副本对象分配足以容纳参数对象中所有元素的自由存储空间
复制内容:将参数对象中的全部元素,复制到副本对象的自由存储空间中
如此构造的副本对象才真正拥有了独立的自由存储空间,且其中的元素与参数对象完全一致。例如:
xxxxxxxxxx
121class Vector : public Container {
2public:
3 ...
4 // 拷贝构造函数
5 Vector(const Vector& vec)
6 : m_elem{ new double[vec.m_size] } // 分配资源
7 , m_size{ vec.m_size } {
8 for (int i = 0; i < m_size; ++i) // 复制内容
9 m_elem[i] = vec.m_elem[i];
10 }
11 ...
12};
下面的代码可以得到预期的结果,且不会导致崩溃:
xxxxxxxxxx
71Vector v1{ 1, 2, 3, 4, 5 }, v2{ v1 };
2
3v1[1] = 20;
4v2[3] = 40;
5
6cout << v1 << endl; // (1)(20)(3)(4)(5)
7cout << v2 << endl; // (1)(2)(3)(40)(5)
类似的逻辑,也应该体现在拷贝赋值操作符函数中。例如:
xxxxxxxxxx
221class Vector : public Container {
2public:
3 ...
4 // 拷贝赋值操作符函数
5 Vector& operator=(const Vector& vec) {
6 if (&vec != this) { // 避免自赋值
7 int size = vec.m_size;
8 double* elem = new double[size]; // 分配新资源
9
10 for (int i = 0; i < size; ++i) // 复制新内容
11 elem[i] = vec.m_elem[i];
12
13 delete[] m_elem; // 释放旧资源
14
15 m_elem = elem;
16 m_size = size;
17 }
18
19 return *this; // 返回自引用
20 }
21 ...
22};
其中,成员函数中的预定义名字this,是一个指针,指向调用此成员函数的对象。
注意,一定要先分配新资源并复制新内容,然后再释放旧资源,因为一旦在资源分配和内容复制的过程中发生异常,即赋值失败,目标对象中的原有内容还能继续使用。
下面的代码可以得到预期的结果,且不会导致崩溃:
xxxxxxxxxx
81Vector v3{ 1, 2, 3, 4, 5 }, v4{ 6, 7, 8, 9 };
2v4 = v3;
3
4v3[1] = 20;
5v4[3] = 40;
6
7cout << v3 << endl; // (1)(20)(3)(4)(5)
8cout << v4 << endl; // (1)(2)(3)(40)(5)
现实世界中的拷贝赋值操作符函数,应该是尽量复用拷贝构造函数的逻辑,而不是重复其中的代码。例如:
xxxxxxxxxx
151class Vector : public Container {
2public:
3 ...
4 // 拷贝赋值操作符函数
5 Vector& operator=(const Vector& vec) {
6 if (&vec != this) {
7 Vector dup{ vec }; // 调用拷贝构造函数得到源对象的局部副本
8 swap(m_elem, dup.m_elem); // 与局部副本对象交换成员变量,成为副本
9 swap(m_size, dup.m_size); // 同时令局部副本对象持有目标对象的资源
10 } // 在局部副本的析构过程中释放原有旧资源
11
12 return *this;
13 }
14 ...
15};
通过自定义的拷贝构造函数和拷贝赋值操作符函数,固然可以实现真正意义上对象复制,但对于包含大量元素的容器对象而言,拷贝可能是一个开销巨大的操作。当给函数传参时,可以凭借引用型参数,规避对象复制的开销,但从函数中返回局部对象的引用却是十分危险的,因为在调用者得到返回值的时候,其所引用的局部对象就已经被销毁了。以下面的代码为例:
xxxxxxxxxx
121Vector operator+(const Vector& va, const Vector& vb) {
2 if (va.size() != vb.size())
3 throw length_error("Vector operator+: the sizes of two operands are not same");
4
5 int size = va.size();
6 Vector vc(size);
7
8 for (int i = 0; i < size; ++i)
9 vc[i] = va[i] + vb[i];
10
11 return vc;
12}
要想得到加号操作符(+)的计算结果,就需要把局部对象vc拷贝到某个调用者可以访问的地方。例如:
xxxxxxxxxx
31Vector v1{ 1, 2, 3 }, v2{ 4, 5, 6 }, v3{ 7, 8, 9 };
2Vector v4 = v1 + v2 + v3;
3cout << v4 << endl;
这里至少需要拷贝Vector对象两次,每次加号操作一次。如果每个Vector对象中均包含数以百万计的double型元素,上述过程必将令人头痛不已。其中最不合理的地方在于,加号操作符函数中的局部对象vc,在完成拷贝之后就不再使用了。事实上,这里并不真的需要一份拷贝。为了把计算结果从函数中取出来,与其将源对象的内容完整地拷贝到目标对象中:
倒不如将源对象中的内容快速地转移到目标对象中,毕竟源对象马上即被销毁:
C++支持这种做法。例如:
xxxxxxxxxx
121class Vector : public Container {
2public:
3 ...
4 // 转移构造函数
5 Vector(Vector&& vec)
6 : m_elem(vec.m_elem) // 转移资源
7 , m_size(vec.m_size) {
8 vec.m_elem = nullptr; // 不再持有
9 vec.m_size = 0;
10 }
11 ...
12};
下面的代码将通过Vector类的转移构造函数,获得加号操作符函数的返回值:
xxxxxxxxxx
31Vector v1{ 1, 2, 3 }, v2{ 4, 5, 6 }, v3{ 7, 8, 9 };
2Vector v4 = v1 + v2 + v3; // 转移构造
3cout << v4 << endl;
符号“&&”表示右值引用,右值引用只能引用右值。右值的含义与左值刚好相反。左值的大致含义是能出现在赋值操作符左边的值,因此右值大致上就是无法为其赋值的值,比如函数的返回值。右值引用引用的就是这样一个无法被赋值的值,对于这样的值,可以安全地窃取其内容。Vector类加号操作符函数的返回值vc就是一个例子。
转移构造函数不接受被const修饰的实参,毕竟转移构造函数最终要删除其实参中的内容。
转移赋值操作符函数的定义与此类似:
xxxxxxxxxx
151class Vector : public Container {
2public:
3 ...
4 // 转移赋值操作符函数
5 Vector& operator=(Vector&& vec) {
6 if (&vec != this) {
7 Vector dup{ move(vec) }; // 调用转移构造函数得到源对象的局部副本
8 swap(m_elem, dup.m_elem); // 与局部副本对象交换成员变量,成为副本
9 swap(m_size, dup.m_size); // 同时令局部副本对象持有目标对象的资源
10 } // 在局部副本的析构过程中释放原有旧资源
11
12 return *this;
13 }
14 ...
15};
注意,转移赋值操作符函数的右值引用型参数vec本身是个左值,需要通过标准库的move函数将其转换为右值,才能与转移构造函数的参数匹配,否则将匹配拷贝构造函数,这显然有悖于实现转移语义的设计初衷。
下面的代码将通过Vector类的转移赋值操作符函数,获得加号操作符函数的返回值:
xxxxxxxxxx
31Vector v1{ 1, 2, 3 }, v2{ 4, 5, 6 }, v3{ 7, 8, 9 };
2v1 = v2 + v3; // 转移赋值
3cout << v1 << endl;
当一个类类型对象的右值,被用作初始值,或出现在赋值操作符(=)的右边时,将优先匹配以右值引用为参数的转移构造函数,或转移赋值操作符函数。如果没有为该类定义转移构造函数,或转移赋值操作符函数,则匹配以常左值引用为参数的拷贝构造函数,或拷贝赋值操作符函数。如果也没有为该类定义拷贝构造函数,或拷贝赋值操作符函数,则匹配编译器自动生成的默认转移构造函数,或默认转移赋值操作符函数。
一旦发生转移,源对象所处的状态应该允许析构函数正常执行,通常,也应该允许对该对象再次赋值,以令其获得新的内容并被继续使用。标准库中的转移构造函数和转移赋值操作符函数,都满足上述条件。截至目前的Vector类也同样满足。
程序员也许知道,代码执行到哪个地方就不再使用哪个对象了,但编译器不一定知道。因此程序员有时需要将某个确定后面不再使用的左值对象,显式地转换为右值,以匹配转移构造和转移赋值。例如:
xxxxxxxxxx
91Vector foo() {
2 Vector x(1000), y(2000), z(3000);
3 ...
4 z = x; // 拷贝赋值
5 ...
6 y = std::move(x); // 转移赋值,编译器不知道后面还会不会继续使用x,显式转换为右值
7 ... // 不再使用x
8 return z; // 转移构造,编译器知道后面不会再使用z了,将其处理为右值
9}
标准库的move函数不会真的转移什么,它只是返回一个能作为参数,传递给转移构造函数,或转移赋值操作符函数的右值,这也是一种强制类型转换。
foo函数进入之初,x、y、z的状态如下图所示:
foo函数即将返回,x、y、z的状态如下图所示:
在foo函数返回时,局部对象x、y、z都会被销毁。Vector类的析构函数负责销毁y所持有的动态资源。z的动态资源已被转移到foo函数外部,x的动态资源则转移给了y。
C++标准规定编译器必须将与初始化有关的绝大多数拷贝行为消除掉,即只要能直接复用已有的对象(如从函数中返回的对象),而不致发生冲突,就尽量不拷贝到新对象,此即拷贝省略优化。基于这种优化,不仅消除了对象复制的开销,而且连对象转移的开销也一并消除了,虽然后者完全可被忽略不计。因此,无论是拷贝构造函数,还是转移构造函数,实际上都很少被调用到。与此相反,赋值语句中的赋值操作,几乎不会被优化掉,因此转移赋值操作符函数对性能的改善会非常明显。
C++11:借助右值引用表达转移语义
C++11:容器的转移语义
C++17:保证拷贝省略
通过定义构造和析构函数、拷贝构造和拷贝赋值操作符函数、转移构造和转移赋值操作符函数,程序员就能完全控制,对象资源(如容器中的元素)的生命周期。其中转移构造函数甚至还能将对象资源的控制权,从一个作用域转移到另一个作用域。凭借这种能力,那些不能或者不希望被复制到作用域之外的对象,就能被简单而高效地转移了。标准库中表示线程的thread类,和表示动态数组的vector类,就是很好的例子。前者是不能被复制,而后者则是不希望被复制。例如:
xxxxxxxxxx
11vector<thread> threads; // 线程池
xxxxxxxxxx
111vector<int> init(int size) {
2 thread t{ heartbeat }; // 在独立的线程中执行心跳
3 threads.push_back(move(t)); // 将心跳线程转移到线程池中
4
5 ... 其它初始化 ...
6
7 vector<int> v(size);
8 for (auto& n : v)
9 n = 777;
10 return v; // 将向量容器转移到函数外部
11}
xxxxxxxxxx
11auto v = init(1'000'000); // 初始化并开始心跳
事实上,对于象线程池这样的对象,使用指针容器可能会更好一些:
xxxxxxxxxx
11vector<thread*> threads; // 线程池
如果这样,线程池中的每个线程对象应该都是通过new操作符动态创建的:
xxxxxxxxxx
51vector<int> init(int size) {
2 thread* t{ new thread{ hearbeat } }; // 在独立的线程中执行心跳
3 threads.push_back(t); // 将心跳线程的指针放到线程池中
4 ...
5}
为了防止内存泄漏,可以使用unique_ptr取代平凡指针:
xxxxxxxxxx
11vector<unique_ptr<thread>> threads; // 线程池
xxxxxxxxxx
51vector<int> init(int size) {
2 unique_ptr<thread> t{ new thread{ hearbeat } }; // 在独立的线程中执行心跳
3 threads.push_back(move(t)); // unique_ptr不支持拷贝构造,转成右值匹配转移构造
4 ...
5}
或者写成更简单的形式:
xxxxxxxxxx
41vector<int> init(int size) {
2 threads.push_back(unique_ptr<thread>{ new thread{ hearbeat } }); // 匿名变量本身就是右值
3 ...
4}
从本质上讲,智能指针也是一个句柄对象。
将指针转化为句柄,同时辅以转移语义,有助于得到简洁且易于维护的代码,在不明显增加额外开销的前提下,规避了资源泄漏的风险。管理线程的thread对象、管理内存的vector对象、管理文件描述符的fstream对象,等等,究其本质,都是句柄。
很多编程语言都倾向于,借助垃圾回收器这样的机制,参与动态资源管理。C++同样也可以外挂一个垃圾回收器接口。不过,最好还是尽可能选择那些更干净,通用性也更好的局部化解决方案。只在实不可解的情况下,再考虑系统提供的垃圾回收机制。理想的情况是,永远不要制造垃圾,当然也就不需要回收垃圾。请勿乱扔垃圾!
从本质上说,垃圾回收是一种全局内存管理模式。适当使用当然没有问题,不过随着系统的分布式趋势(如多核、缓存、集群等)日益增强,在局部范围内管理内存的能力将变得越来越重要。
另一方面,内存并不是需要管理的唯一资源。资源是任何需要在使用前隐式或显式地获取,并在使用后隐式或显式地释放的东西。除了内存,还有很多诸如锁、套接字、文件描述符、线程等非内存资源。一个好的资源管理系统应该能够处理所有类型的资源,而不仅仅是内存资源。任何长时间运行的系统,都应该尽量避免资源泄漏,但从另一方面讲,过度地占用资源和发生资源泄漏一样糟糕。例如,一个系统可能占用了比实际需要多出一倍的内存、锁、套接字、文件描述符、线程等资源,而系统必须留出两倍的空间以存储这些资源。
在不得不求助于垃圾回收机制之前,优先使用资源句柄,让所有资源都在某个特定的作用域内有所归属,并在离开该作用域后默认地被释放。在C++中,这被称为RAII——资源获取即初始化,它与错误处理一起构成了C++的异常机制。此外,还可以借助转移构造和智能指针,把资源从一个作用域转移到另一个作用域,或借助共享指针,在多个作用域中,安全地分享资源的所有权。
在C++标准库中,RAII无处不在。例如:
针对内存资源的string、vector、map、unordered_map等
针对锁资源的lock_guard、unique_lock等
针对文件资源的ifstream、ofstream等
针对线程资源的thread等
针对一般对象的unique_ptr、shared_ptr等
在日常应用中,程序员可能无法察觉到隐式资源管理正在默默无闻地工作,但它的确使资源的实际占用时间被极大地缩短了。
通过操作符重载,可以让标准的C++操作符也能应用于程序员自己定义的数据类型。这种行为之所以被称为操作符重载,是因为针对每种数据类型的操作符的正确实现,必须从一系列同名操作符函数中选择,其选择逻辑与根据参数的数据类型匹配特定的重载版本一样。比如,针对复数的“+”、针对整数的“+”和针对浮点数的“+”,肯定是不一样的,需要匹配到不同的加号操作符实现。
C++不允许定义新的操作符,例如,不能定义“^^”、“===”、“**”、“$”等操作符,也不能改变操作符的目数,即操作数的个数,例如,不能将“%”定义成单目操作符。之所以做出这样的限制,是因为它们所导致的问题,远多于所带来的好处。
在定义操作符时,强烈建议保持它们通常的语义。例如,将“+”操作符定义为减法,不会令任何人感到愉快。
以下是一些可被重载的操作符:
类别 | 操作符 |
---|---|
双目算术操作符 | + (加法)、- (减法)、* (乘法)、/ 、% |
单目算术操作符 | + (正)、- (负) |
双目逻辑操作符 | && 、|| |
单目逻辑操作符 | ! |
双目位操作符 | & (位与)、| 、^ |
单目位操作符 | ~ |
关系操作符 | < 、<= 、> 、>= 、== 、!= 、<=> |
赋值操作符 | = 、+= 、-= 、*= 、/= 、%= 、&= 、|= 、^= |
自增减操作符 | ++ 、-- |
指针操作符 | & (取地址)、* (解引用)、-> |
函数操作符 | () |
下标操作符 | [] |
逗号操作符 | , |
移位操作符 | << 、>> |
注意,虽然可以重载“->”操作符,但不能重载“.”操作符,因此无法象定义智能指针那样,定义智能引用。
可以按如下方式将操作符定义为类的成员函数:
xxxxxxxxxx
71class Matrix {
2 ...
3 Matrix& operator=(const Matrix& mtx) {
4 ... 将mtx赋值给*this,并返回*this的引用 ...
5 }
6 ...
7}
这常见于会修改第一个操作数的操作符。出于历史原因,“=”、“->”、“[]”和“()”,这四个操作符必须以成员函数的形式给出重载定义。其它绝大多数操作符函数,建议定义为全局函数。例如:
xxxxxxxxxx
31Matrix operator+(const Matrix& ma, const Matrix& mb) {
2 ... 执行矩阵加法,并返回计算结果 ...
3}
用于对称(可交换)操作数的操作符,以全局函数的形式重载,有助于凸显两个操作数的平等性。为了保证返回大对象(比如Matrix类型的矩阵)时的高性能,可以借助转移语义。
在定义类时,一部分操作具有常规的含义。程序员和库(特别是标准库)都认可这种常规含义。在为新类定义这些操作时,最好也遵从它们的常规含义。例如:
比较操作:“<”、“<=”、“>”、“>=”、“==”、“!=”和“<=>”
容器操作:size函数、begin函数和end函数
迭代器与智能指针操作:“*”、“->”、“+”、“-”、“+=”、“-=”、“++”、“--”和“[]”
函数操作:“()”
I/O操作:“<<”和“>>”
交换操作:swap函数
哈希操作:hash函数
相等性比较(“==”和“!=”)操作的含义与拷贝有着密切的联系。一个对象与它的副本必定是相等的。例如:
xxxxxxxxxx
31X a = something;
2X b = a;
3assert(a == b); // 如果这里a!=b,那么一定是有什么事情出错了
如果重载了“==”操作符,就没有理由不重载“!=”操作符,并保证“a!=b”的值与“!(a==b)”的值一致。
如果重载了“<”操作符,通常也会同步定义“<=”、“>”和“>=”操作符,并保证逻辑一致:
“a<=b”等价于“a<b||a==b”及“!(b<a)”
“a>b”等价于“b<a”
“a>=b”等价于“a>b||a==b”及“!(a<b)”
为了体现双目操作符“==”的两个操作数的平等性,最好将其定义为一个全局函数,且与定义操作数的类位于同一个名字空间内。例如:
xxxxxxxxxx
91namespace NX {
2 class X {
3 ...
4 };
5
6 bool operator==(const X& x1, const X& x2) {
7 ...
8 }
9}
与其它比较操作符不同,三向比较(宇宙飞船)操作符“<=>”有不同的含义。只要“<=>”操作符采用了编译器提供的默认定义,其它比较操作符也就同时被默认定义了,后者调用前者。例如:
xxxxxxxxxx
51class R {
2 ...
3 auto operator<=>(const R& r) const = default;
4 ...
5};
xxxxxxxxxx
91bool b1 = (r1 <=> r2) == 0; // r1 == r2
2bool b2 = (r1 <=> r2) < 0; // r1 < r2
3bool b3 = (r1 <=> r2) > 0; // r1 > r2
4
5bool b4 = r1 == r2;
6bool b5 = r1 < r2;
7bool b6 = r1 > r2;
8
9cout << (b1 == b4) << ' ' << (b2 == b5) << ' ' << (b3 == b6) << endl; // 1 1 1
与C语言的字符串比较函数strcmp类似,“<=>”实现了三向比较,返回负数表示左操作数小于右操作数,返回正数表示左操作数大于右操作数,返回0表示左右两个操作数的值相等。
当“<=>”操作符没有被声明为default时,“==”操作符不会被默认定义,但其它比较操作符仍会得到编译器提供的默认定义,后者调用两者。例如:
xxxxxxxxxx
91class R {
2 ...
3 auto operator<=>(const R& r) const {
4 return m_var == r.m_var ? 0 : m_var < r.m_var ? -1 : 1;
5 }
6 ...
7 int m_var;
8 ...
9};
这里使用了源自C语言条件表达式“p?x:y”,如果p的值为真,则表达式的值为x,否则为y。
xxxxxxxxxx
71bool b1 = (r1 <=> r2) == 0;
2bool b2 = (r1 <=> r2) < 0;
3bool b3 = (r1 <=> r2) > 0;
4
5bool b4 = r1 == r2; // 错误,无默认的“==”操作符
6bool b5 = r1 < r2;
7bool b6 = r1 > r2;
更一般化的定义形式如下所示:
xxxxxxxxxx
111class R {
2 ...
3};
4
5auto operator<=>(const R& r1, const R& r2) {
6 return r1 == r2 ? 0 : ...
7}
8
9bool operator==(const R& r1, const R& r2) {
10 ...
11}
大多数标准库中的类型,如string、vector等,都遵从以上模式。这时因为,这些表示容器的对象均包含多个元素,容器间默认的“<=>”操作,需要对其中的每个元素做字典比较,这可能会非常耗时。提供自定义的“==”操作符函数,并在自定义的“<=>”操作符函数中调用它,有望提高容器间的比较性能。例如:
xxxxxxxxxx
51string s1 = "asdfghjkl";
2string s2 = "asdfghjk";
3
4bool b1 = s1 == s2; // false
5bool b2 = (s1 <=> s2) == 0; // false
string类的自定义“==”操作符函数,基于对字符串长度的比较,直接判定二者不等。这比默认的“<=>”操作符,按顺序逐个比较两个字符串中对应位置的字符,直到发现s2小于s1,认定二者不等,要快得多。
有关“<=>”操作符还有很多其它细节,比如对排序中比较的考虑,但那是语言库的高级实现者需要考虑的问题。
C++20:三向比较(宇宙飞船)操作符“<=>”
除非有明确不这么做的理由,否则总应该将容器类的风格,设计得尽可能肖似于标准库容器。特别地,可以通过实现句柄并给予基本操作,以确保容器内部的资源安全。
标准库容器都知道其中元素的个数,并通过成员函数size返回给容器的使用者。例如:
xxxxxxxxxx
21for (size_t i = 0; i < con.size(); ++i) // size_t是标准库容器size成员函数的返回类型
2 con[i] = 0;
不过,并不是所有的标准库容器都支持下标操作符([])。更一般的做法,也是标准库算法的通用做法,是将容器中的元素抽象为序列,序列的起止位置以一对迭代器表示,通过迭代器访问容器中的各个元素。例如:
xxxxxxxxxx
21for (auto it = con.begin(); it != con.end(); ++it)
2 *it = 0;
迭代器就象是指向容器中元素的指针。“con.begin()”返回的是指向容器中第一个元素的迭代器,称为起始迭代器。“con.end()”返回的指向容器中最后一个元素的下一个位置的迭代器,称为终止迭代器。通过对迭代器执行“++”操作,使其指向当前元素的下一个位置。通过对迭代器执行“*”操作,获取其目标元素的引用。
begin和end函数,以及容器的迭代器,也被用于实现基于范围的for循环。例如:
xxxxxxxxxx
21for (auto& elem : con)
2 elem = 0;
迭代器常见于标准库算法的参数。例如:
xxxxxxxxxx
11sort(con.begin(), con.end());
相比于下标模型,迭代器模型具有更广的通用性和更高的执行效率。
事实上,用于获取容器起止迭代器的begin和end函数,也可以全局函数的形式定义。多于const容器,可以调用这两个函数的常版本,cbegin和cend。
迭代器和智能指针都属于用户自定义的数据类型。除迭代元素和清理资源外,它们还必须表现得象个指针:
支持“*”、“->”(对于类)、“[]”(对于容器)等操作
支持“+”、“-”、“+=”、“-=”、“++”、“--”等操作
支持拷贝或转移赋值(=)操作
对于整数,“<<”表示左移位,“>>”表示右移位,而对于一个iostream对象,它们则分别表示插入到输出流和从输入流提取。
很多算法函数,尤其是类似于sort这样的函数,经常调用一个名为swap的函数,交换两个对象的值。这些算法通常假定swap函数能够在正确、高效且不抛出任何异常的前提下完成任务。标准库的swap函数通过三次转移,交换其参数所引用目标对象的值。类似下面这样:
xxxxxxxxxx
31auto c = move(a);
2a = move(b);
3b = move(c);
如果所要交换值的对象,复制开销较大,那么就应该为其提供一个转移构造函数和一个转移赋值操作符函数,或专门针对该对象类型的swap函数,或二者兼而有之,以避免对象复制。标准库中的各种容器和表示字符串的string类,都支持快速的转移操作。
标准库中表示无序映射的类模板“unordered_map<K,V>”内部维护了一张哈希表,K为键的类型,V为值的类型。如果要将X类型的对象作为键,那么就必须为该类型定义一个哈希函数“hash<X>”。对于常见类型,比如“std::string”,标准库已经定义了针对该类型的“hash<std::string>”函数。
类的一个目标是允许程序员设计和实现与内置类型相似的类型。构造函数提供了初始化过程,与内置类型的初始化等价,甚至在某种程度上还有所超越,但对于内置类型而言,存在如下形式的字面量:
123是int类型的字面量
0xFF00u是unsigned int类型的字面量
123.456是double类型的字面量
"Surprise!"是const char[10]类型的字面量
······
那么对于用户自定义类型而言,如何表示特定类型的字面量呢?使用后缀是最基本的方法。例如:
"Surprise!"s是string类型(字符串)的字面量
123s是second类型(秒)的字面量
12.7i是imaginary类型(虚数)的字面量
12.7i+47是complex类型(复数)的字面量
使用合适的头文件和命名空间,可以从标准库中找到更多类似的用户自定义字面量后缀:
头文件 | 命名空间 | 用户自定义字面量后缀 |
---|---|---|
<chrono> | std::literals::chrono_literals | h、min、s、ms、us、ns |
<string> | std::literals::string_literals | s |
<string_view> | std::literals::string_literals | sv |
<complex> | std::literals::complex_literals | i、il、if |
······ | ······ | ······ |
带有用户自定义字面量后缀的字面量称为用户自定义字面量,即UDL。除了使用标准库预定义的用户自定义字面量后缀以外,程序员还可以通过字面量操作符函数,定义自己的字面量后缀。例如:
xxxxxxxxxx
71Complex operator""R(long double real) {
2 return { (double)real, 0 };
3}
4
5Complex operator""I(long double imag) {
6 return { 0, (double)imag };
7}
符号“operator""”表明这是一个字面量操作符函数
双引号后面的“R”和“I”是需要给出定义的字面量后缀
long double类型的参数结合写在字面量后缀前面的数值
返回类型Complex指定了该字面量的数据类型
下面的代码即用到了Complex类型的字面量:
xxxxxxxxxx
11cout << 1.23R << 4.56I << 1.23R + 4.56I << endl; // {1.23,0}{0,4.56}{1.23,4.56}
注意,与不使用字面量后缀的区别:
xxxxxxxxxx
11cout << 1.23 << ' ' << 4.56 << ' ' << 1.23 + 4.56 << endl; // 1.23 4.56 5.79
C++11:用户自定义字面量
C++14:用户自定义字面量
尽量将对象的构造与销毁、拷贝与转移掌握在自己手中
为一个类设计构造和析构函数、拷贝构造和拷贝赋值操作符函数、转移构造和转移赋值操作符函数,需要通盘考虑,它们是一个整体
对待基本操作,明智的做法是要么全部自己定义,要么全部交给编译器自动生成默认实现
如果默认的构造和析构函数、拷贝构造和拷贝赋值操作符函数、转移构造和转移赋值操作符函数,已能满足要求,那么就让编译器负责生成它们
对于包含指针型成员变量的类,可能需要考虑删除或者自己定义析构函数、拷贝构造和拷贝赋值操作符函数,以及转移构造和转移赋值操作符函数
如果一个类具有自定义的析构函数,那么它极有可能需要删除或者自己定义拷贝构造函数、拷贝赋值操作符函数、转移构造函数和转移赋值操作符函数
一般而言,任何可以用一个参数调用的构造函数,最好都被声明为explicit
如果一个类的某些成员变量具有合理的默认值,那么就应该在成员初始值设定项中,为它们指定默认值
如果编译器自动生成的默认拷贝构造函数无法满足当前类型的需要,那么就应该自己定义它,或者显式删除它
从函数中返回大对象(比如包含众多元素的容器)未必是低效的,编译优化会尽最大可能减少甚至消除对象复制,即使不考虑编译优化,转移构造和转移赋值,也能保证程序的运行效率
避免显式使用标准库的copy函数
传递给操作符函数的操作数,如果容量较大,建议采用const引用,作为接受该操作数的参数类型
时刻确保强有力的资源安全保障,绝不泄漏任何被当作资源的东西
任何带有资源句柄特征的类,都应该具备非默认的析构函数、拷贝构造函数、拷贝赋值操作符函数、转移构造函数和转移赋值操作符函数
使用RAII管理所有资源——内存资源和非内存资源
进行操作符重载时,尽可能模拟被重载操作符的常规用法
重载一个操作符,往往需要连带着把和它一起工作的其它操作符也重载了
如果为某个类定义了“<=>”操作符函数,那么也要同时定义“==”操作符函数
任何容器的设计,都要遵循标准库容器的准则和惯例