《C++ Primer》
约 42627 字大约 142 分钟
2025-06-06
《C++ Primer》 - [美] Stanley B. Lippman, [美] Josée Lajoie, [美] Barbara E. Moo
这本久负盛名的 C++经典教程,时隔八年之久,终迎来史无前例的重大升级。除令全球无数程序员从中受益,甚至为之迷醉的——C++ 大师 Stanley B. Lippman 的丰富实践经验,C++标准委员会原负责人 Josée Lajoie 对C++标准的深入理解,以及C++ 先驱 Barbara E. Moo 在 C++教学方面的真知灼见外,更是基于全新的 C++11标准进行了全面而彻底的内容更新。非常难能可贵的是,《C++ Primer 中文版(第5版)》所有示例均全部采用 C++11 标准改写,这在经典升级版中极其罕见——充分体现了 C++ 语言的重大进展及其全面实践。书中丰富的教学辅助内容、醒目的知识点提示,以及精心组织的编程示范,让这本书在 C++ 领域的权威地位更加不可动摇。无论是初学者入门,或是中、高级程序员提升,本书均为不容置疑的首选。
序
本书在讲解的时候,常常会提到“编译器会如何如何”,学习语言的一个境界是把自己想象成编译器,这种要求对于一般的程序设计语言可能太高了,但是对于学习C和C++语言是最理想的方法。
C++最大的力量不在于其抽象,恰恰在于其不抽象。
这么多的风格共存于一种语言,就是其强大抽象机制的证明。但是,在C++11以前,C++的抽象可以说存在若干缺陷,其中最严重的是缺少自动内存管理和对象级别的消息发送机制。今天看来,C++98只能说是特定历史条件造成的半成品,无论是从语言机制,还是标准库完备程度来说,可以说都存在明显的、不容忽略的缺陷。其直接后果,就是优雅性的缺失和效率的降低。
差不多十年前,我提出一个观点,每一个具体的技术领域,只需要读四五本书就够了。以前的C++是个例外,因为语言设计有缺陷,所以要读很多书才知道如何绕过缺陷。现在的C++11完全可以了,大家读四五本书就可以达到合格的水平,这恰恰是语言进步的体现。
本书是这四五本中的一本,而且是“教程+参考书”,扛梁之作,初学者的不二法门。另一本是《C++标准程序库》,对于C++熟手来说更为快捷。Scott Meyers的 Efeclive C++永远是学习C++者必读的,只不过这本书的第4版不知道什么时候出来。Anthony Williams的C++Concurrencyin Action是学习用标准C++开发并发程序的最佳选择。国内的作品,我则高度推荐陈硕的《Linux 多线程服务端编程》。这本书的名字赶跑了不少潜在的读者,所以我要特别说明一下。这本书是C++开发的高水平作品,与其说是教你怎么用C++写服务端开发,不如说是教你如何以服务端开发为例子提升C++开发水平。前面几本书都是谈标准C++自己的事情,碰到像iostream 这样失败的标准组件也不得不硬着头皮介绍。而这本书是接地气的实践结晶,告诉你面对具体问题时应怎样权衡,C++里什么好用,什么不好用,为什么,等等。
第 1 章 开始
包含来自标准库的头文件时,也应该用尖括号(<>)包围头文件名。对于不属于标准库的头文件,则用双引号("")包围。
小结
小结
本章介绍了足够多的C++语言的知识,以使你能够编译、运行简单的C++程序。我们看到了如何定义一个main函数,它是操作系统执行你的程序的调用入口。我们还看到了如何定义变量,如何进行输入输出,以及如何编写if、for和while语句。本章最后介绍了C++中最基本的特性--类。在本章中,我们看到了,对于其他人定义的一个类,我们应该如何创建、使用其对象。在后续章节中,我们将介绍如何定义自己的类。
第 I 部分 C++ 基础
第 2 章 变量和基本类型
2.1 基本内置类型
建议:如何选择类型
和C语言一样,C++的设计准则之一也是尽可能地接近硬件。C++的算术类型必须满足各种硬件特质,所以它们常常显得繁杂而令人不知所措。事实上,大多数程序员能够(也应该)对数据类型的使用做出限定从而简化选择的过程。以下是选择类型的一些经验准则: 当明确知晓数值不可能为负时,选用无符导类型。使用int执行整数运算。在实际应用中,short常常显得太小而1ong一般和int有一样的尺寸。如果你的数值超过了int的表示范围,选用1onglong。在算术表达式中不要使用char或boo1,只有在存放字符或布尔值时才使用它们。因为类型char在一些机器上是有符号的,而在另一些机器上又是无符号的所以如果使用char进行运算特别容易出问题。如果你需要使用一个不大的整数那么明确指定它的类型是signed char或者unsigned char。 执行浮点数运算选用double,这是因为f1oat通常精度不够而且双精度浮点数和单精度浮点数的计算代价相差无几。事实上,对于某些机器来说,双精度运算甚至比单精度还快。1ongdouble提供的精度在一般情况下是没有必要的,况且它带来的运行时消耗也不容忽视。
建议:避免无法预知和依赖于实现环境的行为
无法预知的行为源于编译器无须(有时是不能)检测的错误。即使代码编译通过了如果程序执行了一条未定义的表达式,仍有可能产生错误。 不幸的是,在某些情况和/或某些编译器下,含有无法预知行为的程序也能正确执行但是我们却无法保证同样一个程序在别的编译器下能正常工作,甚至已经编译通过的代码再次执行也可能会出错。此外,也不能认为这样的程序对一组输入有效,对另一组输入就一定有效。 程序也应该尽量避免依赖于实现环境的行为。如果我们把int的尺寸看成是一个确定不变的已知值,那么这样的程序就称作不可移植的(nonportable)。当程序移植到别的机器上后,依赖于实现环境的程序就可能发生错误。要从过去的代码中定位这类错误可不是一件轻松愉快的工作。
提示:切勿混用带符号类型和无符号类型
如果表达式里既有带符号类型又有无符号类型,当带符号类型取值为负时会出现异常结果,这是因为带符号数会自动地转换成无符号数。例如,在一个形如a*b的式子中,如果a--1,b=1,而且a和b都是int,则表达式的值显然为-1。然而,如果a是int,而b是unsigned,则结果须视在当前机器上int所占位数而定。在我们的环境里,结果是4294967295。
字符和字符串字面值
由单引号括起来的一个字符称为char型字面值,双引号括起来的零个或多个字符则构成字符串型字面值。
'a'//字符字面值
"Hello World!”//字符串宇面值
字符串字面值的类型实际上是由常量字符构成的数组(aray)。编译器在每个字符串的结尾处添加一个空字符("\0”),因此,字符串字面值的实际长度要比它的内容多1。例如,字面值"'表示的就是单独的字符A,而字符串"A"则代表了一个字符的数组,该数组包含两个字符:一个是字母A、另一个是空字符。如果两个字符串字面值位置紧邻且仅由空格、缩进和换行符分隔,则它们实际上是一个整体。当书写的字符串字面值比较长,写在一行里不太合适时,就可以采取分开书写的方式:
//分多行书写的字符串字面值
std::cout<<"a really,really long string literal "
"that spans two lines"<< std::endl;
2.2 变量
术语:何为对象?
C++程序员们在很多场合都会使用对象(object)这个名词。通常情况下,对象是指一块能存储数据并具有某种类型的内存空间。 一些人仅在与类有关的场景下才使用“对象”这个词。另一些人则已把命名的对象和未命名的对象区分开来,他们把命名了的对象叫做变量。还有一些人把对象和值区分开来,其中对象指能被程序修改的数据,而值(value)指只读的数据。 本书遵循大多数人的习惯用法,即认为对象是具有某种数据类型的内存空间。我们在使用对象这个词时,并不严格区分是类还是内置类型,也不区分是否命名或是否只读。
注意
初始化不是赋值,初始化的含义是创建变量时赋予其一个初始值,而赋值的含义是把对象的当前值擦除,而以一个新值来替代。
注
定义于函数体内的内置类型的对象如果没有初始化,则其值未定义。类的对象如果没有显式地初始化,则其值由类确定。
提示:未初始化变量引发运行时故障
未初始化的变量含有一个不确定的值,使用未初始化变量的值是一种错误的编程行为并且很难调试。尽管大多数编译器都能对一部分使用未初始化变量的行为提出警告,但严格来说,编译器并未被要求检查此类错误。 使用未初始化的变量将带来无法预计的后果。有时我们足够幸运,一访问此类对象程序就崩溃并报错,此时只要找到崩溃的位置就很容易发现变量没被初始化的问题。另外一些时候,程序会一直执行完并产生错误的结果。更糟糕的情况是,程序结果时对时错、无法把握。而且,往无关的位置添加代码还会导致我们误以为程序对了,其实结果仍旧有错。 建议初始化每一个内置类型的变量。虽然并非必须这么做,但如果我们不能确保初始化后程序安全,那么这么做不失为一种简单可靠的方法。
变量能且只能被定义一次,但是可以被多次声明。
声明和定义的区别看起来也许微不足道,但实际上却非常重要。如果要在多个文件中使用同一个变量,就必须将声明和定义分离。此时,变量的定义必须出现在且只能出现在个文件中,而其他用到该变量的文件必须对其进行声明,却绝对不能重复定义。
关键概念:静态类型
C++是一种静态类型(staticallytyped)语言,其含义是在编译阶段检查类型。其中检查类型的过程称为类型检查(typechecking) 我们已经知道,对象的类型决定了对象所能参与的运算。在C+语言中,编译器负贵检查数据类型是否支持要执行的运算,如果试图执行类型不支持的运算,编译器将报错并且不会生成可执行文件。 程序越复杂,静态类型检查越有助于发现问题。然而,前提是编译器必须知道每一个实体对象的类型,这就要求我们在使用某个变量之前必须声明其类型。
建议:当你第一次使用变量时再定义它
一般来说,在对象第一次被使用的地方附近定义它是一种好的选择,因为这样做有助于更容易地找到变量的定义。更重要的是,当变量的定义与它第一次被使用的地方很近时,我们也会赋给它一个比较合理的初始值。
注意
如果函数有可能用到某全局变量,则不宜再定义一个同名的局部变量
2.3 复合类型
C++11中新增了一种引用:所谓的“右值引用(rvalue reference)”,这种引用主要用于内置类。严格来说当我们使用术语“引用(reference)”时,指的其实是“左值引用(lvalue reference )"
一般在初始化变量时,初始值会被拷贝到新建的对象中。然而定义引用时,程序把引用和它的初始值绑定(bind)在一起,而不是将初始值拷贝给引用。一旦初始化完成,引用将和它的初始值对象一直绑定在一起。因为无法令引用重新绑定到另外一个对象,因此引用必须初始化。
引用并非对象,相反的,它只是为一个已经存在的对象所起的另外一个名字。
注意
指针通常难以理解,即使是有经验的程序员也常常因为调试指针引发的错误而被备受折磨。
解引用操作仅适用于那些确实指向了某个对象的有效指针
关键概念:某些符号有多重含义
像&和*这样的符号,既能用作表达式里的运算符,也能作为声明的一部分出现,符号的上下文决定了符号的意义:
int i = 42;
int &r = i; //&紧随类型名出现,因此是声明的一部分,上是一个引用
int *p; //*紧随类型名出现,因此是声明的一部分,P是一个指针
p = &i; //&出现在表达式中,是一个取地址符
*p = i; //*出现在表达式中,是一个解引用符
int &r2 = *p; //&是声明的一部分,*是一个解引用符
在声明语句中,&和*用于组成复合类型:在表达式中,它们的角色又转变成运算符。在不同场景下出现的虽然是同一个符号,但是由于含义截然不同,所以我们完全可以把它当作不同的符号来看待。
建议:初始化所有指针
使用未经初始化的指针是引发运行时错误的一大原因。 和其他变量一样,访问未经初始化的指针所引发的后果也是无法预计的。通常这一行为将造成程序崩溃,而且一旦崩溃,要想定位到出错位置将是特别棘手的问题。 在大多数编译器环境下,如果使用了未经初始化的指针,则该指针所占内存空问的当前内容将被看作一个地址值。访问该指针,相当于去访问一个本不存在的位置上的本不存在的对象。糟糕的是,如果指针所占内存空间中恰好有内容,而这些内容又被当作了某个地址,我们就很难分清它到底是合法的还是非法的了。 因此建议初始化所有的指针,并且在可能的情况下,尽量等定义了对象之后再定义指向它的指针。如果实在不清楚指针应该指向何处,就把它初始化为nullptr或者0,这样程序就能检测并知道它没有指向任何具体的对象了。
注意
很多程序员容易迷惑于基本数据类型和类型修饰符的关系,其实后者不过是声明符的一部分罢了。
注
经常有一种观点会误以为,在定义语句中,类型修饰符(*或&)作用于本次定义的全部变量。造成这种错误看法的原因有很多,其中之一是我们可以把空格写在类型修饰符和变量名中间:
int* p; //合法但是容易产生误导
我们说这种写法可能产生误导是因为int放在一起好像是这条语句中所有变量共同的类型一样。其实恰恰相反,基本数据类型是int而非int。*仅仅是修饰了p而己,对该声明语句中的其他变量,它并不产生任何作用:
int* p1,p2; //p1是指向int的指针,P2是int
涉及指针或引用的声明,一般有两种写法。第一种把修饰符和变量标识符写在一起:
int *p1,*p2; //p1和p2 都是指向 int 的指针这种形式着重强调变量具有的复合类型。第二种把修饰符和类型名写在一起,并且每条语句只定义一个变量:
这种形式着重强调本次声明定义了一种复合类型。
int* p1; // p1是指向 int 的指针
int* p2; // p2是指向int的指针
上述两种定义指针或引用的不同方法没有孰对孰错之分,关键是选择并坚持其中的一种写法,不要总是变来变去。
提示
面对一条比较复杂的指针或引用的声明语句时,从右向左阅读有助于弄清楚它的真实含义。
2.4 const 限定符
重要
因为const对象一旦创建后其值就不能再改变,所以const对象必须初始化(和引用类似)。
注
为了支持这一用法,同时避免对同一变量的重复定义,默认情况下,const对象被设定为仅在文件内有效。当多个文件中出现了同名的 const 变量时,其实等同于在不同文件中分别定义了独立的变量。
注
如果想在多个文件之间共享const对象,必须在变量的定义之前添加extern关键字。
术语:常量引用是对const的引用
C++程序员们经常把词组“对const的引用”简称为“常量引用”,这一简称还是挺靠谱的,不过前提是你得时刻记得这就是个简称而已。 严格来说,并不存在常量引用。因为引用不是一个对象,所以我们没法让引用本身恒定不变。事实上,由于C++语言并不允许随意改变引用所绑定的对象,所以从这层意义上理解所有的引用又都算是常量。引用的对象是常量还是非常量可以决定其所能参与的操作,却无论如何都不会影响到引用和对象的绑定关系本身。
提示
试试这样想吧:所谓指向常量的指针或引用,不过是指针或引用“自以为是罢了,它们觉得自己指向了常量,所以自觉地不去改变所指对象的值。
常量指针将一直指向对象,不可改变指向,可以访问对象,可以修改对象的值。 int i = 0; int *const p = &i;
指针常量
如果你需要判断,可以从右往左读,比如:
int * const p:p 是常量 → 指针常量。指针指向固定,可以改数据。
const int * p:p 指向常量 → 常量指针。数据固定,可以改指向。
int * const p 顶层const const int * p 底层const
在拷贝时,顶层const一般无所谓,但是如果要操作一个底层const,则一般也需要定义一个const
常量表达式:有const并且具体值已知
提示
一般来说,如果你认定变量是一个常量表达式,那就把它声明成constexpr类型。
2.5 处理类型
别名声明:
typedef double db;
db num = 3.14
新标准:
using db = double
db num = 3.14
注
如果 decltype 使用的是一个不加括号的变量,则得到的结果就是该变量的类型:如果给变量加上了一层或多层括号,编译器就会把它当成是一个表达式。变量是一种可以作为赋值语句左值的特殊表达式,所以这样的 decltype 就会得到引用类型:
//decltype的表达式如果是加上了括号的变量,结果将是引用
decltype((i))d; //错误:d是int&,必须初始化
decltype(i)e; //正确:e是一个(未初始化的)int
注意
切记: decltype((variable))
(注意是双层括号)的结果永远是引用,而 decltype(variable)
结果只有当 variable
本身就是一个引用时才是引用。
注意
很多新手程序员经常忘了在类定义的最后加上分号。
头文件一旦改变,相关的源文件必须重新编译以获取更新过的声明
C++程序还会用到的一项预处理功能是头文件保护符(header guard),头文件保护符依赖于预处理变量(参见2.3.2节,第48页)。预处理变量有两种状态:已定义和未定义。#define 指令把一个名字设定为预处理变量,另外两个指令则分别检查某个指定的预处理变量是否已经定义:#ifdef当且仅当变量已定义时为真,#ifndef当且仅当变量未定义时为真。一旦检查结果为真,则执行后续操作直至遇到#endif指令为止。
注意
预处理变量无视C++语言中关于作用域的规则。
整个程序中的预处理变量包括头文件保护符必须唯一,通常的做法是基于头文件中类的名字来构建保护符的名字,以确保其唯一性。为了避免与程序中的其他实体发生名字冲突,一般把预处理变量的名字全部大写。
提示
头文件即使(目前还)没有被包含在任何其他头文件中,也应该设置保护符头文件保护符很简单,程序员只要习惯性地加上就可以了,没必要太在乎你的程序到底需不需要。
小结
小结
类型是 C++编程的基础。 类型规定了其对象的存储要求和所能执行的操作。C++语言提供了一套基础内置类型,如int和char等,这些类型与实现它们的机器硬件密切相关。类型分为非常量和常量,一个常量对象必须初始化,而且一旦初始化其值就不能再改变。此外,还可以定义复合类型,如指针和引用等。复合类型的定义以其他类型为基础。 C++语言允许用户以类的形式自定义类型。C++库通过类提供了一套高级抽象类型,如输入输出和string等。
第 3 章 字符串、向量和数组
3.1 命名空间的 using 声明
3.2 标准库类型 string
C++标准一方面对库类型所提供的操作做了详细规定,另一方面也对库的实现者做出一些性能上的需求。因此,标准库类型对于一般应用场合来说有足够的效率。
我们可以清楚地看到在这些初始化方式之间到底有什么区别和联系。如果使用等号(=)初始化一个变量,实际上执行的是拷贝初始化(copy initialization),编译器把等号右侧的初始值拷贝到新创建的对象中去。与之相反,如果不使用等号,则执行的是直接初始化(direct initialization)。
触发 getline
函数返回的那个换行符实际上被丢弃掉了,得到的 string
对象中并不包含该换行符。
提示
如果一条表达式中已经有了 size()
函数就不要再使用 int
了,这样可以避免混用 int
和 unsigned
可能带来的问题。
注
当把 string
对象和字符字面值及字符串字面值混在一条语句中使用时,必须确保每个加法运算符(+)的两侧的运算对象至少有一个是 string
:
string s4 = sl + ","; //正确:把一个string对象和一个字面值相加
string s5 = "hello" + ","; //错误:两个运算对象都不是 string
//正确:每个加法运算符都有一个运算对象是string
string s6 = s1 + "," + "world";
string s7 = "hello" + "," + s2;//错误:不能把字面值直接相加
注意
因为某些历史原因,也为了与 C 兼容,所以 C++ 语言中的字符串字面值并不是标准库类型 string
的对象。切记,字符串字面值与 string
是不同的类型。
提示
建议:使用C++版本的C标准库头文件 C++标准库中除了定义C++语言特有的功能外,也兼容了语言的标准库。C语言的头文件形如 name.h,C++则将这些文件命名为cname。也就是去掉了.h后缀,而在文件名 name 之前添加了字母c,这里的c表示这是一个属于C语言标准库的头文件。
因此,cctype 头文件和 ctype.h 头文件的内容是一样的,只不过从命名规范上来讲更符合C++语言的要求。特别的,在名为 cname 的头文件中定义的名字从属于命名空间std,而定义在名为.h的头文件中的则不然。
一般来说,C++程序应该使用名为 cname 的头文件面不使用 name.h 的形式,标准库中的名字总能在命名空间std中找到。如果使用.h形式的头文件,程序员就不得不时刻牢记哪些是从C语言那儿继承过来的,哪些又是C++语言所独有的。
注
string 对象的下标必须大于等于 0 而小于 s.size() 。
使用超出此范围的下标将引发不可预知的结果,以此推断,使用下标访问空 string 也会引发不可预知的结果。
重要
C++是可以直接对string字符串做修改的,这点与python不同。
提示:注意检查下标的合法性
使用下标时必须确保其在合理范围之内,也就是说,下标必须大于等于0而小于字符串的size()的值。一种简便易行的方法是,总是设下标的类型为string::size_type,因为此类型是无符号数,可以确保下标不会小于0。此时,代码只需保证下标小于size()的值就可以了。 C++标准并不要求标准库检测下标是否合法。一旦使用了一个超出范围的下标,就会产生不可预知的结果。
3.3 标准库类型 vector
注
vector 是模板而非类型,由 vector 生成的类型必须包含vector中元素的类型,例如 vector<int>
。
注意
某些编译器可能仍需以老式的声明语句来处理元素为vector的vector对象,如 vector<vector<int> >
。
关键概念:vector对象能高效增长
C++标准要求vector应该能在运行时高效快速地添加元素。因此既然vector对象能高效地增长,那么在定义vector对象的时候设定其大小也就没什么必要了,事实上如果这么做性能可能更差。只有一种例外情况,就是所有(all)元素的值都一样。一旦元素的值有所不同,更有效的办法是先定义一个空的vector对象,再在运行时向其中添加具体值。此外,9.4节(第317页)将介绍,vector还提供了方法,允许我们进一步提升动态添加元素的性能。
开始的时候创建空的vector对象,在运行时再动态添加元素,这一做法与C语言及其他大多数语言中内置数组类型的用法不同。特别是如果用惯了C或者Java,可以预计在创建vector对象时顺便指定其容量是最好的。然而事实上,通常的情况是恰恰相反。
注意
范围for语句体内不应改变其所遍历序列的大小。
注
要使用size type,需首先指定它是由哪种类型定义的。vector 对象的类型总是包含着元素的类型(参见33节,第87页):
vector<int>::size type // 正确
vector::size type // 错误
注意
vector 对象(以及string对象)的下标运算符可用于访问已存在的元素,而不能用于添加元素。
提示:只能对确知已存在的元素执行下标操作!
关于下标必须明确的一点是:只能对确知已存在的元素执行下标操作。例如,
vector<int> ivec; //空vector对象
cout << ivec[0]; //错误:ivec 不包含任何元素
vector<int> ivec2(10); //含有10个元素的vector对象
cout << ivec2[10]; //错误:ivec2元素的合法索引是从0到9
试图用下标的形式去访问一个不存在的元素将引发错误,不过这种错误不会被编译器发现,而是在运行时产生一个不可预知的值。
不幸的是,这种通过下标访问不存在的元素的行为非常常见,而且会产生很严重的后果。所谓的缓冲区溢出(bufer overflow)指的就是这类错误,这也是导致PC及其他设备上应用程序出现安全问题的一个重要原因。
确保下标合法的一种有效手段就是尽可能使用范围for语句。
3.4 迭代器介绍
注
如果容器为空,则begin和end返回的是同一个迭代器,都是尾后迭代器。
注
因为 end 返回的迭代器并不实际指示某个元素,所以不能对其进行递增或解引用的操作。
关键概念:泛型编程
原来使用C或Java的程序员在转而使用++语言之后,会对for循环中使用!-而非<进行判断有点儿奇怪,比如上面的这个程序以及85页的那个。C++程序员习惯性地使用!-,其原因和他们更愿意使用迭代器而非下标的原因一样:因为这种编程风格在标准库提供的所有容器上都有效。
之前已经说过,只有string和vector等一些标准库类型有下标运算符,而并非全都如此。与之类似,所有标准库容器的选代器都定义了--和!,但是它们中的大多数都没有定义<运算符。因此,只要我们养成使用选代器和!-的习惯,就不用太在意用的到底是哪种容器类型。
术语:迭代器和迭代器类型
迭代器这个名词有三种不同的含义:可能是选代器概念本身,也可能是指容器定义的选代器类型,还可能是指某个选代器对象。 重点是理解存在一组概念上相关的类型,我们认定某个类型是迭代器当且仅当它支持一套操作,这套操作使得我们能访问容器的元素或者从某个元素移动到另外一个元素。每个容器类定义了一个名为iterator的类型,该类型支持选代器概念所规定的一套操作。
术语:迭代器和迭代器类型
注意,(*it).empty()中的圆括号必不可少,具体原因将在4.1.2节(第121页)介绍,该表达式的含义是先对it解引用,然后解引用的结果再执行点运算符(参见1.5.2节,第20页)。如果不加圆括号,点运算符将由it来执行,而非it解引用的结果:
(*it).empty() //解引用it,然后调用结果对象的empty成员
*it.empty() //错误:试图访问it的名为empty的成员,但it是个迭代器,没有empty成员
上面第二个表达式的含义是从名为it的对象中寻找其empty成员,显然 it是一个迭代器,它没有哪个成员是叫empty的,所以第二个表达式将发生错误。 为了简化上述表达式,C++语言定义了箭头运算符(->)。箭头运算符把解引用和成员访问两个操作结合在一起,也就是说,it->mem和(*it).mem表达的意思相同。
注意
谨记,但凡是使用了迭代器的循环体,都不要向迭代器所属的容器添加元素。
注
只要两个迭代器指向的是同一个容器中的元素或者尾元素的下一位置,就能将其相减,所得结果是两个迭代器的距离。所谓距离指的是右侧的迭代器向前移动多少位置就能追上左侧的迭代器,其类型是名为 difference_type 的带符号整型数。string和vector 都定义了 difference_type,因为这个距离可正可负,所以 difference_type 是带符号类型的。
3.5 数组
提示
如果不清楚元素的确切个数,请使用vector。
注意
和内置类型的变量一样,如果在函数内部定义了某种内置类型的数组,那么默认初始化会令数组含有未定义的值。
定义数组的时候必须指定数组的类型,不允许用auto关键字由初始值的列表推断类型。另外和vector一样,数组的元素应为对象,因此不存在引用的数组。
注
const char a4[6]="Daniel"; //错误:没有空间可存放空字符!
尽管字符串字面值"Daniel"看起来只有6个字符,但是数组的大小必须至少是7,其中6个位置存放字面值的内容,另外1个存放结尾处的空字符。
注意
不能将数组的内容拷贝给其他数组作为其初始值,也不能用数组为其他数组赋值。一些编译器支持数组的赋值,这就是所谓的编译器扩展(compiler extension)。但一般来说,最好避免使用非标准特性,因为含有非标准特性的程序很可能在其他编译器上无法正常工作。
提示
要想理解数组声明的含义,最好的办法是从数组的名字开始按照由内向外的顺序阅读。
注意
大多数常见的安全问题都源于缓冲区溢出错误。当数组或其他类似数据结构的下标越界并试图访问非法内存区域时,就会产生此类错误。
在大多数表达式中,使用数组类型的对象其实是使用一个指向该数组首元素的指针。
一个指针如果指向了某种内置类型数组的尾元素的“下一位置”,则其具备与vector的end函数返回的与选代器类似的功能。特别要注意,尾后指针不能执行解引用和递增操作。
注
auto n = end(arr) - begin(arr); //n的值是5,也就是arr中元素的数量
两个指针相减的结果的类型是一种名为 ptrdiff_t
的标准库类型,和 size_t
一样, ptrdiff_t
也是一种定义在 cstddef
头文件中的机器相关的类型。因为差值可能为负值,所以 ptrdiff_t
是一种带符号类型。
注意
内置的下标运算符所用的索引值不是无符号类型,这一点与vector和string不一样。数组下标可以为负数。
注意
尽管C++支持C风格字符串,但在C++程序中最好还是不要使用它们。这是因为C风格字符串不仅使用起来不太方便,而且极易引发程序漏洞,是诸多安全问题的根本原因。
提示
对大多数应用来说,使用标准库 string 要比使用C风格字符串更安全、更高效。
注意
如果执行完 c_str()
函数后程序想一直都能使用其返回的数组,最好将该数组重新拷贝一份。
建议:尽量使用标准库类型而非数组
使用指针和数组很容易出错。一部分原因是概念上的问题:指针常用于底层操作因此容易引发一些与烦琐细节有关的错误。其他问题则源于语法错误,特别是声明指针时的语法错误。
现代的C++程序应当尽量使用vector和选代器,避免使用内置数组和指针;应该尽量使用string,避免使用C风格的基于数组的字符串。
3.6 多维数组
注
要使用范围for语句处理多维数组,除了最内层的循环外,其他所有循环的控制变量都应该是引用类型。
注
定义指向多维数组的指针时,千万别忘了这个多维数组实际上是数组的数组。
注
在上述声明中,圆括号必不可少:
int *ip[4]; //整型指针的数组
int (*ip)[4]; //指向含有4个整数的数组
小结
小结
string和vector是两种最重要的标准库类型。string对象是一个可变长的字符序列,vector对象是一组同类型对象的容器。 迭代器允许对容器中的对象进行间接访问,对于string对象和vector对象来说可以通过迭代器访问元素或者在元素间移动。 数组和指向数组元素的指针在一个较低的层次上实现了与标准库类型string和vector类似的功能。一般来说,应该优先选用标准库提供的类型,之后再考虑C++语言内置的低层的替代品数组或指针。
第 4 章 表达式
4.1 基础
建议:处理复合表达式
以下两条经验准则对书写复合表达式有益:
拿不准的时候最好用括号来强制让表达式的组合关系符合程序逻辑的要求。
如果改变了某个运算对象的值,在表达式的其他地方不要再使用这个运算对象。
第2条规则有一个重要例外,当改变运算对象的子表达式本身就是另外一个子表达式的运算对象时该规则无效。例如,在表达式*++iter中,递增运算符改变iter的值,iter(已经改变)的值又是解引用运算符的运算对象。此时(或类似的情况下),求值的顺序不会成为问题,因为递增运算(即改变运算对象的子表达式)必须先求值,然后才轮到解引用运算。显然,这是一种很常见的用法,不会造成什么问题。
4.2 算术运算符
提示:溢出和其他算术运算异常
算术表达式有可能产生未定义的结果。一部分原因是数学性质本身:例如除数是0的情况;另外一部分则源于计算机的特点:例如溢出,当计算的结果超出该类型所能表示的范围时就会产生溢出。 假设某个机器的short类型占16位,则最大的short数值是32767。在这样一台机器上,下面的复合赋值语句将产生溢出:
shortshortvalue=32767; //如果short类型占16位,则能表示的最大值是32767
short value +=1; // 该计算导致溢出
cout<"short value:"<< short value << endl;
给short value 赋值的语句是未定义的,这是因为表示一个带符号数32768 需要17位但是short类型只有16位。很多系统在编译和运行时都不报溢出错误,像其他未定义的行为一样,溢出的结果是不可预知的。在我们的系统中,程序的输出结果是:
short value:-32768
该值发生了“环绕(wrappedaround)”,符号位本来是0,由于溢出被改成了1,于是结果变成一个负值。在别的系统中也许会有其他结果,程序的行为可能不同甚至直接崩溃。
4.3 逻辑和关系运算符
短路求值(short-circuit evaluation)
- 对于逻辑与运算符来说,当且仅当左侧运算对象为真时才对右侧运算对象求值。
- 对于逻辑或运算符来说,当且仅当左侧运算对象为假时才对右侧运算对象求值。
注意
进行比较运算时除非比较的对象是布尔类型,否则不要使用布尔字面值true和 false 作为运算对象。
4.4 赋值运算符
因为赋值运算符的优先级低于关系运算符的优先级,所以在条件语句中,赋值
部分通常应该加上括号。
4.5 递增和递减运算符
建议:除非必须,否则不用递增递减运算符的后置版本
有C语言背景的读者可能对优先使用前置版本递增运算符有所疑问,其实原因非常简单:前置版本的递增运算符避免了不必要的工作,它把值加1后直接返回改变了的运算对象。与之相比,后置版本需要将原始值存储下来以便于返回这个未修改的内容。如果我们不需要修改前的值,那么后置版本的操作就是一种浪费。 对于整数和指针类型来说,编译器可能对这种额外的工作进行一定的优化;但是对于相对复杂的选代器类型,这种额外的工作就消耗巨大了。建议养成使用前置版本的习惯,这样不仅不需要担心性能的问题,而且更重要的是写出的代码会更符合编程的初衷。
建议:简洁可以成为一种美德
形如*pbeg++的表达式一开始可能不太容易理解,但其实这是一种被广泛使用的有效的写法。当对这种形式熟悉之后,书写
cout << *iter++ << endl;
要比书写下面的等价语句更简洁、也更少出错
cout << *iter << endl;
++iter;
不断研究这样的例子直到对它们的含义一目了然。大多数C++程序追求简洁、摒弃冗长因此C++程序员应该习惯于这种写法。而且,一旦熟练掌握了这种写法后,程序出错的可能性也会降低。
4.6 成员访问运算符
点运算符和箭头运算符都可用于访问成员,其中,点运算符获取类对象的一个成员;箭头运算符与点运算符有关,表达式 ptr->mem 等价于 (*ptr).mem 。
4.7 条件运算符
注意
有一种常见的错误是把位运算符和逻辑运算符搞混了,比如位与(&)和逻辑与(&&)、位或(1)和逻辑或(11)、位求反()和逻辑非(!)。
4.9 sizeof 运算符
sizeof 运算符返回一条表达式或一个类型名字所占的字节数。sizeof 运算符满足右结合律,其所得的值是一个sizet类型的常量表达式。
sizeof返回的是表达式结果类型的大小。与众不同的一点是,sizeof并不实际计算其运算对象的值:
4.10 逗号运算符
逗号运算符(comma operator)含有两个运算对象,按照从左向右的顺序依次求值。和逻辑与、逻辑或以及条件运算符一样,逗号运算符也规定了运算对象求值的顺序。
对于逗号运算符来说,首先对左侧的表达式求值,然后将求值结果丢弃掉。逗号运算符真正的结果是右侧表达式的值。如果右侧运算对象是左值,那么最终的求值结果也是左值。
4.11 类型转换
注意
虽然有时不得不使用强制类型转换,但这种方法本质上是非常危险的。
建议:避免强制类型转换
强制类型转换干扰了正常的类型检查(参见2.2.2节,第42页),因此我们强烈建议程序员避免使用强制类型转换。这个建议对于reinterpretcast尤其适用,因为此类类型转换总是充满了风险。在有重载函数的上下文中使用constcast无可厚非,关于这一点将在6.4节(第208页)中详细介绍;但是在其他情况下使用constcast也就意味着程序存在某种设计缺陷。其他强制类型转换,比如staticcast和dynamic cast,都不应该频繁使用。每次书写了一条强制类型转换语句,都应该反复斟酌能否以其他方式实现相同的目标。就算实在无法避免,也应该尽量限制类型转换值的作用域,并且记录对相关类型的所有假定,这样可以减少错误发生的机会。
注意
与命名的强制类型转换相比,旧式的强制类型转换从表现形式上来说不那么清晰明了,容易被看漏,所以一旦转换过程出现问题,追踪起来也更加困难。
4.12 运算符优先级表
小结
小结
C++语言提供了一套丰富的运算符,并定义了这些运算符作用于内置类型的运算对象时所执行的操作。此外,C++语言还支持运算符重载的机制,允许我们自己定义运算符作用于类类型时的含义。第14章将介绍如何定义作用于用户类型的运算符。 对于含有超过一个运算符的表达式,要想理解其含义关键要理解优先级、结合律和求值顺序。每个运算符都有其对应的优先级和结合律,优先级规定了复合表达式中运算符组合的方式,结合律则说明当运算符的优先级一样时应该如何组合。 大多数运算符并不明确规定运算对象的求值顺序:编译器有权自由选择先对左侧运算对象求值还是先对右侧运算对象求值。一般来说,运算对象的求值顺序对表达式的最终结果没有影响。但是,如果两个运算对象指向同一个对象而且其中一个改变了对象的值,就会导致程序出现不易发现的严重缺陷。 最后一点,运算对象经常从原始类型自动转换成某种关联的类型。例如,表达式中的小整型会自动提升成大整型。不论内置类型还是类类型都涉及类型转换的问题。如果需要,我们还可以显式地进行强制类型转换。
第 5 章 语句
5.1 简单语句
使用空语句时应该加上注释,从而令读这段代码的人知道该语句是有意省略的。
注意
多余的空语句并非总是无害的。
块不以分号作为结束。
5.2 语句作用域
可以在 if、switch、while和for 语句的控制结构内定义变量。定义在控制结构当中的变量只在相应语句的内部可见,一旦语句结束,变量也就超出其作用范围了。
5.3 条件语句
提示
许多编辑器和开发环境都提供一种辅助工具,它可以自动地缩进代码以匹配其语法结构。善用此类工具益处多多。
提示
case关键字和它对应的值一起被称为case标签(case label)。case 标签必须是整型常量表达式。
提示
一般不要省略case分支最后的break语句。如果没写break语句,最好加段注释说清楚程序的逻辑。
提示
尽管 switch 语句不是非得在最后一个标签后面写上 break,但是为了安全起见,最好这么做。因为这样的话,即使以后再增加新的case分支,也不用再在前面补充 break语句了。
提示
即使不准备在 default标签下做任何工作,定义一个 default 标签也是有用的。其目的在于告诉程序的读者,我们已经考虑到了默认的情况,只是目前什么也没做。
5.4 迭代语句
定义在while条件部分或者while循环体内的变量每次选代都经历从创建到销毁的过程。
牢记 for 语句头中定义的对象只在for循环体内可见。因此在上面的例子中for循环结束后index就不可用了。
注
vector<int> v={0,1,2,3,4,5,6, 7, 8, 9};
//范围变量必须是引用类型,这样才能对元素执行写操作
for (auto &r : v) //对于v中的每一个元素
r *= 2; //将v中每个元素的值翻倍
do while语句应该在括号包围起来的条件后面用一个分号表示语句结束。
5.5 跳转语句
提示
不要在程序中使用goto语句,因为它使得程序既难理解又难修改
5.6 try 语句块和异常处理
提示:编写异常安全的代码非常困难
要好好理解这句话:异常中断了程序的正常流程。异常发生时,调用者请求的一部分计算可能已经完成了,另一部分则尚未完成。通常情况下,略过部分程序意味着某些对象处理到一半就夏然而止,从而导致对象处于无效或未完成的状态,或者资源没有正常释放,等等。那些在异常发生期间正确执行了“清理”工作的程序被称作异常安全(exception safe)的代码。然而经验表明,编写异常安全的代码非常困难,这部分知识也(远远)超出了本书的范围。
对于一些程序来说,当异常发生时只是简单地终止程序。此时,我们不怎么需要担心异常安全的问题。
但是对于那些确实要处理异常并继续执行的程序,就要加倍注意了。我们必须时刻清楚异常何时发生,异常发生后程序应如何确保对象有效、资源无泄漏、程序处于合理状态,等等。
我们会在本书中介绍一些比较常规的提升异常安全性的技术。但是读者需要注意,如果你的程序要求非常鲁棒的异常处理,那么仅有我们介绍的这些技术恐怕还是不够的。
小结
小结
C++语言仅提供了有限的语句类型,它们中的大多数会影响程序的控制流程:
- while、for 和 do while 语句,执行迭代操作。
- if和switch语句,提供条件分支结构。
- continue语句,终止循环的当前一次迭代。
- break语句,退出循环或者switch语句。。goto语句,将控制权转移到一条带标签的语句。。try和catch,将一段可能抛出异常的语句序列括在花括号里构成try语句块catch子句负责处理代码抛出的异常。
- throw表达式语句,存在于代码块中,将控制权转移到相关的catch子句。
- return语句,终止函数的执行。我们将在第6章介绍return语。 除此之外还有表达式语句和声明语句。表达式语句用于求解表达式,关于变量的声明和定义在第2章已经介绍过了。
第 6 章 函数
6.1 函数基础
含有函数声明的头文件应该被包含到定义函数的源文件中。
6.2 参数传递
形参初始化的机理与变量初始化一样。
提示
熟悉C的程序员常常使用指针类型的形参访问函数外部的对象。在C+语言中,建议使用引用类型的形参替代指针。
拷贝大的类类型对象或者容器对象比较低效,甚至有的类类型(包括I类型在内)根本就不支持拷贝操作。当某种类型不支持拷贝操作时,函数只能通过引用形参访问该类型的对象。
提示
如果函数无须改变引用形参的值,最好将其声明为常量引用。
提示
一个函数只能返回一个值,然而有时函数需要同时返回多个值,引用形参为我们一次返回多个结果提供了有效的途径。该如何定义函数使得它能够既返回位置也返回出现次数呢?一种方法是定义一个新的数据类型,让它包含位置和数量两个成员。还有另一种更简单的方法,我们可以给函数传入一个额外的引用实参,令其保存字符出现的次数。
注意
和其他使用数组的代码一样,以数组作为形参的函数也必须确保使用数组时不会越界。
注
&arr两端的括号必不可少:
f(int &arr[10])// 错误:将arr声明成了引用的数组
f(int (&arr)[10])//正确:arr是具有10个整数的整型数组的引用
注
再一次强调,*matrix两端的括号必不可少:
int *matrix[10]; //10个指针构成的数组
int (*matrix)[10]; //指向含有10个整数的数组的指针
注意
当使用argv中的实参时,一定要记得可选的实参从argv[1]开始;argv[0]保存程序的名字,而非用户输入。
注意
省略符形参应该仅仅用于C和C++通用的类型。特别应该注意的是,大多数类类型的对象在传递给省略符形参时都无法正确拷贝。
6.3 返回类型和 return 语句
注意
在含有return语句的循环后面应该也有一条return语句,如果没有的话该程序就是错误的。很多编译器都无法发现此类错误。
提示
要想确保返回值安全,我们不妨提问:引用所引的是在函数之前已经存在的哪个对象?
如前所述,返回局部对象的引用是错误的;同样,返回局部对象的指针也是错误的。一旦函数完成,局部对象被释放,指针将指向一个不存在的对象。
main 函数不能调用它自己。
6.4 函数重载
main 函数不能重载。
注意
一般来说,将函数声明置于局部作用域内不是一个明智的选择。但是为了说明作用域和重载的相互关系,我们将暂时违反这一原则而使用局部函数声明。
在C+语言中,名字查找发生在类型检查之前。
我们可以为一个或多个形参定义默认值,不过需要注意的是,一旦某个形参被赋予了默认值,它后面的所有形参都必须有默认值。
提示
当设计含有默认实参的函数时,其中一项任务是合理设置形参的顺序,尽量让不怎么使用默认值的形参出现在前面,而让那些经常使用默认值的形参出现在后面。
6.5 特殊用途语言特性
提示
通常,应该在函数声明中指定默认实参,并将该声明放在合适的头文件中。
内联说明只是向编译器发出的一个请求,编译器可以选择忽略这个请求。
constexpr函数不一定返回常量表达式。
6.6 函数匹配
如果函数含有默认实参(参见6.5.1节,第211页),则我们在调用该函数时传入的实参数量可能少于它实际使用的实参数量。
如果没找到可行函数,编译器将报告无匹配函数的错误。
调用重载函数时应尽量避免强制类型转换。如果在实际应用中确实需要强制类型转换,则说明我们设计的形参集合不合理。
注意
内置类型的提升和转换可能在函数匹配时产生意想不到的结果,但幸运的是在设计良好的系统中函数很少会含有与下面例子类似的形参。
6.7 函数指针
注
*pf 两端的括号必不可少。如果不写这对括号,则pf是一个返回值为 bool指针的函数:
//声明一个名为pf的函数,该函数返回 bool*
bool*pf(const string&,const string );
小结
小结
函数是命名了的计算单元,它对程序(哪怕是不大的程序)的结构化至关重要。每个函数都包含返回类型、名字、(可能为空的)形参列表以及函数体。函数体是一个块,当函数被调用的时候执行该块的内容。此时,传递给函数的实参类型必须与对应的形参类型相容。 在C+语言中,函数可以被重载:同一个名字可用于定义多个函数,只要这些函数的形参数量或形参类型不同就行。根据调用时所使用的实参,编译器可以自动地选定被调用的函数。从一组重载函数中选取最佳函数的过程称为函数匹配。
第 7 章 类
7.1 定义抽象数据类型
定义在类内部的函数是隐式的 inline 函数。
常量对象,以及常量对象的引用或指针都只能调用常量成员函数。
一般来说,如果非成员函数是类接口的组成部分,则这些函数的声明应该与类在同一个头文件内。
只有当类没有声明任何构造函数时,编译器才会自动地生成默认构造函数。
注意
如果类包含有内置类型或者复合类型的成员,则只有当这些成员全都被赋予了类内的初始值时,这个类才适合于使用合成的默认构造函数。
注意
上面的默认构造函数之所以对 Sales data有效,是因为我们为内置类型的数据成员提供了初始值。如果你的编译器不支持类内初始值,那么你的默认构造函数就应该使用构造函数初始值列表(马上就会介绍)来初始化类的每个成员。
提示
构造函数不应该轻易覆盖掉类内的初始值,除非新赋的值与原值不同。如果你不能使用类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员。
7.2 访问控制与封装
注意
使用class和struct定义类唯一的区别就是默认的访问权限,class默认是private,struct默认是public。
注意
一般来说,最好在类定义开始或结束前的位置集中声明友元。
关键概念:封装的益处
封装有两个重要的优点:
- 确保用户代码不会无意间破坏封装对象的状态,
- 被封装的类的具体实现细节可以随时改变,而无须调整用户级别的代码。 一旦把数据成员定义成private的,类的作者就可以比较自由地修改数据了。当实现部分改变时,我们只需要检查类的代码本身以确认这次改变有什么影响;换句话说,只要类的接口不变,用户代码就无须改变。如果数据是public的,则所有使用了原来数据成员的代码都可能失效,这时我们必须定位并重写所有依赖于老版本实现的代码,之后才能重新使用该程序。
把数据成员的访问权限设成private还有另外一个好处,这么做能防止由于用户的原因造成数据被破坏。如果我们发现有程序缺陷破坏了对象的状态,则可以在有限的范围内定位缺陷:因为只有实现部分的代码可能产生这样的错误。因此,将查错限制在有限范围内将能极大地降低维护代码及修正程序错误的难度。
尽管当类的定义发生改变时无须更改用户代码,但是使用了该类的源文件必须重新编译。
许多编译器并未强制限定友元函数必须在使用之前在类的外部声明。
为了使友元对类的用户可见,我们通常把友元的声明与类本身放置在同一个头文件中(类的外部)。因此,我们的Sales data头文件应该为read、print 和 add 提供独立的声明(除了类内部的友元声明之外)。
7.3 类的其他特性
和我们在头文件中定义inline 函数的原因一样(参见6.5.2节,第214页),inline 成员函数也应该与相应的类定义在同一个头文件中。
当我们提供一个类内初始值时,必须以符号=或者花括号表示。
一个 const 成员函数如果以引用的形式返回*this,那么它的返回类型将是常量引用。
即使两个类的成员列表完全一致,它们也是不同的类型。对于一个类来说,它的成员和其他任何类(或者任何其他作用域)的成员都不是一回事儿。
注意
必须要注意的一点是,友元关系不存在传递性。也就是说,如果window_mgr有它自己的友元,则这些友元并不能理所当然地具有访问Screen的特权。
每个类负责控制自己的友元类或友元函数。
7.4 类的作用域
编译器处理完类中的全部声明后才会处理成员函数的定义。
类型名的定义通常出现在类的开始处,这样就能确保所有使用该类型的成员都出现在类名的定义之后。
尽管类的成员被隐藏了,但我们仍然可以通过加上类的名字或显式地使用 this 指针来强制访问成员。
尽管外层的对象被隐藏掉了,但我们仍然可以用作用城运算符访问它。
7.5 构造函数再探
如果成员是const、引用,或者属于某种未提供默认构造函数的类类型,我们必须通过构造函数初始值列表为这些成员提供初值。
建议:使用构造雨数初始值
在很多类中,初始化和赋值的区别事关底层效率问题:前者直接初始化数据成员,后者则先初始化再赋值。
除了效率问题外更重要的是,一些数据成员必须被初始化。建议读者养成使用构造函数初始值的习惯,这样能避免某些意想不到的编译错误,特别是遇到有的类含有需要构造函数初始值的成员时。
提示
最好令构造函数初始值的顺序与成员声明的顺序保持一致。而且如果可能的话,尽量避免使用某些成员初始化其他成员。
提示
如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数。
注意
对于C+十的新手程序员来说有一种常犯的错误,它们试图以如下的形式声明一 个用默认构造函数初始化的对象:
Sales_data obj (): //错误:声明了一个函数而非对象
Sales_data obj2; //正确:obj2是一个对象而非函数
注
能通过一个实参调用的构造函数定义了一条从构造函数的参数类型向类类型隐式转换的规则。
7.6 类的静态成员
注
和类的所有成员一样,当我们指向类外部的静态成员时,必须指明成员所属的类名。static关键字则只出现在类内部的声明语句中。
注
要想确保对象只定义一次,最好的办法是把静态数据成员的定义与其他非内联函数的定义放在同一个文件中。
注
即使一个常量静态数据成员在类内部被初始化了,通常情况下也应该在类的外部定义一下该成员。
小结
小结
类是C++语言中最基本的特性。类允许我们为自己的应用定义新类型,从而使得程序更加简洁且易于修改。 类有两项基本能力:一是数据抽象,即定义数据成员和函数成员的能力;二是封装,即保护类的成员不被随意访问的能力。通过将类的实现细节设为private,我们就能完成类的封装。类可以将其他类或者函数设为友元,这样它们就能访问类的非公有成员了。 类可以定义一种特殊的成员函数:构造函数,其作用是控制初始化对象的方式。构造函数可以重载,构造函数应该使用构造函数初始值列表来初始化所有数据成员 类还能定义可变或者静态成员。一个可变成员永远都不会是const,即使在const成员函数内也能修改它的值:一个静态成员可以是函数也可以是数据,静态成员存在于所有对象之外。
第 II 部分 C++ 标准库
第 8 章 IO 库
8.1 IO 类
注
本节剩下部分所介绍的标准库流特性都可以无差别地应用于普通流、文件流和string流,以及char或宽字符流版本。
注
一个流一旦发生错误,其上后续的IO操作都会失败。只有当一个流处于无错状态时我们才可以从它读取数据,向它写入数据。由于流可能处于错误状态,因此代码通常应该在使用一个流之前检査它是否处于良好状态。确定一个流对象的状态的最简单的方法是将它当作一个条件来使用:
while (cin >> word)
// ok:读操作成功…
while 循环检查>>表达式返回的流的状态。如果输入操作成功,流保持有效状态,则条件为真。
注
交互式系统通常应该关联输入流和输出流。这意味着所有输出,包括用户提示信息,都会在读操作之前被打印出来。
8.2 文件输入输出
注
当一个fstream对象被销毁时,close会自动被调用。
注
保留被 ofstream 打开的文件中已有数据的唯一方法是显式指定 app或in模式。
注
在每次打开文件时,都要设置文件模式,可能是显式地设置,也可能是隐式地设置。当程序未指定模式时,就使用默认值。
8.3 string 流
小结
小结
C++使用标准库类来处理面向流的输入和输出:
- iostream 处理控制台IO
- fstream处理命名文件IO
- stringstream完成内存string的IO 类fstream和stringstream都是继承自类iostream 的。输入类都继承自istream,输出类都继承自ostream。因此,可以在istream 对象上执行的操作,也可在 ifstream或istringstream 对象上执行。继承自 ostream 的输出类也有类似情况。 每个I〇对象都维护一组条件状态,用来指出此对象上是否可以进行I0操作。如果遇到了错误--例如在输入流上遇到了文件末尾,则对象的状态变为失效,所有后续输入操作都不能执行,直至错误被纠正。标准库提供了一组函数,用来设置和检测这些状态。
第 9 章 顺序容器
9.1 顺序容器概述
注
新标准库的容器比旧版本快得多,原因我们将在13.6节(第470页)解释。新标准库容器的性能几乎肯定与最精心优化过的同类数据结构一样好(通常会更好)。现代C++程序应该使用标准库容器,而不是更原始的数据结构,如内置数组。
注
通常,使用vector是最好的选择,除非你有很好的理由选择其他容器。
提示
如果你不确定应该使用哪种容器,那么可以在程序中只使用 vector 和 list 公共的操作:使用选代器,不使用下标操作,避免随机访问。这样,在必要时选择使用 vector 或 list 都很方便。
9.2 容器库概览
注
较旧的编译器可能需要在两个尖括号之间键入空格,例如 vector<vector<string> >
。
迭代器范围的概念是标准库的基础。
对构成范围的迭代器的要求
如果满足如下条件,两个选代器begin和end构成一个选代器范:
- 它们指向同一个容器中的元素,或者是容器最后一个元素之后的位置,且
- 我们可以通过反复递增begin来到达end。换句话说,end不在begin之前。
注意
编译器不会强制这些要求。确保程序符合这些约定是程序员的责任
注
标准库使用左闭合范围是因为这种范围有三种方便的性质。假定begin和end构成一个合法的迭代器范围,则
- 如果begin与end 相等,则范围为空
- 如果 begin 与end 不等,则范围至少包含一个元素,且 begin 指向该范围中的第一个元素
- 我们可以对 begin 递增若干次,使得 begin==end
注
当不需要写访问时,应使用cbegin和cend。
注
当将一个容器初始化为另一个容器的拷贝时,两个容器的容器类型和元素类型都必须相同。
注
只有顺序容器的构造函数才接受大小参数,关联容器并不支持。
注意
由于右边运算对象的大小可能与左边运算对象的大小不同,因此 array类型不支持 assign,也不允许用花括号包围的值列表进行赋值。
注意
由于其旧元素被替换,因此传递给assign的选代器不能指向调用assign的 容器。
除array外,swap不对任何元素进行拷贝、删除或插入操作,因此可以保证在常数时间内完成。
在新标准库中,容器既提供成员函数版本的swap,也提供非成员版本的swap。而早期标准库版本只提供成员函数版本的swap。非成员版本的swap在泛型编程中是非常重要的。统一使用非成员版本的swap是一个好习惯。
只有当其元素类型也定义了相应的比较运算符时,我们才可以使用关系运算符来比较两个容器。
9.3 顺序容器操作
向一个vector、string或 deque 插入元素会使所有指向容器的选代器引用和指针失效。
关键概念:容器元素是拷贝
当我们用一个对象来初始化容器时,或将一个对象插入到容器中时,实际上放入到容器中的是对象值的一个拷贝,而不是对象本身。就像我们将一个对象传递给非引用参数(参见3.2.2节,第79页)一样,容器中的元素与提供值的对象之间没有任何关联。随后对容器中元素的任何改变都不会影响到原始对象,反之亦然。
注意
将元素插入到vector、deque和string中的任何位置都是合法的。然而这样做可能很耗时。
理解这个循环是如何工作的非常重要,特别是理解这个循环为什么等价于调用push front 尤为重要。
注
//在c的末尾构造一个Sales data对象
c.emplace back("978-0590353403",25,15.99); //使用三个参数的 Sales data构造函数
c.push back("978-0590353403",25,15.99); //错误:没有接受三个参数的push back版本
c.push back(sales data("978-0590353403",25,15.99)); //正确:创建一个临时的Sales data对象传递给push back
emplace 函数在容器中直接构造元素。传递给emplace函数的参数必须与元素类型的构造函数相匹配。
注意
删除 deque 中除首尾位置之外的任何元素都会使所有迭代器、引用和指针失效。指向vector或string中删除点之后位置的选代器、引用和指针都会失效。
注意
删除元素的成员函数并不检查其参数。在删除元素之前,程序员必须确保它(们)是存在的。
注意
如果resize缩小容器,则指向被删除元素的选代器、引用和指针都会失效;对vector、string或degue进行resize可能导致选代器、指针和引用失效。
注意
使用失效的迭代器、指针或引用是严重的运行时错误。
建议:管理迭代器
当你使用选代器(或指向容器元素的引用或指针)时,最小化要求选代器必须保持有效的程序片段是一个好的方法。
由于向选代器添加元素和从迭代器删除元素的代码可能会使选代器失效,因此必须保证每次改变容器的操作之后都正确地重新定位选代器。这个建议对vector、string和 deque 尤为重要。
注
在调用erase后,不必递增迭代器,因为erase返回的迭代器已经指向序列中下一个元素。调用insert后,需要递增迭代器两次。记住,insert在给定位置之前插入新元素,然后返回指向新插入元素的迭代器。因此,在调用insert后,iter指向新插入元素,位于我们正在处理的元素之前。我们将迭代器递增两次,恰好越过了新添加的元素和正在处理的元素,指向下一个未处理的元素。
提示
如果在一个循环中插入/删除deque、string或vector 中的元素,不要缓存 end 返回的迭代器。
9.4 vector 对象是如何增长的
提示
reserve并不改变容器中元素的数量,它仅影响vector预先分配多大的内存空间。
每个vector实现都可以选择自己的内存分配策略。但是必须遵守的一条原则是:只有当迫不得已时才可以分配新的内存空间。
9.5 额外的 string 操作
string 搜索函数返回string::sizetype值,该类型是一个unsigned类型。因此,用一个 int或其他带符号类型来保存这些函数的返回值不是一个好主意(参见2.1.2节,第33页)
如果string不能转换为一个数值,这些函数抛出一个invalid_argument异常(参见5.6节,第173页)。如果转换得到的数值无法用任何类型来表示,则抛出一个out_of_range异常。
9.6 容器适配器
除了顺序容器外,标准库还定义了三个顺序容器适配器:stack、queue和priority_queue。适配器(adaptor)是标准库中的一个通用概念。容器、迭代器和函数都有适配器。本质上,一个适配器是一种机制,能使某种事物的行为看起来像另外一种事物一样。一个容器适配器接受一种已有的容器类型,使其行为看起来像一种不同的类型。例如,stack适配器接受一个顺序容器(除array或forward_list外),并使其操作起来像一个stack一样。表9.17列出了所有容器适配器都支持的操作和类型。
小结
小结
标准库容器是模板类型,用来保存给定类型的对象。在一个顺容器中,元素是按顺序存放的,通过位置来访问。顺序容器有公共的标准接口:如果两个顺序容器都提供一个特定的操作,那么这个操作在两个容器中具有相同的接口和含义。 所有容器(除array外)都提供高效的动态内存管理。我们可以向容器中添加元素,而不必担心元素存储在哪里。容器负责管理自身的存储。vector和string都提供更细致的内存管理控制,这是通过它们的reserve和capacity成员函数来实现的。 很大程度上,容器只定义了极少的操作。每个容器都定义了构造函数、添加和删除元素的操作、确定容器大小的操作以及返回指向特定元素的迭代器的操作。其他一些有用的操作,如排序或搜索,并不是由容器类型定义的,而是由标准库算法实现的,我们将在第10章介绍这些内容。 当我们使用添加和删除元素的容器操作时,必须注意这些操作可能使指向容器中元素的迭代器、指针或引用失效。很多会使选代器失效的操作,如insert和erase,都会返回一个新的迭代器,来帮助程序员维护容器中的位置。如果循环程序中使用了改变容器人小的操作,就要尤其小心其中迭代器、指针和引用的使用。
第 10 章 泛型算法
10.1 概述
关键概念:算法永远不会执行容器的操作
泛型算法本身不会执行容器的操作,它们只会运行于迭代器之上,执行选代器的操作。泛型算法运行于迭代器之上而不会执行容器操作的特性带来了一个令人惊讶但非常必要的编程假定:算法永远不会改变底层容器的大小。算法可能改变容器中保存的元素的值,也可能在容器内移动元素,但永远不会直接添加或删除元素。
如我们将在10.4.1节(第358页)所看到的,标准库定义了一类特殊的选代器,称为插入器(inserter)。与普通选代器只能遍历所绑定的容器相比,插入器能做更多的事情。当给这类迭代器赋值时,它们会在底层的容器上执行插入操作。因此,当一个算法操作一个这样的选代器时,迭代器可以完成向容器添加元素的效果,但算法自身永远不会做这样的操作。
10.2 初识泛型算法
accumulate的第三个参数的类型决定了函数中使用哪个加法运算符以及返回值的类型。
提示
对于只读取而不改变元素的算法,通常最好使用cbegin()和cend()(参见9.2.3节,第298页)。但是,如果你计划使用算法返回的选代器来改变元素的值,就需要使用begin()和end()的结果作为参数。
注意
那些只接受一个单一选代器来表示第二个序列的算法,都假定第二个序列至少与第一个序列一样长。
关键概念:迭代器参数
一些算法从两个序列中读取元素。构成这两个序列的元素可以来自于不同类型的容器。例如,第一个序列可能保存于一个vector中,而第二个序列可能保存于一个 1ist、degue、内置数组或其他容器中。而且,两个序列中元素的类型也不要求严格匹配。算法要求的只是能够比较两个序列中的元素。例如,对equal算法,元素类型不要求相同,但是我们必须能使用--来比较来自两个序列中的元素。
操作两个序列的算法之间的区别在于我们如何传递第二个序列。一些算法,例如equal,接受三个迭代器:前两个表示第一个序列的范围,第三个表示第二个序列中的首元素。其他算法接受四个迭代器:前两个表示第一个序列的元素范围,后两个表示第二个序列的范围。
用一个单一迭代器表示第二个序列的算法都假定第二个序列至少与第一个一样长。确保算法不会试图访问第二个序列中不存在的元素是程序员的责任。例如,算法egual会将其第一个序列中的每个元素与第二个序列中的对应元素进行比较。如果第二个序列是第一个序列的一个子集,则程序会产生一个严重错误--equal会试图访问第二个序列中末尾之后(不存在)的元素。
注意
向目的位置迭代器写入数据的算法假定目的位置足够大,能容纳要写入的元
标准库算法对迭代器而不是容器进行操作。因此,算法不能(直接)添加或删除元素。
10.3 定制操作
如果 lambda的函数体包含任何单- return 语句之外的内容,且未指定返回类型,则返回 void。
一个 lambda只有在其捕获列表中捕获一个它所在函数中的局部变量,才能在函数体中使用该变量。
捕获列表只用于局部非 static 变量,lambda可以直接使用局部static变量和在它所在函数之外声明的名字。
注意
当以引用方式捕获一个变量时,必须保证在1ambda执行时变量是存在的。
建议:尽量保持1ambda的变量捕获简单化
一个lambda捕获从lambda被创建(即,定义lambda的代码执行时)到lambda自身执行(可能有多次执行)这段时间内保存的相关信息。确保lambda每次执行的时候这些信息都有预期的意义,是程序员的责任。
捕获一个普通变量,如int、string或其他非指针类型,通常可以采用简单的值捕获方式。在此情况下,只需关注变量在捕获时是否有我们所需的值就可以了。
如果我们捕获一个指针或迭代器,或采用引用捕获方式,就必须确保在lambda执行时,绑定到迭代器、指针或引用的对象仍然存在。而且,需要保证对象具有预期的值。在lambda从创建到它执行的这段时问内,可能有代码改变绑定的对象的值。也就是说在指针(或引用)被捕获的时刻,绑定的对象的值是我们所期望的,但在lambda执行时,该对象的值可能已经完全不同了。
一般来说,我们应该尽量减少捕获的数据量,来避免潜在的捕获导致的问题。而且如果可能的话,应该避免捕获指针或引用。
向后兼容:参数绑定
旧版本C++提供的绑定函数参数的语言特性限制更多,也更复杂。标准库定义了两个分别名为bind1st和bind2nd的函数。类似bind,这两个函数接受一个函数作为参数生成一个新的可调用对象,该对象调用给定函数,并将绑定的参数传递给它。但是,这些函数分别只能绑定第一个或第二个参数。由于这些函数局限太强,在新标准中已被弃用(deprecated)。所谓被弃用的特性就是在新版本中不再支持的特性。新的C+程序应该使用 bind。
10.4 再探迭代器
反向迭代器的目的是表示元素范围,而这些范围是不对称的,这导致一个重要的结果:当我们从一个普通选代器初始化一个反向选代器,或是给一个反向迭代器赋值时,结果迭代器与原选代器指向的并不是相同的元素。
10.5 泛型算法结构
对于向一个算法传递错误类别的选代器的问题,很多编译器不会给出任何警告或提示。
向输出迭代器写入数据的算法都假定目标空间足够容纳写入的数据。
注意
接受单独beg2的算法假定从beg2开始的序列与 beg 和end 所表示的范围至少一样大。
10.6 特定容器算法
提示
对于 list和forward list,应该优先使用成员函数版本的算法而不是通用算法。
小结
小结
标准库定义了大约100个类型无关的对序列进行操作的算法。序列可以是标准库容器类型中的元素、一个内置数组或者是(例如)通过读写一个流来生成的。算法通过在迭代器上进行操作来实现类型无关。多数算法接受的前两个参数是一对迭代器,表示一个元素范围。额外的迭代器参数可能包括一个表示目的位置的输出迭代器,或是表示第二个输入范围的另一个或另一对迭代器, 根据支持的操作不同,迭代器可分为五类:输入、输出、前向、双向以及随机访问迭代器。如果一个迭代器支持某个迭代器类别所要求的操作,则属于该类别。 如同迭代器根据操作分类一样,传递给算法的迭代器参数也按照所要求的操作进行分类。仅读取序列的算法只要求输入迭代器操作。写入数据到目的位置迭代器的算法只要求输出迭代器操作,依此类推。 算法从不直接改变它们所操作的序列的大小。它们会将元素从一个位置拷贝到另一个位置,但不会直接添加或删除元素。 虽然算法不能向序列添加元素,但插入迭代器可以做到。一个插入迭代器被绑定到一个容器上。当我们将一个容器元素类型的值赋予一个插入迭代器时,迭代器会将该值添加到容器中。 容器 forward ist和1ist对一些通用算法定义了自己特有的版本。与通用算法不同,这些链表特有版本会修改给定的链表。
第 11 章 关联容器
11.1 使用关联容器
11.2 关联容器概述
传递给排序算法的可调用对象(参见10.31节,第344页)必须满足与关联容器中关键字一样的类型要求。
在实际编程中,重要的是,如果一个类型定义了“行为正常”的<运算符,则它可以用作关键字类型。
11.3 关联容器操作
必须记住,一个map的value type是一个pair,我们可以改变 pair 的值,但不能改变关键字成员的值。
本程序的输出是按字典序排列的。当使用一个选代器遍历一个map、multimap、set或multiset时,迭代器按关键字升序遍历元素。
对一个map使用下标操作,其行为与数组或vector上的下标操作很不相同:使用一个不在容器中的关键字作为下标,会添加一个具有此关键字的元素到map中。
与vector与string 不同,map的下标运算符返回的类型与解引用map选代器得到的类型不同。
当我们遍历一个multimap或multiset时,保证可以得到序列中所有具有给定关键字的元素。
lower_bound 返回的选代器可能指向一个具有给定关键字的元素,但也可能不指向。如果关键字不在容器中,则lower_bound 会返回关键字的第一个安全插入点--不影响容器中元素顺序的插入位置。
如果 lower_bound 和 upper_bound 返回相同的迭代器,则给定关键字不在容器中。
11.4 无序容器
如果关键字类型固有就是无序的,或者性能测试发现问题可以用哈希技术解决,就可以使用无序容器。
注
关联容器支持通过关键字高效查找和提取元素。对关键字的使用将关联容器和顺序容器区分开来,顺序容器中是通过位置访问元素的。
标准库定义了8个关联容器,每个容器
- 是一个map 或是一个set。map保存关键字-值对;set 只保存关键字,
- 要求关键字唯一或不要求。
- 保持关键字有序或不保证有序。 有序容器使用比较函数来比较关键字,从而将元素按顺序存储。默认情况下,比较操作是采用关键字类型的<运算符。无序容器使用关键字类型的-运算符和一个
hash<key type>
类型的对象来组织元素。
允许重复关键字的容器的名字中都包含multi:而使用哈希技术的容器的名字都以unordered开头。例如,set是一个有序集合,其中每个关键字只可以出现一次:unordered multiset则是一个无序的关键字集合,其中关键字可以出现多次。
关联容器和顺序容器有很多共同的元素。但是,关联容器定义了一些新操作,并对--些和顺序容器和关联容器都支持的操作重新定义了含义或返回类型。操作的不同反映出关联容器使用关键字的特点。
有序容器的迭代器通过关键字有序访问容器中的元素。无论在有序容器中还是在无序容器中,具有相同关键字的元素都是相邻存储的。
小结
小结
关联容器支持通过关键字高效查找和提取元素。对关键字的使用将关联容器和顺序容器区分开来,顺序容器中是通过位置访问元素的。 标准库定义了8个关联容器,每个容器 。是一个map或是一个set。map保存关键字-值对:set只保存关键字。 要求关键字唯一或不要求。 保持关键字有序或不保证有序。有序容器使用比较函数来比较关键字,从而将元素按顺序存储。默认情况下,比较操作是采用关键字类型的<运算符。无序容器使用关键字类型的-运算符和一个hash<key _type>
类型的对象来组织元素。 允许重复关键字的容器的名字中都包含multi:而使用哈希技术的容器的名字都以unordered开头。例如,set是一个有序集合,其中每个关键字只可以出现一次:unordered multiset则是一个无序的关键字集合,其中关键字可以出现多次。 关联容器和顺序容器有很多共同的元素。但是,关联容器定义了一些新操作,并对些和顺序容器和关联容器都支持的操作重新定义了含义或返回类型。操作的不同反映出关联容器使用关键字的特点。 有序容器的迭代器通过关键字有序访问容器中的元素。无论在有序容器中还是在无序容器中,具有相同关键字的元素都是相邻存储的。
第 12 章 动态内存
虽然使用动态内存有时是必要的,但众所周知,正确地管理动态内存是非常棘手的。
12.1 动态内存与智能指针
新标准库提供的这两种智能指针的区别在于管理底层指针的方式: shared_ptr 允许多个指针指向同一个对象:unigue_ptr 则“独占”所指向的对象。标准库还定义了一个名为 weak_ptr的伴随类,它是一种弱引用,指向shared ptr所管理的对象。这三种类型都定义在memory头文件中。
到底是用一个计数器还是其他数据结构来记录有多少指针共享对象,完全由标准库的具体实现来决定。关键是智能指针类能记录有多少个sharedptr指向相同的对象,并能在恰当的时候自动释放对象。
如果你将 shared_ptr 存放于一个容器中,而后不再需要全部元素,而只使用其中一部分,要记得用erase删除不再需要的那些元素。
使用动态内存的一个常见原因是允许多个对象共享相同的状态。
在学习第13章之前,除非使用智能指针来管理内存,否则不要分配动态内存。
提示
出于与变量初始化相同的原因,对动态分配的对象进行初始化通常是个好主意。
注意
由内置指针(而不是智能指针)管理的动态内存在被显式释放前一直都会存在。
小心:动态内存的管理非常容易出错
使用new和delete管理动态内存存在三个常见问题:
1.忘记 delete内存。忘记释放动态内存会导致人们常说的“内存泄漏”问题、因为这种内存永远不可能被归还给自由空间了。查找内存泄露错误是非常困难的,因为通常应用程序运行很长时间后,真正耗尽内存时,才能检测到这种错误。
2.使用已经释放掉的对象。通过在释放内存后将指针置为空,有时可以检测出这种错误。
3.同一块内存释放两次。当有两个指针指向相同的动态分配对象时,可能发生这种错误。如果对其中一个指针进行了delete操作,对象的内存就被归还给自由空间了。如果我们随后又delete第二个指针,自由空间就可能被破坏。相对于查找和修正这些错误来说,制造出这些错误要简单得多
提示
坚持只使用智能指针,就可以避免所有这些问题。对于一块内存,只有在没有任何智能指针指向它的情况下,智能指针才会自动释放它。
提示
使用一个内置指针来访问一个智能指针所负责的对象是很危险的,因为我们无法知道对象何时会被销毁。
提示
get用来将指针的访问权限传递给代码,你只有在确定代码不会delete指针的情况下,才能使用get。特别是,永远不要用get初始化另一个智能指针或者为另一个智能指针赋值。
注意:智能指针陷阱
智能指针可以提供对动态分配的内存安全而又方便的管理,但这建立在正确使用的前提下。为了正确使用智能指针,我们必须坚持一些基本规范:
- 不使用相同的内置指针值初始化(或reset)多个智能指针
- 不 delete get()返回的指针。
- 不使用get()初始化或reset另一个智能指针。
- 如果你使用get()返回的指针,记住当最后一个对应的智能指针销毁后,你的指针就变为无效了。
- 如果你使用智能指针管理的资源不是new分配的内存,记住传递给它一个删除器(参见12.1.4节,第415页和12.1.5节,第419页)。
向后兼容:auto_ptr
标准库的较早版本包含了一个名为auto_ptr的类,它具有unique_ptr的部分特性,但不是全部。特别是,我们不能在容器中保存autoptr,也不能从函数中返回auto_ptr 虽然autoptr仍是标准库的一部分,但编写程序时应该使用unique_ptr。
12.2 动态数组
注意
大多数应用应该使用标准库容器而不是动态分配的数组。使用容器更为简单、更不容易出现内存管理错误并且可能有更好的性能。
注意
要记住我们所说的动态数组并不是数组类型,这是很重要的。
注意
如果我们在 delete一个数组指针时忘记了方括号,或者在delete一个单一对象的指针时使用了方括号,编译器很可能不会给出警告。我们的程序可能在执行过程中在没有任何警告的情况下行为异常。
注意
为了使用allocate返回的内存,我们必须用construct构造对象。使用未构造的内存,其行为是未定义的。
注意
我们只能对真正构造了的元素进行destroy操作。
12.3 使用标准库:文本查询程序
小结
提示
在C++中,内存是通过new表达式分配,通过delete表达式释放的。标准库还定义了一个 allocator类来分配动态内存块。 分配动态内存的程序应负责释放它所分配的内存。内存的正确释放是非常容易出错的地方:要么内存永远不会被释放,要么在仍有指针引用它时就被释放了。新的标准库定义了智能指针类型--shared ptr、unique ptr和weak ptr,可令动态内存管理更为安全。对于一块内存,当没有任何用户使用它时,智能指针会自动释放它。现代C++程序应尽可能使用智能指针。
第III部分 类设计者的工具
第 13 章 拷贝控制
注意
在定义任何C++类时,拷贝控制操作都是必要部分。对初学C++的程序员来说,必须定义对象拷贝、移动、赋值或销毁时做什么,这常常令他们感到困惑。这种困扰很复杂,因为如果我们不显式定义这些操作,编译器也会为我们定义,但编译器定义的版本的行为可能并非我们所想。
13.1 拷贝、赋值与销毁
提示
赋值运算符通常应该返回一个指向其左侧运算对象的引用。
隐式销毁一个内置指针类型的成员不会delete它所指向的对象。
当指向一个对象的引用或指针离开作用域时,析构函数不会执行。
如果一个类需要自定义析构函数,几乎可以肯定它也需要自定义拷贝赋值运算符和拷贝构造函数。
我们只能对具有合成版本的成员函数使用=default(即,默认构造函数或拷贝控制成员)。
大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论是隐式地还是显式地。
对于析构函数已删除的类型,不能定义该类型的变量或释放指向该类型动态分配对象的指针。
本质上,当不可能拷贝、赋值或销毁类的成员时,类的合成拷贝控制成员就被定义为删除的。
提示
希望阻止拷贝的类应该使用=delete来定义它们自己的拷贝构造函数和拷贝赋值运算符,而不应该将它们声明为private的。
13.2 拷贝控制和资源管理
关键概念:赋值运算符
当你编写赋值运算符时,有两点需要记住:
- 如果将一个对象赋予它自身,赋值运算符必须能正确工作
- 大多数赋值运算符组合了析构函数和拷贝构造函数的工作。当你编写一个赋值运算符时,一个好的模式是先将右侧运算对象拷贝到一个局部临时对象中。当拷贝完成后,销毁左侧运算对象的现有成员就是安全的了。一旦左侧运算对象的资源被销毁,就只剩下将数据从临时对象拷贝到左侧运算对象的成员中了。
注意
对于一个赋值运算符来说,正确工作是非常重要的,即使是将一个对象赋予它自身,也要能正确工作。一个好的方法是在销毁左侧运算对象资源之前拷贝右侧运算对象。
13.3 交换操作
与拷贝控制成员不同,swap并不是必要的。但是,对于分配了资源的类,定义swap 可能是一种很重要的优化手段。
提示
使用拷贝和交换的赋值运算符自动就是异常安全的,且能正确处理自赋值
13.4 拷贝控制示例
提示
拷贝赋值运算符通常执行拷贝构造函数和析构函数中也要做的工作。这种情况下,公共的工作应该放在private的工具函数中完成。
13.5 动态内存管理类
13.6 对象移动
标准库容器、string和sharedptr类既支持移动也支持拷贝。I0类和uniqueptr类可以移动但不能拷贝。
考察左值和右值表达式的列表,两者相互区别之处就很明显了:左值有持久的状态而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。
右值引用指向将要被销毁的对象。因此,我们可以从绑定到右值引用的对象“窃取”状态。
变量是左值,因此我们不能将一个右值引用直接绑定到一个变量上,即使这个变量是右值引用类型也不行。
我们可以销毁一个移后源对象,也可以赋予它新值,但不能使用一个移后源对象的值。
注意
使用move的代码应该使用std::move 而不是move。这样做可以避免潜在的名字冲突。
不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept。
注意
在移动操作之后,移后源对象必须保持有效的、可析构的状态,但是用户不能对其值进行任何假设。
只有当一个类没有定义任何自己版本的拷贝控制成员,且它的所有数据成员都能移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符。
定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作否则,这些成员默认地被定义为删除的。
如果一个类有一个可用的拷贝构造函数而没有移动构造函数,则其对象是通过拷贝构造函数来“移动”的。拷贝赋值运算符和移动赋值运算符的情况类似。
建议:更新三/五法则
所有五个拷贝控制成员应该看作一个整体:一般来说,如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作。如前所述,某些类必须定义拷贝构造函数、拷贝赋值运算符和析构函数才能正确工作(参见13.1.4节,第447页)。这些类通常拥有一个资源,而拷贝成员必须拷贝此资源。一般来说,拷贝一个资源会导致一些额外开销。在这种拷贝并非必要的情况下,定义了移动构造函数和移动赋值运算符的类就可以避免此问题。
建议:不要随意使用移动操作
由于一个移后源对象具有不确定的状态,对其调用std::move是危险的。当我们调用move时,必须绝对确认移后源对象没有其他用户。
通过在类代码中小心地使用move,可以大幅度提升性能。而如果随意在普通用户代码(与类实现代码相对)中使用移动操作,很可能导致莫名其妙的、难以查找的错误而难以提升应用程序性能。
提示
在移动构造函数和移动赋值运算符这些类实现代码之外的地方,只有当你确信Practines需要进行移动操作且移动操作是安全的,才可以使用std::move。
区分移动和拷贝的重载函数通常有一个版本接受一个constT&,而另一个版本接受一个 T&&。
如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符。
小结
提示
每个类都会控制该类型对象拷贝、移动、赋值以及销毁时发生什么。特殊的成员函数-拷贝构造函数、移动构造函数、拷贝赋值运算符、移动赋值运算符和析构函数定义了这些操作。移动构造函数和移动赋值运算符接受一个(通常是非const的)右值引用;而拷贝版本则接受一个(通常是const的)普通左值引用。
如果一个类未声明这些操作,编译器会自动为其生成。如果这些操作未定义成删除的,它们会逐成员初始化、移动、赋值或销毁对象:合成的操作依次处理每个非static数据成员,根据成员类型确定如何移动、拷贝、赋值或销毁它。
分配了内存或其他资源的类几乎总是需要定义拷贝控制成员来管理分配的资源。如果一个类需要析构函数,则它几乎肯定也需要定义移动和拷贝构造函数及移动和拷贝赋值运算符。
第 14 章 重载运算与类型转换
14.1 基本概念
当一个重载的运算符是成员函数时,this绑定到左侧运算对象。成员运算符函数的(显式)参数数量比运算对象的数量少一个。
提示
通常情况下,不应该重载逗号、取地址、逻辑与和逻辑或运算符。
提示:尽量明智地使用运算符重载
每个运算符在用于内置类型时都有比较明确的含义。以二元+运算符为例,它明显执行的是加法操作。因此,把二元+运算符映射到类类型的一个类似操作上可以极大地简化记忆。例如对于标准库类型string来说,我们就会使用+把一个string对象连接到另一个后面,很多编程语言都有类似的用法。
当在内置的运算符和我们自己的操作之间存在逻辑映射关系时,运算符重载的效果最好。此时,使用重载的运算符显然比另起一个名字更自然也更直观。不过,过分滥用运算符重载也会使我们的类变得难以理解。
在实际编程过程中,一般没有特别明显的滥用运算符重载的情况。例如,一般来说没有哪个程序员会定义operator+并让它执行减法操作。然而经常发生的一种情况是,程序员可能会强行扭曲了运算符的“常规”含义使得其适应某种给定的类型,这显然是我们不希望发生的。因此我们的建议是:只有当操作的含义对于用户来说清晰明了时才使用运算符。如果用户对运算符可能有几种不同的理解,则使用这样的运算符将产生二义性。
14.2 输入和输出运算符
提示
通常,输出运算符应该主要负责打印对象的内容而非控制格式,输出运算符不应该打印换行符。
输入运算符必须处理输入可能失败的情况,而输出运算符不需要。
提示
当读取操作发生错误时,输入运算符应该负责从错误中恢复。
14.3 算术和关系运算符
提示
如果类同时定义了算术运算符和相关的复合赋值运算符,则通常情况下应该使用复合赋值来实现算术运算符。
提示
如果某个类在逻辑上有相等性的含义,则该类应该定义operator==,这样做可以使得用户更容易使用标准库算法来处理这个类。
提示
如果存在唯一一种逻辑可靠的<定义,则应该考虑为这个类定义<运算符。如果类同时还包含==,则当且仅当<的定义和==产生的结果一致时才定义<运算符。
14.4 赋值运算符
我们可以重载赋值运算符。不论形参的类型是什么,赋值运算符都必须定义为成员函数。
提示
赋值运算符必须定义成类的成员,复合赋值运算符通常情况下也应该这样做这两类运算符都应该返回左侧运算对象的引用。
14.5 下标运算符
下标运算符必须是成员函数。
提示
如果一个类包含下标运算符,则它通常会定义两个版本:一个返回普通引用另一个是类的常量成员并且返回常量引用。
14.6 递增和递减运算符
提示
定义递增和递减运算符的类应该同时定义前置版本和后置版本。这些运算符通常应该被定义成类的成员。
提示
为了与内置版本保持一致,前置运算符应该返回递增或递减后对象的引用。
提示
为了与内置版本保持一致,后置运算符应该返回对象的原值(递增或递减之前的值),返回的形式是一个值而非引用。
因为我们不会用到int形参,所以无须为其命名。
14.7 成员访问运算符
箭头运算符必须是类的成员。解引用运算符通常也是类的成员,尽管并非必须如此。
重载的箭头运算符必须返回类的指针或者自定义了箭头运算符的某个类的对象。
14.8 函数调用运算符
函数调用运算符必须是成员函数。一个类可以定义多个不同版本的调用运算符,相互之间应该在参数数量或类型上有所区别。
新版本标准库中的 function 类与旧版本中的 unary function 和binary function没有关联,后两个类已经被更通用的bind函数替代了(参见10.3.4节,第357页)。
14.9 重载、类型转换与运算符
一个类型转换函数必须是类的成员函数;它不能声明返回类型,形参列表也必须为空。类型转换函数通常应该是const。
提示:避免过度使用类型转换雨数
和使用重载运算符的经验一样,明智地使用类型转换运算符也能极大地简化类设计者的工作,同时使得使用类更加容易。然而,如果在类类型和转换类型之间不存在明显的映射关系,则这样的类型转换可能具有误导性。
例如,假设某个类表示Date,我们也许会为它添加一个从Date到int的转换然而,类型转换函数的返回值应该是什么?一种可能的解释是,函数返回一个十进制数,依次表示年、月、日,例如,July 30, 1989可能转换为int值19890730。同时还存在另外一种合理的解释,即类型转换运算符返回的int表示的是从某个时间节点(比如January 1, 1970)开始经过的天数。显然这两种理解都合情合理,毕竟从形式上看它们产生的效果都是越靠后的日期对应的整数值越大,而且两种转换都有实际的用处。
问题在于 Date类型的对象和int 类型的值之间不存在明确的一对一映射关系。因此在此例中,不定义该类型转换运算符也许会更好。作为替代的手段,类可以定义一个或多个普通的成员函数以从各种不同形式中提取所需的信息。
提示
向 bool的类型转换通常用在条件部分,因此operator bool一般定义成explicit的。
注意
通常情况下,不要为类定义相同的类型转换,也不要在类中定义两个及两个以上转换源或转换目标是算术类型的转换。
当我们使用两个用户定义的类型转换时,如果转换函数之前或之后存在标准类型转换,则标准类型转换将决定最佳匹配到底是哪个。
提示:类型转换与运算符
要想正确地设计类的重载运算符、转换构造函数及类型转换函数,必须加倍小心。尤其是当类同时定义了类型转换运算符及重载运算符时特别容易产生二义性。以下的经验规则可能对你有所帮助:
- 不要令两个类执行相同的类型转换:如果Foo类有一个接受Bar类对象的构造函数,则不要在Bar类中再定义转换目标是Foo类的类型转换运算符。
- 避免转换目标是内置算术类型的类型转换。特别是当你已经定义了一个转换成算术类型的类型转换时,接下来
- 不要再定义接受算术类型的重载运算符。如果用户需要使用这样的运算符,则类型转换操作将转换你的类型的对象,然后使用内置的运算符。
- 不要定义转换到多种算术类型的类型转换。让标准类型转换完成向其他算术类型转换的工作。
一言以蔽之:除了显式地向bool类型的转换之外,我们应该尽量避免定义类型转换函数并尽可能地限制那些“显然正确”的非显式构造函数。
注意
如果在调用重载函数时我们需要使用构造函数或者强制类型转换来改变实参的类型,则这通常意味着程序的设计存在不足。
在调用重载函数时,如果需要额外的标准类型转换,则该转换的级别只有当所有可行函数都请求同一个用户定义的类型转换时才有用。如果所需的用户定义的类型转换不止一个,则该调用具有二义性。
表达式中运算符的候选函数集既应该包括成员函数,也应该包括非成员函数。
如果我们对同一个类既提供了转换目标是算术类型的类型转换,也提供了重载的运算符,则将会遇到重载运算符与内置运算符的二义性问题。
小结
提示
一个重载的运算符必须是某个类的成员或者至少拥有一个类类型的运算对象。重载运算符的运算对象数量、结合律、优先级与对应的用于内置类型的运算符完全一致。当运算符被定义为类的成员时,类对象的隐式this指针绑定到第一个运算对象。赋值、下标、函数调用和箭头运算符必须作为类的成员。
如果类重载了函数调用运算符operator(),则该类的对象被称作“函数对象”。这样的对象常用在标准函数中。lambda表达式是一种简便的定义函数对象类的方式。
在类中可以定义转换源或转换目的是该类型本身的类型转换,这样的类型转换将自动执行。只接受单独一个实参的非显式构造函数定义了从实参类型到类类型的类型转换;而非显式的类型转换运算符则定义了从类类型到其他类型的转换。
第 15 章 面向对象程序设计
15.1 OOP:概述
在C++语言中,当我们使用基类的引用(或指针)调用一个虚函数时将发生动态绑定。
15.2 定义基类和派生类
基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作也是如此。
在派生类对象中含有与其基类对应的组成部分,这一事实是继承的关键所在。
每个类控制它自己的成员初始化过程。
首先初始化基类的部分,然后按照声明的顺序依次初始化派生类的成员。
关键概念:遵循基类的接口
必须明确一点:每个类负责定义各自的接口。要想与类的对象交互必须使用该类的接口,即使这个对象是派生类的基类部分也是如此。
因此,派生类对象不能直接初始化基类的成员。尽管从语法上来说我们可以在派生类构造函数体内给它的公有或受保护的基类成员赋值,但是最好不要这么做。和使用基类的其他场合一样,派生类应该遵循基类的接口,并且通过调用基类的构造函数来初始化那些从基类中继承而来的成员。
如果我们想将某个类用作基类,则该类必须已经定义而非仅仅声明,这一规定的原因显而易见:派生类中包含并且可以使用它从基类继承而来的成员,为了使用这些成员,派生类当然要知道它们是什么。因此该规定还有一层隐含的意思,即一个类不能派生它本身。
C++11新标准提供了一种防止继承发生的方法,即在类名后跟一个关键字 final。
注意
理解基类和派生类之间的类型转换是理解C++语言面向对象编程的关键所在。
和内置指针一样,智能指针类(参见12.1节,第400页)也支持派生类向基类的类型转换,这意味着我们可以将一个派生类对象的指针存储在一个基类的智能指针内。
基类的指针或引用的静态类型可能与其动态类型不一致,读者一定要理解其中的原因。
注意
当我们用一个派生类对象为一个基类对象初始化或赋值时,只有该派生类对象中的基类部分会被拷贝、移动或赋值,它的派生类部分将被忽略掉。
关键概念:存在继承关系的类型之间的转换规则
要想理解在具有继承关系的类之间发生的类型转换,有三点非常重要
- 从派生类向基类的类型转换只对指针或引用类型有效。
- 基类向派生类不存在隐式类型转换
- 和任何其他成员一样,派生类向基类的类型转换也可能会由于访问受限而变得不可行。我们将在15.5节(第544页)详细介绍可访问性的问题。
尽管自动类型转换只对指针或引用类型有效,但是继承体系中的大多数类仍然(显式或隐式地)定义了拷贝控制成员(参见第13章)。因此,我们通常能够将一个派生类对象拷贝、移动或赋值给一个基类对象。不过需要注意的是,这种操作只处理派生类对象的基类部分。
15.3 虚函数
关键概念:C++的多态性
OOP的核心思想是多态性(polymorphism)。多态性这个词源自希腊语,其含义是“多种形式”。我们把具有继承关系的多个类型称为多态类型,因为我们能使用这些类型的“多种形式”而无须在意它们的差异。引用或指针的静态类型与动态类型不同这一事实正是C+语言支持多态性的根本所在
当我们使用基类的引用或指针调用基类中定义的一个函数时,我们并不知道该函数真正作用的对象是什么类型,因为它可能是一个基类的对象也可能是一个派生类的对象。如果该函数是虚函数,则直到运行时才会决定到底执行哪个版本,判断的依据是引用或指针所绑定的对象的真实类型。
另一方面,对非虚函数的调用在编译时进行绑定。类似的,通过对象进行的函数(虚函数或非虚函数)调用也在编译时绑定。对象的类型是确定不变的,我们无论如何都不可能令对象的动态类型与静态类型不一致。因此,通过对象进行的函数调用将在编译时绑定到该对象所属类中的函数版本上。
当且仅当对通过指针或引用调用虚函数时,才会在运行时解析该调用,也只有在这种情况下对象的动态类型才有可能与静态类型不同。
基类中的虚函数在派生类中隐含地也是一个虚函数。当派生类覆盖了某个虚函数时,该函数在基类中的形参必须与派生类中的形参严格匹配。
如我们将要在15.6节(第550页)介绍的,派生类如果定义了一个函数与基类中虚函数的名字相同但是形参列表不同,这仍然是合法的行为。编译器将认为新定义的这个函数与基类中原有的函数是相互独立的。这时,派生类的函数并没有覆盖掉基类中的版本。就实际的编程习惯而言,这种声明往往意味着发生了错误,因为我们可能原本希望派生类能覆盖掉基类中的虚函数,但是一不小心把形参列表弄错了。要想调试并发现这样的错误显然非常困难。在C++11新标准中我们可以使用override 关键字来说明派生类中的虚函数。这么做的好处是在使得程序员的意图更加清晰的同时让编译器可以为我们发现一些错误,后者在编程实践中显得更加重要。如果我们使用override 标记了某个函数,但该函数并没有覆盖已存在的虚函数,此时编译器将报错。
提示
如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致。
提示
通常情况下,只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数的机制。
通常情况下,只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数的机制。
注意
如果一个派生类虚函数需要调用它的基类版本,但是没有使用作用域运算符,则在运行时该调用将被解析为对派生类版本自身的调用,从而导致无限递归。
15.4 抽象基类
我们通过在函数体的位置(即在声明语句的分号之前)书写=0就可以将一个虚函数说明为纯虚函数。其中,=0只能出现在类内部的虚函数声明语句处。
值得注意的是,我们也可以为纯虚函数提供定义,不过函数体必须定义在类的外部。也就是说,我们不能在类的内部为一个=0的函数提供函数体。
含有(或者未经覆盖直接继承)纯虚函数的类是抽象基类(abstract base class)。抽象基类负责定义接口,而后续的其他类可以覆盖该接口。我们不能(直接)创建一个抽象基类的对象。
我们不能创建抽象基类的对象。
关键概念:重构
在Quote的继承体系中增加Disc quote类是重构(refactoring)的一个典型示例。重构负责重新设计类的体系以便将操作和/或数据从一个类移动到另一个类中。对于面向对象的应用程序来说,重构是一种很普遍的现象 值得注意的是,即使我们改变了整个继承体系,那些使用了Bulkquote或Quote的代码也无须进行任何改动。不过一旦类被重构(或以其他方式被改变),就意味着我们必须重新编译含有这些类的代码了。
15.5 访问控制与继承
对于代码中的某个给定节点来说,如果基类的公有成员是可访问的,则派生类向基类的类型转换也是可访问的;反之则不行。
,即使我们改变了整个继承体系,那些使用了Bulkquote或Quote的代码也无须进行任何改动。不过一旦类被重构(或以其他方式被改变),就意味着我们必须重新编译含有这些类的代码了。
关键概念:类的设计与受保护的成员
不考虑继承的话,我们可以认为一个类有两种不同的用户:普通用户和类的实现者。其中,普通用户编写的代码使用类的对象,这部分代码只能访问类的公有(接口)成员:实现者则负贵编写类的成员和友元的代码,成员和友元既能访问类的公有部分,也能访问类的私有(实现)部分。
如果进一步考虑继承的话就会出现第三种用户,即派生类。基类把它希望派生类能够使用的部分声明成受保护的。普通用户不能访问受保护的成员,而派生类及其友元仍旧不能访问私有成员。
和其他类一样,基类应该将其接口成员声明为公有的;同时将属于其实现的部分分成两组:一组可供派生类访问,另一组只能由基类及基类的友元访问。对于前者应该声明为受保护的,这样派生类就能在实现自己的功能时使用基类的这些操作和数据:对于后者应该声明为私有的。
不能继承友元关系;每个类负责控制各自成员的访问权限。
派生类只能为那些它可以访问的名字提供using 声明。
一个私有派生的类最好显式地将private声明出来,而不要仅仅依赖于默认的设置。显式声明的好处是可以令私有继承关系清晰明了,不至于产生误会。
15.6 继承中的类作用域
派生类的成员将隐藏同名的基类成员。
除了覆盖继承而来的虚函数之外,派生类最好不要重用其他定义在基类中的名字。
关键概念:名字查找与继承
理解函数调用的解析过程对于理解C++的继承至关重要,假定我们调用p->mem()(或者obj.mem()),则依次执行以下4个步骤:
- 首先确定p(或obj)的静态类型。因为我们调用的是一个成员,所以该类型必然是类类型。
- 在p(或obj)的静态类型对应的类中查找mem。如果找不到,则依次在直接基类中不断查找直至到达继承链的顶端。如果找遍了该类及其基类仍然找不到,则编译器将报错。
- 一旦找到了mem,就进行常规的类型检查(参见6.1节,第183页)以确认对于当前找到的mem,本次调用是否合法,
- 假设调用合法,则编译器将根据调用的是否是虚函数而产生不同的代码:
- 如果mem是虚函数且我们是通过引用或指针进行的调用,则编译器产生的代码将在运行时确定到底运行该虚函数的哪个版本,依据是对象的动态类型。
- 反之,如果mem不是虚函数或者我们是通过对象(而非引用或指针)进行的调用,则编译器将产生一个常规函数调用。
15.7 构造函数与拷贝控制
注意
如果基类的析构函数不是虚函数,则 delete一个指向派生类对象的基类指针将产生未定义的行为。
注意
当派生类定义了拷贝或移动操作时,该操作负责拷贝或移动包括基类部分成员在内的整个对象。
注意
在默认情况下,基类默认构造函数初始化派生类对象的基类部分。如果我们想拷贝(或移动)基类部分,则必须在派生类的构造函数初始值列表中显式地使用基类的拷贝(或移动)构造函数。
如果构造函数或析构函数调用了某个虚函数,则我们应该执行与构造函数或析构函数所属类型相对应的虚函数版本。
15.8 容器与继承
注意
当派生类对象被赋值给基类对象时,其中的派生类部分将被“切掉”,因此容器和存在继承关系的类型无法兼容。
15.9 文本查询程序再探
关键概念:继承与组合
继承体系的设计本身是一个非常复杂的问题,已经超出了本书的范围。然而,有一条设计准则非常重要也非常基础,每个程序员都应该熟悉它。
当我们令一个类公有地继承另一个类时,派生类应当反映与基类的“是一种(IsA)关系。在设计良好的类体系当中,公有派生类的对象应该可以用在任何需要基类对象的地方。
类型之间的另一种常见关系是“有一个(HasA)”关系,具有这种关系的类暗含成 员的意思。
在我们的书店示例中,基类表示的是按规定价格销售的书籍的报价。Bulkguote“是一种”报价结果,只不过它使用的价格策略不同。我们的书店类都“有一个”价格成员和ISBN成员。
小结
提示
继承使得我们可以编写一些新的类,这些新类既能共享其基类的行为,又能根据需要覆盖或添加行为。动态绑定使得我们可以忽略类型之间的差异,其机理是在运行时根据对象的动态类型来选择运行函数的哪个版本。继承和动态绑定的结合使得我们能够编写具有特定类型行为但又独立于类型的程序。
在C++语言中,动态绑定只作用于虚函数,并且需要通过指针或引用调用
在派生类对象中包含有与它的每个基类对应的子对象。因为所有派生类对象都含有基类部分,所以我们能将派生类的引用或指针转换为一个可访问的基类引用或指针。 当执行派生类的构造、拷贝、移动和赋值操作时,首先构造、拷贝、移动和赋值其中的基类部分,然后才轮到派生类部分。析构函数的执行顺序则正好相反,首先销毁派生类,接下来执行基类子对象的析构函数。基类通常都应该定义一个虚析构函数,即使基类根本不需要析构函数也最好这么做。将基类的析构函数定义成虚函数的原因是为了确保当我们删除一个基类指针,而该指针实际指向一个派生类对象时,程序也能正确运行。
派生类为它的每个基类提供一个保护级别。public基类的成员也是派生类接口的部分;private基类的成员是不可访问的:protected基类的成员对于派生类的派生类是可访问的,但是对于派生类的用户不可访问。
第 16 章模板与泛型编程
16.1 定义模板
在模板定义中,模板参数列表不能为空。
非类型模板参数的模板实参必须是常量表达式。
提示
模板程序应该尽量减少对实参类型的要求。
函数模板和类模板成员函数的定义通常放在头文件中。
关键概念:模板和头文件
模板包含两种名字:
- 那些不依赖于模板参数的名字。
- 那些依赖于模板参数的名字。 当使用模板时,所有不依赖于模板参数的名字都必须是可见的,这是由模板的提供者来保证的。而且,模板的提供者必须保证,当模板被实例化时,模板的定义,包括类模板的成员的定义,也必须是可见的。
用来实例化模板的所有函数、类型以及与类型关联的运算符的声明都必须是可见的,这是由模板的用户来保证的。
通过组织良好的程序结构,恰当使用头文件,这些要求都很容易满足。模板的设计者应该提供一个头文件,包含模板定义以及在类模板或成员定义中用到的所有名字的声明。模板的用户必须包含模板的头文件,以及用来实例化模板的任何类型的头文件。
注意
保证传递给模板的实参支持模板所要求的操作,以及这些操作在模板中能正确工作,是调用者的责任。
一个类模板的每个实例都形成一个独立的类。类型Blob<string>
与任何其他Blob类型都没有关联,也不会对任何其他B1ob类型的成员有特殊访问权限。
默认情况下,对于一个实例化了的类模板,其成员只有在使用时才被实例化。
在一个类模板的作用域内,我们可以直接使用模板名而不必指定模板实参。
一个特定文件所需要的所有模板的声明通常一起放置在文件开始位置,出现于任何使用这些模板的代码之前,原因我们将在163节(第617页)中解释。
当我们希望通知编译器一个名字表示类型时,必须使用关键字typename,而不能使用 class。
对每个实例化声明,在程序中某个位置必须有其显式的实例化定义。
在一个类模板的实例化定义中,所用类型必须能用于模板的所有成员函数。
16.2 模板实参推断
将实参传递给带模板类型的函数形参时,能够自动应用的类型转换只有const转换及数组或函数到指针的转换。
如果函数参数类型不是模板参数,则对实参进行正常的类型转换。
当参数是一个函数模板实例的地址时,程序上下文必须满足:对每个模板参数能唯一确定其类型或值。
引用折叠只能应用于间接创建的引用的引用,如类型别名或模板参数。
如果一个函数参数是指向模板参数类型的右值引用(如,T&&),则可以传递给它任意类型的实参。如果将一个左值传递给这样的参数,则函数参数被实例化为一个普通的左值引用(T&)。
如果一个函数参数是指向模板类型参数的右值引用(如T&&),它对应的实参的 const 属性和左值/右值属性将得到保持。
当用于一个指向模板参数类型的右值引用函数参数(T&&)时,forward会保持实参类型的所有细节。
与std::move相同,对std::forward不使用using声明是一个好主意我们将在18.2.3节(第706页)中解释原因。
16.3 重载与模板
与std::move相同,对std::forward不使用using声明是一个好主意我们将在18.2.3节(第706页)中解释原因。
正确定义一组重载的函数模板需要对类型间的关系及模板函数允许的有限的实参类型转换有深刻的理解。
当有多个重载模板对一个调用提供同样好的匹配时,应选择最特例化的版本。
对于一个调用,如果一个非函数模板与一个函数模板提供同样好的匹配,则选择非模板版本。
在定义任何函数之前,记得声明所有重载的函数版本。这样就不必担心编译器由于未遇到你希望调用的函数而实例化一个并非你所需的版本。
16.4 可变参数模板
当定义可变参数版本的print时,非可变参数版本的声明必须在作用域中否则,可变参数版本会无限递归。
扩展中的模式会独立地应用于包中的每个元素。
建议:转发和可变参数模板
可变参数函数通常将它们的参数转发给其他函数。这种函数通常具有与我们的emplace back函数一样的形式:
//fun有零个或多个参数,每个参数都是一个模板参数类型的右值引用
template<typename...Args>void fun(rgs&&...args)//将Args扩展为一个右值引用的列表
{
// work的实参既扩展Args又扩展args
work(std::forward<Args>(args)...);
}
这里我们希望将fun的所有实参转发给另一个名为work的函数,假定由它完成函数的实际工作。类似emplace_back中对construct的调用,work调用中的扩展既扩展了模板参数包也扩展了函数参数包。
由于 fun的参数是右值引用,因此我们可以传递给它任意类型的实参;由于我们使用std::forward传递这些实参,因此它们的所有类型信息在调用work时都会得到保持。
16.5 模板特例化
特例化的本质是实例化一个模板,而非重载它。因此,特例化不影响函数匹配。
关键概念:普通作用域规则应用于特例化
为了特例化一个模板,原模板的声明必须在作用域中。而且,在任何使用模板实例的代码之前,特例化版本的声明也必须在作用域中。 对于普通类和函数,丢失声明的情况(通常)很容易发现--编译器将不能继续处理我们的代码。但是,如果丢失了一个特例化版本的声明,编译器通常可以用原模板生成代码。由于在丢失特例化版本时编译器通常会实例化原模板,很容易产生模板及其特例化版本声明顺序导致的错误,而这种错误又很难查找。 如果一个程序使用一个特例化版本,而同时原模板的一个实例具有相同的模板实参集合,就会产生错误。但是,这种错误编译器又无法发现。
提示
模板及其特例化版本应该声明在同一个头文件中。所有同名模板的声明应该放在前面,然后是这些模板的特例化版本。
为了让 Sales data 的用户能使用 hash 的特例化版本,我们应该在Sales data的头文件中定义该特例化版本。
我们只能部分特例化类模板,而不能部分特例化函数模板。
小结
提示
模板是C++语言与众不同的特性,也是标准库的基础。一个模板就是一个编译器用来生成特定类类型或函数的蓝图。生成特定类或函数的过程称为实例化。我们只编写一次模板,就可以将其用于多种类型和值,编译器会为每种类型和值进行模板实例化。 我们既可以定义函数模板,也可以定义类模板。标准库算法都是函数模板,标准库容器都是类模板 显式模板实参允许我们固定一个或多个模板参数的类型或值。对于指定了显式模板实参的模板参数,可以应用正常的类型转换。 -个模板特例化就是一个用户提供的模板实例,它将一个或多个模板参数绑定到特定类型或值上。当我们不能(或不希望)将模板定义用于某些特定类型时,特例化非常有用。最新C++标准的一个主要部分是可变参数模板。一个可变参数模板可以接受数目和类型可变的参数。可变参数模板允许我们编写像容器的emplace成员和标准make shared函数这样的函数,实现将实参传递给对象的造函数。
第 IV 部分 高级主题
第 17 章 标准库特殊设施
17.1 tuple 类型
我们可以将 tuple看作一个“快速而随意”的数据结构。
由于 tuple 定义了<和==运算符,我们可以将tuple序列传递给算法,并且可以在无序容器中将tuple作为关键字类型。
17.2 bitset 类型
string的下标编号习惯与bitset恰好相反:string中下标最大的字符(最右字符)用来初始化bitset中的低位(下标为0的二进制位)。当你用一个string初始化一个bitset时,要记住这个差别。
如果 bitset 中的值不能放入给定类型中,则这两个操作会抛出一个overflowerror异常(参见5.6节,第173页)。
17.3 正则表达式
需要意识到的非常重要的一点是,一个正则表达式的语法是否正确是在运行时解析的。
建议:避免创建不必要的正则表达式
如我们所见,一个正则表达式所表示的“程序”是在运行时而非编译时编译的。正则表达式的编译是一个非常慢的操作,特别是在你使用了扩展的正则表达式语法或是复杂的正则表达式时。因此,构造一个regex对象以及向一个已存在的regex赋予一个新的正则表达式可能是非常耗时的。为了最小化这种开销,你应该努力避免创建很多不必要的regex。特别是,如果你在一个循环中使用正则表达式,应该在循环外创建它而不是在每步迭代时都编译它。
17.4 随机数
提示
C++程序不应该使用库函数rand,而应使用default random engine 类和恰当的分布类对象。
当我们说随机数发生器时,是指分布对象和引警对象的组合。
一个给定的随机数发生器一直会生成相同的随机数序列。一个函数如果定义了局部的随机数发生器,应该将其(包括引警和分布对象)定义为static的。否则,每次调用函数都会生成相同的序列。
如果程序作为一个自动过程的一部分反复运行,将time的返回值作为种子的方式就无效了;它可能多次使用的都是相同的种子。
由于引擎返回相同的随机数序列(参见17.41节,第661页),所以我们必须在循环外声明引擎对象。否则,每步循环都会创建一个新引警,从而每步循环都会生成相同的值。类似的,分布对象也要保持状态,因此也应该在循环外定义。
17.5 IO库再探
当操纵符改变流的格式状态时,通常改变后的状态对所有后续I都生效。
操纵符 hex、oct和dec只影响整型运算对象,浮点值的表示形式不受影响。
操纵符 setprecision 和其他接受参数的操纵符都定义在头文件iomanip中。
除非你需要控制浮点数的表示形式(如,按列打印数据或打印表示金额或百分比的数据),否则由标准库选择记数法是最好的方式。
setw类似end1,不改变输出流的内部状态。它只决定下一个输出的大小。
一个常见的错误是本想从流中删除分隔符,但却忘了做。
小心:低层函数容易出错
一般情况下,我们主张使用标准库提供的高层抽象。返回int的10操作很好地解释了原因。
一个常见的编程错误是将get或peek的返回值赋予一个char而不是一个int。这样做是错误的,但编译器却不能发现这个错误。最终会发生什么依赖于程序运行于哪台机器以及输入数据是什么。例如,在一台char被实现为unsigned char的机器上,下面的循环永远不会停止:
charch;//此处使用char就是引入灾难!
//从cin.get返回的值被转换为char,然后与一个int比较
while((ch=cin.get())!-EOF)
cout.put(ch);
问题出在当get返回EOF时,此值会被转换为一个unsignedchar。转换得到的值与EOE的int值不再相等,因此循环永远也不会停止。这种错误很可能在调试时发现。
在一台char被实现为signedchar的机器上,我们不能确定循环的行为。当一个越界的值被赋予一个signed 变量时会发生什么完全取决于编译器。在很多机器上,这个循环可以正常工作,除非输入序列中有一个字符与EOF值匹配。虽然在普通数据中这种字符不太可能出现,但低层I〇通常用于读取二进制值的场合,而这些二进制值不能直接映射到普通字符和数值。例如,在我们的机器上,如果输入中包含有一个值为'\377'的字符,则循环会提前终止。因为在我们的机器上,将-1转换为一个 signedchar,就会得到’377’。如果输入中有这个值,则它会被(过早)当作文件尾指示符当我们读写有类型的值时,这种错误就不会发生。如果你可以使用标准库提供的类型更加安全、更高层的操作,就应该使用它们。
随机 IO 本质上是依赖于系统的。为了理解如何使用这些特性,你必须查询系统文档。
注意
由于istream和ostream类型通常不支持随机访问,所以本节剩余内容只适用于fstream和sstream类型。
由于只有单一的标记,因此只要我们在读写操作间切换,就必须进行seek操作来重定位标记。
小结
小结
本章介绍了一些特殊I〇操作和四个标准库类型:tuple、bitset、正则表达式和随机数。 tuple是一个模板,允许我们将多个不同类型的成员捆绑成单一对象。每个tuple包含指定数量的成员,但对一个给定的tuple类型,标准库并未限制我们可以定义的成员数量上限。 bitset允许我们定义指定大小的二进制位集合。标准库不限制一个bitset的大小必须与整型类型的大小匹配,bitset的大小可以更大。除了支持普通的位运算符(参见4.8节,第136页)外,bitset还定义了一些命名的操作,允许我们操纵bitset中特定位的状态。 正则表达式库提供了一组类和函数:regex类管理用某种正则表达式语言编写的正则表达式。匹配类保存了某个特定匹配的相关信息。这些类被函数regex_search和regex_match所用。这两个函数接受一个regex对象和一个字符序列,检查regex中的正则表达式是否匹配给定的字符序列。regex迭代器类型是迭代器适配器,它们使用regex_search遍历输入序列,返回每个匹配的子序列。标准库还定义了一个regex_replace函数,允许我们用指定内容替换输入序列中与正则表达式匹配的部分。 随机数库由一组随机数引擎类和分布类组成。随机数引擎返回一个均分布的整型值序列。标准库定义了多个引擎,它们具有不同的性能特点。default_random_engine是适合于大多数普通情况的引擎。标准库还定义了20个分布类型。这些分布类型使用一个引擎来生成指定类型的随机数,这些随机数的值都在给定范围内,且分布满足指定的概率分布。
第 18 章 用于大型程序的工具
18.1 异常处理
一个异常如果没有被捕获,则它将终止当前的程序。
在栈展开的过程中,运行类类型的局部对象的析构函数。因为这些析构函数是自动执行的,所以它们不应该抛出异常。一旦在栈展开的过程中析构函数抛出了异常,并且析构函数自身没能捕获到该异常,则程序将被终止。
抛出指针要求在任何对应的处理代码存在的地方,指针所指的对象都必须存在。
通常情况下,如果catch接受的异常与某个继承体系有关,则最好将该 catch的参数定义成引用类型。
如果在多个 catch 语句的类型之间存在着继承关系,则我们应该把继承链最底端的类(most derivedtype)放在前面,而将继承链最顶端的类(least derivedtype)放在后面。
如果 catch(...)与其他几个catch语句一起出现,则catch(...)必须在最后的位置。出现在捕获所有异常语句后面的catch语句将永远不会被匹配。
处理构造函数初始值异常的唯一方法是将构造函数写成函数try语句块。
通常情况下,编译器不能也不必在编译时验证异常说明。
noexcept有两层含义:当跟在函数参数列表后面时它是异常说明符;而当作为 noexcept异常说明的bool实参出现时,它是一个运算符。
18.2 命名空间
命名空间作用域后面无须分号。
定义多个类型不相关的命名空间应该使用单独的文件分别表示每个类型(或关联类型构成的集合)。
和其他命名空间不同,未命名的命名空间仅在特定的文件内部有效,其作用范围不会横跨多个不同的文件。
未命名的命名空问取代文件中的静态声明
在标准C++引入命名空间的概念之前,程序需要将名字声明成static的以使得其对于整个文件有效。在文件中进行静态声明的做法是从C语言继承而来的。在C语言中,声明为static的全局实体在其所在的文件外不可见。
注意
在文件中进行静态声明的做法已经被C++标准取消了,现在的做法是使用未命名的命名空间。
一个命名空间可以有好几个同义词或别名,所有别名都与命名空间原来的名字等价。
如果我们提供了一个对std等命名空间的using 指示而未做任何特殊控制的话,将重新引入由于使用了多个库而造成的名字冲突问题。
提示:避免 using指示
using 指示一次性注入某个命名空间的所有名字,这种用法看似简单实则充满了风险:只使用一条语句就突然将命名空间中所有成员的名字变得可见了。如果应用程序使用了多个不同的库,而这些库中的名字通过using指示变得可见,则全局命名空间污染的问题将重新出现。 而且,当引入库的新版本后,正在工作的程序很可能会编译失败。如果新版本引入了一个与应用程序正在使用的名字冲突的名字,就会出现这个问题。 另一个风险是由using指示引发的二义性错误只有在使用了冲突名字的地方才能被发现。这种延后的检测意味着可能在特定库引入很久之后才爆发冲突。直到程序开始使用该库的新部分后,之前一直未被检测到的错误才会出现。 相比于使用using指示,在程序中对命名空间的每个成员分别使用using声明效果更好,这么做可以减少注入到命名空间中的名字数量。using声明引起的二义性问题在声明处就能发现,无须等到使用名字的地方,这显然对检测并修改错误大有益处。
提示
using指示也并非一无是处,例如在命名空间本身的实现文件中就可以使用using 指示。
可以从函数的限定名推断出查找名字时检查作用域的次序,限定名以相反次序指出被查找的作用域。
18.3 多重继承与虚继承
当一个类拥有多个基类时,有可能出现派生类从两个或更多基类中继承了同名成员的情况。此时,不加前缀限定符直接使用该名字将引发二义性。
虚派生只影响从指定了虚基类的派生类中进一步派生出的类,它不会影响派生类本身。
虚基类总是先于非虚基类构造,与它们在继承体系中的次序和位置无关。
18.4 小结
小结
C++语言可以用于解决各种类型的问题,既有几个小时就可以解决的小问题,也有一个大团队工作数年才能解决的超大规模问题。C++的某些特性特别适合于处理超大规模问题,这些特性包括:异常处理、命名空间以及多重继承或虚继承。 异常处理使得我们可以将程序的错误检测部分与错误处理部分分隔开来。当程序抛出一个异常时,当前正在执行的函数暂时中止,开始查找最邻近的与异常匹配的catch语句。作为异常处理的一部分,如果查找catch语句的过程中退出了某些函数,则函数中定义的局部变量也随之销毁。 命名空间是一种管理大规模复杂应用程序的机制,这些应用可能是由多个独立的供应商分别编写的代码组合而成的。一个命名空间是一个作用域,我们可以在其中定义对象、类型、函数、模板以及其他命名空间。标准库定义在名为std的命名空间中。 从概念上来说,多重继承非常简单:一个派生类可以从多个直接基类继承而来。在派生类对象中既包含派生类部分,也包含与每个基类对应的基类部分。虽然看起来很简单,但实际上多重继承的细节非常复杂。特别是对多个基类的继承可能会引入新的名字冲突,并造成来自于基类部分的名字的二义性问题。 如果一个类是从多个基类直接继承而来的,那么有可能这些基类本身又共享了另一-个基类。在这种情况下,中间类可以选择使用虚继承,从而声明愿意与层次中虚继承同一基类的其他类其享虚基类。用这种方法,后代派生类中将只有一个其享虚基类的副本。
第 19 章 特殊工具与技术
19.1 控制内存分配
当自定义了全局的operator new函数和operator delete函数后,我们就担负起了控制动态内存分配的职责。这两个函数必须是正确的:因为它们是程序整个处理过程中至关重要的一部分。
术语:new表达式与operatornew数
标准库函数 operator new和operator delete的名字容易让人误解。和其他operator函数不同(比如operator=),这两个函数并没有重载new表达式或delete表达式。实际上,我们根本无法自定义new表达式或delete表达式的行为。 一条new表达式的执行过程总是先调用operator new函数以获取内存空间,然后在得到的内存空间中构造对象。与之相反,一条delete表达式的执行过程总是先销毁对象,然后调用operatordelete函数释放对象所占的空间。 我们提供新的operator new函数和operator delete函数的目的在于改变内存分配的方式,但是不管怎样,我们都不能改变new运算符和delete运算符的基本含义。
当只传入一个指针类型的实参时,定位new表达式构造对象但是不分配内存。
调用析构函数会销毁对象,但是不会释放内存。
19.2 运行时类型识别
注意
使用RTTI必须要加倍小心。在可能的情况下,最好定义虚函数而非直接接管类型管理的重任。
我们可以对一个空指针执行dynamiccast,结果是所需类型的空指针。
提示
在条件部分执行 dynamic cast操作可以确保类型转换和结果检查在同一条表达式中完成。
当 typeid 作用于指针时(而非指针所指的对象),返回的结果是该指针的静态编译时类型。
type info 类在不同的编译器上有所区别。有的编译器提供了额外的成员函数以提供程序中所用类型的额外信息。读者应该仔细阅读你所用编译器的使用手册,从而获取关于type info的更多细节。
19.3 枚举类型
19.4 类成员指针
因为函数调用运算符的优先级较高,所以在声明指向成员函数的指针并使用这样的指针进行函数调用时,括号必不可少:(C::*p)(parms)和(obj.*p)(args)。
通过使用类型别名,可以令含有成员指针的代码更易读写。
19.5 嵌套类
在嵌套类在其外层类之外完成真正的定义之前,它都是一个不完全类型(参见7.3.3节,第250页)。
19.6 union: 一种节省空间的类
匿名 union不能包含受保护的成员或私有成员,也不能定义成员函数。
19.7 局部类
局部类的所有成员(包括函数在内)都必须完整定义在类的内部。因此,局部类的作用与嵌套类相比相差很远。
19.8 固有的不可移植的特性
位域在内存中的布局是与机器相关的。
注意
通常情况下最好将位域设为无符号类型,存储在带符号类型中的位域的行为将因具体实现而定。
注意
volatile的确切含义与机器有关,只能通过阅读编译器文档来理解。要想让使用了 volatile 的程序在移植到新机器或新编译器后仍然有效,通常需要对该程序进行某些改变。
要想把 C++代码和其他语言(包括C语言)编写的代码放在一起使用,要求我们必须有权访问该语言的编译器,并且这个编译器与当前的C++编译器是兼容的。
C++从C语言继承的标准库函数可以定义成C函数,但并非必须:决定使用还是C++实现C标准库,是每个C1实现的事情。
注意
有的C++编译器会接受之前的这种赋值操作并将其作为对语言的扩展,尽管从严格意义上来看它是非法的。
对链接到C的预处理器的支持
有时需要在C和C++中编译同一个源文件,为了实现这一目的,在编译C++版本的程序时预处理器定义cplusplus(两个下画线)。利用这个变量,我们可以在编译C++程序的时候有条件地包含进来一些代码:
#ifdefcplusplus
//正确:我们正在编译C++程序
extern "C"
#endif
int strcmp(const char*,const char*);
小结
小结
C++为解决某些特殊问题设置了一系列特殊的处理机制。
有的程序需要精确控制内存分配过程,它们可以通过在类的内部或在全局作用域中自定义operator new和operator delete来实现这一目的。如果应用程序为这两个操作定义了自己的版本,则new和delete表达式将优先使用应用程序定义的版本。
有的程序需要在运行时直接获取对象的动态类型,运行时类型识别(RTTI)为这种程序提供了语言级别的支持。RTTI只对定义了虚函数的类有效:对没有定义虚函数的类,虽然也可以得到其类型信息,但只是静态类型。
当我们定义指向类成员的指针时,在指针类型中包含了该指针所指成员所属类的类型信息。成员指针可以绑定到该类当中任意一个具有指定类型的成员上。当我们解引用成员指针时,必须提供获取成员所需的对象。 C++定义了另外几种聚集类型:
- 嵌套类,定义在其他类的作用域中,嵌套类通常作为外层类的实现类。
- union,是一种特殊的类,它可以定义几个数据成员但是在任意时刻只有一个成员有值,union通常嵌套在其他类的内部。
- 局部类,定义在函数的内部,局部类的所有成员都必须定义在类内,局部类不能含有静态数据成员。
C++支持几种固有的不可移植的特性,其中位域和volatile使得程序更容易访问硬件;链接指示使得程序更容易访问用其他语言编写的代码。