[Note] C++ Primer

默认状态下 const 对象仅在文件内有效

当以编译时初始化的方式定义一个 const 对象时,编译器将在编译过程中把用到该变量的地方都替换成对应的值,例如:

1
const int bufsize = 512;

为了执行上述替换,编译器必须知道变量的初始值。如果程序包含多个文件,则每个用了 const 对象的文件都必须得能访问到它的初始值才行。要做到这一点,就必须在每一个用到变量的文件中都有对它的定义。为了支持这一用法,同时避免对同一变量的重复定义,默认情况下,const 对象被设定为仅在文件内有效。当多个文件中出现了同名的 const 变量时,其实等同于在不同文件中分别定义了独立的变量。

某些时候有这样一种 const 变量,它的初始值不是一个常量表达式,但又确实有必要在文件间共享。这种情况下,我们不希望编译器为每个文件分别生成独立的变量。相反,我们想让这类 const 对象像其他(非常量)对象一样工作,也就是说,只在一个文件中定义 const,而在其他多个文件中声明并使用它。解决的办法是,对于 const 变量不管是声明还是定义都添加 extern 关键字,这样只需定义一次就可以了:

1
2
3
4
// file_1.cc
extern const int bufSize = fcn();
// file_1.h
extern const int bufSize;

如上述程序所示,file_1.cc 定义并初始化了 bufsize。因为这条语句包含了初始值,所以它(显然)是一次定义。然而,因为 bufsize 是一个常量,必须用 extern 加以限定使其被其他文件使用。file_1.h 头文件中的声明也由 extern 做了限定,其作用是指明 bufsize 并非本文件所独有,它的定义将在别处出现。

初始化和对 const 的引用

引用的类型必须与其所引用对象的类型一致,但是有两个例外。第一种例外情况就是在初始化常量引用时允许用任意表达式作为初始值,只要该表达式的结果能转换成引用的类型即可。尤其,允许为一个常量引用绑定非常量的对象、字面值,甚至是个一般表达式:

1
2
3
4
5
int i = 42
const int &r1 = i; // 允许将 const int& 绑定到一个普通 int 对象上
const int &r2 = 42; // 正确:r1 是一个常量引用
const int &r3 = r1 * 2; // 正确:r3是一个常量引用
int &r4 = r1 * 2; // 错误:r4 是一个普通的非常量引用

要想理解这种例外情况的原因,最简单的办法是弄清楚当一个常量引用被绑定到另外一种类型上时到底发生了什么:

1
2
double dval = 3.14
const int &ri = dval;

此处 ri 引用了一个 int 型的数。对 ri 的操作应该是整数运算,但 dval 却是一个双精度浮点数而非整数。因此为了确保让 ri 绑定一个整数,编译器把上述代码变成了如下形式:

1
2
const int temp = dval;	// 由双精度浮点数生成一个临时的整型常量
const int &ri = temp; // 让 ri 绑定这个临时量

在这种情况下,ri 绑定了一个临时量(temporary)对象。

接下来探讨当 ri 不是常量时,如果执行了类似于上面的初始化过程将带来什么样的后果。如果 ri 不是常量,就允许对 ri 赋值,这样就会改变 ri 所引用对象的值。注意,此时绑定的对象是一个临时量而非 dval。程序员既然让 ri 引用 dval,就肯定想通过 ri 改变 dval 的值,否则干什么要给 r1 赋值呢?如此看来,既然大家基本上不会想着把引用绑定到临时量上,C++ 语言也就把这种行为归为非法。

指针和 constexpr

必须明确一点,在 constexpr 声明中如果定义了一个指针,限定符 constexpr 仅对指针有效,与指针所指的对象无关:

1
2
const int *p = nullptr;		// p是一个指向整型常量的指针
constexpr int *q = nullptr; // q是一个指向整数的常量指针

p 和 q 的类型相差甚远,p 是一个指向常量的指针,而 q 是一个常量指针,其中的关键在于 constexpr 把它所定义的对象置为了顶层 const。与其他常量指针类似,constexpr 指针既可以指向常量也可以指向一个非常量。

头文件不应包含 using 声明

位于头文件的代码一般来说不应该使用 using 声明。这是因为头文件的内容会拷贝到所有引用它的文件中去,如果头文件里有某个 using 声明,那么每个使用了该头文件的文件就都会有这个声明。对于某些程序来说,由于不经意间包含了一些名字,反而可能产生始料未及的名字冲突。

向 vector 对象添加元素蕴含的编程假定

  • 范围 for 语句体内不应改变其所遍历序列的大小;
  • 任何一种可能改变 vector 对象容量的操作,比如 push back,都会使该 vector 对象的迭代器失效。

但凡是使用了迭代器的循环体,都不要向迭代器所属的容器添加元素。

使用迭代器实现二分搜索

1
2
3
4
5
6
7
8
9
auto beg = text.begin(), end = text.end();
auto mid = text.begin() + (end - beg) / 2;
while (mid != end && *mid != sought) {
if (sought < *mid)
end = mid;
else
beg = mid + 1;
mid = beg + (end - beg) / 2;
}

递增和递减运算符

这两种运算符必须作用于左值运算对象。前置版本将对象本身作为左值返回,后置版本则将对象原始值的副本作为右值返回。

除非必须,否则不用递增递减运算符的后置版本。因为前置版本的递增运算符避免了不义要的工作,它把值加 1 后直接返回改变了的运算对象。与之相比,后置版本需要将原始值存储下来以便于返回这个未修改的内容。如果我们不需要修改前的值,那么后置版本的操作就是一种浪费。对于整数和指针类型来说,编译器可能对这种额外的工作进行一定的优化;但是对于相对复杂的迭代器类型,这种额外的工作就消耗巨大了。

如果我们想在一条复合表达式中既将变量加 1 或减 1 又能使用它原来的值,这时就可以使用递增和递减运算符的后置版本。举个例子,可以使用后置的递增运算符来控制循环输出一个 vector 对象内容直至遇 到(但不包括)第一个负值为止:

1
2
3
auto pbeg = v.begin();
while (pbeg != v.end() && *beg >= 0)
cout << *pbeg++ << endl;

位运算符

一般来说,如果运算对象是“小整型”,则它的值会被自动提升成较大的整数类型。运算对象可以是带符号的,也可以是无符号的。如果运算对象是带符号的且它的值为负,那么位运算符如何处理运算对象的“符号位”依赖于机器。而且,此时的左移操作可能会改变符号位的值,因此是一种未定义的行为。强烈建议仅将位运算符用于处理无符号类型。

标准异常

C++ 标准库定义了一组类,用于报告标准库函数遇到的问题。这些异常类也可以在用户编写的程序中使用,它们分别定义在4个头文件中:

  • exception 头文件定义了最通用的异常类 exception,它只报告异常的发生,不提供任何额外信息;
  • stdexcept 头文件定义了几种常用的异常类,详细信息在表 5.1 中列出;
  • new 头文件定义了 bad alloc 异常类型;
  • type_info 头文件定义了 bad_ cast 异常类型。
截屏2021-12-19 20.25.29

我们只能以默认初始化的方式初始化 exception、bad alloc 和 bad cast 对象,不允许为这些对象提供初始值。

其他异常类型的行为则恰好相反:应该使用 string 对象或者C风格字符串初始化这些类型的对象,但是不允许使用默认初始化的方式。当创建此类对象时,必须提供初始值,该初始值含有错误相关的信息。

内联函数和 constexpr 函数

constexpr 函数是指能用于常量表达式的函数。定义 constexpr 函数的方法与其他函数类似,不过要遵循几项约定:函数的返回 类型及所有形参的类型都得是字面值类型,而且函数体中必须有且只有一条 return 语句。

编译器把对 constexpr 函数的调用替换成其结果值。为了能在编译过程中随时展开,constexpr 函数被隐式地指定为内联函数。

和其他函数不一样,内联函数和 constexpr 函数可以在程序中多次定义。毕竟,编译器要想展开函数仅有函数声明是不够的,还需要函数的定义。不过,对于某个给定的内联函数或者 constexpr 函数来说,它的多个定义必须完全一致。基于这个原因,内联函数和 constexpr 函数通常定义在头文件中。

调试帮助

assert 的行为依赖于一个名为 NDEBUG 的预处理变量的状态。如果定义了 NDEBUG,则 assert 什么也不做。默认状态下没有定义 NDEBUG,此时 assert 将执行运行时检查。我们可以使用一个#define 语句定义 NDEBUG,从而关闭调试状态。同时,很多编译器都提供了一个命令行选项使我们可以定义预处理变量:

1
CC -D NDEBUG main.C # use /D with the Microsoft compiler

除了用于 assert 外,也可以使用 NDEBUG 编写自己的条件调试代码:

1
2
3
4
5
6
7
8
void print(const int ia[], size_t size)
{
#ifndef NDEBUG
// __func__ 是编译器定义的一个局部静态变量,用于存放函数的名字
cerr << __func__ << ": array size is " << size << endl;
#endif
// ...
}

在这段代码中,我们使用变量 __func__ 输出当前调试的函数的名字。编译器为每个函数都定义了 __func__,它是 const char 的一个静态数组,用于存放函数的名字。

除了 C++ 编译器定义的 __func__ 之外,预处理器还定义了另外 4 个对于程序调试很有用的名字:

  • __FILE__ 存放文件名的字符串字面值
  • __LINE__ 存放当前行号的整型字面值
  • __TIME__ 存放文件编译时间的字符串字面值
  • __DATE__ 存放文件编译日期的字符串字面值

可以使用这些常量在错误消息中提供更多信息。

友元声明和作用域

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

1
2
3
4
5
6
7
8
9
struct X {
friend void f() { /*友元函数可以定义在类的内部*/ }
X() { f(); } // 错误:f还没有被声明
void g();
void h();
};
void X::g() { return f(); } // 错误:f还没有被声明
void f(); // 声明那个定义在X中的函数
void X::h() { return f(); } // 正确:现在f的声明在作用域中了

关于这段代码最重要的是理解友元声明的作用是影响访问权限,它本身并非普通意义上的声明。

有的编译器并不强制执行上述关于友元的限定规则。

名字查找与类的作用域

一般来说,名字查找(name lookup)的过程比较直截了当:

  • 首先,在名字所在的块中寻找其声明语句,只考虑在名字的使用之前出现的声明;
  • 如果没找到,继续查找外层作用域;
  • 如果最终没有找到匹配的声明,则程序报错。

而对于定义在类内部的成员函数来说,解析其中名字的方式与上述的查找规则有所区别。类的定义分两步处理:

  • 首先,编译成员的声明;
  • 直到类全部可见后才编译函数体。

按照这种两阶段的方式处理类可以简化类代码的组织方式。因为成员函数体直到整个类可见后才会被处理,所以它能使用类中定义的任何名字。相反,如果函数的定义和成员的声明被同时处理,那么我们将不得不在成员函数中只使用那些已经出现的名字。

然而,这种方式只适用于成员函数中使用的名字。声明中使用的名字,包括返回类型或者参数列表中使用的名字,都必须在使用前确保可见。如果某个成员的声明使用了类中尚未出现的名字,则编译器将会在定义该类的作用域中继续查找。例如:

1
2
3
4
5
6
7
8
9
typedef double Money;
string bal;
class Account {
public:
Money balance() { return bal; }
private:
Money bal;
// ...
};

当编译器看到 balance 函数的声明语句时,它将在 Account 类的范围内寻找对 Money 的声明。编译器只考虑 Account 中在使用 Money 前出现的声明,因为没找到匹配的成员,所以编译器会接着到 Account 的外层作用域中查找。在这个例子中,编译器会找到 Money 的 typedef 语句,该类型被用作 balance 函数的返回类型以及数据成员 bal 的类型。另一方面,balance 函数体在整个类可见后才被处理,因此,该函数的 return 语句返回名为 bal 的成员,而非外层作用域的 string 对象。

容器 swap

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

泛型算法

  • 泛型算法步骤不依赖于容器所保存的元素类型,因此只要有一个迭代器可用来访问元素,算法就完全不依赖于容器类型(甚至无须理会保存元素的是不是容器);
  • 迭代器令算法不依赖于容器而仅使用迭代器操作:利用迭代器解引用运算符可以实现元素访问、返回指向元素的迭代器、用迭代器递增运算符可以移动到下一个元素、尾后迭代器可以用来判断是否到达给定序列的末尾、返回尾后迭代器来表示未找到给定元素;
  • 虽然迭代器的使用令算法不依赖于容器类型,但大多数算法都使用了一个(或多个)元素类型上的操作。例如,find 用元素类型的 == 运算符完成每个元素与给定值的比较,其他算法可能要求元素类型支持 < 运算符。大多数算法提供了一种方法,允许我们使用自定义的操作来代替默认的运算符。
  • 泛型算法本身不会执行容器的操作,它们只会运行于迭代器之上,执行迭代器的操作,因此算法永远不会改变底层容器的大小。算法可能改变容器中保存的元素的值,也可能在容器内移动元素,但永远不会直接添加或删除元素。
  • 标准库定义了一类特殊的迭代器,称为插入器(inserter)。与普通迭代器只能遍历所绑定的容器相比,插入器能做更多的事情。当给这类迭代器赋值时,它们会在底层的容器上执行插入操作。因此,当一个算法操作一个这样的迭代器时,迭代器可以完成向容器添加元素的效果,但算法自身永远不会做这样的操作。

lambda 捕获

捕获列表只用于局部非 static 变量,lambda 可以直接使用局部 static 变量和在它所在函数之外声明的名字。

迭代器类别

截屏2022-01-09 16.58.15

特定容器算法

  • 与其他容器不同,链表类型 list 和 forward_list 定义了几个成员函数形式的算法,特别是定义了独有的 sort、 merge、 remove、 reverse 和 unique。通用版本的 sort 要求随机访问迭代器,因此不能用于 list 和 forward_list,因为这两个类型分别提供双向迭代器和前向迭代器。
  • 链表类型定义的其他算法的通用版本可以用于链表,但代价太高。这些算法需要交换输入序列中的元素。一个链表可以通过改变元素间的链接而不是真的交换它们的值来快速“交换”元素。因此,这些链表版本的算法的性能比对应的通用版本好得多,应该优先使用。
  • 多数链表特有的算法都与其通用版本很相似,但不完全相同。链表特有版本与通用版本间的一个至关重要的区别是链表版本会改变底层的容器。例如,remove 的链表版本会删除指定的元素,unique 的链表版本会删除第二个和后继的重复元素。类似的,merge 和 splice 会销毁其参数。例如,通用版本的 merge 将合并的序列写到一个给定的目的迭代器;两个输入序列是不变的。而链表版本的 merge 函数会销毀给定的链表——元素从参数指定的链表中删除,被合并到调用 merge 的链表对象中。在merge 之后,来自两个链表中的元素仍然存在,但它们都己在同一个链表中。

动态内存

程序使用动态内存出于以下三种原因之一:

  1. 程序不知道自己需要使用多少对象(容器)
  2. 程序不知道所需对象的准确类型(虚函数?)
  3. 程序需要在多个对象间共享数据(shared_ptr)

智能指针使用规范

  • 不使用相同的内置指针值初始化(或 reset)多个智能指针;
  • 不 delete get() 返回的指针;
  • 不使用 get() 初始化或 reset 另一个智能指针;
  • 如果你使用 get() 返回的指针,记住当最后一个对应的智能指针销毁后,你的指针就变为无效了;
  • 如果你使用智能指针管理的资源不是 new 分配的内存,记住传递给它一个删除器。

自定义智能指针释放操作

那些分配了资源,而又没有定义析构函数来释放这些资源的类,可能会遇到与使用动态内存相同的错误——程序员非常容易忘记释放资源。类似的,如果在资源分配和释放之间发生了异常,程序也会发生资源泄漏。与管理动态内存类似,我们通常可以使用类似的技术来管理不具有良好定义的析构函数的类。

shared_ptr 版本
1
2
3
4
5
6
7
8
9
void end_connection(connection *p) { disconnect(*p); }

void f(destination &d)
{
connection c = connect(&d);
shared_ptr<connection> p(&c, end_connection);
// 使用连接
// 当 f 退出时(即使是由于异常而退出),connection 会被正确关闭
}

默认情况下,shared_ptr 假定它们指向的是动态内存。因此,当一个 shared_ptr 被销毁时,它默认地对它管理的指针进行 delete 操作。为了用 shared_ptr 来管理一个 connection,我们必须首先定义一个函数来代替 delete。这个删除器(deleter)函数必须能够完成对 shared_ptr 中保存的指针进行释放的操作。当 p 被销毁时,它不会对自己保存的指针执行 delete,而是调用 end_connection。

unique_ptr 版本

重载一个 unique_ptr 中的删除器会影响到 unique_ptr 类型以及如何构造(或 reset)该类型的对象,我们必须在尖括号中 unique_ptr 指向类型之后提供删除器类型。在创建或 reset 一个这种 unique_ptr 类型的对象时,必须提供一个指定类型的可调用对象(删除器):

1
2
3
// P 指向一个类型为 objT 的对象,并使用一个类型为 delT 的对象释放 objT 对象
// 它会调用一个名为 fcn 的 delT 类型对象
unique_ptr<objT, delT> p(new objT, fcn);

智能指针和动态数组

unique_ptr 版本

当一个 unique_ptr 指向一个数组时,不能使用点和箭头成员运算符;可以使用下标运算符来访问数组中的元素:

1
2
3
4
unique_ptr<int[]> up(new int [10]);
for (size_t i = 0; i != 10; ++i)
up[i] = i;
up.release(); // 自动用 delete[] 销毁其指针
shared_ptr 版本

与 unique_ptr 不同,shared_ptr 不直接支持管理动态数组。如果希望使用 shared_ptr 管理一个动态数组,必须提供自己定义的删除器。如果未提供删除器,这段代码将是未定义的,因为默认情况下 shared_ptr 使用 delete 销毁它指向的对象。

此外,shared ptr 未定义下标运算符,而且智能指针类型不支持指针算术运算。因此,为了访问数组中的元素,必须用 get 获取一个内置指针,然后用它来访问数组元素。

1
2
3
4
shared ptr<int> sp(new int[10], [](int *p) { delete[] p; });
for (size t i = 0;i1= 10; ++i)
*(sp.get() + i) = i;
sp.reset(); // 使用我们提供的 lambda 释放数组,它使用 delete[]

内存分配与对象构造分离

new 有一些灵活性上的局限,其中一方面表现在它将内存分配和对象构造组合在了一起。类似的,delete 将对象析构和内存释放组合在了一起。我们分配单个对象时,通常希望将内存分配和对象初始化组合在一起。因为在这种情况下,我们几乎肯定知道对象应有什么值。

当分配一大块内存时,我们通常计划在这块内存上按需构造对象。在此情况下,我们希望将内存分配和对象构造分离。这意味着我们可以分配大块内存,但只在真正需要时才真正执行对象创建操作(同时付出一定开销)。

一般情况下,将内存分配和对象构造组合在一起可能会导致不必要的浪费。例如:

1
2
3
4
5
6
7
8
string *const p = new string In];	// 构造 n 个空 string
string s;
string *q = p; // q 指向第一个 string
while (cin >> s && q != p + n)
*q++ = s; // 赋予 *q 一个新值
const size_t size = q - p; // 记住我们读取了多少个 string
// 使用数组
delete[] p; // p 指向一个数组;记得用 delete[] 来释放

new 表达式分配并初始化了 n 个 string。但是,我们可能不需要 n 个 string,少量 string 可能就足够了。这样,我们就可能创建了一些永远也用不到的对象。而且,对于那些确实要使用的对象,我们也在初始化之后立即赋予了它们新值。每个使用到的元素都被赋值了两次:第一次是在默认初始化时,随后是在赋值时。更重要的是,那些没有默认构造函数的类就不能动态分配数组了。

allocator 类

标准库 allocator 类定义在头文件 memory 中,它帮助我们将内存分配和对象构造分离开来。它提供一种类型感知的内存分配方法,它分配的内存是原始的、未构造的。类似 vector,allocator 是一个模板。为了定义一个 allocator 对象,我们必须指明这个 allocator 可以分配的对象类型。当一个 allocator 对象分配内存时,它会根据给定的对象类型来确定恰当的内存大小和对齐位置。

为了使用 allocate 返回的内存,我们必须用 construct 构造对象。使用未构造的内存,其行为是未定义的。

当我们用完对象后,必须对每个构造的元素调用 destroy 来销毁它们。函数 destroy 接受一个指针,对指向的对象执行析构函数。我们只能对真正构造了的元素进行 destroy 操作。

一旦元素被销毁后,就可以重新使用这部分内存来保存其他 string,也可以将其归还给系统。释放内存通过调用 deallocate 来完成。我们传递给 deallocate 的指针不能为空,它必须指向由 allocate 分配的内存。而且,传递给 deallocate 的大小参数必须与调用 allocate 分配内存时提供的大小参数具有一样的值。

1
2
3
4
5
6
7
8
9
allocator<string> alloc;			// 可以分配 string 的 allocator 对象
auto const p = alloc.allocate(n); // 分配 n 个未初始化的 string
auto q = p; // q 指向最后构造的元素之后的位置
alloc.construct(q++); // *q 为空字符串
alloc.construct(q++, 10, 'c'); // *q 为 cccccccccc
alloc.construct(q++, "hi"); // *q 为 hi
while (q != p)
alloc.destroy(--q); // 销毁我们真正构造的 string
alloc.deallocate(p, n); // 释放内存

表 12.7 概述了 allocator 支持的操作:

截屏2022-01-15 19.57.26
拷贝和填充未初始化内存的算法

标准库还为 allocator 类定义了两个伴随算法,可以在未初始化内存中创建对象。表 12.8 描述了这些函数,它们都定义在头文件 memory 中。

截屏2022-01-15 20.16.25

作为一个例子,假定有一个 int 的 vector,希望将其内容拷贝到动态内存中。我们将分配一块比 vector 中元素所占用空间大一倍的动态内存,然后将原 vector 中的元素拷贝到前一半空间,对后一半空间用一个给定值进行填充:

1
2
3
4
5
6
// 分配比 vi 中元素所占用空间大一倍的动态内存
auto p = alloc.allocate(vi.size() * 2);
// 通过拷贝 vi 中的元素来构造从 p 开始的元素
auto q = uninitialized_copy(vi.begin(), vi.end(), p);
// 将剩余元素初始化为 42
uninitialized_fill_n(q, vi.size(), 42);

拷贝控制的三/五法则

需要析构函数的类也需要拷贝和赋值操作

当我们决定一个类是否要定义它自己版本的拷贝控制成员时,一个基本原则是首先确定这个类是否需要一个析构函数。通常,对析构函数的需求要比对拷贝构造函数或赋值运算符的需求更为明显。如果这个类需要一个析构函数,我们几乎可以肯定它也需要一个拷贝构造函数和一个拷贝赋值运算符。

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

如果一个类需要一个拷贝构造函数,几乎可以肯定它也需要一个拷贝赋值运算符。反之亦然——如果一个类需要一个拷贝赋值运算符,几乎可以肯定它也需要一个拷贝构造函数。然而,无论是需要拷贝构造函数还是需要拷贝赋值运算符都不必然意味着也需要析构函数。

作为一个例子,考虑一个类为每个对象分配一个独有的、唯一的序号。这个类需要一个拷贝构造函数为每个新创建的对象生成一个新的、独一无二的序号。除此之外,这个拷贝构造函数从给定对象拷贝所有其他数据成员。这个类还需要自定义拷贝赋值运算符来避免将序号赋予目的对象。但是,这个类不需要自定义析构函数。

=default 与 =delete

  • 与 =default 不同,=delete 必须出现在函数第一次声明的时候,这个差异与这些声明的含义在逻辑上是吻合的。一个默认的成员只影响为这个成员而生成的代码,因此 =default 直到编译器生成代码时才需要。而另一方面,编译器需要知道一个函数是删除的,以便禁止试图使用它的操作。
  • 与 =default 的另一个不同之处是,我们可以对任何函数指定 =delete(我们只能对编译器可以合成的默认构造函数或拷贝控制成员使用 =default)。虽然删除函数的主要用途是禁止拷贝控制成员,但当我们希望引导函数匹配过程时,删除函数有时也是有用的。

析构函数不能 =delete

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

1
2
3
4
5
6
7
struct NoDtor {
NoDtor() = default; // 使用合成默认构造函数
~NoDtor() = delete; // 我们不能销毁 NoDtor 类型的对象
};
NoDtor nd; // 错误:NoDtor 的析构函数是删除的
NoDtor *p = new NoDtor(); // 正确:但我们不能 delete p
delete p; // 错误:NoDtor 的析构函数是删除的

合成的拷贝控制成员可能是删除的

如前所述,如果我们未定义拷贝控制成员,编译器会为我们定义合成的版本。类似的,如果一个类未定义构造函数,编译器会为其合成一个默认构造函数。对某些类来说,编译器将这些合成的成员定义为删除的函数:

  • 如果类的某个成员的析构函数是删除的或不可访问的(例如是 private 的),则类的合成析构函数被定义为删除的。
  • 如果类的某个成员的拷贝构造函数是删除的或不可访问的,则类的合成拷贝构造函数被定义为删除的。如果类的某个成员的析构函数是删除的或不可访问的,则类合成的拷贝构造函数也被定义为删除的。
  • 如果类的某个成员的拷贝赋值运算符是删除的或不可访问的,或是类有一个 const 的或引用成员,则类的合成拷贝赋值运算符被定义为删除的。
  • 如果类的某个成员的析构函数是删除的或不可访问的,或是类有一个引用成员,它没有类内初始化器,或是类有一个 const 成员,它没有类内初始化器且其类型未显式定义默认构造函数,则该类的默认构造函数被定义为删除的。

本质上,这些规则的含义是:如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。

赋值运算符

当你编写赋值运算符时,有两点需要记住:

  • 如果将一个对象赋予它自身,赋值运算符必须能正确工作。
  • 大多数赋值运算符组合了析构函数和拷贝构造函数的工作。

当你编写一个赋值运算符时,一个好的模式是先将右侧运算对象拷贝到一个局部临时对象中。当拷贝完成后,销毁左侧运算对象的现有成员就是安全的了。一旦左侧运算对象的资源被销毁,就只剩下将数据从临时对象拷贝到左侧运算对象的成员中了。

交换操作

编写我们自己的 swap 函数

可以在我们的类上定义一个自己版本的 swap 来重载 swap 的默认行为。与拷贝控制成员不同,swap 并不是必要的。但是,对于分配了资源的类,定义 swap 可能是一种很重要的优化手段。swap 的典型实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class HasPtr {
public:
friend void swap(HasPtr&, HasPtr&);
public:
HasPtr (const std::string &s = std::string()) :
ps (new std::string(s)), i(0) {}
HasPtr (const HasPtr &p) :
ps (new std::string(*p.ps)), i(p.i) {}
HasPtr& operator=(const HasPtr &);
~HasPtr() { delete ps; }
private:
std::string *ps;
int i;
}

inline void swap(HasPtr &lhs, HasPtr &rhs)
{
using std::swap;
swap(lhs.ps, rhs.ps); // 交换指针,而不是 string 数据
swap(lhs.i, rhs.i); // 交换 int 成员
}
swap 函数应该调用 swap,而不是 std::swap

此代码中有一个很重要的微妙之处:虽然这一点在这个特殊的例子中并不重要,但在一般情况下它非常重要——swap 函数中调用的 swap 不是 std::swap。在本例中,数据成员是内置类型的,而内置类型是没有特定版本的 swap 的,所以在本例中,对 swap 的调用会调用标准库 std::swap。

但是,如果一个类的成员有自己类型特定的 swap 函数,调用 std::swap 就是错误的了。例如,假定我们有另一个命名为 Foo 的类,它有一个类型为 HasPtr 的成员 h。如果我们未定义 Foo 版本的 swap,那么就会使用标准库版本的 swap。如我们所见,标准库 swap 对 HasPtr 管理的 string 进行了不必要的拷贝。

我们可以为 Foo 编写一个 swap 函数,来避免这些拷贝。但是,如果这样编写 Foo 版本的 swap:

1
2
3
4
5
6
void swap(Foo &lhs, Foo &rhs)
{
// 错误:这个函数使用了标准库版本的 swap,而不是 HasPtr 版本
std::swap(lhs.h, rhs.h);
// 交换类型 Foo 的其他成员
}

此编码会编译通过,且正常运行。但是,使用此版本与简单使用默认版本的 swap 并没有任何性能差异。问题在于我们显式地调用了标准库版本的 swap。但是,我们不希望使用 std 中的版本,我们希望调用为 HasPtr 对象定义的版本。正确的 swap 函数如下所示:

1
2
3
4
5
6
void swap(Foo &lhs, Foo &rhs)
{
using std::swap;
swap(lhs.h, rhs.h); //使用 HasPtr 版本的 swap
// 交换类型 Foo 的其他成员
}

每个 swap 调用应该都是未加限定的。即,每个调用都应该是 swap,而不是 std::swap。如果存在类型特定的 swap 版本,其匹配程度会优于 std 中定义的版本。因此,如果存在类型特定的 swap 版本,swap 调用会与之匹配。如果不存在类型特定的版本,则会使用 std 中的版本(假定作用域中有 using 声明)。

然而,为什么 swap 函数中的 using 声明没有隐藏 HasPtr 版本 swap 的声明?

在赋值运算符中使用 swap

定义 swap 的类通常用 swap 来定义它们的赋值运算符。这些运算符使用了一种名为拷贝并交换(copy and swap)的技术。这种技术将左侧运算对象与右侧运算对象的一个副本进行交换:

1
2
3
4
5
6
7
8
// 注意 rhs 是按值传递的,意味着 HasPtr 的拷贝构造函数
// 将右侧运算对象中的 string 拷贝到 rhs
HasPtr& HasPtr::operator=(HasPtr rhs)
{
// 交换左侧运算对象和局部变量 rhs 的内容
swap(*this, rhs); // rhs 现在指向本对象曾经使用的内存
return *this; // rhs 被销毁,从而 delete 了 rhs 中的指针
}

这个技术的有趣之处是它自动处理了自赋值情况且天然就是异常安全的。它通过在改变左侧运算对象之前拷贝右侧运算对象保证了自赋值的正确,这与我们在原来的赋值运算符中使用的方法是一致的。它保证异常安全的方法也与原来的赋值运算符实现一样。代码中唯一可能抛出异常的是拷贝构造函数中的 new 表达式。如果真发生了异常,它也会在我们改变左侧运算对象之前发生。

移动操作、标准库容器和异常

由于移动操作“窃取”资源,它通常不分配任何资源。因此,移动操作通常不会抛出任何异常。当编写一个不抛出异常的移动操作时,我们应该将此事通知标准库:将移动构造函数和移动赋值运算符标记为 noexcept,且必须在类头文件的声明中和定义中(如果定义在类外的话)都指定 noexcept。

搞清楚为什么需要 noexcept 能帮助我们深入理解标准库是如何与我们自定义的类型交互的。我们需要指出一个移动操作不抛出异常,这是因为两个相互关联的事实:首先,虽然移动操作通常不抛出异常,但抛出异常也是允许的;其次,标准库容器能对异常发生时其自身的行为提供保障。例如,vector 保证,如果我们调用 push back 时发生异常,vector 自身不会发生改变。

现在让我们思考 push back 内部发生了什么。对一个 vector 调用 push back 可能要求为 vector 重新分配内存空间。当重新分配 vector 的内存时,vector 将元素从旧空间移动到新内存中,而移动一个对象通常会改变它的值。如果重新分配过程使用了移动构造函数,且在移动了部分而不是全部元素后抛出了一个异常,就会产生问题。旧空间中的移动源元素已经被改变了,而新空间中未构造的元素可能尚不存在。在此情况下,vector 将不能满足自身保持不变的要求。

另一方面,如果 vector 使用了拷贝构造函数且发生了异常,它可以很容易地满足要求。在此情况下,当在新内存中构造元素时,旧元素保持不变。如果此时发生了异常,vector 可以释放新分配的(但还未成功构造的)内存并返回。vector 原有的元素仍然存在。

为了避免这种潜在问题,除非 vector 知道元素类型的移动构造函数不会抛出异常,否则在重新分配内存的过程中,它就必须使用拷贝构造函数而不是移动构造函数。如果希望在 vector 重新分配内存这类情况下对我们自定义类型的对象进行移动而不是拷贝,就必须显式地告诉标准库我们的移动构造函数可以安全使用。我们通过将移动构造函数(及移动赋值运算符)标记为 noexcept 来做到这一点。

合成的移动操作

只有当一个类没有定义任何自己版本的拷贝控制成员(拷贝构造函数、拷贝赋值运算符、析构函数),且它的所有数据成员都能移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符。

编译器可以移动内置类型的成员。如果一个成员是类类型,且该类有对应的移动操作,编译器也能移动这个成员:

1
2
3
4
5
6
7
8
9
10
11
12
// 编译器会为 X 和 hasX 合成移动操作
struct X {
int i; // 内置类型可以移动
std::string s; // string 定义了自己的移动操作
};

struct hasx {
X mem; // X 有合成的移动操作
};

X x, x2 = std::move(x); // 使用合成的移动构造函数
hasX hx, hx2 = std::move(hx); // 使用合成的移动构造函数

与拷贝操作不同,移动操作永远不会隐式定义为删除的函数。但是,如果我们显式地要求编译器生成 =default 的移动操作,且编译器不能移动所有成员,则编译器会将移动操作定义为删除的函数。除了一个重要例外,什么时候将合成的移动操作定义为删除的函数遵循与定义删除的合成拷贝操作类似的原则:

  • 与拷贝构造函数不同,移动构造函数被定义为删除的函数的条件是:有类成员定义了自己的拷贝构造函数且未定义移动构造函数,或者是有类成员未定义自己的拷贝构造函数且编译器不能为其合成移动构造函数。移动赋值运算符的情况类似。
  • 如果有类成员的移动构造函数或移动赋值运算符被定义为删除的或是不可访问的,则类的移动构造函数或移动赋值运算符被定义为删除的。
  • 类似拷贝构造函数,如果类的析构函数被定义为删除的或不可访问的,则类的移动构造函数被定义为删除的。
  • 类似拷贝赋值运算符,如果有类成员是 const 的或是引用,则类的移动赋值运算符被定义为删除的。

移动操作和合成的拷贝控制成员间还有最后一个相互作用关系:如果类定义了一个移动构造函数和/或一个移动赋值运算符,则该类的合成拷贝构造函数和拷贝赋值运算符会被定义为删除的。

拷贝并交换赋值运算符和移动操作

我们的 HasPtr 版本定义了一个拷贝并交换赋值运算符,如果我们为此类添加一个移动构造函数,它实际上也会获得一个移动赋值运算符:

1
2
3
4
5
6
7
8
class HasPtr {
public:
// 添加的移动构造函数
HasPtr(HasPtr &&p) noexcept : ps(p.ps), i(p.i) { p.ps = 0; }
// 赋值运算符既是移动赋值运算符,也是拷贝賦值运算符
HasPtr& operator=(HasPtr rhs) { swap(*this, rhs); return *this; }
// 其他成员的定义
}

现在让我们观察赋值运算符。此运算符有一个非引用参数,这意味着此参数要进行拷贝初始化。依赖于实参的类型,拷贝初始化要么使用拷贝构造函数,要么使用移动构造函数——左值被拷贝,右值被移动。因此,单一的赋值运算符就实现了拷贝赋值运算符和移动赋值运算符两种功能。

例如,假定 hp 和 hp2 都是 HasPtr 对象:

1
2
hp = hp2;				// hp2 是一个左值;hp2 通过拷贝构造函数来拷贝
hp = std::move(hp2); // 移动构造函数移动 hp2

某些运算符不应该被重载

通常情况下,不应该重载逗号、取地址、逻辑与和逻辑或运算符。

某些运算符指定了运算对象求值的顺序。因为使用重载的运算符本质上是一次函数调用,所以这些关于运算对象求值顺序的规则无法应用到重载的运算符上。特别是,逻辑与运算符、逻辑或运算符和逗号运算符的运算对象求值顺序规则无法保留下来。除此之外,&& 和 || 运算符的重载版本也无法保留内置运算符的短路求值属性,两个运算对象总是会被求值。

因为上述运算符的重载版本无法保留求值顺序和/或短路求值属性,因此不建议重载它们。当代码使用了这些运算符的重载版本时,用户可能会突然发现他们一直习惯的求值规则不再适用了。

还有一个原因使得我们一般不重载逗号运算符和取地址运算符:C++ 语言已经定义了这两种运算符用于类类型对象时的特珠含义,这一点与大多数运算符都不相同。因为这两种运算符已经有了内置的含义,所以一般火说它们不应该被重载,否则它们的行为将异于常态,从而导致类的用户无法适应。

重载运算符作为成员或者非成员

  • 赋值(=)、下标([ ])、调用(( ))和成员访问箭头(->)运算符必须是成员。
  • 复合赋值运算符一般来说应该是成员,但并非必须,这一点与赋值运算符略有不同。
  • 改变对象状态的运算符或者与给定类型密切相关的运算符,如递增、递减和解引用运算符,通常应该是成员。
  • 具有对称性的运算符可能转换任意一端的运算对象,例如算术、相等性、关系和位运算符等,因此它们通常应该是普通的非成员函数。
  • 输入输出运算符必须是非成员函数,由于通常需要读写类的非公有数据成员,所以一般被声明为友元。

下标运算符

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

递增和递减运算符

定义递增和递减运算符的类应该同时定义前置版本和后置版本:

  • 前置版本:返回递增或递减后对象的引用
  • 后置版本:
    • 返回对象的原值(递增或递减之前的值),返回的形式是一个值而非引用
    • 接受一个额外(不被使用)的 int 类型的形参用来区分,无须为其命名,编译器为这个形参提供一个值为 0 的实参
    • 调用前置版本来完成实际的工作

成员访问运算符

在迭代器类及智能指针类中常常用到解引用运算符(*)和箭头运算符(->)。我们以如下形式向 StrBlobptr 类添加这两种运算符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class StrBlobPtr {
public
std::string& operator*() const
{
auto p = check(curr, "dereference past end");
return (*p)[curr]; // (*p) 是对象所指的 vector
}

std::string* operator->() const
{
// 将实际工作委托给解引用运算符
return & this->operator*();
}
// 其他成员
}
对箭头运算符返回值的限定

和大多数其他运算符一样(尽管这么做不太好),我们能令 operator* 完成任何我们指定的操作。箭头运算符则不是这样,它永远不能丢掉成员访问这个最基本的含义。当我们重载箭头时,可以改变的是箭头从哪个对象当中获取成员,而箭头获取成员这一事实则永远不变。重载的箭头运算符必须返回类的指针或者自定义了箭头运算符的某个类的对象。

对于形如 point->mem 的表达式来说,point 必须是指向类对象的指针或者是一个重载了 operator-> 的类的对象。根据 point 类型的不同,point->mem 分别等价于:

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

除此之外,代码都将发生错误。point->mem 的执行过程如下所示:

  1. 如果 point 是指针,则我们应用内置的箭头运算符,表达式等价于 (*point).mem。首先解引用该指针,然后从所得的对象中获取指定的成员。如果 point 所指的类型没有名为 mem 的成员,程序会发生错误。
  2. 如果 point 是定义了 operator-> 的类的一个对象,则我们使用 point. operator->() 的结果来获取 mem。其中,如果该结果是一个指针,则执行第 1 步;如果该结果本身含有重载的 operator->(),则重复调用当前步骤。最终,当这一过程结束时程序或者返回了所需的内容,或者返回一些表示程序错误的信息。

使用标准库函数对象比较指针

比较两个无关指针将产生未定义的行为,然而我们可能会希望通过比较指针的内存地址来 sort 指针的 vector。直接这么做将产生未定义的行为,因此我们可以使用一个标准库函数对象来实现该目的:

1
2
3
4
5
6
vector<string*> nameTable;	// 指针的 vector
// 错误:nameTable 中的指针彼此之间没有关系,所以 < 将产生未定义的行为
sort(nameTable.begin(), nameTable.end(),
[](string *a, string *b) { return a< b; });
// 正确:标准库规定指针的 less 是定义良好的
sort(nameTable.begin(), nameTable.end(), less<string*>());

关联容器使用 less 对元素排序,因此我们可以定义一个指针的 set 或者在 map 中使用指针作为关键值而无须直接声明 less。

类型转换运算符

一个类型转换函数必须是类的成员函数;它不能声明返回类型,形参列表也必须为空。类型转换函数通常应该是 const。

为了防止意外的转换,C++11 新标准引入了显式的类型转换运算符(explicit conversion operator):

1
2
3
4
5
6
class SmallInt {
public:
// 编译器不会自动执行这一类型转换
explicit operator int () const { return val; }
// 其他成员
};

和显式的构造函数一样,编译器(通常)也不会将一个显式的类型转换运算符用于隐式类型转换。但该规定存在一个例外,即如果表达式被用作条件,则编译器会将显式的类型转换自动应用于它。换句话说,当表达式出现在下列位置时,显式的类型转换将被隐式地执行:

  • if、while 及 do 语句的条件部分
  • for 语句头的条件表达式
  • 逻辑非运算符(!)、逻辑或运算符(||)、逻辑与运算符(&&) 的运算对象
  • 条件运算符(? :)的条件表达式

向 bool 的类型转换通常用在条件部分,因此 operator bool 一般定义成 explicit 的。

避免有二义性的类型转换

  • 不要令两个类执行相同的类型转换:如果 Foo 类有一个接受 Bar 类对象的构造函数,则不要在 Bar 类中再定义转换目标是 Foo 类的类型转换运算符。
  • 避免转换目标是内置算术类型的类型转换。特别是当你已经定义了一个转换成算术类型的类型转换时,接下来:
    • 不要再定义接受算术类型的重载运算符。如果用户需要使用这样的运算符,则类型转换操作将转换你的类型的对象,然后使用内置的运算符。
    • 不要定义转换到多种算术类型的类型转换。让标准类型转换完成向其他算术类型转换的工作。

一言以蔽之:除了显式地向 bool 类型的转换之外,我们应该尽量避免定义类型转换函数并尽可能地限制那些“显然正确”的非显式构造函数。

函数匹配与重载运算符

如果我们对同一个类既提供了转换目标是算术类型的类型转换,也提供了重载的运算符,则将会遇到重载运算符与内置运算符的二义性问题。

不存在从基类向派生类的隐式类型转换

即使一个基类指针或引用绑定在一个派生类对象上,我们也不能执行从基类向派生类的转换:

1
2
3
Quote base;
Bulk_quote* bulkP = &base; // 错误:不能将基类转换成派生类
Bulk_quote& bulkRef = base; // 错误:不能将基类转换成派生类

编译器在编译时无法确定某个特定的转换在运行时是否安全,这是因为编译器只能通过检查指针或引用的静态类型来推断该转换是否合法。如果在基类中含有一个或多个虚函数,我们可以使用 dynamic_cast 请求一个类型转换,该转换的安全检查将在运行时执行。同样,如果我们己知某个基类向派生类的转换是安全的,则我们可以使用 static_cast 来强制覆盖掉编译器的检查工作。

虚函数

  • 当且仅当对通过指针或引用调用虚函数时,才会在运行时解析该调用(动态绑定),也只有在这种情况下对象的动态类型才有可能与静态类型不同。
  • 因为我们直到运行时才能知道到底调用了哪个版本的虚函数,所以所有虚函数都必须有定义。通常情况下,如果我们不使用某个函数,则无须为该函数提供定义。但是我们必须为每一个虚函数都提供定义,而不管它是否被用到了,这是因为连编译器也无法确定到底会使用哪个虚函数。
  • 一个派生类的函数如果覆盖了某个继承而来的虚函数,则它的形参类型必须与被它覆盖的基类函数完全一致。同样,派生类中虚函数的返回类型也必须与基类函数匹配。该规则存在一个例外,当类的虚函数返回类型是类本身的指针或引用时,上述规则无效。也就是说,如果 D 由 B 派生得到,则基类的虚函数可以返回 B* 而派生类的对应函数可以返回 D*,只不过这样的返回类型要求从 D 到 B 的类型转换是可访问的。

final 和 override 说明符

派生类如果定义了一个函数与基类中虚函数的名字相同但是形参列表不同,这仍然是合法的行为。编译器将认为新定义的这个函数 与基类中原有的函数是相互独立的。这时,派生类的函数并没有覆盖掉基类中的版本。就实际的编程习惯而言,这种声明往往意味着发生了错误,因为我们可能原本希望派生类能覆盖掉基类中的虚函数,但是一不小心把形参列表弄错了。要想调试并发现这样的错误显然非常困难。在 C++11 新标准中我们可以使用 override 关键字来说明派生类中的虚函数。如果我们使用 override 标记了某个函数,但该函数并没有覆盖已存在的虚函数,此时编译器将报错。

我们还能把某个函数指定为 final,如果我们已经把函数定义成 final 了,则之后任何尝试覆盖该函数的操作都将引发错误。

虚函数和默认实参

如果某次函数调用使用了默认实参,则该实参值由本次调用的静态类型决定。换句话说,如果我们通过基类的引用或指针调用函数,则使用基类中定义的默认实参,即使实际运行的是派生类中的函数版本也是如此。此时,传入派生类函数的将是基类函数定义的默认实参。如果派生类函数依赖不同的实参,则程序结果将与我们的预期不符。因此,如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致。

回避虚函数的机制

在某些情况下,我们希望对虚函数的调用不要进行动态绑定,而是强迫其执行虚函数的某个特定版本。使用作用域运算符可以实现这一目的,例如下面的代码:

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

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

什么时候我们需要回避虚函数的默认机制呢?通常是当一个派生类的虚函数调用它覆盖的基类的虚函数版本时。在此情况下,基类的版本通常完成继承层次中所有类型都要做的共同任务,而派生类中定义的版本需要执行一些与派生类本身密切相关的操作。如果一个派生类虚函数需要调用它的基类版本,但是没有使用作用域运算符,则在运行时该调用将被解析为对派生类版本自身的调用,从而导致无限递归。

派生类向基类转换的可访问性

派生类向基类的转换是否可访问由使用该转换的代码决定,同时派生类的派生访问说明符也会有影响。假定 D继承自 B:

  • 只有当 D 公有地继承 B 时,用户代码才能使用派生类向基类的转换:如果 D 继承 B 的方式是受保护的或者私有的,则用户代码不能使用该转换。
  • 不论 D 以什么方式继承 B,D 的成员函数和友元都能使用派生类向基类的转换:派生类向其直接基类的类型转换对于派生类的成员和友元来说永远是可访问的。
  • 如果 D 继承 B 的方式是公有的或者受保护的,则 D 的派生类的成员和友元可以使用 D 向 B 的类型转换:反之,如果 D 继承 B 的方式是私有的,则不能使用。

名字查找与继承

理解函数调用的解析过程对于理解 C++ 的继承至关重要,假定我们调用 p->mem()(或者 obj.mem()),则依次执行以下4个步骤:

  • 首先确定 p(或 obj)的静态类型。因为我们调用的是一个成员,所以该类型必然是类类型。
  • 在 p(或 obj)的静态类型对应的类中查找 mem。如果找不到,则依次在直接基类中不断查找直至到达继承链的顶端。如果找遍了该类及其基类仍然找不到,则编译器将报错。
  • 一旦找到了 mem,就进行常规的类型检查以确认对于当前找到的 mem,本次调用是否合法。
  • 假设调用合法,则编译器将根据调用的是否是虚函数而产生不同的代码:
    • 如果 mem 是虚函数且我们是通过引用或指针进行的调用,则编译器产生的代码将在运行时确定到底运行该虚函数的哪个版本,依据是对象的动态类型。
    • 反之,如果 mem 不是虚函数或者我们是通过对象(而非引用或指针)进行的调用,则编译器将产生一个常规函数调用。

名字查找先于类型检查

声明在内层作用城的函数并不会重载声明在外层作用域的函数,因此定义派生类中的函数也不会重载其基类中的成员。和其他作用域一样,如果派生类(即内层作用域)的成员与基类(即外层作用城)的某个成员同名, 派生类将在其作用域内隐藏该基类成员。即使派生类成员和基类成员的形参列表不一致,基类成员也仍然会被隐藏掉:

1
2
3
4
5
6
7
8
9
10
11
struct Base {
int memfcn();
};
struct Derived : Base {
int memfcn(int); // 隐藏基类的 memfcn
}
Derived d; Base b;
b.memfcn(); // 调用 Base::memfcn
d.memfcn(10); // 调用 Derived::memfcn
d.memfcn(); // 错误:参数列表为空的 memfcn 被隐藏了
d.Base::memfcn(); // 正确:调用 Base::memfcn

为了解析 d.memfcn(),编译器首先在 Derived 中查找名字 memfcn;因为 Derived 确实定义了一个名为 memfcn 的成员,所以查找过程终止。一旦名字找到,编译器就不再继续查找了。Derived 中的 memfcn 版本需要一个 int 实参,而当前的调用语句无法提供任何实参,所以该调用语句是错误的。

虚函数与作用域

基类与派生类中的虚函数必须有相同的形参列表。假如基类与派生类的虚函数接受的实参不同,则我们就无法通过基类的引用或指针调用派生类的虚函数了。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Base {
public:
Virtual int fcn();
};
class D1 : public Base {
public:
// D1 继承了 Base::fcn() 的定义
int fcn(int); // 隐藏基类的 fcn, 这个 fcn 不是虚函数
virtual void f2(); // 形参列表与 Base 中的 fcn 不一致,是一个新的虚函数,在 Base 中不存在
};

class D2 : public D1 {
public:
int fcn(int); // 是一个非虚函数,隐藏了 D1::fcn(int)
int fcn(); // 覆盖了 Base 的虚函数 fcn
void f2(); // 覆盖了 D1 的虚函数 f2
};

Base bobj; D1 d1obj; D2 d2obj;

Base *bp1 = &bobj, *bp2 = &d1obj, *bp3 = &d2obj;
bp1->fcn(); // 虚调用,将在运行时调用 Base::fcn
bp2->fcn(); // 虚调用,将在运行时调用 Base::fcn
bp3->fcn(); // 虚调用,将在运行时调用 D2::fcn

D1 *d1p = &d1obj; D2 *d2p = &d2obj;
bp2->f2(); // 错误:Base 没有名为 f2 的成员
d1p->f2(); // 虚调用,将在运行时调用 D1::f2()
d2p->f2(); // 虚调用,将在运行时调用 D2::f2()

Base *p1 = &d2obj; D1 *p2 = &d2obj; D2 *p3 = &d2obj;
p1->fcn(42); // 错误:Base 中没有接受一个 int 的 fcn
p2->fcn(42); // 静态绑定,调用 D1::fcn(int)
p3->fcn(42); // 静态绑定,调用 D2::fcn(int)

覆盖重载的函数

和其他函数一样,成员函数无论是否是虚函数都能被重载。派生类可以覆盖重载函数的 0 个或多个实例。如果派生类希望所有的重载版本对于它来说都是可见的,那么它就需要覆盖所有的版本,或者一个也不覆盖。

有时一个类仅需覆盖重载集合中的一些而非全部函数,此时,如果我们不得不覆盖基类中的每一个版本的话,显然操作将极其烦琐。一种好的解决方案是为重载的成员提供一条 using 声明语句,这样我们就无须覆盖基类中的每一个重载版本了。using 声明语句指定一个名字而不指定形参列表,所以一条基类成员函数的 using 声明语句就可以把该函数的所有重载实例添加到派生类作用域中。此时,派生类只需要定义其特有的函数就可以了,而无须为继承而来的其他函数重新定义。

类内 using 声明的一般规则同样适用于重载函数的名字:基类函数的每个实例在派生类中都必须是可访问的。对派生类没有重新定义的重载版本的访问实际上是对 using 声明点的访问。

虚析构函数

  • 继承关系对基类拷贝控制最直接的影响是基类通常应该定义一个虚析构函数。
  • 之前我们曾介绍过一条经验准则,即如果一个类需要析构函数,那么它也同样需要拷贝和赋值操作。基类的析构函数并不遵循上述准则,它是一个重要的例外。一个基类总是需要析构函数,而且它能将析构函数设定为虚函数。此时,该析构函数为了成为虚函数而令内容为空,我们显然无法由此推断该基类还需要赋值运算符或拷贝构造函数。
  • 基类需要一个虚析构函数这一事实还会对基类和派生类的定义产生另外一个间接的影响:如果一个类定义了析构函数,即使它通过 =default 的形式使用了合成的版本,编译器也不会为这个类合成移动操作。

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

某些定义基类的方式可能导致有的派生类成员成为被删除的函数:

  • 如果基类中的默认构造函数、拷贝构造函数、拷贝赋值运算符或析构函数是被删除的函数或者不可访问,则派生类中对应的成员将是被删除的,原因是编译器不能使用基类成员来执行派生类对象基类部分的构造、赋值或销毁操作。
  • 如果在基类中有一个不可访问或删除掉的析构函数,则派生类中合成的默认和拷贝构造函数将是被删除的,因为编译器无法销毁派生类对象的基类部分。
  • 和过去一样,编译器将不会合成一个删除掉的移动操作。当我们使用 =default 请求一个移动操作时,如果基类中的对应操作是删除的或不可访问的,那么派生类中该函数将是被删除的,原因是派生类对象的基类部分不可移动。同样,如果基类的析构函数是删除的或不可访问的,则派生类的移动构造函数也将是被删除的。

派生类的拷贝控制成员

  • 在默认情况下,基类默认构造函数初始化派生类对象的基类部分。如果我们想拷贝(或移动)基类部分,则必须在派生类的构造函数初始值列表中显式地使用基类的拷贝(或移动)构造函数。
  • 派生类的赋值运算符与拷贝/移动构造函数类似,但注意需要以函数形式显式调用基类的赋值运算符。
  • 析构函数只负责销毁派生类自己分配的资源。

继承的构造函数

类不能继承默认、拷贝和移动构造函数。如果派生类没有直接定义这些构造函数,则编译器将为派生类合成它们。在 C++11 新标准中,派生类能够重用其直接基类定义的构造函数:

1
2
3
4
5
class Bulk_quote : public Disc_quote {
public:
using Disc_quote::Disc_quote; // 继承 Dise_quote 的构造函数
double net_price(std::size_t) const;
};

通常情况下,using 声明语句只是令某个名字在当前作用域内可见。而当作用于构造函数时,using 声明语句将令编译器产生代码。对于基类的每个构造函数,编译器都生成一个与之对应的派生类构造函数。换句话说,对于基类的每个构造函数,编译器都在派生类中生成一个形参列表完全相同的构造函数,形如:

1
derived(parms) : base(args) {}

如果派生类含有自己的数据成员,则这些成员将被默认初始化。

模板编译

通常,当我们调用一个函数时,编译器只需要掌握函数的声明。类似的,当我们使用一个类类型的对象时,类定义必须是可用的,但成员函数的定义不必已经出现。因此,我们将类定义和函数声明放在头文件中,而普通函数和类的成员函数的定义放在源文件中。

模板则不同:为了生成一个实例化版本,编译器需要掌握函数模板或类模板成员函数的定义。因此,与非模板代码不同,模板的头文件通常既包括声明也包括定义。

类模板成员函数的实例化

默认情况下,一个类模板的成员函数只有当程序用到它时才进行实例化,这一特性使得即使某种类型不能完全符合模板操作的要求,我们仍然能用该类型实例化类。

显式实例化

当模板被使用时才会进行实例化这一特性意味着,相同的实例可能出现在多个对象文件中。当两个或多个独立编译的源文件使用了相同的模板,并提供了相同的模板参数时,每个文件中就都会有该模板的一个实例。在大系统中,在多个文件中实例化相同模板的额外开销可能非常严重。在新标准中,我们可以通过显式实例化(explicit instantiation)来避免这种开销。一个显式实例化有如下形式:

1
2
extern template declaration;	// 实例化声明
template declaration; // 实例化定义

declaration 是一个类或函数声明,其中所有模板参数已被替换为模板实参。例如:

1
2
3
// 实例化声明与定义
extern template class Blob<string>; // 声明
template int compare(const int&, const int&); // 定义

当编译器遇到 extern 模板声明时,它不会在本文件中生成实例化代码。将一个实例化声明为 extern 就表示承诺在程序其他位置有该实例化的一个非 extern 声明(定义)。对于一个给定的实例化版本,可能有多个 extern 声明,但必须只有一个定义。由于编译器在使用一个模板时自动对其实例化,因此 extern 声明必须出现在任何使用此实例化版本的代码之前。

一个类模板的实例化定义会实例化该模板的所有成员,包括内联的成员函数。当编译器遇到一个实例化定义时,它不了解程序使用哪些成员函数。因此,与处理类模板的普通实例化不同,编译器会实例化该类的所有成员。即使我们不使用某个成员,它也会被实例化。因此,我们用来显式实例化一个类模板的类型,必须能用于模板的所有成员。

效率与灵活性

shared_ptr 在运行时绑定删除器

在一个 shared ptr 的生存期中,我们可以随时改变其删除器的类型,因此删除器必须保存为一个指针或一个封装了指针的类,而不是直接保存为一个成员,因为删除器的类型直到运行时才会知道。由于删除器是间接保存的,调用它需要一次运行时的跳转操作,转到指针中保存的地址来执行对应的代码。

unique_ptr 在编译时绑定删除器

删除器的类型是一个 unique_ptr 对象的类型的一部分,用户必须在定义 unique_ptr 时以显式模板实参的形式提供删除器的类型,因此删除器成员的类型在编译时是知道的,从而删除器可以直接保存在 unique_ptr 对象中,甚至可能被编译为内联形式,避免了间接调用删除器的运行时开销。

引用折叠和右值引用参数

1
2
3
4
template <typename T> void f3(T&&);
f3(42); // 实参是一个 int 类型的右值;模板参数 T 是 int
f3(i); // 实参是一个左值;模板参数 T 是 int&
f3(ci); // 实参是一个左值;模板参数 T 是一个 const int&

假定 i 是一个 int 对象,我们可能认为像 f3(i) 这样的调用是不合法的。毕竟,i 是一个左值,而通常我们不能将一个右值引用绑定到一个左值上。但是,C++ 语言在正常绑定规则之外定义了两个例外规则,允许这种绑定。这两个例外规则是 move 这种标准库设施正确工作的基础。

第一个例外规则影响右值引用参数的推断如何进行。当我们将一个左值(如 i)传递给函数的右值引用参数,且此右值引用指向模板类型参数(如 T&&)时,编译器推断模板类型参数为实参的左值引用类型。因此,当我们调用 f3(i) 时,编译器推断 T 的类型为 int&,而非 int。

T 被推断为 int& 看起来好像意味着 f3 的函数参数应该是一个类型 int& 的右值引用。通常,我们不能(直接)定义一个引用的引用。但是,通过类型别名或通过模板类型参数间接定义是可以的。

在这种情况下,我们可以使用第二个例外绑定规则:如果我们间接创建一个引用的引用,则这些引用形成了“折叠”。在所有情况下(除了一个例外),引用会折叠成一个普通的左值引用类型。在新标淮中,折叠规则扩展到右值引用。只在一种特殊情况下引用会折叠成右值引用:右值引用的右值引用。即,对于一个给定类型 X:

  • X& &X& &&X&& & 都折叠成类型 X&
  • 类型 X&& && 折叠成 X&&

f3 的函数参数是 T&& 且 T 是 int&,因此 T&& 是 int& &&,会折叠成 int&。因此,即使 f3 的函数参数形式是一个右值引用(即 T&&),此调用也会用一个左值引用类型(即 int&)实例化 f3:

1
void f3<int&>(int&);	// 当 T 是 int& 时,函数参数折叠为 int&

这两个规则导致了两个重要结果:

  • 如果一个函数参数是一个指向模板类型参数的右值引用(如 T&&),则它可以被绑定到一个左值;且
  • 如果实参是一个左值,则推断出的模板实参类型将是一个左值引用,且函数参数将被实例化为一个(普通)左值引用参数(T&)

这两个规则暗示,我们可以将任意类型的实参传递给 T&& 类型的函数参数。对于这种类型的参数,(显然)可以传递给它右值,也可以传递给它左值。当代码中涉及的类型可能是普通(非引用)类型,也可能是引用类型时,编写正确的代码就变得异常困难(虽然 remove_reference 这样的类型转换类可能会有帮助)。

在实际中,右值引用通常用于两种情况:模板转发其实参或模板被重载。

std::move 的实现

1
2
3
4
5
template <typename T>
typename remove_reference<T>::type&& move(T&& t)
{
return static_cast<typename remove_reference<T>::type&&>(t);
}

转发

  • 如果一个函数参数是指向模板类型参数的右值引用(如 T&&),它对应的实参的 const 属性和左值/右值属性将得到保持。
  • 与 move 不同,forward 必须通过显式模板实参来调用。
  • forward 返回该显式实参类型的右值引用,即 forward 的返回类型是 T&&。
  • 通常情况下,我们使用 forward 传递那些定义为模板类型参数的右值引用的函数参数。通过其返回类型上的引用折叠,forward 可以保持给定实参的左值/右值属性。
  • 与 std::move 相同,对 std::forward 不使用 using 声明是一个好主意。

转发和可变参数模板

可变参数函数通常将它们的参数转发给其他函数。这种函数通常具有与 emplace_back 函数一样的形式:

1
2
3
4
5
template <typename... Args>
void fun(Args&&... args)
{
work(std::forward<Args>(args)...);
}

这里我们希望将 fun 的所有实参转发给另一个名为 work 的函数,假定由它完成函数的实际工作。work 调用中的扩展既扩展了模板参数包也扩展了函数参数包。由于 fun 的参数是右值引用,因此我们可以传递给它任意类型的实参;由手我们使用 std::forward 传递这些实参,因此它们的所有类型信息在调用 work 时都会得到保持。

模板特例化

为了特例化一个模板,原模板的声明必须在作用域中。而且,在任何使用模板实例的代码之前,特例化版本的声明也必须在作用域中。

对于普通类和函数,丢失声明的情况(通常)很容易发现——编译器将不能继续处理我们的代码。但是,如果丢失了一个特例化版本的声明,编译器通常可以用原模板生成代码。由于在丢失特例化版本时编译器通常会实例化原模板,很容易产生模板及其特例化版本声明顺序导致的错误,而这种错误又很难查找。

如果一个程序使用一个特例化版本,而同时原模板的一个实例具有相同的模板实参集合,就会产生错误。但是,这种错误编译器又无法发现。

模板及其特例化版本应该声明在同一个头文件中。所有同名模板的声明应该放在前面,然后是这些模板的特例化版本。

特例化 hash 模板类

一个特例化 hash 类必须定义:

  • 一个重载的调用运算符,它接受一个容器关键字类型的对象,返回一个 size_t。
  • 两个类型成员,result_type 和 argument_type,分别调用运算符的返回类型和参数类型。
  • 默认构造函数和拷贝赋值运算符(可以隐式定义)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 打开 std命名空间,以便特例化 std::hash
namespace std {
template<> // 我们正在定义一个特例化版本,模板参数为 Sales_data
struct hash<Sales_data>
{
// 用来散列一个无序容器的类型必须要定义下列类型
typedef size_t result_type;
typedef Sales_data argument_type; // 默认情况下,此类型需要 ==
size_t operator()(const Sales_data& s) const;
// 我们的类使用合成的拷贝控制成员和默认构造函数
};
size_t hash<Sales_data>::operator()(const Sales_data& s) const
{
return hash<string>()(s.bookNo) ^
hash<unsigned>()(s.units_sold) ^
hash<double>()(s.revenue);
}
} // 关闭 std 命名空间;注意:右花括号之后没有分号

由于 hash 使用 Sales_data 的私有成员,我们必须将它声明为 Sales_data 的友元:

1
2
3
4
5
template <class T> class std::hash;	// 友元声明所需要的
class Sales_data {
friend class std::hash<Sales_data>;
// 其他成员定义
};

为了让 Sales_data 的用户能使用 hash 的特例化版本,我们应该在 Sales_data 的头文件中定义该特例化版本。

类模板部分特例化

与函数模板不同,类模板的特例化不必为所有模板参数提供实参。我们可以只指定一部分而非所有模板参数,或是参数的一部分而非全部特性。一个类模板的部分特例化(partial specialization)本身是一个模板,使用它时用户还必须为那些在特例化版本中未指定的模板参数提供实参。

标淮库 remove_reference 模板就是通过一系列的特例化版本来完成其功能的:

1
2
3
4
5
6
7
8
9
// 原始的、最通用的版本
template <class T> struct remove_reference {
typedef T type;
};
// 部分特例化版本,将用于左值引用和右值引用
template <class T> struct remove_reference<T&> // 左值引用
{ typedef T type; };
template <class T> struct remove_reference<T&&> // 右值引用
{ typedef T type; };

我们可以只特例化特定成员函数而不是特例化整个模板。例如,如果 Foo 是一个模板类,包含一个成员 Bar,我们可以只特例化该成员:

1
2
3
4
5
6
7
8
9
10
11
template <typename T> struct Foo {
Foo(const T &t = T()) : mem(t) { }
void Bar() { /*...*/ }
T mem;
// Foo 的其他成员
};
template<> // 我们正在特例化一个模板
void Foo<int>::Bar() // 我们正在特例化 Foo<int> 的成员 Bar
{
// 进行应用于 int 的特例化处理
}

流随机访问

  • 虽然标准库为所有流类型都定义了 seek 和 tell 函数,但它们是否会做有意义的事情依赖于流绑定到哪个设备。在大多数系统中,绑定到 cin、 cout、 cerr 和 clog 的流不支持随机访问。对这些流我们可以调用 seek 和 tell 函数,但在运行时会出错,将流置于一个无效状态。
  • 我们只能对 istream 和派生自 istream 的类型 ifstream 和 istringstream 使用 g 版本,同样只能对 ostream 和派生自 ostream 的类型 ofstream 和 ostringstream 使用 p 版本。一个 iostream、fstream 或 stringstream 既能读又能写关联的流,因此对这些类型的对象既能使用 g 版本又能使用 p 版本。
  • 即使标准库进行了区分,但它在一个流中只维护单一的标记——并不存在独立的读标记和写标记。fstream 和 stringstream 类型读写同一个流,有单一的缓冲区用于保存读写的数据,同样,标记也只有一个,表示缓冲区中的当前位置。标准库将 g 和 p 版本的读写位置都胦射到这个单一的标记。因此,只要我们在读写操作间切换,就必须进行 seek 操作来重定位标记。

析构函数与异常

在栈展开的过程中,运行类类型的局部对象的析构西数。因为这些析构函数是自动执行的,所以它们不应该抛出异常。一旦在栈展开的过程中析构函数抛出了异常,并且析构西数自身没能捕获到该异常,则程序将被终止。

异常对象

当我们抛出一条表达式时,该表达式的静态编译时类型决定了异常对象的类型。必须牢记这一点,因为很多情况下程序抛出的表达式类型来自于某个继承体系。如果一条 throw 表达式解引用一个基类指针,而该指针实际指向的是派生类对象,则抛出的对象将被切掉一部分,只有基类部分被抛出。

捕获异常

与实参和形参的匹配规则相比,异常和 catch 异常声明的匹配规则受到更多限制。此时,绝大多数类型转换都不被允许,除了一些极细小的差别之外,要求异常的类型和 catch 声明的类型是精确匹配的:

  • 允许从非常量向常量的类型转换,也就是说,一条非常量对象的 throw 语句可以匹配一个接受常量引用的 catch 语句。
  • 允许从派生类向基类的类型转换。
  • 数组被转换成指向数组(元素)类型的指针,函数被转换成指向该函数类型的指针。

除此之外,包括标准算术类型转换和类类型转换在内,其他所有转换规则都不能在匹配 catch 的过程中使用。

如果在多个 catch 语向的类型之间存在着继承关系,则我们应该把继承链最底端的类(most derived type)放在前面,而将继承链最顶端的类(least derived type)放在后面。

如果 catch(...) 与其他几个 catch 语句一起出现,则 catch(...) 必须在最后的位置。出现在捕获所有异常语句后面的 catch 语句将永远不会被匹配。

函数 try 语句块与构造函数

通常情况下,程序执行的任何时刻都可能发生异常,特别是异常可能发生在处理构造函数初始值的过程中。构造函数在进入其函数体之前首先执行初始值列表。因为在初始值列表抛出异常时构造函数体内的 try 语句块还未生效,所以构造函数体内的 catch 语句无法处理构造函数初始值列表抛出的异常。

要想处理构造函数初始值抛出的异常,我们必须将构造函数写成函数 try 语句块(也称为函数测试块,function try block)的形式。函数 try 语句块使得一组 catch 语句既能处理构造函数体(或析构函数体),也能处理构造函数的初始化过程(或析构函数的析构过程)。例如:

1
2
3
4
5
template <typename T>
Blob<T>::Blob(std::initializer_list<T> il) try :
data(std::make_shared<std::vector<T>>(il)) {
/* 空函数体 */
} catch(const std::bad_alloc &e) { handle_out_of_memory(e); }

还有一种情况值得注意,在初始化构造函数的参数时也可能发生异常,这样的异常不属于函数 try 语句块的一部分。函数 try 语句块只能处理构造函数开始执行后发生的异常。和其他函数调用一样,如果在参数初始化的过程中发生了异常,则该异常属于调用表达式的一部分,并将在调用者所在的上下文中处理。

标准库异常类层次

截屏2022-02-04 18.56.15

重载 new 和 delete

new 表达式的工作机理
  1. new 表达式调用一个名为 operator new(或者 operator new[])的标准库函数。该函数分配一块足够大的、原始的、未命名的内存空间以便存储特定类型的对象(或者对象的数组);
  2. 编译器运行相应的构造函数以构造这些对象,并为其传入初始值;
  3. 对象被分配了空间并构造完成,返回一个指向该对象的指针。
delete 表达式的工作机理
  1. 对指针所指的对象(或者对象的数组)中的元素执行对应的析构函数;
  2. 编译器调用名为 operator delete(或者 operator delete[ ])的标准库函数释放内存空间。

如果应用程序希望控制内存分配的过程,则它们需要定义自己的 operator new 函数和 operator delete 函数。应用程序可以在全局作用域中定义这两个函数,也可以将它们定义为成员函数。对于后者,由于 operator new 用在对象构造之前而 operator delete 用在对象销毁之后,所以这两个成员必须是静态的(隐式静态,无须显式声明 static),而且它们不能操纵类的任何数据成员。

当编译器发现一条 new 表达式或 delete 表达式后,将在程序中查找可供调用的 operator 函数。如果被分配(释放)的对象是类类型,则编译器首先在类及其基类的作用域中查找。此时如果该类含有 operator new 成员或 operator delete 成员,则相应的表达式将调用这些成员。否则,编译器在全局作用域查找匹配的函数。此时如果编译器找到了用户自定义的版本,则使用该版本执行 new 表达式或 delete 表达式;如果没找到,则使用标准库定义的版本。

我们可以使用作用域运算符令 new 表达式或 delete 表达式忽略定义在类中的函数,直接执行全局作用域中的版本。例如,::new 只在全局作用域中查找匹配的 operator new 函数,::delete 与之类似。

标准库 operator new 和 operator delete 接口

应用程序可以自定义下面函数版本中的任意一个:

1
2
3
4
5
6
7
8
9
10
11
// 这些版本可能抛出异常
void *operator new(size_t);
void *operator new[](size_t);
void *operator delete(void*) noexcept;
void *operator delete[](void*) noexcept;

// 这些版本承诺不会抛出异常
void *operator new(size_t, nothrow_t&) noexcept;
void *operator new[](size_t, nothrow_t&) noexcept;
void *operator delete(void*, nothrow_t&) noexcept;
void *operator delete[](void*, nothrow_t&) noexcept;

我们可以为自定义的 operator new 函数提供额外的形参,用到这些自定义函数的 new 表达式必须使用 new 的定位形式将实参传给新增的形参。尽管在一般情况下我们可以自定义具有任何形参的 operator new,但是下面这种形式只供标准库使用,不能被用户重新定义:

1
void *operator new(size_t, void*);

当我们将 operator delete 或 operator delete[] 定义成类的成员时,该函数可以包含另外一个类型为 size_t 的形参。此时,该形参的初始值是第一个形参所指对象的字节数。size_t 形参可用于删除继承体系中的对象。如果基类有一个虚析构函数,则传递给 operator delete 的字节数将因待删除指针所指对象的动态类型不同而有所区别。而且,实际运行的 operator delete 函数版本也由对象的动态类型决定。

使用 malloc/free 函数重载 new/delete
1
2
3
4
5
6
7
void *operator new(size_t size) {
if (void *mem = malloc(size))
return mem;
else
throw bad_alloc();
}
void operator delete(void *mem) noexcept { free(mem); }

标准库函数 operator new 和 operator delete 的名字容易让人误解。和其他 operator 函数不同(比如operator=),这两个函数并没有重载 new 表达式或 delete 表达式。实际上,我们根本无法自定义 new 表达式或 delete 表达式的行为。

一条 new 表达式的执行过程总是先调用 operator new 函数以获取内存空间,然后在得到的内存空间中构造对象。与之相反,一条 delete 表达式的执行过程总是先销毁对象,然后调用 operator delete 函数释放对象所占的空间。

我们提供新的 operator new 函数和 operator delete 函数的目的在于改变内存分配的方式,但是不管怎样,我们都不能改变 new 运算符和 delete 运算符的基本含义。

定位 new 表达式

在 C++ 的早期版本中,allocator 类还不是标准库的一部分。应用程序如果想把内存分配与初始化分离开来的话,需要显式调用 operator new 和 operator delete 函数。这两个函数的行为与 allocator 的 allocate 成员和 deallocate 成员非常类似,它们负责分配或释放内存空间,但是不会构造或销段对象。与 allocator 不同的是,对于 operator new 分配的内存空间来说我们无法使用 construct 函数构造对象。相反,我们应该使用 new 的定位 new(placement new)形式构造对象:

1
2
3
4
new (place_address) type
new (place_address) type (initializers)
new (place_address) type [size]
new (place_address) type [size] { braced initializer list }

当仅通过一个地址值调用时,定位 new 使用 operator new(size_t, void*) “分配”它的内存。这是一个我们无法自定义的 operator new版本,它不分配任何内存,只是简单地返回指针实参;然后由 new 表达式负责在指定的地址初始化对象以完成整个工作。事实上,定位 new 允许我们在一个特定的、预先分配的内存地址上构造对象。

尽管在很多时候使用定位 new 与 allocator 的 construct 成员非常相似,但在它们之间也有一个重要的区别。我们传给 construct 的指针必须指向同一个 allocator 对象分配的空间,但是传给定位 new 的指针无须指向 operator new 分配的内存。实际上,传给定位 new 表达式的指针甚至不需要指向动态内存。

运行时类型识别

运行时类型识别(run-time type identification, RTTI) 的功能由两个运算符实现:

  • typeid 运算符,用于返回表达式的类型。
  • dynamic_cast 运算符,用于将基类的指针或引用安全地转换成派生类的指针或引用。

这两个运算符特别适用于以下情况:我们想使用基类对象的指针或引用执行某个派生类操作并且该操作不是虚函数。

在可能的情况下,最好定义虚函数而非直接接管类型管理的重任。

dynamic_cast 运算符
1
2
3
dynamic_cast<type*>(e)
dynamic_cast<type&>(e)
dynamic_cast<type&&>(e)

其中,type 必须是一个类类型,并且通常情况下该类型应该含有虚函数。在第一种形式中,e 必须是一个有效的指针;在第二种形式中,e 必须是一个左值;在第三种形式中,e 不能是左值。

在上面的所有形式中,e 的类型必须符合以下三个条件中的任意一个:e 的类型是目标 type 的公有派生类、e 的类型是目标 type 的公有基类或者 e 的类型就是目标 type 的类型。如果符合,则类型转换可以成功。否则,转换失败。

typeid 运算符

typeid 运算符可以作用于任意类型的表达式或类型的名字。顶层 const 被忽略,如果表达式是一个引用,则 typeid 返回该引用所引对象的类型。不过当 typeid 作用于数组或函数时,并不会执行向指针的标准类型转换。

typeid 应该作用于对象。当作用于指针时(而非指针所指的对象),返回的结果是该指针的静态编译时类型。

当运算对象不属于类类型或者是一个不包含任何虚函数的类时,typeid 运算符指示的是运算对象的静态类型,编译器无须对表达式求值也能知道表达式的静态类型。而当运算对象是定义了至少一个虚函数的类的左值时,typeid 的结果直到运行时才会求得,此时编译器才会对表达式求值。这条规则适用于 typeid(*p) 的情况:如果指针 p 所指的类型不含有虚函数,则 p 不必非得是一个有效的指针;否则,*p 将在运行时求值,此时必须是一个有效的指针。如果 p 是一个空指针,则 typeid(*p) 将抛出一个名为 bad_typeid 的异常。

使用 RTTI 为具有继承关系的类实现相等运算符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Base {
friend bool operator==(const Base&, const Base&);
public:
// Base 的接口成员
protected:
virtual bool equal(const Base&) const;
// Base 的数据成员和其他用于实现的成员
};

class Derived : public Base {
public:
// Derived 的其他接口成员
protected:
bool equal(const Base&) const;
// Derived 的数据成员和其他用于实现的成员
};

bool operator==(const Base &lhs, const Base &rhs)
{
// 如果 typeid 不相同,返回 false;否则虚调用 equal
return typeid(lhs) == typeid(rhs) && lhs.equal(rhs);
}

bool Derived::equal(const Base &rhs) const
{
// 我们清楚这两个类型是相等的,所以转换过程不会拋出异常
auto r = dynamic+cast<const Derived&>(rhs);
// 执行比较两个 Derived 对象的操作并返回结果
}

bool Base::equal(const Base &rhs) const
{
// 执行比较 Base 对象的操作
}

成员指针函数表

对于普通函数指针和指向成员函数的指针来说,一种常见的用法是将其存入一个函数表当中。如果一个类含有几个相同类型的成员,则这样一张表可以帮助我们从这些成员中选择一个。假定 Screen 类含有几个成员函数,每个函数负责将光标向指定的方向移动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Screen {
public:
// 光标移动函数
Screen& home();
Sereen& forward();
Screen& back();
Screen& up();
Screen& down();
public:
// Action 是一个指针,可以用任意一个光标移动函数对其赋值
using Action = Screen&(Screen::*)();
// 指定具体要移动的方向
enum Directions { HOME, FORWARD, BACK, UP DOWN };
Sereen& move(Directions);
private:
static Action Menu[]; // 函数表
};

Screen::Action Screen::Menu[] = {
&Screen::home,
&Screen::forward,
&Screen::back,
&Screen::up,
&Screen::down,
};

Sereen& Screen::move(Directions cm)
{
// 运行 this 对象中索引值为 cm 的元素
return (this->*Menu[cm])(); // Menu[cm] 指向一个成员函数
}

Screen myScreen;
myScreen.move(Screen::HOME); // 调用 myScreen.home
myScreen.move(Screen::DOwN); // 调用 myscreen.down

将成员函数用作可调用对象

要想通过一个指向成员函数的指针进行函数调用,必须首先利用 .*运算符或 ->* 运算符将该指针绑定到特定的对象上。因此与普通的函数指针不同,成员指针不是一个可调用对象,不支持函数调用运算符,所以我们不能直接将一个指向成员函数的指针传递给算法。

使用 function 生成一个可调用对象

通常情况下,执行成员函数的对象将被传给隐式的 this 形参。当我们想要使用 function 为成员函数生成一个可调用对象时,必须使隐式的形参变成显式的,即第一个形参必须表示该成员是在哪个对象上执行的。同时,我们提供给 function 的形式中还必须指明对象是以指针还是引用的形式传入的。

1
2
3
4
5
6
7
vector<string> svec;
function<bool (const string&)> fcn = &string::empty;
find_if(svec.begin(), svec.end(), fcn);

vector<string*> pvec;
function<bool (const string*)> fp = &string::empty;
find_if(pvec.begin(), pvec.end(), fp);
使用 mem_fn 生成一个可调用对象

要想使用 function,我们必须提供成员的调用形式。我们也可以采取另外一种方法,通过使用标准库功能 mem_ fn 来让编译器负责推断成员的类型。 和 function 一样,mem_fn 也定义在 functional 头文件中,并且可以从成员指针生成一个可调用对象;和 function 不同的是,mem_fn 可以根据成员指针的类型推断可调用对象的类型,而无须用户显式地指定:

1
find_if(svec.begin(), svec.end(), mem_fn(&string::empty));

我们使用 mem_fn(&string::empty) 生成一个可调用对象,该对象接受一个 string 实参,返回一个 bool值。mem_fn 生成的可调用对象可以通过对象调用,也可以通过指针调用,可以认为它含有一对重载的函数调用运算符:一个接受 string*,另一个接受 string&:

1
2
3
auto f = mem_fn(&string::empty);	// f 接受一个 string 或者一个 string*
f(*svec.begin()); // 正确:传入一个 string 对象,f 使用 .* 调用 empty
f(&svec[0]); // 正确:传入一个 string 指针,f 使用 ->* 调用 empty
使用 bind 生成一个可调用对象

我们还可以使用 bind 从成员函数生成一个可调用对象:

1
auto it = find_if(svec.begin(),svec.end(), bind(&string::empty, placeholders::_1));

和 function 类似的地方是,当我们使用 bind 时,必须将函数中用于表示执行对象的隐式形参转换成显式的。和 mem_fn 类似的地方是,bind 生成的可调用对象的第一个实参既可以是 string 的指针,也可以是 string 的引用:

1
2
3
auto f = bind(&string::empty, placeholders::_1);
f(*svec.begin());
f(&svec[0J);

对链接到 C 的预处理器的支持

有时需要在 C 和 C++ 中编译同一个源文件,为了实现这一目的,在编译 C++ 版本的程序时预处理器定义 __cplusplus(两个下画线)。利用这个变量,我们可以在编译 C++ 程序的时候有条件地包含进来一些代码:

1
2
3
4
#ifdef __cplusplus
extern "C"
#endif
int strcmp(const char*, const char*);