条款 7:在创建对象时区分 () 和 {}

这是一个令很多人头痛的问题,如果没有完全搞懂这里边的门道,是不会愿意使用 C++ 提供的大括号初始化语法的。或者,大多数人多少也有踩过坑的过去。

大括号初始化语法的优点

讨论以下代码:

1
2
3
4
int x(0);  // 使用小括号来初始化 x 值为 0 
int y = 0; // 使用等号来初始化 y 值为 0
int z{0}; // 使用大括号来初始化 z 值为 0
int r = {0}; // 使用等号和大括号来初始化 r 值为 0

以上这几种写法,本质上有什么区别?这些初始化语法都做到了相同的目的,但作为一个高级语言,这种混乱多变的用法,事实上并不讨喜。于是 C++ 11 引入了统一初始化语法,或者书中被称为大括号初始化语法。

大括号初始化除了能初始化一个单独的值,还可以直接初始化容器:

1
std::vector<int> v{1, 2, 3}; // 初始化 v 中存放 3 个元素:1,2,3

小括号初始化和等号初始化,在一些场合不能使用,但都可以替换成大括号初始化语法,大括号初始化语法适用于所有初始化的场景。

另外,大括号初始化的方式还会禁止不同类型的隐式窄化类型转换,比如,以下代码无法通过编译:

1
2
3
double x, y; 
int s{x + y}; // 编译失败
int s = x + y; // 编译成功

这避免了一些潜在的精度损失问题。

最后,大括号初始化语法解决了 C++ 的解析语法问题。
讲一下这个问题。解析语法的一个示例为:

1
Widget w();

单纯看这个代码,你(和编译器)都不能判断出 w 到底是一个 Widget 的对象(调用默认构造函数),还是一个返回 Widget 的函数声明。编译器优先会将其解析为一个函数声明,具体要取决于上下文。

大括号初始化语法就直接将两者区分开来了。

1
Widget w{}; // 一定是初始化 Widget 对象,而不是函数声明

大括号初始化的缺点

然而,好消息总会伴随着坏消息。大括号初始化语法也有一些不适合使用的场景。

一个常见的问题就是它和初始化列表之间的纠葛。

如果一个类型的构造函数中,没有提供任何传入初始化列表作为参数的版本,那么一切都是正常的,但反之,选择调用构造函数时,编译器就会非常强烈地选择使用传入初始化列表作为参数的构造函数版本。如下代码:

1
2
3
4
5
6
7
8
9
class Widget { 
public:
Widget(int i, bool b);
Widget(std::initializer_list<double> d); // 这是那个很敏感的版本
operator float() const; // 提供强制转换为 float 的行为
};

Widget w1(10, true); // 如果使用小括号来初始化,自然调用了第一个构造函数
Widget w2{10, true}; // 如果使用大括号来初始化,则一定调用第二个构造函数

如代码中 w2 的初始化,编译器会强制使用初始化列表作为参数的构造函数,即使它需要额外做将 int 和 bool 转换为 double 的动作。这就是为什么说会 “非常强烈地”。

即使是编译器默认提供的构造函数,比如上例中的默认复制构造函数,也难逃意外。

1
2
3
Widget w0; 
Widget w1(w0); // 如果使用小括号来初始化,调用了默认复制构造函数
Widget w2{w0}; // 如果使用大括号来初始化,依然调用的是那个敏感的版本!

编译器还是会对 w2 调用带有初始化列表作为参数的构造函数,它会将 w0 这个 Widget 类型转换为float,之后转换为 double,最后使用那个敏感的版本来构造 w2

那么,在上边这些问题之后,以下代码声明,调用的是什么构造函数呢?

1
2
3
4
5
Widget w1;   // 调用无参构造函数 
Widget w2{}; // 虽然使用了大括号,但实际上调用了无参默认构造函数
Widget w3(); // 又是解析语法问题,这是函数声明
Widget w4({}); // 调用了带初始化列表作为参数的构造函数,初始化列表为空
Widget w5{{}}; // 和 w4 一样

上边的内容可能比较太 “语言律师” 了,但它在实际工程中,确确实实会带来一些潜在的意外。比如:

1
2
std::vector<int> v1(10, 20); // 调用了 vector 的一个构造函数,创建了包含有 10 个元素,元素值都是 20 的 vector 对象 
std::vector<int> v2{10, 20}; // 调用了 vector 中带有初始化列表作为参数的构造函数,创建了包含 2 个元素,值分别是 10 和 20 的 vector 对象

事实上我就在这里犯过错。

作为类的实现者,最好能避免用户犯这种错误,让用户使用类型时,不用在意它应该怎么写,才能调用到哪个构造函数。C++ STL 中的这种设计,事实上是个败笔,当然,根源还是 C++ 语法的问题。

陷阱:区分一个类型使用小括号来传入多个值,和用大括号来传入多个值的区别,尤其是标准库类型。

事实上,在工程实践中,很难快速意识到这种问题,比如说,最一开始的实现中,没有添加带有初始化列表的构造函数,之后也在很远的地方,正常使用大括号初始化语法来初始化类的对象(它会调用到构造函数),很久之后,我们又需要给类中添加带有初始化列表的构造函数版本,添加之后,发现测试用例挂了(感谢你提前编写了测试用例吧)。

陷阱:在为自定义类添加带有初始化列表作为参数的构造函数时,一定要小心。

现在,关于现代 C++ 中,是否建议使用大括号语法来初始化对象,依然是激烈争论的议题。两者各有优缺点。对于我们,最重要的是,理解上述的这些问题,并在使用大括号初始化语法和带有初始化列表的构造函数时,多留个心眼

对于模板的实现者来说,这个问题更为头疼,因为模板的实现者无法决定用户会使用小括号还是大括号,从而便无法决定模板中的两种不同实例化,应该怎么做到统一实现。

条款 8:优先选用 nullptr

一条耳熟能详的 C++11 建议,使用 nullptr 替代 NULL 或 0 来作为空指针类型。

原因是 0 可能被解析为 int,而 NULL 在大多数库中的实现也只是宏定义到 0。nullptr 不具备整形类型,当然,它也不是某一种类型的指针,它的类型是 std::nullptr_t,它的能力是提供了可以转换为任意指针类型的类型转换操作。

这一条没有什么副作用,所以大多数程序员都已经无缝切换。

条款 9:优先选用别名声明

C++ 11 标准中另一个实用的改进,允许我们使用 using 关键字来声明一些自定义类型,也就是类型别名,替代传统 C/C++ 中的 typedef 语法。

替换并不只是让代码更清晰,毕竟并没有那么多场合需要声明很复杂的类型别名,但有一些是 typedef 做不到的。

一个例子是类型别名可以模板化。举例来说:

1
2
3
4
5
// 这是一个存放多个类型及类型带有的自定义分配器的列表 
template<typename T> using MyAllocList = std::list<T, MyAlloc<T>>;

// 应用代码,声明一个 lw 的对象
MyAllocList<Widget> lw;

如果使用 typedef,因为不能声明模板别名,只能编写一个自定义结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<typename T> 
sturct MyAllocList {
typedef std::list<T, MyAlloc<T>> type;
};

// 应用代码 a
MyALlocList<Widget1>::type lw; // 不能省略 ::type

// 应用代码 b
// 如果想在模板类型定义中使用这个自定义类型
template<typename T>
class Widget2 {
typename MyAllocList<T>::type list; // 不能省略 typename 和 ::type
};

编写的代码就要复杂一些,应用代码 b 中的用法,typename 不能省略,因为编译器需要知道后边的 MyAllocList<T>::type 是一个类型,而不是其他什么东西(因为从语法上来看,它仅仅只是对一个模板类内静态成员的引用)。

类型特征相关的应用

也许你会认为,这种场景太少见了,然而,在库实现中,这种用法却到处都是,C++ 11 中的类型特征,就是采用这种原始的方式实现的,你会在各种地方看到诸如 std::remove_const<T>::type 这种以 ::type 结尾的类型修饰词转换操作。如果这些场景下的应用代码都使用 typedef,那么将提高代码的复杂性。

当然,在 C++11 的库实现中,还是大量使用了 typedef 这种用法来定义类型特征,书中没有解释原因,不过,using 的好处在未来被人重视起来,C++14 开始,都添加了对应的类型别名版本,比如:

1
2
std::remove_const<T>::type    // C++11 中将 const T 类型转换为 T 
std::remove_const_t<T> // C++14 中的版本,它们都会带有一个 _t 后缀来替代 ::type

作者建议,无条件地优先使用 using,即使你使用的是 C++11,也去自己实现用类型别名来取代 typedef 实现类型特征:

1
template <typename T> using remove_const_t = typename remove_const<T>::type;

条款 10:优先选用枚举类

C++ 11 中的另一个新引入的语法特征,使用枚举类来取代枚举。在绝大多数场景下,你都应该这样做。下文把 enum 定义的类型叫做枚举,把 enum class 定义的类型叫做枚举类。

这样替代的好处

我们知道,枚举是不限定作用域的,也就是说,通过枚举定义的类型,其作用域与定义枚举的作用域一致,而不是限定在枚举类型本身,这导致枚举类型的名字污染以及潜在的类型转换错误。而枚举类可以做到这一点。这带来很多好处,我相信不需要把书里的例子拿出来复述一遍了。

另外一个好处是,枚举类可以做前置声明(在表面上看):

1
2
enum Color; // 预先前置声明枚举,后边再定义,编译会出错 
enum class Color; // 预先前置声明枚举类,可以编译

究其原因,并不是语法上不允许,而是编译器需要在前置声明时,为枚举(或枚举类)选择一个默认的底层实现类型,而枚举没有默认的底层实现类型,所以编译器无法预先分配前置声明的类型。

所以,在 Pre-C++ 11,也可以通过手动指定枚举类型来实现将枚举做前置声明:

1
enum Color: std::uint8_t;

枚举类不需要手动指定,就可以前置声明,因为枚举类有默认的底层实现类型:int。所以,这个好处是在不需要指定底层类型时,做前置声明。

枚举仍然有用的地方

书中提到了一个枚举可能的用途。因为枚举可以做隐式类型转换,而枚举类不可以,所以在一些明确需要编译器完成隐式类型转换的场合,枚举就有用了。比如说 std::tuple 下的 std::get<> 操作,后者会按指定的静态下标来获取元组中的元素。

考虑以下代码:

1
2
3
4
5
using UserInfo = std::tuple<std::string, std::string, std::size_t>; 
UserInfo info;

// 获取元素最常见的写法是
auto val = std::get<1>(info); // 取下标 1 的元素

但这种写法,从调用方来看,很难直观地看到获取的 val 是什么东西。
所以我们习惯用枚举来作为下标,因为枚举定义的默认值是从 0 开始的整形,刚好可以用来做下标值:

1
2
3
4
5
enum UserInfoFields { uiName, uiEmail, uiReputation }; 
UserInfo info;

// 使用枚举作为下标获取元素
auto val = std::get<uiEmail>(info); // 明显看出是获取 Email 域

然而,如果使用的是枚举类,则代码会冗长一些:

1
2
3
4
5
6
enum class UserInfoFields { uiName, uiEmail, uiReputation }; 
UserInfo info;

// 使用枚举类作为下标获取元素
// 因为枚举类不能做隐式类型转换,所以需要显式完成
auto val = std::get<static_cast<std::size_t>(UserInfoFields::uiEmail)>(info);

其他办法也不会比它更简单。

当然,在我看来,这个遗留问题,更应该拷问下,是不是 std::get<> 的设计有问题,如果 C++ 可以提供 info.uiEmail 这种更便捷的语法,这里的问题便不攻自破了。

条款 11:优先选用 delete 来删除函数

在 C++11 之前,如果我们需要删除类内的一些类成员函数,比如编译器自动生成的那些构造函数,做法是将这些函数显式声明出来,并放在 private 区中,同时不去实现它。

这样,如果类外部的对象调用这些被删除的函数,则编译器会因为这些函数位于 private 而阻止调用,如果类内调用这些被删除的函数,则编译器会因为这些函数没有被定义而报错。

在 C++11 中,无条件将这种做法替换为使用 delete 关键字来标记,也就是在类成员函数声明的末尾添加 = delete; 语法。编译器会保证这些函数不允许被实现和调用。

delete 的第一个优点便是简单,比之前的做法少写一点代码。但其另外一个无法被取代的优点是,它可以用来修饰一个普通函数(类外定义的函数)。举例来说:

1
2
3
4
5
6
7
// 我们有一个函数 
bool isLucky(int number);

// 因为 C++ 会对类型做隐式变换,所以以下调用,都可以通过编译
isLucky('a');
isLucky(true);
isLucky(3.5);

如果我们不允许一些特殊的类型转换,就需要利用 delete 来删除一些 “重载版本”:

1
2
3
4
5
6
7
8
bool isLucky(char) = delete; 
bool isLucky(bool) = delete;
bool isLucky(double) = delete;

// 以下调用会报错
isLucky('a'); // 错误
isLucky(true); // 错误
isLucky(3.5); // 错误

另外,delete 还可以删除那些不希望被模板实例化的模板类型:

1
2
3
4
template<typename T> void processPointer(T* ptr); 

// 下边这种实例化被删除
template<> void processPointer<void *>(void*) = delete;

值得一提的是,如果模板函数是类内的成员函数,则无法通过改到 private 中来删除实例化,只能通过 delete 来删除。所以,private 的方案也并不总是能应用在类内成员函数的删除。

条款 12:留意使用 override 来声明需要改写的成员函数

首先需要明确函数的 改写 和 重载 的区别。重载可以实现多个函数名称相同,但函数参数类型不同的一系列函数,从而方便调用使用;而改写是派生类对象中,实现和基类中 相同 的虚函数,从而可以让多态行为生效(有时也叫做 覆写 或 重写)。

上文中我高亮了 相同 两个字,在 C++ 标准中,需要满足一定的约束,才能实现改写的目的。
在 C++98 中,已经提出的约束有:

  • 基类中的函数必须是虚函数。
  • 基类和派生类中的函数必须同名(析构函数除外)。
  • 基类和派生类中的函数参数类型必须完全一样。
  • 基类和派生类中的函数常量性必须完全一样。
  • 基类和派生类中的函数返回值类型和异常规格必须能够兼容。

在 C++11 中,新增了一条:

  • 基类和派生类中的函数需要使用相同的引用修饰词。

陷阱:C++11 中,需要改写的成员函数,需要保证具有相同的引用性质。

有关于引用修饰词,书中也在该小节予以大篇幅的介绍。引用修饰词是对成员函数的修饰,来标记该成员函数应该在对象是左值引用还是右值引用时被调用,因为有些场景中,我们希望调用左值引用对象的成员函数与调用右值引用对象的成员函数时,采用不同的实现策略。

我个人认为这部分内容和条款本身没有直接联系,所以不再做展开。

回到条款中,如果希望改写类成员函数,则无条件建议在派生类中,对要改写的函数使用 override 来修饰。编译器会对这种修饰词做检查,排查出任何与成员函数改写相关的意外、漏写或设计错误。

另外,C++11 还提供了另一个关键字 final,和 override 刚好相反,被修饰为 final 的成员函数,将拒绝被其后边继承的派生类中改写。推荐在编写类层次结构时,总是留意去使用这两个关键字,它们可能会帮你大忙。

条款 13:优先选用 const_iterator 代替 iterator

const_iterator 用于指定指向带有 const 属性的 iterator 类型,所以它所指向的值,内容不可修改。建议在可能的情况下,使用 const_iterator 代替 iterator

C++98 中 const_iterator 的实现不完整,所以若想在 C++98 中使用,需要做一些额外的约束。C++ 98 中没有提供 cbegin() 和 cend() 等操作,所以需要这样使用:

1
2
3
4
5
std::vector<int> values; 
auto ConstIterator =
std::find( static_cast<std::vector<int>::const_iterator>(values.begin()),
static_cast<std::vector<int>::const_iterator>(values.end()),
100);

不需要特别了解这种用法,现在使用 C++98 的场合并不多了,即使有那种环境,使用迭代器而不是指针的场合就更少了。

在 C++11 中,标准的写法变成了调用 values.cbegin() 和 values.cend(),不需要我多举例子了。

讨论另一个话题。现代 C++ 中,除了为大多数标准库容器提供 cbegin() 和 cend() 接口之外,还提供了非成员函数版本的 cbegin() 和 cend(),那么,什么时候使用成员函数版本,什么时候使用非成员函数版本?或者说,非成员函数版本的意义是什么?

答案是,在一些容器中,或类似容器的数据结构,如数组中,是没有成员函数版本的迭代器获取接口的,如果在泛型的实现中,调用成员函数版本的迭代器获取,便会导致编译错误,而使用非成员函数的版本,则可以充分兼容这种情况。

举例来说:

1
2
3
4
5
6
7
8
9
// 我们实现一个泛型的容器操作,可以接受各种不同的容器类型 
template<typename C, typename V> // C 是容器类型,V 是容器中元素类型
void findAndInsert(C& container, const V& targetVal, const V& insertVal) {
using std::cbegin;
using std::cend; // 将非成员函数版本的 cbegin 和 cend 导入命名空间

auto it = std::find(cbegin(container), cend(container), targetVal);
container.insert(it, insertVal);
}

很明显可以看出来,如果容器 C 没有内置的成员函数版本 cbegin() 和 cend(),这个实现也是可以工作的。

谬误:非成员函数版本的 cbegin()  cend() 与成员函数版本的 cbegin()  cend(),在大多数情况下都一样,但建议在可能的情况下,还是使用非成员函数版本。

陷阱:不过需要注意,非成员函数版本的 cbegin()  cend() 在 C++14 中才提供,而在 C++11 中不存在。所以,将你的项目中构建参数里用 --std=c++14 代替 --std=c++11 吧。

如果你的项目中不得不使用 C++11,那么实现一个非成员函数版本的 cbegin 也非常方便:

1
2
3
4
template<typename C> 
auto cbegin(const C& container) -> decltype(std::begin(container)) {
return std::begin(container);
}

实现方案非常取巧,直觉上,在实现体中,应该会写成:return container.cbegin();,但因为有一些容器,它并没有成员函数版本的 cbegin(),所以这样不可行。上边的实现策略,依据是:如果非成员函数版本的 begin() 中传入的是一个 const 类型容器,那么它返回的是 const_iterator,所以这个实现的重点在模板函数的参数声明上。这种实现,对数组也一样适用。

条款 14:只要函数不发射异常,就使用 noexcept 声明

这一条聊一下异常,C++ 中,异常的使用这部分话题,是高频被讨论到的话题。然而本条款更关注的是,如果在使用异常时,让编译器尽可能简化对异常代码的处理。

在实现接口函数时,如果明知道一个函数不会抛出异常,但没有使用 noexcept 声明,那这就是接口设计缺陷。使用 noexcept,可以让函数以及调用函数的其他函数,能够生成更好的代码。

在 C++11 之前,我们使用 throw() 来修饰一个函数会抛出异常,但这种修饰,编译器能做的优化并不多,所以不建议再使用。

使用 noexcept 修饰后,编译器就会做一些更激进的优化。讨论一个复杂的例子。C++11 中的移动语义,由于需要考虑异常,所以,只有在被 noexcept 修饰后,才会说明移动的过程不会发生异常(也就是说,不会因为中途出现异常,导致了数据被破坏),编译器才能妥善放心地使用移动操作。

swap() 函数是 C++ 标准库中一个很常见的操作,然而,它有多种不同的实现,最容易理解的就是,我们是应该采用复制操作来交换数据,还是用移动操作来交换数据,移动操作显然更高效,但其前提便是,swap(a, b) 中的两个输入,都必须是 noexcept 的。
一个典型的 swap 函数声明为:

1
void swap(pair &p) noexcept(noexcept(swap(first, p.first)) && noexcept(swap(second, p.second)));

末尾的声明称为 条件式 noexcept 声明,满足 swap 是否可以为 noexcept 的前提是,传入的每个输入,都必须是 noexcept 的。

所以可以看得出来,如果想保证函数调用时,尽可能会通过 noexcept 修饰,就必须让其相关函数都是 noexcept 的。

然而,大多数函数其实是中立的,虽然自身不抛出异常,但其内部调用的函数(和更深的调用层次中的函数),可能会抛出异常,所以它也不能使用 noexcept 修饰。

当然,也不能刻意为了 noexcept 而扭曲了函数实现,那就是主次不分了。如果已经为接口函数添加了 noexcept 修饰,但是在将来又删除这个修饰,那么很有可能,在很多调用该函数的其他场景中,造成很多编译错误。

在 C++11 中,所有的析构函数是默认带有 noexcept 属性的,不需要手动添加。

条款 15:尽可能地使用 constexpr

C++11 中引入了一个新的关键字 constexpr,它和 const 的区别用一句话就可以清晰的说明。const 是表示对象的不可变性(后续无法修改该对象),constexpr 是表示对象的常量性(编译期便可求值)。过去,我们对 const 等价于 常量 的错误观念,应该被纠正。

谬误:const 表示的是不可变性,其反义词是 mutable,并不表示常量。constexpr 才表示常量。至于关键字的取名,属于 C++ 特色了,取名总是很难与其实际意义相契合。

所以,constexpr 的对象,一定是 const 的(常量是不可变的),但反过来不成立(不可变的变量可能是受约束的变量)。

由于 constexpr 修饰的是常量,所以编译器可以在编译期对这种对象做求值,体现出来的便是,会把运行时的一些开销,移动到编译时完成。

如果使用 constexpr 修饰函数,并不意味着这个函数一定可以在编译期求值,它只是表示,当函数的参数都是常量时,编译器才会对这个函数做编译期求值,否则则和正常函数没有区别。

谬误:使用 constexpr 修饰的函数,只有当其参数都是常量时,才会做编译期求值。

在 C++11 中,constexpr 修饰函数还有一些受限,比如只能有一个 return,也不能存在条件语句,这是为了让编译期能更好的完成求值计算。不过,在 C++14 中,这种没必要的约束被解除了。所以,有条件时,尽量用 C++14 代替 C++11。

使用 constexpr 修饰类

如果用 constexpr 修饰了类的构造函数,那么很可能会让类定义的对象,也成为一个常量。这很神奇,但确实存在,如书中示例:

1
2
3
4
5
6
7
8
9
10
11
class Point { 
public:
constexpr Point(double xVal = 0, double yVal = 0) noexcept : x(xVal), y(yVal) {}
constexpr double xValue() const noexcept { return x; }
constexpr double yValue() const noexcept { return y; }

private:
double x, y;
};

constexpr Point p1(10.1, 20.2); // 编译期求值的常量对象,值放在只读内存中

因为它是常量对象,所以也可以用在任何使用 constexpr 修饰的常量函数中:

1
2
3
4
5
constexpr Point midPoint(const Point &p1, const Point &p2) noexcept {
return { (p1.xValue() + p2.xValue()) / 2, (p1.yValue() + p2.yValue()) / 2};
}

constexpr auto mid = midPoint(p1, p2);

这意味着,mid 这个对象,其构造过程涉及到了构造函数,访问器,以及非成员函数的调用,但它依然是一个常量,可以放在只读内存中。

不过,如果类中的成员函数修改了对象的成员数据,则在 C++11 中,不能修饰为 constepxr,因为我们理应认为,这种函数没有不可变性。不过,在 C++14 中,这种约束也被去掉了。所以,在 C++14 中,可以编写:

1
2
3
4
5
class Point { 
public:
constexpr setX(double newX) noexcept { x = newX; }
constexpr setY(double newY) noexcept { y = newY; }
};

使用 constexpr 没有副作用,所以在可以使用它的时候,还是尽量去使用它。但如果你是在编写接口函数,在添加 constexpr 修饰时,一定想清楚,不要在将来又想要删除它,那时候,可能和 noexcept 一样,会遇到很多编译错误。

条款 16:小心 const 成员函数的线程安全性

从条款标题上看,似乎 const 修饰的成员函数,应该不可能存在线程安全性,因为 const 修饰后,它们是只读操作,不会对外部数据产生修改和破坏性的影响。

然而,C++ 还提供了 mutable 这个关键字,它自相矛盾地设计了这样一种机制:在类的 const 成员函数中,允许修改被 mutable 修饰的成员变量。比如:

1
2
3
4
5
6
7
8
9
class Widget { 
public:
void count() const {
// val1++; // 非法,编译器报错,const 函数中无法修改
val2++; // 合法
}
private:
int val1; mutable int val2;
};

看起来很不合理,对吧?

因为这个机制,所以 const 成员函数,并不完全可以认为是只读的,从而,在这种场合下,如果这个成员函数刚好位于多线程上下文中,就很有可能出现常见的并发问题,就如同那些普通的并发场景下的问题一样的问题。

陷阱:const 成员函数,不一定是完全不会对外界产生影响的函数。留意那些 mutable 修饰的成员变量。

解决方案,书中列举了很多,但我认为没必要展开,因为它脱离了本条款的重点,加锁、原子操作等常见的方案都可以处理,只要把那个看起来是 const 的成员函数,当作普通函数处理就好了。

为什么 C++ 要有这种自相矛盾的设计

mutable 很早就有了,事实上它不比 const 来的更晚。这种设计的场合,通常发生在对接口函数的维护中。

假设你有一个已经公开的接口类,其中的 const 成员函数接口已经被很多地方引用,如果此时,因为一些与 const 本身要保护的主体不太相关的需求,需要破坏该接口的 const 属性,应该怎么办?使用 mutable 便是一个办法。

举个具体的例子,如果有一个控制数据库的类,其中有一个 const 成员函数是查询数据库信息。但现在有一个新需求,要求在每次查询数据库信息时,将查询日志写入一个位置,在不删除 const 属性的前提下,一种解决办法,便是把日志存放对象修饰为 mutable,这样就可以在 const 成员函数中写入日志了。写入日志本身是非 const 的操作,但它不违背这个成员函数添加 const 的初衷,即保证该函数不会改变数据库数据。

所以,在这些场合下,如果引入多线程,便可能产生奇怪的问题,这些问题非常难定位。这就是这一条款要表达的内容。

条款 17:注意特殊成员函数的隐式生成规则

特殊成员函数包括默认构造函数、析构函数、复制构造函数和复制赋值运算符函数,在 C++11 之后,又增加了移动构造函数和移动赋值运算符函数。编译器可能为一些类自动生成其中部分或全部特殊成员函数。本条规则需要留意,它们的隐式行为可能导致程序错误或性能衰退。

编译器隐式生成特殊构造函数的规则是:

  • 默认构造函数:当类中不包含任何用户声明的构造函数时才生成。
  • 析构函数:当类中不包含用户声明的析构函数时才生成。如果基类中的析构函数是 virtual 的,那么此时生成的析构函数,也是 virtual 的。C++11 引入了 noexcept 关键字,生成的析构函数也默认带有 noexcept 属性。
  • 复制构造函数:只有当类中不包含用户自定义的复制构造函数、移动构造函数和移动复制运算符时,才会自动生成。注意,如果类中包含了用户自定义的复制赋值运算符,复制构造函数仍然会自动生成,这一点和移动操作函数不同。
  • 复制赋值运算符:和复制构造函数相同。
  • 移动构造函数:只有当类中不包含用户自定义的复制操作、移动操作和析构函数时,才会自动生成。注意,和复制操作不同,移动构造函数和移动赋值运算符是互斥的,其中一个存在,另一个不会自动生成,而复制操作没有这个规则。
  • 移动赋值运算符:和移动构造函数相同。

C++ 本有意将两者统一起来,避免上述加粗内容的歧义,但考虑到代码兼容的问题,此条只能成为建议,而不是规则。书中称为 “大三律(Rule of Three)”:如果类中声明了复制构造函数、复制赋值运算符或析构函数中的任何一个,就应该同时声明这三个。

陷阱:复制构造函数和复制赋值运算符的隐式生成是独立的;移动构造函数和移动赋值运算符的隐式生成是互斥的。

建议:大三律规则,如果类中声明了复制构造函数、复制赋值运算符或析构函数中的任何一个,那么就应该同时声明这三个。

大三律的理由是,如果一个类中涉及到了这三种特殊成员函数中的任何一个,说明这个类会做资源管理,如果它会做资源管理,那么这三个就应该同时存在。进一步地,如果用户自定义了这三个之中任何一个成员函数,那么其他成员函数就也应该交由用户自定义,而不是自动生成,但 C++98 中没有对此做约束。

在 C++11 中,成功让大三律能自动推广到移动操作,使之成为规则而不仅仅是建议,即其中之一存在自定义版本时,就不会自动生成其他几个成员函数。

需要留意,移动构造函数和移动赋值运算符函数,不一定真的会让对象以移动的方式构造或赋值,能否移动取决于这个对象的所有组成部分是否可以移动。如果不能,移动构造函数和移动赋值运算符函数会转而以复制的方式来执行。

原理上来说,它们使用了 std::move() 操作,而这个操作,实际做的只是尝试性把左值引用类型转换为右值引用。第五章的条款会展开这个话题。

使用 =default 可以强行让编译器生成默认特殊成员函数,这样将隐式生成改为显式生成,让代码更容易理解,也可以避免意外地隐式生成规则生效或失效导致的一系列问题。

比如说下边的示例:

1
2
3
4
5
6
7
class StringTable { 
public:
StringTable() {} ... // 没有实现其他复制、移动和析构函数

private:
std::map<int, std::string> values; // 沉重的负载
};

如果我们在未来的某一天,或者是其他开发人员,对此代码做了一些修改。比如想要在资源申请和释放时,添加日志记录,这需求非常常见。为了释放时记录日志,我们不得不自定义析构函数,但我们忘记了大三律规则:

1
2
3
4
5
6
7
8
class StringTable { 
public:
StringTable() { makeLog("Create"); } ... // 没有实现其他复制、移动操作
~StringTable() { makeLog("Destroy"); } // 增加了自定义的析构函数

private:
std::map<int, std::string> values;
};

这种改动似乎合理,但却引入了很严重的性能问题。添加自定义析构函数后,会阻止移动操作的隐式生成,但不会阻止复制操作的隐式生成。所以,第一个版本中,对象可以通过移动操作来高效移动,但添加日志后,我们发现对象只能用复制操作了,因为负载很大,所以引入了严重的性能下降。

是不是很 amazing 呀!一个简单的改进:

1
2
3
4
5
6
7
8
9
10
11
12
class StringTable { 
public:
StringTable() { makeLog("Create"); }
StringTable(const StringTable &) = default; // 显式要求编译器生成默认版本
StringTable &operator=(const StringTable &) = default;
StringTable(const StringTable &&) = default;
StringTable &operator=(const StringTable &&) = default;
~StringTable() { makeLog("Destroy"); }

private:
std::map<int, std::string> values;
};

C++ 的进化和 C++ 工程师的成长,就是在这样一遍又一遍的摸索和爬坑的过程中完成的。

本系列的其他文章:

  1. Effective Modern C++ 读书笔记:类型推导
  2. Effective Modern C++ 读书笔记:auto
  3. Effective Modern C++ 读书笔记:转向现代C++
  4. Effective Modern C++ 读书笔记:智能指针
  5. Effective Modern C++:右值引用、移动语义和完美转发
  6. Effective Modern C++:lambda 表达式
  7. Effective Modern C++:并发 API
  8. Effective Modern C++:微调

本文同步发布在知乎账号下:https://zhuanlan.zhihu.com/p/1939768367813727100