《C++ Primer》第五版笔记
Stanley B. Lippman,Josée Lajoie,Barbara E. Moo 著
王刚,杨巨峰 译
摘录整理。
推荐序1
第5版之于2011版标准,如同第3版之于1998版标准,必将成为经典的学习读本。
第5版则更加像一本学习教程,由浅入深,并结合大量代码实例来讲述C++语法和标准库。
学习语言的一个境界是把自己想象成编译器。
本书的另一个特色是将C++的语法和标准库融为一体来介绍。C++标准库本身就是C++语法的最佳样例,其中包含不少C++高级特性的指导性用法。在我的程序设计经历中,有些C++语言特性(比如虚拟继承),我只在标准库中看到过实用做法。
在实践中,不必全面地使用C++语言的各种特性,而应根据工程项目的实际情况,适当取舍(譬如动态类型信息、虚拟继承、异常等特性的使用很值得商榷)。通常只鼓励使用C++语言的一个子集就够了,一个值得学习和参考的例子是Google发布的Google C++ Style Guide。
推荐序2
编译器实现的速度也令人惊喜。短短两年时间,从开源的GCC、LLVM到专有的Visual C++和Intel C++,对于新标准的追踪之快,覆盖之全,与当年C++98标准颁布之后迟迟不能落地的窘境相比,可谓对比强烈。
一种优秀的编程语言,一定要对于计算这件事情实现一个完整和自洽的抽象。
而C语言之所以四十年长盛不衰,根本在于它对于现代计算机提供了一个底层的高级抽象:凡是比它低的抽象都过于简陋,凡是比它高的抽象都可以用C语言构造出来。
每当你需要走下去直接与硬件对话时,C++成为C之外唯一有效率的选择。
C++同时支持4种不同的编程风格:C风格、基于对象、面向对象和泛型。事实上,把微软的COM也算进来的话,还可以加上一种“基于组件”的风格。
每一个具体的技术领域,只需要读四五本书就够了。以前的C++是个例外,因为语言设计有缺陷,所以要读很多书才知道如何绕过缺陷。现在的C++11完全可以了,大家读四五本书就可以达到合格的水平,这恰恰是语言进步的体现。
本书是这四五本中的一本,而且是“教程+参考书”,扛梁之作,初学者的不二法门。另一本是《C++标准程序库》,对于C++熟手来说更为快捷。Scott Meyers的Effective C++永远是学习C++者必读的,只不过这本书的第4版不知道什么时候出来。Anthony Williams的C++ Concurrency in Action是学习用标准C++开发并发程序的最佳选择。国内的作品,我则高度推荐陈硕的《Linux多线程服务端编程》。这本书的名字赶跑了不少潜在的读者,所以我要特别说明一下。这本书是C++开发的高水平作品,与其说是教你怎么用C++写服务端开发,不如说是教你如何以服务端开发为例子提升C++开发水平。前面几本书都是谈标准C++自己的事情,碰到像iostream这样失败的标准组件也不得不硬着头皮介绍。而这本书是接地气的实践结晶,告诉你面对具体问题时应怎样权衡,C++里什么好用,什么不好用,为什么,等等。
前言
2011年,C++标准委员会发布了ISO C++标准的一个重要修订版。此修订版是C++进化过程中的最新一步,延续了前几个版本对编程效率的强调。新标准的主要目标是:
- 使语言更为统一,更易于教学。
- 使标准库更简单、安全、使用更高效。
- 使编写高效率的抽象和库变得更简单。
现代C++语言可以看作是三部分组成的:
- 低级语言,大部分继承自C语言。
- 现代高级语言特性,允许我们定义自己的类型以及组织大规模程序和系统。
- 标准库,它利用高级特性来提供有用的数据结构和算法。
扩展示例的源码,在书本下方的download选项卡里下载。
第1章 开始
1.1 编写一个简单的C++程序
一个函数的定义包含四部分:返回类型(return type)、函数名(function name)、一个括号包围的形参列表(parameter list,允许为空)以及函数体(function body)。
在大多数系统中,main的返回值被用来指示状态。返回值0表明成功,非0的返回值的含义由系统定义,通常用来指出错误类型。
命令行运行编译器:
1 |
|
CC是编译器程序的名字,$是系统提示符。编译器生成一个可执行文件。Windows系统会将这个可执行文件命名为prog1.exe。UNIX系统中的编译器通常将可执行文件命名为a.out。在Windows下执行可以忽略其扩展名.exe,在一些系统中需要用\(./\)显式指出其位于当前目录。
访问main的返回值的方法依赖于系统。在UNIX和Windows系统中,执行完一个程序后,都可以通过echo命令获得其返回值。
1 |
|
默认情况下,运行GNU编译器的命令是g++:
1 |
|
-o prog1是编译器参数,指定了可执行文件的文件名。在不同的操作系统中,此命令生成一个名为prog1或prog1.exe的可执行文件。在UNIX系统中,可执行文件没有后缀;在Windows系统中,后缀为.exe。如果省略了-o prog1参数,在UNIX系统中编译器会生成一个名为a.out的可执行文件,在Windows系统中则会生成一个名为a.exe的可执行文件。
根据使用的GNU编译器的版本,你可能需要指定-std=c++0x参数来打开对C++11的支持。
1.2 初识输入输出
cin、cout、cerr、clog
写入endl的效果是结束当前行,并将与设备关联的缓冲区(buffer)中的内容刷到设备中。缓冲刷新操作可以保证到目前为止程序所产生的所有输出都真正写入输出流中,而不是仅停留在内存中等待写入流。
1 |
|
1.3 注释简介
C++中有两种注释:单行注释和界定符对注释。
- 单行注释以双斜线(//)开始,以换行符结束。当前行双斜线右侧的所有内容都会被编译器忽略,这种注释可以包含任何文本,包括额外的双斜线。
- 另一种注释使用继承自C语言的两个界定符(/*和*/)。这种注释以/*开始,以*/结束,可以包含除*/外的任意内容,包括换行符。编译器将落在/*和*/之间的所有内容都当作注释。
注释风格:
1 |
|
注释界定符不能嵌套!单行注释会忽略一切。
1.4 控制流
while、for、if
1 |
|
1.5 类简介
成员函数是定义为类的一部分的函数,有时也被称为方法(method)。
1.6 书店程序
无。
第Ⅰ部分 C++基础
第2章 变量和基本类型
2.1 基本内置类型
C++定义了一套包括算术类型(arithmetic type)和空类型(void)在内的基本数据类型。其中算术类型包含了字符、整型数、布尔值和浮点数。空类型不对应具体的值,仅用于一些特殊的场合,例如最常见的是,当函数不返回任何值时使用空类型作为返回类型。
大多数计算机以2的整数次幂个比特作为块来处理内存,可寻址的最小内存块称为“字节(byte)”,存储的基本单元称为“字(word)”,它通常由几个字节组成。在C++语言中,一个字节要至少能容纳机器基本字符集中的字符。大多数机器的字节由8比特构成,字则由32或64比特构成,也就是4或8字节。
如果表达式里既有带符号类型又有无符号类型,当带符号类型取值为负时会出现异常结果,这是因为带符号数会自动地转换成无符号数。
2.2 变量
在C++语言中,初始化是一个异常复杂的问题,我们也将反复讨论这个问题。很多程序员对于用等号=来初始化变量的方式倍感困惑,这种方式容易让人认为初始化是赋值的一种。事实上在C++语言中,初始化和赋值是两个完全不同的操作。然而在很多编程语言中二者的区别几乎可以忽略不计,即使在C++语言中有时这种区别也无关紧要,所以人们特别容易把二者混为一谈。
初始化不是赋值,初始化的含义是创建变量时赋予其一个初始值,而赋值的含义是把对象的当前值擦除,而以一个新值来替代。
1 |
|
使用{}初始化被称为列表初始化(list initialization)。在 C++11 中,初始化列表的适用性被大大增加了。它可以用于任何类型对象的初始化。
定义于任何函数体之外的变量被初始化为0。定义在函数体内部的内置类型变量将不被初始化(uninitialized)。一个未被初始化的内置类型变量的值是未定义的(参见2.1.2节,第33页),如果试图拷贝或以其他形式访问此类值将引发错误。
为了允许把程序拆分成多个逻辑部分来编写,C++语言支持分离式编译(separate compilation)机制,该机制允许将程序分割为若干个文件,每个文件可被独立编译。
为了支持分离式编译,C++语言将声明和定义区分开来。声明(declaration)使得名字为程序所知,一个文件如果想使用别处定义的名字则必须包含对那个名字的声明。而定义(definition)负责创建与名字关联的实体。变量声明规定了变量的类型和名字,在这一点上定义与之相同。但是除此之外,定义还申请存储空间,也可能会为变量赋一个初始值。
1 |
|
在函数体内部,如果试图初始化一个由extern关键字标记的变量,将引发错误。
变量能且只能被定义一次,但是可以被多次声明。
如果要在多个文件中使用同一个变量,就必须将声明和定义分离。此时,变量的定义必须出现在且只能出现在一个文件中,而其他用到该变量的文件必须对其进行声明,却绝对不能重复定义。
C++是一种静态类型(statically typed)语言,其含义是在编译阶段检查类型。其中,检查类型的过程称为类型检查(type checking)。
作用域(scope)是程序的一部分,在其中名字有其特定的含义。C++语言中大多数作用域都以花括号分隔。同一个名字在不同的作用域中可能指向不同的实体。名字的有效区域始于名字的声明语句,以声明语句所在的作用域末端为结束。
因为全局作用域本身并没有名字,所以当作用域操作符的左侧为空时,向全局作用域发出请求获取作用域操作符右侧名字对应的变量。
2.3 复合类型
复合类型(compound type)是指基于其他类型定义的类型。C++语言有几种复合类型,本章将介绍其中的两种:引用和指针。
引用
引用并非对象,相反的,它只是为一个已经存在的对象所起的另外一个名字。因为引用本身不是一个对象,所以不能定义引用的引用。一旦定义了引用,就无法令其再绑定到另外的对象,之后每次使用这个引用都是访问它最初绑定的那个对象。
除了2.4.1节(第55页)和15.2.3节(第534页)将要介绍的两种例外情况,其他所有引用的类型都要和与之绑定的对象严格匹配。而且,引用只能绑定在对象上,而不能与字面值或某个表达式的计算结果绑定在一起。
第一种例外情况就是在初始化常量引用时允许用任意表达式作为初始值,只要该表达式的结果能转换成引用的类型即可。尤其,允许为一个常量引用绑定非常量的对象、字面值,甚至是个一般表达式。这是因为编译器创建了一个临时变量作为绑定的中间值。
1
2
3
4
5
6
int i = 42;
const int &r1 = i; // 正确
// 相当于 const int temp = i; cosnt int &r1 = temp; 因此i和r1的类型可以不一致
const int &r2 = 42; // 正确
const int &r3 = r1 * 2; // 正确
int &r4 = r1 * 2; // 错误第二种例外情况是,我们可以将基类的指针或引用绑定到派生类对象上。
指针
其一,指针本身就是一个对象,允许对指针赋值和拷贝,而且在指针的生命周期内它可以先后指向几个不同的对象。其二,指针无须在定义时赋初值。和其他内置类型一样,在块作用域内定义的指针如果没有被初始化,也将拥有一个不确定的值。
除了2.4.2节(第56页)和15.2.3节(第534页)将要介绍的两种例外情况,其他所有指针的类型都要和它所指向的对象严格匹配。
第一种例外情况是允许令一个指向常量的指针指向一个非常量对象:
1
2
double pi = 3.14;
const double *cptr = π和常量引用一样,指向常量的指针也没有规定其所指的对象必须是一个常量。所谓指向常量的指针仅仅要求不能通过该指针改变对象的值,而没有规定那个对象的值不能通过其他途径改变。
第二种例外情况是,我们可以将基类的指针或引用绑定到派生类对象上。
空指针
得到空指针最直接的办法就是用字面值nullptr来初始化指针,这也是C++11新标准刚刚引入的一种方法。nullptr是一种特殊类型的字面值,它可以被转换成任意其他的指针类型。另一种办法就如对p2的定义一样,也可以通过将指针初始化为字面值0来生成空指针。过去的程序还会用到一个名为NULL的预处理变量(preprocessor variable)来给指针赋值,这个变量在头文件cstdlib中定义,它的值就是0。
预处理变量不属于命名空间std,它由预处理器负责管理,因此我们可以直接使用预处理变量而无须在前面加上std::。
以void*的视角来看内存空间也就仅仅是内存空间,没办法访问内存空间中所存的对象,关于这点将在19.1.1节(第726页)有更详细的介绍,4.11.3节(第144页)将讲述获取void*指针所存地址的方法。
当我们把指针存放在void*中,并且使用static_cast将其强制转换回原来的类型时,应该确保指针的值保持不变。也就是说,强制转换的结果将与原始的地址值相等,因此我们必须确保转换后所得的类型就是指针所指的类型。类型一旦不符,将产生未定义的后果。
1 |
|
2.4 const限定符
因为const对象一旦创建后其值就不能再改变,所以const对象必须初始化。
默认状态下,const对象仅在文件内有效。
如果想在多个文件之间共享const对象,必须在变量的定义之前添加extern关键字。
const的引用
可以把引用绑定到const对象上,就像绑定到其他对象上一样,我们称之为对常量的引用(reference to const)。与普通引用不同的是,对常量的引用不能被用作修改它所绑定的对象。
对const的引用可能引用一个并非const的对象。
1 |
|
(自己补充的例子)
由于前面2.3中提到的引用绑定的例外情况,对常量的引用会存在以下情况:
1
2
3
4
5
6
7
8
9
10
double i = 42;
const int &r1 = i;
i = 0;
cout << r1 << endl;
// 42
int j = 42;
const int &r2 = j;
j = 0;
cout << r2 << endl;
// 0
指针和const
用名词顶层const(top-level const)表示指针本身是个常量,而用名词底层const(low-level const)表示指针所指的对象是一个常量。
更一般的,顶层const可以表示任意的对象是常量,这一点对任何数据类型都适用,如算术类型、类、指针等。底层const则与指针和引用等复合类型的基本类型部分有关。比较特殊的是,指针类型既可以是顶层const也可以是底层const,这一点和其他类型相比区别明显。
1 |
|
constexpr和常量表达式
常量表达式(const expression)是指值不会改变并且在编译过程就能得到计算结果的表达式。显然,字面值属于常量表达式,用常量表达式初始化的const对象也是常量表达式。
1 |
|
在一个复杂系统中,很难(几乎肯定不能)分辨一个初始值到底是不是常量表达式。当然可以定义一个const变量并把它的初始值设为我们认为的某个常量表达式,但在实际使用时,尽管要求如此却常常发现初始值并非常量表达式的情况。可以这么说,在此种情况下,对象的定义和使用根本就是两回事儿。
C++11新标准规定,允许将变量声明为constexpr类型以便由编译器来验证变量的值是否是一个常量表达式。声明为constexpr的变量一定是一个常量,而且必须用常量表达式初始化。
常量表达式的值需要在编译时就得到计算,因此对声明constexpr时用到的类型必须有所限制。因为这些类型一般比较简单,值也显而易见、容易得到,就把它们称为“字面值类型”(literal type)。其他一些字面值类型将在7.5.6节(第267页)和19.3节(第736页)介绍。
除了算术类型、引用和指针外,某些类也是字面值类型。和其他类不同,字面值类型的类可能含有constexpr函数成员。这样的成员必须符合constexpr函数的所有要求,它们是隐式const的(参见7.1.2节,第231页)。
枚举属于字面值常量类型(参见7.5.6节,第267页)。
尽管指针和引用都能定义成constexpr,但它们的初始值却受到严格限制。一个constexpr指针的初始值必须是nullptr或者0,或者是存储于某个固定地址中的对象。
在constexpr声明中如果定义了一个指针,限定符constexpr仅对指针有效,与指针所指的对象无关。
(constexpr只能写在指针左边。)
2.5 处理类型
有两种方法可用于定义类型别名。
1 |
|
不能用简单的代换来理解别名。
1 |
|
auto
编译器推断出来的auto类型有时候和初始值的类型并不完全一样,编译器会适当地改变结果类型使其更符合初始化规则。
使用auto也能在一条语句中声明多个变量。因为一条声明语句只能有一个基本数据类型,所以该语句中所有变量的初始基本数据类型都必须一样(符号&和*只从属于某个声明符,而非基本数据类型的一部分):
1 |
|
对引用对象,编译器以它引用的类型作为auto的类型。
1
2int i = 0, &r = i;
auto a = r; // a是整数auto一般会忽略掉顶层const,同时底层const则会保留下来。如果希望推断出的auto类型是一个顶层const,需要明确指出。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17const int ci = i, &cr = ci;
auto b = ci; // b是整数(顶层const,忽略)
auto c = cr; // c是整数
auto d = &i; // d是整数指针
auto e = &ci; // e是指向整数常量的指针(对常量对象取地址是底层const)
const auto f = ci; // f是const int
// 设置一个类型为auto的引用时,初始值中的顶层const属性仍然保留。
auto &g = ci; // g是整数常量引用
auto &h = 42; // 错误,不能为非常量引用绑定字面值
const auto &j = 42; // 正确
// 续初始基本数据类型一致原则
auto k = ci, &l = i; //k是整数,l是整型引用
auto &m = ci, *p = &ci; //m是整型常量引用,p是指向整型常量的指针
auto &n = i, *p2 = &ci; //错误,不一致
decltype
希望从表达式的类型推断出要定义的变量的类型,但是不想用该表达式的值初始化变量。
decltype处理顶层const和引用的方式与auto有些许不同。如果decltype使用的表达式是一个变量,则decltype返回该变量的类型(包括顶层const和引用在内)。
1 |
|
2.6 自定义数据结构
C++11新标准规定,可以为数据成员提供一个类内初始值(in-class initializer)。创建对象时,类内初始值将用于初始化数据成员。没有初始值的成员将被默认初始化。
头文件通常包含那些只能被定义一次的实体,如类、const和constexpr变量等。
头文件一旦改变,相关的源文件必须重新编译以获取更新过的声明。
确保头文件多次包含仍能安全工作的常用技术是预处理器(preprocessor),它由C++语言从C语言继承而来。预处理器是在编译之前执行的一段程序,可以部分地改变我们所写的程序。
- #include,当预处理器看到#include标记时就会用指定的头文件的内容代替#include。
- 头文件保护符(header guard),头文件保护符依赖于预处理变量。预处理变量有两种状态:已定义和未定义。#define指令把一个名字设定为预处理变量,另外两个指令则分别检查某个指定的预处理变量是否已经定义:#ifdef当且仅当变量已定义时为真,#ifndef当且仅当变量未定义时为真。一旦检查结果为真,则执行后续操作直至遇到#endif指令为止。
头文件保护符很简单,程序员只要习惯性地加上就可以了,没必要太在乎你的程序到底需不需要。
第3章 字符串、向量和数组
3.1 命名空间的using声明
位于头文件的代码一般来说不应该使用using声明。这是因为头文件的内容会拷贝到所有引用它的文件中去,如果头文件里有某个using声明,那么每个使用了该头文件的文件就都会有这个声明。
3.2 标准库类型string
1 |
|
在执行读取操作时,string对象会自动忽略开头的空白(即空格符、换行符、制表符等)并从第一个真正的字符开始读起,直到遇见下一处空白为止。
getline从给定的输入流中读入内容,直到遇到换行符为止(注意换行符也被读进来了),然后把所读的内容存入到那个string对象中去(注意不存换行符)。
string::size_type类型
string类及其他大多数标准库类型都定义了几种配套的类型。这些配套类型体现了标准库类型与机器无关的特性,类型size_type即是其中的一种。无符号整型数,勿要与带符号数混用,例如比大小。
因为某些历史原因,也为了与C兼容,所以C++语言中的字符串字面值并不是标准库类型string的对象。切记,字符串字面值与string是不同的类型。当把string对象和字符字面值及字符串字面值混在一条语句中使用时,必须确保每个加法运算符(+)的两侧的运算对象至少有一个是string。
一般来说,C++程序应该使用名为cname的头文件而不使用name.h的形式,标准库中的名字总能在命名空间std中找到。如果使用.h形式的头文件,程序员就不得不时刻牢记哪些是从C语言那儿继承过来的,哪些又是C++语言所独有的。
3.3 标准库类型vector
因为vector“容纳着”其他对象,所以它也常被称作容器(container)。
vector是模板而非类型,由vector生成的类型必须包含vector中元素的类型,例如vector<int>。
早期版本的C++标准中如果vector的元素还是vector(或者其他模板类型),则其定义的形式与现在的C++11新标准略有不同。过去,必须在外层vector对象的右尖括号和其元素类型之间添加一个空格,如应该写成vector<vector<int> >而非vector<vector<int>>。
C++语言提供了几种不同的初始化方式,在大多数情况下这些初始化方式可以相互等价地使用,三个例外:
- 使用拷贝初始化时(即使用=时),只能提供一个初始值;
- 如果提供的是一个类内初始值,则只能使用拷贝初始化或使用花括号的形式初始化;
- 如果提供的是初始元素值的列表,则只能把初始值都放在花括号里进行列表初始化,而不能放在圆括号里。
1 |
|
1 |
|
3.4 迭代器介绍
原来使用C或Java的程序员在转而使用C++语言之后,会对for循环中使用!=而非<进行判断有点儿奇怪。C++程序员习惯性地使用!=,是因为因为这种编程风格在标准库提供的所有容器上都有效。所有标准库容器的迭代器都定义了==和!=,但是它们中的大多数都没有定义<运算符。
1 |
|
箭头运算符 -> 把解引用和成员访问两个操作结合在一起:
1 |
|
谨记,但凡是使用了迭代器的循环体,都不要向迭代器所属的容器添加元素。
迭代器相减得到类型名为difference_type的带符号整型数。
3.5 数组
1 |
|
在使用数组下标的时候,通常将其定义为size_t类型。size_t是一种机器相关的无符号类型,它被设计得足够大以便能表示内存中任意对象的大小。在cstddef头文件中定义了size_t类型,这个文件是C标准库stddef.h头文件的C++语言版本。两个指针相减的结果的类型是一种名为ptrdiff_t的标准库类型,带符号类型。
使用数组的时候编译器一般会把它转换成指针。在大多数表达式中,使用数组类型的对象其实是使用一个指向该数组首元素的指针。
1 |
|
内置的下标运算符所用的索引值不是无符号类型,这一点与vector和string不一样。
C风格字符串
字符串字面值是一种通用结构的实例,这种结构即是C++由C继承而来的C风格字符串(C-style character string)。C风格字符串不是一种类型,而是为了表达和使用字符串而形成的一种约定俗成的写法。按此习惯书写的字符串存放在字符数组中并以空字符结束(null terminated)。以空字符结束的意思是在字符串最后一个字符后面跟着一个空字符('\0')。一般利用指针来操作这些字符串。
尽管C++支持C风格字符串,但在C++程序中最好还是不要使用它们。这是因为C风格字符串不仅使用起来不太方便,而且极易引发程序漏洞,是诸多安全问题的根本原因。
1 |
|
3.6 多维数组
严格来说,C++语言中没有多维数组,通常所说的多维数组其实是数组的数组。
1 |
|
第4章 表达式
4.1 基础
作用于一个运算对象的运算符是一元运算符,如取地址符(&)和解引用符(*);作用于两个运算对象的运算符是二元运算符,如相等运算符(==)和乘法运算符(*)。除此之外,还有一个作用于三个运算对象的三元运算符(?:)。函数调用也是一种特殊的运算符,它对运算对象的数量没有限制。
使用重载运算符时,其包括运算对象的类型和返回值的类型,都是由该运算符定义的;但是运算对象的个数、运算符的优先级和结合律都是无法改变的。
C++的表达式要不然是右值(rvalue,读作“are-value”),要不然就是左值(lvalue,读作“ell-value”)。当一个对象被用作右值的时候,用的是对象的值(内容);当对象被用作左值的时候,用的是对象的身份(在内存中的位置)。
假定p的类型是int*,因为解引用运算符生成左值,所以decltype(*p)的结果是int&。另一方面,因为取地址运算符生成右值,所以decltype(&p)的结果是int**,也就是说,结果是一个指向整型指针的指针。
1 |
|
有4种运算符明确规定了运算对象的求值顺序。?: && || ,
。
4.2 算术运算符
1 |
|
C++11新标准则规定商一律向0取整(即直接切除小数部分)。
除了-m导致溢出的特殊情况,其他时候 (-m)/n 和 m/(-n) 都等于 -(m/n),m%(-n) 等于 m%n,(-m)%n 等于 -(m%n)。
4.3 逻辑和关系运算符
逻辑与运算符和逻辑或运算符都是先求左侧运算对象的值再求右侧运算对象的值,当且仅当左侧运算对象无法确定表达式的结果时才会计算右侧运算对象的值。这种策略称为短路求值(short-circuit evaluation)。
4.4 赋值运算符
无。
4.5 递增和递减运算符
除非必须,否则不用递增递减运算符的后置版本。
前置版本的递增运算符避免了不必要的工作,它把值加1后直接返回改变了的运算对象。与之相比,后置版本需要将原始值存储下来以便于返回这个未修改的内容。如果我们不需要修改前的值,那么后置版本的操作就是一种浪费。
简洁可以成为一种美德
1 |
|
4.6 成员访问运算符
箭头运算符作用于一个指针类型的运算对象,结果是一个左值。点运算符分成两种情况:如果成员所属的对象是左值,那么结果是左值;反之,如果成员所属的对象是右值,那么结果是右值。
4.7 条件运算符
?:
具有右结合性。
4.8 位运算符
无。
4.9 sizeof运算符
sizeof运算符返回一条表达式或一个类型名字所占的字节数。
sizeof运算符满足右结合律,其所得的值是一个size_t类型的常量表达式。
1 |
|
sizeof运算符无须我们提供一个具体的对象,因为要想知道类成员的大小无须真的获取该成员。
sizeof运算符的结果部分地依赖于其作用的类型:
- 对char或者类型为char的表达式执行sizeof运算,结果得1。
- 对引用类型执行sizeof运算得到被引用对象所占空间的大小。
- 对指针执行sizeof运算得到指针本身所占空间的大小。
- 对解引用指针执行sizeof运算得到指针指向的对象所占空间的大小,指针不需有效。
- 对数组执行sizeof运算得到整个数组所占空间的大小,等价于对数组中所有的元素各执行一次sizeof运算并将所得结果求和。注意,sizeof运算不会把数组转换成指针来处理。
- 对string对象或vector对象执行sizeof运算只返回该类型固定部分的大小,不会计算对象中的元素占用了多少空间。
4.10 逗号运算符
逗号运算符(comma operator)含有两个运算对象,按照从左向右的顺序依次求值。
4.11 类型转换
编译器会自动隐式转换:
- 在大多数表达式中,比int类型小的整型值首先提升为较大的整数类型。
- 在条件中,非布尔值转换成布尔类型。
- 初始化过程中,初始值转换成变量的类型;在赋值语句中,右侧运算对象转换成左侧运算对象的类型。
- 如果算术运算或关系运算的运算对象有多种类型,需要转换成同一种类型。
- 如第6章将要介绍的,函数调用时也会发生类型转换。
算术转换
如果一个运算对象是无符号类型、另外一个运算对象是带符号类型,而且其中的无符号类型不小于带符号类型,那么带符号的运算对象转换成无符号的。例如,假设两个类型分别是unsigned int和int,则int类型的运算对象转换成unsigned int类型。
如果两个运算对象的类型分别是long和unsigned int,并且int和long的大小相同,则long类型的运算对象转换成unsigned int类型;如果long类型占用的空间比int更多,则unsigned int类型的运算对象转换成long类型。
其它隐式转换:
数组转换成指针
指针的转换
常量整数值0或者字面值nullptr能转换成任意指针类型;指向任意非常量的指针能转换成void *;指向任意对象的指针能转换成const void *。
转换成布尔类型
转换成常量
允许将指向非常量类型的指针/引用转换成指向相应的常量类型的。
类类型定义的转换
例如需要标准库string类型的地方使用C风格字符串
显式转换
static_cast可以执行编译器无法自动执行的类型转换。任何具有明确定义的类型转换,只要不包含底层const,都可以使用static_cast。
1 |
|
const_cast是唯一能改变表达式的常量属性的显示转换(仅限底层const),不能用来改变表达式的类型。
1 |
|
reinterpret_cast非常危险,本质上依赖于机器。要想安全地使用reinterpret_cast必须对涉及的类型和编译器实现转换的过程都非常了解。
1 |
|
dynamic_cast
19.2节详细介绍。
4.12 运算符优先级表
不同地方分级略有差异。
对于含有超过一个运算符的表达式,要想理解其含义关键要理解优先级、结合律和求值顺序。
第5章 语句
5.1 简单语句
空块的作用等价于空语句。
5.2 语句作用域
定义在控制结构当中的变量只在相应语句的内部可见。
5.3 条件语句
如果要在switch结构中case标签内定义变量,最好用花括号限制在语句块内。
书中给的例子比较简略,详细的讨论可以参考:【C++】switch case内部的变量定义问题。变量定义在编译阶段就会执行,变量初始化是运行阶段的事。
5.4 迭代语句
无。
5.5 跳转语句
无。
5.6 try语句块和异常处理
异常处理包括:·
- throw表达式(throw expression),异常检测部分使用throw表达式来表示它遇到了无法处理的问题。我们说throw引发(raise)了异常。
- try语句块(try block),异常处理部分使用try语句块处理异常。try语句块以关键字try开始,并以一个或多个catch子句(catch clause)结束。try语句块中代码抛出的异常通常会被某个catch子句处理。因为catch子句“处理”异常,所以它们也被称作异常处理代码(exception handler)。
- 一套异常类(exception class),用于在throw表达式和相关的catch子句之间传递异常的具体信息。
1 |
|
异常中断了程序的正常流程。异常发生时,调用者请求的一部分计算可能已经完成了,另一部分则尚未完成。通常情况下,略过部分程序意味着某些对象处理到一半就戛然而止,从而导致对象处于无效或未完成的状态,或者资源没有正常释放,等等。那些在异常发生期间正确执行了“清理”工作的程序被称作异常安全(exception safe)的代码。然而经验表明,编写异常安全的代码非常困难,这部分知识也(远远)超出了本书的范围。
C++标准库定义了一组类,用于报告标准库函数遇到的问题。它们分别定义在4个头文件中:
- exception头文件定义了最通用的异常类exception。它只报告异常的发生,不提供任何额外信息。
- stdexcept头文件定义了几种常用的异常类。
- new头文件定义了bad_alloc异常类型,这种类型将在12.1.2节(第407页)详细介绍。
- type_info头文件定义了bad_cast异常类型,这种类型将在19.2节(第731页)详细介绍。
异常类型只定义了一个名为what的成员函数,该函数没有任何参数,返回值是一个指向C风格字符串的const char*。该字符串的目的是提供关于异常的一些文本信息。
第6章 函数
6.1 函数基础
为了与C语言兼容,也可以使用关键字void表示函数没有形参。
我们把只存在于块执行期间的对象称为自动对象(automatic object)。
局部静态对象(local static object)在程序的执行路径第一次经过对象定义语句时初始化,并且直到程序终止才被销毁,在此期间即使对象所在的函数结束执行也不会对它有影响。
函数的声明和函数的定义非常类似,唯一的区别是函数声明无须函数体,用一个分号替代即可。因为函数的声明不包含函数体,所以也就无须形参的名字。事实上,在函数的声明中经常省略形参的名字。函数应该在头文件中声明而在源文件中定义。
分离式编译
如果我们修改了其中一个源文件,那么只需重新编译那个改动了的文件。大多数编译器提供了分离式编译每个文件的机制,这一过程通常会产生一个后缀名是.obj(Windows)或.o(UNIX)的文件,后缀名的含义是该文件包含对象代码(object code)。
6.2 参数传递
和其他变量一样,形参的类型决定了形参和实参交互的方式。如果形参是引用类型,它将绑定到对应的实参上;否则,将实参的值拷贝后赋给形参。
熟悉C的程序员常常使用指针类型的形参访问函数外部的对象。在C++语言中,建议使用引用类型的形参替代指针。
拷贝大的类类型对象或者容器对象比较低效,甚至有的类类型(包括IO类型在内)根本就不支持拷贝操作。当某种类型不支持拷贝操作时,函数只能通过引用形参访问该类型的对象。当函数无须修改引用形参的值时最好使用常量引用。
我们不能把const对象、字面值或者需要类型转换的对象传递给普通的引用形参。
1 |
|
1 |
|
1 |
|
可变形参
为了编写能处理不同数量实参的函数,C++11新标准提供了两种主要的方法:
如果所有的实参类型相同,可以传递一个名为initializer_list的标准库类型。
1
2
3
4
5
6
7
8
9
10
11
12// initializer_list对象中的元素永远是常量值,我们无法改变initializer_list对象中元素的值。
if (expected != actual)
error_msg({"functionX", expected, actual});
else
error_msg({"functionX", "okay"});
void error_msg(initializer_list<string> il)
{
for (auto beg = il.begin(); beg != il.end(); ++beg)
cout << *beg << " ";
cout << endl;
}如果实参的类型不同,我们可以编写一种特殊的函数,也就是所谓的可变参数模板,关于它的细节将在16.4节(第618页)介绍。
还有一种特殊的形参类型(即省略符),可以用它传递可变数量的实参。这种功能一般只用于与C函数交互的接口程序,是为了便于C++程序访问某些特殊的C代码而设置的,这些代码使用了名为varargs的C标准库功能。
6.3 返回类型和return语句
不要返回局部对象的引用或指针。
调用一个返回引用的函数得到左值,其他返回类型得到右值。
允许main函数没有return语句直接结束。如果控制到达了main函数的结尾处而且没有return语句,编译器将隐式地插入一条返回0的return语句。
返回数组指针
1 |
|
6.4 函数重载
如果同一作用域内的几个函数名字相同但形参列表不同,我们称之为重载(overloaded)函数。
main函数不能重载。
底层const才可重载
1 |
|
当我们传递一个非常量对象或者指向非常量对象的指针时,编译器会优先选用非常量版本的函数。
const_cast用于重载
1 |
|
在C++语言中,名字查找发生在类型检查之前。
如果我们在内层作用域中声明名字,它将隐藏外层作用域中声明的同名实体。在不同的作用域中无法重载函数名。
6.5 特殊用途语言特性
默认实参
一旦某个形参被赋予了默认值,它后面的所有形参都必须有默认值。
在给定的作用域中一个形参只能被赋予一次默认实参。
内联函数
在大多数机器上,一次函数调用其实包含着一系列工作:调用前要先保存寄存器,并在返回时恢复;可能需要拷贝实参;程序转向一个新的位置继续执行。内联函数可避免函数调用的开销。
将函数指定为内联函数(inline),通常就是将它在每个调用点上“内联地”展开。
内联说明只是向编译器发出的一个请求,编译器可以选择忽略这个请求。
constexpr函数
函数的返回类型及所有形参的类型都得是字面值类型,而且函数体中必须有且只有一条return语句。
constexpr函数体内也可以包含其他语句,只要这些语句在运行时不执行任何操作就行。例如,constexpr函数中可以有空语句、类型别名以及using声明。
1 |
|
调试帮助
定义在cassert头文件中的assert预处理宏由预处理器而非编译器管理。
assert的行为依赖于一个名为NDEBUG的预处理变量的状态。如果定义了NDEBUG,则assert什么也不做。默认状态下没有定义NDEBUG,此时assert将执行运行时检查。我们可以使用一个#define语句定义NDEBUG,从而关闭调试状态。
编译器为每个函数都定义了_ _func_ _,它是const char的一个静态数组,用于存放函数的名字。
6.6 函数匹配
1 |
|
6.7 函数指针
1 |
|
函数作为实参:此时它会自动转换成指针。
函数作为返回类型:必须显式地将返回类型指定为指针。
1 |
|
形参(parameter)实参(argument)
第7章 类
7.1 定义抽象数据类型
成员函数通过一个名为this的额外的隐式参数来访问调用它的那个对象。
默认情况下,this的类型是指向类类型非常量版本的常量指针。成员函数的参数列表之后的const关键字表示this是一个指向常量的指针。常量对象,以及常量对象的引用或指针都只能调用常量成员函数。
一旦我们定义了一些其他的构造函数,那么除非我们再定义一个默认的构造函数,否则类将没有默认构造函数。
在参数列表后面写上= default来要求编译器生成构造函数。= default既可以和声明一起出现在类的内部,也可以作为定义出现在类的外部。和其他函数一样,如果= default在类的内部,则默认构造函数是内联的;如果它在类的外部,则该成员默认情况下不是内联的。
构造函数初始值列表负责为新创建的对象的一个或几个数据成员赋初值。
7.2 访问控制与封装
使用class和struct定义类唯一的区别就是默认的访问权限。如果我们使用struct关键字,则定义在第一个访问说明符之前的成员是public的;相反,如果我们使用class关键字,则这些成员是private的。
类可以允许其他类或者函数访问它的非公有成员,方法是令其他类或者函数成为它的友元(friend)。
友元的声明仅仅指定了访问的权限,而非一个通常意义上的函数声明。如果我们希望类的用户能够调用某个友元函数,那么我们就必须在友元声明之外再专门对函数进行一次声明。许多编译器并未强制限定友元函数必须在使用之前在类的外部声明。
7.3 类的其他特性
定义在类内部的成员函数是自动inline的。
通过在变量的声明中加入mutable关键字声明为可变数据成员,即使是在一个const成员函数内也可修改。
当我们提供一个类内初始值时,必须以符号=或者花括号表示。
一个const成员函数如果以引用的形式返回* this
,那么它的返回类型将是常量引用。这对链式调用函数不利(temp.a().b()
形式),可以采用以下重载避免:
1 |
|
一旦一个类的名字出现后,它就被认为是声明过了(但尚未定义),因此类允许包含指向它自身类型的引用或指针。
类还可以把其他的类定义成友元,也可以把其他类(之前已定义过的)的成员函数定义成友元。此外,友元函数能定义在类的内部,这样的函数是隐式内联的。友元关系不存在传递性。要想令某个成员函数作为友元,我们必须仔细组织程序的结构以满足声明和定义的彼此依赖关系。
7.4 类的作用域
一个类就是一个作用域。
编译器处理完类中的全部声明后才会处理成员函数的定义。
如果成员函数用到和类中成员变量相同的名字,可以用作用域运算符(::)或this指针显式访问类中成员变量。
7.5 构造函数再探
使用使用构造函数初始值列表直接初始化,少了一步赋值操作效率更高;并且const成员变量或引用必须直接初始化。
构造函数初始值列表只说明用于初始化成员的值,而不限定初始化的具体执行顺序。
C++11新标准扩展了构造函数初始值的功能,使得我们可以定义所谓的委托构造函数(delegating constructor)。一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把它自己的一些(或者全部)职责委托给了其他构造函数。
1 |
|
如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制,有时我们把这种构造函数称作转换构造函数(converting constructor)。
编译器只会自动地执行一步类型转换:
1 |
|
可以将构造函数声明为explicit来阻止隐式转换。(1.只能在类内声明构造函数时使用explicit关键字,在类外部定义时不应重复 2.explict声明的构造函数不能用于拷贝初始化)
举例:
- 接受一个单参数的const char*的string构造函数(参见3.2.1节,第76页)不是explicit的。
- 接受一个容量参数的vector构造函数(参见3.3.1节,第87页)是explicit的。
聚合类(aggregate class)特点:
- 所有成员都是public的
- 没有定义任何构造函数
- 没有类内初始值(参见2.6.1节,第64页)
- 没有基类,也没有virtual函数
字面值常量类是数据成员都是字面值类型的聚合类,或者满足以下特点:
- 数据成员都是字面值类型
- 至少含有一个constexpr构造函数
- 如果一个数据成员含有类内初始值,内置类型成员的初始值必须是一条常量表达式;某种类类型的初始值必须使用成员自己的constexpr构造函数。
- 必须使用析构函数的默认定义,该成员负责销毁类的对象。
构造函数不能是const的(参见7.1.4节,第235页),但是字面值常量类的构造函数可以是constexpr(参见6.5.2节,第213页)函数。
7.6 类的静态成员
当在类的外部定义静态成员时,不能重复static关键字,该关键字只出现在类内部的声明语句。
即使一个常量静态数据成员在类内部被初始化了,通常情况下也应该在类的外部定义一下该成员。(不然可能找不到定义)
非静态数据成员必须是完全类型,静态数据成员可以是不完全类型。
可以使用静态成员作为默认实参。
第Ⅱ部分 C++标准库
第8章 IO库
8.1 IO类
头文件 | 类型 |
---|---|
iostream | istream,wistream osteam,wostream iostream,wiostream |
fsteam | ifstream,wifstream ofstream,wofstream fstream,ofstream |
sstream | isstringstream,wistringstream ostringstream,wostringstream stringstream,wstringstream |
IO对象无拷贝或赋值。
1 |
|
如果程序异常终止,输出缓冲区是不会被刷新的。当一个程序崩溃后,它所输出的数据很可能停留在输出缓冲区中等待打印。
标准库将cout和cin关联在一起,输入会导致输出缓冲区刷新。
使用tie
成员函数,既可以将一个istream对象关联到另一个ostream,也可以将一个ostream关联到另一个ostream。每个流同时最多关联到一个流,但多个流可以同时关联到同一个ostream。
8.2 文件输入输出
接受一个iostream类型引用(或指针)参数的函数,可以用一个对应的fstream(或sstream)类型来调用。
文件模式
- in 读
- out 写
- app 每次写前均定位到文件末尾
- ate 打开文件后立即定位到文件末尾
- trunc 截断文件
- binary 以二进制方式进行IO
open调用未显式指定输出模式,文件隐式地以out模式打开。out模式意味着同时使用trunc模式。
保留被ofstream打开的文件中已有数据的唯一方法是显式指定app或in模式。
8.3 string流
无
第9章 顺序容器
9.1 顺序容器概述
容器 | 特点 |
---|---|
vector | 可变大小数组。支持快速随机访问。尾部之外的位置插入/删除很慢。 |
deque | 双端队列。支持快速随机访问。头尾位置插入/删除很快。 |
list | 双向链表。只支持双向顺序访问。任何位置插入/删除很快。 |
forward_list | 单向链表。只支持单项顺序访问。任何位置插入/删除很快。 |
array | 固定大小数组。支持快速随机访问。不能添加或删除元素。 |
string | 与vector类似,专用于保存字符。 |
现代C++程序应该使用标准库容器,而不是更原始的数据结构,如内置数组。
9.2 容器库概览
较旧的编译器可能需要在两个尖括号之间键入空格,例如,vector<vector<string> >。
通用容器操作 | |
---|---|
类型别名 | |
iterator | 此容器类型的迭代器类型 |
const_iterator | const版本 |
size_type | 无符号整数类型,容器大小 |
difference_type | 带符号整数类型,迭代器距离 |
value_type | 元素类型 |
reference | 元素的左值类型;等同于value_type& |
const_reference | 元素的const左值类型 |
构造函数 | |
C c; | 默认构造函数(与内置数组一样,标准库array的大小也是类型的一部分) |
C c1(c2); | 构造c2的拷贝c1 |
C c(b,e); | 构造c,将迭代器b、e范围内的元素拷贝到c(array不支持) |
C c{a,b,c...}; | 列表初始化c |
赋值与swap | |
c1=c2 | 将c1中元素替换为c2中元素 |
c1={a,b,c...} | 将c1中元素替换为列表中元素(array不支持) |
a.swap(b) | 交换 |
swap(a,b) | 交换 |
大小 | |
c.size() | c中元素数目(forward_list不支持) |
c.max_size() | c可保存的最大元素数目 |
c.empty() | 是否为空 |
添加/删除元素 | (array不支持)(不同容器中接口不同) |
c.insert(args) | 将args中的元素拷贝进c |
c.emplace(inits) | 使用inits构造c中的一个元素 |
c.erase(args) | 删除args指定的元素 |
c.clear() | 删除c中所有元素,返回void |
关系运算符 | |
==, != | 所有容器都支持相等/不等运算符 |
<, <=, >, >= | 关系运算符(无序关联容器不支持) |
获取迭代器 | |
c.begin(), c.end() | 返回指向c的首元素和尾元素之后位置的迭代器 |
c.cbegin(), c.end() | const版本 |
反向容器的额外成员 | (forward_list不支持) |
reverse_iterator | 逆序寻址 |
const_reverse_iterator | const版本 |
c.rbegin(), c.rend() | 返回指向c的尾元素和首元素之前位置的迭代器 |
c.crbegin(), c.crend() | const版本 |
虽然不能对内置数组类型进行拷贝或对象赋值操作,但array无此限制。
assign操作不适用于关联容器和array。
赋值相关运算会导致指向左边容器内部的迭代器、引用和指针失效。
swap操作不会导致容器内部的迭代器、引用和指针失效。array和string除外。
- 对一个string调用swap会导致迭代器、引用和指针失效。
- swap两个array会真正交换它们的元素。指针、引用和迭代器所绑定的元素保持不变,但元素值已经与另一个array中对应元素的值进行了交换。
9.3 顺序容器操作
顺序容器添加元素操作 | |
---|---|
c.push_back(t) | 尾部创建 |
c.emplace_back(args) | |
c.push_front() | 头部创建 |
c.emplace_front(args) | |
c.insert(p,t) | 迭代器p指向的元素之前创建元素,返回指向新添加的元素的迭代器 |
c.emplace(p,args) | |
c.insert(p,n,t) | 插入n个值为t的元素 |
c.insert(p,b,e) | 插入迭代器b、e范围内的元素 |
c.insert(p,il) | 插入il(花括号包围的元素值列表) |
在新标准下,接受元素个数或范围的insert版本返回指向第一个新加入元素的迭代器。(在旧版本的标准库中,这些操作返回void。)如果范围为空,不插入任何元素,insert操作会将第一个参数返回。
emplace_front、emplace和emplace_back构造而不是拷贝元素。
forward_list专有:
- insert_after
- emplace_after
- 首前迭代器before_begin()、cbefore_begin()
forward_list不支持push_back和emplace_back
vector、string不支持push_front和emplace_front
顺序容器访问元素操作 | (返回引用) |
---|---|
c.back() | 若c为空,函数行为未定义(forward_list不支持) |
c.front() | 若c为空,函数行为未定义 |
c[n] | 若n>=c.size(),函数行为未定义 |
c.at(n) | 若下表越界,抛出out_of_range异常 |
at和下标只适用于string、vector、deque、array。
顺序容器删除元素操作 | (一律不适用于array) |
---|---|
c.pop_back() | 若c为空,函数行为未定义(forward_list不支持) |
c.pop_front() | 若c为空,函数行为未定义(vector、string不支持) |
c.erase(p) | 删除迭代器p所指向元素,返回指向被删元素之后元素的迭代器 |
c.erase(b,e) | |
c.clear() |
forward_list专有:
- erase_after
操作容器后的迭代器、指针和引用失效问题
添加元素后:
- 对于deque,插入到除首尾位置之外的任何位置都会导致迭代器、指针和引用失效。如果在首尾位置添加元素,迭代器会失效,但指向存在的元素的引用和指针不会失效。
- 如果容器是vector或string,且存储空间被重新分配,则指向容器的迭代器、指针和引用都会失效。如果存储空间未重新分配,指向插入位置之前的元素的迭代器、指针和引用仍有效,但指向插入位置之后元素的迭代器、指针和引用将会失效。
删除元素后:
- 对于deque,如果在首尾之外的任何位置删除元素,那么指向被删除元素外其他元素的迭代器、引用或指针也会失效。如果是删除deque的尾元素,则尾后迭代器也会失效,但其他迭代器、引用和指针不受影响;如果是删除首元素,这些也不会受影响。
- 对于vector和string,指向被删元素之前元素的迭代器、引用和指针仍有效。注意:当我们删除元素时,尾后迭代器总是会失效。
9.4 vector对象是如何增长的
resize改变容器中元素的数目size,reserve改变容器的容量capacity(只增大)。
可以调用shrink_to_fit来要求deque、vector或string退回不需要的内存空间。此函数指出我们不再需要任何多余的内存空间。但是,具体的实现可以选择忽略此请求。也就是说,调用shrink_to_fit也并不保证一定退回内存空间。
只有在执行insert操作时size与capacity相等,或者调用resize或reserve时给定的大小超过当前capacity,vector才可能重新分配内存空间。会分配多少超过给定容量的额外空间,取决于具体实现。
9.5 额外的string操作
如果string搜索函数搜索失败,则返回一个名为string::npos的static成员。标准库将npos定义为一个const string::size_type类型,并初始化为值-1。由于npos是一个unsigned类型,此初始值意味着npos等于任何string最大的可能大小。
9.6 容器适配器
一个容器适配器接受一种已有的容器类型,使其行为看起来像一种不同的类型。
3个顺序容器适配器:stack、queue、priority_queue。
默认情况下,stack和queue是基于deque实现的,priority_queue是在vector之上实现的。我们可以在创建一个适配器时将一个命名的顺序容器作为第二个类型参数,来重载默认容器类型。
stack只要求push_back、pop_back和back操作,因此可以使用除array和forward_list之外的任何容器类型来构造stack。queue适配器要求back、push_back、front和push_front,因此它可以构造于list或deque之上,但不能基于vector构造。priority_queue除了front、push_back和pop_back操作之外还要求随机访问能力,因此它可以构造于vector或deque之上,但不能基于list构造。
第10章 泛型算法
10.1 概述
泛型算法本身不会执行容器的操作,它们只会运行于迭代器之上,执行迭代器的操作。
10.2 初识泛型算法
无。
10.3 定制操作
谓词是一个可调用的表达式,其返回结果是一个能用作条件的值。
标准库算法所使用的谓词分为两类:一元谓词(unary predicate,意味着它们只接受单一参数)和二元谓词(binary predicate,意味着它们有两个参数)。
1 |
|
stable_sort稳定排序算法维持相等元素的原有顺序。
lambda表达式
当向一个函数传递一个lambda时,同时定义了一个新类型和该类型的一个对象:传递的参数就是此编译器生成的类类型的未命名对象。
捕获列表只用于局部非static变量,lambda可以直接使用局部static变量和在它所在函数之外声明的名字。
值捕获 / 引用捕获。
如果函数返回一个lambda,则与函数不能返回一个局部变量的引用类似,此lambda也不能包含引用捕获。
如果我们希望能改变一个被捕获的变量的值,就必须在参数列表尾加上关键字mutable。
被捕获的变量成为了static成员。
bind函数
1 |
|
10.4 再探迭代器
插入迭代器(insert iterator)
这些迭代器被绑定到一个容器上,可用来向容器插入元素。
1 |
|
流迭代器(stream iterator)
这些迭代器被绑定到输入或输出流上,可用来遍历所关联的IO流。
1 |
|
反向迭代器(reverse iterator)
这些迭代器向后而不是向前移动。除了forward_list之外的标准库容器都有反向迭代器。
移动迭代器(move iterator)
这些专用的迭代器不是拷贝其中的元素,而是移动它们。我们将在13.6.2节(第480页)介绍移动迭代器。
10.5 泛型算法结构
无。
10.6 特定容器算法
容器forward_list和list对一些通用算法定义了自己特有的版本。与通用算法不同,这些链表特有版本会修改给定的链表。
第11章 关联容器
容器 | 描述 |
---|---|
map | 键值对 |
set | 键 |
multimap | 可重复 |
multiset | 可重复 |
unordered_map | 哈希函数组织 |
unordered_set | 哈希函数组织 |
unordered_multimap | 可重复 |
unordered_multiset | 可重复 |
11.1 使用关联容器
无。
11.2 关联容器概述
标准库类型pair定义在头文件utility中。
与其他标准库类型不同,pair的数据成员是public的,两个成员分别命名为first和second。
1 |
|
11.3 关联容器操作
关联容器额外的类型别名
key_type 此容器类型的关键字类型
mapped_type 每个关键字关联的类型,仅限map
value_type 对于set,与key_type相同;对于map,为pair<const key_type, mapped_type>
set的迭代器是const的。
对一个map使用下标操作,其行为与数组或vector上的下标操作很不相同:使用一个不在容器中的关键字作为下标,会添加一个具有此关键字的元素到map中。
因此,下标和at操作只适用于非const的map和unordered_map。
c.find(k)
c.count(k)
c.lower_bound(k)
c.upper_bound(k)
c.equal_range(k)
11.4 无序容器
无序容器提供了一组管理桶的函数,可以用来性能调优。
自定义类型的无序容器:
1 |
|
第12章 动态内存
静态内存用来保存局部static对象、类static数据成员以及定义在任何函数之外的变量。static对象在使用之前分配,在程序结束时销毁。
栈内存用来保存定义在函数内的非static对象。栈对象仅在其定义的程序块运行时才存在
除了自动和static对象外,C++还支持动态分配对象,存储在堆内存中。动态分配的对象的生存期与它们在哪里创建是无关的,只有当显式地被释放时,这些对象才会销毁。
12.1 动态内存与智能指针
shared_ptr和unique_ptr都支持 | |
---|---|
shared_ptr<T> sp | 空智能指针 |
unique_ptr<T> up | |
p | 若p指向一个对象,返回true |
*p | 解引用p,获得它指向的对象 |
p->mem | 等价于(*p).mem |
p.get() | 返回p中保存的指针。永远不要用get初始化另一个智能指针或者为另一个智能指针赋值。 |
swap(p,q) / p.swap(q) | 交换 |
shared_ptr独有操作 | |
---|---|
make_shared<T>(args) | 初始化shared_ptr |
shared_ptr<T> p(q) | |
p = q | |
p.unique() | p.use_count()为1则返回true |
p.use_count() | 返回与p共享对象的智能指针数量;可能很慢,用于调试 |
到底是用一个计数器还是其他数据结构来记录有多少指针共享对象,完全由标准库的具体实现来决定。关键是智能指针类能记录有多少个shared_ptr指向相同的对象,并能在恰当的时候自动释放对象。
自己直接管理内存的类与使用智能指针的类不同,它们不能依赖类对象拷贝、赋值和销毁操作的任何默认定义。
1 |
|
动态内存的一个基本问题是可能有多个指针指向相同的内存。
可以通过定位new向new传递额外的参数:
1 |
|
接受指针参数的智能指针构造函数是explicit的。因此,我们不能将一个内置指针隐式转换为一个智能指针,必须使用直接初始化形式来初始化一个智能指针。
1 |
|
智能指针陷阱:
- 不使用相同的内置指针值初始化(或reset)多个智能指针。
- 不delete get()返回的指针。· 不使用get()初始化或reset另一个智能指针。
- 如果你使用get()返回的指针,记住当最后一个对应的智能指针销毁后,你的指针就变为无效了。
- 如果你使用智能指针管理的资源不是new分配的内存,记住传递给它一个删除器。
定义和改变shared_ptr的其它方法 | |
---|---|
shared_ptr<T> p(q) | p管理内置指针q所指向的对象;q必须指向new分配的内存,且能够转换为T*类型 |
shared_ptr<T> p(u) | p从unique_ptr u那里接管对象所有权,将u置空 |
shared_ptr<T> p(q, d) | p接管内置指针q所指向对象的所有权。p将使用可调用对象d来代替delete。 |
shared_ptr<T> p(p2, d) | p是shared_ptr p2的拷贝。p将使用可调用对象d来代替delete。 |
p.reset() | 释放 |
p.reset(q) | |
p.reset(q, d) |
unique_ptr操作 | |
---|---|
unique_ptr<T> u1 | |
unique_ptr<T, D> u2 | u2会使用一个类型为D的可调用对象来释放它的指针 |
unique_ptr<T, D> u(d) | |
u = nullptr | |
u.release() | 释放,返回指针,置空 |
u.reset() | |
u.reset(q) | |
u.reset(nullptr) |
不能拷贝unique_ptr的规则有一个例外:我们可以拷贝或赋值一个将要被销毁的unique_ptr。最常见的例子是从函数返回一个unique_ptr。
unique_ptr管理删除器的方式与shared_ptr不同,其原因我们将在16.1.6节(第599页)中介绍。
weak_ptr操作 | |
---|---|
weak_ptr<T> w | |
weak_ptr<T> w(sp) | 与shared_ptr sp指向相同对象得weak_ptr |
w = p | p可以是shared_ptr或weak_ptr |
w.reset() | |
w.use_count() | 与w共享对象的shared_ptr的数量 |
w.expired() | w.use_count()为0返回true |
w.lock() | expired为true返回空shared_ptr,否则返回指向w的对象的shared_ptr |
12.2 动态数组
1 |
|
动态数组并不是数组类型
由于分配的内存并不是一个数组类型,因此不能对动态数组调用begin或end。这些函数使用数组维度(回忆一下,维度是数组类型的一部分)来返回指向首元素和尾后元素的指针。出于相同的原因,也不能用范围for语句来处理(所谓的)动态数组中的元素。
1 |
|
用new分配一个大小为0的数组时,new返回一个合法的非空指针。
当我们释放一个指向数组的指针时,空方括号对是必需的:它指示编译器此指针指向一个对象数组的第一个元素。如果我们在delete一个指向数组的指针时忽略了方括号(或者在delete一个指向单一对象的指针时使用了方括号),其行为是未定义的。
1 |
|
unique_ptr支持直接管理动态数组,shared_ptr不支持。
如果希望使用shared_ptr管理一个动态数组,必须提供自己定义的删除器;并且,为了访问数组中的元素,不能使用下标,必须用get获取一个内置指针,然后用它来访问数组元素。
1 |
|
标准库allocator类定义在头文件memory中,它帮助我们将内存分配和对象构造分离开来。
allocator | |
---|---|
allocator<T> a | |
a.allocate(n) | |
a.deallocate(p, n) | 释放内存,n必须是p创建时所要求的大小。之前要destroy。 |
a.construct(p, args) | args被传递给类型为T的构造函数。 |
a.destroy(p) |
1 |
|
12.3 使用标准库:文本查询程序
无。
第Ⅲ部分 类设计者的工具
直到学习完第13章,不要在类内的代码中分配动态内存。
第13章 拷贝控制
一个类通过定义五种特殊的成员函数来控制对象拷贝、移动、赋值和销毁时做什么,包括:
拷贝构造函数(copy constructor)、拷贝赋值运算符(copy-assignment operator)、移动构造函数(move constructor)、移动赋值运算符(move-assignment operator)和析构函数(destructor)。
13.1 拷贝、赋值与销毁
拷贝构造函数
第一个参数是自身类类型的引用,且任何额外参数都有默认值。
拷贝初始化不仅在我们用=定义变量时会发生,在下列情况下也会发生:
- 将一个对象作为实参传递给一个非引用类型的形参
- 从一个返回类型为非引用类型的函数返回一个对象
- 用花括号列表初始化一个数组中的元素或一个聚合类中的成员
- 初始化标准库容器或是调用其insert或push成员时,容器会对其元素进行拷贝初始化。与之相对,用emplace成员创建的元素都进行直接初始化。
为什么拷贝构造函数自己的参数必须是引用类型?
拷贝构造函数被用来初始化非引用类类型参数。如果其参数不是引用类型,则调用永远也不会成功——为了调用拷贝构造函数,我们必须拷贝它的实参,但为了拷贝实参,我们又需要调用拷贝构造函数,如此无限循环。
无论何时一个对象被销毁,就会自动调用其析构函数:
- 变量在离开其作用域时被销毁。
- 当一个对象被销毁时,其成员被销毁。
- 容器(无论是标准库容器还是数组)被销毁时,其元素被销毁。
- 对于动态分配的对象,当对指向它的指针应用delete运算符时被销毁。
- 对于临时对象,当创建它的完整表达式结束时被销毁。
定义为=default来显式地要求编译器生成合成的版本。
定义为=delete来显式删除。
与=default不同,=delete必须出现在函数第一次声明的时候。
对于删除了析构函数的类型,虽然我们不能定义这种类型的变量或成员,但可以动态分配这种类型的对象。但是,不能释放这些对象。
如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。
13.2 拷贝控制和资源管理
可以定义拷贝操作,使类的行为看起来像一个值或者像一个指针。
当你编写赋值运算符时,有两点需要记住:
- 如果将一个对象赋予它自身,赋值运算符必须能正确工作。
- 大多数赋值运算符组合了析构函数和拷贝构造函数的工作。
13.3 交换操作
自定义swap友元函数效率更高。
定义swap的类通常用swap来定义它们的赋值运算符。这些运算符使用了一种名为拷贝并交换(copy and swap)的技术。在这个版本的赋值运算符中,参数并不是一个引用。
13.4 拷贝控制示例
无。
13.5 动态内存管理类
无。
13.6 对象移动
在旧C++标准中,没有直接的方法移动对象。因此,即使不必拷贝对象的情况下,我们也不得不拷贝。
标准库容器、string和shared_ptr类既支持移动也支持拷贝。IO类和unique_ptr类可以移动但不能拷贝。
通过&&而不是&来获得右值引用(rvalue reference)。
1 |
|
变量表达式都是左值,我们不能将一个右值引用绑定到一个右值引用类型的变量上。
可以通过调用一个名为move的新标准库函数来获得绑定到左值上的右值引用,此函数定义在头文件utility中。
noexcept是我们承诺一个函数不抛出异常的一种方法。我们在一个函数的参数列表后指定noexcept。不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept。除非vector知道元素类型的移动构造函数不会抛出异常,否则在重新分配内存的过程中,它就必须使用拷贝构造函数而不是移动构造函数。
🔺只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符。
如果类定义了一个移动构造函数和/或一个移动赋值运算符,则该类的合成拷贝构造函数和拷贝赋值运算符会被定义为删除的。
如果一个类既有移动构造函数,也有拷贝构造函数,编译器使用普通的函数匹配规则来确定使用哪个构造函数。
🔺所有五个拷贝控制成员应该看作一个整体:一般来说,如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作。
网上有一种说法称其为三/五法则
1 |
|
通过调用标准库的make_move_iterator函数将一个普通迭代器转换为一个移动迭代器。与其他迭代器不同,移动迭代器的解引用运算符生成一个右值引用。
由于一个移后源对象具有不确定的状态,对其调用std::move是危险的。当我们调用move时,必须绝对确认移后源对象没有其他用户。通过在类代码中小心地使用move,可以大幅度提升性能。而如果随意在普通用户代码(与类实现代码相对)中使用移动操作,很可能导致莫名其妙的、难以查找的错误,而难以提升应用程序性能。
引用限定符(&或&&)分别指出this可以指向一个左值或右值。对于&限定的函数,我们只能将它用于左值;对于&&限定的函数,只能用于右值。类似const限定符,引用限定符只能用于(非static)成员函数,且必须同时出现在函数的声明和定义中。一个函数可以同时用const和引用限定。在此情况下,引用限定符必须跟随在const限定符之后。
可以用&修饰=运算符函数阻止向右值赋值。
可以综合引用限定符和const来区分一个成员函数的重载版本。
如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符。
第14章 重载运算与类型转换
14.1 基本概念
当运算符作用于内置类型的运算对象时,我们无法改变该运算符的含义。
使用重载的运算符本质上是一次函数调用,所以这些关于运算对象求值顺序的规则无法应用到重载的运算符上。特别是,逻辑与运算符、逻辑或运算符和逗号运算符的运算对象求值顺序规则无法保留下来。除此之外,&&和||运算符的重载版本也无法保留内置运算符的短路求值属性,两个运算对象总是会被求值。
1 |
|
将运算符定义为成员函数还是普通的非成员函数?
- 赋值(=)、下标([ ])、调用(( ))和成员访问箭头(->)运算符必须是成员。
- 复合赋值运算符一般来说应该是成员,但并非必须,这一点与赋值运算符略有不同。
- 改变对象状态的运算符或者与给定类型密切相关的运算符,如递增、递减和解引用运算符,通常应该是成员。
- 具有对称性的运算符可能转换任意一端的运算对象,例如算术、相等性、关系和位运算符等,因此它们通常应该是普通的非成员函数。
14.2 输入和输出运算符
无。
14.3 算术和关系运算符
通常情况下,我们把算术和关系运算符定义成非成员函数以允许对左侧或右侧的运算对象进行转换。
如果类同时定义了算术运算符和相关的复合赋值运算符,则通常情况下应该使用复合赋值来实现算术运算符。
14.4 赋值运算符
在拷贝赋值和移动赋值运算符之外,标准库vector类还定义了第三种赋值运算符,该运算符接受花括号内的元素列表作为参数。
14.5 下标运算符
如果一个类包含下标运算符,则它通常会定义两个版本:一个返回普通引用,另一个是类的常量成员并且返回常量引用。当作用于一个常量对象时,下标运算符返回常量引用以确保我们不会给返回的对象赋值。
14.6 递增和递减运算符
定义递增和递减运算符的类应该同时定义前置版本和后置版本。
为了与内置版本保持一致,前置运算符应该返回递增或递减后对象的引用;后置运算符应该返回对象的原值(递增或递减之前的值),返回的形式是一个值而非引用。
后置版本接受一个额外的(不被使用)int类型的形参。当我们使用后置运算符时,编译器为这个形参提供一个值为0的实参。尽管从语法上来说后置函数可以使用这个额外的形参,但是在实际过程中通常不会这么做。这个形参的唯一作用就是区分前置版本和后置版本的函数,而不是真的要在实现后置版本时参与运算。
1 |
|
14.7 成员访问运算符
重载的箭头运算符必须返回类的指针或者自定义了箭头运算符的某个类的对象。
14.8 函数调用运算符
当我们编写了一个lambda后,编译器将该表达式翻译成一个未命名类的未命名对象。在lambda表达式产生的类中含有一个重载的函数调用运算符。
1 |
|
标准库定义了一组表示算术运算符、关系运算符和逻辑运算符的类,每个类分别定义了一个执行命名操作的调用运算符。
1 |
|
比较两个无关指针将产生未定义的行为,但标准库规定其函数对象对于指针同样适用。
1 |
|
可以使用function调用可调用对象(函数、函数指针、lambda表达式、bind创建的对象以及重载了函数调用运算符的类)。
1 |
|
不能(直接)将重载函数的名字存入function类型的对象中,需要通过函数指针消除二义性。
14.9 重载、类型转换与运算符
类型转换运算符
1 |
|
通常情况下,不要为类定义相同的类型转换,也不要在类中定义两个及两个以上转换源或转换目标是算术类型的转换。
第15章 面向对象程序设计
面向对象程序设计基于三个基本概念:数据抽象、继承和动态绑定。
15.1 OOP:概述
对于某些函数,基类希望它的派生类各自定义适合自身的版本,此时基类就将这些函数声明成虚函数(virtual function)。任何构造函数之外的非静态函数都可以是虚函数。成员函数如果没被声明为虚函数,则其解析过程发生在编译时而非运行时。
使用基类的引用(或指针)调用一个虚函数时将发生动态绑定。
15.2 定义基类和派生类
基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作也是如此。
派生类必须通过使用类派生列表(class derivation list)明确指出它是从哪个(哪些)基类继承而来的。类派生列表的形式是:首先是一个冒号,后面紧跟以逗号分隔的基类列表,其中每个基类前面可以有以下三种访问说明符中的一个:public、protected或者private。
C++标准并没有明确规定派生类的对象在内存中如何分布。
编译器会隐式地执行派生类到基类的转换。我们可以把派生类对象或者派生类对象的引用用在需要基类引用的地方;同样的,我们也可以把派生类对象的指针用在需要基类指针的地方。
每个类负责定义各自的接口。派生类应该遵循基类的接口,并且通过调用基类的构造函数来初始化那些从基类中继承而来的成员。
如果基类定义了一个静态成员,则在整个继承体系中只存在该成员的唯一定义。
派生类的声明包含类名但是不包含它的派生列表。一条声明语句的目的是令程序知晓某个名字的存在以及该名字表示一个什么样的实体,如一个类、一个函数或一个变量等。派生列表以及与定义有关的其他细节必须与类的主体一起出现。
用作基类的类必须已经定义而非仅仅声明(隐含一个类不能派生它本身)。
类名后跟一个关键字final防止继承。
派生类向基类的自动类型转换只对指针或引用类型有效,在派生类类型和基类类型之间不存在这样的转换。和内置指针一样,智能指针类也支持派生类向基类的类型转换。基类向派生类不存在隐式类型转换。
15.3 虚函数
当我们在派生类中覆盖了某个虚函数时,可以再一次使用virtual关键字指出该函数的性质。然而这么做并非必须,因为一旦某个函数被声明成虚函数,则在所有派生类中它都是虚函数。
使用override关键字来说明派生类中的虚函数,使得程序员的意图更加清晰的同时让编译器可以为我们发现一些错误。
final和override说明符出现在形参列表(包括任何const或引用修饰符)以及尾置返回类型之后。
虚函数也可以拥有默认实参,如果某次函数调用使用了默认实参,则该实参值由本次调用的静态类型决定。
使用作用域运算符可以强迫其执行虚函数的某个特定版本。
15.4 抽象基类
纯虚(pure virtual)函数无须定义。在函数体的位置(即在声明语句的分号之前)书写=0。我们也可以为纯虚函数提供定义,不过函数体必须定义在类的外部。也就是说,我们不能在类的内部为一个=0的函数提供函数体。
含有纯虚函数的类是抽象基类。不能创建抽象基类的对象。
15.5 访问控制与继承
protected关键字需要注意:派生类的成员或友元只能通过派生类对象来访问基类的受保护成员。派生类对于一个基类对象中的受保护成员没有任何访问特权。避免基类的protected保护被规避。
某个类对其继承而来的成员的访问权限受到两个因素影响:
- 在基类中该成员的访问说明符
- 在派生类的派生列表中的访问说明符
派生访问说明符对于派生类的成员(及友元)能否访问其直接基类的成员没什么影响,目的是控制派生类用户(包括派生类的派生类在内)对于基类成员的访问权限。
派生类向基类转换的可访问性
假定D继承自B,D的XX能否使用派生类向基类的转换:
继承方式 | 成员函数和友元 | 用户代码 | 派生类的成员和友元 |
---|---|---|---|
public | ✓ | ✓ | ✓ |
protected | ✓ | ✗ | ✓ |
private | ✓ | ✗ | ✗ |
友元关系不能传递和继承。
使用using声明语句可以改变可访问性(派生类只能为那些它可以访问的名字提供using声明)。
人们常常有一种错觉,认为在使用struct关键字和class关键字定义的类之间还有更深层次的差别。事实上,唯一的差别就是默认成员访问说明符及默认派生访问说明符;除此之外,再无其他不同之处。
15.6 继承中的类作用域
派生类的作用域位于基类作用域之内。
即使静态类型与动态类型可能不一致(当使用基类的引用或指针时会发生这种情况),但是我们能使用哪些成员仍然是由静态类型决定。
派生类的成员将隐藏同名的基类成员(即使派生类成员和基类成员的形参列表不一致)。可以通过作用域运算符来使用一个被隐藏的基类成员。
如果派生类希望所有的重载版本对于它来说都是可见的,那么它就需要覆盖所有的版本,或者一个也不覆盖。——解决方案:一条基类成员函数的using声明语句就可以把该函数的所有重载实例添加到派生类作用域中。
15.7 构造函数与拷贝控制
只要基类的析构函数是虚函数,就能确保当我们delete基类指针时将运行正确的析构函数版本。
如前所述(13.6),大多数基类都会定义一个虚析构函数。因此在默认情况下,基类通常不含有合成的移动操作,而且在它的派生类中也没有合成的移动操作。
和构造函数及赋值运算符不同的是,析构函数只负责销毁派生类自己分配的资源。
在默认情况下,基类默认构造函数初始化派生类对象的基类部分。如果我们想拷贝(或移动)基类部分,则必须在派生类的构造函数初始值列表中显式使用基类的拷贝(或移动)构造函数。
和普通成员的using声明不一样,一个构造函数的using声明不会改变该构造函数的访问级别。
15.8 容器与继承
无。
15.9 文本查询程序再探
无。
第16章 模板与泛型编程
16.1 定义模板
模板定义以关键字template开始,后跟一个模板参数列表(template parameter list),这是一个逗号分隔的一个或多个模板参数(template parameter)的列表,用小于号(<)和大于号(>)包围起来。
编译器生成的版本通常被称为模板的实例(instantiation)。
在模板参数列表中,class和typename的含义相同,可以互换使用。
除了定义类型参数,还可以在模板中定义非类型参数(nontype parameter)。当一个模板被实例化时,非类型参数被一个用户提供的或编译器推断出的值所代替。这些值必须是常量表达式,从而允许编译器在编译时实例化模板。绑定到指针或引用非类型参数的实参必须具有静态的生存期。
1 |
|
inline或constexpr说明符放在模板参数列表之后,返回类型之前。
与非模板代码不同,模板的头文件通常既包括声明也包括定义。
模板的提供者保证:当使用模板时,所有不依赖于模板参数的名字都必须是可见的。当模板被实例化时,模板的定义,包括类模板的成员的定义,也必须是可见的。
模板的用户保证:用来实例化模板的所有函数、类型以及与类型关联的运算符的声明都必须是可见的。
与函数模板的不同之处是,编译器不能为类模板推断模板参数类型。
与其他任何类相同,我们既可以在类模板内部,也可以在类模板外部为其定义成员函数,且定义在类模板内的成员函数被隐式声明为内联函数。
默认情况下,对于一个实例化了的类模板,其成员只有在使用时才被实例化。这意味着相同的实例可能出现在多个对象文件中。可以通过显式实例化(explicit instantiation)来避免这种开销。
1 |
|
1 |
|
在新标准中可以将模板类型参数声明为友元。
1 |
|
1 |
|
在新标准中,我们可以为函数和类模板提供默认实参。如果一个类模板为其所有模板参数都提供了默认实参,且我们希望使用这些默认实参,就必须在模板名之后跟一个空尖括号对。
1 |
|
一个类(无论是普通类还是类模板)可以包含本身是模板的成员函数。这种成员被称为成员模板(member template)。成员模板不能是虚函数。
通过在编译时绑定删除器,unique_ptr避免了间接调用删除器的运行时开销。通过在运行时绑定删除器,shared_ptr使用户重载删除器更为方便。
16.2 模板实参推断
将实参传递给带模板类型的函数形参时,能够自动应用的类型转换只有const转换及数组或函数到指针的转换(如果形参是一个引用,则数组不会转换为指针)。如果函数参数类型不是模板参数,则对实参进行正常的类型转换。
1 |
|
1 |
|
为了获得元素类型,我们可以使用标准库的类型转换(type transformation)模板。这些模板定义在头文件type_traits中。这个头文件中的类通常用于所谓的模板元程序设计,这一主题已超出本书的范围。
1 |
|
1 |
|
正常绑定规则之外的两个例外规则:
1 |
|
在实际中,右值引用通常用于两种情况:模板转发其实参或模板被重载。
std::move的定义:
1 |
|
如果一个函数参数是指向模板类型参数的右值引用(如T&&),它对应的实参的const属性和左值/右值属性将得到保持。
std::forward的定义:
1 |
|
使用std::forward实现翻转函数:
1 |
|
16.3 重载与模板
函数匹配规则:
- 对于一个调用,其候选函数包括所有模板实参推断成功的函数模板实例。
- 候选的函数模板总是可行的,因为模板实参推断会排除任何不可行的模板。
- 与往常一样,可行函数(模板与非模板)按类型转换(如果对此调用需要的话)来排序。当然,可以用于函数模板调用的类型转换是非常有限的(16.2.1)。
- 与往常一样,如果恰有一个函数提供比任何其他函数都更好的匹配,则选择此函数。但是,如果有多个函数提供同样好的匹配,则:
- 如果同样好的函数中只有一个是非模板函数,则选择此函数。
- 如果同样好的函数中没有非模板函数,而有多个函数模板,且其中一个模板比其他模板更特例化,则选择此模板。
- 否则,此调用有歧义。
在定义任何函数之前,记得声明所有重载的函数版本。这样就不必担心编译器由于未遇到你希望调用的函数而实例化一个并非你所需的版本。
16.4 可变参数模板
一个可变参数模板(variadic template)就是一个接受可变数目参数的模板函数或模板类。
可变数目的参数被称为参数包(parameter packet)。存在两种参数包:模板参数包(template parameter packet),表示零个或多个模板参数;函数参数包(function parameter packet),表示零个或多个函数参数。
1 |
|
1 |
|
可变参数函数通常将它们的参数转发给其他函数。
1 |
|
16.5 模板特例化
当定义函数模板的特例化版本时,我们本质上接管了编译器的工作。即,我们为原模板的一个特殊实例提供了定义。重要的是要弄清:一个特例化版本本质上是一个实例,而非函数名的一个重载版本。
为了特例化一个模板,原模板的声明必须在作用域中。而且,在任何使用模板实例的代码之前,特例化版本的声明也必须在作用域中。因此,模板及其特例化版本应该声明在同一个头文件中。所有同名模板的声明应该放在前面,然后是这些模板的特例化版本。
1 |
|
与函数模板不同,类模板的特例化不必为所有模板参数提供实参。我们可以只指定一部分而非所有模板参数,或是参数的一部分而非全部特性。一个类模板的部分特例化(partial specialization)本身是一个模板,使用它时用户还必须为那些在特例化版本中未指定的模板参数提供实参。
1 |
|
可以只特例化特定成员函数而不是特例化整个模板。
第Ⅳ部分 高级主题
这些特性分为两类:一类对于求解大规模的问题很有用;另一类适用于特殊问题而非通用问题。
第17章 标准库特殊设施
17.1 tuple类型
tuple类似pair,但可以有任意数量的成员。
1 |
|
17.2 bitset类型
bitset类使得位运算的使用更为容易,并且能够处理超过最长整型类型大小的位集合。
bitset可以用unsigned long long、string、字符数组构造。字符数组如果不提供数组长度,必须是一个C风格字符串。
string的下标编号习惯与bitset恰好相反:string中下标最大的字符(最右字符)用来初始化bitset中的低位(下标为0的二进制位)。
17.3 正则表达式
默认情况下,regex使用的正则表达式语言是ECMAScript。
regex_search和regex_match的参数:
(seq, m, r, mft) (seq, r, mft)
在字符序列seq中查找regex对象r中的正则表达式。
seq可以是一个string、表示范围的一对迭代器以及一个指向空字符结尾的字符数组的指针。
m是一个match对象,用来保持匹配结果的相关细节。
mft是一个可选的regex_constants::match_flag_type值。
1 |
|
一个正则表达式的语法是否正确是在运行时解析的。
regex类保存类型char的正则表达式。wregex类保存类型wachar_t的正则表达式。
smatch表示string类型的输入序列;cmatch表示字符数组序列;wsmatch表示宽字符串(wstring)输入;而wcmatch表示宽字符数组。
正则表达式中的模式通常包含一个或多个子表达式(subexpression)。正则表达式语法通常用括号表示子表达式。第一个子匹配位置为0,表示整个模式对应的匹配,随后是每个子表达式对应的匹配。
17.4 随机数
随机数库的组成:
引擎 / 类型,生成随机unsigned整数序列
分布 / 类型,使用引擎返回服从特定概率分布的随机数
C++程序不应该使用库函数rand,而应使用default_random_engine类和恰当的分布类对象。
1 |
|
一个函数如果定义了局部的随机数发生器,应该将其(包括引擎和分布对象)定义为static的。否则,每次调用函数都会生成相同的序列。
17.5 IO库再探
标准库定义了一组操纵符(manipulator)(参见1.2节,第6页)来修改流的格式状态。当操纵符改变流的格式状态时,通常改变后的状态对所有后续IO都生效。
标准库还提供了一组低层操作,支持未格式化IO(unformatted IO)。这些操作允许我们将一个流当作一个无解释的字节序列来处理。
1 |
|
⬜17.5.3 流随机访问
第18章 用于大型程序的工具
18.1 异常处理
当抛出一个异常后,程序暂停当前函数的执行过程并立即开始寻找与异常匹配的catch子句。控制权从一处转移到另一处,这有两个重要的含义:
- 沿着调用链的函数可能会提早退出。
- 一旦程序开始执行异常处理代码,则沿着调用链创建的对象将被销毁。
栈展开过程沿着嵌套函数的调用链不断查找,直到找到了与异常匹配的catch子句为止;或者也可能一直没找到匹配的catch,则退出主函数后查找过程终止。一个异常如果没有被捕获,则它将终止当前的程序。
出于栈展开可能使用析构函数的考虑,析构函数不应该抛出不能被它自身处理的异常。
当我们抛出一条表达式时,该表达式的静态编译时类型决定了异常对象的类型。
抛出指针要求在任何对应的处理代码存在的地方,指针所指的对象都必须存在。
像在形参列表中一样,如果catch无须访问抛出的表达式的话,则我们可以忽略捕获形参的名字。
通常情况下,如果catch接受的异常与某个继承体系有关,则最好将该catch的参数定义成引用类型。否则异常对象将被切掉一部分。
空的throw语句只能出现在catch语句或catch语句直接或间接调用的函数之内(用于重新抛出)。如果在处理代码之外的区域遇到了空throw语句,编译器将调用terminate。(通常与catch(...)捕获所有异常一起使用)
要想处理构造函数初始值抛出的异常,我们必须将构造函数写成函数try语句块(也称为函数测试块,function try block)的形式。
1 |
|
初始化构造函数的参数时发生的异常不属于函数try语句块,属于调用表达式的一部分。
noexcept
提供noexcept说明(noexcept specification)指定某个函数不会抛出异常。
对于一个函数来说,noexcept说明要么出现在该函数的所有声明语句和定义语句中,要么一次也不出现。该说明应该在函数的尾置返回类型之前。我们也可以在函数指针的声明和定义中指定noexcept。在typedef或类型别名中则不能出现noexcept。在成员函数中,noexcept说明符需要跟在const及引用限定符之后,而在final、override或虚函数的=0之前。
noexcept有两层含义:当跟在函数参数列表后面时它是异常说明符;而当作为noexcept异常说明的bool实参出现时,它是一个运算符。
函数指针及该指针所指的函数必须具有一致的异常说明。
18.2 命名空间
命名空间既可以定义在全局作用域内,也可以定义在其他命名空间中,但是不能定义在函数或类的内部。
命名空间可以是不连续的。
在通常情况下,我们不把#include放在命名空间内部。如果我们这么做了,隐含的意思是把头文件中所有的名字定义成该命名空间的成员。
全局作用域中定义的名字(即在所有类、函数及命名空间之外定义的名字)也就是定义在全局命名空间(global namespace)中。全局命名空间以隐式的方式声明,并且在所有程序中都存在。全局作用域中定义的名字被隐式地添加到全局命名空间中。
C++11新标准引入了一种新的嵌套命名空间,称为内联命名空间(inline namespace)(在关键字namespace前添加关键字inline)。和普通的嵌套命名空间不同,内联命名空间中的名字可以被外层命名空间直接使用。
未命名的命名空间(unnamed namespace)是指关键字namespace后紧跟花括号括起来的一系列声明语句。它可以在某个给定的文件内不连续,但是不能跨越多个文件。
如果未命名的命名空间定义在文件的最外层作用域中,则该命名空间中的名字一定要与全局作用域中的名字有所区别。
在标准C++引入命名空间的概念之前,程序需要将名字声明成static的以使得其对于整个文件有效。在文件中进行静态声明的做法是从C语言继承而来的。在C语言中,声明为static的全局实体在其所在的文件外不可见。在文件中进行静态声明的做法已经被C++标准取消了,现在的做法是使用未命名的命名空间。
1 |
|
using声明(using declaration)一次只引入命名空间的一个成员。有效范围从using声明的地方开始,一直到using声明所在的作用域结束为止。
一条using声明语句可以出现在全局作用域、局部作用域、命名空间作用域以及类的作用域中。在类的作用域中,这样的声明语句只能指向基类成员。
using指示(using directive)使得某个特定的命名空间中所有的名字都可见,一直到using指示所在的作用域结束都能使用。
using指示可以出现在全局作用域、局部作用域和命名空间作用域中,但是不能出现在类的作用域中。
🔺using指示具有将命名空间成员提升到包含命名空间本身和using指示的最近作用域的能力。
1 |
|
虽然存在风险,using指示也并非一无是处,例如在命名空间本身的实现文件中就可以使用using指示。
当我们给函数传递一个类类型的对象时,除了在常规的作用域查找外还会查找实参类所属的命名空间。这一例外对于传递类的引用或指针的调用同样有效。
标准库move和forward函数极易冲突。
对于接受类类型实参的函数来说,其名字查找将在实参类所属的命名空间中进行。这条规则对于我们如何确定候选函数集同样也有影响。
与using声明不同的是,对于using指示来说,引入一个与已有函数形参列表完全相同的函数并不会产生错误。此时,只要我们指明调用的是命名空间中的函数版本还是当前作用域的版本即可。
18.3 多重继承与虚继承
在派生类的派生列表中可以包含多个基类,每个基类包含一个可选的访问说明符。
在多重继承关系中,派生类的对象包含有每个基类的子对象。
如果一个类从它的多个基类中继承了相同的构造函数,则这个类必须为该构造函数定义它自己的版本。
编译器不会在派生类向基类的几种转换中进行比较和选择,因为在它看来转换到任意一种基类都一样好。
当一个类拥有多个基类时,有可能出现派生类从两个或更多基类中继承了同名成员的情况。此时,不加前缀限定符直接使用该名字将引发二义性。
虚继承的目的是令某个类做出声明,承诺愿意共享它的基类。其中,共享的基类子对象称为虚基类(virtual base class)。在这种机制下,不论虚基类在继承体系中出现了多少次,在派生类中都只包含唯一一个共享的虚基类子对象。
指定虚基类的方式是在派生列表中添加关键字virtual。虚派生只影响从指定了虚基类的派生类中进一步派生出的类,它不会影响派生类本身。
虚基类成员的可见性
假定类B定义了一个名为x的成员,D1和D2都是从B虚继承得到的,D继承了D1和D2,则在D的作用域中,x通过D的两个基类都是可见的。如果我们通过D的对象使用x,有三种可能性:
如果在D1和D2中都没有x的定义,则x将被解析为B的成员,此时不存在二义性,一个D的对象只含有x的一个实例。
如果x是B的成员,同时是D1和D2中某一个的成员,则同样没有二义性,派生类的x比共享虚基类B的x优先级更高。
如果在D1和D2中都有x的定义,则直接访问x将产生二义性问题。
与非虚的多重继承体系一样,解决这种二义性问题最好的方法是在派生类中为成员自定义新的实例。
在虚继承中,虚基类是由最低层的派生类初始化的。
基类的构造顺序与派生列表中基类的出现顺序保持一致,而与派生类构造函数初始值列表中基类的顺序无关。
编译器按照直接基类的声明顺序对其依次进行检查,以确定其中是否含有虚基类。如果有,则先构造虚基类,然后按照声明的顺序逐一构造其他非虚基类。
第19章 特殊工具与技术
19.1 控制内存分配
当使用一条new表达式时:
- new表达式调用一个名为operator new(或者operator new[])的标准库函数。该函数分配一块足够大的、原始的、未命名的内存空间以便存储特定类型的对象(或者对象的数组)。
- 编译器运行相应的构造函数以构造这些对象,并为其传入初始值。
- 对象被分配了空间并构造完成,返回一个指向该对象的指针。
当使用一条delete表达式时:
- 对sp所指的对象或者arr所指的数组中的元素执行对应的析构函数。
- 编译器调用名为operator delete(或者operator delete[ ])的标准库函数释放内存空间。
应用程序可以在全局作用域中定义operator new函数和operator delete函数,也可以将它们定义为成员函数。不能改变new运算符和delete运算符的基本含义。
标准库定义了operator new函数和operator delete函数的8个重载版本:
1 |
|
如果我们想要自定义operator new函数,则可以为它提供额外的形参。此时,用到这些自定义函数的new表达式必须使用new的定位形式(🔺12.1.2 定位new)将实参传给新增的形参。
一般情况下可以自定义具有任何形参的operator new,但以下形式只供标准库使用,不能被用户重新定义:
1 |
|
1 |
|
调用析构函数会销毁对象,但是不会释放内存。
19.2 运行时类型识别
运行时类型识别(run-time type identification,RTTI)的功能由两个运算符实现:
- typeid运算符,用于返回表达式的类型。
- dynamic_cast运算符,用于将基类的指针或引用安全地转换成派生类的指针或引用。
这两个运算符特别适用于以下情况:我们想使用基类对象的指针或引用执行某个派生类操作并且该操作不是虚函数。
如果一条dynamic_cast语句的转换目标是指针类型并且失败了,则结果为0。如果转换目标是引用类型并且失败了,则dynamic_cast运算符将抛出一个bad_cast异常。
当typeid作用于指针时(而非指针所指的对象),返回的结果是该指针的静态编译时类型。
在某些情况下RTTI非常有用,比如当我们想为具有继承关系的类实现相等运算符时。
1 |
|
type_info类的精确定义随着编译器的不同而略有差异。不过,C++标准规定type_info类必须定义在typeinfo头文件中,并且至少提供以下操作:
t1 == t2
t1 != t2
t.name() : 返回类型名(C风格字符串)
t1.before(t2) : 返回t1是否位于t2之前(bool)
19.3 枚举类型
枚举类型(enumeration)使我们可以将一组整型常量组织在一起。
C++包含两种枚举:限定作用域的和不限定作用域的。C++11新标准引入了限定作用域的枚举类型(scoped enumeration)。
(在限定作用域的枚举类型中,枚举成员的名字遵循常规的作用域准则,并且在枚举类型的作用域外是不可访问的。在不限定作用域的枚举类型中,枚举成员的作用域与枚举类型本身的作用域相同。)
1 |
|
一个不限定作用域的枚举类型的对象或枚举成员才能自动地转换成整型(反过来不行)。
不限定作用域的enum未指定成员的默认大小,因此每个前向声明必须指定成员的大小。限定作用域的enum成员大小隐式地定义成int。
1 |
|
19.4 类成员指针
1 |
|
19.5 嵌套类
一个类可以定义在另一个类的内部,前者称为嵌套类(nested class)或嵌套类型(nested type)。
和成员函数一样,嵌套类必须声明在类的内部,但是可以定义在类的内部或者外部。
19.6 union:一种节省空间的类
当我们给union的某个成员赋值之后,该union的其他成员就变成未定义的状态了。
union不能含有引用类型的成员。
union不能继承,不能含有虚函数。
默认情况下,union的成员都是公有的(与struct相同)。
1 |
|
在匿名union的定义所在的作用域内该union的成员都是可以直接访问的。匿名union不能包含受保护的成员或私有成员,也不能定义成员函数。
对于union来说,要想构造或销毁类类型的成员必须执行非常复杂的操作,因此我们通常把含有类类型成员的union内嵌在另一个类当中。(这个确实复杂)
19.7 局部类
类可以定义在某个函数的内部,我们称这样的类为局部类(local class)。
局部类中不允许声明静态数据成员。
局部类只能访问外层作用域定义的类型名、静态变量以及枚举成员。如果局部类定义在某个函数内部,则该函数的普通局部变量不能被该局部类使用。
定义成员时用到的名字可以出现在类的任意位置。如果某个名字不是局部类的成员,则继续在外层函数作用域中查找;如果还没有找到,则在外层函数所在的作用域中查找。
可以在局部类的内部再嵌套一个嵌套类。
19.8 固有的不可移植的特性
为了支持低层编程,C++定义了一些固有的不可移植(nonportable)的特性。
不可移植的特性是指因机器而异的特性,当我们将含有不可移植特性的程序从一台机器转移到另一台机器上时,通常需要重新编写该程序。
位域(C++从C语言继承)
因为带符号位域的行为是由具体实现确定的,所以在通常情况下我们使用无符号类型保存一个位域。
1 |
|
取地址运算符(&)不能作用于位域,因此任何指针都无法指向类的位域。
volatile限定符(C++从C语言继承)
当对象的值可能在程序的控制或检测之外被改变时(例如一个由系统时钟定时更新的变量),应该将该对象声明为volatile。关键字volatile告诉编译器不应对这样的对象进行优化。
volatile限定符的用法和const很相似,但互相没什么影响,可以兼具。
链接指示:extern "C"(C++新增)
C++使用链接指示(linkage directive)指出任意非C++函数所用的语言。
1 |
|
其中位域和volatile使得程序更容易访问硬件;链接指示使得程序更容易访问用其他语言编写的代码。