Effective Modern Cpp

为什么const成员函数应当线程安全?怎样使用std::unique_ptr实现Pimpl惯用法?为何要避免lambda表达式用默认捕获模式?std::atomicvolatile的区别是什么?

基础简介

C++中的许多东西都可被声明和定义。声明declarations)引入名字和类型,并不给出比如存放在哪或者怎样实现等的细节:

1
2
3
4
5
6
7
extern int x;                       //对象声明

class Widget; //类声明

bool func(const Widget& w); //函数声明

enum class Color; //限域enum声明

定义definitions)提供存储位置或者实现细节:

1
2
3
4
5
6
7
8
9
10
11
int x;                              //对象定义

class Widget { //类定义

};

bool func(const Widget& w)
{ return w.size() < 10; } //函数定义

enum class Color
{ Yellow, Red, Blue }; //限域enum定义

定义也有资格称为声明。

定义一个函数的签名signature)为它声明的一部分,这个声明指定了形参类型和返回类型。函数名和形参名不是签名的一部分。在上面的例子中,func的签名是bool(const Widget&)。函数声明中除了形参类型和返回类型之外的元素(比如noexcept或者constexpr,如果存在的话)都被排除在外。

另外 std::auto_ptr在C++11中被废弃,因为std::unique_ptr可以做同样的工作,而且只会做的更好。

如果一个操作的结果有未定义的行为undefined behavior)。这意味着运行时表现是不可预测的。比如,在std::vector范围外使用方括号(“[]”),解引用未初始化的迭代器,或者数据竞争(即有两个或以上线程,至少一个是writer,同时访问相同的内存位置)。

还有,智能指针通常重载指针解引用运算符(operator->operator*),但 std::weak_ptr是个例外。

1 类型推导

C++11修改了一些类型推导规则并增加了两套规则,一套用于auto,一套用于decltype。C++14扩展了autodecltype可能使用的范围。

条款1:理解模板类型推导

C++最重要最吸引人的特性auto是建立在模板类型推导的基础上的。

考虑像这样一个函数模板:

1
2
template<typename T>
void f(ParamType param);

更具体一点是:

1
2
template<typename T>
void f(const T& param); //ParamType是const T&

编译器对 f 函数的参数进行两个类型推导:一个是针对T的,另一个是针对ParamType的。这两个类型通常是不同的,因为ParamType包含一些修饰,比如const和引用修饰符。

然后这样进行调用:

1
2
int x = 0;
f(x); //用一个int类型的变量调用f

T被推导为intParamType却被推导为const int&

T的类型推导不仅取决于expr的类型,也取决于ParamType的类型。有三种情况:

  • ParamType是一个指针或引用,但不是通用引用(参见 Item24,它不同于左值引用和右值引用)
  • ParamType是一个通用引用
  • ParamType既不是指针也不是引用

ParamType是一个指针或引用,但不是通用引用

f(expr) 的类型推导会这样进行:

  1. 如果expr的类型是一个引用,忽略引用部分
  2. 然后expr的类型与ParamType进行模式匹配来决定T

如果这是我们的模板,

1
2
template<typename T>
void f(T& param); //param是一个引用

我们声明这些变量,

1
2
3
int x=27;                       //x是int
const int cx=x; //cx是const int
const int& rx=x; //rx是指向作为const int的x的引用

在不同的调用中,对paramT推导的类型会是这样:

1
2
3
f(x);                           //T是int,param的类型是int&
f(cx); //T是const int,param的类型是const int&
f(rx); //T是const int,param的类型是const int&

即使rx的类型是一个引用,T也会被推导为一个非引用 ,这是因为rx的引用性(reference-ness)在类型推导中会被忽略。

param是reference-to-constconst不再被推导为T的一部分:

1
2
3
4
5
6
7
8
9
10
template<typename T>
void f(const T& param); //param现在是reference-to-const

int x = 27; //如之前一样
const int cx = x; //如之前一样
const int& rx = x; //如之前一样

f(x); //T是int,param的类型是const int&
f(cx); //T是int,param的类型是const int&
f(rx); //T是int,param的类型是const int&

同之前一样,rx的reference-ness在类型推导中被忽略了。

如果param是一个指针(或者指向const的指针)而不是引用,情况本质上也一样:

1
2
3
4
5
6
7
8
template<typename T>
void f(T* param); //param现在是指针

int x = 27; //同之前一样
const int *px = &x; //px是指向作为const int的x的指针

f(&x); //T是int,param的类型是int*
f(px); //T是const int,param的类型是const int*

ParamType是一个通用引用

形参被声明为像右值引用一样(也就是,在函数模板中假设有一个类型形参T,那么通用引用声明形式就是T&&)。

  • 如果expr是左值,TParamType都会被推导为左值引用。

    • 这是模板类型推导中唯一一种T被推导为引用的情况。

    • 虽然ParamType被声明为右值引用类型,但是最后推导的结果是左值引用。

  • 如果expr是右值,就使用正常的(也就是上一节中的)推导规则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template<typename T>
void f(T&& param); //param现在是一个通用引用类型

int x=27;
const int cx=x;
const int & rx=cx;

f(x); //x是左值,所以T是int&,
//param类型也是int&

f(cx); //cx是左值,所以T是const int&,
//param类型也是const int&

f(rx); //rx是左值,所以T是const int&,
//param类型也是const int&

f(27); //27是右值,所以T是int,
//param类型就是int&&

通用引用的类型推导规则是不同于普通的左值或者右值引用的。尤其是,当通用引用被使用时,类型推导会区分左值实参和右值实参,但是对非通用引用时不会区分。

ParamType既不是指针也不是引用

ParamType既不是指针也不是引用时,我们通过传值(pass-by-value)的方式处理:

1
2
template<typename T>
void f(T param); //以传值的方式处理param

这意味着无论传递什么param都会成为它的一份拷贝——一个完整的新对象。事实上param成为一个新对象这一行为会影响T如何从expr中推导出结果。

  1. 如果expr的类型是一个引用,忽略这个引用部分
  2. 如果忽略expr的引用性(reference-ness)之后,expr是一个const,那就再忽略const。如果它是volatile,也忽略volatilevolatile对象不常见,它通常用于驱动程序的开发中)

因此:

1
2
3
4
5
6
7
int x=27;                       //如之前一样
const int cx=x; //如之前一样
const int & rx=cx; //如之前一样

f(x); //T和param的类型都是int
f(cx); //T和param的类型都是int
f(rx); //T和param的类型都是int

即使cxrx表示const值,param也不是const。这是有意义的。param是一个完全独立于cxrx的对象——是cxrx的一个拷贝。

认识到只有在传值给形参时才会忽略const(和volatile)这一点很重要。对于reference-to-const和pointer-to-const形参来说,expr的常量性constness在推导时会被保留。

例如:

1
2
3
4
5
6
7
template<typename T>
void f(T param); //仍然以传值的方式处理param

const char* const ptr = //ptr是一个常量指针,指向常量对象
"Fun with pointers";

f(ptr); //传递const char * const类型的实参

ptr作为实参传给f,组成这个指针的每一比特都被拷贝进paramptr自身的值会被传给形参。

在类型推导中,这个指针指向的数据的常量性constness将会被保留,但是当拷贝ptr来创造一个新指针param时,ptr自身的常量性constness将会被忽略。

数组实参

1
2
3
const char name[] = "J. P. Briggs";     //name的类型是const char[13]

const char * ptrToName = name; //数组退化为指针

在这里const char*指针ptrToName会由name初始化,而name的类型为const char[13],这两种类型(const char*const char[13])是不一样的,但是由于数组退化为指针的规则,编译器允许这样的代码。

但要是一个数组传值给一个模板会怎样?会发生什么?

1
2
3
4
template<typename T>
void f(T param); //传值形参的模板

f(name); //T和param会推导成什么类型?

数组与指针形参这样的等价是C语言的产物,C++又是建立在C语言的基础上,它让人产生了一种数组和指针是等价的的错觉。

因为数组形参会视作指针形参,所以传值给模板的一个数组类型会被推导为一个指针类型。这意味着在模板函数f的调用中,它的类型形参T会被推导为const char*

1
f(name);                        //name是一个数组,但是T被推导为const char*

但是在C++中,虽然函数不能声明形参为真正的数组,但是可以接受指向数组的引用!所以我们修改f为传引用:

1
2
template<typename T>
void f(T& param); //传引用形参的模板

我们这样进行调用,

1
f(name);                                //传数组给f

T被推导为了真正的数组!这个类型包括了数组的大小,在这个例子中T被推导为const char[13]f的形参(对这个数组的引用)的类型则为const char (&)[13]

可声明指向数组的引用的能力,使得我们可以创建一个模板函数来推导出数组的大小:

1
2
3
4
5
//在编译期间返回一个数组大小的常量值,这里的数组形参没有名字
template<typename T, std::size_t N>
constexpr std::size_t arraySize(T (&)[N]) noexcept {
return N;
}

将一个函数声明为constexpr使得结果在编译期间可用。这使得我们可以用一个花括号声明一个数组,然后第二个数组可以使用第一个数组的大小作为它的大小,就像这样:

1
2
3
int keyVals[] = { 1, 3, 7, 9, 11, 22, 35 };             //keyVals有七个元素

int mappedVals[arraySize(keyVals)]; //mappedVals也有七个

当然作为一个现代C++程序员,你自然应该想到使用std::array而不是内置的数组:

1
std::array<int, arraySize(keyVals)> mappedVals;         //mappedVals的大小为7

至于arraySize被声明为noexcept,会使得编译器生成更好的代码。

函数实参

在C++中不只是数组会退化为指针,函数类型也会退化为一个函数指针。对于数组类型推导的全部讨论都可以应用到函数类型推导和退化为函数指针上来。结果是:

1
2
3
4
5
6
7
8
9
10
11
12
13
void someFunc(int, double);         //someFunc是一个函数,
//类型是void(int, double)

template<typename T>
void f1(T param); //传值给f1

template<typename T>
void f2(T & param); //传引用给f2

f1(someFunc); //param被推导为指向函数的指针,
//类型是void(*)(int, double)
f2(someFunc); //param被推导为指向函数的引用,
//类型是void(&)(int, double)

这个实际上没有什么不同,只是函数退化为指针。

结论

  • 在模板类型推导时,有引用的实参会被视为无引用,引用会被忽略
  • 对于通用引用的推导,左值实参会被特殊对待
  • 对于传值类型推导,const和/或volatile实参会被认为是non-const的和non-volatile
  • 在模板类型推导时,数组名或者函数名实参会退化为指针,除非它们被用于初始化引用模板参数类型。

2 理解 auto 类型推导

模板类型推导使用下面这个函数模板

1
2
3
4
template<typename T>
void f(ParmaType param);

f(expr); //使用一些表达式调用f

f的调用中,编译器使用expr推导TParamType的类型。

当一个变量使用auto进行声明时,auto扮演了模板中T的角色,变量的类型说明符扮演了ParamType的角色。

考虑这个例子:

1
auto x = 27;

这里x的类型说明符是auto自己,另一方面,在这个声明中:

1
const auto cx = x;

类型说明符是const auto。另一个:

1
const auto & rx=cx;

类型说明符是const auto&

在这里例子中要推导xrxcx的类型,编译器的行为看起来就像是认为这里每个声明都有一个模板,然后使用合适的初始化表达式进行调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template<typename T>            //概念化的模板用来推导x的类型
void func_for_x(T param);

func_for_x(27); //概念化调用:
//param的推导类型是x的类型

template<typename T> //概念化的模板用来推导cx的类型
void func_for_cx(const T param);

func_for_cx(x); //概念化调用:
//param的推导类型是cx的类型

template<typename T> //概念化的模板用来推导rx的类型
void func_for_rx(const T & param);

func_for_rx(x); //概念化调用:
//param的推导类型是rx的类型

在使用auto作为类型说明符的变量声明中,类型说明符代替了ParamType

1
2
3
auto x = 27;                    //类型说明符既不是指针也不是引用
const auto cx = x; //类型说明符既不是指针也不是引用
const auto & rx=cx; //类型说明符是一个指针或引用但不是通用引用
1
2
3
4
5
6
auto&& uref1 = x;               //x是int左值,
//所以uref1类型为int&
auto&& uref2 = cx; //cx是const int左值,
//所以uref2类型为const int&
auto&& uref3 = 27; //27是int右值,
//所以uref3类型为int&&
1
2
3
4
5
6
7
8
9
10
11
const char name[] =             //name的类型是const char[13]
"R. N. Briggs";

auto arr1 = name; //arr1的类型是const char*
auto& arr2 = name; //arr2的类型是const char (&)[13]

void someFunc(int, double); //someFunc是一个函数,
//类型为void(int, double)

auto func1 = someFunc; //func1的类型是void (*)(int, double)
auto& func2 = someFunc; //func2的类型是void (&)(int, double)

auto类型推导和模板类型推导几乎一样的工作。

如果你想声明一个带有初始值27的int,C++98提供两种语法选择:

1
2
int x1 = 27;
int x2(27);

C++11由于也添加了用于支持统一初始化(uniform initialization)的语法:

1
2
int x3 = { 27 };
int x4{ 27 };

总之,这四种不同的语法只会产生一个相同的结果:变量类型为int值为27。

使用 auto 进行的类型推导,其结果却不一样:

1
2
3
4
5
auto x1 = 27;                   //类型是int,值是27
auto x2(27); //同上
auto x3 = { 27 }; //类型是std::initializer_list<int>,
//值是{ 27 }
auto x4{ 27 }; //同上

这就造成了auto类型推导不同于模板类型推导的特殊情况。当用auto声明的变量使用花括号进行初始化,auto类型推导推出的类型则为std::initializer_list。如果这样的一个类型不能被成功推导(比如花括号里面包含的是不同类型的变量),编译器会拒绝这样的代码:

1
auto x5 = { 1, 2, 3.0 };        //错误!无法推导std::initializer_list<T>中的T

对于花括号的处理是auto类型推导和模板类型推导唯一不同的地方。

然而如果在模板中指定Tstd::initializer_list<T>而留下未知T,模板类型推导就能正常工作:

1
2
3
4
5
template<typename T>
void f(std::initializer_list<T> initList);

f({ 11, 23, 9 }); //T被推导为int,initList的类型为
//std::initializer_list<int>

在C++11编程中一个典型的错误就是偶然使用了std::initializer_list<T>类型的变量。

但是对于C++14故事还在继续,C++14允许auto用于函数返回值并会被推导。

而且C++14的lambda函数也允许在形参声明中使用auto。但是在这些情况下auto实际上使用模板类型推导的那一套规则在工作,而不是auto类型推导,所以说下面这样的代码不会通过编译:

1
2
3
4
auto createInitList()
{
return { 1, 2, 3 }; //错误!不能推导{ 1, 2, 3 }的类型
}

同样在C++14的 lambda 函数中这样使用auto也不能通过编译:

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

auto resetV =
[&v](const auto& newValue){ v = newValue; }; //C++14

resetV({ 1, 2, 3 }); //错误!不能推导{ 1, 2, 3 }的类型

结论

  • auto类型推导通常和模板类型推导相同,但是auto类型推导假定花括号初始化代表std::initializer_list,而模板类型推导不这样做
  • 在C++14中auto允许出现在函数返回值或者lambda函数形参中,但是它的工作机制是模板类型推导那一套方案,而不是auto类型推导

3 decltype

decltype,给它一个名字或者表达式decltype就会告诉你这个名字或者表达式的类型。通常,它会精确的告诉你你想要的结果。

decltype只是简单的返回名字或者表达式的类型:

1
2
3
4
5
6
7
8
9
10
11
12
const int i = 0;                //decltype(i)是const int

bool f(const Widget& w); //decltype(w)是const Widget&
//decltype(f)是bool(const Widget&)

struct Point{
int x,y; //decltype(Point::x)是int
}; //decltype(Point::y)是int

Widget w; //decltype(w)是Widget

if (f(w))… //decltype(f(w))是bool
1
2
3
4
5
6
7
8
9
10
11
template<typename T>            //std::vector的简化版本
class vector{
public:

T& operator[](std::size_t index);

};

vector<int> v; //decltype(v)是vector<int>

if (v[0] == 0)… //decltype(v[0])是int&

在C++11中,decltype最主要的用途就是用于声明函数模板,而这个函数返回类型依赖于形参类型。

对一个T类型的容器使用operator[] 通常会返回一个T&对象,比如std::deque就是这样。

但是std::vector有一个例外,对于std::vector<bool>operator[]不会返回bool&,它会返回一个全新的对象(MSVC的STL实现中返回的是std::_Vb_reference<std::_Wrap_alloc<std::allocator<unsigned int>>>对象)。

decltype 获取返回值类型的示例

使用decltype计算返回类型的一个例子是:

1
2
3
4
5
6
7
template<typename Container, typename Index>    //可以工作,
auto authAndAccess(Container& c, Index i) //但是需要改良
->decltype(c[i])
{
authenticateUser();
return c[i];
}

函数名称前面的auto不会做任何的类型推导工作。相反的,他只是暗示使用了C++11的尾置返回类型语法,即在函数形参列表后面使用一个”->“符号指出函数的返回类型,尾置返回类型的好处是我们可以在函数返回类型中使用函数形参相关的信息。在authAndAccess函数中,我们使用ci指定返回类型。

在C++14标准下我们可以忽略尾置返回类型,只留下一个auto。使用这种声明形式,auto标示这里会发生类型推导。更准确的说,编译器将会从函数实现中推导出函数的返回类型。

1
2
3
4
5
6
template<typename Container, typename Index>    //C++14版本,
auto authAndAccess(Container& c, Index i) //不那么正确
{
authenticateUser();
return c[i]; //从c[i]中推导返回类型
}

从返回对象进行修改

上述代码出现的一个问题是:

operator[]对于大多数T类型的容器会返回一个T&,但是 条款1 解释了在模板类型推导期间,表达式的引用性(reference-ness)会被忽略。基于这样的规则,考虑它会对下面用户的代码有哪些影响:

1
2
3
4
5
std::deque<int> d;

authAndAccess(d, 5) = 10; //认证用户,返回d[5],
//然后把10赋值给它
//无法通过编译器!

在这里d[5]本该返回一个int&,但是模板类型推导会剥去引用的部分,因此产生了int返回类型。函数返回的那个int是一个右值,上面的代码尝试把10赋值给右值int,C++11禁止这样做,所以代码无法编译。

要想让authAndAccess像我们期待的那样工作,我们需要使用decltype类型推导来推导它的返回值,即指定authAndAccess应该返回一个和c[i]表达式类型一样的类型。

因此我们可以这样写authAndAccess

1
2
3
4
5
6
7
template<typename Container, typename Index>    //C++14版本,
decltype(auto) //可以工作,
authAndAccess(Container& c, Index i) //但是还需要
{ //改良
authenticateUser();
return c[i];
}

现在authAndAccess将会真正的返回c[i]的类型。现在事情解决了,一般情况下c[i]返回T&authAndAccess也会返回T&,特殊情况下c[i]返回一个对象,authAndAccess也会返回一个对象。

decltype(auto)的使用不仅仅局限于函数返回类型,当你想对初始化表达式使用decltype推导的规则,你也可以使用:

1
2
3
4
5
6
7
8
Widget w;

const Widget& cw = w;

auto myWidget1 = cw; //auto类型推导
//myWidget1的类型为Widget
decltype(auto) myWidget2 = cw; //decltype类型推导
//myWidget2的类型是const Widget&

形参传递问题

authAndAccess声明:

1
2
template<typename Container, typename Index>
decltype(auto) authAndAccess(Container& c, Index i);

容器通过传引用的方式传递非常量左值引用(lvalue-reference-to-non-const),因为返回一个引用允许用户可以修改容器。

但是这意味着在不能给这个函数传递右值容器,右值不能被绑定到左值引用上,除非这个左值引用是一个const(lvalue-references-to-const)。

一个右值容器,是一个临时对象,通常会在authAndAccess调用结束被销毁,这意味着authAndAccess返回的引用将会成为一个悬置的(dangle)引用。

为了使authAndAccess的引用可以绑定左值和右值,可以使用通用引用。所以我们这样声明:

1
2
template<typename Containter, typename Index>   //现在c是通用引用
decltype(auto) authAndAccess(Container&& c, Index i);

这行代码中还有一个问题:

在这个模板中,我们不知道我们操纵的容器的类型是什么,也就是说不知道它使用的索引对象(index objects)的类型。

对一个未知类型的对象使用传值通常会造成不必要的拷贝,对程序的性能有极大的影响,还会造成对象切片行为。

但是只针对 STL 容器(比如std::stringstd::vectorstd::dequeoperator[]),这样处理是合理的。

为了保持参数本身的左右值属性,还需要进行 std::forward

1
2
3
4
5
6
7
template<typename Container, typename Index>    //最终的C++14版本
decltype(auto)
authAndAccess(Container&& c, Index i)
{
authenticateUser();
return std::forward<Container>(c)[i];
}

C++11版本:

1
2
3
4
5
6
7
8
template<typename Container, typename Index>    //最终的C++11版本
auto
authAndAccess(Container&& c, Index i)
->decltype(std::forward<Container>(c)[i])
{
authenticateUser();
return std::forward<Container>(c)[i];
}

decltype应用于变量名会产生该变量名的声明类型。虽然变量名都是左值表达式,但这不会影响decltype的行为。但是对于一些表达式,其类型推导结果,可能出现&引用类型:

1
2
3
4
5
6
7
8
9
10
decltype(auto) f1() {
int x = 0;

return x; //decltype(x)是int,所以f1返回int
}

decltype(auto) f2() {
int x = 0;
return (x); //decltype((x))是int&,所以f2返回int&
}

对于名字来说,x是一个左值,C++11定义了表达式(x)也是一个左值。decltype((x))int&。用小括号覆盖一个名字可以改变decltype对于名字产生的结果。

因此,当使用decltype(auto)的时候一定要加倍的小心,在表达式中看起来无足轻重的细节将会影响到decltype(auto)的推导结果。

结论

  • decltype产生变量或者表达式的类型
  • 对于T类型的不是单纯的变量名的左值表达式,decltype总是产出T的引用即T&
  • C++14支持decltype(auto),推导出类型,但是它使用decltype的规则进行推导,而不是 auto

4 查看类型推导结果

三种方案:

  • IDE编辑器获得类型推导的结果
  • 在编译期间获得结果
  • 在运行时获得结果

IDE

IDE之所以能提供这些信息是因为一个C++编译器(或者至少是前端中的一个部分)运行于IDE中。如果这个编译器对你的代码不能做出有意义的分析或者推导,它就不会显示推导的结果。

编译

可以首先声明一个类模板但不定义。就像这样:

1
2
template<typename T>                //只对TD进行声明
class TD; //TD == "Type Displayer"

尝试实例化这个类模板就会引出一个错误消息,因为这里没有用来实例化的类模板定义。为了查看xy的类型,只需要使用它们的类型去实例化TD

1
2
TD<decltype(x)> xType;              //引出包含x和y
TD<decltype(y)> yType; //的类型的错误消息

出现 undefined template TD<xxx>。

运行时

使用printf的方法使类型信息只有在运行时才会显示出来(尽管不建议使用printf)。

1
2
std::cout << typeid(x).name() << '\n';  //显示x和y的类型
std::cout << typeid(y).name() << '\n';

这种方法对一个对象如xy调用typeid产生一个std::type_info的对象,然后std::type_info里面的成员函数name()来产生一个C风格的字符串(即一个const char*)表示变量的名字。

调用std::type_info::name不保证返回任何有意义的东西,但是库的实现者尝试尽量使它们返回的结果有用。

举个例子,GNU和Clang环境下x的类型会显示为"i",y会显示为"PKi"。"i"表示"int",""PK"表示"pointer to konst const"(指向常量的指针)。

如果传递的是一个引用,那么引用部分(reference-ness)将被忽略,如果忽略后还具有const或者volatile,那么常量性constness或者易变性volatileness也会被忽略。

std::type_info::name的结果并不总是可信的,因为std::type_info::name规范批准像传值形参一样来对待这些类型。Boost TypeIndex库(Boost.TypeIndex)是更好的选择。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <boost/type_index.hpp>

template<typename T>
void f(const T& param)
{
using std::cout;
using boost::typeindex::type_id_with_cvr;

//显示T
cout << "T = "
<< type_id_with_cvr<T>().pretty_name()
<< '\n';

//显示param类型
cout << "param = "
<< type_id_with_cvr<decltype(param)>().pretty_name()
<< '\n';
}

boost::typeindex::type_id_with_cvr获取一个类型实参(我们想获得相应信息的那个类型),它不消除实参的constvolatile和引用修饰符(因此模板名中有“with_cvr”)。结果是一个boost::typeindex::type_index对象,它的pretty_name成员函数输出一个std::string,包含我们能看懂的类型表示。 基于这个f的实现版本,再次考虑那个使用typeid时获取param类型信息出错的调用:

1
2
3
4
5
6
std::vetor<Widget> createVec();         //工厂函数
const auto vw = createVec(); //使用工厂函数返回值初始化vw
if (!vw.empty()){
f(&vw[0]); //调用f

}

在GNU和Clang的编译器环境下,使用Boost.TypeIndex版本的f最后会产生下面的(准确的)输出:

1
2
T =     Widget const *
param = Widget const * const&

在Microsoft的编译器环境下,结果也是极其相似:

1
2
T =     class Widget const *
param = class Widget const * const &

结论

  • 类型推断可以使用IDE,使用编译器报错,使用Boost.TypeIndex库
  • 这些工具可能既不准确也无帮助,所以理解C++类型推导规则才是最重要的

5 优先考虑auto而非显式类型声明

从程序员的角度来说,如果按照符合规定的流程走,那auto类型推导的一些结果是错误的。当这些情况发生时,引导auto产生正确的结果是很重要的。

auto变量从初始化表达式中推导出类型,所以我们必须初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int x1;                         //潜在的未初始化的变量

auto x2; //错误!必须要初始化

auto x3 = 0; //没问题,x已经定义了

template<typename It>
void dwim(It b,It e)
{
while (b != e) {
auto currValue = *b;

}
}

auto derefUPLess =
[](const std::unique_ptr<Widget> &p1, //用于std::unique_ptr
const std::unique_ptr<Widget> &p2) //指向的Widget类型的
{ return *p1 < *p2; }; //比较函数

如果使用C++14,将会变得更酷,因为lambda表达式中的形参也可以使用auto

1
2
3
4
5
6
7
8
9
10
11
auto derefLess =                                //C++14版本
[](const auto& p1, //被任何像指针一样的东西
const auto& p2) //指向的值的比较函数
{ return *p1 < *p2; };

// 也即
std::function<bool(const std::unique_ptr<Widget> &,
const std::unique_ptr<Widget> &)>
derefUPLess = [](const std::unique_ptr<Widget> &p1,
const std::unique_ptr<Widget> &p2)
{ return *p1 < *p2; };

实例化std::function并声明一个对象这个对象将会有固定的大小。这个大小可能不足以存储一个闭包,这个时候std::function的构造函数将会在堆上面分配内存来存储,这就造成了使用std::functionauto声明变量会消耗更多的内存。

通过std::function调用一个闭包几乎无疑比auto声明的对象调用要慢。换句话说,std::function方法比auto方法要更耗空间且更慢,还可能有out-of-memory异常。并且正如上面的例子,比起写std::function实例化的类型来,使用auto要方便得多。

考虑以下问题:

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

unsigned sz = v.size();

v.size()的标准返回类型是std::vector<int>::size_type,但是只有少数开发者意识到这点。std::vector<int>::size_type实际上被指定为无符号整型。上述的代码,会造成一些有趣的结果。

举个例子,在Windows 32-bit上std::vector<int>::size_typeunsigned是一样的大小,但是在Windows 64-bit上std::vector<int>::size_type是64位,unsigned是32位。这意味着这段代码在Windows 32-bit上正常工作,但是当把应用程序移植到Windows 64-bit上时就可能会出现一些问题。

所以使用auto可以确保你不需要浪费时间:

1
auto sz =v.size();                      //sz的类型是std::vector<int>::size_type

考虑下面的代码:

1
2
3
4
5
6
7
std::unordered_map<std::string, int> m;


for(const std::pair<std::string, int>& p : m)
{
//用p做一些事
}

看起来好像很合情合理的表达,但是这里有一个问题:

std::unordered_mapkeyconst的,所以hash table中的std::pair的类型不是std::pair<std::string, int>,而是std::pair<const std::string, int>

编译器会努力的找到一种方法把std::pair<const std::string, int>(即hash table中的东西)转换为std::pair<std::string, int>p的声明类型)。它会成功的,因为它会通过拷贝m中的对象创建一个临时对象,是m中元素的类型。然后把p的引用绑定到这个临时对象上。在每个循环迭代结束时,临时对象将会销毁。

所以不只是让p指向m中各个元素的引用而已。

使用auto可以避免这些很难被意识到的类型不匹配的错误:

1
2
3
for(const auto& p : m) {
//如之前一样
}

这样无疑更具效率,且更容易书写。而且,这个代码有一个非常吸引人的特性,如果你获取p的地址,你确实会得到一个指向m中元素的指针。在没有auto的版本中p会指向一个临时变量,这个临时变量在每次迭代完成时会被销毁。

讲究!

有时候,显式的指定类型可能会导致你不想看到的类型转换。如果你使用auto声明目标变量你就不必担心这个问题。

然而auto也不是完美的。每个auto变量都从初始化表达式中推导类型,有一些表达式的类型和我们期望的大相径庭,比如在 理解auto类型推导 小结的内容。

另外,一个适当的变量名称就能体现大量的抽象类型信息,所以不用考虑 auto 带来的信息不可见性。

结论

  • auto变量必须初始化,通常它可以避免一些移植性和效率性的问题,也使得重构更方便,还能让你少打几个字。
  • 注意 auto 可能出现一些类型推导不一致的问题。

6 auto 遇上代理类型,使用显式类型初始化

假如我有一个函数,参数为Widget,返回一个std::vector<bool>,这里的bool表示Widget是否提供一个独有的特性。

1
std::vector<bool> features(const Widget& w);

更进一步假设第5个bit表示Widget是否具有高优先级,我们可以写这样的代码:

1
2
3
4
5
Widget w;

bool highPriority = features(w)[5]; //w高优先级吗?

processWidget(w, highPriority); //根据它的优先级处理w

这个代码没有任何问题。它会正常工作,但是如果我们使用auto代替highPriority的显式指定类型做一些看起来很无害的改变:

1
auto highPriority = features(w)[5];     //w高优先级吗?

情况变了。所有代码仍然可编译,但是行为不再可预测:

1
processWidget(w,highPriority);          //未定义行为!

因为 features(w)[5] 调用 operator[]不会返回容器中元素的引用,取而代之它返回一个std::vector<bool>::reference的对象。

调用features将返回一个std::vector<bool>临时对象,这个对象没有名字,为了方便我们的讨论,我这里叫他tempoperator[]temp上调用,它返回的std::vector<bool>::reference包含一个指向存着这些 bits 的指针(temp管理这些bits)。highPriority是这个std::vector<bool>::reference的拷贝,所以highPriority也包含一个指针,指向temp中的管理 bits 。在这个语句结束的时候temp将会被销毁,因为它是一个临时变量。因此highPriority包含一个悬挂(dangling)指针,如果用于processWidget调用中将会造成未定义行为:

1
2
processWidget(w, highPriority);         //未定义行为!
//highPriority包含一个悬置指针!

代理类问题

std::vector<bool>::reference是一个代理类(proxy class)的例子:所谓代理类就是以模仿和增强一些类型的行为为目的而存在的类。

C++标准模板库中的智能指针也是用代理类实现了对原始指针的资源管理行为。

一些代理类被设计于用以对客户可见。比如std::shared_ptrstd::unique_ptr。其他的代理类则或多或少不可见,比如std::vector<bool>::reference就是不可见代理类的一个例子,还有std::bitset::reference 等。

一些C++库也是用了表达式模板(expression templates)的黑科技。这些库通常被用于提高数值运算的效率。给出一个矩阵类Matrix和矩阵对象m1m2m3m4,举个例子,这个表达式

1
Matrix sum = m1 + m2 + m3 + m4;

可以使计算更加高效,只需要使让operator+返回一个代理类代理结果 Sum<Matrix, Matrix> 而不是返回结果本身。

作为一个通则,不可见的代理类通常不适用于auto。这样类型的对象的生命期通常不会活过一条语句,所以创建那样的对象是危险的。

显式类型初始化器(the explicitly typed initialized idiom)

1
auto highPriority = static_cast<bool>(features(w)[5]);

这里,features(w)[5]还是返回一个std::vector<bool>::reference对象,但是这个转型使得表达式类型为bool,然后auto才被用于推导highPriority

1
2
auto sum = static_cast<Matrix>(m1 + m2 + m3 + m4);
auto ep = static_cast<float>(calcEpsilon()); // 转换精度

结论

  • 不可见的代理类可能会使auto从表达式中推导出“错误的”类型
  • 显式类型初始化器强制auto推导出你想要的结果

7 区分(){}创建对象

C++11使用统一初始化(uniform initialization

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

C++11允许"="初始化不加花括号也拥有这种能力,括号初始化也能被用于为非静态数据成员指定默认初始值:

1
2
3
4
5
6
7
8
class Widget{


private:
int x{ 0 }; //没问题,x初始值为0
int y = 0; //也可以
int z(0); //错误!
}

另一方面,不可拷贝的对象(例如std::atomic)可以使用花括号初始化或者圆括号初始化,但是不能使用"="初始化:

1
2
3
std::atomic<int> ai1{ 0 };      //没问题
std::atomic<int> ai2(0); //没问题
std::atomic<int> ai3 = 0; //错误!

内置类型间的隐式变窄转换 (narrowing conversion)

括号表达式还有一个少见的特性,即它不允许内置类型间隐式的变窄转换(narrowing conversion)。如果一个使用了括号初始化的表达式的值,不能保证由被初始化的对象的类型来表示,代码就不会通过编译:

1
2
3
double x, y, z;

int sum1{ x + y + z }; //错误!double的和可能不能表示为int

使用圆括号和"="的初始化不检查是否转换为变窄转换,因为由于历史遗留问题它们必须要兼容老旧代码:

1
2
3
int sum2(x + y +z);             //可以(表达式的值被截为int)

int sum3 = x + y + z; //同上

被误认为是声明

C++规定任何可以被解析为一个声明的东西必须被解析为声明。

1
Widget w1(10);                  //使用实参10调用Widget的一个构造函数

但是如果你尝试使用相似的语法调用Widget无参构造函数,它就会变成函数声明:

1
Widget w2();                    //最令人头疼的解析!声明一个函数w2,返回Widget

由于函数声明中形参列表不能带花括号,所以使用花括号初始化表明你想调用默认构造函数构造对象就没有问题:

1
Widget w3{};                    //调用没有参数的构造函数构造对象

initializer_list关联问题

在构造函数调用中,只要不包含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的那个构造函数。

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的构造函数的决心是如此强烈,以至于就算带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时,编译器才会回到正常的函数决议流程中。

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}; // 使用花括号初始化,现在调用第二个构造函数

空的花括号意味着没有实参,不是一个空的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(); //最令人头疼的解析!声明一个函数
1
2
Widget w4({});                  //使用空花括号列表调用std::initializer_list构造函数
Widget w5{{}}; //同上

作为一个类库使用者,你必须认真的在花括号和圆括号之间选择一个来创建对象。大多数开发者都使用其中一种作为默认情况,只有当他们不能使用这种的时候才会考虑另一种。

结论

  • 花括号初始化是最广泛使用的初始化语法,它防止变窄转换,并且对于C++最令人头疼的解析(括号解析为函数)有天生的免疫性
  • 在构造函数重载决议中,编译器会尽最大努力将括号初始化与std::initializer_list参数匹配,即便其他构造函数看起来是更好的选择
  • 对于数值类型的std::vector来说使用花括号初始化和圆括号初始化会造成巨大的不同
  • 在模板类选择使用圆括号初始化或使用花括号初始化创建对象是需要仔细考虑

8 优先 nullptr

在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*的转换都同样好。

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

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

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

看下面的例子:

1
2
3
4
auto result = findRecord( /* arguments */ );
if (result == 0) {

}

如果你不知道findRecord返回了什么,那么你就不太清楚到底result是一个指针类型还是一个整型。但是换一种假设如果你看到这样的代码:

1
2
3
4
5
auto result = findRecord( /* arguments */ );

if (result == nullptr) {

}

这就没有任何歧义:result的结果一定是指针类型。

再考虑下面例子:

假如你有一些函数只能被合适的已锁互斥量调用。每个函数都有一个不同类型的指针:

1
2
3
int    f1(std::shared_ptr<Widget> spw);     //只能被合适的
double f2(std::unique_ptr<Widget> upw); //已锁互斥量
bool f3(Widget* pw); //调用

如果这样传递空指针:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
std::mutex f1m, f2m, f3m;       //用于f1,f2,f3函数的互斥量

using MuxGuard = std::lock_guard<std::mutex>;


{
MuxGuard g(f1m); //为f1m上锁
auto result = f1(0); //向f1传递0作为空指针
} //解锁

{
MuxGuard g(f2m); //为f2m上锁
auto result = f2(NULL); //向f2传递NULL作为空指针
} //解锁

{
MuxGuard g(f3m); //为f3m上锁
auto result = f3(nullptr); //向f3传递nullptr作为空指针
} //解锁

令人遗憾前两个调用没有使用nullptr,但是代码可以正常运行。

模板化这个调用流程:

1
2
3
4
5
6
7
8
9
10
template<typename FuncType,
typename MuxType,
typename PtrType>
auto lockAndCall(FuncType func,
MuxType& mutex,
PtrType ptr) -> decltype(func(ptr))
{
MuxGuard g(mutex);
return func(ptr);
}
1
2
3
4
5
6
7
8
9
10
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

这意味着lockAndCallfunc会被int类型的实参调用,这与f1期待的std::shared_ptr<Widget>形参不符。把int类型看做std::shared_ptr<Widget>类型给f1自然是一个类型错误。在模板lockAndCall中使用0之所以失败是因为在模板中,传给的是int但实际上函数期待的是一个std::shared_ptr<Widget>

nullptr传给lockAndCall时,ptr被推导为std::nullptr_t。当ptr被传递给f3的时候,隐式转换使std::nullptr_t转换为Widget*,因为std::nullptr_t可以隐式转换为任何指针类型。

结论

  • 优先考虑 nullptr
  • 避免重载指针和整型

9 优先 alias 而不是 typedef

typedef即可:

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

typedef是C++98的东西。

C++11也提供了一个别名声明(alias declaration):

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

由于这里给出的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,就不需要使用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”
};

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&

注释仅仅简单的总结了类型转换做了什么,所以不要太随便的使用。在你的项目使用它们之前,你最好看看它们的详细说明书。

这些别名声明有一个通用形式:对于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中也有效。其简单实现形式是:

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转换的别名声明版本

10 限域`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 class”声明,所以它们有时候也被称为枚举类(enum classes)。

使用限域enum来减少命名空间污染,这是一个足够合理使用它而不是它的同胞未限域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);

}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
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参数

}

// 除非 static_cast<double>(c)

在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也是有缺点的。最大的缺点莫过于它可能增加编译依赖。系统中某个枚举类型的头文件包含在多个文件中。如果引入一个新状态值,那么可能整个系统都得重新编译。

C++11中的前置声明enums可以解决这个问题。

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

即使Status的定义发生改变,包含这些声明的头文件也不需要重新编译。

默认情况下,限域枚举的底层类型是int

1
enum class Status;                  //底层类型是int

如果默认的int不适用,你可以重写它:

1
2
3
enum class Status: std::uint32_t;   //Status的底层类型
                                   //是std::uint32_t
                                   //(需要包含 <cstdint>)

不管怎样,编译器都知道限域enum中的枚举名占用多少字节。

要为非限域enum指定底层类型,你可以同上,结果就可以前向声明:

1
2
3
enum Color: std::uint8_t;   //非限域enum前向声明
                           //底层类型为
                           //std::uint8_t

底层类型说明也可以放到enum定义处:

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

假设我们有一个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); //获取第一个字段

在 get 时,显示写明1随代表的字段。

用非限域enum将名字和字段编号关联起来以避免上述需求:

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

UserInfo uInfo; //同之前一样

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

之所以它能正常工作是因为UserInfoFields中的枚举名隐式转换成std::size_t

对应的限域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函数模板,因为它应该能用于任何enum

底层类型可以通过std::underlying_type这个type trait获得。

1
2
3
4
5
6
7
8
template<typename E>
constexpr typename std::underlying_type<E>::type
toUType(E enumerator) noexcept
{
return
static_cast<typename
std::underlying_type<E>::type>(enumerator);
}

在C++14中,toUType还可以进一步用std::underlying_type_t代替typename std::underlying_type<E>::type打磨:

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

还可以再用C++14 auto打磨一下代码:

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的枚举名仅在enum内可见。要转换为其它类型只能使用cast
  • 非限域/限域enum都支持底层类型说明语法,限域enum底层类型默认是int。非限域enum没有默认底层类型。
  • 限域enum总是可以前置声明。非限域enum仅当指定它们的底层类型时才能前置。

11 使用 delete 而不是私有化其声明

1
2
3
4
5
6
7
8
9
template <class charT, class traits = char_traits<charT> >
class basic_ios : public ios_base {
public:


basic_ios(const basic_ios& ) = delete;
basic_ios& operator=(const basic_ios&) = delete;

};

deleted函数不能以任何方式被调用,即使你在成员函数或者友元函数里面调用deleted函数也不能通过编译。这是较之C++98行为的一个改进,C++98中不正确的使用这些函数在链接时才被诊断出来。

通常,deleted函数被声明为public而不是private。这也是有原因的。当客户端代码试图调用成员函数,C++会在检查deleted状态前检查它的访问性。当客户端代码调用一个私有的deleted函数,一些编译器只会给出该函数是private的错误。

deleted函数还有一个重要的优势是任何函数都可以标记为deleted,而只有成员函数可被标记为private

1
2
3
4
bool isLucky(int number);       //原始版本
bool isLucky(char) = delete; //拒绝char
bool isLucky(bool) = delete; //拒绝bool
bool isLucky(double) = delete; //拒绝float和double

另一个deleted函数用武之地(private成员函数做不到的地方)是禁止一些模板的实例化。假如你要求一个模板仅支持原生指针:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<typename T>
void processPointer(T* ptr);

template<>
void processPointer<void>(void*) = delete;

template<>
void processPointer<char>(char*) = delete;

template<>
void processPointer<const void>(const void*) = delete;

template<>
void processPointer<const char>(const char*) = delete;

如果你想做得更彻底一些,你还要删除const volatile void*const volatile char*重载版本,另外还需要一并删除其他标准字符类型的重载版本:std::wchar_tstd::char16_tstd::char32_t

类模板在命名空间作用域中,删除特定实例化(private 是做不到的):

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; //但是已经被删除了

结论

  • 使用delete函数更好
  • 任何函数都能 delete,包括非成员函数和模板实例

12 使用override声明重写函数

派生类的虚函数重写基类同名函数,很可能一不小心就错了。

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)必须兼容

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

  • 函数的引用限定符(reference qualifiers)必须完全一样。它可以限定成员函数只能用于左值或者右值:
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
class Base {
public:
virtual void mf1() const;
virtual void mf2(int x);
virtual void mf3() &;
void mf4() const;
};

C++11提供一个方法让你可以显式地指定一个派生类函数是基类版本的重写:将它声明为override。还是上面那个例子,我们可以这样做:

1
2
3
4
5
6
7
class Derived: public Base {
public:
virtual void mf1() override;
virtual void mf2(unsigned int x) override;
virtual void mf3() && override;
virtual void mf4() const override;
};

代码不能编译,当然了,因为这样写的时候,编译器会显示所有与重写有关的问题。这也是你想要的,以及为什么要在所有重写函数后面加上override

没有override,你只能寄希望于完善的单元测试。

C++11引入了两个上下文关键字(contextual keywords),overridefinal(向虚函数添加final可以防止派生类重写。final也能用于类,这时这个类不能用作基类)。

函数引用限定符

reference qualifiers。如果我们想写一个函数只接受左值实参,我们声明一个non-const左值引用形参:

1
void doSomething(Widget& w);    //只接受左值Widget对象

如果我们想写一个函数只接受右值实参,我们声明一个右值引用形参:

1
void doSomething(Widget&& w);   //只接受右值Widget对象

成员函数的引用限定可以很容易的区分一个成员函数被哪个对象(即*this)调用。它和在成员函数声明尾部添加一个const很相似,暗示了调用这个成员函数的对象(即*this)是const的。

考虑下面一个例子:

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

DataType& data() { return values; }

private:
DataType values;
};

客户端代码:

1
2
3
Widget w;

auto vals1 = w.data(); //拷贝w.values到vals1

Widget::data函数的返回值是一个左值引用(准确的说是std::vector<double>&), 因为左值引用是左值,所以vals1是从左值初始化的。因此vals1w.values拷贝构造而得。

现在假设我们有一个创建Widgets的工厂函数,

1
Widget makeWidget();

我们想用makeWidget返回的Widget里的std::vector初始化一个变量:

1
auto vals2 = makeWidget().data();   //拷贝Widget里面的值到vals2

WidgetmakeWidget返回的临时对象(即右值),所以将其中的std::vector进行拷贝纯属浪费。最好是移动,但是因为data返回左值引用,C++的规则要求编译器不得不生成一个拷贝。

指明当data被右值Widget对象调用的时候结果也应该是一个右值。现在就可以使用引用限定

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)

13 优先考虑 const_iterator 而不是 iterator

STL const_iterator等价于指向常量的指针(pointer-to-const)。它们都指向不能被修改的值。标准实践是能加上const就加上。

只是需要注意,C++11 和 C++98 对 const_iterator 的支持不一样。

没办法简简单单的从non-const容器中获取const_iterator

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); //可能无法通过编译,
//原因见下

因为向 insert 传入const_iterator不能通过编译,所以我们将const_iterator 转换为iterator的。

上面的代码仍然可能无法编译,因为没有一个可移植的从const_iteratoriterator的方法,即使使用static_cast也不行。

所有的这些都在C++11中改变了,现在const_iterator既容易获取又容易使用。容器的成员函数cbegincend产出const_iterator,甚至对于non-const容器也可用,那些之前使用iterator指示位置(如inserterase)的STL成员函数也可以使用const_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);

C++11 的一个缺陷是,对于 非成员函数,没有类似的 cbegin,cend 函数支持。C++14补上了这一空白。

非成员函数也叫 自由函数free function,即一个函数,只要不是成员函数就可被称作free function

举个例子,我们可以泛化下面的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,并且想写一个最大程度通用的代码,而你使用的STL没有提供缺失的非成员函数cbegin,你可以简单的写下你自己的实现。比如,下面就是非成员函数cbegin的实现:

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

结论

  • 优先考虑const_iterator而非iterator
  • 在最大程度通用的代码中,优先考虑非成员函数版本的beginendrbegin等,而非同名成员函数

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

调用者可以查看函数是否声明为noexcept,这个可以影响到调用代码的异常安全性(exception safety)和效率。就其本身而言,函数是否为noexcept和成员函数是否const一样重要。当你知道这个函数不会抛异常而没加上noexcept,那这个接口说明就有点差劲了。

noexcept 允许编译器生成更好的目标代码。

两种表达方式如下:

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++11的移动操作替换C++98的复制操作才是安全的。

但是如何知道一个函数中的移动操作是否产生异常?答案很明显:它检查这个操作是否被声明为noexcept

像是 std::vector::push_back 之类的函数调用std::move_if_noexcept,这是个std::move的变体,根据其中类型的移动构造函数是否为noexcept的. std::move_if_noexcept查阅std::is_nothrow_move_constructible这个type trait.

swap函数是noexcept的另一个绝佳用地。swap是STL算法实现的一个关键组件,它也常用于拷贝运算符重载中。它的广泛使用意味着对其施加不抛异常的优化是非常有价值的。有趣的是,标准库的swap是否noexcept有时依赖于用户定义的swap是否noexcept。比如,数组和std::pairswap声明如下:

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

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。使其noexcept有重大意义,只要可能就应该将它们实现为noexcept

对于一些函数,使其成为noexcept是很重要的,它们应当默认如是。在C++98,允许内存释放(memory deallocation)函数(即operator deleteoperator delete[])和析构函数抛出异常是糟糕的代码设计,C++11将这种作风升级为语言规则。

默认情况下,内存释放函数和析构函数——不管是用户定义的还是编译器生成的——都是隐式noexcept。因此它们不需要声明noexcept

析构函数非隐式noexcept的情况仅当类的数据成员(包括继承的成员还有继承成员内的数据成员)明确声明它的析构函数可能抛出异常(如声明“noexcept(false)”)。

如果一个对象的析构函数可能被标准库使用(比如在容器内或者被传给一个算法),析构函数又可能抛异常,那么程序的行为是未定义的。

有时候, 一些库函数, C++98的函数, 即使是决不抛出异常的, 也没有标识为 noexcept. 因为从C标准库移动到了std命名空间,也可能缺少异常规范,std::strlen就是一个例子,它没有声明noexcept. 另外C++98异常规范和C++11不同.

结论

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

15 尽量使用 constexpr

从概念上来说,constexpr表明一个值不仅仅是常量,还是编译期可知的。这个表述并不全面,因为当constexpr被用于函数的时候,事情就有一些细微差别了。

但是,并不能保证constexpr函数的结果是const,也不能保证它们的返回值是在编译期可知的。

const一样,constexpr是编译期可知的。技术上来讲,它们的值在翻译期(translation)决议,所谓翻译(translation)不仅仅包含是编译(compilation)也包含链接(linking)。

编译期可知的值“享有特权”,它们可能被存放到只读存储空间中。对于那些嵌入式系统的开发者,这个特性是相当重要的。更广泛的应用是 “其值编译期可知” 的常量整数会出现在需要 “整型常量表达式(integral constant expression)的上下文中:包括数组大小,整数模板参数(包括std::array对象的长度),枚举名的值,对齐修饰符(alignas(val)),等等。

1
2
3
4
5
6
7
8
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

注意,I/O语句一般不被允许出现在constexpr函数里。

constexpr 限制

因为constexpr函数必须能在编译期值调用的时候返回编译期结果,就必须对它的实现施加一些限制。这些限制在C++11和C++14标准间有所出入。

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

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

在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; //返回它的副本
}

C++14 constexpr

在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;
}

constexpr函数限制为只能获取和返回字面值类型,这基本上意味着那些有了值的类型能在编译期决定。在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对象

mid对象通过调用构造函数,getter和非成员函数来进行初始化过程就能在只读内存中被创建出来。

constexpr对象和constexpr函数可以使用的范围比non-constexpr对象和函数大得多。使用constexpr关键字可以最大化你的对象和函数可以使用的场景。

还有个重要的需要注意的是constexpr是对象和函数接口的一部分。加上constexpr相当于宣称“我能被用在C++要求常量表达式的地方”。

结论

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

16 让 const 成员函数线程安全

考虑下面的例子,计算多项式的根,多项式的根在多项式确定时,根一般是确定的,声明为 const

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

rootsconst成员函数,那就表示着它是一个读操作。在没有同步的情况下,让多个线程执行读操作是安全的。

但是,在roots中,这些线程中的一个或两个可能尝试修改成员变量rootsAreValidrootVals。这就意味着在没有同步的情况下,这些代码会有不同的线程读写相同的内存,这就是数据竞争(data race)的定义。这段代码的行为是未定义的。

问题就是roots被声明为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 既不可移动,也不可复制。因而包含他们的类也同时是不可移动和不可复制的

在某些情况下,互斥量的副作用显会得过大。例如,如果你所做的只是计算成员函数被调用了多少次,使用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的使用
{
++callCount; //atomic的递增

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

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

std::mutex 类似的,实际上 std::atomic 既不可移动,也不可复制。因而包含他们的类也同时是不可移动和不可复制的

但是只使用 std::atomic 存在以下问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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,因此执行刚才完成的第一个线程相同的计算。(这里的“第二个线程”实际上可能是其他几个线程。)

这种行为与使用缓存的目的背道而驰。将cachedValueCacheValid的赋值顺序交换可以解决这个问题,但结果会更糟。

假设cacheValid是false,那么:

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

对于需要同步的是单个的变量或者内存位置,使用std::atomic就足够了。不过,一旦你需要对两个以上的变量或内存位置作为一个单元来操作的话,就应该使用互斥量。

结论

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

17 理解特殊成员函数的生成

C++98有四个:默认构造函数,析构函数,拷贝构造函数,拷贝赋值运算符。默认构造函数仅在类完全没有构造函数的时候才生成。

C++11特殊成员函数俱乐部迎来了两位新会员:移动构造函数和移动赋值运算符。它们的签名是:

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

Widget(Widget&& rhs); //移动构造函数
Widget& operator=(Widget&& rhs); //移动赋值运算符

};

移动操作仅在需要的时候生成,如果生成了,就会对类的non-static数据成员执行逐成员的移动。

逐成员移动的核心是对对象使用std::move,然后函数决议时会选择执行移动还是拷贝操作。记住如果支持移动就会逐成员移动类成员和基类成员,如果不支持移动就执行拷贝操作就好了。

拷贝构造与移动构造生成方式

如果你声明一个拷贝构造函数,但是没有声明拷贝赋值运算符,如果写的代码用到了拷贝赋值,编译器会帮助你生成拷贝赋值运算符。同样的,如果你声明拷贝赋值运算符但是没有拷贝构造函数,代码用到拷贝构造函数时编译器就会生成它。

两个移动操作不是相互独立的。如果你声明了其中一个,编译器就不再生成另一个。如果你给类声明了,比如,一个移动构造函数,就表明对于移动操作应怎样实现,与编译器应生成的默认逐成员移动有些区别。如果逐成员移动构造有些问题,那么逐成员移动赋值同样也可能有问题。所以声明移动构造函数阻止移动赋值运算符的生成,声明移动赋值运算符同样阻止编译器生成移动构造函数。

如果一个类显式声明了拷贝操作,编译器就不会生成移动操作。这种限制的解释是如果声明拷贝操作(构造或者赋值)就暗示着平常拷贝对象的方法(逐成员拷贝)不适用于该类,编译器会明白如果逐成员拷贝对拷贝操作来说不合适,逐成员移动也可能对移动操作来说不合适。

Rule of Three

如果你声明了拷贝构造函数,拷贝赋值运算符,或者析构函数三者之一,你应该也声明其余两个。

如果一个类显式声明了拷贝操作,编译器就不会生成移动操作。所以,C++11不会为那些有用户定义的析构函数的类生成移动操作。

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

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

假设编译器生成的函数行为是正确的(即逐成员拷贝类non-static数据是你期望的行为),你的工作很简单,C++11的= default就可以表达你想做的:

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

~Widget(); //用户声明的析构函数
//默认拷贝构造函数
Widget(const Widget&) = default; //的行为还可以

Widget& //默认拷贝赋值运算符
operator=(const Widget&) = default; //的行为还可以

};

就算编译器乐于为你的类生成拷贝和移动操作,生成的函数也如你所愿,你也应该手动声明它们然后加上= default。这看起来比较多余,但是它让你的意图更明确,也能帮助你避免一些微妙的bug。

声明析构有潜在的副作用:它阻止了移动操作的生成。然而,拷贝操作的生成是不受影响的。所以手动声明为 =default 是有意义的。

C++11 处理规则

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

结论

  • 特殊成员函数是编译器可能自动生成的函数:默认构造函数,析构函数,拷贝操作,移动操作。
  • 移动操作仅当类没有显式声明移动操作,拷贝操作,析构函数时才自动生成。
  • 拷贝构造函数仅当类没有显式声明拷贝构造函数时才自动生成,并且如果用户声明了移动操作,拷贝构造就是delete
  • 拷贝赋值运算符仅当类没有显式声明拷贝赋值运算符时才自动生成,并且如果用户声明了移动操作,拷贝赋值运算符就是delete。当用户声明了析构函数,拷贝操作的自动生成已被废弃。
  • 成员函数模板不抑制特殊成员函数的生成。

18 对独占资源使用 std::unique_ptr

默认情况下,std::unique_ptr大小等同于原始指针,而且对于大多数操作(包括取消引用),他们执行的指令完全相同。这意味着你甚至可以在内存和时间都比较紧张的情况下使用它。如果原始指针够小够快,那么std::unique_ptr一样可以。

std::unique_ptr体现了专有所有权(exclusive ownership)语义。

一个non-null std::unique_ptr始终拥有其指向的内容。移动一个std::unique_ptr将所有权从源指针转移到目的指针。(源指针被设为null)

拷贝一个std::unique_ptr是不允许的,因为如果你能拷贝一个std::unique_ptr,你会得到指向相同内容的两个std::unique_ptr,每个都认为自己拥有(并且应当最后销毁)资源,销毁时就会出现重复销毁。

因此,std::unique_ptr是一种只可移动类型(move-only type)。当析构时,一个non-null std::unique_ptr销毁它指向的资源。默认情况下,资源析构通过对std::unique_ptr里原始指针调用delete来实现。

Investment继承关系的工厂函数可以这样声明:

1
2
3
template<typename... Ts>            //返回指向对象的std::unique_ptr,
std::unique_ptr<Investment> //对象使用给定实参创建
makeInvestment(Ts&&... params);

调用者应该在单独的作用域中使用返回的std::unique_ptr智能指针:

1
2
3
4
5
6
{

auto pInvestment = //pInvestment是
makeInvestment( arguments ); //std::unique_ptr<Investment>类型

} //销毁 *pInvestment

std::unique_ptr将保证指向内容的析构函数被调用,销毁对应资源。

这个规则也有些例外。大多数情况发生于不正常的程序终止。

如果一个异常传播到线程的基本函数,比如程序初始线程的main函数外,或者违反noexcept说明,局部变量可能不会被销毁;如果std::abort或者退出函数(如std::_Exitstd::exit,或std::quick_exit)被调用,局部变量一定没被销毁。

自定义删除器

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
auto delInvmt = [](Investment* pInvestment)         //自定义删除器
{ //(lambda表达式)
makeLogEntry(pInvestment);
delete pInvestment;
};

template<typename... Ts>
std::unique_ptr<Investment, decltype(delInvmt)> //更改后的返回类型
makeInvestment(Ts&&... params)
{
std::unique_ptr<Investment, decltype(delInvmt)> //应返回的指针
pInv(nullptr, delInvmt);
if (/*一个Stock对象应被创建*/)
{
pInv.reset(new Stock(std::forward<Ts>(params)...));
}
else if ( /*一个Bond对象应被创建*/ )
{
pInv.reset(new Bond(std::forward<Ts>(params)...));
}
else if ( /*一个RealEstate对象应被创建*/ )
{
pInv.reset(new RealEstate(std::forward<Ts>(params)...));
}
return pInv;
}

上述代码中:

  • delInvmt是从makeInvestment返回的对象的自定义的删除器。
  • 删除器类型必须作为第二个类型实参传给std::unique_ptr
  • 尝试将原始指针(比如new创建)赋值给std::unique_ptr通不过编译,因为是一种从原始指针到智能指针的隐式转换。这种隐式转换会出问题,所以C++11的智能指针禁止这个行为。这就是通过reset来让pInv接管通过new创建的对象的所有权的原因。
  • 使用new时,我们使用std::forward把传给makeInvestment的实参完美转发出去。
  • 自定义删除器的一个形参,类型是Investment*,不管在makeInvestment内部创建的对象的真实类型(如StockBond,或RealEstate)是什么,它最终在lambda表达式中,作为Investment*对象被删除。这意味着我们通过基类指针删除派生类实例,为此,基类Investment必须有虚析构函数。

C++14中,存在返回类型推导,写法更为简单:

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
template<typename... Ts>
auto makeInvestment(Ts&&... params) //C++14
{
auto delInvmt = [](Investment* pInvestment) //现在在
{ //makeInvestment里
makeLogEntry(pInvestment);
delete pInvestment;
};

std::unique_ptr<Investment, decltype(delInvmt)> //同之前一样
pInv(nullptr, delInvmt);
if ( … ) //同之前一样
{
pInv.reset(new Stock(std::forward<Ts>(params)...));
}
else if ( … ) //同之前一样
{
pInv.reset(new Bond(std::forward<Ts>(params)...));
}
else if ( … ) //同之前一样
{
pInv.reset(new RealEstate(std::forward<Ts>(params)...));
}
return pInv; //同之前一样
}

当使用默认删除器时(如delete),你可以合理假设std::unique_ptr对象和原始指针大小相同。

但是当自定义删除器时,情况可能不再如此。函数指针形式的删除器,通常会使std::unique_ptr的从一个字(word)大小增加到两个。这可能导致 std::unique_ptr对象变得过大。

1
2
3
4
5
6
7
8
9
void delInvmt2(Investment* pInvestment)             //函数形式的
{ //自定义删除器
makeLogEntry(pInvestment);
delete pInvestment;
}

template<typename... Ts> //返回类型大小是
std::unique_ptr<Investment, void (*)(Investment*)> //Investment*的指针
makeInvestment(Ts&&... params); //加至少一个函数指针的大小

对于函数对象形式的删除器来说,变化的大小取决于函数对象中存储的状态多少,无状态函数(stateless function)对象(比如不捕获变量的lambda表达式)对大小没有影响,这意味当自定义删除器可以实现为函数或者lambda时,尽量使用lambda

1
2
3
4
5
6
7
8
9
auto delInvmt1 = [](Investment* pInvestment)        //无状态lambda的
{ //自定义删除器
makeLogEntry(pInvestment);
delete pInvestment;
};

template<typename... Ts> //返回类型大小是
std::unique_ptr<Investment, decltype(delInvmt1)> //Investment*的大小
makeInvestment(Ts&&... args);

std::shared_ptr 的自动转化

std::unique_ptr是C++11中表示专有所有权的方法,但是其最吸引人的功能之一是它可以轻松高效的转换为std::shared_ptr

1
2
std::shared_ptr<Investment> sp =            //将std::unique_ptr
makeInvestment(arguments); //转为std::shared_ptr

这就是std::unique_ptr非常适合用作工厂函数返回类型的原因的关键部分。 工厂函数无法知道调用者是否要对它们返回的对象使用专有所有权语义,或者共享所有权(即std::shared_ptr)是否更合适。

结论

  • std::unique_ptr是轻量级、快速的、只可移动(move-only)的管理专有所有权语义资源的智能指针
  • 默认情况,资源销毁通过delete实现,但是支持自定义删除器。有状态的删除器(捕获变量的lambda表达式)和函数指针(带参数)会增加std::unique_ptr对象的大小。所以是一般使用无状态的 lambda 表达式
  • std::unique_ptr转化为std::shared_ptr非常简单

19 对于共享资源使用std::shared_ptr

std::shared_ptr通过引用计数(reference count)来确保它是否是最后一个指向某种资源的指针,引用计数关联资源并跟踪有多少std::shared_ptr指向该资源。如果std::shared_ptr在计数值递减后发现引用计数值为零,没有其他std::shared_ptr指向该资源,它就会销毁资源。

引用计数暗示着性能问题:

  • std::shared_ptr大小是原始指针的两倍,因为它内部包含一个指向资源的原始指针,还包含一个指向资源的引用计数值的原始指针。
  • 引用计数的内存几乎使用动态分配std::make_shared创建std::shared_ptr可以避免引用计数的动态分配,但是还存在一些std::make_shared不能使用的场景,这时候引用计数就会动态分配。
  • 递增递减引用计数必须是原子性的,因为多个reader、writer可能在不同的线程。比如,指向某种资源的std::shared_ptr可能在一个线程执行析构(于是递减指向的对象的引用计数),在另一个不同的线程,std::shared_ptr指向相同的对象,但是执行的却是拷贝操作(因此递增了同一个引用计数)。原子操作通常比非原子操作要慢,所以即使引用计数通常只有一个word大小,你也应该假定读写它们是存在开销的。

移动std::shared_ptr会比拷贝它要快:拷贝要求递增引用计数值,移动不需要。移动赋值运算符同理,所以移动构造比拷贝构造快,移动赋值运算符也比拷贝赋值运算符快。

std::unique_ptr 的区别

std::shared_ptr使用delete作为资源的默认销毁机制,但是它也支持自定义的删除器。这种支持有别于std::unique_ptr。对于std::unique_ptr来说,删除器类型是智能指针类型的一部分。对于std::shared_ptr则不是:

1
2
3
4
5
6
7
8
9
10
11
auto loggingDel = [](Widget *pw)        //自定义删除器
{
makeLogEntry(pw);
delete pw;
};

std::unique_ptr< //删除器类型是
Widget, decltype(loggingDel) //指针类型的一部分
> upw(new Widget, loggingDel);
std::shared_ptr<Widget> //删除器类型不是
spw(new Widget, loggingDel); //指针类型的一部分

std::shared_ptr的设计更为灵活。考虑有两个std::shared_ptr<Widget>,每个自带不同的删除器(比如通过lambda表达式自定义删除器):

1
2
3
4
auto customDeleter1 = [](Widget *pw) { … };     //自定义删除器,
auto customDeleter2 = [](Widget *pw) { … }; //每种类型不同
std::shared_ptr<Widget> pw1(new Widget, customDeleter1);
std::shared_ptr<Widget> pw2(new Widget, customDeleter2);

因为 pw1pw2 有相同的类型,所以它们都可以放到存放那个类型的对象的容器中:

1
std::vector<std::shared_ptr<Widget>> vpw{ pw1, pw2 };

它们也能相互赋值,也可以传入一个形参为std::shared_ptr<Widget>的函数。但是自定义删除器类型不同的std::unique_ptr就不行,因为std::unique_ptr把删除器视作类型的一部分。

另一个不同于std::unique_ptr的地方是,指定自定义删除器不会改变std::shared_ptr对象的大小。不管删除器是什么,一个std::shared_ptr对象都是两个指针大小。这是个好消息,但是它应该让你隐隐约约不安。自定义删除器可以是函数对象,函数对象可以包含任意多的数据。它意味着函数对象是任意大的。

引用计数是另一个更大的数据结构的一部分,那个数据结构通常叫做控制块control block)。每个std::shared_ptr管理的对象都有个相应的控制块。控制块除了包含引用计数值外还有一个自定义删除器的拷贝,当然前提是存在自定义删除器。如果用户还指定了自定义分配器,控制块也会包含一个分配器的拷贝。控制块可能还包含一些额外的数据,一个次级引用计数weak count

image-20230918083719236

对于一个创建指向对象的std::shared_ptr的函数来说不可能知道是否有其他std::shared_ptr早已指向那个对象,所以控制块的创建会遵循下面几条规则:

  • std::make_shared总是创建一个控制块。它创建一个要指向的新对象,所以可以肯定std::make_shared调用时对象不存在其他控制块。
  • 当从独占指针(即std::unique_ptr或者std::auto_ptr)上构造出std::shared_ptr时会创建控制块。独占指针没有使用控制块,所以指针指向的对象没有关联控制块。(作为构造的一部分,std::shared_ptr侵占独占指针所指向的对象的独占权,所以独占指针被设置为null)
  • 当从原始指针上构造出std::shared_ptr时会创建控制块。如果你想从一个早已存在控制块的对象上创建std::shared_ptr,你将假定传递一个std::shared_ptr或者std::weak_ptr。作为构造函数实参,而不是原始指针。用std::shared_ptr或者std::weak_ptr作为构造函数实参创建std::shared_ptr不会创建新控制块,因为它可以依赖传递来的智能指针指向控制块。

从原始指针上构造超过一个std::shared_ptr会造成未定义行为,因为指向的对象有多个控制块关联。多个控制块意味着多个引用计数值,多个引用计数值意味着对象将会被销毁多次(每个引用计数一次)。那意味着像下面的代码是有问题的:

1
2
3
4
5
auto pw = new Widget;                           //pw是原始指针

std::shared_ptr<Widget> spw1(pw, loggingDel); //为*pw创建控制块

std::shared_ptr<Widget> spw2(pw, loggingDel); //为*pw创建第二个控制块

使用智能指针而不是原始指针。

使用 std::shared_ptr 的建议是:第一,避免传给std::shared_ptr构造函数原始指针。通常替代方案是使用std::make_shared,不过用std::make_shared就没办法使用自定义删除器。第二,如果你必须传给std::shared_ptr构造函数原始指针,直接传new出来的结果,不要传指针变量。

1
2
std::shared_ptr<Widget> spw1(new Widget,    //直接使用new的结果
loggingDel);

创建spw2也会很自然的用spw1作为初始化参数(即用std::shared_ptr拷贝构造函数),那就没什么问题了:

1
std::shared_ptr<Widget> spw2(spw1);         //spw2使用spw1一样的控制块

this 指针:避免创建多余的控制块

std::enable_shared_from_this。如果你想创建一个用std::shared_ptr管理的类,这个类能够用this指针安全地创建一个std::shared_ptrstd::enable_shared_from_this就可作为基类的模板类。Widget将会继承自std::enable_shared_from_this

1
2
3
4
5
6
class Widget: public std::enable_shared_from_this<Widget> {
public:

void process();

};

这个标准名字就是奇异递归模板模式(The Curiously Recurring Template PatternCRTP))。

std::enable_shared_from_this 定义了一个成员函数,成员函数会创建指向当前对象的std::shared_ptr 却不创建多余控制块。这个成员函数就是shared_from_this,无论在哪当你想在成员函数中使用std::shared_ptr指向this所指对象时都请使用它。这里有个Widget::process的安全实现:

1
2
3
4
5
6
7
void Widget::process()
{
//和之前一样,处理Widget

//把指向当前对象的std::shared_ptr加入processedWidgets
processedWidgets.emplace_back(shared_from_this());
}

从内部来说,shared_from_this查找当前对象控制块,然后创建一个新的std::shared_ptr关联这个控制块。设计的依据是当前对象已经存在一个关联的控制块。

要想符合设计依据的情况,必须已经存在一个指向当前对象的std::shared_ptr(比如调用shared_from_this的成员函数外面已经存在一个std::shared_ptr)。如果没有std::shared_ptr指向当前对象(即当前对象没有关联控制块),行为是未定义的,shared_from_this通常抛出一个异常。

要想防止客户端在存在一个指向对象的std::shared_ptr前先调用含有shared_from_this的成员函数,继承自std::enable_shared_from_this的类通常将它们的构造函数声明为private,并且让客户端通过返回std::shared_ptr的工厂函数创建对象。以Widget为例,代码可以是这样:

1
2
3
4
5
6
7
8
9
10
11
class Widget: public std::enable_shared_from_this<Widget> {
public:
//完美转发参数给private构造函数的工厂函数
template<typename... Ts>
static std::shared_ptr<Widget> create(Ts&&... params);

void process(); //和前面一样

private:
//构造函数
};

确保需要先调用 create,才能调用 process。

shared_ptr 开销

控制块通常只占几个word大小,自定义删除器和分配器可能会让它变大一点。通常控制块的实现比你想的更复杂一些。它使用继承,甚至里面还有一个虚函数(用来确保指向的对象被正确销毁)。这意味着使用std::shared_ptr ,会带来使用虚函数带来的成本。

使用默认删除器和默认分配器,使用std::make_shared创建std::shared_ptr,产生的控制块只需三个word大小。它的分配基本上是无开销的。

std::shared_ptr解引用的开销不会比原始指针高。执行需要原子引用计数修改的操作需要承担一两个原子操作开销,这些操作通常都会一一映射到机器指令上,所以即使对比非原子指令来说,原子指令开销较大,但是它们仍然只是单个指令上的。对于每个被std::shared_ptr指向的对象来说,控制块中的虚函数机制产生的开销通常只需要承受一次,即对象销毁的时候。

如果独占资源可行或者可能可行,用std::unique_ptr是一个更好的选择。它的性能表现更接近于原始指针,并且从std::unique_ptr升级到std::shared_ptr也很容易,因为std::shared_ptr可以从std::unique_ptr上创建。

std::shared_ptr转换到 std::unique_ptr 是不行的 。当你的资源由std::shared_ptr管理,现在又想修改资源生命周期管理方式是没有办法的。即使引用计数为一,你也不能重新修改资源所有权,改用std::unique_ptr管理它。资源和指向它的std::shared_ptr的签订的所有权协议是“除非死亡否则永不分开”。不能分离,不能废除,没有特许。

std::shared_ptr不能处理的另一个东西是数组。和std::unique_ptr不同的是,std::shared_ptr的API设计之初就是针对单个对象的,没有办法std::shared_ptr<T[]>

std::shared_ptr没有提供operator[],所以数组索引操作需要借助怪异的指针算术。另一方面,std::shared_ptr支持转换为指向基类的指针,这对于单个对象来说有效,但是当用于数组类型时这是容易出问题。(出于这个原因,std::unique_ptr<T[]> API禁止这种转换。)

更重要的是,C++11已经提供了很多内置数组的候选方案(比如std::arraystd::vectorstd::string)。所以,声明一个指向数组的智能指针几乎总是糟糕的设计。

结论

  • std::shared_ptr为有共享所有权的任意资源提供一种自动垃圾回收的便捷方式。
  • 较之于std::unique_ptrstd::shared_ptr对象通常大两倍,控制块会产生开销,需要原子性的引用计数修改操作。
  • std::shared_ptr 默认资源销毁是通过delete,但是也支持自定义删除器。但是删除器的类型不是 std::shared_ptr 的类型的一部分。
  • 避免从原始指针变量上创建std::shared_ptr

20 当std::shared_ptr可能悬空时使用std::weak_ptr

一个真正的智能指针应该跟踪所指对象,在悬空时知晓,悬空(dangle)就是指针指向的对象不再存在。这就是对std::weak_ptr最精确的描述。

std::weak_ptr不能解引用,也不能测试是否为空值。因为std::weak_ptr不是一个独立的智能指针。它是std::shared_ptr的增强。

std::weak_ptr通常从std::shared_ptr上创建。当从std::shared_ptr上创建std::weak_ptr时两者指向相同的对象,但是std::weak_ptr不会影响所指对象的引用计数。

1
2
3
4
5
6
7
8
auto spw =                      //spw创建之后,指向的Widget的
std::make_shared<Widget>(); //引用计数(ref count,RC)为1。


std::weak_ptr<Widget> wpw(spw); //wpw指向与spw所指相同的Widget。RC仍为1

spw = nullptr; //RC变为0,Widget被销毁。
//wpw现在悬空

悬空的std::weak_ptr被称作已经expired(过期)。你可以用它直接做测试:

1
if (wpw.expired()) …            //如果wpw没有指向对象…

但是通常你期望的是检查std::weak_ptr是否已经过期,如果没有过期则访问其指向的对象。不过,将检查是否过期和解引用分开会引入竞态条件:在调用expired和解引用操作之间,另一个线程可能对指向这对象的std::shared_ptr重新赋值或者析构,并由此造成对象已析构。这种情况下,你的解引用将会产生未定义行为。

你需要的是一个原子操作检查std::weak_ptr是否已经过期,如果没有过期就访问所指对象。这可以通过从std::weak_ptr创建std::shared_ptr来实现,具体有两种形式可以从std::weak_ptr上创建std::shared_ptr,具体用哪种取决于std::weak_ptr过期时你希望std::shared_ptr表现出什么行为。

一种形式是std::weak_ptr::lock,它返回一个std::shared_ptr,如果std::weak_ptr过期这个std::shared_ptr为空:

1
2
3
std::shared_ptr<Widget> spw1 = wpw.lock();  //如果wpw过期,spw1就为空

auto spw2 = wpw.lock(); //同上,但是使用auto

另一种形式是以std::weak_ptr为实参构造std::shared_ptr。这种情况中,如果std::weak_ptr过期,会抛出一个异常:

1
std::shared_ptr<Widget> spw3(wpw);          //如果wpw过期,抛出std::bad_weak_ptr异常

一个例子

考虑一个工厂函数,它基于一个唯一ID从只读对象上产出智能指针。根据条款18的描述,工厂函数会返回一个该对象类型的std::unique_ptr

1
std::unique_ptr<const Widget> loadWidget(WidgetID id);

如果调用loadWidget是一个昂贵的操作(比如它操作文件或者数据库I/O)并且重复使用ID很常见,一个合理的优化是再写一个函数除了完成loadWidget做的事情之外再缓存它的结果。另一个合理的优化可以是当Widget不再使用的时候销毁它的缓存。

对于可缓存的工厂函数,返回std::unique_ptr不是好的选择。调用者应该接收缓存对象的智能指针,调用者也应该确定这些对象的生命周期,但是缓存本身也需要一个指针指向它所缓存的对象。缓存对象的指针需要知道它是否已经悬空,因为当工厂客户端使用完工厂产生的对象后,对象将被销毁,关联的缓存条目会悬空。所以缓存应该使用std::weak_ptr,这可以知道是否已经悬空。这意味着工厂函数返回值类型应该是std::shared_ptr,因为只有当对象的生命周期由std::shared_ptr管理时,std::weak_ptr才能检测到悬空。

一个简版的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
std::shared_ptr<const Widget> fastLoadWidget(WidgetID id)
{
static std::unordered_map<WidgetID,
std::weak_ptr<const Widget>> cache;
//std::weak_ptr<const Widget>
auto objPtr = cache[id].lock(); //objPtr是去缓存对象的
//std::shared_ptr(或
//当对象不在缓存中时为null)

if (!objPtr) { //如果不在缓存中
objPtr = loadWidget(id); //加载它
cache[id] = objPtr; //缓存它
}
return objPtr;
}

fastLoadWidget的实现仍有以下问题:缓存可能会累积过期的std::weak_ptr,这些指针对应了不再使用的Widget(也已经被销毁了)。

另一个例子

考虑第二个用例:观察者设计模式(Observer design pattern)。

此模式的主要组件是subjects(状态可能会更改的对象)和observers(状态发生更改时要通知的对象)。在大多数实现中,每个subject都包含一个数据成员,该成员持有指向其observers的指针。这使subjects很容易发布状态更改通知。

subjects对控制observers的生命周期(即它们什么时候被销毁)没有兴趣,但是subjects对确保另一件事具有极大的兴趣,那事就是一个observer被销毁时,不再尝试访问它。一个合理的设计是每个subject持有一个std::weak_ptrs容器指向observers,因此可以在使用前检查是否已经悬空。

最后一个例子

考虑一个持有三个对象ABC的数据结构,AC共享B的所有权,因此持有std::shared_ptr

item20_fig1

假定从B指向A的指针也很有用。应该使用哪种指针?

item20_fig2

有三种选择:

  • 原始指针。使用这种方法,如果A被销毁,但是C继续指向BB就会有一个指向A的悬空指针。而且B不知道指针已经悬空,所以B可能会继续访问,就会导致未定义行为。
  • std::shared_ptr。这种设计,AB都互相持有对方的std::shared_ptr,导致的std::shared_ptr环状结构(A指向BB指向A)阻止AB的销毁。甚至AB无法从其他数据结构访问了(比如,C不再指向B),每个的引用计数都还是1。如果发生了这种情况,AB都被泄漏:程序无法访问它们,但是资源并没有被回收。
  • std::weak_ptr。这避免了上述两个问题。如果A被销毁,B指向它的指针悬空,但是B可以检测到这件事。尤其是,尽管AB互相指向对方,B的指针不会影响A的引用计数,因此在没有std::shared_ptr指向A时不会导致A无法被销毁。

但是,需要注意使用std::weak_ptr打破std::shared_ptr循环并不常见。在严格分层的数据结构比如树中,子节点只被父节点持有。当父节点被销毁时,子节点就被销毁。从父到子的链接关系可以使用std::unique_ptr很好的表征。从子到父的反向连接可以使用原始指针安全实现,因为子节点的生命周期肯定短于父节点。因此没有子节点解引用一个悬空的父节点指针这样的风险。

从效率角度来看,std::weak_ptrstd::shared_ptr基本相同。两者的大小是相同的,使用相同的控制块。构造、析构、赋值操作涉及引用计数的原子操作。

虽然,std::weak_ptr不参与对象的共享所有权,因此不影响指向对象的引用计数。实际上在控制块中还是有第二个引用计数,std::weak_ptr操作的是第二个引用计数。

结论

  • std::weak_ptr替代可能会悬空的std::shared_ptr
  • std::weak_ptr的潜在使用场景包括:缓存、观察者列表、打破std::shared_ptr环状结构。

21 优先考虑使用std::make_uniquestd::make_shared,而非直接使用new

std::make_shared是C++11标准的一部分,但是,std::make_unique是从C++14开始加入标准库。

一个基础版本的std::make_unique是很容易自己写出的,如下:

1
2
3
4
template<typename T, typename... Ts>
std::unique_ptr<T> make_unique(Ts&&... params) {
return std::unique_ptr<T>(new T(std::forward<Ts>(params)...));
}

这种形式的函数不支持数组和自定义析构(见条款18)

std::make_uniquestd::make_shared是三个make函数 中的两个:接收任意的多参数集合,完美转发到构造函数去动态分配一个对象,然后返回这个指向这个对象的指针。

第三个make函数是std::allocate_shared。它行为和std::make_shared一样,只不过第一个参数是用来动态分配内存的allocator对象。

使用 std::make_unique 的理由一

例如:

1
2
3
4
auto upw1(std::make_unique<Widget>());      //使用make函数
std::unique_ptr<Widget> upw2(new Widget); //不使用make函数
auto spw1(std::make_shared<Widget>()); //使用make函数
std::shared_ptr<Widget> spw2(new Widget); //不使用make函数

我高亮了关键区别:使用new的版本重复了类型,但是make函数的版本没有。

重复写类型和软件工程里面一个关键原则相冲突:应该避免重复代码。源代码中的重复增加了编译的时间,会导致目标代码冗余,并且通常会让代码库使用更加困难。

它经常演变成不一致的代码,而代码库中的不一致常常导致bug。

使用 std::make_unique 的理由二

在调用processWidget时使用了new而不是std::make_shared

1
2
processWidget(std::shared_ptr<Widget>(new Widget),  //潜在的资源泄漏!
computePriority());

内存泄漏的原因在于:

在运行时,一个函数的实参必须先被计算,这个函数再被调用,所以在调用processWidget之前,必须执行以下操作,processWidget才开始执行:

  • 表达式“new Widget”必须计算,例如,一个Widget对象必须在堆上被创建
  • 负责管理new出来指针的std::shared_ptr<Widget>构造函数必须被执行
  • computePriority必须运行

而编译器不保证按照顺序生成代码。

虽然“new Widget”必须在std::shared_ptr的构造函数被调用前执行,因为new出来的结果作为构造函数的实参,但computePriority可能在这之前,之后,或者之间执行。也就是说,编译器可能按照这个执行顺序生成代码:

  1. 执行“new Widget
  2. 执行computePriority
  3. 运行std::shared_ptr构造函数

在运行时computePriority产生了异常,那么第一步动态分配的Widget就会泄漏。因为它永远都不会被第三步的std::shared_ptr所管理了。

使用std::make_shared可以防止这种问题。调用代码看起来像是这样:

1
2
processWidget(std::make_shared<Widget>(),   //没有潜在的资源泄漏
computePriority());

在运行时,std::make_sharedcomputePriority其中一个会先被调用。

使用 std::make_unique 的理由三

使用std::make_shared允许编译器生成更小,更快的代码,并使用更简洁的数据结构。考虑以下对new的直接使用:

1
std::shared_ptr<Widget> spw(new Widget);

显然,这段代码需要进行内存分配,但它实际上执行了两次。

每个std::shared_ptr指向一个控制块,其中包含被指向对象的引用计数,还有其他东西。这个控制块的内存在std::shared_ptr构造函数中分配。因此,直接使用new需要为Widget进行一次内存分配,为控制块再进行一次内存分配。

如果使用std::make_shared代替:

1
auto spw = std::make_shared<Widget>();

一次分配足矣。这是因为std::make_shared分配一块内存,同时容纳了Widget对象和控制块。这种优化减少了程序的静态大小,因为代码只包含一个内存分配调用,并且它提高了可执行代码的速度,因为内存只分配一次。此外,使用std::make_shared避免了对控制块中的某些簿记信息的需要,潜在地减少了程序的总内存占用。

不使用 std::make_shared 的情况

需要自定义删除器时

make函数都不允许指定自定义删除器,但是std::unique_ptrstd::shared_ptr有构造函数这么做。有个Widget的自定义删除器:

1
auto widgetDeleter = [](Widget* pw) { … };

创建一个使用它的智能指针只能直接使用new

1
2
3
4
std::unique_ptr<Widget, decltype(widgetDeleter)>
upw(new Widget, widgetDeleter);

std::shared_ptr<Widget> spw(new Widget, widgetDeleter);

对于make函数,没有办法做同样的事情。

不支持花括号调用 std::initializer_list

常规的用花括号创建的对象更倾向于使用std::initializer_list作为形参的重载形式,而用小括号创建对象将调用不用std::initializer_list作为参数的的重载形式。

但是,在这些调用中,

1
2
auto upv = std::make_unique<std::vector<int>>(10, 20);
auto spv = std::make_shared<std::vector<int>>(10, 20);

生成的智能指针指向带有10个元素的std::vector,每个元素值为20。

如果你想用花括号初始化指向的对象,你必须直接使用new

一个变通的方法:使用auto类型推导从花括号初始化创建std::initializer_list对象,然后将auto创建的对象传递给make函数。

1
2
3
4
//创建std::initializer_list
auto initList = { 10, 20 };
//使用std::initializer_list为形参的构造函数创建std::vector
auto spv = std::make_shared<std::vector<int>>(initList);

对于std::unique_ptr,只有这两种情景(自定义删除器和花括号初始化)使用make函数有点问题。对于std::shared_ptr和它的make函数,还有2个问题。都属于边缘情况,但是一些开发者常碰到。

类重载了operator newoperator delete

例如,Widget类的operator newoperator delete只会处理sizeof(Widget)大小的内存块的分配和释放。因为std::allocate_shared需要的内存总大小不等于动态分配的对象大小,还需要再加上控制块大小。

与直接使用new相比,std::make_shared在大小和速度上的优势源于std::shared_ptr的控制块与指向的对象放在同一块内存中。当对象的引用计数降为0,对象被销毁(即析构函数被调用)。但是,因为控制块和对象被放在同一块分配的内存块中,直到控制块的内存也被销毁,对象占用的内存才被释放。

控制块除了引用计数,还包含簿记信息。引用计数追踪有多少std::shared_ptrs指向控制块,但控制块还有第二个计数,记录多少个std::weak_ptrs指向控制块。第二个引用计数就是weak count。当一个std::weak_ptr检测它是否过期时,它会检测指向的控制块中的引用计数(而不是weak count)。

如果引用计数是0(即对象没有std::shared_ptr再指向它,已经被销毁了),std::weak_ptr就已经过期。

但是只要std::weak_ptrs引用一个控制块(即weak count大于零),该控制块必须继续存在。只要控制块存在,包含它的内存就必须保持分配。

所以,通过std::shared_ptrmake函数分配的内存,直到最后一个std::shared_ptr和最后一个指向它的std::weak_ptr已被销毁,才会释放。

所以,如果对象类型非常大,而且销毁最后一个std::shared_ptr和销毁最后一个std::weak_ptr之间的时间很长,那么在销毁对象和释放它所占用的内存之间可能会出现延迟。

例如,下面这种情况,明显,直接只用new,对象的释放会立即执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ReallyBigType { … };

auto pBigObj = //通过std::make_shared
std::make_shared<ReallyBigType>(); //创建一个大对象

//创建std::shared_ptrs和std::weak_ptrs
//指向这个对象,使用它们

//最后一个std::shared_ptr在这销毁,
//但std::weak_ptrs还在

//在这个阶段,原来分配给大对象的内存还分配着

//最后一个std::weak_ptr在这里销毁;
//控制块和对象的内存被释放

直接只用new,一旦最后一个std::shared_ptr被销毁,ReallyBigType对象的内存就会被释放:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class ReallyBigType { … };              //和之前一样

std::shared_ptr<ReallyBigType> pBigObj(new ReallyBigType);
//通过new创建大对象

//像之前一样,创建std::shared_ptrs和std::weak_ptrs
//指向这个对象,使用它们

//最后一个std::shared_ptr在这销毁,
//但std::weak_ptrs还在;
//对象的内存被释放

//在这阶段,只有控制块的内存仍然保持分配

//最后一个std::weak_ptr在这里销毁;
//控制块内存被释放

一个优化的例子

考虑前面的 processWidget 函数,现在我们指定一个自定义删除器:

1
2
3
void processWidget(std::shared_ptr<Widget> spw,     //和之前一样
int priority);
void cusDel(Widget *ptr); //自定义删除器

下面这个是非异常安全的调用:

1
2
3
4
processWidget( 									    //和之前一样,
std::shared_ptr<Widget>(new Widget, cusDel), //潜在的内存泄漏!
computePriority()
);

还是实参调用的顺序问题。

一个优化方式如下:

1
2
std::shared_ptr<Widget> spw(new Widget, cusDel);
processWidget(spw, computePriority()); // 正确,但是没优化,见下

但是有一个性能问题,实参在前一个非异常安全调用中,std::shared_ptr形参是传值,从右值构造只需要移动。

而优化后,传递左值构造需要拷贝。对std::shared_ptr而言,这种区别是有意义的,因为拷贝std::shared_ptr需要对引用计数原子递增,移动则不需要对引用计数有操作。

所以,更高效安全的版本是:

1
processWidget(std::move(spw), computePriority());   //高效且异常安全

结论

  • 和直接使用new相比,make函数消除了代码重复,提高了异常安全性。对于std::make_sharedstd::allocate_shared,生成的代码更小更快。
  • 不适合使用make函数的情况包括需要指定自定义删除器和希望用花括号初始化。
  • 对于std::shared_ptrs,其他不建议使用make函数的情况包括:(1) 有自定义内存管理的类;(2) 特别关注内存的系统,非常大的对象,以及std::weak_ptrs比对应的std::shared_ptrs活得更久。

22 当使用 Pimpl Idiom,请在实现文件中定义特殊成员函数

Pimpl Idiom 将类数据成员替换成一个指向包含具体实现的类(implementation class)(或结构体)的指针,并将原本放在主类(primary class)的相关数据成员们移动到实现类(implementation class)去,而这些数据成员的访问将通过指针间接访问。

举个例子:

1
2
3
4
5
6
7
8
9
class Widget() {                    //定义在头文件“widget.h”
public:
Widget();

private:
std::string name;
std::vector<double> data;
Gadget g1, g2, g3; //Gadget是用户自定义的类型
};

因为类Widget的数据成员包含有类型std::stringstd::vectorGadget, 定义有这些类型的头文件在类Widget编译的时候,必须被包含进来,这意味着类Widget的使用者必须要#include <string><vector>以及gadget.h

这些头文件将会增加类Widget使用者的编译时间,并且让这些使用者依赖于这些头文件。 如果一个头文件的内容变了,类Widget使用者也必须要重新编译。 标准库文件<string><vector>不是很常变,但是gadget.h可能会经常修订。

在C++98中使用Pimpl惯用法,可以把Widget的数据成员替换成一个原始指针,指向一个已经被声明过却还未被定义的结构体:

1
2
3
4
5
6
7
8
9
10
11
class Widget                        //仍然在“widget.h”中
{
public:
Widget();
~Widget();


private:
struct Impl; //声明一个 实现结构体
Impl *pImpl; //以及指向它的指针
};

因为类Widget不再提到类型std::stringstd::vector以及GadgetWidget的使用者不再需要为了这些类型而引入头文件。 这可以加速编译,并且意味着,如果这些头文件中有所变动,Widget的使用者不会受到影响。

一个已经被声明,却还未被实现的类型,被称为未完成类型incomplete type)。 Widget::Impl就是这种类型。

下一步是对 实现类(implementation class) 的内存管理:

Widget.cpp里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include "widget.h"             //以下代码均在实现文件“widget.cpp”里
#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl { //含有之前在Widget中的数据成员的
std::string name; //Widget::Impl类型的定义
std::vector<double> data;
Gadget g1,g2,g3;
};

Widget::Widget() //为此Widget对象分配数据成员
: pImpl(new Impl)
{}

Widget::~Widget() //销毁数据成员
{ delete pImpl; }

它使用了原始指针,原始的new和原始的delete,一切都让它如此的...原始。

使用智能指针

如果我们想要的只是在类Widget的构造函数动态分配Widget::impl对象,在Widget对象销毁时一并销毁它, std::unique_ptr是最合适的工具。

1
2
3
4
5
6
7
8
9
class Widget {                      //在“widget.h”中
public:
Widget();


private:
struct Impl;
std::unique_ptr<Impl> pImpl; //使用智能指针而不是原始指针
};

实现文件也可以改成如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "widget.h"                 //在“widget.cpp”中
#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl { //跟之前一样
std::string name;
std::vector<double> data;
Gadget g1,g2,g3;
};

Widget::Widget()
: pImpl(std::make_unique<Impl>())
{}

问题出现了

以上的代码能编译,但是,最普通的Widget用法却会导致编译出错:

1
2
3
#include "widget.h"

Widget w; //错误!

在对象w被析构时(例如离开了作用域),问题出现了。

在这个时候,它的析构函数被调用。我们在类的定义里使用了std::unique_ptr,所以我们没有声明析构函数。编译器会自动为我们生成一个析构函数。 在这个析构函数里,编译器会插入一些代码来调用类Widget的数据成员pImpl的析构函数。

问题就在于,此时pImpl的析构函数调用默认的删除器。默认删除器是一个函数,它使用delete来销毁内置于std::unique_ptr的原始指针。然而,在使用delete之前,通常会使默认删除器使用C++11的特性static_assert来确保原始指针指向的类型不是一个未完成类型。

需要确保在编译器生成销毁std::unique_ptr<Widget::Impl>的代码之前, Widget::Impl已经是一个完成类型(complete type)。 当编译器“看到”它的定义的时候,该类型就成为完成类型了。 但是 Widget::Impl的定义在widget.cpp里。

成功编译的关键,就是在widget.cpp文件内,让编译器在“看到” Widget的析构函数实现之前(也即编译器插入的,用来销毁std::unique_ptr这个数据成员的代码段之前),先定义Widget::Impl

修改方法就是,在widget.h里只声明类Widget的析构函数,但不要在这里定义它:

1
2
3
4
5
6
7
8
9
10
class Widget {                  //跟之前一样,在“widget.h”中
public:
Widget();
~Widget(); //只有声明语句


private: //跟之前一样
struct Impl;
std::unique_ptr<Impl> pImpl;
};

widget.cpp文件中,在结构体Widget::Impl被定义之后,再定义析构函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include "widget.h"                 //跟之前一样,在“widget.cpp”中
#include "gadget.h"
#include <string>
#include <vector>

// 先于析构函数定义
struct Widget::Impl { //跟之前一样,定义Widget::Impl
std::string name;
std::vector<double> data;
Gadget g1,g2,g3;
}

Widget::Widget() //跟之前一样
: pImpl(std::make_unique<Impl>())
{}

Widget::~Widget() //析构函数的定义
{}

如果你想强调编译器自动生成的析构函数会做和你一样正确的事情,你可以直接使用“= default”定义析构函数体

1
Widget::~Widget() = default;        //同上述代码效果一致

移动

编译器自动生成的移动操作对其中的std::unique_ptr进行移动。但是,声明一个类Widget的析构函数会阻止编译器生成移动操作,所以你需要这样做:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Widget {                                  //仍然在“widget.h”中
public:
Widget();
~Widget();

Widget(Widget&& rhs) = default; //思路正确,
Widget& operator=(Widget&& rhs) = default; //但代码错误


private: //跟之前一样
struct Impl;
std::unique_ptr<Impl> pImpl;
};

问题在于:

编译器生成的移动赋值操作符,在重新赋值之前,需要先销毁指针pImpl指向的对象。然而在Widget的头文件里,pImpl指针指向的是一个未完成类型。

移动构造函数的情况有所不同。 移动构造函数的问题是编译器自动生成的代码里,包含有抛出异常的事件,在这个事件里会生成销毁pImpl的代码。

这些都需要,Impl是一个完成类型。

解决方法:

把移动操作的定义移动到实现文件里

1
2
3
4
5
6
7
8
9
10
11
12
13
class Widget {                          //仍然在“widget.h”中
public:
Widget();
~Widget();

Widget(Widget&& rhs); //只有声明
Widget& operator=(Widget&& rhs);


private: //跟之前一样
struct Impl;
std::unique_ptr<Impl> pImpl;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
#include <string>                   //跟之前一样,仍然在“widget.cpp”中


struct Widget::Impl { … }; //跟之前一样

Widget::Widget() //跟之前一样
: pImpl(std::make_unique<Impl>())
{}

Widget::~Widget() = default; //跟之前一样

Widget::Widget(Widget&& rhs) = default; //这里定义
Widget& Widget::operator=(Widget&& rhs) = default;

拷贝

对于 struct Impl 中数据成员,可以使用默认拷贝函数,完成拷贝动作。

1
2
3
4
5
6
7
8
9
10
11
class Widget {                          //仍然在“widget.h”中
public:


Widget(const Widget& rhs); //只有声明
Widget& operator=(const Widget& rhs);

private: //跟之前一样
struct Impl;
std::unique_ptr<Impl> pImpl;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <string>                   //跟之前一样,仍然在“widget.cpp”中


struct Widget::Impl { … }; //跟之前一样

Widget::~Widget() = default; //其他函数,跟之前一样

Widget::Widget(const Widget& rhs) //拷贝构造函数
: pImpl(std::make_unique<Impl>(*rhs.pImpl))
{}

Widget& Widget::operator=(const Widget& rhs) //拷贝operator=
{
*pImpl = *rhs.pImpl;
return *this;
}

利用了编译器会为我们自动生成结构体Impl的复制操作函数的机制,而不是逐一复制结构体Impl的成员,自动生成的复制操作能自动复制每一个成员。

std::shared_ptr

如果我们使用std::shared_ptr而不是std::unique_ptr来做pImpl指针,本条款的建议不再适用。

不需要在类Widget里声明析构函数,没有了用户定义析构函数,编译器将会成移动操作,并且将会如我们所期望般工作。widget.h里的代码如下,

1
2
3
4
5
6
7
8
9
class Widget {                      //在“widget.h”中
public:
Widget();
//没有析构函数和移动操作的声明

private:
struct Impl;
std::shared_ptr<Impl> pImpl; //用std::shared_ptr
}; //而不是std::unique_ptr

这是#includewidget.h的客户代码,

1
2
3
Widget w1;
auto w2(std::move(w1)); //移动构造w2
w1 = std::move(w2); //移动赋值w1

这些都能编译,并且工作地如我们所望:w1将会被默认构造,它的值会被移动进w2,随后值将会被移动回w1,然后两者都会被销毁(指向的Widget::Impl对象一并也被销毁)。

std::unique_ptrstd::shared_ptrpImpl指针上的表现上的区别的深层原因在于,他们支持自定义删除器的方式不同。

std::unique_ptr而言,删除器的类型是这个智能指针的一部分,这让编译器有可能生成更小的运行时数据结构和更快的运行代码。 这种更高效率的后果之一就是std::unique_ptr指向的类型,在编译器的生成特殊成员函数(如析构函数,移动操作)被调用时,必须已经是一个完成类型。

而对std::shared_ptr而言,删除器的类型不是该智能指针的一部分,这让它会生成更大的运行时数据结构和稍微慢点的代码,但是当编译器生成的特殊成员函数被使用的时候,指向的对象不必是一个完成类型。

结论

  • Pimpl 惯用法通过减少在类实现和类使用者之间的编译依赖来减少编译时间。
  • 对于std::unique_ptr类型的pImpl指针,需要在头文件的类里声明特殊成员函数,并在实现文件中 struct Impl定义之后来实现他们。即使是编译器自动生成的代码可以工作,也要这么做。
  • 以上的建议只适用于std::unique_ptr,不适用于std::shared_ptr

23 理解std::movestd::forward

Intro

  • 移动语义使编译器有可能用廉价的移动操作来代替昂贵的拷贝操作。正如拷贝构造函数和拷贝赋值操作符给了你控制拷贝语义的权力,移动构造函数和移动赋值操作符也给了你控制移动语义的权力。移动语义也允许创建只可移动(move-only)的类型,例如std::unique_ptrstd::futurestd::thread

  • 完美转发使接收任意数量实参的函数模板成为可能,它可以将实参转发到其他的函数,使目标函数接收到的实参与被传递给转发函数的实参保持一致。

  • 右值引用是连接这两个截然不同的概念的胶合剂。它是使移动语义和完美转发变得可能的基础语言机制。

但是,std::move并不移动任何东西,完美转发也并不完美。移动操作并不永远比复制操作更廉价。构造“type&&”也并非总是代表一个右值引用。

非常重要的一点是要牢记形参永远是左值,即使它的类型是一个右值引用。比如,假设

1
void f(Widget&& w);

形参w是一个左值,即使它的类型是一个rvalue-reference-to-Widget

std::movestd::forward不会做什么

std::move不移动(move)任何东西,std::forward也不转发(forward)任何东西。在运行时,它们不做任何事情。它们不产生任何可执行代码,一字节也没有。

std::move

C++11的std::move的示例实现。它并不完全满足标准细则,但是它已经非常接近了。

1
2
3
4
5
6
7
8
9
template<typename T>                            
typename remove_reference<T>::type&&
move(T&& param)
{
using ReturnType =
typename remove_reference<T>::type&&;

return static_cast<ReturnType>(param);
}

该函数返回类型的&&部分表明std::move函数返回的是一个右值引用,但是,如果类型T恰好是一个左值引用,那么T&&将会成为一个左值引用。

所以,使用 std::remove_reference,得到 ReturnType。这保证了std::move返回的真的是右值引用。

std::move在C++14中可以被更简单地实现。

1
2
3
4
5
6
template<typename T>
decltype(auto) move(T&& param) //在std命名空间
{
using ReturnType = remove_referece_t<T>&&;
return static_cast<ReturnType>(param);
}

因此,std::move将它的实参转换为一个右值,这就是它的全部作用。

const 的限制

1
2
3
4
5
6
7
8
9
10
11
class Annotation {
public:
explicit Annotation(const std::string text)
:value(std::move(text)) //“移动”text到value里;这段代码执行起来
{ … } //并不是看起来那样



private:
std::string value;
};

这段代码可以编译,可以链接,可以运行。

text通过std::move被转换到右值,但是text被声明为const std::string,所以在转换之前,text是一个左值的const std::string,而转换的结果是一个右值的const std::string

那么,string 对 value 赋值时,调用的是哪个构造函数?

1
2
3
4
5
6
7
class string {                  //std::string事实上是
public: //std::basic_string<char>的类型别名

string(const string& rhs); //拷贝构造函数
string(string&& rhs); //移动构造函数

};

右值不能被传递给std::string的移动构造函数,因为移动构造函数只接受一个指向non-conststd::string的右值引用。

该右值却可以被传递给std::string的拷贝构造函数,因为lvalue-reference-to-const允许被绑定到一个const右值上。因此,std::string在成员初始化的过程中调用了拷贝构造函数。

可以总结出两点:

第一,不要在你希望能移动对象的时候,声明他们为const

第二,std::move不仅不移动任何东西,而且它也不保证它执行转换的对象可以被移动。

std::forward

std::move总是无条件的将它的实参为右值不同,std::forward有条件的转换。

最常见的情景是一个模板函数,接收一个通用引用形参(T&&),并将它传递给另外的函数:

1
2
3
4
5
6
7
8
9
10
11
12
void process(const Widget& lvalArg);        //处理左值
void process(Widget&& rvalArg); //处理右值

template<typename T> //用以转发param到process的模板
void logAndProcess(T&& param)
{
auto now = //获取现在时间
std::chrono::system_clock::now();

makeLogEntry("Calling 'process'", now);
process(std::forward<T>(param));
}

考虑两次对logAndProcess的调用,一次左值为实参,一次右值为实参:

1
2
3
4
Widget w;

logAndProcess(w); //用左值调用
logAndProcess(std::move(w)); //用右值调用

std::forward 将保留实参的值类型,传递到 process 函数,调用正确的函数重载。

对比

考虑一个类,我们希望统计有多少次移动构造函数被调用了。我们只需要一个static的计数器,它会在移动构造的时候自增。假设在这个类中,唯一一个非静态的数据成员是std::string,一种经典的移动构造函数(即,使用std::move)可以被实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
class Widget {
public:
Widget(Widget&& rhs)
: s(std::move(rhs.s))
{ ++moveCtorCalls; }



private:
static std::size_t moveCtorCalls;
std::string s;
};

如果要用std::forward来达成同样的效果,代码可能会看起来像:

1
2
3
4
5
6
7
8
9
class Widget{
public:
Widget(Widget&& rhs) //不自然,不合理的实现
: s(std::forward<std::string>(rhs.s))
{ ++moveCtorCalls; }



}

std::forward不但需要一个函数实参(rhs.s),还需要一个模板类型实参std::string

std::move的使用代表着无条件向右值的转换,而使用std::forward只对绑定了右值的引用进行到右值转换。这是两种完全不同的动作。前者是典型地为了移动操作,而后者只是传递(亦为转发)一个对象到另外一个函数,保留它原有的左值属性或右值属性。

结论

  • std::move执行到右值的无条件的转换,但就自身而言,它不移动任何东西。
  • std::forward只有当它的参数被绑定到一个右值时,才将参数转换为右值。
  • std::movestd::forward在运行期什么也不做。

24 区分通用引用与右值引用

为了声明一个指向某个类型T的右值引用,你写下了T&&。但是,这不一定是一个右值引用:

1
2
3
4
5
6
7
8
9
void f(Widget&& param);             //右值引用
Widget&& var1 = Widget(); //右值引用
auto&& var2 = var1; //不是右值引用

template<typename T>
void f(std::vector<T>&& param); //右值引用

template<typename T>
void f(T&& param); //不是右值引用

T&&”有两种不同的意思。第一种,当然是右值引用。它们只绑定到右值上,并且它们主要的存在原因就是为了识别可以移动操作的对象。

T&&”的另一种意思是,它既可以是右值引用,也可以是左值引用。它们的二重性使它们既可以绑定到右值上(就像右值引用),也可以绑定到左值上(就像左值引用)。 此外,它们还可以绑定到const或者non-const的对象上,也可以绑定到volatile或者non-volatile的对象上,甚至可以绑定到既constvolatile的对象上。它们可以绑定到几乎任何东西。它叫做通用引用universal references)。

一些C++社区的成员已经开始将这种通用引用称之为转发引用forwarding references

通用引用,其特性是引用折叠决定的。

初始化

通用引用是引用,所以它们必须被初始化。一个通用引用的初始值决定了它是代表了右值引用还是左值引用。如果初始值是一个右值,那么通用引用就会是对应的右值引用,如果初始值是一个左值,那么通用引用就会是一个左值引用。对那些是函数形参的通用引用来说,初始值在调用函数的时候被提供:

1
2
3
4
5
6
7
8
9
template<typename T>
void f(T&& param); //param是一个通用引用

Widget w;
f(w); //传递给函数f一个左值;param的类型
//将会是Widget&,也即左值引用

f(std::move(w)); //传递给f一个右值;param的类型会是
//Widget&&,即右值引用

对一个通用引用而言,类型推导是必要的,但是其必须是 T&& 形式,如果是 std::vector<T>&& 的形式,那就变成了 右值引用。

而如果传入左值,那么是不能传入右值参数的。

一个例子

考虑如下push_back成员函数,来自std::vector

1
2
3
4
5
6
7
template<class T, class Allocator = allocator<T>>   //来自C++标准
class vector
{
public:
void push_back(T&& x);

}

push_back函数的形参当然有一个通用引用的正确形式,然而,在这里并没有发生类型推导。类型推导发生在 vector 实例化时。

作为对比,std::vector内的概念上相似的成员函数emplace_back,却确实包含类型推导:

1
2
3
4
5
6
7
template<class T, class Allocator = allocator<T>>   //依旧来自C++标准
class vector {
public:
template <class... Args>
void emplace_back(Args&&... args);

};

类型参数(type parameterArgs是独立于vector的类型参数T的,所以Args会在每次emplace_back被调用的时候被推导。

所以,此时是一个通用引用。

auto&&

类型声明为auto&&的变量是通用引用,因为会发生类型推导,并且它们具有正确形式(T&&)。auto类型的通用引用不如函数模板形参中的通用引用常见,但是它们在C++11中常常突然出现。而它们在C++14中出现得更多,因为C++14的lambda表达式可以声明auto&&类型的形参。

举个例子,如果你想写一个C++14标准的lambda表达式,来记录任意函数调用的时间开销,你可以这样写:

1
2
3
4
5
6
7
8
9
auto timeFuncInvocation =
[](auto&& func, auto&&... params) //C++14
{
start timer;
std::forward<decltype(func)>(func)( //对params调用func
std::forward<delctype(params)>(params)...
);
stop timer and record elapsed time;
};

结论

  • 如果一个函数模板形参的类型为type&&,并且type需要被推导得知,或者如果一个对象被声明为auto&&,这个形参或者对象就是一个通用引用。
  • 如果类型声明的形式不是标准的type&&,或者如果类型推导没有发生,那么type&&代表一个右值引用。
  • 通用引用,如果它被右值初始化,就会对应地成为右值引用;如果它被左值初始化,就会成为左值引用。

25 对右值引用使用std::move,对通用引用使用std::forward

在参数传递时,std::forward是有条件的传递,会根据参数的类型,传递实际的参数形式,右值还是右值,左值还是左值。

std::move是无条件的将其变为右值。

有的时候,并不一定需要对象的移动操作。区分移动和拷贝是有必要的。遇到下面这种情况:

1
2
3
4
5
6
7
8
9
10
class Widget {
public:
void setName(const std::string& newName) //用const左值设置
{ name = newName; }

void setName(std::string&& newName) //用右值设置
{ name = std::move(newName); }


};

如果不用 通用引用,那么实现会变得冗长,尤其是参数数量较多的时候。

使用通用引用 + 完美转发 std::forward,那么实现会优雅得多。

并且对于变参函数模板:

1
2
3
4
5
template<class T, class... Args>                //来自C++11标准
shared_ptr<T> make_shared(Args&&... args);

template<class T, class... Args> //来自C++14标准
unique_ptr<T> make_unique(Args&&... args);

对于这种函数,对于左值和右值分别重载就不能考虑了:通用引用是仅有的实现方案。对这种函数,我向你保证,肯定使用std::forward传递通用引用形参给其他函数。

返回值的情况

如果你在按值返回的函数中,返回值绑定到右值引用或者通用引用上,需要对返回的引用使用std::move或者std::forward

1
2
3
4
5
6
Matrix                              //按值返回
operator+(Matrix&& lhs, const Matrix& rhs)
{
lhs += rhs;
return std::move(lhs); //移动lhs到返回值中
}

通过在return语句中将lhs转换为右值(通过std::move),lhs可以移动到返回值的内存位置。如果省略了std::move调用,

1
2
3
4
5
6
Matrix                              //同之前一样
operator+(Matrix&& lhs, const Matrix& rhs)
{
lhs += rhs;
return lhs; //拷贝lhs到返回值中
}

lhs是个左值的事实,会强制编译器拷贝它到返回值的内存空间。

假定Matrix支持移动操作,并且比拷贝操作效率更高,在return语句中使用std::move的代码效率更高。

如果Matrix不支持移动操作,将其转换为右值不会变差,因为右值可以直接被Matrix的拷贝构造函数拷贝。

使用通用引用和std::forward的情况类似。考虑函数模板reduceAndCopy收到一个未reduce(unreduced)对象Fraction,将其规约,并返回一个reduce (规约,好难听的名字) 后的副本。

如果原始对象是右值,可以将其移动到返回值中(避免拷贝开销),但是如果原始对象是左值,必须创建副本,因此如下代码:

1
2
3
4
5
6
7
template<typename T>
Fraction //按值返回
reduceAndCopy(T&& frac) //通用引用的形参
{
frac.reduce();
return std::forward<T>(frac); //移动右值,或拷贝左值到返回值中
}

如果std::forward被忽略,frac就被无条件复制到reduceAndCopy的返回值内存空间。

注意,对于函数内部的局部变量,这是不成立的。

1
2
3
4
5
6
Widget makeWidget()                 //makeWidget的“拷贝”版本
{
Widget w; //局部对象
//配置w
return w; //“拷贝”w到返回值中
}

他们想要“优化”代码,把“拷贝”变为移动:

1
2
3
4
5
6
Widget makeWidget()                 //makeWidget的移动版本
{
Widget w;

return std::move(w); //移动w到返回值中(不要这样做!)
}

因为有 RVO 的存在,makeWidget的“拷贝”版本实际上不拷贝任何东西。在返回的地址上,进行对象的构造。

但是 move 版本,不支持 RVO。

返回的已经不是局部对象w,而是w的引用——std::move(w)的结果。

结论

  • 对于传入函数的形参,在函数内最后一次使用时,在右值引用上使用std::move,在通用引用上使用std::forward
  • 对按值返回的函数要返回的右值引用使用std::move,和通用引用使用std::forward
  • 如果局部对象可以被返回值优化消除,就绝不使用std::move或者std::forward

26 避免重载通用引用

弊端一

比如,下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<typename T>
void logAndAdd(T&& name)
{
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}

void logAndAdd(int idx) //新的重载
{
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(nameFromIdx(idx));
}

如果有以下调用:

1
2
3
short nameIdx;

logAndAdd(nameIdx); //错误!

由于没有 short 类型的重载,但是有通用引用存在,所以name形参绑定到要传入的short上,然后namestd::forwardnames(一个std::multiset<std::string>)的emplace成员函数,然后又被转发给std::string构造函数。std::string没有接受short的构造函数,所以logAndAdd调用里的multiset::emplace调用里的std::string构造函数调用失败。

所有这一切的原因就是对于short类型通用引用重载优先于int类型的重载。这导致了代码执行出错。

使用通用引用的函数在C++中是最贪婪的函数。它们几乎可以精确匹配任何类型的实参。

通用引用的实现会匹配比开发者预期要多得多的实参类型。

弊端二

有以下Person类:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Person {
public:
template<typename T>
explicit Person(T&& n) //完美转发的构造函数,初始化数据成员
: name(std::forward<T>(n)) {}

explicit Person(int idx) //int的构造函数
: name(nameFromIdx(idx)) {}


private:
std::string name;
};

函数模板能实例化产生与拷贝和移动构造函数一样的签名。如果拷贝和移动构造被生成,Person类看起来就像这样:

1
2
3
4
5
6
7
8
9
10
11
12
class Person {
public:
template<typename T> //完美转发的构造函数
explicit Person(T&& n)
: name(std::forward<T>(n)) {}

explicit Person(int idx); //int的构造函数

Person(const Person& rhs); //拷贝构造函数
Person(Person&& rhs); //移动构造函数

};

如果通过 non-const左值类型的Person 来拷贝构造一个新的对象,完美转发的构造函数会优先匹配。

如果通过 const左值类型的Person 来拷贝构造一个新的对象,拷贝构造函数会优先匹配,因为这是精确匹配。

如果在继承关系中,会有以下行为:

1
2
3
4
5
6
7
8
9
10
class SpecialPerson: public Person {
public:
SpecialPerson(const SpecialPerson& rhs) //拷贝构造函数,调用基类的
: Person(rhs) //基类的完美转发构造函数!
{ … }

SpecialPerson(SpecialPerson&& rhs) //移动构造函数,调用基类的
: Person(std::move(rhs)) //基类的完美转发构造函数!
{ … }
};

派生类的拷贝和移动构造函数没有调用基类的拷贝和移动构造函数,而是调用了基类的完美转发构造函数

派生类将SpecialPerson类型的实参传递给其基类,然后通过模板实例化和重载解析规则作用于基类Person。最终,代码无法编译,因为std::string没有接受一个SpecialPerson的构造函数(只有完美转发构造函数初始化了 name )。

结论

  • 对通用引用形参的函数进行重载时,通用引用函数可匹配的类型,几乎总会比你期望的多得多。
  • 完美转发构造函数是糟糕的实现,因为对于non-const左值,它们会优先于拷贝构造函数匹配,而且会劫持派生类对于基类的拷贝和移动构造函数的调用。

27 通用引用重载的替代方法

一个直接的思路,放弃重载,另外声明一个函数签名。

另一种思路,放弃重载,但是使用 lvalue-refrence-to-const 的方式,参数类型变为 const T&。

放弃重载的另一种思路是,直接传值 + std::mov,的方式。

另外两种方案,保留了重载,但是都有局限,效率更高但是并不是万能的。这两种方案是:tag dispatchenable_if 约束模板

tag dispath

实现形式:

1
2
3
4
5
6
7
8
9
10
template<typename T>
void logAndAdd(T&& name)
{
logAndAddImpl(
std::forward<T>(name),
std::is_integral<typename std::remove_reference<T>::type>()
// C++14
// std::is_integral<std::remove_reference<T>>()
);
}

原来函数模板不变,但是将实际的函数调用,进行了分发(dispatch)。

之所以 remove_reference,是因为 T 可能被推导为左值 T&,这不是 type trait std::is_integral 识别为真的类型。

分发实现为:

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T>                            //非整型实参:添加到全局数据结构中
void logAndAddImpl(T&& name, std::false_type)
{
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}

std::string nameFromIdx(int idx); // 整型实参:查找名字并用它调用logAndAdd
void logAndAddImpl(int idx, std::true_type)
{
logAndAdd(nameFromIdx(idx));
}

这里的 std::false_typestd::true_type ,是 T 分别在不满足 std::is_integral和满足 std::is_integral 的情况下的父类。

enable_if 约束模板

tag dispath 并不能解决父类的通用引用重载函数的问题(见上一条款 26)。

enable_if 约束模板,基于以下机制:

默认情况下,所有模板是启用的(enabled),但是使用std::enable_if可以使得仅在std::enable_if指定的条件满足时模板才启用。不满足条件,模板就是被禁止(disabled)的。

首先解决传入 Person 类对象,导致通用引用重载中,string 用 Person 对象初始化报错的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// C++11
class Person {
public:
template<
typename T,
typename = typename std::enable_if<
// !std::is_base_of<Person,
!std::is_base_of<Person,
typename std::decay<T>::type
>::value
>::type
>
explicit Person(T&& n);

};

使用 type trait is_same ,可以在传入 Person 对象时,禁用模板。但是,传入子类对象,同样时不允许的,所以使用了 std::is_base_of

std::decay<T> 去掉了对于T的引用,constvolatile修饰。

再结合对传入 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
class Person {
public:
template<
typename T,
typename = std::enable_if_t<
!std::is_base_of<Person, std::decay_t<T>>::value
&&
!std::is_integral<std::remove_reference_t<T>>::value
>
>
explicit Person(T&& n)
: name(std::forward<T>(n)) //std::strings的实参的构造函数
{
//断言可以用T对象创建std::string
static_assert(
std::is_constructible<std::string, T>::value,
"Parameter n can't be used to construct a std::string"
);
...
}

explicit Person(int idx) //整型实参的构造函数
: name(nameFromIdx(idx))
{ … }

//拷贝、移动构造函数等

private:
std::string name;
};

其中:

1
2
template< bool B, class T = void >
using enable_if_t = typename enable_if<B,T>::type; // (since C++14)

这里的 static_assert 断言虽然可以识别,T 类型是否可以构建 string,但是这发生在 name(std::forward<T>(n)) 之后。所以,报错先于断言。

结论

  • 通用引用重载的替代方法:使用不同的函数名,通过lvalue-reference-to-const传递形参,按值传递形参,使用tag dispatch,使用 enable_if 约束模板。
  • 通用引用参数通常具有高效率的优势,但是其使用需要仔细分析。

28 引用折叠

目的就是禁止你生成引用的引用。

存在两种类型的引用(左值和右值),所以有四种可能的引用组合(左值的左值,左值的右值,右值的右值,右值的左值)。

这些组合的的结果:如果两个中任一引用为左值引用,则结果为左值引用。否则(即,如果引用都是右值引用),结果为右值引用。

组合 结果
左值的右值 左值
左值的左值 左值
右值的左值 左值
右值的右值 右值

std::forword实现

std::forward应用在通用引用参数上,所以经常能看到这样使用:

1
2
3
4
5
6
template<typename T>
void f(T&& fParam)
{
//做些工作
someFunc(std::forward<T>(fParam)); //转发fParam到someFunc
}

因为fParam是通用引用,类型参数T的类型根据f被传入实参(即用来实例化fParam的表达式)是左值还是右值来决定。

std::forward的作用是当且仅当传给f的实参为右值时(此时T为非引用类型),才将fParam转化为一个右值。

std::forward可以这样实现:

1
2
3
4
5
template<typename T>                                //在std命名空间
T&& forward(typename remove_reference<T>::type& param)
{
return static_cast<T&&>(param);
}

在C++14中,std::remove_reference_t的存在使得实现变得更简洁:

1
2
3
4
5
template<typename T>                        //C++14;仍然在std命名空间
T&& forward(remove_reference_t<T>& param)
{
return static_cast<T&&>(param);
}

假设传入到f的实参是Widget的左值类型。T被推导为Widget&,然后调用std::forward将实例化为std::forward<Widget&>

Widget&带入到上面的std::forward的实现中:

1
2
3
Widget& && forward(typename 
remove_reference<Widget&>::type& param)
{ return static_cast<Widget& &&>(param); }

std::remove_reference<Widget&>::type这个type trait产生Widget ,所以std::forward成为:

1
2
Widget& && forward(Widget& param)
{ return static_cast<Widget& &&>(param); }

根据引用折叠规则,返回值和强制转换可以化简,最终版本的std::forward调用就是:

1
2
Widget& forward(Widget& param)
{ return static_cast<Widget&>(param); }

当左值实参被传入到函数模板f时,std::forward被实例化为接受和返回左值引用。

如果传入右值,那么结果会是这样:

1
2
Widget&& forward(Widget& param)
{ return static_cast<Widget&&>(param); }

auto

在auto的写法中,规则是类似的。声明

1
auto&& w1 = w;

用一个左值初始化w1,因此为auto推导出类型Widget&。把Widget&代回w1声明中的auto里,产生了引用的引用,

1
Widget& && w1 = w;

应用引用折叠规则,就是

1
Widget& w1 = w

结果就是w1是一个左值引用。

下面这个声明,

1
auto&& w2 = widgetFactory();

使用右值初始化w2,为auto推导出非引用类型Widget。把Widget代入auto得到:

1
Widget&& w2 = widgetFactory()

没有引用的引用,这就是最终结果,w2是个右值引用。

通用引用

通用引用不是一种新的引用,它实际上是满足以下两个条件下的右值引用:

  • 类型推导区分左值和右值T类型的左值被推导为T&类型,T类型的右值被推导为T
  • 发生引用折叠

结论

  • 引用折叠发生在四种情况下:模板实例化,auto类型推导,typedef与别名声明的创建和使用,decltype
  • 当编译器在引用折叠环境中生成了引用的引用时,结果就是单个引用。带有左值引用的引用折叠,结果就是左值引用。否则就是右值引用。
  • 通用引用就是引用折叠的结果。

29 移动操作的缺点

升级C++11之前的代码

C++11倾向于为缺少移动操作的类生成它们,但是只有在没有声明复制操作,移动操作,析构函数的类中才会生成移动操作。

另外数据成员或者某类型的基类禁止移动操作,编译器不生成移动操作的支持。

所以,对于没有明确支持移动操作的类型,并且不符合编译器默认生成的条件的类,没有理由期望C++11会比C++98进行任何性能上的提升。

移动大对象

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

//把数据存进vw1


//把vw1移动到vw2。以常数时间运行。只有vw1和vw2中的指针被改变
auto vm2 = std::move(vm1);

std::array没有这种指针实现,数据就保存在std::array对象中:

1
2
3
4
5
6
7
std::array<Widget, 10000> aw1;

//把数据存进aw1


//把aw1移动到aw2。以线性时间运行。aw1中所有元素被移动到aw2
auto aw2 = std::move(aw1);

移动还是遍历了所有元素。

移动小字符串

std::string提供了常数时间的移动操作和线性时间的复制操作。

许多字符串的实现采用了小字符串优化(small string optimization,SSO)。“小”字符串(比如长度小于15个字符的)存储在了std::string的缓冲区中,并没有存储在堆内存,移动这种存储的字符串并不比复制操作更快(并不会执行指针的复制,而是将字符串完全从一个位置拷贝到另一个位置,再清空原来的内存)。

结论

C++11的移动语义并无优势:

  • 没有移动操作:要移动的对象没有提供移动操作,所以移动的写法也会变成复制操作。

  • 移动不会更快:要移动的对象提供的移动操作并不比复制速度更快。

  • 移动不可用:进行移动的上下文要求移动操作不会抛出异常,但是该操作没有被声明为noexcept

  • 源对象是左值:除了极少数的情况外,只有右值可以作为移动操作的来源。

30 完美转发失败的情况

完美转发perfect forwarding)意味着我们不仅转发对象,我们还转发显著的特征:它们的类型,是左值还是右值,是const还是volatile

有以下函数:

1
2
3
4
5
template<typename... Ts>
void fwd(Ts&&... params) //接受任何实参
{
f(std::forward<Ts>(params)...); //转发给f
}

讨论下面函数调用失败的情况:

1
2
f( expression );        //调用f执行某个操作
fwd( expression ); //但调用fwd执行另一个操作,则fwd不能完美转发expression给f

花括号初始化器

假定f这样声明:

1
void f(const std::vector<int>& v);

在这个例子中,用花括号初始化调用f通过编译,

1
f({ 1, 2, 3 });         //可以,“{1, 2, 3}”隐式转换为std::vector<int>

但是传递相同的列表初始化给fwd不能编译

1
fwd({ 1, 2, 3 });       //错误!不能编译

当通过调用函数模板fwd间接调用f时,编译器不再把调用地传入给fwd的实参和f的声明中形参类型进行比较。

而是推导传入给fwd的实参类型,然后比较推导后的实参类型和f的形参声明类型。

编译器不允许在对fwd的调用中推导表达式{ 1, 2, 3 }的类型,因为fwd的形参没有声明为std::initializer_list。对于fwd形参的推导类型被阻止,编译器只能拒绝该调用。

但是,使用花括号初始化的auto的变量的类型推导是成功的。这种变量被视为std::initializer_list对象,在转发函数应推导出类型为std::initializer_list的情况,这提供了一种简单的解决方法——使用auto声明一个局部变量,然后将局部变量传进转发函数:

1
2
auto il = { 1, 2, 3 };  //il的类型被推导为std::initializer_list<int>
fwd(il); //可以,完美转发il给f

0或者NULL

当你试图传递0或者NULL作为空指针给模板时,类型推导会出错,会把传来的实参推导为一个整型类型(典型情况为int)而不是指针类型。结果就是不管是0还是NULL都不能作为空指针被完美转发。解决方法非常简单,传一个nullptr而不是0或者NULL

只有声明的 static const 数据成员

下面的代码:

1
2
3
4
5
6
7
8
9
class Widget {
public:
static const std::size_t MinVals = 28; //MinVal的声明

};
//没有MinVals定义

std::vector<int> widgetData;
widgetData.reserve(Widget::MinVals); //使用MinVals

使用MinVals调用f是可以的,因为编译器直接将值28代替MinVals

1
f(Widget::MinVals);         //可以,视为“f(28)”

不过如果我们尝试通过fwd调用f,事情不会进展那么顺利:

1
fwd(Widget::MinVals);       //错误!

代码可以编译,但是不应该链接。

尽管代码中没有使用MinVals的地址,但是fwd的形参是通用引用。

而引用,在编译器生成的代码中,通常被视作指针。

在程序的二进制底层代码中(以及硬件中)指针和引用是一样的。在这个水平上,引用只是可以自动解引用的指针。在这种情况下,通过引用传递MinVals实际上与通过指针传递MinVals是一样的,因此,必须有内存使得指针可以指向。

链接时,链接不到内存,就会报错。

只要给整型static const提供一个定义,就可以解决问题了,比如这样:

1
const std::size_t Widget::MinVals;  //在Widget的.cpp文件

注意定义中不要重复初始化。如果在两个地方都提供了初始化,编译器就会报错,提醒你只能初始化一次。

重载函数的名称和模板名称

假设有了一个重载函数,processVal

1
2
3
4
void f(int (*pf)(int));

int processVal(int value);
int processVal(int value, int priority);

传递给 f 是没问题的,因为编译器是可以基于现有信息判断调用哪一个重载函数的。

但是,fwd(processVal); 不行。 单用processVal是没有类型信息的,所以就不能类型推导,完美转发失败。

需要这样使用:

1
2
3
4
5
6
using ProcessFuncType = int (*)(int);

ProcessFuncType processValPtr = processVal; //指定所需的processVal签名

fwd(processValPtr); //可以
fwd(static_cast<ProcessFuncType>(workOnVal)); //也可以

对于模板,有相似的问题。一个函数模板不代表单独一个函数,它表示一个函数族:

1
2
3
4
5
template<typename T>
T workOnVal(T param) //处理值的模板
{ … }

fwd(workOnVal); //错误!哪个workOnVal实例?

要让像fwd的完美转发函数接受一个重载函数名或者模板名,方法是指定要转发的那个重载或者实例。

位域

IPv4的头部有如下模型:(假定位域是按从最低有效位(least significant bit,lsb)到最高有效位(most significant bit,msb)

1
2
3
4
5
6
7
8
struct IPv4Header {
std::uint32_t version:4,
IHL:4,
DSCP:6,
ECN:2,
totalLength:16;

};

如果声明我们的函数f(转发函数fwd的目标)为接收一个std::size_t的形参,则使用IPv4Header对象的totalLength字段进行调用没有问题:

1
2
3
4
5
void f(std::size_t sz);         //要调用的函数

IPv4Header h;

f(h.totalLength); //可以

如果通过fwd转发h.totalLengthf呢,那就是一个不同的情况了:

1
fwd(h.totalLength);             //错误!

问题在于fwd的形参是引用,而h.totalLength是non-const位域,这是C++不允许的行为。

位域可能包含了一个字的任意部分(比如32位int的3-5位),但是这些东西无法直接寻址。在硬件层面引用和指针是一样的,所以没有办法创建一个指向任意bit的指针。

传递位域给完美转发的方法就是,创建副本然后利用副本调用完美转发。在IPv4Header的例子中,可以如下写法:

1
2
3
4
//拷贝位域值
auto length = static_cast<std::uint16_t>(h.totalLength);

fwd(length); //转发这个副本

结论

  • 导致完美转发失败的实参种类有:花括号初始化,作为空指针的0或者NULL,仅有声明的static const数据成员,模板和重载函数的名字,位域。

30 Lambda 表达式

闭包enclosure)是lambda创建的运行时对象。依赖捕获模式,闭包持有被捕获数据的副本或者引用。

闭包类closure class)是从中实例化闭包的类。每个lambda都会使编译器生成唯一的闭包类。lambda中的语句成为其闭包类的成员函数中的可执行指令。

lambda通常被用来创建闭包,该闭包仅用作函数的实参。闭包通常可以拷贝,所以可能有多个闭包对应于一个lambda

但是对于闭包,需要明白的一点是:区分什么存在于编译期(lambdas 和闭包类),什么存在于运行时(闭包)以及它们之间的相互关系。

避免使用默认的捕获模式

按默认引用捕获会导致闭包中包含了对某个局部变量或者形参的引用。如果该lambda创建的闭包生命周期超过了局部变量,那么闭包中的引用将会变成悬空引用。

另外,成员函数中,使用捕获需要明白 this 指针的存在,直接捕获成员变量是会出错的。

一个解决方案是:

1
2
3
4
5
6
7
8
9
void Widget::addFilter() const
{
auto divisorCopy = divisor; //拷贝数据成员

filters.emplace_back(
[divisorCopy](int value) //捕获副本
{ return value % divisorCopy == 0; } //使用副本
);
}

在C++14中,一个更好的捕获成员变量的方式时使用通用的lambda捕获:

1
2
3
4
5
6
7
void Widget::addFilter() const
{
filters.emplace_back( //C++14:
[divisor = divisor](int value) //拷贝divisor到闭包
{ return value % divisor == 0; } //使用这个副本
);
}

如果是 static 成员,那么默认捕获行为将什么也不会捕获。

结论

  • 默认的按引用捕获可能会导致悬空引用。
  • 默认的按值捕获对于悬空指针很敏感(尤其是this指针),并且它会误导人产生lambda是独立的想法。

31 使用 init capture 来移动对象到闭包

在某些场景下,按值捕获和按引用捕获都不是你所想要的。如果你有一个只能被移动的对象(例如std::unique_ptrstd::future)要进入到闭包里,使用C++11是无法实现的。到了C++14就另一回事了,它能支持将对象移动到闭包中。

init capture

C++14中,这是使用初始化捕获将std::unique_ptr移动到闭包中的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Widget {                          //一些有用的类型
public:

bool isValidated() const;
bool isProcessed() const;
bool isArchived() const;
private:

};

auto pw = std::make_unique<Widget>(); //创建Widget;使用std::make_unique
//的有关信息参见条款21

//设置*pw

auto func = [pw = std::move(pw)] //使用std::move(pw)初始化闭包数据成员
{ return pw->isValidated()
&& pw->isArchived(); };

pw = std::move(pw)”的意思是“在闭包中创建一个数据成员pw,并使用将std::move应用于局部变量pw的结果来初始化该数据成员”。

在C++11中,无法捕获表达式的结果。 因此,初始化捕获的另一个名称是通用lambda捕获generalized lambda capture)。

lambda表达式只是生成一个类和创建该类型对象的一种简单方式而已。没什么是你用lambda可以做而不能自己手动实现的。

C++14的示例代码可以用C++11重新编写,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class IsValAndArch {                            //“is validated and archived”
public:
using DataType = std::unique_ptr<Widget>;

explicit IsValAndArch(DataType&& ptr) //条款25解释了std::move的使用
: pw(std::move(ptr)) {}

bool operator()() const
{ return pw->isValidated() && pw->isArchived(); }

private:
DataType pw;
};

auto func = IsValAndArch(std::make_unique<Widget>());

使用 bind 的解决方法:

C++11的等效代码如下,其中我强调了相同的关键事项:

1
2
3
4
5
6
7
8
9
10
std::vector<double> data;           



auto func =
std::bind( //C++11模拟初始化捕获
[](const std::vector<double>& data) //译者注:本行高亮
{ /*使用data*/ },
std::move(data) //译者注:本行高亮
);

默认情况下,从lambda生成的闭包类中的operator()成员函数为const的。在lambda主体内把闭包中的所有数据成员渲染为const

但是,bind对象内部的移动构造的data副本不是const的,因此,为了防止在lambda内修改该data副本,lambda的形参应声明为reference-to-const。 如果将lambda声明为mutable,则闭包类中的operator()将不会声明为const,并且在lambda的形参声明中省略const也是合适的:

1
2
3
4
5
6
auto func =
std::bind( //C++11对mutable lambda
[](std::vector<double>& data) mutable //初始化捕获的模拟
{ /*使用data*/ },
std::move(data)
);

结论

  • 使用C++14的初始化捕获将对象移动到闭包中。
  • 在C++11中,可以通过手写类或std::bind的方式来模拟初始化捕获。

33 对auto&&形参使用decltype

1
2
auto f = [](auto&& x)
{ return func(normalize(std::forward<???>(x))); };

这里的???该是什么?

在泛型lambda中,没有可用的类型参数T。在lambda生成的闭包里,模版化的operator()函数中的确有一个T,但在lambda里却无法直接使用它。

如果一个左值实参被传给通用引用的形参,那么形参类型会变成左值引用。传递的是右值,形参就会变成右值引用。这意味着在这个lambda中,可以通过检查形参x的类型来确定传递进来的实参是一个左值还是右值,decltype就可以实现这样的效果。

传递给lambda的是一个左值,decltype(x)就能产生一个左值引用;如果传递的是一个右值,decltype(x)就会产生右值引用。

在调用std::forward时,惯例决定了类型实参是左值引用时来表明要传进左值,类型实参是非引用就表明要传进右值。

在前面的lambda中,如果x绑定的是一个左值,decltype(x)就能产生一个左值引用。

然而如果x绑定的是一个右值,decltype(x)就会产生右值引用,而不是常规的非引用。

但是decltype(x)就会产生右值引用传入 std::forward 后,引用折叠后的结果和传入非引用的结果是相同的。

所以decltype(x) 完美解决了问题。

C++14中的lambda也可以是可变形参的,最后的实现如下:

1
2
3
4
5
6
auto f =
[](auto&&... params)
{
return
func(normalize(std::forward<decltype(params)>(params)...));
};

34 优先考虑lambda而非std::bind

优先lambda而不是std::bind的最重要原因是lambda更易读。

但是,在C++11中,可以在两种情况下使用std::bind是合理的:

  • 移动捕获。C++11的lambda不提供移动捕获,但是可以通过结合lambdastd::bind来模拟。
  • 多态函数对象。因为bind对象上的函数调用运算符使用完美转发,所以它可以接受任何类型的实参

例如:

1
2
3
4
5
6
class PolyWidget {
public:
template<typename T>
void operator()(const T& param);

};

std::bind可以如下绑定一个PolyWidget对象:

1
2
PolyWidget pw;
auto boundPW = std::bind(pw, _1);

boundPW可以接受任意类型的对象了:

1
2
3
boundPW(1930);              //传int给PolyWidget::operator()
boundPW(nullptr); //传nullptr给PolyWidget::operator()
boundPW("Rosebud"); //传字面值给PolyWidget::operator()

这一点无法使用C++11的lambda做到。 但是,在C++14中,可以通过带有auto形参的lambda轻松实现:

1
2
auto boundPW = [pw](const auto& param)  //C++14 
{ pw(param); };

结论

  • 与使用std::bind相比,lambda更易读,更具表达力并且可能更高效。
  • 只有在C++11中,std::bind 对实现移动捕获,或者绑定函数模板,会很有用。

并发API

C++11的伟大成功之一是将并发整合到语言和库中。

35 优先考虑基于任务的编程而非基于线程的编程

如果开发者想要异步执行doAsyncWork函数,通常有两种方式。其一是通过创建std::thread执行doAsyncWork,这是应用了基于线程thread-based)的方式:

1
2
int doAsyncWork();
std::thread t(doAsyncWork);

其二是将doAsyncWork传递给std::async,一种基于任务task-based)的策略:

1
auto fut = std::async(doAsyncWork); //“fut”表示“future”

这种方式中,传递给std::async的函数对象被称为一个任务task)。

基于任务的方法通常比基于线程的方法更优:

  • 代码量更少
  • 如果 task 的返回值是必需的,那么 thread-based 的方式将无能为力。而基于任务的方法就简单了,因为std::async返回的future提供了get函数(从而可以获取返回值)。
  • 如果doAsycnWork发生了异常,get函数就显得更为重要,因为get函数可以提供抛出异常的访问,而基于线程的方法,如果doAsyncWork抛出了异常,程序会直接终止(通过调用std::terminate)。

区别

基于线程与基于任务最根本的区别在于,基于任务的抽象层次更高。基于任务的方式使得开发者从线程管理的细节中解放出来,对此在C++并发软件中总结了“thread”的三种含义:

  • 硬件线程(hardware threads)是真实执行计算的线程。现代计算机体系结构为每个CPU核心提供一个或者多个硬件线程。
  • 软件线程(software threads)(也被称为系统线程(OS threads、system threads))是操作系统管理的在硬件线程上执行的线程。通常可以存在比硬件线程更多数量的软件线程。当软件线程被阻塞的时候(比如 I/O、同步锁或者条件变量),操作系统可以调度其他未阻塞的软件线程执行提供吞吐量。
  • std::thread 是C++执行过程的对象,并作为软件线程的句柄(handle)。

软件线程是有限的资源。如果开发者试图创建大于系统支持的线程数量,会抛出std::system_error异常。

即使没有超出软件线程的限额,仍然可能会遇到资源超额oversubscription)的麻烦。当前准备运行的(即未阻塞的)软件线程大于硬件线程的数量时,线程调度器会将软件线程时间切片,分配到硬件上。

当一个软件线程的时间片执行结束,会让给另一个软件线程,此时发生上下文切换。软件线程的上下文切换会增加系统的软件线程管理开销。当软件线程安排到与上次时间片运行时不同的硬件线程上,这个开销会更高。

使用std::async 将线程管理的职责转交给C++标准库的开发者。举个例子,这种调用方式会减少抛出资源超额异常的可能性,因为这个调用可能不会开启一个新的线程。合理的调度器在系统资源超额或者线程耗尽时就会利用这个自由度。

通过向std::async传递std::launch::async启动策略来保证想运行函数在不同的线程上执行,处理不同线程响应优先级的问题。

线程调度器使用系统级线程池(thread pool)来避免资源超额的问题,并且通过工作窃取算法(work-stealing algorithm)来提升了跨硬件核心的负载均衡。实现更为繁琐。

直接使用std::thread编程,处理线程耗尽、资源超额、负责均衡问题的责任就压在了你身上,更不用说你对这些问题的解决方法与同机器上其他程序采用的解决方案配合得好不好了。

基于任务的设计为开发者避免了手动线程管理的痛苦,并且自然提供了一种获取异步执行程序的结果(即返回值或者异常)的方式。当然,仍然存在一些场景直接使用std::thread会更有优势:

  • 你需要访问非常基础的线程API。C++并发API通常是通过操作系统提供的系统级API(pthreads或者Windows threads)来实现的,系统级API通常会提供更加灵活的操作方式(举个例子,C++没有线程优先级和亲和性的概念)。为了提供对底层系统级线程API的访问,std::thread对象提供了native_handle的成员函数,而std::future(即std::async返回的东西)没有这种能力。
  • 你需要且能够优化应用的线程使用
  • 你需要实现C++并发API之外的线程技术,比如,实现未支持的平台的线程池。

结论

  • std::thread API不能直接访问异步执行的结果,如果执行函数有异常抛出,代码会终止执行。
  • 基于线程的编程方式需要手动的线程耗尽、资源超额、负责均衡、平台适配性管理。
  • 通过带有默认启动策略的std::async进行基于任务的编程方式会解决大部分问题。

36 如有必要指定std::launch::async

  • std::launch::async启动策略意味着f必须异步执行,即在不同的线程。
  • std::launch::deferred启动策略意味着f仅当在std::async返回的future上调用get或者wait时才执行。这表示f推迟到存在这样的调用时才执行。当getwait被调用,f会同步执行,即调用方被阻塞,直到f运行结束。如果getwait都没有被调用,f将不会被执行。

std::async的默认启动策略,如果你不显式指定一个策略,不是上面中任意一个。相反,是求或在一起的。下面的两种调用含义相同:

1
2
3
4
auto fut1 = std::async(f);                      //使用默认启动策略运行f
auto fut2 = std::async(std::launch::async | //使用async或者deferred运行f
std::launch::deferred,
f);

因此默认策略允许f异步或者同步执行。

这导致了三种结果:

  • 无法预测f是否会与t并发运行,因为f可能被安排延迟运行。
  • 无法预测f是否会在与某线程相异的另一线程上执行,这个某线程在fut上调用getwait。如果对fut调用函数的线程是t,无法预测f是否在异于t的另一线程上执行。
  • 无法预测f是否执行

所以,以下循环看似应该最终会终止,但可能实际上永远运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
using namespace std::literals;      //为了使用C++14中的时间段后缀

void f() //f休眠1秒,然后返回
{
std::this_thread::sleep_for(1s);
}

auto fut = std::async(f); //异步运行f(理论上)

while (fut.wait_for(100ms) != //循环,直到f完成运行时停止...
std::future_status::ready) //但是有可能永远不会发生!
{

}

如果f与调用std::async的线程并发运行(即,如果为f选择的启动策略是std::launch::async),这里没有问题(假定f最终会执行完毕),但是如果f是延迟执行,fut.wait_for将总是返回std::future_status::deferred。这永远不等于std::future_status::ready,循环会永远执行下去。

改进的方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
auto fut = std::async(f);               //同上

if (fut.wait_for(0s) == //如果task是deferred(被延迟)状态
std::future_status::deferred)
{
//在fut上调用wait或get来异步调用f
} else { //task没有deferred(被延迟)
while (fut.wait_for(100ms) != //不可能无限循环(假设f完成)
std::future_status::ready) {
//task没deferred(被延迟),也没ready(已准备)
//做并行工作直到已准备
}
//fut是ready(已准备)状态
}

一个总是使用 std::launch::async 的函数实现如下:

C++11版本如下:

1
2
3
4
5
6
7
8
9
template<typename F, typename... Ts>
inline
std::future<typename std::result_of<F(Ts...)>::type>
reallyAsync(F&& f, Ts&&... params) //返回异步调用f(params...)得来的future
{
return std::async(std::launch::async,
std::forward<F>(f),
std::forward<Ts>(params)...);
}

在C++14中,reallyAsync返回类型的推导能力可以简化函数的声明:

1
2
3
4
5
6
7
8
9
template<typename F, typename... Ts>
inline
auto // C++14
reallyAsync(F&& f, Ts&&... params)
{
return std::async(std::launch::async,
std::forward<F>(f),
std::forward<Ts>(params)...);
}

结论

  • std::async的默认启动策略是异步和同步执行兼有的。
  • std::async的默认启动策略,隐含了任务可能不会被执行的意思,会影响调用基于超时的wait的程序逻辑。
  • 如果异步执行任务非常关键,则指定std::launch::async

37 使std::thread 是 unjoinable 的

每个std::thread对象处于两个状态之一:可结合的joinable)或者不可结合的unjoinable)。

可结合状态的std::thread对应于正在运行或者可能要运行的异步执行线程。比如,对应于一个阻塞的(blocked)或者等待调度的线程的std::thread是可结合的;对应于运行结束的线程的std::thread也可以认为是可结合的。

不可结合的 std::thread 包括:

  • 默认构造的std::threads。这种std::thread没有函数执行,因此没有对应到底层执行线程上。
  • 已经被移动走的std::thread对象。移动的结果就是一个std::thread原来对应的执行线程现在对应于另一个std::thread
  • 已经被joinstd::thread 。在join之后,std::thread不再对应于已经运行完了的执行线程。
  • 已经被detachstd::threaddetach断开了std::thread对象与执行线程之间的连接。

如果发生 std::thread 析构,而 std::thread 是 joinable,那么会造成程序终止。析构时发生的 隐式join 可能还会访问已经被回收的值。隐式detach ,可能出现访问或者修改没有所有权的内存的行为。

解决方法,使用 RAII 对象类管理,保证每当在执行跳至块之外时,调用局部对象的析构函数。

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
class ThreadRAII {
public:
enum class DtorAction { join, detach };

ThreadRAII(std::thread&& t, DtorAction a) // 在析构函数中对t实行a动作
: action(a), t(std::move(t)) {} // `std::thread`不可以复制

~ThreadRAII()
{
if (t.joinable()) { //可结合性测试见下
if (action == DtorAction::join) {
t.join();
} else {
t.detach();
}
}
}

ThreadRAII(ThreadRAII&&) = default; //支持移动
ThreadRAII& operator=(ThreadRAII&&) = default;

std::thread& get() { return t; }

private: // as before
DtorAction action;
std::thread t; // 最后声明,实例化时,前面的成员都是可用状态
};

结论

  • 析构时join会导致难以调试的表现异常问题。
  • 析构时detach会导致难以调试的未定义行为。
  • 声明类数据成员时,最后声明std::thread对象。

38 future析构行为

结论

  • future的正常析构行为就是销毁future本身的数据成员。
  • 使用std::async启动的 future,引用了共享状态(std::shared_future)的最后一个future的析构函数会阻塞住,直到任务完成。

39 简单事件通信

一个任务通知另一个异步执行的任务发生了特定的事件很有用,因为第二个任务要等到这个事件发生之后才能继续执行。事件也许是一个数据结构已经初始化,也许是计算阶段已经完成,或者检测到重要的传感器值。

使用条件变量

如果我们将检测条件的任务称为检测任务detecting task),对条件作出反应的任务称为反应任务reacting task),策略很简单:反应任务等待一个条件变量,检测任务在事件发生时改变条件变量。代码如下:

1
2
std::condition_variable cv;         //事件的条件变量
std::mutex m; //配合cv使用的mutex

检测任务中的代码不能再简单了:

1
2
//检测事件
cv.notify_one(); //通知反应任务

如果有多个反应任务需要被通知,使用notify_all代替notify_one

线程API的存在一个事实(不只是C++),等待一个条件变量的代码即使在条件变量没有被通知时,也可能被唤醒,这种唤醒被称为虚假唤醒spurious wakeups)。

正确的代码通过确认要等待的条件确实已经发生来处理这种情况,并将这个操作作为唤醒后的第一个操作。C++条件变量的API使得这种问题很容易解决,因为允许把一个测试要等待的条件的lambda(或者其他函数对象)传给wait。因此,可以将反应任务wait调用这样写:

1
2
cv.wait(lk, 
[]{ return whether the evet has occurred; });

使用 condition variable 的示例:

1
2
3
4
5
6
7
8
9
std::condition_variable cv;             
std::mutex m;
bool flag(false); //不是std::atomic
//检测某个事件
{
std::lock_guard<std::mutex> g(m); //通过g的构造函数锁住m
flag = true; //通知反应任务(第1部分)
} //通过g的析构函数解锁m
cv.notify_one(); //通知反应任务(第2部分)

反应任务代码如下:

1
2
3
4
5
6
7
//准备作出反应
{
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, [] { return flag; }); //使用lambda来避免虚假唤醒
//对事件作出反应(m被锁定)
}
//继续反应动作(m现在解锁)

原子变量轮询

使用原子变量的示例:

当检测线程识别到发生的事件,将flag置位:

1
2
3
std::atomic<bool> flag(false);          //共享的flag
//检测某个事件
flag = true; //告诉反应线程

就其本身而言,反应线程轮询该flag。当发现flag被置位,它就知道等待的事件已经发生了:

1
2
3
//准备作出反应
while (!flag); //等待事件
//对事件作出反应

这里多出了轮询的开销。

promise + future

检测任务使用std::promise<void>,反应任务使用std::future<void>或者std::shared_future<void>。当感兴趣的事件发生时,检测任务设置std::promise<void>,反应任务在futurewait

尽管反应任务不从检测任务那里接收任何数据,通信信道也可以让反应任务知道,检测任务什么时候已经通过对std::promise<void>调用set_value“写入”了void数据。

1
std::promise<void> p;                   //通信信道的promise

检测任务代码很简洁:

1
2
//检测某个事件
p.set_value(); //通知反应任务

反应任务代码也同样简单:

1
2
3
//准备作出反应
p.get_future().wait(); //等待对应于p的那个future
//对事件作出反应

像使用flag的方法一样,此设计不需要互斥锁,无论在反应线程调用wait之前检测线程是否设置了std::promise都可以工作,并且不受虚假唤醒的影响(只有条件变量才容易受到此影响)。

与基于条件变量的方法一样,反应任务在调用wait之后是真被阻塞住的,不会一直占用系统资源。

但是以上代码中,std::promisefuture之间有个共享状态,并且共享状态是动态分配的。因此你应该假定此设计会产生基于堆的分配和释放开销。

std::promise只能设置一次。std::promisefuture之间的通信是一次性的:不能重复使用。这是与基于条件变量或者基于flag的设计的明显差异,条件变量和flag都可以通信多次。

假设你仅仅想要对某线程挂起一次(在创建后,运行线程函数前),使用voidfuture就是一个可行方案。

通过share获得的shared_future要被在反应线程中运行的lambda按值捕获:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
std::promise<void> p;                   //跟之前一样
void detect() //现在针对多个反映线程
{
// 写在前面,防止异常发生,p.set_value() 不执行
auto sf = p.get_future().share(); //sf的类型是std::shared_future<void>
std::vector<std::thread> vt; //反应线程容器
for (int i = 0; i < threadsToRun; ++i) {
vt.emplace_back([sf]{ sf.wait(); //在sf的局部副本上wait;
react(); });
}
//如果这个“…”抛出异常,detect挂起!
p.set_value(); //所有线程解除挂起

for (auto& t : vt) { //使所有线程不可结合;
t.join(); //“auto&”见条款2
}
}

结论

  • 三种简单事件通信:使用条件变量、使用原子变量、使用 promise + future
  • promise + future 的方式,在单次事件通信时,更有优势

40 并发使用std::atomic,特殊内存使用volatile

如下使用std::atmoic的代码:

1
2
3
4
5
std::atomic<int> ai(0);         //初始化ai为0
ai = 10; //原子性地设置ai为10
std::cout << ai; //原子性地读取ai的值
++ai; //原子性地递增ai到11
--ai; //原子性地递减ai到10

在这些语句执行过程中,其他线程读取ai,只能读取到0,10,11三个值其中一个。假设只有这个线程会修改ai,没有其他可能的值。

使用volatile在多线程中实际上不保证任何事情:

1
2
3
4
5
volatile int vi(0);             //初始化vi为0
vi = 10; //设置vi为10
std::cout << vi; //读vi的值
++vi; //递增vi到11
--vi; //递减vi到10

代码的执行过程中,如果其他线程读取vi,可能读到任何值,比如-12,68,4090727——任何值!这份代码有未定义行为,因为这里的语句修改vi,所以如果同时其他线程读取vi,同时存在多个readers和writers读取没有std::atomic或者互斥锁保护的内存,这就是数据竞争的定义。

指令排序

代码执行本身,即使编译器没有重排顺序,底层硬件也可能重排(或者可能使它看起来运行在其他核心上),因为有时这样代码执行更快。

然而,std::atomic会限制这种重排序,保持了指令执行的有序性。

1
2
3
std::atomic<bool> valVailable(false); 
auto imptValue = computeImportantValue(); //计算值
valAvailable = true; //告诉另一个任务,值可用了

编译器不仅要保证imptValuevalAvailable的赋值顺序,还要保证生成的硬件代码不会改变这个顺序。结果就是,将valAvailable声明为std::atomic确保了必要的顺序——其他线程看到的是imptValue值的改变不会晚于valAvailable

valAvailable声明为volatile不能保证上述顺序:

1
2
3
volatile bool valVailable(false); 
auto imptValue = computeImportantValue();
valAvailable = true; //其他线程可能看到这个赋值操作早于imptValue的赋值操作

这份代码编译器可能将imptValuevalAvailable赋值顺序对调。

结论

  • std::atomic用于在不使用互斥锁情况下,来使变量被多个线程访问的情况。是用来编写并发程序的一个工具。
  • volatile用在读取和写入不应被优化掉的内存上。是用来处理特殊内存的一个工具。

其他优化

41 对于移动成本低且总是被拷贝的可拷贝形参,考虑按值传递

三个版本的addName

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
class Widget {                                  //方法1:对左值和右值重载
public:
void addName(const std::string& newName)
{ names.push_back(newName); } // rvalues
void addName(std::string&& newName)
{ names.push_back(std::move(newName)); }

private:
std::vector<std::string> names;
};

class Widget { //方法2:使用通用引用
public:
template<typename T>
void addName(T&& newName)
{ names.push_back(std::forward<T>(newName)); }

};

class Widget { //方法3:传值
public:
void addName(std::string newName)
{ names.push_back(std::move(newName)); }

};

我将前两个版本称为“按引用方法”,因为都是通过引用传递形参。

仍然考虑这两种调用方式:

1
2
3
4
5
6
Widget w;

std::string name("Bart");
w.addName(name); //传左值

w.addName(name + "Jenne"); //传右值

现在分别考虑三种实现中,给Widget添加一个名字的两种调用方式,拷贝和移动操作的开销。

  • 重载:无论传递左值还是传递右值,调用都会绑定到一个叫newName的引用上。从拷贝和移动操作方面看,这个过程零开销。左值重载中,newName拷贝到Widget::names中,右值重载中,移动进去。

    ​ 开销总结:左值一次拷贝,右值一次移动。

  • 使用通用引用:同重载一样,调用也绑定到addName这个引用上,没有开销。由于使用了std::forward,左值std::string实参会拷贝到Widget::names,右值std::string实参移动进去。

    ​ 对std::string实参来说,开销同重载方式一样:左值一次拷贝,右值一次移动。

  • 按值传递:无论传递左值还是右值,都必须构造newName形参。如果传递的是左值,需要拷贝的开销,如果传递的是右值,需要移动的开销。在函数的实现中,newName总是采用移动的方式到Widget::names

    ​ 开销总结:左值实参,一次拷贝一次移动,右值实参两次移动。

对于特殊的场景,可拷贝且移动开销小的类型,传递给总是会拷贝他们的一个函数,切片也不需要考虑,。这时,按值传递就提供了一种简单的实现方式,效率接近传递引用的函数,但是避免了传引用方案的缺点。

当移动的开销较低,额外的一次移动才能被开发者接受,但是当移动的开销很大,执行不必要的移动就类似执行一个不必要的拷贝,也就是避免不必要的拷贝。

对于可拷贝形参使用按值传递。不符合此条件的的形参必须有只可移动的类型(move-only types)。

只会在目标代码中生成一个函数。避免了通用引用的种种问题。

只对总是被拷贝的形参考虑按值传递。因为传引用可以避免这个不必要的开销。

结论

  • 对于可拷贝,移动开销低,而且无条件被拷贝的形参,按值传递效率基本与按引用传递效率一致,而且易于实现,还生成更少的目标代码。

  • 某些情况下,通过构造拷贝形参可能比通过赋值拷贝形参开销大的多。

  • 按值传递会引起切片问题,所说不适合基类形参类型。

42 emplacement 而不是 insertion

编译器处理的下面的调用:

1
vs.push_back(std::string("xyzzy")); //创建临时std::string,把它传给push_back

为了在std::string容器中创建新元素,调用了std::string的构造函数,但是这份代码并不仅调用了一次构造函数,而是调用了两次,而且还调用了std::string析构函数。下面是在push_back运行时发生了什么:

  1. 一个std::string的临时对象从字面量“xyzzy”被创建。这个对象没有名字,我们可以称为temptemp的构造是第一次std::string构造。因为是临时变量,所以temp是右值。
  2. temp被传递给push_back的右值重载函数,绑定到右值引用形参x。在std::vector的内存中一个x的副本被创建。这次构造——也是第二次构造——在std::vector内部真正创建一个对象。
  3. push_back返回之后,temp立刻被销毁,调用了一次std::string的析构函数。

使用传递给它的任何实参直接在std::vector内部构造一个std::string。没有临时变量会生成:

1
vs.emplace_back("xyzzy");           //直接用“xyzzy”在vs内构造std::string

emplace_back使用完美转发,因此只要你没有遇到使用完美转发的限制,就可以传递任何实参以及组合到emplace_back

emplace_back

  • 值是通过构造函数添加到容器,而不是直接赋值。
  • 传递的实参类型与容器的初始化类型不同。
  • 容器不拒绝重复项作为新值。
1
2
vs.emplace_back("xyzzy");              //在容器末尾构造新值;不是传递的容器中元
//素的类型;没有使用拒绝重复项的容器

资源管理

假定你有一个盛放std::shared_ptr<Widget>s的容器,

1
std::list<std::shared_ptr<Widget>> ptrs;

使用push_back的代码如下:

1
ptrs.push_back(std::shared_ptr<Widget>(new Widget, killWidget));

也可以像这样:

1
ptrs.push_back({new Widget, killWidget});

不管哪种写法,在调用push_back前会生成一个临时std::shared_ptr对象。push_back的形参是std::shared_ptr的引用,因此必须有一个std::shared_ptr

emplace_back应该可以避免std::shared_ptr临时对象的创建,但是在这个场景下,临时对象值得被创建。考虑如下可能的时间序列:

  1. 在上述的调用中,一个std::shared_ptr<Widget>的临时对象被创建来持有“new Widget”返回的原始指针。称这个对象为temp
  2. push_back通过引用接受temp。在存储temp的副本的list节点的内存分配过程中,内存溢出异常被抛出。
  3. 随着异常从push_back的传播,temp被销毁。作为唯一管理这个Widgetstd::shared_ptr,它自动销毁Widget,在这里就是调用killWidget

这样的话,即使发生了异常,没有资源泄漏。

考虑使用emplace_back代替push_back

1
ptrs.emplace_back(new Widget, killWidget);
  1. 通过new Widget创建的原始指针完美转发给emplace_back中,list节点被分配的位置。如果分配失败,还是抛出内存溢出异常。
  2. 当异常从emplace_back传播,原始指针是仅有的访问堆上Widget的途径,但是因为异常而丢失了,那个Widget的资源(以及任何它所拥有的资源)发生了泄漏。

在这个场景中,生命周期不良好,这个失误不能赖std::shared_ptr。使用带自定义删除器的std::unique_ptr也会有同样的问题。

解决方法是:

1
2
3
std::shared_ptr<Widget> spw(new Widget,      //创建Widget,让spw管理它
killWidget);
ptrs.push_back(std::move(spw)); //添加spw右值

emplace_back的版本如下:

1
2
std::shared_ptr<Widget> spw(new Widget, killWidget);
ptrs.emplace_back(std::move(spw));

无论哪种方式,都会产生spw的创建和销毁成本。

explicit的构造函数的交互

相似的初始化语句导致了多么不一样的结果:

1
2
std::regex r1 = nullptr;                 //错误!不能编译
std::regex r2(nullptr); //可以编译

在标准的官方术语中,用于初始化r1的语法(使用等号)是所谓的拷贝初始化。相反,用于初始化r2的语法是(使用小括号,有时也用花括号)被称为直接初始化

emplace_back 使用直接初始化,这意味着可能使用explicit的构造函数。

push_back 使用拷贝初始化,所以不能用explicit的构造函数。因此:

1
2
3
regexes.emplace_back(nullptr);           //可编译。直接初始化允许使用接受指针的
//std::regex的explicit构造函数
regexes.push_back(nullptr); //错误!拷贝初始化不允许用那个构造函数

获得的经验是,当你使用emplace_back时,请特别小心确保传递了正确的实参,因为即使是explicit的构造函数也会被编译器考虑,编译器会试图以有效方式解释你的代码。

结论

  • 原则上,emplace_back有时会比push_back高效,并且不会更差。
  • 实际上,当以下条件满足时,emplace_back更快:(1)值被构造到容器中,而不是直接赋值;(2)传入的类型与容器的元素类型不一致;(3)容器不拒绝已经存在的重复值。
  • emplace_back可能执行push_back拒绝的类型转换。

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!