现代C++编程:从入门到实践
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

致C语言程序员

Arthur Dent:他怎么啦?

Hig Hurtenflurst:他的鞋子不合脚。

——Douglas Adms,《银河系漫游指南》,“Fit the Eleventh”

这篇文章是为那些正在考虑是否阅读本书的有经验的C语言程序员准备的,其他语言的程序员可以跳过。

Bjarne Stroustrup基于C语言开发了C++。虽然C++与C语言并不完全兼容,但写得好的C语言程序往往也是有效的C++程序。例如,Brian Kernighan和Dennis Ritchie所著的The C Programming Language中的每个例子都是合法的C++程序。

C语言在系统编程界无处不在的一个主要原因是,相对于汇编语言,C语言允许程序员在较高的抽象层次上编写程序,这往往会产生更清晰、更不容易出错、更容易维护的代码。

一般来说,系统程序员不愿意为编程的便利性买单,所以C语言坚持零开销原则:不为不使用的东西买单。强类型系统是零开销抽象的一个典型例子。它只在编译时被用来检查程序的正确性,编译结束后,类型就会消失,产生的汇编代码也不会有类型系统的痕迹。

作为C语言的后裔,C++也非常重视零开销的抽象和对硬件的直接映射。这一承诺不仅仅限于C++所支持的C语言特性,C++在C语言基础上建立的一切(包括新的语言特性)都坚持这些原则,任何违背原则的设计决策都应慎重对待。事实上,一些C++特性的开销甚至比相应的C代码产生的开销更少。constexpr关键字就是这样一个例子,它指示编译器在编译时对表达式求值(如果可能的话),如代码清单1中的程序所示。

代码清单1 演示constexpr的程序

isqrt函数计算参数n的平方根。从1开始,该函数递增局部变量i,直到i*i大于或等于n。如果i*i==n,则返回i,否则返回i-1。注意,isqrt的调用有一个字面量,所以编译器理论上可以为你计算结果。结果将只有一个值❶。

在GCC 8.3上用-O2编译代码清单1(目标平台为x86-64),即可得到代码清单2中的汇编代码。

代码清单2 编译代码清单1后产生的汇编代码

这里的重点是main❶中的第二条指令,编译器不是在运行时计算1764的平方根,而是计算它并直接输出指令,将x视为42。当然,你可以用计算器计算它的平方根并手动插入结果,但使用constexpr有很多好处,不仅可以减少手动复制粘贴导致的许多错误,而且使代码更有表现力。

注意 如果不熟悉x86汇编,可以参考Randall Hyde的The Art of Assembly Language(第2版)和Richard Blum的Professional Assembly Language

升级到Super C

现代C++编译器可以适应你的大部分C语言编程习惯,这使得你很容易接受C++提供的一些战术上的好处,同时刻意避开语言本身的更深层次的主题。这种C++风格——我们称之为Super C——是值得讨论的,原因有几个。首先,经验丰富的C语言程序员可以立即将简单的、战术层面的C++概念应用于自己的程序。其次,Super C并不是地道的C++。在C语言程序中简单地应用引用和auto的实例,可能会使代码更加健壮、更加可读,但你需要学习其他概念来充分利用它。最后,在一些严苛的环境(例如,嵌入式软件、某些操作系统内核和异构计算)中,工具链并不完全支持C++。在这种情况下,至少可以从一些C++特性中获益,而这时Super C很可能得到支持。本节讨论一些可以立即应用到代码中的Super C概念。

注意 一些C语言支持的结构体在C++中无法使用,详见https://ccc.codes。

函数重载

请考虑以下来自标准C语言库的转换函数:

char* itoa(int value, char* str, int base);

char* ltoa(long value, char* buffer, int base);

char* ultoa(unsigned long value, char* buffer, int base);

这些函数实现了相同的目标:将整数类型转换为C语言风格的字符串。在C语言中,每个函数必须有唯一的名称。但在C++中,参数不同时,函数可以共享名称,这被称为函数重载。我们可以使用函数重载来创建自己的转换函数,如代码清单3所示。

代码清单3 调用重载函数

几个函数的第一个参数的数据类型不同,所以C++编译器可以从传入toa的参数中获得足够的信息来调用正确的函数。每次调用的函数都是唯一的。这里,我们创建了变量a❶、b❷和c❸,它们是不同类型的int对象,分别与三个toa函数对应。这比定义单独命名的函数更方便,因为我们只需要记住一个名字,编译器会自己确定要调用哪个函数。

引用

指针是C语言(以及大多数系统编程扩展)的一个重要特性。它能够让我们通过传递数据地址而不是实际数据来有效地处理大量的数据。指针对C++而言也同样重要,但C++有了额外的安全特性,可以防止空指针解引用和无意的指针再赋值。

引用是指针处理的一个重大改进。它与指针相似,但有一些关键的区别。在语法上,引用与指针有两个重要的区别。首先,我们通过&而不是*来声明引用,如代码清单4所示。

代码清单4 说明如何声明接受指针和引用的函数的代码

其次,我们使用点运算符(.)而不是箭头运算符(->)与成员交互,如代码清单5所示。

代码清单5 说明使用点运算符和箭头运算符的程序

究其实现原理,引用其实等同于指针,因为它们都是零开销的抽象概念。编译器会生成差不多的代码。为了说明这一点,请考虑在GCC 8.3上以x86-64(-O2)为目标编译make_sentient函数的结果。代码清单6给出了通过编译代码清单5生成的汇编代码。

代码清单6 编译代码清单5生成的汇编代码

然而,在编译时,引用比原始指针更安全,因为一般来说它不能为空。

对于指针,为了安全起见,需要添加nullptr检查。例如,我们可以给make_sentient添加一个检查,如代码清单7所示。

代码清单7 对代码清单5中的make_sentient进行重构,使其执行一个nullptr检查

在接受引用时,这样的检查是不必要的,但是,这并不意味着引用总是有效的。请考虑下面这个函数:

not_dinkum函数返回一个引用,它保证非空。但是,它指向的是垃圾内存(可能是在not_dinkum的返回栈帧中)。我们绝不能这样做,这样做将很痛苦,其结果被称为未定义的运行时行为:它可能会崩溃,也可能会返回一个错误,还可能会做一些完全意想不到的事情。

引用的另一个安全特性是,不能被重定位。换句话说,一旦引用被初始化,它就不能指向另一个内存地址,如代码清单8所示。

代码清单8 说明引用不能被重定位的程序

我们将a_ref声明为对int a的引用❶。没有办法重新设置a_ref,使其指向另一个int。我们可以尝试用operator=❷来重新设置a,但这实际上是将a的值设置为b的值,而不是将a_ref设置为对b的引用。运行这个代码段后,ab都等于100,并且a_ref仍然指向a。代码清单9给出了使用指针的等效程序。

代码清单9 使用指针的代码清单8的等效程序

这里,我们用a*而不是a&来声明指针❶,把b的值分配给a_ptr指向的内存❷。对于引用,等号的左边不需要有任何装饰。但是这里如果省略了*a_ptr中的*,编译器会认为我们试图把int对象赋值给指针类型。

引用只是带有额外安全预防措施和语法糖的指针。当我们把引用放在等号的左边时,实际上就是把被引用的值设置为等号右边的值。

auto初始化

C语言经常要求我们多次重复类型信息。在C++中,我们可以利用auto关键字来表达变量的类型信息,只需一次。编译器将知道变量的类型,因为它知道用于初始化变量的值的类型。请考虑下面这些C++变量的初始化:

这里,xy都是int类型的。考虑到42是一个整数字面量,你可能会惊讶地发现编译器可以推断出y的类型。通过auto,编译器可以推断出等号右侧的类型,并将变量的类型设置为相同的类型。因为整数字面量是int类型的,所以编译器推断y的类型也是int。在这样一个简单的例子中,这似乎没有什么好处,但是请考虑一下用函数的返回值来初始化变量的情况,如代码清单10所示。

代码清单10 用函数的返回值来初始化变量的玩具程序

auto关键字更容易阅读,并且比明确声明变量的类型更容易进行代码调整。如果在声明函数时自由地使用auto,那么以后要改变make_mike的返回类型,只需要做少量的工作。对于更复杂的类型,例如那些与标准库的模板代码有关的类型,auto的作用就更大了。auto关键字使编译器为你做所有的类型推导工作。

注意 还可以给auto添加constvolatile&*限定符。

命名空间与结构体、联合体和枚举的隐式类型定义

C++把类型标记当作隐式的typedef名称。在C语言中,当你想使用结构体(struct)、联合体(union)或枚举(enum)时,必须使用typedef关键字为创建的类型指定一个名称,例如:

在C++中,你会对这样的代码嗤之以鼻,因为typedef关键字可以是隐式的,C++允许你像下面这样声明Jabberwock的类型:

这样更方便,也省去了一些打字工作。如果你还想定义一个Jabberwock函数,会发生什么呢?不应该这样做,因为对数据类型和函数重复使用同一个名字很可能会引起混淆。但是,如果你真的想这样做,那么可以声明一个命名空间(namespace),为标识符创建不同的作用域。这有助于保持用户类型和函数的整洁,如代码清单11所示。

代码清单11 使用命名空间来消除名称相同的函数和类型的歧义

在这个例子中,Jabberwock结构体和Jabberwock函数可以“和谐”地出现在一个地方了。通过将每个元素放在各自的命名空间中——结构体JabberwockCreature命名空间中❶,函数JabberwockFunc命名空间中❷——你可以分辨出具体指哪个Jabberwock。可以用几种方法消除歧义,最简单的方法是用命名空间来限定名称,例如:

也可以使用using指令导入命名空间中的所有名字,这样就不再需要使用完整名称的元素名了。代码清单12使用了Creature命名空间。

代码清单12 通过using namespace来指定使用Creature命名空间中的一个类型

使用using namespace❶,你可以省略命名空间限定词❷。但是对于函数Jabberwock(),仍然需要使用限定词(Func::Jabberwock),因为它不是Creature命名空间的一部分。

使用namespace是C++的惯例,是一种零开销的抽象。就像类型的其他标识符一样,namespace会在生成汇编代码时被编译器清除掉。在大型项目中,它对分离不同库中的代码有极大的帮助。

C和C++对象文件的混用

如果小心些,C和C++代码可以和平共存。有时,有必要让C编译器链接C++编译器生成的对象文件(反之亦然)。这是可以做到的,但需要做一些额外工作。

有两个问题与链接文件有关。首先,C和C++代码中的调用约定可能是不匹配的,例如,调用函数时堆栈和寄存器的设置协议可能不同。调用约定不匹配是语言层面的不匹配,通常与编写函数的方式无关。其次,C++编译器发出的符号与C编译器发出的不同。有时,链接器必须通过名称来识别对象。C++编译器通过装饰目标对象,将叫作装饰名称的字符串与该对象相关联。由于函数重载、调用约定和命名空间的使用,编译器必须通过装饰对函数的额外信息进行编码,而不仅仅是其名称。这样做是为了确保链接器能够唯一地识别该函数。不幸的是,在C++中没有关于这种装饰的标准(这就是在编译单元之间进行链接时应该使用相同的工具链和设置的原因)。C语言的链接器对C++的名称装饰一无所知,如果在C++中对C代码进行链接时不停止装饰,就会产生问题(反之亦然)。

这个问题的解决办法很简单,只要使用extern"C"语句将要用C语言风格的链接方式编译的代码包装起来即可,如代码清单13所示。

代码清单13 采用C语言风格的链接方式

这个头文件可以在C和C++代码之间共享。它之所以起作用是因为__cplusplus是一个特殊的标识符,C++编译器定义了它(但C编译器没有)。因此,在预处理完成后,C编译器将看到代码清单14中的代码。

代码清单14 预处理程序在C环境下处理代码清单13后的代码

这只是一个简单的C头文件。在预处理过程中,#ifdef __cplusplus语句之间的代码被删除了,所以extern"C"包装器并不可见。对于C++编译器来说,__cplusplus被定义在header.h中,所以它可以看到代码清单15所示的内容。

代码清单15 预处理程序在C++环境下处理代码清单13后的代码

extract_arkenstoneMistyMountains现在都用extern"C"来包装,所以编译器知道要使用C链接。现在C源代码可以调用已编译的C++代码,C++源代码也可以调用已编译的C代码。

C++主题

本节将带你简要浏览一些使C++成为首要的系统编程语言的核心主题。不要太在意细节,下面各小节的重点是吊起你的胃口。

简洁地表达想法和重用代码

精心设计的C++代码很优雅,很紧凑。请通过以下简单操作考虑一下从ANSI-C到现代C++的演变,该操作遍历有n个元素的数组v,如代码清单16所示。

代码清单16 说明对数组进行迭代的几种方法的程序

这段代码显示了在ANSI-C、C99和C++中声明循环的不同方法。ANSI-C❶和C99❷的例子中,索引变量i可以辅助你完成任务,也就是辅助你访问v中的每个元素。C++版本❸使用了基于范围(range-based)的for循环,它在v中的数值范围内循环,同时隐藏了实现迭代的细节。就像C++中的许多零开销抽象一样,这个结构使你能够专注于意义而不是语法。基于范围的for循环适用于许多类型,甚至可以适用于用户自定义类型。

用户自定义类型允许你在代码中直接表达想法。假设你想设计一个函数navigate_to,告诉假想的机器人导航到给定xy坐标的某个位置。请考虑以下函数原型:

xy是什么?它们的单位是什么?用户必须阅读文件(或者源文件)来找出答案。请考虑以下改进后的原型:

这个函数要清楚得多。对于navigate_to接受的内容没有任何歧义。只要有有效的Position,你就知道如何调用navigate_to。单位转换等问题则是构建Position类的人的责任。

在C99/C11中,你也可以使用常量(const)指针来实现这种表现力,但C++也使返回类型变得紧凑而富有表现力。假设你想为机器人写一个名为get_position(获取位置的推论函数。在C语言中,有两种方法,如代码清单17所示。

代码清单17 用于返回用户自定义类型的C风格API

在第一种方法中,调用者负责清理返回值❶,这可能产生一个动态内存分配(尽管从代码中看不清楚)。在第二种方法中,调用者负责分配一个Position并把它传入get_position❷。第二种方法更符合C语言的习惯,但语言碍手碍脚:你本来只想得到一个位置对象,却不得不担心是调用者还是被调用者负责分配和删除内存。C++可以让你通过直接从函数中返回用户自定义类型来简洁地完成这一切,如代码清单18所示。

代码清单18 在C++中按值返回用户自定义类型

因为get_position返回一个值❶,编译器可以忽略这个复制操作,所以就好像你直接构造了一个自动的Position变量❷,没有运行时开销。从功能上讲,这个情况与代码清单17中的C风格指针传递非常相似。

C++标准库

C++标准库(stdlib)是人们从C语言迁移到C++的一个主要原因。它包含了高性能的泛型代码,保证可以从符合标准的盒子里直接使用。stdlib的三个主要组成部分是容器、迭代器和算法。

容器是数据结构,负责保存对象的序列。容器是正确的、安全的,而且(通常)至少和你手写的代码一样有效,这意味着自己编写容器将花费巨大的精力,而且不会比stdlib的容器更好。容器大体分为两类:顺序容器和关联容器。顺序容器在概念上类似于数组,提供对元素序列的访问权限。关联容器包含键值对,所以容器中的元素可以通过键来查询。

算法是通用的函数,用于常见的编程任务,如计数、搜索、排序和转换。与容器一样,算法的质量非常高,而且适用范围很广。用户应该很少需要自己实现算法,而且使用stdlib算法可以极大地提高程序员的工作效率、代码安全性和可读性。

迭代器可以将容器与算法连接起来。对于许多stdlib算法的应用,你想操作的数据驻留在容器中。容器公开迭代器,以提供平滑、通用的接口,而算法消费迭代器,使程序员(包括stdlib的实现者)不必为每种容器类型实现一个自定义算法。

代码清单19显示了如何用几行代码对容器的值进行排序。

代码清单19 使用stdlib对容器的值进行排序

虽然会在后台进行大量的计算,但代码是紧凑而富有表现力的。首先,初始化一个std::vector❶。向量是stdlib中大小动态变化的数组。初始化大括号(即{0, 1,...})用来设置x的初始值。我们可以使用中括号([])和索引号,像访问数组的元素一样访问向量的元素。我们用这个方法将第一个元素设置为21❷。因为向量数组的大小是动态的,所以可以用push_back方法向它们追加数值❸。std::sort看似神奇的调用展示了stdlib算法的强大❹。方法x.begin()x.end()返回迭代器,std::sort用该迭代器对x进行原地排序。通过迭代器,排序算法与向量解耦了。

有了迭代器,我们就可以用类似的方式使用stdlib中的其他容器了。例如,我们可以使用list(stdlib的双向链表)而不是向量,因为list也通过.begin().end()公开了迭代器,我们可以用同样的方式在列表迭代器上调用sort

此外,代码清单19使用了iostream。iostream是一种执行缓冲输入和输出的机制。我们使用左移操作符(<<)将x.size()的值(x中的元素数)、一些字符串字面量和斐波那契元素number发给std::cout(包装标准输出)❺❻。std::endl对象是一个I/O操纵符,它写下\n并刷新缓冲区,确保在执行下一条指令之前将整个数据流写到标准输出。

现在,想象一下用C语言编写同等程序所要经历的所有障碍,你就会明白为什么stdlib是一个如此有价值的工具。

lambda

lambda,在某些圈子里也被称为无名函数或匿名函数,是另一个强大的语言特性,它可以提高代码的局部性。在某些情况下,我们应该将指针传递给函数,使用函数指针作为新创建的线程的目标函数,或者对序列的每个元素进行一些转换。一般来说,定义一个一次性使用的自由函数通常很不方便。这就是lambda发挥作用的地方。lambda是一个新的、自定义的函数,与调用的其他参数一起内联定义。考虑下面这个单行程序,它计算x中偶数的数量:

这行代码使用stdlib的count_if算法来计算x中偶数的数量。std::count_if的前两个参数与std::sort相似,它们是定义算法将操作的范围的迭代器。第三个参数是lambda。这个符号可能看起来有点陌生,但基本原理非常简单:

capture包含任何需要从定义lambda的范围中获得的对象,以便执行body中的计算。arguments定义lambda希望被调用的参数的名称和类型。body包含在调用时完成的任何计算。它可能返回值,也可能不返回值。编译器将根据隐含的类型来推导函数原型。

在上面的std::count_if调用中,lambda不需要捕获任何变量。它所需要的所有信息就是单一参数number。因为编译器知道x中包含的元素的类型,所以我们用auto声明number的类型,这样编译器就可以自己推导出来。lambda被调用时,x中的每个元素都作为number参数传入。在body中,只有当数字能被2整除时,该lambda返回true,所以只有为偶数时才会计数。

C语言中不存在lambda,我们也不可能真正模拟它。每次需要函数对象时,我们都需要声明一个单独的函数,而且不可能以同样的方式将对象捕获到一个函数中。

使用模板的泛型编程

泛型编程是指写一次代码就能适用于不同的类型,而不是通过复制和粘贴每个类型来多次重复相同的代码。在C++中,我们使用模板来产生泛型代码。模板是一种特殊的参数,告诉编译器它代表各种可能的类型。

我们已经使用过模板了:stdlib的所有容器都是模板。在大多数情况下,这些容器中的对象的类型并不重要。例如,确定容器中的元素数量或返回其第一个元素的逻辑并不取决于元素的类型。

假设我们想写一个函数,将三个相同类型的数字相加。我们希望函数接受任何可加类型。在C++中,这是一个简单的泛型编程问题,可以直接用模板解决,如代码清单20所示。

代码清单20 使用模板来创建一个泛型add函数

当声明add❶时,我们不需要知道T,只需要知道所有的参数和返回值都是T类型的,并且T类型数据是可加的。当编译器遇到add调用时,它会推断出T并生成一个定制的函数。这就是一种重要的代码复用!

类的不变量和资源管理

也许C++给系统编程带来的最大创新是对象生命周期。这个概念源于C语言,在C语言中,对象具有不同的存储期,这取决于它们在代码中的声明方式。C++在这个内存管理模型的基础上,创造了构造函数和析构函数。这些特殊函数是属于用户自定义类型的方法。用户自定义类型是C++应用程序的基本构建块,我们可以把它想象成有函数的struct对象。

对象的构造函数在其存储期开始后被调用,而析构函数则在其存储期结束前被调用。构造函数和析构函数都是没有返回类型的函数,其名称与包围它的类相同。要声明一个析构函数,请在类名的开头加上~,如代码清单21所示。

代码清单21 一个包含构造函数和析构函数的Hal类

Hal的第一个方法是构造函数❶。它设置了Hal对象并建立了它的类不变量。不变量是类的特征,一旦被构造出来就不会改变。在编译器和运行时的帮助下,程序员决定类的不变量是什么,并确保代码可以保证这些不变量。在本例中,构造函数将version设定为9000,这是一个不变量。析构函数是第二个方法❷。每当Hal要被删除时,它就向控制台打印Stop, Dave.(让HalDaisy Bell是留给读者的一个练习)。

编译器会确保静态、本地和线程局部存储期的对象自动调用构造函数和析构函数。对于具有动态存储期的对象,可以使用关键字newdelete来分别代替mallocfree,代码清单22说明了这一点。

代码清单22 一个创建和销毁Hal对象的程序

如果构造函数无法达到一个好的状态(无论什么原因),那么它通常会抛出异常。作为C语言程序员,你可能在使用一些操作系统API编程时处理过异常(例如,Windows结构化异常处理)。当抛出异常时,堆栈会展开,直到找到异常处理程序,这时程序就会恢复。谨慎地使用异常可以使代码更干净,因为只需要在有意义的地方检查错误条件。如代码清单23所示,C++对异常有语言级的支持。

代码清单23 一个try-catch块

我们可以把可能抛出异常的代码放在紧跟try的代码块中❶。如果抛出了异常,堆栈将展开(“慷慨”地销毁任何超出作用域的对象)并运行catch表达式之后的代码❷。如果没有抛出异常,catch代码块永远不会执行。

构造函数、析构函数和异常与另一个C++核心主题密切相关,该核心主题便是把对象的生命周期与它所拥有的资源联系起来。

这就是资源获取即初始化(RAII)的概念(有时也称为构造函数获取、析构函数释放)。请考虑代码清单24中的C++类。

代码清单24 一个File类

File的构造函数需要两个参数❶。第一个参数是文件的路径,第二个参数是一个布尔值,对应于文件模式(file_mode)应该是写(true)还是读(false)。这个参数的值通过三元运算符?:设置file_mode❷。三元运算符评估一个布尔表达式,并根据布尔值返回其中的一个,例如:

如果布尔表达式xtrue,表达式的值为val_if_true。如果xfalse,则值为val_if_false

在代码清单24的File构造函数代码段中,构造函数试图以读/写访问方式打开位于path路径的文件❸。如果有问题,本次调用将把file_pointer设置为nullptr,这是一个特殊的C++值,类似于0。当发生这种情况时,将抛出一个system_error❹。system_error是封装了系统错误细节的对象。如果file_pointer不是nullptr,它就可以有效地使用。这就是这个类的不变量。

现在请考虑代码清单25中的程序,它采用了File类。

代码清单25 一个使用File类的程序

大括号❶❸定义了一个作用域。因为第一个文件file驻留在这个作用域内,作用域定义了file的生命周期。一旦构造函数返回❷,我们就知道file.file_pointer是有效的,这要归功于类的不变量。根据File的构造函数的设计,我们知道file.file_pointer必须在File对象的生命周期内有效。我们用fwrite写了一条信息。没有必要明确地调用fclose,因为file会过期,而且析构函数会自动清理file.file_pointer❸。我们再次打开File,但这次以读访问方式打开❹。同样,只要构造函数返回,我们就知道last_message.txt被成功打开并继续读入read_message。打印信息之后,调用file的析构函数,file.file_pointer又被清理掉了。

有时,我们需要动态内存分配的灵活性,但仍然想依靠C++的对象生命周期来确保不会泄漏内存或意外地“释放后使用”。这正是智能指针的作用,它通过所有权模型来管理动态对象的生命周期。一旦没有智能指针拥有动态对象,该对象就会被销毁。

unique_ptr就是一种这样的智能指针,它模拟了独占的所有权。代码清单26说明了它的基本用法。

代码清单26 一个采用unique_ptr的程序

我们动态地分配了一个Foundation,而产生的Foundation*指针被传给second_foundation的构造函数,使用大括号初始化语法❶。second_foundation的类型是unique_ptr,它只是一个包裹着动态Foundation的RAII对象。当second_foundation被析构时❷,动态Foundation就会适当地销毁。

智能指针与普通的原始指针不同,因为原始指针只是一个简单的内存地址。我们必须手动协调所有涉及该地址的内存管理。但是,智能指针可以自行处理所有这些混乱的细节。用智能指针包装动态对象,我们可以很放心,一旦不再需要这个对象,内存就会被适当地清理掉。编译器知道不再需要该对象了,因为当它超出作用域时,智能指针的析构函数会被调用。

移动语义

有时,我们想转移对象的所有权,这种情况很常见,例如使用unique_ptr时。我们不能复制unique_ptr,因为一旦某个副本被销毁,剩下的unique_ptr将持有对已删除对象的引用。与其复制对象,不如使用C++的移动(move)语义将所有权从一个指针转移到另一个指针,如代码清单27所示。

代码清单27 一个移动unique_ptr的程序

像以前一样,我们创建unique_ptr<Foundation>❶。在使用它一段时间后,我们决定将所有权转移到一个Mutant对象。move函数告诉编译器我们想转移所有权。在构造the_mule后❷,Foundation的生命周期通过其成员变量与the_mule的生命周期联系在一起。

放松并享受C++学习之旅

C++是最主要的系统编程语言。你的许多C语言知识都可以直接用到C++中,但你也将学到许多新概念。随着对C++的一些深层主题的掌握,你会发现现代C++相比C语言有许多实质性的优势。你将能够在代码中简洁地表达想法,利用令人印象深刻的stdlib在更高的抽象水平编写代码,采用模板来提高运行时的性能、强化代码复用,并依靠C++对象生命周期来管理资源。

我希望你学习完C++后将获得巨大的回报。读完本书后,我想你会同意这种看法的。