6 基本操作

6.1 引言

语言规则对一部分基本操作,如初始化、赋值、拷贝和转移等,遵循其约定俗成的定义,对其它基本操作,如取地址、解引用、插入和提取等,则给出了常规的定义。任何时候都不应违背这些定义,忽视这一点将是十分危险的。

6.1.1 基本操作

构造函数、析构函数、拷贝操作和转移操作,它们在逻辑上有着千丝万缕的联系。在定义这些函数时,必须考虑到它们之间的内在联系,否则就会遇到逻辑问题或者性能问题。如果在X类的析构函数中,执行了某种特定的操作,比如释放自由存储中的内存或者释放锁,那么在该类的其它相关函数中,也必然会顾及到这种操作所带来的影响。比如对象拷贝,会否导致副本对象和源对象重复执行对同一份资源的释放或重复解锁?这组高度相关的函数如下所示:

遇到以下五种情况,对象将被拷贝或转移:

除了赋值语句会调用拷贝/转移赋值操作符函数外,其它情况均调用拷贝/转移构造函数。然而,拷贝/转移构造函数,常常被编译器优化为对目标对象的直接初始化。例如:

这里,编译器往往会直接在x对象上构造make函数的返回值,从而避免了一次对象的拷贝或转移。

除了用于初始化有名对象和在自由存储中动态创建的对象以外,构造函数还可用于初始化临时对象,和实现显式类型转换。

除普通构造函数外的其它六个函数,编译器都会根据需要自动生成。如果这些函数的默认实现已足够好用,则无需再显式定义它们。例如:

借助“= default”显式要求编译器自动生成相应的函数实现,这样做除了提高代码的明确性和可读性外,还可以避免因某些自定义实现的存在(比如普通构造函数),编译器可能会放弃生成相应的默认实现(比如默认构造函数)。

当类中含有指针成员时,最好通过自己编写的代码,显式指定对象间的拷贝和转移操作,否则,当编译器自动生成的默认实现,试图通过delete操作符,销毁这个指针所指向的对象时,系统将发生错误。即使不想delete某些对象,也应该在函数中指明这一点,以便于代码的阅读者理解。

好的经验法则,亦称零法则,对于基本操作,要么全部自己定义,要么全部采用默认,要么全做,要么不做,半推半就,通常都不会有什么好结果。例如:

这里,编译器根据需要为Z类自动生成了默认构造函数、拷贝构造函数、拷贝赋值操作符函数、转移构造函数、转移赋值操作符函数和析构函数等,共计六个完成基本操作的特殊成员函数。编译器生成的默认实现,未必比自定义的差。

与“= default”相反,可以用“= delete”显式指明不生成某些基本操作函数。例如:

对类层次结构中的基类禁止拷贝是有意义的。拷贝过程中的源对象和目标对象,可能分别属于不同的派生类。将一个Rectangle对象拷贝给一个Circle对象是没有意义的。

试图调用被“= delete”标记的函数,会在编译时报错。事实上,“= delete”可用于禁止包括基本操作函数在内的任意函数。

C++11:通过default和delete关键字控制(对象成员的)默认实现

6.1.2 类型转换

可以接受单个参数的构造函数,兼具了从参数类型到类类型的类型转换功能。例如:

Complex类提供了一个接受单个double型参数的构造函数,该参数代表复数的实部。编译器允许下面的写法:

这里面发生了隐式类型转换。编译器会在任何需要的时候(包括但不限于初始化、赋值、传参、接收返回值、计算表达式,等等)借助该构造函数,将一个double类型的数值,隐式转换为Complex类型的对象。

这种隐式类型转换,有时是合理的,比如上面的例子,但有时则不然。例如:

Vector类提供了一个接受单个int型参数的构造函数,该参数代表元素的数量。编译器允许下面的写法:

这行代码的执行结果是得到了一个包含5个元素的Vector对象。但以这种方式,允许在一个整数和一个容器之间划等号,怎么说都会令人颇觉诡异。代码编写者的真实意图可能并非如此。标准库中的vector就禁止这种从int到vector的隐式转换。

解决这个问题的方法,就是通过explicit关键字,明确强调该构造函数所能参与的类型转换,必须是显式转换:

修改后的结果:

关于类型转换的问题,多数时候都与Vector的情况类似,Complex只能代表很小的一部分。因此,除非有十分充分的理由,最好把每个能接受单个参数的构造函数都声明为explicit。

6.1.3 成员初始值设定项

在类中定义成员变量时,可为其提供默认的初始值,此即成员初始值设定项。例如:

这里的单参构造函数和默认构造函数,并没有为全部成员变量指定初始值。成员变量的默认初始值,在初始值设定项中给出,因此:

任何没有在构造函数中获得初始值的成员变量,一律取初始值设定项中的默认初始值。这样既可以简化代码,又可以避免因忘记初始化某个成员变量,而引入不确定性缺陷。

C++11:类内成员的初始值设定项

6.2 拷贝和转移

默认情况下,无论是内置类型的对象,还是用户自定义类型的对象,都是可以拷贝的。默认的拷贝行为,就是逐个成员变量地复制。例如:

因为拷贝构造和拷贝赋值都复制了Complex类的两个成员变量,所以c2和c1一样,c3和赋值前的c2一样。

在设计一个类时,必须考虑清楚该类的对象是否会被拷贝以及应该如何拷贝的问题。对于简单的具体类型而言,默认的逐个成员复制,通常符合拷贝操作的本意。然而对于象Vector这样的复杂类型,因其含有指向动态资源的指针型成员变量,逐个成员复制的默认拷贝方式,通常是不正确的,抽象类型更是如此。自己定义拷贝构造函数和拷贝赋值操作符函数,正是为了解决这个问题。

6.2.1 拷贝容器

当一个类被作为资源句柄,即通过指针型成员变量持有并访问一个对象时,默认的逐个成员复制式的拷贝操作,通常会导致灾难性的后果。例如:

逐个成员复制违反了资源句柄的约束条件。v2作为v1的副本,其m_elem和m_size成员皆从v1复制。复制的结果是使v2的m_elem成员与v1的m_elem成员保存同一块内存的地址,因此通过v2和通过v1访问的是同一组元素,修改了v1中的元素,也就等于修改了v2中的元素,反之亦然。更有甚者,在v1和v2双双离开作用域时,分别在析构函数中对各自的m_elem成员执行“delete[]”操作,即释放同一块内存,这通常会导致崩溃。

自由存储
v2
v1
delete[]
delete[]
1 | 20 | 3 | 40 | 5
析构函数
m_elem
析构函数
m_elem

解决这个问题的最佳方案,是通过自定义的拷贝构造函数和拷贝赋值操作符函数,实现真正意义上的拷贝语义——复制内容而非复制指针。

自由存储
v2
v1
delete[]
delete[]
1 | 20 | 3 | 4 | 5
1 | 2 | 3 | 40 | 5
析构函数
m_elem
析构函数
m_elem

对于Vector类而言,拷贝构造函数的任务如下:

如此构造的副本对象才真正拥有了独立的自由存储空间,且其中的元素与参数对象完全一致。例如:

下面的代码可以得到预期的结果,且不会导致崩溃:

类似的逻辑,也应该体现在拷贝赋值操作符函数中。例如:

其中,成员函数中的预定义名字this,是一个指针,指向调用此成员函数的对象。

注意,一定要先分配新资源并复制新内容,然后再释放旧资源,因为一旦在资源分配和内容复制的过程中发生异常,即赋值失败,目标对象中的原有内容还能继续使用。

下面的代码可以得到预期的结果,且不会导致崩溃:

现实世界中的拷贝赋值操作符函数,应该是尽量复用拷贝构造函数的逻辑,而不是重复其中的代码。例如:

6.2.2 转移容器

通过自定义的拷贝构造函数和拷贝赋值操作符函数,固然可以实现真正意义上对象复制,但对于包含大量元素的容器对象而言,拷贝可能是一个开销巨大的操作。当给函数传参时,可以凭借引用型参数,规避对象复制的开销,但从函数中返回局部对象的引用却是十分危险的,因为在调用者得到返回值的时候,其所引用的局部对象就已经被销毁了。以下面的代码为例:

要想得到加号操作符(+)的计算结果,就需要把局部对象vc拷贝到某个调用者可以访问的地方。例如:

这里至少需要拷贝Vector对象两次,每次加号操作一次。如果每个Vector对象中均包含数以百万计的double型元素,上述过程必将令人头痛不已。其中最不合理的地方在于,加号操作符函数中的局部对象vc,在完成拷贝之后就不再使用了。事实上,这里并不真的需要一份拷贝。为了把计算结果从函数中取出来,与其将源对象的内容完整地拷贝到目标对象中:

源对象
目标对象
指针成员
指针成员
副本
内容

倒不如将源对象中的内容快速地转移到目标对象中,毕竟源对象马上即被销毁:

源对象
目标对象
指针成员
指针成员
内容

C++支持这种做法。例如:

下面的代码将通过Vector类的转移构造函数,获得加号操作符函数的返回值:

符号“&&”表示右值引用,右值引用只能引用右值。右值的含义与左值刚好相反。左值的大致含义是能出现在赋值操作符左边的值,因此右值大致上就是无法为其赋值的值,比如函数的返回值。右值引用引用的就是这样一个无法被赋值的值,对于这样的值,可以安全地窃取其内容。Vector类加号操作符函数的返回值vc就是一个例子。

转移构造函数不接受被const修饰的实参,毕竟转移构造函数最终要删除其实参中的内容。

转移赋值操作符函数的定义与此类似:

注意,转移赋值操作符函数的右值引用型参数vec本身是个左值,需要通过标准库的move函数将其转换为右值,才能与转移构造函数的参数匹配,否则将匹配拷贝构造函数,这显然有悖于实现转移语义的设计初衷。

下面的代码将通过Vector类的转移赋值操作符函数,获得加号操作符函数的返回值:

当一个类类型对象的右值,被用作初始值,或出现在赋值操作符(=)的右边时,将优先匹配以右值引用为参数的转移构造函数,或转移赋值操作符函数。如果没有为该类定义转移构造函数,或转移赋值操作符函数,则匹配以常左值引用为参数的拷贝构造函数,或拷贝赋值操作符函数。如果也没有为该类定义拷贝构造函数,或拷贝赋值操作符函数,则匹配编译器自动生成的默认转移构造函数,或默认转移赋值操作符函数。

一旦发生转移,源对象所处的状态应该允许析构函数正常执行,通常,也应该允许对该对象再次赋值,以令其获得新的内容并被继续使用。标准库中的转移构造函数和转移赋值操作符函数,都满足上述条件。截至目前的Vector类也同样满足。

程序员也许知道,代码执行到哪个地方就不再使用哪个对象了,但编译器不一定知道。因此程序员有时需要将某个确定后面不再使用的左值对象,显式地转换为右值,以匹配转移构造和转移赋值。例如:

标准库的move函数不会真的转移什么,它只是返回一个能作为参数,传递给转移构造函数,或转移赋值操作符函数的右值,这也是一种强制类型转换。

foo函数进入之初,x、y、z的状态如下图所示:

foo函数栈
自由存储
x
y
z
*
3000
*
2000
*
1000
1 | 1 | 1 | ...
2 | 2 | 2 | ...
3 | 3 | 3 | ...

foo函数即将返回,x、y、z的状态如下图所示:

调用者栈
foo函数栈
自由存储
临时
y
转移
转移
*
1000
x
nullptr
0
z
nullptr
0
*
1000
1 | 1 | 1 | ...
1 | 1 | 1 | ...

在foo函数返回时,局部对象x、y、z都会被销毁。Vector类的析构函数负责销毁y所持有的动态资源。z的动态资源已被转移到foo函数外部,x的动态资源则转移给了y。

C++标准规定编译器必须将与初始化有关的绝大多数拷贝行为消除掉,即只要能直接复用已有的对象(如从函数中返回的对象),而不致发生冲突,就尽量不拷贝到新对象,此即拷贝省略优化。基于这种优化,不仅消除了对象复制的开销,而且连对象转移的开销也一并消除了,虽然后者完全可被忽略不计。因此,无论是拷贝构造函数,还是转移构造函数,实际上都很少被调用到。与此相反,赋值语句中的赋值操作,几乎不会被优化掉,因此转移赋值操作符函数对性能的改善会非常明显。

C++11:借助右值引用表达转移语义

C++11:容器的转移语义

C++17:保证拷贝省略

6.3 资源管理

通过定义构造和析构函数、拷贝构造和拷贝赋值操作符函数、转移构造和转移赋值操作符函数,程序员就能完全控制,对象资源(如容器中的元素)的生命周期。其中转移构造函数甚至还能将对象资源的控制权,从一个作用域转移到另一个作用域。凭借这种能力,那些不能或者不希望被复制到作用域之外的对象,就能被简单而高效地转移了。标准库中表示线程的thread类,和表示动态数组的vector类,就是很好的例子。前者是不能被复制,而后者则是不希望被复制。例如:

事实上,对于象线程池这样的对象,使用指针容器可能会更好一些:

如果这样,线程池中的每个线程对象应该都是通过new操作符动态创建的:

为了防止内存泄漏,可以使用unique_ptr取代平凡指针:

或者写成更简单的形式:

从本质上讲,智能指针也是一个句柄对象。

自由存储
句柄对象
资源
指针

将指针转化为句柄,同时辅以转移语义,有助于得到简洁且易于维护的代码,在不明显增加额外开销的前提下,规避了资源泄漏的风险。管理线程的thread对象、管理内存的vector对象、管理文件描述符的fstream对象,等等,究其本质,都是句柄。

很多编程语言都倾向于,借助垃圾回收器这样的机制,参与动态资源管理。C++同样也可以外挂一个垃圾回收器接口。不过,最好还是尽可能选择那些更干净,通用性也更好的局部化解决方案。只在实不可解的情况下,再考虑系统提供的垃圾回收机制。理想的情况是,永远不要制造垃圾,当然也就不需要回收垃圾。请勿乱扔垃圾!

从本质上说,垃圾回收是一种全局内存管理模式。适当使用当然没有问题,不过随着系统的分布式趋势(如多核、缓存、集群等)日益增强,在局部范围内管理内存的能力将变得越来越重要。

另一方面,内存并不是需要管理的唯一资源。资源是任何需要在使用前隐式或显式地获取,并在使用后隐式或显式地释放的东西。除了内存,还有很多诸如锁、套接字、文件描述符、线程等非内存资源。一个好的资源管理系统应该能够处理所有类型的资源,而不仅仅是内存资源。任何长时间运行的系统,都应该尽量避免资源泄漏,但从另一方面讲,过度地占用资源和发生资源泄漏一样糟糕。例如,一个系统可能占用了比实际需要多出一倍的内存、锁、套接字、文件描述符、线程等资源,而系统必须留出两倍的空间以存储这些资源。

在不得不求助于垃圾回收机制之前,优先使用资源句柄,让所有资源都在某个特定的作用域内有所归属,并在离开该作用域后默认地被释放。在C++中,这被称为RAII——资源获取即初始化,它与错误处理一起构成了C++的异常机制。此外,还可以借助转移构造和智能指针,把资源从一个作用域转移到另一个作用域,或借助共享指针,在多个作用域中,安全地分享资源的所有权。

在C++标准库中,RAII无处不在。例如:

在日常应用中,程序员可能无法察觉到隐式资源管理正在默默无闻地工作,但它的确使资源的实际占用时间被极大地缩短了。

6.4 操作符重载

通过操作符重载,可以让标准的C++操作符也能应用于程序员自己定义的数据类型。这种行为之所以被称为操作符重载,是因为针对每种数据类型的操作符的正确实现,必须从一系列同名操作符函数中选择,其选择逻辑与根据参数的数据类型匹配特定的重载版本一样。比如,针对复数的“+”、针对整数的“+”和针对浮点数的“+”,肯定是不一样的,需要匹配到不同的加号操作符实现。

C++不允许定义新的操作符,例如,不能定义“^^”、“===”、“**”、“$”等操作符,也不能改变操作符的目数,即操作数的个数,例如,不能将“%”定义成单目操作符。之所以做出这样的限制,是因为它们所导致的问题,远多于所带来的好处。

在定义操作符时,强烈建议保持它们通常的语义。例如,将“+”操作符定义为减法,不会令任何人感到愉快。

以下是一些可被重载的操作符:

类别操作符
双目算术操作符+(加法)、-(减法)、*(乘法)、/%
单目算术操作符+(正)、-(负)
双目逻辑操作符&&||
单目逻辑操作符!
双目位操作符&(位与)、|^
单目位操作符~
关系操作符<<=>>===!=<=>
赋值操作符=+=-=*=/=%=&=|=^=
自增减操作符++--
指针操作符&(取地址)、*(解引用)、->
函数操作符()
下标操作符[]
逗号操作符,
移位操作符<<>>

注意,虽然可以重载“->”操作符,但不能重载“.”操作符,因此无法象定义智能指针那样,定义智能引用。

可以按如下方式将操作符定义为类的成员函数:

这常见于会修改第一个操作数的操作符。出于历史原因,“=”、“->”、“[]”和“()”,这四个操作符必须以成员函数的形式给出重载定义。其它绝大多数操作符函数,建议定义为全局函数。例如:

用于对称(可交换)操作数的操作符,以全局函数的形式重载,有助于凸显两个操作数的平等性。为了保证返回大对象(比如Matrix类型的矩阵)时的高性能,可以借助转移语义。

6.5 常规操作

在定义类时,一部分操作具有常规的含义。程序员和库(特别是标准库)都认可这种常规含义。在为新类定义这些操作时,最好也遵从它们的常规含义。例如:

6.5.1 比较操作

相等性比较(“==”和“!=”)操作的含义与拷贝有着密切的联系。一个对象与它的副本必定是相等的。例如:

如果重载了“==”操作符,就没有理由不重载“!=”操作符,并保证“a!=b”的值与“!(a==b)”的值一致。

如果重载了“<”操作符,通常也会同步定义“<=”、“>”和“>=”操作符,并保证逻辑一致:

为了体现双目操作符“==”的两个操作数的平等性,最好将其定义为一个全局函数,且与定义操作数的类位于同一个名字空间内。例如:

与其它比较操作符不同,三向比较(宇宙飞船)操作符“<=>”有不同的含义。只要“<=>”操作符采用了编译器提供的默认定义,其它比较操作符也就同时被默认定义了,后者调用前者。例如:

与C语言的字符串比较函数strcmp类似,“<=>”实现了三向比较,返回负数表示左操作数小于右操作数,返回正数表示左操作数大于右操作数,返回0表示左右两个操作数的值相等。

当“<=>”操作符没有被声明为default时,“==”操作符不会被默认定义,但其它比较操作符仍会得到编译器提供的默认定义,后者调用两者。例如:

这里使用了源自C语言条件表达式“p?x:y”,如果p的值为真,则表达式的值为x,否则为y。

更一般化的定义形式如下所示:

大多数标准库中的类型,如string、vector等,都遵从以上模式。这时因为,这些表示容器的对象均包含多个元素,容器间默认的“<=>”操作,需要对其中的每个元素做字典比较,这可能会非常耗时。提供自定义的“==”操作符函数,并在自定义的“<=>”操作符函数中调用它,有望提高容器间的比较性能。例如:

string类的自定义“==”操作符函数,基于对字符串长度的比较,直接判定二者不等。这比默认的“<=>”操作符,按顺序逐个比较两个字符串中对应位置的字符,直到发现s2小于s1,认定二者不等,要快得多。

有关“<=>”操作符还有很多其它细节,比如对排序中比较的考虑,但那是语言库的高级实现者需要考虑的问题。

C++20:三向比较(宇宙飞船)操作符“<=>”

6.5.2 容器操作

除非有明确不这么做的理由,否则总应该将容器类的风格,设计得尽可能肖似于标准库容器。特别地,可以通过实现句柄并给予基本操作,以确保容器内部的资源安全。

标准库容器都知道其中元素的个数,并通过成员函数size返回给容器的使用者。例如:

不过,并不是所有的标准库容器都支持下标操作符([])。更一般的做法,也是标准库算法的通用做法,是将容器中的元素抽象为序列,序列的起止位置以一对迭代器表示,通过迭代器访问容器中的各个元素。例如:

迭代器就象是指向容器中元素的指针。“con.begin()”返回的是指向容器中第一个元素的迭代器,称为起始迭代器。“con.end()”返回的指向容器中最后一个元素的下一个位置的迭代器,称为终止迭代器。通过对迭代器执行“++”操作,使其指向当前元素的下一个位置。通过对迭代器执行“*”操作,获取其目标元素的引用。

容器
++/*
++/*
++/*
++/*
共size个元素
第0个元素
第1个元素
第2个元素
第size-1个元素
终点
起始迭代器
终止迭代器
迭代器

begin和end函数,以及容器的迭代器,也被用于实现基于范围的for循环。例如:

迭代器常见于标准库算法的参数。例如:

相比于下标模型,迭代器模型具有更广的通用性和更高的执行效率。

事实上,用于获取容器起止迭代器的begin和end函数,也可以全局函数的形式定义。多于const容器,可以调用这两个函数的常版本,cbegin和cend。

6.5.3 迭代器与智能指针操作

迭代器和智能指针都属于用户自定义的数据类型。除迭代元素和清理资源外,它们还必须表现得象个指针:

6.5.4 I/O操作

对于整数,“<<”表示左移位,“>>”表示右移位,而对于一个iostream对象,它们则分别表示插入到输出流和从输入流提取。

输出
插入
输入
提取
屏幕
输出流
<<
键盘
输入流
>>
对象

6.5.5 交换操作

很多算法函数,尤其是类似于sort这样的函数,经常调用一个名为swap的函数,交换两个对象的值。这些算法通常假定swap函数能够在正确、高效且不抛出任何异常的前提下完成任务。标准库的swap函数通过三次转移,交换其参数所引用目标对象的值。类似下面这样:

如果所要交换值的对象,复制开销较大,那么就应该为其提供一个转移构造函数和一个转移赋值操作符函数,或专门针对该对象类型的swap函数,或二者兼而有之,以避免对象复制。标准库中的各种容器和表示字符串的string类,都支持快速的转移操作。

6.5.6 哈希操作

标准库中表示无序映射的类模板“unordered_map<K,V>”内部维护了一张哈希表,K为键的类型,V为值的类型。如果要将X类型的对象作为键,那么就必须为该类型定义一个哈希函数“hash<X>”。对于常见类型,比如“std::string”,标准库已经定义了针对该类型的“hash<std::string>”函数。

6.6 用户自定义字面量

类的一个目标是允许程序员设计和实现与内置类型相似的类型。构造函数提供了初始化过程,与内置类型的初始化等价,甚至在某种程度上还有所超越,但对于内置类型而言,存在如下形式的字面量:

那么对于用户自定义类型而言,如何表示特定类型的字面量呢?使用后缀是最基本的方法。例如:

使用合适的头文件和命名空间,可以从标准库中找到更多类似的用户自定义字面量后缀:

头文件命名空间用户自定义字面量后缀
<chrono>std::literals::chrono_literalsh、min、s、ms、us、ns
<string>std::literals::string_literalss
<string_view>std::literals::string_literalssv
<complex>std::literals::complex_literalsi、il、if
··················

带有用户自定义字面量后缀的字面量称为用户自定义字面量,即UDL。除了使用标准库预定义的用户自定义字面量后缀以外,程序员还可以通过字面量操作符函数,定义自己的字面量后缀。例如:

下面的代码即用到了Complex类型的字面量:

注意,与不使用字面量后缀的区别:

C++11:用户自定义字面量

C++14:用户自定义字面量

6.7 建议