《EffectiveModernC++》第三章:移步现代C++

第三章 移步现代C++

item7:区别使用(){}创建对象

1
2
3
4
5
int x(0);               //使用小括号初始化

int y = 0; //使用"="初始化

int z{ 0 }; //使用花括号初始化

在很多情况下,你可以使用”=”和花括号的组合:

1
int z = { 0 };          //使用"="和花括号

在这个条款的剩下部分,我通常会忽略”=”和花括号组合初始化的语法,因为C++通常把它视作和只有花括号一样。

区别赋值运算符和初始化就非常重要了,因为这可能包含不同的函数调用:

1
2
3
4
5
Widget w1;              //调用默认构造函数

Widget w2 = w1; //不是赋值运算,调用拷贝构造函数,看调用时机区分

w1 = w2; //是赋值运算,调用拷贝赋值运算符(copy operator=)

括号初始化让你可以表达以前表达不出的东西。使用花括号,指定一个容器的元素变得很容易:

1
std::vector<int> v{ 1, 3, 5 };  //v初始内容为1,3,5

只要不包含std::initializer_list形参,那么花括号初始化和小括号初始化都会产生一样的结果:

1
2
3
4
5
6
7
8
9
10
class Widget { 
public:
Widget(int i, bool b); //构造函数未声明
Widget(int i, double d); //std::initializer_list形参

};
Widget w1(10, true); //调用第一个构造函数
Widget w2{10, true}; //也调用第一个构造函数
Widget w3(10, 5.0); //调用第二个构造函数
Widget w4{10, 5.0}; //也调用第二个构造函数

然而,如果有一个或者多个构造函数的声明一个std::initializer_list形参,使用括号初始化语法的调用更倾向于适用std::initializer_list重载函数。而且只要某个使用括号表达式的调用能适用接受std::initializer_list的构造函数,编译器就会使用它。如果上面的Widget类有一个std::initializer_list<long double>构造函数并被传入实参,就像这样:

1
2
3
4
5
6
7
class Widget { 
public:
Widget(int i, bool b); //同上
Widget(int i, double d); //同上
Widget(std::initializer_list<long double> il); //新添加的

};

w2w4将会使用新添加的构造函数构造,即使另一个非std::initializer_list构造函数对于实参是更好的选择:

1
2
3
4
5
6
7
8
9
10
11
12
13
Widget w1(10, true);    //使用小括号初始化,同之前一样
//调用第一个构造函数

Widget w2{10, true}; //使用花括号初始化,但是现在
//调用std::initializer_list版本构造函数
//(10 和 true 转化为long double)

Widget w3(10, 5.0); //使用小括号初始化,同之前一样
//调用第二个构造函数

Widget w4{10, 5.0}; //使用花括号初始化,但是现在
//调用std::initializer_list版本构造函数
//(10 和 5.0 转化为long double)

甚至普通的构造函数和移动构造函数都会被std::initializer_list构造函数劫持:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Widget { 
public:
Widget(int i, bool b); //同之前一样
Widget(int i, double d); //同之前一样
Widget(std::initializer_list<long double> il); //同之前一样
operator float() const; //转换为float

};

Widget w5(w4); //使用小括号,调用拷贝构造函数

Widget w6{w4}; //使用花括号,调用std::initializer_list构造
//函数(w4转换为float,float转换为double)

Widget w7(std::move(w4)); //使用小括号,调用移动构造函数

Widget w8{std::move(w4)}; //使用花括号,调用std::initializer_list构造
//函数(与w6相同原因)

编译器热衷于把括号初始化与使std::initializer_list构造函数匹配了,尽管最佳匹配std::initializer_list构造函数不能被调用也会凑上去。比如:

1
2
3
4
5
6
7
8
9
class Widget { 
public:
Widget(int i, bool b); //同之前一样
Widget(int i, double d); //同之前一样
Widget(std::initializer_list<bool> il); //现在元素类型为bool
//没有隐式转换函数
};

Widget w{10, 5.0}; //错误!要求变窄转换

这里,编译器会直接忽略前面两个构造函数(其中第二个提供了所有实参类型的最佳匹配),然后尝试调用std::initializer_list<bool>构造函数。调用这个函数将会把int(10)double(5.0)转换为bool,由于会产生变窄转换(bool不能准确表示其中任何一个值),括号初始化拒绝变窄转换,所以这个调用无效,代码无法通过编译。

只有当没办法把括号初始化中实参的类型转化为std::initializer_list时,编译器才会回到正常的函数决议流程中。比如我们在构造函数中用std::initializer_list<std::string>代替std::initializer_list<bool>,这时非std::initializer_list构造函数将再次成为函数决议的候选者,因为没有办法把intbool转换为std::string:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Widget { 
public:
Widget(int i, bool b); //同之前一样
Widget(int i, double d); //同之前一样
//现在std::initializer_list元素类型为std::string
Widget(std::initializer_list<std::string> il);
//没有隐式转换函数
};

Widget w1(10, true); // 使用小括号初始化,调用第一个构造函数
Widget w2{10, true}; // 使用花括号初始化,现在调用第一个构造函数
Widget w3(10, 5.0); // 使用小括号初始化,调用第二个构造函数
Widget w4{10, 5.0}; // 使用花括号初始化,现在调用第二个构造函数

还要记住一个有趣的edge case:

假如你使用的花括号初始化是空集,并且你欲构建的对象有默认构造函数,也有std::initializer_list构造函数。你的空的花括号意味着什么?如果它们意味着没有实参,就该使用默认构造函数,但如果它意味着一个空的std::initializer_list,就该调用std::initializer_list构造函数。所以该调用哪儿个呢?

最终会调用默认构造函数。空的花括号意味着没有实参,不是一个空的std::initializer_list

1
2
3
4
5
6
7
8
9
10
11
class Widget { 
public:
Widget(); //默认构造函数
Widget(std::initializer_list<int> il); //std::initializer_list构造函数

//没有隐式转换函数
};

Widget w1; //调用默认构造函数
Widget w2{}; //也调用默认构造函数
Widget w3(); //最令人头疼的解析!声明一个函数

如果你用空std::initializer来调用std::initializer_list构造函数,你就得创建一个空花括号作为函数实参——通过把空花括号放在小括号或者另一花括号内来界定你想传递的东西。这个简直太过于trick反人类了,建议不要写这样的代码。

1
2
Widget w4({});                  //使用空花括号列表调用std::initializer_list构造函数
Widget w5{{}}; //同上

vector利用了这个{},()区分了一些内容:

1
2
3
4
5
6
std::vector<int> v1(10, 20);    //使用非std::initializer_list构造函数
//创建一个包含10个元素的std::vector,
//所有的元素的值都是20
std::vector<int> v2{10, 20}; //使用std::initializer_list构造函数
//创建包含两个元素的std::vector,
//元素的值为10和20

如果你是一个模板的作者,花括号和小括号创建对象就更麻烦了。通常不能知晓哪个会被使用。举个例子,假如你想创建一个接受任意数量的参数,然后用它们创建一个对象。使用可变参数模板(variadic template)可以非常简单的解决:

1
2
3
4
5
6
7
template<typename T,            //要创建的对象类型
typename... Ts> //要使用的实参的类型
void doSomeWork(Ts&&... params)
{
create local T object from params...

}

在现实中我们有两种方式实现这个伪代码(关于std::forward请参见Item25):

1
2
T localObject(std::forward<Ts>(params)...);             //使用小括号
T localObject{std::forward<Ts>(params)...}; //使用花括号

考虑这样的调用代码:

1
2
3
std::vector<int> v; 

doSomeWork<std::vector<int>>(10, 20);

如果doSomeWork创建localObject时使用的是小括号,std::vector就会包含10个元素。如果doSomeWork创建localObject时使用的是花括号,std::vector就会包含2个元素。哪个是正确的?doSomeWork的作者不知道,只有调用者知道。

请记住:

  • 括号初始化是最广泛使用的初始化语法,它防止变窄转换,并且对于C++最令人头疼的解析有天生的免疫性
  • 在构造函数重载决议中,括号初始化尽最大可能与std::initializer_list参数匹配,即便其他构造函数看起来是更好的选择
  • 对于数值类型的std::vector来说使用花括号初始化和小括号初始化会造成巨大的不同
  • 在模板类选择使用小括号初始化或使用花括号初始化创建对象是一个挑战。

item8:优先考虑nullptr而非0NULL

在C++98中,对指针类型和整型进行重载意味着可能导致奇怪的事情。如果给下面的重载函数传递0NULL,它们绝不会调用指针版本的重载函数:

1
2
3
4
5
6
7
8
void f(int);        //三个f的重载函数
void f(bool);
void f(void*);

f(0); //调用f(int)而不是f(void*)

f(NULL); //可能不会被编译,一般来说调用f(int),
//绝对不会调用f(void*)

f(NULL)的不确定行为是由NULL的实现不同造成的。如果NULL被定义为0L(指的是0long类型),这个调用就具有二义性,因为从longint的转换或从longbool的转换或0Lvoid*的转换都同样好。

有趣的是源代码表现出的意思(“我使用空指针NULL调用f”)和实际表达出的意思(“我是用整型数据而不是空指针调用f”)是相矛盾的。

nullptr的优点是它不是整型。老实说它也不是一个指针类型,但是你可以把它认为是所有类型的指针。nullptr的真正类型是std::nullptr_t,在一个完美的循环定义以后,std::nullptr_t又被定义为nullptrstd::nullptr_t可以隐式转换为指向任何内置类型的指针,这也是为什么nullptr表现得像所有类型的指针。

使用nullptr调用f将会调用void*版本的重载函数,因为nullptr不能被视作任何整型:

1
f(nullptr);         //调用重载函数f的f(void*)版本

使用nullptr代替0NULL可以避开了那些令人奇怪的函数重载决议。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int    f1(std::shared_ptr<Widget> spw);     //只能被合适的
double f2(std::unique_ptr<Widget> upw); //已锁互斥量
bool f3(Widget* pw); //调用

template<typename FuncType,
typename MuxType,
typename PtrType>
decltype(auto) lockAndCall(FuncType func, //C++14
MuxType& mutex,
PtrType ptr)
{
MuxGuard g(mutex);
return func(ptr);
}

可以写这样的代码调用lockAndCall模板:

1
2
3
4
5
auto result1 = lockAndCall(f1, f1m, 0);         //错误!
...
auto result2 = lockAndCall(f2, f2m, NULL); //错误!
...
auto result3 = lockAndCall(f3, f3m, nullptr); //没问题

前两个情况不能通过编译。在第一个调用中存在的问题是当0被传递给lockAndCall模板,模板类型推导会尝试去推导实参类型,0的类型总是int,所以这就是这次调用lockAndCall实例化出的ptr的类型。不幸的是,这意味着lockAndCallfunc会被int类型的实参调用,这与f1期待的std::shared_ptr<Widget>形参不符。传递0lockAndCall本来想表示空指针,结果f1得到的是和它相差十万八千里的int。把int类型看做std::shared_ptr<Widget>类型给f1自然是一个类型错误。在模板lockAndCall中使用0之所以失败是因为在模板中,传给的是int但实际上函数期待的是一个std::shared_ptr<Widget>

第二个使用NULL调用的分析也是一样的。当NULL被传递给lockAndCall,形参ptr被推导为整型(译注:由于依赖于具体实现所以不一定是整数类型,所以用整型泛指intlong等类型),然后当ptr——一个int或者类似int的类型——传递给f2的时候就会出现类型错误,f2期待的是std::unique_ptr<Widget>

然而,使用nullptr是调用没什么问题。当nullptr传给lockAndCall时,ptr被推导为std::nullptr_t。当ptr被传递给f3的时候,隐式转换使std::nullptr_t转换为Widget因为std::nullptr_t可以隐式转换为任何指针类型。

记住

  • 优先考虑nullptr而非0NULL
  • 避免重载指针和整型

item9:优先考虑别名声明而非typedef

1
2
3
typedef
std::unique_ptr<std::unordered_map<std::string, std::string>>
UPtrMapSS;

typedef是C++98的东西。虽然它可以在C++11中工作,但是C++11也提供了一个别名声明(alias declaration):

1
2
using UPtrMapSS =
std::unique_ptr<std::unordered_map<std::string, std::string>>;

由于这里给出的typedef和别名声明做的都是完全一样的事情,所以using有什么更好的地方吗?

当声明一个函数指针时别名声明更容易理解:

1
2
3
4
5
6
//FP是一个指向函数的指针的同义词,它指向的函数带有
//int和const std::string&形参,不返回任何东西
typedef void (*FP)(int, const std::string&); //typedef

//含义同上
using FP = void (*)(int, const std::string&); //别名声明

当然,两个结构都不是非常让人满意,没有人喜欢花大量的时间处理函数指针类型的别名(译注:指FP)。

别名声明可以被模板化(这种情况下称为别名模板alias templates)但是typedef不能。

1
2
3
4
5
template<typename T>                            //MyAllocList<T>是
using MyAllocList = std::list<T, MyAlloc<T>>; //std::list<T, MyAlloc<T>>
//的同义词

MyAllocList<Widget> lw; //用户代码

使用typedef,你就只能从头开始:

1
2
3
4
5
6
template<typename T>                            //MyAllocList<T>是
struct MyAllocList { //std::list<T, MyAlloc<T>>
typedef std::list<T, MyAlloc<T>> type; //的同义词
};

MyAllocList<Widget>::type lw; //用户代码

更糟糕的是如果你想使用在一个模板内使用typedef声明一个链表对象,而这个对象又使用了模板形参,你就不得不在typedef前面加上typename

1
2
3
4
5
6
template<typename T>
class Widget { //Widget<T>含有一个
private: //MyAllocLIst<T>对象
typename MyAllocList<T>::type list; //作为数据成员

};

这里MyAllocList<T>::type使用了一个类型,这个类型依赖于模板参数T。因此MyAllocList<T>::type是一个依赖类型(dependent type),在C++很多讨人喜欢的规则中的一个提到必须要在依赖类型名前加上typename你可能会疑惑MyAllocList<T>::type有可能不是类型吗?是的,比如下面,type不是一个类型而是一个数据成员,再或者一个static成员变量也是可以::调用到的:

1
2
3
4
5
6
7
8
9
10
11
class Wine { … };

template<> //当T是Wine
class MyAllocList<Wine> { //特化MyAllocList
private:
enum class WineType //参见Item10了解
{ White, Red, Rose }; //"enum class"

WineType type; //在这个类中,type是
//一个数据成员!
};

如果使用别名声明定义一个MyAllocList,就不需要使用typename(同时省略麻烦的“::type”后缀):

1
2
3
4
5
6
7
8
9
template<typename T> 
using MyAllocList = std::list<T, MyAlloc<T>>; //同之前一样

template<typename T>
class Widget {
private:
MyAllocList<T> list; //没有“typename”
//没有“::type”
};

对你来说,MyAllocList<T>(使用了模板别名声明的版本)可能看起来和MyAllocList<T>::type(使用typedef的版本)一样都应该依赖模板参数T,但是你不是编译器。当编译器处理Widget模板时遇到MyAllocList<T>(使用模板别名声明的版本),它们知道MyAllocList<T>是一个类型名,因为MyAllocList是一个别名模板:它一定是一个类型名。因此MyAllocList<T>就是一个非依赖类型non-dependent type),就不需要也不允许使用typename修饰符。

C++11在type traits(类型特性)中给了你一系列工具去实现类型转换,如果要使用这些模板请包含头文件<type_traits>。里面有许许多多type traits,也不全是类型转换的工具,也包含一些可预测接口的工具。给一个你想施加转换的类型T,结果类型就是std::transformation<T>::type,比如:

1
2
3
std::remove_const<T>::type          //从const T中产出T
std::remove_reference<T>::type //从T&和T&&中产出T
std::add_lvalue_reference<T>::type //从T中产出T&

注意类型转换尾部的::type。如果你在一个模板内部将他们施加到类型形参上(实际代码中你也总是这么用),你也需要在它们前面加上typename。至于为什么要这么做是因为这些C++11的type traits是通过在struct内嵌套typedef来实现的。是的,它们使用类型同义词(译注:根据上下文指的是使用typedef的做法)技术实现,而正如我之前所说这比别名声明要差。因为标准委员会没有及时认识到别名声明是更好的选择,所以直到C++14它们才提供了使用别名声明的版本。这些别名声明有一个通用形式:对于C++11的类型转换std::transformation<T>::type在C++14中变成了std::transformation_t。举个例子或许更容易理解:

1
2
3
4
5
6
7
8
std::remove_const<T>::type          //C++11: const T → T 
std::remove_const_t<T> //C++14 等价形式

std::remove_reference<T>::type //C++11: T&/T&& → T
std::remove_reference_t<T> //C++14 等价形式

std::add_lvalue_reference<T>::type //C++11: T → T&
std::add_lvalue_reference_t<T> //C++14 等价形式

C++11的的形式在C++14中也有效,但是我不能理解为什么你要去用它们。就算你没办法使用C++14,使用别名模板也是小儿科。只需要C++11的语言特性,甚至每个小孩都能仿写,对吧?如果你有一份C++14标准,就更简单了,只需要复制粘贴:

1
2
3
4
5
6
7
8
9
template <class T> 
using remove_const_t = typename remove_const<T>::type;

template <class T>
using remove_reference_t = typename remove_reference<T>::type;

template <class T>
using add_lvalue_reference_t =
typename add_lvalue_reference<T>::type;

看见了吧?不能再简单了。

请记住:

  • typedef不支持模板化,但是别名声明支持。
  • 别名模板避免了使用“::type”后缀,而且在模板中使用typedef还需要在前面加上typename
  • C++14提供了C++11所有type traits转换的别名声明版本。

item10:优先考虑限域enum而非未限域enum

通常来说,在花括号中声明一个名字会限制它的作用域在花括号之内。但这对于C++98风格的enum中声明的枚举名(译注:enumerator,连同下文“枚举名”都指enumerator)是不成立的。这些枚举名的名字(译注:enumerator names,连同下文“名字”都指names)属于包含这个enum的作用域,这意味着作用域内不能含有相同名字的其他东西:

1
2
3
4
enum Color { black, white, red };   //black, white, red在
//Color所在的作用域
auto white = false; //错误! white早已在这个作用
//域中声明

这些枚举名的名字泄漏进它们所被定义的enum在的那个作用域,这个事实有一个官方的术语:未限域枚举(unscoped enum)。在C++11中它们有一个相似物,限域枚举(scoped enum),它不会导致枚举名泄漏:

1
2
3
4
5
6
7
8
enum class Color { black, white, red }; //black, white, red
//限制在Color域内
auto white = false; //没问题,域内没有其他“white”

Color c = white; //错误,域中没有枚举名叫white

Color c = Color::white; //没问题
auto c = Color::white; //也没问题(也符合Item5的建议)

因此使用限域enum的第一个好处就是:减少命名空间污染。

除此外还有一个更好的理由:在限域enum中,枚举名是强类型。未限域enum中的枚举名会隐式转换为整型(现在,也可以转换为浮点类型), 这很容易造成歪曲语义的情况出现:

1
2
3
4
5
6
7
8
9
10
11
12
13
enum Color { black, white, red };       //未限域enum

std::vector<std::size_t> //func返回x的质因子
primeFactors(std::size_t x);

Color c = red;


if (c < 14.5) { // Color与double比较 (!)
auto factors = // 计算一个Color的质因子(!)
primeFactors(c);

}

如上 color被隐式转换为了floatsize_t

enum后面写一个class就可以将非限域enum转换为限域enum,接下来就是完全不同的故事展开了。现在不存在任何隐式转换可以将限域enum中的枚举名转化为任何其他类型:

1
2
3
4
5
6
7
8
9
10
11
enum class Color { black, white, red }; //Color现在是限域enum

Color c = Color::red; //和之前一样,只是
... //多了一个域修饰符

if (c < 14.5) { //错误!不能比较
//Color和double
auto factors = //错误!不能向参数为std::size_t
primeFactors(c); //的函数传递Color参数

}

如上隐式转换被禁止了,如果你真的想用限域enum,但是却像做到类型转换,可以这么做,虽然这很奇怪,使用正确的类型转换运算符扭曲类型系统:

1
2
3
4
5
6
if (static_cast<double>(c) < 14.5) {    //奇怪的代码,
//但是有效
auto factors = //有问题,但是
primeFactors(static_cast<std::size_t>(c)); //能通过编译

}

似乎比起非限域enum而言,限域enum有第三个好处,因为限域enum可以被前置声明。也就是说,它们可以不指定枚举名直接声明:

1
2
enum Color;         //错误!
enum class Color; //没问题

其实这是一个误导。在C++11中,非限域enum也可以被前置声明,但是只有在做一些其他工作后才能实现。这些工作来源于一个事实:在C++中所有的enum都有一个由编译器决定的整型的底层类型。对于非限域enum比如Color

1
enum Color { black, white, red };

编译器可能选择char作为底层类型,因为这里只需要表示三个值。

然而,有些enum中的枚举值范围可能会大些,比如:

1
2
3
4
5
6
enum Status { good = 0,
failed = 1,
incomplete = 100,
corrupt = 200,
indeterminate = 0xFFFFFFFF
};

这里值的范围从00xFFFFFFFF。除了在不寻常的机器上(比如一个char至少有32bits的那种),编译器都会选择一个比char大的整型类型来表示Status

为了高效使用内存,编译器通常在确保能包含所有枚举值的前提下为enum选择一个最小的底层类型。在一些情况下,编译器将会优化速度,舍弃大小,这种情况下它可能不会选择最小的底层类型,而是选择对优化大小有帮助的类型。为此,C++98只支持enum定义(所有枚举名全部列出来);enum声明是不被允许的。这使得编译器能在使用之前为每一个enum选择一个底层类型。

不能前置声明enum也是有缺点的。最大的缺点莫过于它可能增加编译依赖。再次考虑Status enum

1
2
3
4
5
6
enum Status { good = 0,
failed = 1,
incomplete = 100,
corrupt = 200,
indeterminate = 0xFFFFFFFF
};

这种enum很有可能用于整个系统,因此系统中每个包含这个头文件的组件都会依赖它。如果引入一个新状态值,

1
2
3
4
5
6
7
enum Status { good = 0,
failed = 1,
incomplete = 100,
corrupt = 200,
audited = 500,
indeterminate = 0xFFFFFFFF
};

那么可能整个系统都得重新编译,即使只有一个子系统——或者只有一个函数——使用了新添加的枚举名。这是大家都不希望看到的。C++11中的前置声明enums可以解决这个问题。比如这里有一个完全有效的限域enum声明和一个以该限域enum作为形参的函数声明:

1
2
enum class Status;                  //前置声明
void continueProcessing(Status s); //使用前置声明enum

即使Status的定义发生改变,包含这些声明的头文件也不需要重新编译。而且如果Status有改动(比如添加一个audited枚举名),continueProcessing的行为不受影响(比如因为continueProcessing没有使用这个新添加的audited),continueProcessing也不需要重新编译。

所以说了上面一堆,我希望大家可以理解的就是:

前向声明(也叫前置声明,forward declaration)都是需要知道该种类型的大小的,那么如果编译器在使用它之前需要知晓该enum的大小,该怎么声明才能让C++11做到C++98不能做到的事情呢?

答案很简单:

  • 限域enum的底层类型总是已知的,默认是int,当然如果你的枚举名中有大于int的,那么编译器出现变窄转换报错,所以你需要改变下你指定的底层类型。

  • 而对于非限域enum,也是同理,既然你选择了前置声明,那么编译器无法帮你推导了这个枚举类底层类型,那么你可以直接指定它的底层类型即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
enum class Status {
good = 0,
failed = 1,
incomplete = 100,
corrupt = 200,
indeterminate = 0xFFFFFFFF//这里超过了int,变窄转换报错
};

//修改为: 底层类型修改为long long
enum class Status: std::int64_t {
good = 0,
failed = 1,
incomplete = 100,
corrupt = 200,
indeterminate = 0xFFFFFFFF //ok 没问题了
};
1
2
3
4
5
6
7
8
9
10
11
12
enum Status {
good = 0,
failed = 1,
incomplete = 100,
corrupt = 200,
indeterminate = 0xFFFFFFFF//非限域enum会自动调整底层类型,因此这么定义ok
};

//但是前置声明会错误
enum Status;//ISO C++ forbids forward references to 'enum' types
//这是因为他不知道底层该选什么类型,手动指定即可:
enum Status: std::int64_t;//ok 没问题

这个规则看起来冗余复杂,但其实只要理解本质并不难理解:不管怎样,编译器都要知道enum中的枚举名占用多少字节。

限域enum避免命名空间污染而且不接受荒谬的隐式类型转换,但它并非万事皆宜,你可能会很惊讶听到至少有一种情况下非限域enum是很有用的。那就是牵扯到C++11的std::tuple的时候。比如在社交网站中,假设我们有一个tuple保存了用户的名字,email地址,声望值:

1
2
3
4
using UserInfo =                //类型别名,参见Item9
std::tuple<std::string, //名字
std::string, //email地址
std::size_t> ; //声望

虽然注释说明了tuple各个字段对应的意思,但当你在另一文件遇到下面的代码那之前的注释就不是那么有用了:

1
2
3
UserInfo uInfo;                 //tuple对象

auto val = std::get<1>(uInfo); //获取第一个字段

作为一个程序员,你有很多工作要持续跟进。你应该记住第一个字段代表用户的email地址吗?我认为不。可以使用非限域enum将名字和字段编号关联起来以避免上述需求:

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

UserInfo uInfo; //同之前一样

auto val = std::get<uiEmail>(uInfo); //啊,获取用户email字段的值

之所以它能正常工作是因为UserInfoFields中的枚举名隐式转换成std::size_t了,其中std::size_tstd::get模板实参所需的。

对应的限域enum版本就很啰嗦了:

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

UserInfo uInfo; //同之前一样

auto val =
std::get<static_cast<std::size_t>(UserInfoFields::uiEmail)>
(uInfo);

为避免这种冗长的表示,我们可以写一个函数传入枚举名并返回对应的std::size_t值,但这有一点技巧性。为了更好的性能,枚举名变换为std::size_t值的函数必须在编译期产生这个结果,因此这个转换至少是一个constexpr函数

但是较之于返回std::size_t,我们更应该返回枚举的底层类型。这可以通过std::underlying_type这个type trait获得。

1
2
3
4
5
template<typename E>
constexpr std::underlying_type_t<E> toUType(E enumerator) noexcept
{
return static_cast<std::underlying_type_t<E>>(enumerator);
}

还可以再用C++14 auto(参见Item3)打磨一下代码:

1
2
3
4
5
6
template<typename E>                //C++14
constexpr auto
toUType(E enumerator) noexcept
{
return static_cast<std::underlying_type_t<E>>(enumerator);
}

不管它怎么写,toUType现在允许这样访问tuple的字段了:

1
auto val = std::get<toUType(UserInfoFields::uiEmail)>(uInfo);

这仍然比使用非限域enum要写更多的代码,但同时它也避免命名空间污染,防止不经意间使用隐式转换。

记住

  • C++98的enum即非限域enum
  • 限域enum的枚举名仅在enum内可见。要转换为其它类型只能使用cast
  • 非限域/限域enum都支持底层类型说明语法,限域enum底层类型默认是int。非限域enum没有默认底层类型。
  • 限域enum总是可以前置声明。非限域enum仅当指定它们的底层类型时才能前置。
  • 在某些时候,非限域的enum可能会比限域的enum在拿tuple属性时更直观,但是为了不抛弃限域enum的优势,我们常会写一个constexpr的转换函数来等价地做到这件事情。

item11:优先考虑使用deleted函数而非使用未定义的私有声明

这里基础的delete方法不再讨论,比如可以delete普通函数和成员函数。

在远古时期,如果你想实现delete等效的功能,常用的方法是定义在private中并不实现。现在有了delete我们可以更直观的做这件事情了。

当然private+不实现在一些情况是无法替代delete的:

首先就是非成员函数,也就是对于一个最简单的普通函数,由于不是在类里没有private的说法,所以delete在此时是无法替代的。

另一种就是模板实例,即实例化后的函数。

如果的类里面有一个函数模板,你可能想用private(经典的C++98惯例)来禁止这些函数模板实例化,但是不能这样做,因为不能给特化的成员模板函数指定一个不同于主函数模板的访问级别。如果processPointer是类Widget里面的模板函数, 你想禁止它接受void*参数,那么通过下面这样C++98的方法就不能通过编译:

1
2
3
4
5
6
7
8
9
10
11
12
class Widget {
public:

template<typename T>
void processPointer(T* ptr)
{ … }

private:
template<> //错误!
void processPointer<void>(void*);

};

问题是模板特例化必须位于一个命名空间作用域,而不是类作用域。

deleted函数不会出现这个问题,因为它不需要一个不同的访问级别,且他们可以在类外被删除(因此位于命名空间作用域):

1
2
3
4
5
6
7
8
9
10
11
12
class Widget {
public:

template<typename T>
void processPointer(T* ptr)
{ … }


};

template<> //还是public,
void Widget::processPointer<void>(void*) = delete; //但是已经被删除了

事实上C++98的最佳实践即声明函数为private但不定义是在做C++11 deleted函数要做的事情。作为模仿者,C++98的方法不是十全十美。它不能在类外正常工作,不能总是在类中正常工作,它的罢工可能直到链接时才会表现出来。所以请坚定不移的使用deleted函数。

item12:使用override声明重写函数

虽然“重写(overriding)”听起来像“重载(overloading)”,然而两者完全不相关,所以让我澄清一下,正是虚函数重写机制的存在,才使我们可以通过基类的接口调用派生类的成员函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Base {
public:
virtual void doWork(); //基类虚函数

};

class Derived: public Base {
public:
virtual void doWork(); //重写Base::doWork
//(这里“virtual”是可以省略的)
};

std::unique_ptr<Base> upb = //创建基类指针指向派生类对象
std::make_unique<Derived>(); //关于std::make_unique
//请参见Item21


upb->doWork(); //通过基类指针调用doWork,
//实际上是派生类的doWork
//函数被调用

要想重写一个函数,必须满足下列要求:

  • 基类函数必须是virtual,也就是父类里要有个虚函数
  • 同名:基类和派生类函数名必须完全一样(除非是析构函数)
  • 同参数:基类和派生类函数形参类型必须完全一样
  • 基类和派生类函数常量性constness必须完全一样
  • 基类和派生类函数的返回值和异常说明(exception specifications)必须兼容

简单来说就是同签名+virtual

除了这些C++98就存在的约束外,C++11又添加了一个:

  • 函数的引用限定符(reference qualifiers)必须完全一样。成员函数的引用限定符是C++11很少抛头露脸的特性,所以如果你从没听过它无需惊讶。它可以限定成员函数只能用于左值或者右值。成员函数不需要virtual也能使用它们:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Widget {
public:

void doWork() &; //只有*this为左值的时候才能被调用
void doWork() &&; //只有*this为右值的时候才能被调用
};

Widget makeWidget(); //工厂函数(返回右值)
Widget w; //普通对象(左值)

w.doWork(); //调用被左值引用限定修饰的Widget::doWork版本
//(即Widget::doWork &)
makeWidget().doWork(); //调用被右值引用限定修饰的Widget::doWork版本
//(即Widget::doWork &&)

这么多的重写需求意味着哪怕一个小小的错误也会造成巨大的不同。代码中包含重写错误通常是有效的,但它的意图不是你想要的。因此你不能指望当你犯错时编译器能通知你。比如,下面的代码是完全合法的,咋一看,还很有道理,但是它没有任何虚函数重写——没有一个派生类函数联系到基类函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Base {
public:
virtual void mf1() const;
virtual void mf2(int x);
virtual void mf3() &;
void mf4() const;
};

class Derived: public Base {
public:
virtual void mf1(); //const没有,签名不同
virtual void mf2(unsigned int x);//参数不同,签名不同
virtual void mf3() &&;//引用限定符为右值,父类中的mf3限定为左值,签名不同
void mf4() const;//父类的mf4没virtual关键字
};

为了让这种编译可以通过但是并没有传达出正确的运行方式的代码不要出现,加入了override/final关键字就很好的减少了这种低级错误的出现。

用法不赘述,以前博客写到过:https://chillstepp.github.io/2022/05/27/C-11%E6%96%B0%E7%89%B9%E6%80%A7/#Override-%E4%B8%8E-final

item12(增补):成员函数引用限定(reference qualifiers

item12里有一个很有意思的新知识,叫做成员函数引用限定(reference qualifiers,我们这里再来谈谈这个东西:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Widget {
public:
using DataType = std::vector<double>;

DataType& data() & //对于左值Widgets,
{ return values; } //返回左值

DataType data() && //对于右值Widgets,
{ return std::move(values); } //返回右值


private:
DataType values;
};

注意data重载的返回类型是不同的,左值引用重载版本返回一个左值引用(即一个左值),右值引用重载返回一个临时对象(即一个右值)。这意味着现在客户端的行为和我们的期望相符了:

1
2
3
4
auto vals1 = w.data();              //调用左值重载版本的Widget::data,
//拷贝构造vals1
auto vals2 = makeWidget().data(); //调用右值重载版本的Widget::data,
//移动构造vals2

请记住:

  • 为重写函数加上override
  • 成员函数引用限定让我们可以区别对待左值对象和右值对象(即*this)

item13:优先考虑const_iterator而非iterator

STL const_iterator等价于指向常量的指针(pointer-to-const)。它们都指向不能被修改的值。标准实践是能加上const就加上,这也指示我们需要一个迭代器时只要没必要修改迭代器指向的值,就应当使用const_iterator

上面的说法对C++11和C++98都是正确的,但是在C++98中,标准库对const_iterator的支持不是很完整。首先不容易创建它们,其次就算你有了它,它的使用也是受限的。假如你想在std::vector<int>中查找第一次出现1983(C++代替C with classes的那一年)的位置,然后插入1998(第一个ISO C++标准被接纳的那一年)。如果vector中没有1983,那么就在vector尾部插入。在C++98中使用iterator可以很容易做到:

1
2
3
4
5
std::vector<int> values;

std::vector<int>::iterator it =
std::find(values.begin(), values.end(), 1983);
values.insert(it, 1998);

但是这里iterator真的不是一个好的选择,因为这段代码不修改iterator指向的内容。const_iterator重写这段代码是很平常的,但是在C++98中就不是了。下面是一种概念上可行但是不正确的方法:

1
2
3
4
5
6
7
8
9
10
11
12
typedef std::vector<int>::iterator IterT;               //typedef
typedef std::vector<int>::const_iterator ConstIterT;

std::vector<int> values;

ConstIterT ci =
std::find(static_cast<ConstIterT>(values.begin()), //cast
static_cast<ConstIterT>(values.end()), //cast
1983);

values.insert(static_cast<IterT>(ci), 1998); //可能无法通过编译,
//原因见下

之所以std::find的调用会出现类型转换是因为在C++98中values是non-const容器,没办法简简单单的从non-const容器中获取const_iterator

严格来说类型转换不是必须的,因为用其他方法获取const_iterator也是可以的(比如你可以把values绑定到reference-to-const变量上,然后再用这个变量代替values),但不管怎么说,从non-const容器中获取const_iterator的做法都有点别扭。

当你费劲地获得了const_iterator,事情可能会变得更糟,因为C++98中,插入操作(以及删除操作)的位置只能由iterator指定,const_iterator是不被接受的。这也是我在上面的代码中,将const_iterator(我那么小心地从std::find搞出来的东西)转换为iterator的原因,因为向insert传入const_iterator不能通过编译。 因为没有一个可移植的从const_iteratoriterator的方法,即使使用static_cast也不行。甚至传说中的牛刀reinterpret_cast也杀不了这条鸡。

所有的这些都在C++11中改变了,现在const_iterator既容易获取又容易使用。容器的成员函数cbegincend产出const_iterator,甚至对于non-const容器也可用,那些之前使用iterator指示位置(如inserterase)的STL成员函数也可以使用const_iterator了。使用C++11 const_iterator重写C++98使用iterator的代码也稀松平常:

1
2
3
4
5
std::vector<int> values;                                //和之前一样

auto it = //使用cbegin
std::find(values.cbegin(), values.cend(), 1983);//和cend
values.insert(it, 1998);

现在使用const_iterator的代码就很实用了!

我们可以泛化下面的findAndInsert

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename C, typename V>
void findAndInsert(C& container, //在容器中查找第一次
const V& targetVal, //出现targetVal的位置,
const V& insertVal) //然后在那插入insertVal
{
using std::cbegin;
using std::cend;

auto it = std::find(cbegin(container), //非成员函数cbegin
cend(container), //非成员函数cend
targetVal);
container.insert(it, insertVal);
}

它可以在C++14工作良好,但是很遗憾,C++11不在良好之列。由于标准化的疏漏,C++11只添加了非成员函数beginend,但是没有添加cbegincendrbeginrendcrbegincrend。C++14修订了这个疏漏。

当然在c++11你也可以自己写一个cbegin的非成员函数,下面就是非成员函数cbegin的实现:

1
2
3
4
5
template <class C>
auto cbegin(const C& container)->decltype(std::begin(container))
{
return std::begin(container); //解释见下
}

​ 你可能很惊讶非成员函数cbegin没有调用成员函数cbegin吧?这个cbegin模板接受任何代表类似容器的数据结构的实参类型C,并且通过reference-to-const形参container访问这个实参。

​ 如果C是一个普通的容器类型(如std::vector<int>),container将会引用一个const版本的容器(如const std::vector<int>&)。const容器调用非成员函数begin(由C++11提供)将产出const_iterator,这个迭代器也是模板要返回的。

​ 用这种方法实现的好处是就算容器只提供begin成员函数(对于容器来说,C++11的非成员函数begin调用这些成员函数)不提供cbegin成员函数也没问题。那么现在你可以将这个非成员函数cbegin施于只直接支持begin的容器。

如果C是原生数组,这个模板也能工作。这时,container成为一个const数组的引用。C++11为数组提供特化版本的非成员函数begin,它返回指向数组第一个元素的指针。一个const数组的元素也是const,所以对于const数组,非成员函数begin返回指向const的指针(pointer-to-const)。在数组的上下文中,所谓指向const的指针(pointer-to-const),也就是const_iterator了。

请记住:

  • 优先考虑const_iterator而非iterator
  • 在最大程度通用的代码中,优先考虑非成员函数版本的beginendrbegin等,而非同名成员函数。这是因为非成员函数版本可以支持原生指针也可以支持迭代器。

item14:如果函数不抛出异常请使用noexcept

​ 在C++11标准化过程中,大家一致认为异常说明真正有用的信息是一个函数是否会抛出异常。非黑即白,一个函数可能抛异常,或者不会。这种”可能-绝不”的二元论构成了C++11异常说的基础,从根本上改变了C++98的异常说明。(C++98风格的异常说明也有效,但是已经标记为deprecated(废弃))。在C++11中,无条件的noexcept保证函数不会抛出任何异常。

​ 函数是否声明为noexcept,这个可以影响到调用代码的异常安全性(exception safety)和效率不抛异常的函数加上noexcept允许编译器生成更好的目标代码

考虑一个函数f,它保证调用者永远不会收到一个异常。两种表达方式如下:

1
2
int f(int x) throw();   //C++98风格,没有来自f的异常
int f(int x) noexcept; //C++11风格,没有来自f的异常

​ 如果在运行时,f出现一个异常,那么就和f的异常说明冲突了。在C++98的异常说明中,调用栈(the call stack)会展开至f的调用者,在一些与这地方不相关的动作后,程序被终止。C++11异常说明的运行时行为有些不同:调用栈只是可能在程序终止前展开。

​ 展开调用栈和可能展开调用栈两者对于代码生成(code generation)有非常大的影响:

​ 在一个noexcept函数中,当异常可能传播到函数外时,优化器不需要保证运行时栈(the runtime stack)处于可展开状态;也不需要保证当异常离开noexcept函数时,noexcept函数中的对象按照构造的反序析构而标注“throw()”异常声明的函数缺少这样的优化灵活性,没加异常声明的函数也一样。可以总结一下:

1
2
3
RetType function(params) noexcept;  //极尽所能优化
RetType function(params) throw(); //较少优化
RetType function(params); //较少优化

这是一个充分的理由使得你当知道它不抛异常时加上noexcept

下面考虑这样一段C++98的代码:Widget通过push_back一次又一次的添加进std::vector

1
2
3
4
5
6
std::vector<Widget> vw;

Widget w;
//用w做点事
vw.push_back(w); //把w添加进vw

​ 假设这个代码能正常工作,你也无意修改为C++11风格。但是你确实想要C++11移动语义带来的性能优势,毕竟这里的类型是可以移动的(move-enabled types)。因此你需要确保Widget有移动操作,可以手写代码也可以让编译器自动生成,当然前提是能满足自动生成的条件(item17)

std::vector的大小(size)等于它的容量(capacity)时:std::vector会分配一个新的更大块的内存用于存放其中元素,然后将元素从老内存区移动到新内存区,然后析构老内存区里的对象。

  • 在C++98中,移动是通过复制老内存区的每一个元素到新内存区完成的,然后老内存区的每个元素发生析构。这种方法使得push_back可以提供很强的异常安全保证:如果在复制元素期间抛出异常,std::vector状态保持不变,因为老内存元素析构必须建立在它们已经成功复制到新内存的前提下。
  • 在C++11中,一个很自然的优化就是将上述复制操作替换为移动操作。但是这会破坏push_back的异常安全保证。如果n个元素已经从老内存移动到了新内存区,但异常在移动第n+1个元素时抛出,那么push_back操作就不能完成。但是原始的std::vector已经被修改:有n个元素已经移动走了。恢复std::vector至原始状态也不太可能,因为从新内存移动到老内存本身又可能引发异常。

因为老代码可能依赖于push_back提供的强烈的异常安全保证。因此,C++11版本的实现不能简单的将push_back里面的复制操作替换为移动操作,除非知晓移动操作绝不抛异常,这时复制替换为移动就是安全的,作用就是性能得到提升。

std::vector::push_back受益于“如果可以就移动,如果必要则复制”策略,并且它不是标准库中唯一采取该策略的函数。C++98中还有一些函数(如std::vector::reversestd::deque::insert等)也受益于这种强异常保证。对于这个函数只有在知晓移动不抛异常的情况下用C++11的移动操作替换C++98的复制操作才是安全的。

​ 但是如何知道一个函数中的移动操作是否产生异常?答案很明显:它检查这个操作是否被声明为noexcept(这个检查非常弯弯绕。像是std::vector::push_back之类的函数调用std::move_if_noexcept,这是个std::move的变体,根据其中类型的移动构造函数是否为noexcept的,视情况转换为右值或保持左值(参见Item23)。反过来,std::move_if_noexcept查阅std::is_nothrow_move_constructible这个type trait,基于移动构造函数是否有noexcept(或者throw())的设计,编译器设置这个type trait的值。)

swap函数是noexcept的另一个绝佳用地。swap是STL算法实现的一个关键组件,它也常用于拷贝运算符重载中,它的广泛使用意味着对其施加不抛异常的优化是非常有价值的。

有趣的是,标准库的swap是否noexcept有时依赖于用户定义的swap是否noexcept。比如,数组和std::pairswap声明如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
//1.
template <class T, size_t N>
void swap(T (&a)[N],
T (&b)[N]) noexcept(noexcept(swap(*a, *b))); //见下文

//2.
template <class T1, class T2>
struct pair {

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

};

这些函数视情况noexcept:它们是否noexcept依赖于noexcept声明中的表达式是否noexcept

比如上面的swap pair代码,如果pair的first和second元素的swap都是noexcept的,那么这个函数就会称为noexcept的。

再比如上面代码第一段,假设有两个Widget数组,交换数组操作为noexcept的前提是数组中的元素交换是noexcept的,即Widgetswapnoexcept。因此Widgetswap的作者决定了交换widget的数组是否noexcept

这些例子都在说明的是: c++11提供了一种依赖的noexcept,高层数据结构是否noexcept取决于底层数据结构的noexcept,这是十分合理好用的一个性质。

​ 实际上大多数函数都是异常中立(exception-neutral的。这些函数自己不抛异常,但是它们内部的调用可能抛出。此时,异常中立函数允许那些抛出异常的函数在调用链上更进一步直到遇到异常处理程序,而不是就地终止。异常中立函数决不应该声明为noexcept,也就是说在当前这个函数内不处理异常,但是又不立即终止程序,而是让调用这个函数的函数处理异常。因此大多数函数缺少noexcept设计。

​ 当然一部分函数也是应该保证不要抛出异常的,这是为了更好的提高程序的性能,比如我们上面提到的swap和移动操作。

​ 对于一些函数,使之称为noexcept十分重要,在C++98,允许内存释放(memory deallocation)函数(即operator deleteoperator delete[])和析构函数抛出异常是糟糕的代码设计,C++11将这种作风升级为语言规则。默认情况下,内存释放函数和析构函数——不管是用户定义的还是编译器生成的——都是隐式noexcept。因此它们不需要声明noexcept

请记住:

  • noexcept是函数接口的一部分,这意味着调用者可能会依赖它
  • noexcept函数较之于non-noexcept函数更容易优化
  • noexcept对于移动语义,swap,内存释放函数和析构函数非常有用
  • 大多数函数是异常中立的(译注:可能抛也可能不抛异常)而不是noexcept

item15:尽可能使用constexpr

注释:constexpr在如今时代已经拥有了大量新增特性,这本书当时最多涉及到C++14, C++17和C++20的诸多特性不会涉及到,如果感兴趣,可以看

看这两篇文章:

constexpr来表明一个值的时候被认作是一个常量或是编译期可知的值,这是有失偏颇的,在constexpr应用于函数的时候,你不能假设函数的返回值要是一个const的,也不一定是需要编译期可知的。

​ 编译期可知的值“享有特权”,它们可能被存放到只读存储空间中。对于那些嵌入式系统的开发者,这个特性是相当重要的。

广泛的应用是“其值编译期可知”的常量整数会出现在需要“整型常量表达式(integral constant expression)的上下文中,这类上下文包括数组大小,整数模板参数(包括std::array对象的长度),枚举名的值,对齐修饰符(译注:alignas(val)),等等。如果你想在这些上下文中使用变量,你一定会希望将它们声明为constexpr,因为编译器会确保它们是编译期可知的:

1
2
3
4
5
6
7
8
9
10
int sz;                             //non-constexpr变量

constexpr auto arraySize1 = sz; //错误!sz的值在
//编译期不可知

std::array<int, sz> data1; //错误!一样的问题
constexpr auto arraySize2 = 10; //没问题,10是
//编译期可知常量

std::array<int, arraySize2> data2; //没问题, arraySize2是constexpr

注意const不提供constexpr所能保证之事,因为const对象不需要在编译期初始化它的值。

1
2
3
4
int sz;                            //和之前一样

const auto arraySize = sz; //没问题,arraySize是sz的const复制
std::array<int, arraySize> data; //错误,arraySize值在编译期不可知

简而言之,所有constexpr对象都是const,但不是所有const对象都是constexpr

如果你想编译器保证一个变量有一个值,这个值可以放到那些需要编译期常量(compile-time constants)的上下文的地方,你需要的工具是constexpr而不是const

下面再来说一说constexpr应用于函数的情况:

如果实参是编译期常量,这些函数将产出编译期常量;如果实参是运行时才能知道的值,它们就将产出运行时值。这听起来就像你不知道它们要做什么一样,那么想是错误的,请这么看:

  • constexpr函数可以用于需求编译期常量的上下文。如果你传给constexpr函数的实参在编译期可知,那么结果将在编译期计算。如果实参的值在编译期不知道,你的代码就会被拒绝。
  • 当一个constexpr函数被一个或者多个编译期不可知值调用时,它就像普通函数一样,运行时计算它的结果。这意味着你不需要两个函数,一个用于编译期计算,一个用于运行时计算。constexpr全做了。
1
2
3
4
5
6
7
8
9
10
11
12
constexpr                                   //pow是绝不抛异常的
int pow(int base, int exp) noexcept //constexpr函数
{
//实现在下面
}
//编译期常量的应用:
constexpr auto numConds = 5; //(上面例子中)条件的个数
std::array<int, pow(3, numConds)> results; //结果有3^numConds个元素
//运行时的应用:
auto base = readFromDB("base"); //运行时获取这些值
auto exp = readFromDB("exponent");
auto baseToExp = pow(base, exp); //运行时调用pow函数

回忆下pow前面的constexpr不表明pow返回一个const值,它只说了如果baseexp是编译期常量,pow的值可以被当成编译期常量使用。如果base和/或exp不是编译期常量,pow结果将会在运行时计算。这意味着pow不止可以用于像std::array的大小这种需要编译期常量的地方,它也可以用于运行时环境。

我们再来讨论下上面pow函数的实现:

C++11中,constexpr函数的代码不超过一行语句:一个return。听起来很受限,但实际上有两个技巧可以扩展constexpr函数的表达能力。第一,使用三元运算符“?:”来代替if-else语句,第二,使用递归代替循环。因此pow可以像这样实现:

1
2
3
4
constexpr int pow(int base, int exp) noexcept
{
return (exp == 0 ? 1 : base * pow(base, exp - 1));
}

这样没问题,但是很难想象除了使用函数式语言的程序员外会觉得这样硬核的编程方式更好。在C++14中,constexpr函数的限制变得非常宽松了,所以下面的函数实现成为了可能:

1
2
3
4
5
6
7
constexpr int pow(int base, int exp) noexcept   //C++14
{
auto result = 1;
for (int i = 0; i < exp; ++i) result *= base;

return result;
}

在C++11中,除了void外的所有内置类型,以及一些用户定义类型都可以是字面值类型,因为构造函数和其他成员函数可能是constexpr

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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; }

void setX(double newX) noexcept { x = newX; }
void setY(double newY) noexcept { y = newY; }

private:
double x, y;
};

Point的构造函数可被声明为constexpr,因为如果传入的参数在编译期可知,Point的数据成员也能在编译器可知。因此这样初始化的Point就能为constexpr

1
2
3
constexpr Point p1(9.4, 27.7);  //没问题,constexpr构造函数
//会在编译期“运行”
constexpr Point p2(28.8, 5.3); //也没问题

类似的,xValueyValuegetter(取值器)函数也能是constexpr,因为如果对一个编译期已知的Point对象(如一个constexpr Point对象)调用getter,数据成员xy的值也能在编译期知道。这使得我们可以写一个constexpr函数,里面调用Pointgetter并初始化constexpr的对象:

1
2
3
4
5
6
7
8
constexpr
Point midpoint(const Point& p1, const Point& p2) noexcept
{
return { (p1.xValue() + p2.xValue()) / 2, //调用constexpr
(p1.yValue() + p2.yValue()) / 2 }; //成员函数
}
constexpr auto mid = midpoint(p1, p2); //使用constexpr函数的结果
//初始化constexpr对象

这太令人激动了。

在C++11中,有两个限制使得Point的成员函数setXsetY不能声明为constexpr。第一,它们修改它们操作的对象的状态, 并且在C++11中,constexpr成员函数是隐式的const。第二,它们有void返回类型,void类型不是C++11中的字面值类型。这两个限制在C++14中放开了,所以C++14中Pointsetter(赋值器)也能声明为constexpr

1
2
3
4
5
6
7
class Point {
public:

constexpr void setX(double newX) noexcept { x = newX; } //C++14
constexpr void setY(double newY) noexcept { y = newY; } //C++14

};

现在也能写这样的函数:

1
2
3
4
5
6
7
8
//返回p相对于原点的镜像
constexpr Point reflection(const Point& p) noexcept
{
Point result; //创建non-const Point
result.setX(-p.xValue()); //设定它的x和y值
result.setY(-p.yValue());
return result; //返回它的副本
}

请记住:

  • constexpr对象是const,它被在编译期可知的值初始化
  • 当传递编译期可知的值时,constexpr函数可以产出编译期可知的结果
  • constexpr对象和函数可以使用的范围比non-constexpr对象和函数要大
  • constexpr是对象和函数接口的一部分

item16:让const成员函数线程安全

考虑这样一段代码:

一个多项式类,有一个求根的成员方法roots,为了使得求根更高效,对于以前查询过的根缓存起来,下次遇到相同的查询直接输出即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Polynomial {
public:
using RootsType = std::vector<double>;

RootsType roots() const
{
if (!rootsAreValid) { //如果缓存不可用
//计算根
//用rootVals存储它们
rootsAreValid = true;
}

return rootVals;
}

private:
mutable bool rootsAreValid{ false }; //初始化器(initializer)的
mutable RootsType rootVals{}; //更多信息请查看条款7
};

这里面roots成员函数被声明为const,你可能会疑惑这个成员函数中不是修改了rootsAreValid和rootVals的内容吗,怎么还可以让这个方法是const的呢?如下图报错

image-20221017095901255

是的,所以rootsAreValid和rootVals都被标记为了mutable也就是可变的,这样一个const的方法也可以允许被标有mutabe的变量被修改,其他没标的则不允许。 为什么要搞出这样一个关键字呢?其实有些时候类里的某些内容是不影响我们类的实际内容的,比如一个计数器,你想看看这个类的某个方法调用了多少次,但是这个计数器是不会对类的使用有任何影响的。因此他在const函数中出现变化也是无所谓的。同理,在多项式中,缓存答案这个功能并不会影响多项式的计算结果和多项式本身的任何数据,所以这两个变量都可以设置成mutable,使其在const成员函数下也可以被修改

对于mutable关键字,在cppreference中可以看到其实就两个usage:

https://en.cppreference.com/w/cpp/keyword/mutable

image-20221017094900480

下面我们来讨论第二个问题,如果没有mutable关键字,那么对于一个const的成员函数,是不会产生线程安全的,因为都是简单的读而已,不会存在写竞争,可有了mutable关键字的出现,就要注意下mutable变量是可以写的而且可以存在const成员函数中,此时的const成员函数就不再是线程安全的了。

解决这个问题最普遍简单的方法就是——使用mutex(互斥量):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Polynomial {
public:
using RootsType = std::vector<double>;

RootsType roots() const
{
std::lock_guard<std::mutex> g(m); //锁定互斥量

if (!rootsAreValid) { //如果缓存无效
//计算/存储根值
rootsAreValid = true;
}

return rootsVals;
} //解锁互斥量

private:
mutable std::mutex m;
mutable bool rootsAreValid { false };
mutable RootsType rootsVals {};
};

​ 注意到std::mutex m被声明为mutable,因为锁定和解锁它的都是non-const成员函数。在rootsconst成员函数)中,m却被视为const对象,所以需要mutable修饰m。

​ 值得注意的是,因为std::mutex是一种只可移动类型(move-only type,一种可以移动但不能复制的类型),所以将m添加进Polynomial中的副作用是使Polynomial失去了被复制的能力。不过,它仍然可以移动。

​ 在某些情况下,互斥量的副作用显会得过大。例如,如果你所做的只是计算成员函数被调用了多少次,使用std::atomic 修饰的计数器(保证其他线程视它的操作为不可分割的整体,参见item40)通常会是一个开销更小的方法。(然而它是否轻量取决于你使用的硬件和标准库中互斥量的实现。)

以下是如何使用std::atomic来统计调用次数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Point {                                   //2D点
public:

double distanceFromOrigin() const noexcept //noexcept的使用
{ //参考条款14
++callCount; //atomic的递增

return std::sqrt((x * x) + (y * y));
}

private:
mutable std::atomic<unsigned> callCount{ 0 };
double x, y;
};

std::mutex一样,std::atomic是只可移动类型,所以在Point中存在callCount就意味着Point也是只可移动的。

​ 因为std::atomic变量的操作通常比互斥量的获取和释放的消耗更小,所以你可能会过度倾向与依赖std::atomic

​ 例如,在一个类中,缓存一个开销昂贵的int,你就会尝试使用一对std::atomic变量而不是互斥量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Widget {
public:

int magicValue() const
{
if (cacheValid) return cachedValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cachedValue = val1 + val2; //第一步
cacheValid = true; //第二步
return cachedValid;
}
}

private:
mutable std::atomic<bool> cacheValid{ false };
mutable std::atomic<int> cachedValue;
};

这当然是可行的,但难以避免有时出现重复计算的情况。考虑:

  • 一个线程调用Widget::magicValue,将cacheValid视为false,执行这两个昂贵的计算,并将它们的和分配给cachedValue
  • 此时,第二个线程调用Widget::magicValue,也将cacheValid视为false,因此执行刚才完成的第一个线程相同的计算。(这里的“第二个线程”实际上可能是其他几个线程。)

也就是说还是有一定概率两个线程重复计算相同的东西。

你可能会认为:是因为cachevalue先计算出了结果,但是没及时的修改cachevalid的状态导致这样的,你可能会修改下 cachedValue和cacheValid的赋值顺序,结果会更糟:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Widget {
public:

int magicValue() const
{
if (cacheValid) return cachedValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cacheValid = true; //第一步
return cachedValue = val1 + val2; //第二步
}
}

}

假设cacheValid是false,那么:

  • 一个线程调用Widget::magicValue,刚执行完将cacheValid设置true的语句。
  • 在这时,第二个线程调用Widget::magicValue,检查cacheValid。看到它是true,就返回cacheValue,即使第一个线程还没有给它赋值。因此返回的值是不正确的。

是的,这甚至会导致cachedValue没赋值但是cacheValid先有效了,导致计算结果的错误。

所以在Effective Modern CPP中提到:

  • 对于需要同步的是单个的变量或者内存位置,使用std::atomic就足够了

  • 不过,一旦你需要对两个以上的变量或内存位置作为一个单元来操作的话,就应该使用互斥量。

所以对于Widget::magicValue应该是这样实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Widget {
public:

int magicValue() const
{
std::lock_guard<std::mutex> guard(m); //锁定m

if (cacheValid) return cachedValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cachedValue = val1 + val2;
cacheValid = true;
return cachedValue;
}
} //解锁m


private:
mutable std::mutex m;
mutable int cachedValue; //不再用atomic
mutable bool cacheValid{ false }; //不再用atomic
};
  • 为独占单线程使用而设计的类的成员函数是否线程安全并不重要。在这种情况下,你可以避免因使用互斥量和std::atomics所消耗的资源
  • 然而,这种线程无关的情况越来越少见,而且很可能会越来越少。可以肯定的是,const成员函数应支持并发执行,这就是为什么你应该确保const成员函数是线程安全的。

请记住:

  • 确保const成员函数线程安全,除非你确定它们永远不会在并发上下文(concurrent context)中使用。
  • 使用std::atomic变量可能比互斥量提供更好的性能,但是它只适合操作单个变量或内存位置。

item17:理解特殊成员函数的生成

先从C++98时代谈起,那个时候还没有移动操作,因此没有移动构造和移动赋值函数。在那个时代可以自动生成的特殊成员函数是 默认构造,拷贝构造,拷贝赋值,析构。这些函数当然不是在任何情况下都会自动生成:默认构造函数仅在类没有任何构造函数的情况下才会生成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class CLA
{
public:
int a;
explicit CLA(int x):a(x){}
};


int main()
{
CLA(2);// ok
CLA();//ERROR: 未定义默认构造函数
return 0;
}

一般来说,默认生成的特殊成员函数都是非虚,public,inline的。当然这里有一个edge case,就是除了析构函数,如果继承的父类析构函数是virtual的,那么自动生成的也会是virtual的。

我们再来讨论移动构造和移动赋值这两个C++11的新产物,在C++11这俩也会被自动生成。

​ 当我对一个数据成员或者基类使用移动构造或者移动赋值时,没有任何保证移动一定会真的发生。逐成员移动,实际上,更像是逐成员移动请求,因为对不可移动类型(即对移动操作没有特殊支持的类型,比如大部分C++98传统类)使用“移动”操作实际上执行的是拷贝操作。

  • 对于拷贝操作的特殊函数自动生成,两者是独立的:即当你声明了拷贝构造函数,不会影响拷贝赋值函数的自动生成,反之同理。但是对于移动操作却不是的,你声明了其中之一另一个就不会自动生成了。
  • 甚至,如果一个类显式声明了析构/拷贝操作,编译器就不会生成移动操作。

他们背后的理由是:如果声明拷贝操作(构造或者赋值)就暗示着平常拷贝对象的方法(逐成员拷贝)不适用于该类,编译器会明白如果逐成员拷贝对拷贝操作来说不合适,逐成员移动也可能对移动操作来说不合适。析构函数同理,你需要很特殊的析构成员,也不能自动生成移动操作。

综上,仅当下面条件成立时才会生成移动操作(当需要时):

  • 类中没有拷贝操作
  • 类中没有移动操作
  • 类中没有用户定义的析构

假设这个类没有声明拷贝操作,没有移动操作,也没有析构,如果它们被用到编译器会自动生成。没错,很方便。

后来需要在对象构造和析构中打日志,增加这种功能很简单:

1
2
3
4
5
6
7
8
9
10
11
class StringTable {
public:
StringTable()
{ makeLogEntry("Creating StringTable object"); } //增加的

~StringTable() //也是增加的
{ makeLogEntry("Destroying StringTable object"); }
//其他函数同之前一样
private:
std::map<int, std::string> values; //同之前一样
};

看起来合情合理,但是声明析构有潜在的副作用:它阻止了移动操作的生成。但如果我们还是希望可以用默认的移动构造移动赋值可以让这俩函数=default即可。

最后总结下在C++11自动生成的特殊函数的条件:

C++11对于特殊成员函数处理的规则如下:

  • 默认构造函数:和C++98规则相同。仅当类不存在用户声明的构造函数时才自动生成。
  • 析构函数:基本上和C++98相同;稍微不同的是现在析构默认noexcept(参见Item14)。和C++98一样,仅当基类析构为虚函数时该类析构才为虚函数。
  • 拷贝构造函数:和C++98运行时行为一样:逐成员拷贝non-static数据。仅当类没有用户定义的拷贝构造时才生成。如果类声明了移动操作它就是delete的。当用户声明了拷贝赋值或者析构,该函数自动生成已被废弃。
  • 拷贝赋值运算符:和C++98运行时行为一样:逐成员拷贝赋值non-static数据。仅当类没有用户定义的拷贝赋值时才生成。如果类声明了移动操作它就是delete的。当用户声明了拷贝构造或者析构,该函数自动生成已被废弃。
  • 移动构造函数移动赋值运算符:都对非static数据执行逐成员移动。仅当类没有用户定义的拷贝操作,移动操作或析构时才自动生成。

还要知道一个edge case:

注意没有“成员函数模版阻止编译器生成特殊成员函数”的规则。这意味着如果Widget是这样:

1
2
3
4
5
6
7
8
9
class Widget {

template<typename T> //从任何东西构造Widget
Widget(const T& rhs);

template<typename T> //从任何东西赋值给Widget
Widget& operator=(const T& rhs);

};

编译器仍会生成移动和拷贝操作(假设正常生成它们的条件满足),即使可以模板实例化产出拷贝构造和拷贝赋值运算符的函数签名。(当TWidget时。)很可能你会觉得这是一个不值得承认的边缘情况,但是我提到它是有道理的,Item26将会详细讨论它可能带来的后果。

当然我还是建议你如果想自动生成,不放显示的写出来+=default,让阅读者和写代码的人都可以一目了然,毕竟不是所有人你都会了解特殊函数的生成条件。