《C++ Primer》——在读
约 21536 字大约 72 分钟
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 章 开始
包含来自标准库的头文件时,也应该用尖括号(<>)包围头文件名。对于不属于标准库的头文件,则用双引号("")包围。
第 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++语言中关于作用域的规则。
整个程序中的预处理变量包括头文件保护符必须唯一,通常的做法是基于头文件中类的名字来构建保护符的名字,以确保其唯一性。为了避免与程序中的其他实体发生名字冲突,一般把预处理变量的名字全部大写。
提示
头文件即使(目前还)没有被包含在任何其他头文件中,也应该设置保护符头文件保护符很简单,程序员只要习惯性地加上就可以了,没必要太在乎你的程序到底需不需要。
第 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个整数的数组
第 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 运算符优先级表
第 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)的代码。然而经验表明,编写异常安全的代码非常困难,这部分知识也(远远)超出了本书的范围。
对于一些程序来说,当异常发生时只是简单地终止程序。此时,我们不怎么需要担心异常安全的问题。
但是对于那些确实要处理异常并继续执行的程序,就要加倍注意了。我们必须时刻清楚异常何时发生,异常发生后程序应如何确保对象有效、资源无泄漏、程序处于合理状态,等等。
我们会在本书中介绍一些比较常规的提升异常安全性的技术。但是读者需要注意,如果你的程序要求非常鲁棒的异常处理,那么仅有我们介绍的这些技术恐怕还是不够的。
第 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 );
第 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关键字则只出现在类内部的声明语句中。
注
要想确保对象只定义一次,最好的办法是把静态数据成员的定义与其他非内联函数的定义放在同一个文件中。
注
即使一个常量静态数据成员在类内部被初始化了,通常情况下也应该在类的外部定义一下该成员。
第 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 流
第 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列出了所有容器适配器都支持的操作和类型。
第 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,应该优先使用成员函数版本的算法而不是通用算法。
第 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则是一个无序的关键字集合,其中关键字可以出现多次。
关联容器和顺序容器有很多共同的元素。但是,关联容器定义了一些新操作,并对--些和顺序容器和关联容器都支持的操作重新定义了含义或返回类型。操作的不同反映出关联容器使用关键字的特点。
有序容器的迭代器通过关键字有序访问容器中的元素。无论在有序容器中还是在无序容器中,具有相同关键字的元素都是相邻存储的。
第 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第二个指针,自由空间就可能被破坏。相对于查找和修正这些错误来说,制造出这些错误要简单得多
提示
坚持只使用智能指针,就可以避免所有这些问题。对于一块内存,只有在没有任何智能指针指向它的情况下,智能指针才会自动释放它。