笔记 C++ Primer

第一章 开始

1.2 初识输入输出

#include <iostream>
使用了 iostream 库,包含了 istream 类和 ostream 类,分别表示输入流和输出流。

向流写入数据
输出运算符(<<)接受两个运算对象,左侧必须是一个 ostream 对象,右侧是要打印的值,然后返回其左侧的对象。
std::endl
这是一个被称为操作符的特殊值,写入 endl 的效果是结束当前行,并将与设备关联的缓冲区中的内容刷到设备中。调试时应保证一直刷新流,避免程序崩溃时输出还留在缓冲区中。
std::cerr
一个 ostream 对象,写到 cerr 的数据是不缓冲的。
std::clog
一个 ostream 对象,写到 clog 的数据是被缓冲的。

从流读取数据
输入运算符(>>)接受两个运算对象,左侧必须是一个 istream 对象,右侧是要接收值的对象,然后返回其左侧的对象。

1.4 控制流

1.4.3 读取数量不定的输入数据

while(std::cin >> value)
当我们使用一个 istream 对象作为条件时,其效果是检测流的状态。当遇到文件结束符,或遇到无效输入时,istream 对象的状态会变为无效。

第二章 变量和基本类型

2.1 基本内置类型

2.1.1 算术类型

带符号类型和无符号类型
尽管字符型有三种charsigned charunsigned char,字符的表现形式却只有两种:类型char实际上会表现为另两种的一种,具体是否带符号由编译器决定,所以使用字符型进行运算特别容易出问题。

2.1.2 类型转换

无符号超范围时取模,有符号超范围时结果未定义。

含有无符号类型的表达式
当一个表达式里既有带符号数又有无符号数时,带符号数会自动转换成无符号数时。这个过程相当于将一个带符号数直接赋给成无符号变量,那么当带符号数为负时就会出现异常的结果。

2.1.3 字面值查常量

字符字面值和字串符字面值
字符串字面值的类型实际上是由常量字符构成的数组。编译器在每个字符串的结尾处添加一个空字符(‘\0’),因此,字符串字面值的实际长度要比它的内容多1。
如果两个字符串字面值位置紧邻且仅由空格、缩进和换行符分隔,则它们实际上是一 个整体。

2.2 变量

2.2.1 变量定义

默认初始化
定义于任何函数体之外的变量被初始化为0,定义在函数体内部的内置类型变量将不被初始化。string 类规定如果没有指定初值则生成一个空串。

2.2.2 变量声明和定义的关系

声明使得名字为程序所知。而定义负责创建与名字关联的实体。变量声明规定了变量的类型和名字。定义还申请存储空间,也可能会为变量赋一个初始值。

extern
如果想声明一个变量而非定义它,就在变量名前添加关键字 extern,而且不要显式地初始化变量。extern 语句如果包含初始值就不再是声明,而变成定义了。变量能且只能被定义一次,但是可以被多次声明。
如果要在多个文件中使用同一个变量,就必须将声明和定义分离。此时,变量的定义必须出现在且只能出现在一个文件中,而其他用到该变量的文件必须对其进行声明,却绝对不能重复定义。

2.3 复合类型

2.3.1 引用

因为无法令引用重新绑定到另外一个对象,因此引用必须初始化为一个对象。因为引用本身不是一个对象,所以不能定义引用的引用。

2.4 const 限定符

2.4.1 const 的引用

所谓常量引用是对常量的引用,因为引用不是一个对象,所以不存在常量引用。在初始化常量引用时允许用任意表达式作为初始值,此时该引用会绑定到一个临时量对象(常量)上,而与原本等号右侧的表达式几乎不相关。

2.4.2 指针和 const

允许一个指向常量的指针指向一个非常量对象,所谓的底层 const,不过是指针或引用自己认为自己指向了常量,然后自觉地不去改变所指对象的值。

2.4.3 顶层 const

用于声明引用的 const 都是底层 const。当执行对象的拷贝操作时,拷入和拷出的对象必须具有相同的底层 const 资格,或者两个对象的数据类型必须能够转换。一般来说,非常量可以转换成常量,反之则不行。

2.4.4 constexpr 表达式

常量表达式是指值不会改变并且在编译过程就能得到计算结果的表达式。显然字面值属于常量表达式,用常量表达式初始化的 const 对象也是常量表达式。
将变量声明为 constexpr 类型以便由编译器来验证变量的值是否是一个常量表达式。

字面值类型
算术类型、引用和指针都属于字面值类型。一个 constexpr 指针的初始值必须是 nullptг 或者 0,或者是存储于某个固定地址中的对象(定义于所有函数体之外的变量)。

指针和 constexpr
在 constexpr 声明中如果定义了一个指针,无论限定符 constexpr 出现在什么位置,都仅对指针生效,与指针所指的对象无关。

2.5 处理类型

2.5.1 类型别名

使用typedef char *pstr;using pstr = char *;
注意const pstr cstr不等于const char *cstr,就像是类型别名决定了声明中结合的优先级一样。
前者声明了一个指向 char 的常量指针,后者声明了一个指向 const char 的指针。

2.5.2 auto 类型说明符

auto —般会忽略掉顶层 const 和引用,如果希望推断出的 auto 类型是一个顶层 const,需要明确指出。
auto 引用会保留初始值中的顶层常量属性。
int i = 0;
cosnt int ci = i, &cr = ci;
auto b = ci; auto c = cr b 和 c 是 int。
auto d = &i; auto e = &ci; d 是一个 int 指针,e 是一个指向 const int 的指针。
const auto f = ci; f 是一个指向 int 的常量指针。
auto &g = ci; const auto &j = 42; g 和 j 都是 const int 引用。

2.5.3 decltype 类型指示

编译器分析表达式并得到它的类型,却不实际计算表达式的值。decltype 完整地保留参数的类型,包括 const 与引用。

decltype 和引用
如果表达式的内容是解引用操作,则decltype将得到引用类型。如果给变量加上了一层或多层括号,编译器就会把它当成是一个表达式。如果表达式的求值结果是左值,decltype 得到一个引用类引用型。因为变量是一种可以作为赋值语句左值的特殊表达式,所以这样的 decltype 就会得到引用类型。
切记:decltype((variaide))的结果永远是引用类型。

2.6.3 编写自己的头文件

有必要在书写 头文件时做适当处理,使其遇到多次包含情况也能安全和正常地工作。

预处理器概述
预处理器是在编译之前执行的一段程序。

头文件保护符
头文件保护符依赖于预处理变量。
#define指令把一个名字设定为预处理变量。
#ifdef当且仅当变量已定义时为真。
#ifndef当且仅当变量未定义时为真,一旦检查结果为真,则执行后续操作直至遇到#endif指令为止。
整个程序中的预处理变量包括头文件保护符必须唯一,通常的做法是基于头文件中类的名字来构建保护符的名字,以确保其唯一性。为了避免与程序中的其他实体发生名字冲突,一般把预处理变量的名字全部大写。

1
2
3
4
#ifndef CLASS_NAME_H
#define CLASS_NAME_H
// 各种包含与定义。
#endif

第三章 字符串、向量和数组

3.2 标准库类型 string

3.2.1 定义和初始化 string 对象

1
2
3
string s1(10, 'c'); // s 的内容是 cccccccccc
string s2 = "Hello"; // 拷贝初始化
string s3("Hello"); // 直接初始化

读写 string 对象
string 对象会自动忽略开头的空白,直到遇见下一处空白为止。

使用 getline 读取一整行
getline 函数的参数是一个输入流和一个 string 对象,函数从给定的输入流中读入内容,直到遇到换行符为止(注意换行符也被读进来了),然后把所读的内容存入到那个 string 对象中去(注意不存换行符)。

string::size_type 类型
size() 函数返回的是一个 string::size_type类型的值,它是一 个无符号类型的值(参见2.1.1节,第30页)而且能足够存放下任何string对象的大小。如果一条表达式中已经有了 size() 函数就不要再使用 int 了,这样可以避免
混用 int 和 unsigned 可能带来的问题。

3.3 标准库类型 vector

因为引用不是对象,所以不存在包含引用的 vector。

3.3.1 定义和初始化 vector 对象

值初始化
可以只提供 vector 对象容纳的元素数量而略去初始值。此时库会创建一个值初始化的元素初值,并把它赋给容器中的所有元素。这个初值由 vector 对象中元素的类型决定。如果 vector 对象的元素是内置类型,比如 int,则元素初始值自动设为 0。如果元素是某种类类型,比如 string,则元素由类默认初始化。
如果初始化时使用了花括号的形式但是提供的值又不能用来列表初始化,就要考虑用这样的值来构造 vector 对象了。

3.4 迭代器介绍

3.4.1 使用迭代器

vector<Type>::iteratorvector<Type>::const_iterator类型

结合解引用与成员访问操作
(*it).mem();括号必不可少。为了简化上述表达式,C++ 语言定义了箭头运算符(->)。箭头运算符把解引用和成员访问两个操作结合在一起,也就是说,it->mem(*it) .mem表达的意思相同。
任何一种可能改变 vector 对象容量的操作,比如 push_back,都会使该 vector 对象的迭代器失效。
两个迭代器相减所得结果的类型是名为 difference_type 的带符号整型数。

3.5 数组

数组的大小确定不变,维度必须是一个常量表达式,必须指定类型,不存在引用的数组,不允许拷贝和赋值。

复杂的数组声明
int *(&arry)[10] = prts; // 首先arry是一个引用,引用对象为大小为10的数组,数组类型是指向int的指针

3.5.3 指针和数组

1
2
3
int ia[] = {0, 1, 2, 3, 4};
auto ia2(ia); // ia2是指向is第一个元素的指针
decltype(ia) ia3 = {4, 3, 2, 1, 0}; // ia3是长度为5的整型数组

标准库函数 begin 和 end
begin() 函数返回指向数组首元素的指针,end() 函数返回指向数组尾元素下一位置的指针,这两个函数定义在 iterator 头文件中。

指针运算
两个指针相减的结果的类型是一种名为 ptrdiff_t 的标准库类型,和 size_t —样,ptrdiff_t 也是一种定义在 cstddef 头文件中的机器相关的类型。因为差值可能为负值,所以 ptrdiff_t 是一种带符号类型。

3.5.4 C 风格字符串

string 类提供了 c_str() 函数,返回的是一个 C 风格字符串。
允许使用数组来初始化 vector 对象。
vectro<int> ivec(begin(int_arr), end(int_arr));

3.6 多维数组

int ia[3][4] = {{0}, {1}, {3}}; // 显示地初始化每行的首元素
要使用范围 for 语句处理多维数组,除了最内层的循环外,其他所有循环的控制变量都应该是引用类型,否则 auto 的类型会是一个指针。

第四章 表达式

4.1 基础

4.1.1 基本概念

运算对象转换
小整数类型(如 bool、char、short 等)通常会被提升成较大的整数类型,主要是 int。

4.1.3 求值顺序

编译器不会明确指定表达式中运算对象的求值顺序。i++ + ++i自然是未定义的操作。

4.2 算术运算符

m%(-n)=m%n; (-m)%n=-(m%n)

4.4 赋值运算符

右侧运算对象将转换成左侧运算对象的类型,赋值运算返回的是其左侧运算对象。
复合运算符只求值一次。

4.5 递增和递减运算符

前置版本将更新后的对象本身作为左值返回,后置版本则将对象原始值的副本作为右值返回。除非必须,否则不用递增递减运算符的后置版本。

4.7 条件运算符

条件运算符的优先级非常低,因此当一条长表达式中嵌套了条件运算子表达式时,通 常需要在它两端加上括号。

4.8 位运算符

如果运算对象是带符号的且它的值为负,那么位运算符如何处理运算对象的“符号位”依赖于机器。而且, 此时的左移操作可能会改变符号位的值,因此是一种未定义的行为。
左移运算符(<<)在右侧插入值为 0 的二进制位。右移运算符(>>)的行为则依赖于其左侧运算对象的类型:如果该运算对象是无符号类型,在左侧插入值为 0 的二进制位: 如果该运算对象是带符号类型,在左侧插入符号位的副本或值为 0 的二进制位。

4.11 类型转换

4.11.1 算术转换

算数表达式中运算符的运算对象将转换成最宽的类型。
带符号类型大于无符号类型,此时转换的结果依赖于机器。如果无符号类型的所有值都能存在该带符号类型中,则无符号类型的运算对象转换成带符号类型。如果不能,那么带符号类型的运算对象转换成无符号类型。

4.11.2 其他隐式类型转换

包括常量整数值 0 或者字面值 nullptr 能转换成任意指针类型。指向任意非常量的指针能转换成 void*。指向任意对象的指针能转换成 const void*。

4.11.3 显示转换

static_cast
任何具有明确定义的类型转换,只要不包含底层 const,都可以使用 static_cast。

const_cast
const_cast只能改变运算对象的底层 const。

reinterpret_cast
通常为运算对象的位模式提供较低层次上的重新解释。reinterpret_cast 本质上依赖于机器且非常危险。

旧式的强制转换
type(exper)(type)exper
如果替换为 static_cast 或 const_cast 也合法,则执行与他们相似的行为,否则执行与 reinterpret_cast 相似的行为。

第五章 语句

5.6 try 语句和异常处理

抛出异常将终止当前的函数,并把控制权转移给能处理该异常的代码。
在 try 中 throw 异常,然后层层查找符合的 catch。如果最终还是没能找到任何匹配的catch子句,程序转到名为 terminate 的标准库函数。该函数的行为与系统有关,一般情况下,执行该函数将导致程序非正常退出。

第六章 函数

6.1 函数基础

形参和实参
实参是形参的初始值。编译器能以任意可行的顺序对实参求值。

6.1.1 局部对象

自动对象
我们把只存在于块执行期间的对象称为自动对象。

局部静态对象
局部静态对象在程序的执行路径第一次经过对象定义语句时初始化,并且直到程序终止才被销毁,在此期间即使对象所在的函数结束执行也不会对它有影响。
如果局部静态变量没有显式的初始值,它将执行值初始化。

6.2 参数传递

6.2.3 const 形参和实参

当用实参初始化形参时会忽略掉顶层 const。换句话说,形参的顶层 const 被忽略掉了。

尽量使用常量引用
一个非常量引用的形参无法接受一个常量的实参

6.2.4 数组形参

1
2
3
4
void fun(int*);
void fun(int[]);
void fun(int[10]);
// 这三个函数是等价的。

数组引用形参
形参是数组的引用,维度是类型的一部分。
void fun(int (&arr)[10]);

传递多维数组
数组第二维(以及后面所有维度)的大小都是数组类型的一部分,不能省略:
void fun(int (*mat)[10])

6.2.6 含有可变形参的函数

initializer_list 形参
实参数量未知但是全部实参的类型都相同,initializer_list 也是一种模板类型,对象中的元素永远是常量值。如果想向 initializer_list 形参中传递一个值的序歹,则必须把序列放在一对花括号内:
fun({a, b, c});

省略符形参
省略符形参只能出现在形参列表的最后一个位置,大多数类类型的对象在传递给省略符形参时都无法正确拷贝,省略符形参所对应的实参无须类型检查。
void fun(int a, ...)

6.3 返回类型和 return 语句

6.3.1 无返回值函数

在这类函数的最后一句后面会隐式地执行 return。一个返冋类型是 void 的函数也能返回另一个返回 void 的函数。

6.3.2 有返回值函数

返冋的值用于初始化调 用点的一个临时量,该临时量就是函数调用的结果。

6.3.3 返回数组指针

1
2
typedef int arrT[10]; // arrT的类型是长度为10的int数组
using arrT = int[10]; // 等价

返回数组指针的函数形如:
Type(*fun(par_list))[dim]

使用尾置返回类型
尾置返冋类型跟在形参列表后面并以一个(->)符号开头。在本应该出现返回类型的地方放置一个 auto。
auto fun(int i) -> int (*) [10];
注意:decltype 并不负责把数组类型转换成对应的指针。

6.4 函数重载

一个拥有顶层 const 的形参无法和另一个没有顶层 const 的形参R分开来。

6.5 特殊用途语言特性

6.5.1 默认实参

为了使得窗口函数既能接纳默认值,也能接受用户指定的值,我们把它定义成如下的形式:
void fun(int a, int b = 0, int c = 1);
一旦某个形参被赋予了默认值,它后面的所有形参都必须有默认值,默认实参负责填补函数调用缺少的尾部实参。
用作默认实参的名字在函数声明所在的作用域内解析,而这些名字的求值过程发生在函数调用时。

6.5.2 内联函数和 constexpr 函数

内联函数可避免函数调用的开销
在函数的返回类型前面加上关键字 inline,内联说明只是向编译器发出的一个请求,编译器可以选择忽略这个请求。

constexpr 函数
函数的返回类型及所有形参的类型都得是字面值类型,而且函数体中必须有且只有一条 return 语句。
编译器把对 constexpr 函数的调用替换成其结果值。为了能在编译过程中随时展开,constexpr 函数被隐式地指定为内联函数。
我们允许 constexpr 函数的返回值并非一个常量。

6.5.3 调试帮助

assert(expr);
如果表达式为真(即非0),assert 什么也不做。assert 宏常用于检查“不能发生”的条件。

NDEBUG 预处理变量
#define NDEBUG
如果定义了 NDEBUG, 则 assert 什么也不做。定义 NDEBUG 能避免检查各种条件所需的运行时开销。

6.6 函数匹配

确定候选函数和可行函数
候选函数:一是与被调用的函数同名,二是其声明在调用点可见。
可行函数:一是其形参数量与本次调用提供的实参数量相等,二是每个实参的类型与对应的形参类型相同,或者能转换成形参的类型。

寻找最佳匹配
如果有且只有一个函数满足下列条件,则匹配成功:

  • 该函数每个实参的匹配都不劣于其他可行函数需要的匹配。
  • 至少有一个实参的匹配优于其他可行函数提供的匹配。

如果在检查了所有实参之后没有任何一个函数脱颖而出,则该调用是错误的。编译器将报 告二义性调用的信息。

6.7 函数指针

使用函数指针
当我们把函数名作为一个值使用时,该函数自动地转换成指针(取地址符可选)
我们还能直接使用指向函数的指针调用该函数,无须提前解引用指针(解引用符可选)
在指向不同函数类型的指针间不存在转换规则。

函数指针形参
形参可以是指向函数的指针。此时,形参看起来是函数类型,实际上却是当成指针使用。

1
2
void fun1(int fun2()); // 形参是函数类型,它会自动地转换成指向函数的指针。
void fun1(int (*fun2)()); // 显式地将形参定义成指向函数的指针。

直接把函数作为实参使用,此时它会自动转换成指针。
fun1(fun);

返回指向函数的指针
编译器不会自动地将函数返回类型当成对应的指针类型处理。

1
2
3
4
5
using F = int();
using PF = int(*)();

PF f1(); // PF是指向函数的指针,fl返回指向函数的指针。
F *fi(); // 显式地指定返回类型是指向函数的指针。

第七章 类

7.1 定义抽象数据类型

引入 this
total.isbn()
当 isbn() 返冋 bookNo 时,实际上它隐式地返回 total.bookNo。
编译器负责把 total 的地址传递给 isbn() 的隐式形参 this,任何对类成员的直接访问都被看作this的隐式引用。
因为 this 的目的总是指向“这个”对象,所以 this 是一个常量指针。

引入 const 成员函数
默认情况下,this 的类型是指向类类型非常量版本的常量指针。由于底层 const 不匹配,我们不能在一个常量对象上调用普通的成员函数。
紧跟在参数列表后面的 const 表示 this 是一个指向常量的指针,常量成员函数不能改变调用它的对象的内容。常量对象,以及常量对象的引用或指针都只能调用常量成员函数。

7.1.4 构造函数

构造函数不能被声明成 const 的,直到构造函数完成初始化过程,对象才能真正取得其“常量”属性。因此,构造函数在 const 对象的构造过程中可以向其写值。

7.2.1 友元

友元声明只能出现在类定义的内部,友元不是类的成员也不受它所在区域访问控制级别的约束。

友元的声明
友元的声明仅仅指定了访问的权限,而非一个通常意义上的函数声明。如果我们希望类的用户能够调用某个友元函数,那么我们就必须在友元声明之外再专门对函数进行一次声明。

7.3 类的其他特性

7.3.1 类成员再探

定义_个类型成员
类还可以自定义某种类型在类中的别名。由类定义的类型名字和其他成员一样存在访问限制。

1
2
3
4
class ClassName{
public:
using pos = std::string::size_type;
};

定义在类内部的成员函数是自动 inline 的。

可变数据成员
在变量的声明中加入 mutable 关键字,一个可变数据成员永远不会是 const,即使它是 const 对象的成员。因此,一个 const 成员函数可以改变一个可变成员的值。

7.3.2 返回 *this 的成员函数

1
2
3
ClassName &fun1(){
return *this;
}

返回引用的函数是左值的,所以这样的操作将在同一个对象上执行:
实例.fun1().fun2().fun3()
如果定义的返回类型不是引用,则函数的返回值将是 *this 的副本,因此调用函数只能改变临时副本,而不能改变原实例的值。

*从 const 成员函数返回 this
一个 const 成员函数如果以引用的形式返回 *this,那么它的返回类里将是常量引用,因为非常量版本的函数对于常量对象是不可用的。
根据对象是否是常量重载函数,隐式传入的 this 参数同样参与函数匹配的过程。

1
2
3
4
5
6
7
8
9
class ClassName{
public:
ClassName &fun() {
return *this;
}
const ClassName &fun() const {
return *this;
}
};

7.3.3 类类型

在类声明之后定义之前是一个不完全类型,可以定义指向这种类型的指针或引用,也可以声明(但是不能定义)以不完全类型作为参数或者返回类型的函数。一旦一个类的名字出现后,它就被认为是声明过了。

7.3.4 友元再探

友元关系不存在传递性。
友元类的成员函数和友元成员函数可以访问此类包括非公有成员在内的所有成员。
如果一个类想把一组重载函数声明成它的友元,它需要对这组函数中的每一个分别声明。

友元声明和作用域
当一个名字第一次出现在一个友元声明中时,我们隐式地假定该名字在当前作用域中是可见的。然而,友元本身不一定真的声明在当前作用域中。甚至就算在类的内部定义该函数,我们也必须在类的外部提供相应的声明从而使得函数可见。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ClassName {
friend void fun() {}
ClassName() {
fun(); // 错误
}
void a();
void b();
}
void ClassName::a() {
fun(); // 错误
}
void fun();
void ClassName::b() {
fun(); // 正确
}

7.4 类的作用域

在类的外部定义其成员函数时,因为返回类型出现在类名之前,所以事实上返回类型是位于类的作用域之外的。

类作用域之后,在外围的作用域中查找
如果我们需要的是外层作用域中的名字,可以显式地通过作用域运算符(::)来进行请求:

1
2
3
void fun(int tmp) {
int a = 2 * ::tmp; // 此时的in是最外层的全局变量
}

7.5 构造函数再探

7.5.1 构造函数初姶值列表

如果没有在构造函数的初始值列表中显式地初始化成员,则该成员将在构造函数体之前执行默认初始化。
如果成员是 const、引用,或者属于某种未提供默认构造函数的类类型,我们必须通过构造函数初始值列表为这些成员提供初值。
成员的初始化顺序与它们在类定义中的出现顺序一致,构造函数初始值列表中初始值的前后位置关系不会影响实际的初始化顺序。
如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数。

7.5.4 隐式的类类型转换

如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制(转换构造函数)。在需要使用类类型的地方,我们可以使用转换构造函数的参数的类型作为替代。
只允许一步类类型转换,隐式地使用两种转换规则是错误的。比如只定义了 string 的转换构造函数却提供了一个字符串常量实参。

抑制构造函数定义的隐式转换
将构造函数声明为 explicit 加以抑制隐式转换。
关键字 explicit 只对一个实参的构造函数有效。需要多个实参的构造函数不能用于执行隐式转换。只能在类内声明构造函数时使用 explicit 关键字,在类外部定义时不应重复。
显示地进行强制转换依旧可以正常地发挥转换构造函数的效果。

7.5.5 聚合类

当一个类满足如下条件时,我们说它是聚合的:

  • 所有成员都是 public 的。
  • 没有定义任何构造函数。
  • 没有类内初始值。
  • 没有基类,也没有 virtual 函数。
    可以提供一个花括号括起来的成员初始值列表,并用它初始化聚合类的数据成员,初始值的顺序必须与声明的顺序一致。

7.5.6 字面值常量类

数据成员都是字面值类型的聚合类是字面值常量类。如果一个类不是聚合类,但它符合下述要求,则它也是一个字面值常量类:

  • 数据成员都必须是字面值类型。
  • 类必须至少含有一个 constexpr 构造函数。
  • 如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式;或者如果成员属于某种类类型,则初始值必须使用成员自己的 constexpr 构造函数。
  • 类必须使用析构函数的默认定义,该成员负责销毁类的对象。

constexpr 构造函数
尽管构造函数不能是 const 的,但是字面值常量类的构造函数可以是 constexpr 函数,constexpr 构造函数体一般来说应该是空的,或者声明成= default.
constexpr 构造函数必须初始化所有数据成员.

7.6 类的静态成员

对象中不包含任何与静态数据成员有关的数据,静态成员函数不能声明成 const 的,而且我们也不能在 static 函数体内使用 this 指针。
使用作用域运算符直接访问静态成员,使用类的对象、引用或者指针的点运算符来访问静态成员。

定义静态成员
当在类的外部定义静态成员时,不能重复 static 关键字,该关键字只出现在类内部的声明语句。
我们不能在类的内部初始化静态成员。相反的,必须在类的外部定义和初始化每个静态成员。

静态成员的类内初始化
我们可以为静态成员提供 const 整数类型的类内初始值,不过要求静态成员必须是字面值常量类型的 constexpr。即使一个常量静态数据成员在类内部被初始化了,通常情况下也应该在类的外
部定义一下该成员。
静态数据成员可以是不完全类型。

第八章 IO 库

8.1 IO 类

8.1.1 IO 对象无拷贝或赋值

进 IO 操作的函数通常以引用方式传递和返回流。读写一个 IO 对象会改变其状态,因此传递和返回的引用不能是 const 的。

8.1.2 条件状态

如果我们输入一个非期望的数据类型或一个文件结束标识,cin 会进入错误状态。
一个流一旦发生错误,其上后续 IO 操作都会失败。只有当一个流处于无错状态时,我们才可以从它读取数据,向它写入数据。

8.1.3 管理输出缓冲

读 cin 或写 cerr 都会导致 cout 的缓冲区被刷新。

unitbf
unitbuf 操纵符告诉流在接下来的每次写操作之后都进行一次 flush 操作。而 nounitbuf 操纵符则重置流,使其恢复使用正常的系统管理的缓冲区刷新机制。

1
2
cout << unitbuf;
cout << nounitbuf;

关联输入和输出流
当一个输入流被关联到一个输出流时,任何试图从输入流读取数据的操作都会先刷新关联的输出流。
标准库将 cout 和 cin 关联在一起,
tie 有两个重载的版本:一个版本不带参数,返回指向输出流的指针。如果本对象当前关联到一个输出流,则返回的就是指向这个流的指针,如果对象未关联到流,则返回空指针。tie 的第二个版本接受一个指向 ostream 的指针,将自己关联到此 ostream。

1
2
3
4
5
in.tie(&out);
// old_tie 指向当前关联到 in 的流,in 不再与其他流关联。
ostream *old_out = in.tie(nullptr);
in.tie(&new_out);
in.tie(old_out);

8.2 文件输入输出

ifstream 从一个给定文件读取数据,ofstream 向一个给定文件写入数据,以及 fstream 可以读写给定文件。

8.2.1 使用文件流对象

创建文件流对象时,我们可以提供文件名。如果提供了一个文件名,则 open 会自动被调用。为了将文件流关联到另外一个文件,必须首先关闭已经关联的文件。当一个 fstream 对象被销毁时,close 会自动被调用。

8.2.2 文件模式

in:以读方式打开;out:以写方式打开;app:每次写操作前均定位到文件末尾;ate:打开文件后立即定位到文件末尾;trunc:截断文件;binary:以二进制方式进 IO;
在 app 模式下,即使没有显式指定 out 模式,文件也总是以输出方式被打开。
即使我们没有指定 trun ,以 out 模式打幵的文件也会被截断。
与 ifstream 关联的文件默认以 in 模式打幵;与 ofstream 关联的文件默认以 out 模式打开;与 fstream 关联的文件默认以 in 和 out 模式打幵。
默认情况下,当我们打开一个 ofstream 时,文件的内容会被丢弃。每当打开文件时,都可以改变其文件模式。

8.3 string 流

1
2
3
sstream strm(s); // strm 保存 string s 的一个拷贝。
strm.str(); // 返回 strm 保存的 string 拷贝。
strm.str(s); // 将 string s 拷贝到 strm 中,返回 void。

第九章 顺序容器

9.1 顺序容器概述

vector:可变大小数组。支持快速随机访问。在尾部之外的位置插入或删除元素可能很慢。
deque:双端队列。支持快速随机访问。在头尾位置插入/删除速度很快。
list:双向链表。只支持双向顺序访问。在list中任何位置进行插入/删除 操作速度都很快。
forward_list:单向链表。只支持单向顺序访问。在链表任何位置进行插入/删除操作速度都很快。
array:固定大小数组。支持快速随机访问。不能添加或删除元素。
string:与vector相似的容器,但专门用于保存字符。随机访问快。在尾部插入/删除速度快。

确定使用哪种顺序容器

  • 除非你有很好的理由选择其他容器,否则应使用 vector。
  • 如果你的程序有很多小的元素,且空间的额外开销很重要,则不耍使用 list 或 forward_list。
  • 如果程序要求随机访问兀素,应使用 vector 或 deque。
  • 如果程序要求在容器的中间插入或删除元素,应使用 list 或 forward_list。
  • 如果程序需要在头尾位置插入或刪除元素,但不会在中间位置进行插入或删除操作,则使用 deque。

9.2.4 容器定义和初始化

标准库 array 具有固定大小
当定义一个 array 时,除了指定元素类型,还要指定容器大小。
一个默认构造的 array 是非空的,它包含了与其大小一样多的元素。这些元素都被默认初始化。
如果初始值列表中的初始值数目小于 array 的大小,则它们被用来初始化 array 中靠前的元素,所有剩余元素都会进行值初始化。
此外,array 还要求初始值元素类型和大小也都一样,因为大小是 array 类型的一部分。

9.2.5 赋值和 swap

array 类型不支持 assign, 也不允许用花括号包围的值列表进行赋值。
賦值相关运算会导致指向左边容器内部的迭代器、引用和指针失效而 swap 操作将容器内容交换不会导致指向容器的迭代器、引用和指针失效。

使用 assign (仅顺序容器)
assign 允许我们从一个不同但相容的类型赋值,或者从容器的一个子序列赋值。用参数所指定的元素(的拷贝)替换左边容器中Й所有元素。
传递给 assign 的迭代器不能指向调用 assign 的容器。

使用 swap
swap 不对任何元素进行拷贝、删除或插入操作,因此可以保证在常数时间内完成。
元素不会被移动意味着,除 string 外,指向容器的迭代器、引用和指针在 swap 操作之后都不会失效。它们仍指向 swap 操作之前所指向的那些元素。但是,在 swap 之后,这些元素已经属于不同的容器了。
与其他容器不同,swap 两个 array 会真正交换它们的元素。因此,交换两个 array 所需的时间与 array 中元素的数目成正比。

9.2.7 关系运算符

关系运算符左右两边的运算对象必须是相同类型的容器,且必须保存相同类型的元素。比较两个容器实际上是逐元素使用元素的关系运算符完成比较。
如果元素类型不支持所需运算符,那么保存这种元素的容器就不能使用相应的关系运算。

9.3 顺序容器操作

9.3.1 向顺序容器添加元素

使用 emplace 操作
当调用 push 或 insert 成员函数时,我们将元素类型的对象传递给它们,这些对象被拷贝到容器中。
而当我们调用一个 emplace 成员函数时,则是将参数传递给元素类型的构造函数。emplace 成员使用这些参数在容器管理的内存空间中直接构造元素。

9.3.2 访问元素

front() 返回首元素的引用,back() 返回尾元素的引用。c[n] 与 a.at(n) 在 n 未越界时效果相同,越界时 at() 抛出 out_of_range 异常。

9.3.5 改变容器大小

1
2
3
list<int> ilist(10, 42);
ilist.resize(15, -1); // 将 5 个 -1 添加到 ilist 的末尾。
ilist.resize(5); // 从 ilist 的末尾删除 10 个元素。

9.3.6 容器操作可能使迭代器失效

当我们删除元素时,尾后迭代器总是会失效。保证每次改变谷器的操作之后都正确地重新定位迭代器。
不能在循环之前保存 end 返回的迭代器,通常 end() 操作都很快。

9.4 vector 对象是如何增长的

没有空间容纳新元素,因为元素必须连续存储,容器必须分配新的内存空间来保存己有元素和新元素,将已有元素从旧位置移动到新空间中,然后添加新元素,释放旧存储空间。
vector 和 string 的实现通常会分配比新的空间需求更大的内存空间,容器预留这些空间作为备用。
补充:push_back 的时间复杂度为什么是 o(1)

管理容量的成员函数
c.shrink_to_fit():请求将 capacity() 减少为与 size() 相同d 大小,标准库并不保证一定能退还内存。
c.capacity():不重新分配内存空间的话,容器可以保存多少兀素。
c.reserve(n):分配至少能容纳 n 个元素的内存空间。
reserve 并不改变容器中元素的数量,它仅影响 vector 預先分配多大的内存空间。只有当需要的内存空间超过当前容量时,reserve 调用才会改变 vector 的容量,否则什么也不做,所以说调用 reserve 永远也不会减少容器占用的内存空间。

9.5 额外的 string 操作

9.5.3 string 搜索操作

如果搜索失败,则返回一个名为 stringr::npos 的 static 成员。
string 搜索函数返回 string::size_type 值,该类型是一个 unsigned 类型。因此,用一个 int 或其他带符号类型来保存这些函数的返回值不是一个好主意。

第十章 泛型算法

10.2 初始泛型算法

10.2.2 写容器元素的算法

介绍 back_inserter
back_inserter 接受一个指向容器的引用,当我们通过此迭代器赋值时,赋值运算符会调用 push_back 将一个具有给定值的元素添加到容器中。

1
2
3
vector<int> vec; // 空向量
auto it = back_inserter(vec); // 通过它賦值会将元素添加到 vec 中
it = 42; // vec 中现在有一个元素,值为 42

10.3 定制操作

向算法传递函数

谓词
谓词是一个可调用的表达式,其返回结果是一个能用作条件的值。标准库算法所使用的谓词分为一元谓词和二元谓词。

10.3.2 lambda 表达式

介绍lambda
可以将其理解为一个未命名的内联函数。lambda可能定义在函数内部。lambda 必须使用尾置返回。一个lambda表达式具有如下形式:
[capture list] (parameter list) -> return type { function body }
如果 lambda 的函数体包含任何单一 return 语句之外的内容,且未指定返回类型,则返回 void。

向lambda传递参数
lambda 不能有默认参数。一个 lambda 通过将局部变量包含在其捕获列表中来指出将会使用这些变量。捕获列表指引 lambda 在其内部包含访问局部变量所需的信息。
捕获列表只用于局部非 static 变量,lambda 可以直接使用局部 static 变量和在它所在函数之外声明的名字。

10.3.3 lambda 捕获的返回

当向一个函数传递一个 lambda 时,同时定义了一个新类型和该类型的一个对象。
值捕获则是在定义之时进行捕获,而引用捕获在调用时进行捕获。

值捕获
采用值捕获的前提是变量可以拷贝。与参数不同,被捕获的变量的值是在 lambda 创建时拷贝,而不是调用时拷贝,随后对其修改不会影响到 lambda 内对应的值。

引用捕获
采用引用方式捕获一个变量,就必须确保被引用的对象在 lambda 执行的时候是存在的。

隐式捕获

1
2
[=](const string &s)
{ return s.size () >= sz; } // sz 未隐式的值捕获

如果我们希望对一部分变量采用值捕获,对其他变量采用引用捕获,可以混合使用隐式捕获和显式捕获:

1
2
3
4
// os隐式捕获,引用捕获方式;c 显式捕获,值捕获方式
[&, c](const string &s) { os << s << c; }
// os显式捕获,引用捕获方式;c隐式捕获,值捕获方式
[=, &os](const string &s) { os << s << c; }

当我们混合使用隐式捕获和显式捕获时,捕获列表中的第一个元素必须是一个 & 或 =。此符号指定了默认捕获方式为引用或值,显式捕获的变量必须使用与隐式捕获不同的方式。

可变 lambda
如果我们希望能改变一个被捕获的变量的值,就必须在参数列表首加上关键字 mutable。

1
2
3
4
5
6
void fсп() {
int vl = 42; // 局部变量
// f 可以改变它所捕获的变量的值
auto f = [vl] () mutable { return ++vl; };
vl = 0;
auto j = f(); // j 为 43

10.3.4 参数绑定

标准库 bind 函数
bind 接受一个可调用对象,生成一个新的可调用对 象来“适应”原对象的参数列表。

1
auto newCallable = bind (callable, arg_list);

当我们调用 newCallable 时 newCallable 会调用 callable,并传递给它中的参数。
arg_list 参数可能包含形如 _n 的名字。这些参数是“占位符”,其本身的位置代表其在原函数形参列表中的位置,其数字代表其在新函数形参列表中的位置。
名字 _n 都定义在一个名为 placeholders 的命名空间中。

1
auto newFun = bind(oldFun, a, b, _2, c, _1);

这个 bind 调用会将 newFun(_1, _2); 映射为 oldFun(a, b, _2, с, _1);

绑定引用参数
如果我们希望传递给 bind 一个对象而又不拷贝它,就必须使用标准库 ref 函数。

1
2
// 我们无法拷贝 ostream
for_each(words.begin(), words.end(), bind(print, ref(os), _1, ' '));

函数 ref 返回一个对象,包含给定的引用,此对象是可以拷贝的。标准库中还有一个 cref 函数,生成一个保存 const 引用的类。

第十一章 关联容器

允许重复关键字的容器的名字中都包含单词 multi,不保持关键字按顺序存储的容器的名字都以单词 unordered 开头。

11.1 使用关联容器

如果下标还未在 map 中,下标运算符会创建一个新元素,其关键字为下标,值为值初始化。

1
2
3
if(set.find(target) == set.end()) {
// set 中不存在 target
}

11.2 关联容器概述

11.2.2 关键字类型的要求

对于有序容器 map、multimap、set 以及 multiset,关键字类型必须定义元素比较的方法

11.2.3 pair 类型

一个 pair 保存两个 public 数据成员, 两个成员分别命名为 first 和 second。

1
make_pair(v1, v2); // pair 的类型由 v1 v2 推断出来。

11.3 关联容器的操作

key_type:此容器类型的关键字类型。
mapped_type:每个关键字关联的类型,只适用于map。
value_type:对于 set,与 key_type 相同;对于 map,为 pair<const key_type, mapped_type>
关键字是 const 的。

11.3.2 添加元素

插入一个已存在的元素对容器没有任何影响。

检测 insert 的返回值
添加单一元素的 insert 和 emplace 版本返冋一个 pair,first 成员是一个迭代器,指向具有给定关键字的元素;second 成员是一个 bool 值,指出元素是插入成功还是已经存在于容器中。

11.3.4 map 的下标操作

set 类型不支持下标,也不能对一个 multimap 或一个 unordered_multimap 进行下标操作。
如果关键字并不在 map 中,会为它创建一个元素并插入到 map 中,关联值将进行值初始化。如果不希望产生新的插入,可以使用 at()。
当对一个 map 进行下标操作时,会获得一个 mapped_type 对象;但当解引用一个 map 迭代器时,会得到一个 value_type 对象。

11.3.5 访问元素

c.lower_bound(k):返回一个迭代器,指向第一个关键字不小于к的元素。
с.upper_bound(k):返回一个迭代器,指向第一个关键字大于к的元素。
с.equal_range(k):返回一个迭代器 pair,表示关键字等于 K 的元素的范围。若不存在,pair 的两个成员均等于 c.end()。
若关键字未匹配,他们都返回一个迭代器,指向不影响排序的关键字插入位置。

在 multimap 或 multiset 中查找元素
如果一个 multimap 或 multiset 中有多个元素具有给定关键字,则这些元素在容器中会相邻存储。

在容器中使用自定义的类

如果要在 map/set 中使用自定义的类,需要提供判断 < 的方法。
如果要在 unordered_map/unordered_set 中使用自定义的类,需要提供计算哈希值的方法,以及判断 == 的方法。

  • 使用函数指针
1
2
3
4
5
6
7
8
9
10
11
struct Line{/* 成员与构造函数 */};

size_t m_hash (const Line &l) {
return l.k * 100 + l.b;
};
bool m_equal (const Line &l1, const Line &l2) {
return l1.k == l2.k && l1.b == l2.b;
};

// 参数代表了桶的大小、哈希函数指针、相等性判断运算符函数指针
unordered_set<Line, decltype(m_hash)*, decltype(m_equal)*> h_set(42, m_hash, m_equal);
  • 如果我们的类重载了 == 运算符,则可以只重载哈希函数。
    并且,我们可以通过重载函数调用运算符的方式提供一个函数对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Line {
// 成员与构造函数
bool operator == (const Line &l) const {
return k == l.k && b == l.b;
}
};

struct LineHash {
size_t operator () (const Line &l) const {
return l.k * 100 + l.b;
}
};

unordered_set <Line, LineHash> h_set;
unordered_map <Line, string, LineHash> h_map;
  • 最后,在 C++20 中似乎可以用 lambda 代替这个函数对象。

C++11 到 C++17 里,lambda 表达式的复制运算符被删除了,这导致 lambda 表达式无法复制构造,不满足哈希函数所需的条件(是函数对象,可复制构造,可析构,调用时对相同对象返回相同哈希)。
C++20 里据说无捕获的 lambda 表达式可以复制构造了。理论上这应该解决了 lambda 表达式不能做哈希的缺陷。
知乎

1
2
3
4
5
6
7
8
9
10
11
12
struct Line{/* 成员与构造函数 */};

auto m_hash = [](const Line &l) {
return (size_t)(l.k * 100 + l.b);
};
auto m_equal = [](const Line &l1, const Line &l2) {
return l1.k == l2.k && l1.b == l2.b;
};

unordered_set<Line, decltype(m_hash), decltype(m_equal)> h_set;
// 或者写成
unordered_set<Line, decltype(m_hash), decltype(m_equal)> h_set(42, m_hash, m_equal);

第十二章 动态内存

分配在静态或栈内存中的对象由编译器自动创建和销毁。每个程序还拥有一个内存池,这部分内存被称作自由空间或堆。程序用堆来存储动态分配的对象。

12.1 动态内存与智能指针

12.1.1 shared_ptr 类

最安全的分配和使用动态内存的方法是调用一个名为 make_shared<>() 的标准库函数。此函数在动态内存中分配一个对象并初始化它,返回指向此对象 shared_ptr。
每个 shared_ptr 都有一个关联的计数器,通常称其为引用计数。一旦一个 shared_ptr 的计数器变为 0,它就会自动释放自己所管理的对象。但只要有其他 shared_ptr 也指向这块内存,它就不会被释放掉。
补充:shared_ptr 导致的循环引用及其解决方法

12.1.2 直接管理内存

使用 new 动态分配和初始化对象
new 无法为其分配的对象命名,而是返回一个指向该对象的指针。动态分配的对象是默认初始化的,而类类型对象将用默认构造函数进行初始化。

1
2
int *p1 = new int; // 默认初始化
int *p2 = new int(); // 值初始化

只有当括号中仅有单一初始化器时才可以使用 auto 推断 new 的返回值。

1
int *p2 = new (nothrow) int; //如果分配失败,new 返回一个空才旨针,不抛出异常

12.1.3 shared_ptr 和 new 结合使用

不能将一个内置指针隐式转换为一个智能指针,必须使用直接初始化形式。

1
2
shared_ptr<int> pi = new int(42); //错误:必须使用直接初始化形式
shared_ptr<int> p2 (new int(42)); // 正确:使用了直接初始化形式

一个临时的 shared_ptr 指向的内存会立刻被释放。

1
2
3
4
int *x(new int(42)); //危险:x 是一个普通指针,不是一个智能指针
process (x); // 错误:不能将 int* 转换为一个 shared_ptr<int>
process (shared_ptr<int>(x)); // 合法的,但内存会被释放
int j = *x; // 未定义的:X 是一个空悬指针

使用一个内置指针来访问一个智能指针所负责的对象是很危险的,因为我们无法知道对象何时会被销毁。
也不要使用 get 初始化另一个智能指针或为智能指针赋值,因为另一个智能指针会做额外的 delete。
而且使用 get 返回的指针的代码不能 delete 此指针。

12.1.4 智能指针的异常

在 new 之后在对应的 delete 之前发生了异常,则内存不会被释放。

使用我们自己的释放操作
当我们创建一个 shared_ptr 时,可以传递一个指向删除器函数的参数。

1
shared_ptr<connection> p(&c, end_connection);

当 p 被销毁时,它不会对自己保存的指针执行 delete,而是调用 end_connection。

12.1.5 unique_ptr

某个时刻只能有一个 unique_ptr 指向一个给定对象。初始化 unique_ptr 必须采用直接初始化形式。unique_ptr 不支持普通的拷贝或赋值操作。

传递 unique_ptr 参数和返回 unique_ptr
有一个例外:我们可以拷贝或赋值一个将要被销毁的 unique_ptr。

1
2
3
4
unique一ptr<int> clone(int p) {
// 正确:从 int* 创建一个 unique_ptr<int>
return unique_ptr<int>(new int(p));
}

还可以返回一个局部对象的拷贝

1
2
3
4
5
unique_ptr<int> clone (int p) {
unique_ptr<int> ret(new int (p));
//…
return ret;
}

向 unique_ptr 传递删除器
unique_ptr 默认情况下用 delete 释放它指向的对象。
必须在尖括号中 unique_ptr 指向类型之后提供删除器类型。

1
unique_ptr<objT, delT> p (new objT, fun);

12.1.6 weak_ptr

weak_ptr 是一种不控制所指向对象生存期的智能指针。创建一个 weak_ptr 时,要用一个 shared_ptr 来初始化它。
由于对象可能不存在,我们不能使用 weak_ptr 直接访问对象,而必须调用lock检查 weak_ptr 指向的对象是否仍存在。

12.2 动态数组

12.2.1 new 和数组

分配一个数组会得到一个元素类型的指针
当用 new 分配一个数组时,我们并未得到一个数组类型的对象,而是得到一个数组元素类型的指针。
由于分配的内存并不是一个数组类型,因此不能对动态数组调用 begin 或 end,也不能用范围 for。

动态分配一个空数组是合法的
当我们用 new 分配一个大小为0的数组时,new 返回一个合法的非空指针。此指针保证与 new 返回的其他任何指针都不相同,但此指针不能解引用。

释放动态数组
销毁 p 指向的数组中的元素,并释放对应的内存,数组中的元素按逆序销毁。

1
delete [] p; // p 必须指向一个动态分配的数组或为空

智能指针和动态数组

1
2
unique_ptr<int[]> p(new int[10]);
p.release (); // 自动用 delete [] 销毁其指针

对于指向数组的 unique_ptr 我们不能使用点和箭头成员运算符,可以使用下标运算符来访问数组中的元素。
shared_ptr 不直接支持管理动态数组。如果希望使用 shared_ptr 管理一个动态数组,必须提供自己定义的删除器。
shared_ptr 未定义下标运算符,而且智能指针类型不支持指针算术运算。

1
2
3
for (size_t i = 0; i != 10; ++i) {
*(sp.get() + i) = i; // 使用get获取一个内置指针
}

12.2.2 allocator 类

allocator 分配的内存是原始的、未构造的。construct 成员函数接受一个指针和零个或多个额外参数,在给定位置构造一个元素。额外参数用来初始化构造的对象。

1
2
allocator<string> alloc; // 可以分配 string 的 allocator 对象
auto const p = alloc.allocate(n); // 分配 n 个未初始化的 string

当我们用完对象后,必须对每个构造的元素调用 destroy 来销毁它们。

1
2
3
4
5
auto q = p; // q 指向最后构造的元素之后的位置
alloc.construct(q++, 10, 'c') // *q 为 cccccccccc
while (q != p) {
alloc. destroy (--q) ;
}

释放内存通过调用 deallocate 来完成。

1
alloc.deallocate(p, n); // n 就是 allocate(n) 时的大小

第十三章 拷贝控制

13.1 拷贝、赋值与销毁

13.1.1 拷贝构造函数

如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值, 则此构造函数是拷贝构造函数。

1
2
3
4
class Foo {
public:
Foo (const Foo&); //拷贝构造函数
};

合成拷贝构造函数
合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中,并且会逐元素地拷贝一个数组类型的成员。

13.1.3 析构函数

析构函数的名字由波浪号接类名构成,它没有返回值,不接受参数,也不能被重载。

1
2
3
4
class Foo {
public:
Foo(); //析构函数
};

析构函数完成什么工作
在一个析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁。
成员销毁时发生什么完全依赖于成员的类型。销毁类类型的成员需要执行成员自己的析构函数。内置类型没有析构函数,因此销毁内置类型成员什么也不需要做。
隐式销毁一个内置指针类型的成员不会delete它所指向的对象。与普通指针不同,智能指针成员在析构阶段会被自动销毁。

什么时候会调用析构函数
无论何时一个对象被销毁,就会自动调用其析构函数。
当指向一个对象的引用或指针离开作用域时,析构函数不会执行。

合成析构函数
析构函数体自身并不直接销毁成员,成员是在析构函数体之后隐含的析构阶段中被销毁的。

13.1.4 三/五法则

需要析构函数的类也需要拷贝和赋值操作
合成析构函数不会delete—个指针数据成员。因此,此类需要定义一个析构函数来释放构造函数分配的内存。

合成的拷贝构造函数和拷贝
赋值运算符简单拷贝指针成员,这意味着多个指针可能指向相同的内存。

1
2
3
4
HasPtr f (HasPtr hp) } // HasPtr 是传值参数,所以将被拷贝
HasPtr ret = hp; // 拷贝给定的 HasPtr
return ret; // ret 和 hp 被销毁
}

当 f 返回时,hp 和 ret 都被销毁。此代码会导致此指针被 delete 两次,将要发生什么是未定义的。

需要拷贝搡作的类也需要赋值操作,反之亦然

13.1.5 使用 =default

如果我们不希望合成的成员是内联函数,应该只对成员的类外定义使用 =default。

13.1.6 阻止拷贝 =delete

可以将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝。我们虽然声明了删除的函数,但不能以任何方式使用它们。
与 =default 不同,=delete 必须出现在函数第一次声明的时候。

析构函数不能是删除的成员
对于一个删除了析构函数的类型,编译器将不允许定义该类型的变量或创建该类的临时对象。
如果一个类有某个成员的类型删除了析构函数,我们也不能定义该类的变量或临时对象。
对于删除了析构函数的类型,虽然我们不能定义这种类型的变量或成员,但可以动态分配这种类型的对象。但是,不能释放这些对象。

合成的拷贝控制成员可能是删除的
如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。

13.2 拷贝控制和资源管理

通常,管理类外资源的类必须定义拷贝控制成员。

13.2.1 行为像值的类

类值拷贝赋值运算符
类似析构函数,赋值操作会销毁左侧运算对象的资源。类似拷贝构造函数,赋值操作会从右侧运算对象拷贝数据。
如果将一个对象赋予它自身,赋值运算符必须能正确工作。
大多数赋值运算符组合了析构函数和拷贝构造函数的工作,当右侧拷贝完成后,销毁左侧运算对象的现有成员就是安全的了。

13.2.2 定义行为像指针的类

令一个类展现类似指针的行为的最好方法是使用 shared_ptr 来管理类中的资源。

13.6 对象移动

在重新分配内存的过程中,从旧内存将元素拷贝到新内存是不必要的,更好的方式是移动元素。
使用移动而不是拷贝的另一个原因源于 IO 类或 unique_ptr 这样的类都包含不能被共享的资源(如指针或IO缓冲)。因此,这些类型对象不能拷贝但可以移动。

13.6.1 右值引用

右值引用只能绑定到一个将要销毁的对象。

1
2
3
int i = 42;
const int &r = i * 42; // 正确:我们可以将一个 const 的引用绑定到一个右值上
int &&rr = i * 42; // 正确:将 rr 绑定到乘法结果上

左值持久;右值短暂

  • 所引用的对象将要被销毁
  • 该对象没有其他用户

这两个特性意味着:使用右值引用的代码可以自由地接管所引用的对象的资源。

变量是左值
变量表达式都是左值,所以我们不能将一个右值引用绑定到一个右值引用类型的变量上。

标准库 move 函数
可以显式地将一个左值转换为对应的右值引用类型。
还可以通过调用一个名为 move 的新标准库函数来获得绑定到左值上的右值引用。
move 调用告诉编译器:我们有一个左值,但我们希望像一个右值一样处理它。
调用 move 就意味着承诺:除了对rrl赋值或销毁它外,我们将不再使用它,我们不能对移后源对象的值做任何假设。
我们可以销毁一个移后源对象,也可以赋予它新值,但不能使用一个移后源对象的值。

13.6.2 移动构造函数和移动赋值运算符

为了让我们自己的类型支持移动操作,需要为其定义移动构造函数和移动赋值运算符,它们从给定对象“窃取”资源而不是拷贝资源。
移动构造函数的第一个参数是该类类型的一个右值引用,任何额外的参数都必须有默认实参。
移动构造函数还必须确保销毁移后源对象是无害的。一旦资源完成移动,源对象必须不再指向被移动的资源。

1
2
3
4
5
6
StrVec::StrVec (StrVec &&s) noexcept
:elements(s.elements), first_free(s.first_free), cap(s.cap)
{
// 令 s 进入这样的状态:对其运行析构函数是安全的
s.elements = s.first_free = s.cap = nullptr;
}

移动操作、标准库容器和异常
除非标准库知道我们的移动构造函数不会抛出异常,否则它会认为移动我们的类对象时可能会抛出异常,并且为了处理这种可能性而做一些额外的工作。
noexcept 是我们承诺一个函数不抛出异常的一种方法。在一个构造函数中,noexcept 出现在参数列表和初始化列表开始的冒号之间。
如果重新分配过程使用了移动构造函数,且在移动了部分而不是全部元素后抛出了一个异常,就会产生问题:旧空间中的移动源元素已经被改变了,而新空间中未构造的元素可能尚不存在。
为了避免这种潜在问题,除非 vector 知道元素类型的移动构造函数不会抛出异常, 否则在重新分配内存的过程中,它就必须使用拷贝构造函数而不是移动构造函数。如果希望在 vector 重新分配内存这类情况下对我们自定义类型的对象进行移动而不是拷贝,就必须显式地告诉标准库我们的移动构造函数可以安全使用。我们通过将移动构造函数(及移动赋值运算符)标记为 noexcept 来做到这一点。

移动赋值运算符
移动赋值运算符不抛出任何异常,移动赋值运算符必须正确处理自赋值。

1
2
3
4
5
6
7
8
9
10
11
12
StrVec StrVec::operator=(StrVec &&rhs) noexcept {
// 直接检测自賦值
if (this != &rhs) {
free();
elements = rhs.elements; // 从 rhs 接管资源
first_free = rhs.first_free;
cap = rhs.cap;
// 将 rhs 置于可析构状态
rhs.elements = rhs.first_free = rhs.cap = nullptr;
}
return *this;
}

移后源对象必须可析构
在移动操作之后,移后源对象必须保持有效的、可析构的状态,但是用户不能对其值进行任何假设

合成的移动操作
如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符了。
只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非 static 数据成 员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符。编译器可以移动内置类型的成员。如果一个成员是类类型,且该类有对应的移动操作,编译器也能移动这个成员。
移动操作永远不会隐式定义为删除的函数,如果我们显式地要求编译器生成 =default 的移动操作,且编译器不能移动所有成员,则编译器会将移动操作定义为删除的函数。
如果类定义了一个移动构造函数和/或一个移动赋值运算符,则该类的合成拷贝构造函数和拷贝赋值运算符会被定义为删除的。

移动右值,拷贝左值……
如果一个类既有移动构造函数,也有拷贝构造函数,编译器使用普通的函数匹配规则来确定使用哪个构造函数。

……但如果没有移动构造函数,右值也被拷贝
如果一个类有一个拷贝构造函数但未定义移动构造函数,编译器不会合成移动构造函数

移动迭代器
移动迭代器通过改变给定迭代器的解引用运算符的行为来适配此迭代器,移动迭代器的解引用运算符生成一个右值引用。
通过调用标准库的 make_move_iterator 函数将一个普通迭代器转换为一个移动迭代器。

13.6.3 右值引用和成员函数

右值和左值引用成员函数

1
sl + s2 = "wow!"

此处我们对两个 string 的连接结果(右值),进行了赋值。新标准库类仍然允许向右值赋值,阻止这种用法的方式是在参数列表后放置一个引用限定符。
引用限定符可以是 & 或 &&,分别指出 this 可以指向一个左值或右值。类似 const 限定符, 引用限定符只能用于(非 static)成员函数,且必须同时出现在函数的声明和定义中。引用限定符必须跟随在 const 限定符之后。

重载和引用函数
如果我们定义两个或两个以上具有相同名字和相同参数列表的成员函数,就必须对所有函数都加上引用限定符,或者所有都不加。

第十四章 重载运算与类型转换

14.1 基本概念

重载运算符函数的参数数量与该运算符作用的运算对象数量一样多。
除了重载的函数调用运算符 operator() 之外,其他重载运算符不能含有默认实参。
如果一个运算符函数是成员函数,则它的第一个(左侧)运算对象绑定到隐式的 this 指针上。
对于一个运算符函数来说,它或者是类的成员,或者至少含有一个类类型的参数,这意味着当运算符作用于内置类型的运算对象时,我们无法改变该运算符的含义。
我们无权发明新的运算符号。
重载的运算符的优先级和结合律与对应的内置运算符保持一致。

某些运算符不应该被重载
因为使用重载的运算符本质上是一次函数调用,所以这些关于运算对象求值顺序的规则无法应用到重载的运算符上。

选择作为成员或者非成员

  • 赋值、下标、调用和成员访问箭头运算符必须是成员
  • 复合赋值运算符一般来说应该是成员
  • 改变对象状态的运算符或者与给定类型密切相关的运算符,通常应该是成员
  • 具有对称性的运算符可能转换任意一端的运算对象,通常应该是普通的非成员函数

当我们把运算符定义成成员函数时,它的左侧运算对象必须是运算符所属类的一个对象。

14.2 输入和输出运算符

14.2.1 重载输出运算符 <<

输出运算符的第一个形参是一个非常量 ostream 对象的引用,第二个形参一般来说是一个常量的引用,一般要返回它的 ostream 形参。

输入输出运算符必须是非成员函数
否则它左侧的运算对象将是我们的类的一个对象。
IO 运算符通常需要读写类的非公有数据成员,所以 IO 运算符一般被声明为友元。

14.2.2 重载输人运算符 >>

常会返回某个给定流的引用。
输入运算符必须处理输入可能失败的情况,而输出运算符不需要。
如果在发生错误前对象已经有一部分被改变,则适时地将对象置为合法状态显得异常重要。

14.3 算术和关系运算符

通常把算术和关系运算符定义成非成员函数以允许对左侧或右侧的运算对象进行转换。
操作完成后返回该局部变量的副本作为其结果。
如果类同时定义了算术运算符和相关的复合赋值运算符,则通常情况下应该使用复合赋值来实现算术运算符。

14.3.1 相等运算符

相等运算符和不相等运算符中的一个应该把工作委托给另外一个。

14.5 下标运算符

如果一个类包含下标运算符,则它通常会定义两个版本:一个返回普通引用,另一个是类的常量成员并且返回常量引用。

14.6 递增和递减运算符

定义前置递增/递减运算符

1
StrBlobPtrS operator++(); // 前置运算符

区分前置和后置运算符
后置版本接受一个额外的(不被使用)int 类型的形参。当我们使用后置运算符时,编译器为这个形参提供一个值为 0 的实参。

1
StrBlobPtr operator++(int); // 后置运算符

显式地调用后置运算符

1
p.operator++(0); // 调用后置版本的 operator++

14.7 成员访问运算符

1
2
3
4
5
6
7
8
9
class StrBlobPtr {
public:
std::strings operator*() const {
//...
}
std::string* operator->() const { // 将实际工作委托给解引用运算符
return & this->operator*();
}
}

箭头运算符不执行任何自己的操作,而是调用解引用运算符并返回解引用结果元素的地址。

对箭头运算符返回值的限定
根据 point 类型的不同,point->mem 分别等价于

1
2
(*point).mem; // point 是一个内置的指针类型
point.operator()->mem; // point 是类的一个对象

如果 point 是定义了 operator-> 的类的一个对象,则我们使用 point.operator->() 的结果来获取 mem。其中,如果该结果是一个指针,则执行 (*point).mem。如果该结果本身含有重载的 operator->(),则重复调用当前步骤。最终,当这一过程结束时程序或者返回了所需的内容,或者返回一些表示程序错误的信息。

14.8 函数调用运算符

可以像使用函数一样使用该类的对象。函数调用运算符必须是成员函数。如果类定义了调用运算符,则该类的对象称作函数对象。

14.8.1 lambda 是函数对象

当我们编写了一个 lambda 后,编译器将该表达式翻译成一个未命名类的未命名对象,在 lambda 表达式产生的类中含有一个重载的函数调用运算符。

表示 lambda 及相应捕获行为的类
当一个 lambda 表达式通过引用捕获变量时,编译器可以直接使用该引用而无须在 lambda 产生的类中将其存储为数据成员。
相反,通过值捕获的变量被拷贝到 lambda 中,这种类必须为每个值捕获的变量建立对应的数据成员,同时创建构造函数,令其使用捕获的变量的值来初始化数据成员。
lambda 表达式产生的类不含默认构造函数、赋值运算符及默认析构函数。它是否含有默认的拷贝/移动构造函数则通常要视捕获的数据成员类型而定。

14.8.3 可调用对象与 function

两个不同类型的可调用对象却可能共享同一种调用形式。调用形式指明了调用返回的类型以及传递给调用的实参类型。

标准库function类型
当创建一个具体的 function 类型时我们必须提供该 function 类型能够表示的对象的调用形式,如:
function<int(int, int)>
function 类型重载了调用运算符,该运算符接受它自己的实参然后将其传递给存好的可调用对象:

1
2
3
4
map<string, function<int(int, int)>> binops = {
{"*", [](int i, int j) { return i * j; }}
}
binops["*"](2, 3); // 调用 lambda 对象

重载的函数与 function
我们不能(直接)将重载函数的名字存入 function 类型的对象中,解决上述二义性问题的一条途径是存储函数指针,而非函数名。

1
2
3
4
5
6
int add(int i, int j) { return i + j; }
Sales_data add(const Sales_data&, const Sales_data&);
map<string, function<int(int, int)>> binops;
binops.insert({"+", add}); // 错误:哪个 add?
int (*fp)(int, int) = add; // 指针所指的 add 是接受两个 int 的版本
binops.insert({"+", fp}); // 正确

14.9 重载、类型转换与运算符

转换构造函数和类型转换运算符共同定义了类类型转换。

14.9.1 类型转换运算符

类型转换运算符是类的一种特殊成员函数,它负责将一个类类型的值转换成其他类型。
operator type() const;
不允许转换成数组或者函数类型,但允许转换成指针或者引用类型。
类型转换运算符既没有敁式的返回类型,也没有形参,而且必须定义成类的成员函数,一般被定义成 const 成员。

显式的类型转换运算符

1
2
3
4
5
6
7
8
class SmallInt {
public:
//编译器不会自动执行这一类型转换
explicit operator int () const { return val; }
};
SmallInt si = 1;
si + 3; // 错误:此处需要隐式的类型转换,但类的运算符是显式的
static_cast<int>(si) + 3; // 正确:显式地请求类型转换

该规定存在一个例外,即如果表达式被用作条件,显式的类型转换将被隐式地执行。

第十五章 面向对象程序设计

15.2 定义基类和派生类

15.2.1 定义基类

基类通常都应该定义一个虚析构函数。

成员函数与继承
关键字 virtual 只能出现在类内部的声明语句之前而不能用于类外部的函数定义。如果基类把一个函数声明成虚函数,则该函数在派生类中隐式地也是虚函数。

15.2.2 定义派生类

派生类中的虚函数
派生类可以在它覆盖的函数前使用 virtual 关键字,但不是非得这么做。C++11 新标准允许派生类显式地注明它使用某个成员函数覆盖了它继承的虚函数。具体做法是在后面添加一个关键字 override。

派生类构造函数
派生类必须使用基类的构造函数来初始化它的基类部分。

1
2
3
Bulk_quote(const std::string& book, double p, std::size_t qty, double disc) :
Quote(book, p), min_qty(qty), discount(disc) { }
};

首先初始化基类的部分,然后按照声明的顺序依次初始化派生类的成员。

派生类使用基类的成员
派生类的作用域嵌套在基类的作用域之内。
派生类应该遵循基类的接口,并且通过调用基类的构造函数来初始化那些从基类中继承而来的成员。

继承与静态成员
不论从基类中派生出来多少个派生类,对于每个静态成员来说都只存在唯一的实例。

派生类的声明
声明中包含类名但是不包含它的派生列表,派生列表以及与定义有关的其他细节必须与类的主体一起出现。

被用作基类的类
如果我们想将某个类用作基类,则该类必须已经定义而非仅仅声明。

防止继承的发生
C++11 新标准提供了一种防止继承发生的方法,即在类名后跟一个关键字 final。

15.2.3 类型转换与继承

静态类型与动态类型
动态类型直到运行时才可知。如果表达式既不是引用也不是指针,则它的动态类型永远与静态类型一致。

不存在从基类向派生类的隐式类型转换……
如果我们己知某个基类向派生类的转换是安全的,则我们可以使用 static_cast 来强制覆盖掉编译器的检查工作。

……在对象之间不存在类型转换
派生类向基类的自动类型转换只对指针或引用类型有效。
当我们用一个派生类对象为一个基类对象初始化或赋值时,只有该派生类对象中的基类部分会被拷贝、移动或赋值,它的派生类部分将被忽略掉。

15.3 虚函数

当我们使用基类的引用或指针调用一个虚成员函数时会执行动态绑定,因为我们直到运行时才能知道到底调用了哪个版本的虚函数,所以所有虚函数都必须有定义。

对虚函数的调用可能在运行时才被解析
被调用的函数是与绑定到指针或引用上的对象的动态类型相匹配的那一个。

派生类中的虚函数
一旦某个函数被声明成虚函数,则在所有派生类中它都是虚函数。
派生类中虚函数的返回类型必须与基类函数匹配。当类的虚函数返回类型是类本身的指针或引用时,上述规则无效。
基类的虚函数在派生类中隐含的也是一个虚函数。当派生类覆盖了某个虚函数时,该函数在基类中的形参必须与派生类中的形参严格匹配。

final 和 override 说明符
派生类如果定义了一个函数与基类中虚函数的名字相同但是形参列表不同,编译器将认为新定义的这个函数与基类中原有的函数是相互独立的。
在 C++11 新标准中我们可以使用 override 关键字来说明派生类中的虚函数,使得程序员的意图更加清晰的同时让编译器可以为我们发现一些错误。
final 和 override 说明符出现在形参列表以及尾置返冋类型之后。

虚函数与默认实参
如果我们通过基类的引用或指针调用函数,则使用基类中定义的默认实参,即使实际运行的是派生类中的函数版本也是如此。

回避虚函数的机制
在某些情况下,我们希望强制执行虚函数的某个特定版本,使用作用域运算符可以实现这一目的:

1
double undiscounted = baseP->Quote::net_price(42);

该代码强行调用 Quote 的 net_price 函数,而不管 baseP 实际指向的对象类型到底是什么。该调用将在编译时完成解析。

15.4 抽象基类

纯虚函数
一个纯虚函数无须定义,我们通过在函数体的位置书写 =0 就可以将一个虚函数说明为纯虚函数。=0 只能出现在类内部的虚函数声明语句处。
我们也可以为纯虚函数提供定义,不过函数体必须定义在类的外部。

含有纯虚函数的类是抽象基类
含有(或者未经覆盖直接继承)纯虚函数的类是抽象基类。我们不能(直接)创建一个抽象基类的对象。

15.5 访问控制与继承

派生类的成员或友元只能通过派生类对象来访问基类的受保护成员。派生类对于一个基类对象中的受保护成员没有任何访问特权。
派生类的成员的友元只能访问派生类对象中的基类部分的受保护成员,对于普通的基类对象中的成员没有特殊的访问权限。

公有、私有和受保护继承
派生类访问说明符对于派生类的成员(及友元)能否访问其直接基类的成员没什么影响。对基类成员的访问权限只与基类中的访问说明符有关。派生类访问说明符的目的是控制派生类用户(包括派生类的派生类在内)对于基类成员的访问权限,以及继承自派生类的新类的访问权限。

派生类向基类转换的可访问性
假定 D 继承自 B:

  • 只有当 D 公有地继承 В 时,用户代码才能使用派生类向基类的转换。
  • 不论 D 以什么方式继承 B, D 的成员函数和友元都能使用派生类向基类的转换。
  • 如果 D 继承 В 的方式是公有的或者受保护的,则 D 的派生类的成员和友元可以使用 D 向 В 的类型转换。

友元与继承
友元关系同样也不能继承, 基类的友元在访问派生类成员时不具有特殊性。
对基类的访问权限由基类本身控制,即使对于派生类的基类部分也是如此。

改变个别成员的可访问性

1
2
3
4
5
6
7
8
class Base {
protected:
std::size_t n;
};
class Derived : private Base { // 注意:private 继承
public:
using Base::n;
};

using 声明语句中名字的访问权限由该 using 声明语句之前的访问说明符来决定。
using 只影响派生类的使用者对基类成员的访问权限,派生类只能为那些它可以访问的名字提供 using 声明。

15.6 继承中的类作用域

派生类的作用域嵌套在其基类的作用域之内,如果一个名字在派生类的作用域内无法正确解析,则编译器将继续在外层的基类作用域中寻找该名字的定义。

在编译时进行名字查找
一个对象、引用或指针的静态类型决定了该对象的哪些成员是可见的。不能通过基类对象/指针/引用(静态类型)调用派生类(动态类型)独有的成员。

名字冲突与继承
定义在内层作用域的名字将隐藏定义在外层作用域的名字。

通过作用域运算符来使用隐藏的成员

1
2
3
struct Derived : Base {
int get_base_mem() { return Base::mem; }
}

作用域运算符将覆盖掉原有的查找规则,并指示编译器从 Base 类的作用域开始查找 men。

名字查找先于类型检查

虚函数与作用域

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Base {
public:
virtual int fun1();
};
class D1 : public Base {
public:
int fun1(int); // 隐藏基类的 fun1(),这个 fun1() 不是虚函数
virtual void fun2(); // 一个新的虚函数
}
class D2 : public D1 {
public:
int fun1(int); // 非虚函数,隐藏了 D1::fun1(int)
int fun1(); // 覆盖了 Base 的虚函数 fun1()
void fun2(); // 覆盖了 D1 的虚函数 fun2()
}

通过基类调用隐藏的虚函数

1
2
3
4
5
D2 d2;
Base *p1 = &d2; D1 *p2 = &d2; D2 *p3 = &d2;
p1->fun1(42); // 错误,Base 中没有接受一个 int 的 fun1
p2->fun1(42); // 静态绑定,调用 D1::fun1(int)
p3->fun1(42); // 静态绑定,调用 D2::fun1(int)

覆盖重载的函数
如果派生类希望所有的重载版本对于它来说都是可见的,那么它就需要覆盖所有的版本,或者一个也不覆盖。
一种好的解决方案是为重载的成员提供一条 using 声明语句。using 声明语句指定一个名字而不指定形参列表,所以一条基类成员函数的 using 声明语句就可以把该函数的所有重载实例添加到派生类作用域中。此时,派生类只需要定义其特有的函数就可以了。

15.7 构造函数与拷贝控制

15.7.1虚析构函数

我们通过在基类中将析构函数定义成虚函数以确保执行正确的析构函数版本。
一个基类总是需要析构函数,而且它能将析构函数设定为虚函数,但是无法由此推断该基类还需要赋值运算符或拷贝构造函数。

虚析构函数将阻止合成移动操作
如果一个类定义了析构函数,即使它通过 =default 的形式使用了合成的版本,编译器也不会为这个类合成移动操作。

15.7.2 合成拷贝控制与继承

派生类中删除的拷贝控制与基类的关系

  • 如果基类中的默认构造函数、拷贝构造函数、拷贝赋值运算符或析构函数是被删除的函数或者不可访问,则派生类中对应的成员将是被删除的。
  • 如果在基类中有一个不可访问或删除掉的析构函数,则派生类中合成的默认和拷贝构造函数将是被删除的。

15.7.3 派生类的拷贝控制成员

派生类的拷贝和移动构造函数在拷贝和移动自有成员的同时,也要拷贝和移动基类部分的成员。类似的,派生类赋值运算符也必须为其基类部分的成员赋值。
析构函数只负责销毁派生类自己分配的资源。

定义派生类的拷贝或移动构造函数
当为派生类定义拷贝或移动构造函数时,我们通常使用对应的基类构造函数初始化对象的基类部分。

1
2
3
4
5
6
7
8
class Base { /*...*/ };
class D: public Base {
public:
// 默认情况下,基类的默认构造函数初始化对象的基类部分
// 要想使用拷贝或移动构造函数,我们必须在构造函数初始值列表中显式地调用该构造函数
D(const D& d) : Base(d) {} // 拷贝基类成员
D(D&& d) : Base(std::move(d)) {} // 移动基类成员
};

如果我们想拷贝或移动基类部分,则必须在派生类的构造函数初始值列表中显式地使用基类的拷或移动构造函数。

派生类赋值运算符
与拷贝和移动构造函数一样,派生类的赋值运算符也必须显式地为其基类部分赋值。

1
2
3
4
5
D &D::operator=(const D &rhs) {
Base::operator=(rhs); // 为基类部分賦值
// 按照过去的方式为派生类的成员赋值,酌情处理自赋值及释放已有资源等情况
return *this;
}

在构造函数和析构函数中调用虚函数
当执行基类的构造函数时,该对象的派生类部分是未被初始化的状态。销毁派生类对象的次序正好相反,当执行基类的析构函数时,派生类部分已经被销毁掉了。
如果构造函数或析构函数调用了某个虚函数,则我们应该执行与构造函数或析构函数所属类型相对应的虚函数版本。

15.7.4 继承的构造函数

派生类继承基类构造函数的方式是提供一条注明了(直接)基类名的 using 声明语句。

1
2
3
4
class Bulk_quote : public Disc_quote {
public:
using Disc_quote::Disc_quote; // 继承 Disc_quote 的构造函数
};

对于基类的每个构造函数,编译器都在派生类中生成一个形参列表完全相同的构造函数。在我们的 Bulk_quote 类中,继承的构造函数等价于:

1
2
Bulk_quote(const std::strings book, double price, std::size_t qty, double disc):
Disc_quote(book, price, qty, disc) { }

继承的构造函数的特点
一个构造函数的 using 声明不会改变该构造函数的访问级别。一个 using 声明语句不能指定 explicit 或 constexpr。如果基类的构造函数是 explicit 或者 constexpr,则继承的构造函数也拥有相同的属性。
当一个基类构造函数含有默认实参时,这些实参并不会被继承。相反,派生类将获得多个继承的构造函数,其中每个构造函数分別省略掉一个含有默认实参的形参。
如果派生类定义的构造函数与基类的构造函数具有相问的参数列表,则该构造函数将不会被继承。定义在派生类中的构造函数将替换继承而来的构造函数。
默认、拷贝和移动构造函数不会被继承。如果一个类只含有继承的构造函数,则它也将拥有一个合成的默认构造函数。

15.8 容器与继承

当派生类对象被赋值给基类对象时,其中的派生类部分将被“切掉”,因此容器和存在继承关系的类型无法兼容。

在容器中放置(智能)指针而非对象
当我们希望在容器中存放具有继承关系的对象时,我们实际上存放的通常是基类的指针。

第十六章 模板与泛型编程

16.1 定义模板

16.1.1 函数模板

模板类型参数
类型参数前必须使用关键字 class 或 typename

1
2
template <typename T, class U> calc (const T&, const U&);
// 正确:在模板参数列表中,typename 和 class 没有什么不同

非类型模板参数
当一个模板被实例化时,非类型参数被一个用户提供的或编译器推断出的值所代替。这些值必须是常量表达式。

1
2
3
4
template<unsigned N, unsigned M>
int compare(const char (&pl)[N], const char (&p2)[M])
return strcrap(p1, p2);
}

当我们调用:

1
compare("hi", "mom")

时,编译器会使用字面常量的大小来代替 N 和 M,从而实例化模板:

1
int compare(const char (&pl)[3], const char (&p2)[4])

一个非类型参数可以是一个整型,或者是一个指向对象或函数类型的指针或(左值)引用。绑定到非类型整型参数的实参必须是一个常量表达式。绑定到指针或引用非类型参数的实参必须具有静态的生存期。针参数也可以用 nullptr 或一个值为 0 的常量表达式来实例化。

模板编译
编译器遇到一个模板定义时,它并不生成代码。只有当我们实例化出模板的一个特定版本时,编译器才会生成代码。
函数模板和类模板成员函数的定义通常放在头文件中。

大多数编译错误在实例化期间报告
通常,编译器会在三个阶段报告错误:

  • 第一个阶段是编译模板本身时。在这个阶段编译器可以检查语法错误。
  • 第二个阶段是编译器遇到模板使用时。在此阶段编译器通常会检查实参数目是否正确,它还能检查参数类型是否匹配。
  • 第三个阶段是模板实例化时,只有这个阶段才能发现类型相关的错误。依赖于编译器如何管理实例化,这类错误可能在链接时才报告。

16.1.2 类模板

编译器不能为类模板推断模板参数类型。

实例化类模板
一个类模板的每个实例都形成一个独立的类。类型Blob<string>与任何其他 Blob 类型都没有关联。

在模板作用域中引用模板类型
类模板的名字不是一个类型名,类模板用来实例化类型,而一个实例化的类型总是包含模板参数的。

类模板的成员函数
定义在类模板之外的成员函数就必须以关键字 template 开始,后接类模板参数列表。

1
2
template <typename T>
ret_type class_name<T>::member_name(parm_list)

类模板成员函数的实例化
对于一个实例化了的类模板,其成员只有在使用时才被实例化。

在类代码内简化模板类名的使用
当我们使用一个类模板类型时必须提供模板实参,但是在类模板自己的作用域中,我们可以直接使用模板名而不提供实参。

一对一友好关系
类模板与另一个(类或函数)模板间友好关系的最常见的形式是建立对应实例及其友元间的友好关系。

1
2
3
4
5
6
7
template <typename> class BlobPtr;
template <typename T> class Blob {
//每个Blob实例将访问权限授予用相同类型实例化的BlobPtr和相等运算符
friend class BlobPtr<T>;
};

Blob<char> ca; // BlobPtr<char> 是本对象的友元

BlobPtr 的成员可以访问任何其他 Blob 对象的非 public 部分,但 ca 对 Blob 的任何其他实例都没有特殊访问权限。

通用和特定的模板友好关系
一个类也可以将另一个模板的每个实例都声明为自己的友元,或者限定特定的实例为友元:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 前置声明,在将模板的一个特定实例声明为友元时要用到
template <typename T> class Pal;
class C { // C 是一个普通的非模板类
friend class Pal<C>; // 用类 C 实例化的 Pal 是 C 的一个友元
// Ра12 的所有实例都是 С 的友元,这种情况无须前置声明
template <typename T> friend class Pal2;
};
template <typename T> class C2 { // C2 本身是一个类模板
// C2 的每个实例将相同实例化的 Pal 声明为友元
friend class Pal<T>; // Pal 的模板声明必须在作用域之内
// Pal2 的所有实例都是 C2 的每个实例的友元,不需要前置声明
template <typename X> friend class Pal2;
// Pal3 是一个非模板类,它是 C2 所有实例的友元
friend class Pal3; // 不需要 Pal3 的前置声明
};

令模板自己的类型参数成为友元
我们可以将模板类型参数声明为友元:

1
2
3
4
template <typename Type> class Bar {
friend Type; // 将访问权限授予用来实例化Bar的类型
//...
};

对于某个类型名 Name,Name 将成为 Bar 的友元。

模板类型别名

1
typedef Blob<string> StrBlob;

新标准允许我们为类模板定义一个类型别名:

1
2
template <typename T> using twin = pair<T, T>;
twin<string> authors; // authors 是一个 pair<string, string>

当我们定义一个模板类型别名时,可以固定一个或多个模板参数:

1
2
template <typename T> using partNo = pair<T, unsigned>;
partNo<string> books; // books 是一个 pair<string, unsigned>

类模板的 static 成员
每个实例都有其自己的 static 成员实例。
模板类的每个static 数据成员必须有且仅有一个定义。但是,类模板的每个实例都有一个独有的 static 对象。因此,与定义模板的成员函数类似,我们将 static 数据成员也定义为模板:

1
2
template <typename T>
size_t Foo<T>::ctr = 0; // 定义并初始化 ctr

为了通过类来直接访问 static 成员,我们必须引用一个特定的实例:

1
2
3
4
Foo<int> fi; 
auto ct = Foo<int>::count(); // 实例化 Foo<int>::count
ct = fi.count(); // 使用 Foo<int>::count
ct = Foo::count(); // 错误

16.1.3 模板参数

我们通常将类型参数命名为 T,但实际上我们可以使用任何名字。

模板参数与作用域
模板参数会隐藏外层作用域中声明的相同名字。但是在模板内不能重用模板参数名。

模板声明
声明中的模板参数的名字不必与定义中相同。
一个特定文件所需要的所有模板的声明通常一起放置在文件开始位置,出现于任何使用这些模板的代码之前。

使用类的类型成员
假定 T 是一个模板类型参数,当编译器遇到类似 T::mem 这样的代码时,它不会知道 mem 是一个类型成员还是一个 static 数据成员,直至实例化时才会知道。
默认情况下,C++ 假定通过作用域运算符访问的名字不是类型。因此,如果我们希望使用一个模板类型参数的类型成员,就必须显式告诉编译器该名字是一个类型,通过使用关键字 typename 来实现这一点:

1
2
3
4
template <typename T>
typename T::value_type top (const T& c) {
return typename T::value_type();
}

当我们希望通知编译器一个名字表示类型时,必须使用关键字 typename,而不能使用 class。

默认模板实参
与函数默认实参一样,对于一个模板参数,只有当它右侧的所有参数都有默认实参时, 它才可以有默认实参。

模板默认实参与类模板
如果一个类模板为其所有模板参数都提供了默认实参,且我们希望使用这些默认实参,就必须在模板名之后跟一个空尖括号对。

16.1.4 成员模板

成员模板不能是虚函数。

类模板的成员模板
当我们在类模板外定义一个成员模板时,必须同时为类模板和成员模板提供模板参数列表。类模板的参数列表在前,后跟成员自己的模板参数列表:

1
2
3
4
template <typename T> // 类的类型参数
template <typename It> // 构造函数的类型参數
Blob<T>::Blob(It b, It e) :
data(std::make_shared<std::vector<T>>(b, e)) { }

16.1.5 控制实例化

在多个文件中实例化相同模板的额外开销可能非常严重,我们可以通过显式实例化来避免这种开销。
当编译器遇到 extern 模板声明时,它不会在本文件中生成实例化代码。将一个实例化声明为 extern 就表示承诺在程序其他位置有该实例化的一个非 extern 声明(定义)。对于一个给定的实例化版本,可能有多个 extern 声明,但必须只有一个定义。extern 声明必须出现在任何使用此实例化版本的代码之前。
实例化文件必须为每个在其他文件中声明为 extern 的类型和函数提供一个(非extern)的定义。
当编译器遇到一个实例化定义时,它为其生成代码。
实例化定义会实例化所有成员。

16.2.2 函数模板显式实参

指定显式模板实参

1
2
3
// 编译器无法推断T1,它未出现在函数参数列表中
template <typename Tl, typename T2, typename T3>
T1 sum(T2, T3);

没有任何函数实参的类型可用米推断 Tl 的类型。毎次调用 sum 时调用者都必须为 T1 提供一个显式模板实参:

1
2
// T1 是显式指定的,T2 和 T3 是从函数实参类型推断而来的
auto val3 = sum<long long>(i, lng); // long long sum(int, long)

显式模板实参按由左至右的顺序与对应的模板参数匹配,只有尾部(最右)参数的显式模板实参才可以忽略,而且前提是它们可以从函数参数推断出来。

1
2
3
4
// 糟糕的设计:用户必须指定所有三个模板参数
template <typename Tl, typename T2, typename T3>
T3 alternative_sum(T2, Tl);
auto val2 = alternative_sum<long long, int, long>(i, lng);

正常类型转换应用于显式指定的实参

1
2
3
4
long lng;
compare (lng, 1024); // 错误:模板参数不匹配
compare<long> (lng, 1024); // 正确:实例化 compare (long, long)
compare<int> (lng, 1024); // 正确:实例化 compare (int, int)

16.2.3 尾置返回类型与类型转换

1
2
3
4
template <typename It>
??? &fcn(It beg, It end) {
return *beg; // 返回序列中一个元素的引用
}

在编译器遇到函数的参数列表之前,beg 都是不存在的。为了定义此函数,我们必须使用尾置返回类型。由于尾置返回出现在参数列表之后,它可以使用函数的参数:

1
2
3
4
5
// 尾置返回允许我们在参数列表之后声明返回类型
template <typename It>
auto fcn (It beg, It end) -> decltype(*beg) {
return *beg; // 返回序列中一个元素的引用
}

进行类型转换的标准库模板类
为了获得元素类型,我们可以使用标准库的类型转换模板。remove_reference 模板有一个模板类型参数和一个名为 type 的 public 类型成员。如果我们用一个引用类型实例化 remove_reference,则 type 将表示被引用的类型。
decltype(*beg)返回元素类型的引用类型。remove_reference::type脱去引用,剩下元素类型本身。
组合使用 remove_reference、尾置返冋及 decltype,我们就可以在函数中返回元素值的拷贝:

1
2
3
4
5
6
// 为了使用模板参数的成员,必须用 typename
template <typename It>
auto fcn2(It beg, It end)->
typename remove_reference<decltype(*beg)>::type {
return *beg; // 返回序列中一个元素的拷贝
}

注意:type 是一个类的成员,而该类依赖于一个模板函数。因此我们必须在返回类型的声明中使用 typename 告知编译器,type 表示一个类型。

16.2.4 函数指针和实参推断

1
2
3
template <typename T> int compare(const T&, const T&);
int (*pfl)(const int&, const int&) = compare;
// pfl 指向实例 int compare (const int&, const int&)

pfl 中参数的类型决定了 T 的模板实参的类型。在本例中,T 的模板实参类型为 int。指针 pfl 指向 compare 的 int 版本实例。
当参数是一个函数模板实例的地址时,程序上下文必须满足:对每个模板参数,能唯一确定其类型或值。

1
2
3
4
5
6
// func 的重载版本,每个版本接受一个不同的函数指针类型
void func(int(*)(const strings, const strings));
void func(int(*)(const int&, const int&));
func(compare); // 错误:使用 compare 的哪个实例?
func(compare<int>); // 正确:显式指出实例化哪个 compare 版本
// 传递 compare (const int&, const int&)

16.2.5 模板实参推断和引用

从左值引用函数参数推断类型
当一个函数参数是模板类型参数的一个普通(左值)引用时,只能传递给它一个左值,如果实参是 const 的,则 T 将被推断为 const 类型。
如果一个函数参数的类型是 const T&,当函数参数本身是 const 时,T 的类型推断的结果不会是一个 const 类型。

引用折叠和右值引用参数
当我们将一个左值(i)传递给函数的右值引用参数,且此右值引用指向模板类型参数(T&&)时,编译器推断模板类型参数为实参的左值引用类型。因此,当我们调用 f3(i) 时,编译器推断 T 的类型为 int&,而非 int。
这好像意味着 f3 的函数参数应该是一个类型 int& 的右值引用。通常,我们不能(直接)定义一个引用的引用。但是,通过类型别名或通过模板类型参数间接定义是可以的。
如果我们间接创建一个引用的引用,则这些引用形成了“折叠”。除了右值引用的右值引用会折叠为一个右值引用,其余情况都会折叠为一个普通的左值引用类型。
这两个规则暗示,我们可以将任意类型的实参传递给 T&& 类型的函数参数。

16.2.6 理解 std::move

从一个左值 static_cast 到一个右值引用是允许的
可以用 static_cast 显式地将一个左值转换为一个右值引用。

16.2.7 转发

定义能保持类型信息的函数参数
如果一个函数参数是指向模板类型参数的右值引用(如T&&),它对应的实参的 const 属性和左值/右值属性将得到保持。

在调用中使用 std::forward 保持类型信息
forward 返回该显式实参类型的右值引用即,forward 的返回类型是 T&&。通过其返回类型上的引用折叠,forward 可以保持给定实参的左值/右值属性。
当用于一个指向模板参数类型的右值引用函数参数(T&&)时,forward 会保持实参类型的所有细节。

16.4 可变参数模板

可变数量的参数被称为参数包,我们用一个省略号来指出一个模板参数或函数参数表示一个包。
在一个模板参数列表中,class… 或 typename… 指出接下来的参数表示零个或多个类型的列表。一个类型名后面跟一个省略号表示零个或多个给定类型的非类型参数的列表。

1
2
3
4
5
// Args 是一个模板参数包,rest 是一个函数参数包
// Args 表示零个或多个模板类型参数
// rest 表示零个或多个函数参数
template <typename T, typename... Args>
void foo (const T &t, const Args& ... rest);

sizeof… 运算符
当我们需要知道包中有多少元素时,可以使用 sizeof… 运算符。sizeof…返回一个常量表达式,而且不会对其实参求值。

16.4.1 编写可变参数函数模板

1
2
3
4
5
template <typename T, typename... Args>
ostream &print(ostream &os, const T &t, const Args&... rest) {
os « t « ", "// 打印第一个实参
return print (os, rest...); // 递归调用,打印其他实参
)

rest 中的第一个实参被绑定到 t,剩余实参形成下一个 print 调用的参数包。

16.4.2 包扩展

当扩展一个包时,我们还要提供用于每个扩展元素的模式。扩展一个包就是将它分解为构成的元素,对每个元素应用模式,获得扩展后的列表。我们通过在模式右边放一个省略号来触发扩展操作。

1
2
3
4
5
template <typename T, typename... Args>
ostream &print(ostream &os, const T &t, const Args&... rest) { // 扩展 Args
os « t << ",";
return print(os, rest...); // 扩展 rest
}

对 Args 的扩展中,编译器将模式 const Arg& 应用到模板参数包 Args 中的每个元素。因此,此模式的扩展结果是一个逗号分隔的零个或多个类型的列表,每个类型都形如 const type&。
第二个扩展发生在对 print 的调用中。在此情况下,模式是函数参数包的名字(即 rest)。此模式扩展出一个由包中元素组成的、逗号分隔的列表。因此,这个调用等价于:

理解包扩展

1
return print(os, debug_rep(rest)...);

这个 print 调用使用了模式 debug_reg(rest)。此模式表示我们希望对函数参数包 rest 中的每个元素调用 debug_rep。扩展结果将是一个逗号分隔的 debug_rep 调用列表。即,下面调用:

1
errorMsg(cerr, fcnName, code.num(), otherData, "other", item);

就好像我们这样编写代码一样

1
2
print (cerr, debug_rep(fcnName), debug_rep(code.num()),
debug_rep(otherData), debug_rep("otherData"), debug_rep(item));

16.5 模板特例化

一个特例化版本就是模板的一个独立的定义,在其中一个或多个模板参数被指定为特定的类型。

定义函数模板特例化
当我们特例化一个函数模板时,必须为原模板中的每个模板参数都提供实参。为了指出我们正在实例化一个模板,应使用关键字 template 后跟一个空尖括号对,指出我们将为原模板的所有模板参数提供实参。

1
2
3
4
5
// compare 的特殊版本,处理字符数组的指针
template <>
int compare(const char* const &pl, const char* const &p2) {
return strcmp(pi, p2);
}

函数重载与模板特例化
当定义函数模板的特例化版本时,我们本质上接管了编译器的工作。即,我们为原模板的一个特殊实例提供了定义。一个特例化版本本质上是一个实例,而非函数名的一个重载版本。
特例化不影响函数匹配。
为了特例化一个模板,原模板的声明必须在作用域中。而且,在任何使用模扳实例的代码之前,特例化版本的声明也必须在作用域中。