Record of reading Effective Modern C++

Effective Modern C++ 42 Specific Ways to Improve Your Use of C++11 and C++14
Scott Meyers
PDF Version

CH.1 类型推导

Item.1 理解模板类型推导

首先约定如下模板和调用:

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

编译期间,expr会推导出两种类型:TParamType,这两种类型经常是不一样,ParamType会包含一些修饰符,e.g. const,&(引用)。例如下面情况:

template<typename T>
void f(const T& param);
int x = 0;
f(x);

ParamTypeconst T& 进而推导为const int&,而T推导为int

模板推导过程中T不止取决于expr还和ParamType有关,具体分为如下三种情况:

  1. ParamType是引用或指针但不是右值引用(e.g. T&&)
  2. ParamType是右值引用
  3. ParamType既不是引用也不是指针

CASE.1 ParamType是引用或指针但不是右值引用


这中情况是最简单的,推导法则为两部:

  1. 如果expr是引用,忽略引用部分
  2. expr的类型与ParamType匹配来决定T
template<typename T>
void f(const T& param);	// ParamType是const T的引用

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

f(x); 	// T:int	ParamType: const int&
f(cx); 	// T:const int 	ParamType: const int&
f(rx);	// T:const int	ParamType: const int&

上例为ParamType是引用的时候,对于指针的情况同理。

CASE.2 ParamType是是右值引用


模板参数为T时,右值引用类型声明为T&&,此时对与传入左值引用时处理较为特殊:

  1. expr类型为左值lvalue时,TParamType都推导为左值引用。两点注意: a.这是T推导为引用唯一的一种情况 b. 虽然ParamType声明为右值引用但是推导为左值引用
  2. expr类型为右值时正常应用CASE.1即可
template<typename T>
void f(T&& param); 	//ParamType为右值引用

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

f(x); 	// x: lvalue	T: int&		ParamType: int&
f(cx); 	// cx: lvalue	T: const int&	ParamType: const int&
f(rx); 	// rx: lvalue 	T: const int&	ParamType: const int&
f(27); 	// 27: rvalue 	T: int		ParamType: int&&

CASE.3 ParamType既不是引用也不是指针


ParamType既不是引用也不是指针的时候以传值(pass-by-value)的方式处理,无论expr声明类型param形参都会是实参expr的拷贝(copy),从而推导法则为:

  1. 如果expr是引用则忽略掉引用部分
  2. 第一步后,constvolatile修饰符也会被忽略(传入的是独立拷贝)
template<typename T>
void f(T param);	// pass-by-value

int x = 27;
const int cx = x;
const int& rx = x;
const char* const ptr = "hello world";

f(x);	// T: int	ParamType: int
f(cx);	// T: int	ParamType: int
f(rx); 	// T: int	ParamType: int
f(ptr);	// T:const char*	ParamType: const char*

上例中f(ptr)的推导结果TParamType都有const,但是这并不违背上面给出的推导法则。简单分析,ptr是一个指向常量char的常量指针,表示ptr本身不能指向别的地方且不能修改指向地址的值,当pass-by-value传入一份独立的copy时候,copy本身不再有const限制了,可以指向其他的地方,但是copy指向的地址和ptr是相同的const char,所以推导结果中有const依然是合理的。

数组参数


首先要明确的是数组类型array和指针类型pointer是不同的,数组类型包含了元素类型和数组大小两部分信息,不过 大多数情况下数组类型都可以退化为指向首元素的指针,例如作为函数参数时候,void foo(int* argp)void foo(int arga[])是等同的,数组会退化为指针。不过当使用引用的时候,数组不会退化,因此有下面示例:

template<typename T>
void f1(T param);

const char foo[] = "123";	// foo是数组
const char* ptr = foo;		// ptr是指针

f1(foo);	// T和ParamType: const char* 
f1(ptr);	// T和ParamType: const char* 

template<typename T>
void f2(T& param);

f2(foo);	// T: const char(3)	ParamType: const char (&)[3]
f2(ptr);	// T: const char*	ParamType: const char* &

可以利用数组引用可以写出如下获得数组大小的函数模板:

template<typename T, std::size_t N>
// 此处我们只关心数组大小,所以不给出形参名
// constexpr 可以是的函数返回编译器有效
constexpr std::size_t arraySize(T (&)[N]) noexcept 
{
  return N;
}

int foo[] = {1,2,3};
int bar[arraySize(foo)];	// 定义一个和foo一样大小的新数组

函数参数


函数名也可以退化为函数指针,所以如上讨论数组退化一样,但是在实际使用时很少有区别。

void foo(int);

template<typename T>
void f1(T param);	// pass-by-value

template<typename T>
void f2(T& param);	// pass-by-ref

f1(foo);	// T:void(*)(int)	ParamType 函数指针
f2(foo);	// T: void(&)(int)	ParamType 函数引用

最后可以看出类型推导基本是很直观的,除了ParamType为右值引用时expr为左值需要特别对待,另外数组和函数退化为指针也会产生困扰。 在实践的时候有时需要编译器明确告诉我们类型推导的结果,可以参考条款4

需要记住

  • 类型推导时,有引用特性的参数的引用特性会别忽略
  • 右值引用时,左值参数需要特殊对待
  • 推导传值参数时,const/volatile 会被忽略
  • 类型推导时,参数为数组名或者函数名会退化为相应指针,用引用则不会

Item.2 理解auto类型推导

首先明确的是除了有一个例外情况外auto的类型推导就是模板类型推导。

auto类型推导可以映射为模板类型推导,如下示例:

template<typename T>
void f(ParamType param);
f(expr);	//调用

auto x = 27;		//T: auto	ParamType: auto		x: int
const auto cx = x;	//T: auto	ParamType: const auto	x: const int
const auto& rx = x;	//T: auto	ParamType: const auto&	x: const int&

当用auto声明变量时,auto就是模板参数T,类型限定符就是ParamType。所以和Item.1一样会有三种情况,同时对数组,函数退化为指针也是一样的。

唯一例外情况
在C++中处理化一个变量,C++98给出了两种语法,C++11增加了统一初始化语法(为了处理C++初始化歧义问题C++ Most vexing parse),所以定义一个值为27的int有如下四种合法方式:

int x1 = 27;		//C++98
int x2(27);		//C++98
int x3 = { 27 };	//C++11
int x4{ 27 };		//C++11

//使用auto定义
auto x1 = 27;		//type: int				value: 27
auto x2(27);		//type: int				value: 27
auto x3 = { 27 };	//type: std::initializer_list<int>	value: {27}
auto x4{ 27 };		//type: std::initializer_list<int>	value: {27}

相应的进行替换会有上面四种采用auto的定义,但是得到的结果却并不是我们想要的。前两种C++98风格是得到了我们想要的int,但是C++11增加的统一初始化方式得到的是std::initializer_list<T>类型。这是因为auto类型推导的一条特殊规则:当用花括号来初始化一个auto类型的变量时,推导的类型为std::initializer_list。如果类型不能推导(e.g.花括号内的变量不是同一个类型),会产生报错。

auto x5 = { 1, 2, 3.0 };	//error, 不能推导std::initializer_list<T>的T,因为花括号内变量类型不同

在这种情况我们应该需要注意到这儿有两种类型推导发生 1)x5因为是花括号初始化必须推导为std::initializer_list 2)由于std::initializer_list是个模板,需要推导std::initializer_list<T>的类型T

对待花括号初始化是auto和模板推导唯一的不同,模板推导不会有这条特殊的规则。

auto x = { 11, 23, 9 }; 	// type: std::initializer_list<int>

template<typename T>	
void f(T param);
f({ 11, 23, 9 });	// error!没办法推导T的类型

//如果已知是 std::initializer_list 改为如下
template<typename T>
void f(std::initializer_list<T> initList);
f({ 11, 23, 9 }); 

至于为什么会有这样的规则无法解释,只能记住它-了解它-然后知道合适的使用。上述对于C++11来说是完整的,但是C++14增加了auto作为函数的返回值,C++14的lambda的参数声明中也会使用auto,但是这里复用的是模板推导的规则而不是auto的,所以没有对待花括号的特事规则

// auto声明函数返回值
auto createInitList()
{
    return { 1, 2, 3 }; 	// 编译错误:不能推导出{ 1, 2, 3 }的类型
}

// auto在lambda中
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,模板推导没有这个特殊规则
  • auto作为函数返回类型或者lambda参数类型时候意味着模板类型推导规则而不是auto类型推导 (C++14)

Item.3 理解decltype

给定一个变量名或者表达式,decltype可以告诉你对应的类型,大多时候和我们期望的结果是一样的,但是也有部分情况差距甚远。

const int i = 0;	 	// decltype(i) is const int

bool f(const Widget& w);    	// decltype(w) is const Widget&
                       		// decltype(f) is bool(const Widget&)
struct Point{
int x, y; 			// decltype(Point::x) is int
};

Widget w; 			// decltype(w) is Widget

if (f(w)) ...  			// decltype(f(w)) is bool

template<typename T>        	// simplified version of std::vector
class vector {
public:
	...
	T& operator[](std::size_t index);
	...
};

vector<int> v;             	// decltype(v) is vector<int>
...
if(v[0] == 0)                	// decltype(v[0]) is int&

如上示例,一般情况相对Item.1 模板推导Item.2 auto推导decltype更简单直观-原封不动的给出对应的类型。

在C++11 中,decltype主要用来声明函数模板,因为部分函数模板的返回值取决于参数类型。e.g. 一个函数接受一个支持[]索引的容器作为参数,验证用户的合法性后返回索引结果,这个函数的返回值类型应该就是索引操作的返回值类型。operator []对于类型为T的容器的返回值一般是T&,但是需要注意对std::vector不成立,返回的是一个全新的对象,详见item.6

template<typename Container, typename Index>  
auto authAndAccess(Container& c, Index i) -> decltype(c[i])
{
	authenticateUser();
	return c[i];
}

如上例是使用decltype来求返回值类型,这里使用了Container&左值引用不能接受右值,为了方便统一接受左值引用和右值引用可以采用右值引用Container&,但是相应的最后的[]调用也需要调整,使用std::forward©[i]来转发左值引用/右值引用。这里函数名前的auto和类型推导没有什么关系,而是用了C++11的尾随返回类型,函数的返回值类型声明再‘->’后面,这样的又是是返回值类型的定义用到了函数参数,而传统的函数名前定义返回值类型时函数参数还没有被声明无法使用(上例中c,i)。

C++11允许单语句的lambda的返回值推导,C++14已经扩展到多语句的lambda和函数。所以再C++14时候可以不写尾随返回类型,仅仅使用auto就可以,如下例:

template<typename Container, typename Index>  
// C++14
auto authAndAccess(Container& c, Index i) 
{
	authenticateUser();
	return c[i];
}
// call
std::deque<int> d;
authAndAccess(d, 5) = 10;	// error! 右值不可以赋值给右值

但是如之前Item.2说道,此时auto使用的是模板推导规则,c[i]是T&类型,则最终的返回类型是T,也就导致了上例最后的赋值失败报错(将一个右值10赋值给右值int)。

为了让上面的例子正常工作(返回T&类型),C++14中可以使用decltype(auto)来推导,auto指定推导的类型,decltype表明推导的规则:

template<typename Container, typename Index>  
// C++14
decltype(auto) authAndAccess(Container&& c, Index i) 
{
	authenticateUser();
	return c[i];		// 返回T& 类型
}
// call
std::deque<int> d;
authAndAccess(d, 5) = 10;	// work

decltype(auto)不仅可以用在函数返回值推导上,也可以用来对表达式推导,如定义一个变量:

Widget w;
const Widget& cw = w;

auto myWidget1 = cw;		//auto: const Widget
decltype(auto) myWidget2 = cw;	// const Widget&

下面讨论一下最开始提到可能不合我们期望的情景
对一个变量名使用decltype是很常规的,变量是什么类型就返回什么类型,但是对于比变量名更复杂的类型为T的左值表达式的时候,报告的类型是T&,这种情况虽然一般没有什么大问题,但是仍然需要警惕。

int x = 0;
decltype(x) y1 = 1;	// y1: int	value: 1
decltype( (x) ) y2 = 1;	// error! 推导y2是 int&, 非const左值引用不能绑定右值

 decltype(auto) f1()
{
	int x = 0;
	...
	return x;        // decltype(x) is int, so f1 returns int
}

decltype(auto) f2()
{
	int x = 0;
	return (x);     // decltype((x)) is int&, so f2 return int&
}

上例中,给x加上括号就变成了一个复杂表达式,从而产生了错误和隐患。f2不仅返回类型和f1不同,f2更是返回了一个局部变量的引用,这个的终点只有undefined behavior

需要记住

  • decltype几乎可以不加修改地得到变量或表达式的类型
  • 对于非名字的T类型的左值表达式,decltype总是给出 T& 类型
  • C++14支持decltype(auto), 使用的是decltype的规则推导

Item.4 查看推导的类型

查看推导的类型可以按照程序开发分为三个阶段:代码编写期间,编译器,运行时。

1. IDE 查看
代码编写过程中查看可以依靠IDE提供的信息,但是需要了解的是IDE提供的这些信息也是来自于C++编译器或者编译器前端。对于简单类型,一般是可靠的,对于复杂的类型IDE经常不能给出及时,可靠的类型提示。

2. 编译器诊断
可以通过调用一个类型T的不存在的方法引起编译器编译期报错,报错信息中一般会含有T的类型。例如:

const int theAnswer = 42;
auto x = theAnswer;
auto y = &theAnswer;

template<typename T>
class TD;

TD<decltype(x)> xType;	// error!
TD<decltype(y)> yType;	// error!

上述代码最后调用了不存在的TD构造函数,导致报错,可以参数类似如下的错误信息:

error: aggregate ‘TD<int> xType’ has incomplete type and cannot be defined
error: aggregate ‘TD<const int *> yType’ has incomplete type and cannot be defined

3. 运行时输出
允许时输出很容易想到typeidstd::type_info::nametypeid(expr)会产生一个std::type_info, 这个对象有一个返回C风格字符串(const char*)的成员函数name,所以很简单得出下面的示例:

const int theAnswer = 42;
auto x = theAnswer;
auto y = &theAnswer;

std::cout << typeid(x).name() << '\n';	// gcc/clang: i		MSVC: int
std::cout << typeid(y).name() << '\n';	// gcc/clang: PK	MSVC: int*

GCC/Clang会给出一些简写(‘i’->‘int’, ‘PK’->‘pointer to konst const’), 这两个编译器都支持一个c++filt的工具来解码这些看不懂的缩写,而MSVC的输出会更符合人的阅读的。但是这样还没有完,对于复杂的类型,这个方法给出的也不是我们预期的结果。

template<typename T>
void f(const T& param)
{
    std::cout << "T =	" << typeid(T).name() << '\n';
    std::cout << "param = " << typeid(param).name() << '\n'; 
}

std::vector<Widget> createVec();
const auto vw = createVec();
if (!vw.empty()) {
    f(&vw[0]);	// 调用
}

首先分析,&vw[0]的类型为Widget const *,是一个指向const Widget的指针,然后函数f的定义出使用的是常左引用接受右值(这一点后面讨论),按照Item.1 给出规则,T应该是Widget const *而param的类型为Widget const * const&: 一个指向常量Widget的指针的常量引用,也就是引用了常量的指针常量。然而GCC/Clang给出的输出是:

T = PK6Widget
param = PK6Widget

MSVC给出的输出是:

T = class Widget const *
param = class Widget const *

两种基本一样,GCC/Clang给出类型中的数字6是后面类型的长度(Widget)。但是这个和我们分析的是不一样的,且出现了T和param类型相同,这也是说std::type_info::name并不是很可靠的,然而这种不可靠是实际上规定的,规定要求std::type_info::name以pass-by-value对待函数模板传入参数,然后安装Item.1 会有忽略引用特性,忽略const 忽略volatile,这也是为什么Widget const * const&变成了const Widget *。前面也提到IDE的类型提示来自于编译器,所以自然IDE也会提示错误的类型。

替代的方式是使用可以得到可靠类型的Boost TypeIndex(Boost.TypeIndex)库,如下示例:

#include <boost/type_index.hpp>
template<typename T>
void f(const T& param)
{
    using boost::typeindex::type_id_with_cvr;
    std::cout << "T = "
              << type_id_with_cvr<T>().pretty_name() 
              << std::endl;
    std::cout << "param = "
              << type_id_with_cvr<decltype(param)>().pretty_name() 
              << std::endl;              
}

std::vector<Widget> createVec();
const auto vw = createVec();
if (!vw.empty()) {
    f(&vw[0]);	// 调用
}

boost::typeindex::type_id_with_cvr接受一个类型参数,with_cvr表保留constvolatilereference属性。boost::typeindex::type_index对象的pretty_name成员函数返回一个可阅读的std::string

上段代码在GCC/Clang下输出:

T = Widget const*
param = Widget const* const&

MSVC下输出:

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

需要记住

  • 查看推导的类型可以用IDE、编译错误信息、Boost TypeIndex库
  • 有些工具给出的类型推导记不准确也没有帮助意义,所以重点还是自己理解C++的类型推导规则




CH.2 auto

auto不仅仅可以省略打字,还可以处理一些声明类型的正确性喝性能问题。虽然有时候得到的不是期望的类型,但是有了CH.1 类型推导我们可以理解其规则,我们也应该掌握引导auto得到正确类型的能力。另外虽然我们可以回到手动声明类型,但是一般应该避免这样。

Item.5 优先使用auto而非显示声明

这里主要用使用auto的优势来佐证论点。

1) 减少打字

2) 强制初始化
由于auto类型推导是从初始化推导的,使用auto声明变量时可以强制我们初始化: 如果忘记初始化则会导致auto类型推导失败产生编译错误。

3) 表示只有编译器知道的类型
auto类型推导可以用来表示只有编译器知道的类型,经典例子就是lambda了,如下提供比较功能的lambda:

// C++11 & C++14
 auto dereUPLess = [](const std::unique_ptr<Widget>& p1, 
        	      const std::unique_ptr<Widget>& p2)
{ 
    return *p1 < *p2
};

//  C++14
 auto dereUPLess = [](const auto& p1, const auto& p2)
{ 
    return *p1 < *p2
};

当然lambda也可以用C++11开始的std::function接受,而这是要扩展讨论的。std::function通过实现一套类型消除机制(type erasure)提供了一个通用描述方法,它的实例可以对任何可以调用的目标实体进行存储、复制、和调用操作,这些目标实体包括:

  • 普通函数
  • 成员函数
  • lambda
  • std::bind的结果

这里我们列出autostd::function的一些区别,自然可以得到相应的结论。

  • std::function需要指定函数类型。虽然std::function可以接受任何可调用对象,但是和使用函数指针需要指定函数签名一样,使用std::function需要通过模板参数来制定函数类型。例如上例的lambda 需要如下接受:

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

    很明显我们需要反复的写很长的参数声明(实际有很多更长),

  • std::function对象更大。首先要意识到autostd::function声明对象不同。auto声明的是一个匿名且唯一的闭包(closure)类型,闭包需要使用多少memory它就实际占用多少;而std::function的类型是一个持有上述闭包实例的std::function模板,它占用的memory是固定,不够则会再堆上动态分配。所以比较典型的就是使用std::functionauto会占用更多的memory。

  • std::function更慢。由于实现上的inline的限制和间接调用(使用std::function得到的是闭包的封装),很自然的可以理解相比auto使用std::function更慢。

上面讨论了使用autostd::function去持有闭包时auto相对的优势,同样的也适用于用autostd::function去接受std::bind的结果,两种情况都是首先auto(Item.6会看到应该优先适用lambda而不是std::bind)。

4) 避免隐式类型转换

  • 类型截断

    std::vector<int> v;
    unsigned sz = v.size();
    

    上面的代码中v.size()的返回值类型是std::vector<int>::size_type,这个类型是取决于平台的,e.g 32位Windows下,unsignedstd::vector<int>::size_type大小一样,64位Windows时候unsigned是32位而std::vector<int>::size_type是64位,这在代码移植时候就带来了潜在隐患,且修改繁琐。

  • 隐式转换

    std::unordered_map<std::string, int> m;
    for (const std::pair<std::string, int>& p : m)
    {
        ...
    }
    

    上面代码就存在容易忽略的隐式转换而导致错误。原因在于std::unor dered_map的key是const,std::unordered_map中的std::pairstd::pair<const std::string, int>而不是std::pair<std::string, int>,从而使用后者的结果就是编译器会复制然后创建一个临时对象,之后把p绑定到这个临时对象上来完成前者到后者的转换。这样的后果就是1)性能损失(每次都会复制临时对象然后销毁) 2)解引用p来修改值达不到预期结果,因为p绑定在临时对象上。

上面提到了一些使用auto的理由,当然也会找到一些auto的缺点,但是总的来说利大于弊,针对auto的缺点我们应该找到应对的方法,下面讨论一下auto的不足:

i. auto推导的类型不是预期的auto类型推导是从初始化表达式推导的,初始化表达式的类型有时候不是我们希望的,这时候应该参考Item.2Item.6来进行修改。

ii. auto的使用降低了代码可读性。1. auto只是多一个选择不是强制的,类型推导并不是C++发明的新技术,很多语言都已采用,现实已经说明这个技术和工业级代码库的创建、维护并不矛盾。2. 使用auto是的变量不能一眼看出类型,然而一般IDE都可以减轻这个问题,另外抽象视图、正确选择变量名都可以帮助减缓这个问题。

需要记住

  • auto变量强制要求初始化;可以防止类型不匹配带来的潜在问题和性能问题;自动重构;打字更少
  • auto类型变量受限于Item.2Item.6的坑

Item.6 auto推导不合理时用显示类型转换

Item.5 介绍了使用auto的好处,但是有些情况下auto推导的类型不是我们期望的,这是我们需要显示初始化了。例如:

 std::vector<bool> features(const Widget& w)
 {...}
 
 Widget w;
 bool p1 = features(w)[5];	
 auto p2 = features(w)[5];	
 
 processWidget(w, p1);	// work fine
 processWidget(w, p2);	// undefined behavior

上例中,用auto替代bool之后会得到undefined behavior,原因在于std::vector<bool>是对bool数据封装的模板特化,和其他一般容器随机访问返回T&不同,std::vector<bool>返回的是std::vector<bool>::reference。原因是C++为了保存bool占用少的memory使用bit保存,而bit是不能取引用,所以返回了std::vector<bool>::reference来达到行为上的一致。而上例中使用bool可以正常是因为std::vector<bool>::reference可以隐式转换为bool(不是bool&);而使用auto时候,p2则是std::vector<bool>::reference没有转换,features(w)返回的是临时变量,p2的内部指针或者引用则使用了这个临时变量,后续调用时临时变量已经销毁产生undefined behavior。

std::vector::reference 是代理类的一个例子,代理类的是为了模拟和增强其他类型的行为的一种类,使用它的目的多种多样。 std::vector<bool>::reference的目的是使得std::vector<bool>的operator[]返回类似bit的reference。智能指针也是代理类,目的是为了管理裸指针的。事实上,代理模式是设计模式中最坚挺之一。有些代理类设计的对用户很明显(隔离用户),例如std::shared_ptrstd::unique_ptr;而有的则是设计的尽量透明不可见,例如std::vector<bool>::referencestd::bitsetstd::bitset::reference

C++库中还有一种叫表达式模板(expression templates)的技术,最初目的是提高数值计算效率。例如计算矩阵加法:

 // m1 m2 m3 m4 are Matrix
 Matrix sum = m1 + m2 + m3 + m4;

这里Matrix的operator+不是直接返回Matrix,而是返回一个类似Sum的代理类,然后存在这个代理类到Matrix的隐式转换。上例=右边可以得到类似Sum,Matrix>, Matrix>的代理类,然后隐式转换初始化sum是一次性计算完加法,很显然这种类型对用户应该就是不可见的。

总的来说,对于“不可见”的代理类auto一般不能达到预期目的,而且这种代理类的实例生命期一般都不会超过一条语句,去持有这样一个实例也违背了设计的初衷(例如上面提到的导致undefined behavior)。由于这种类设计的对用户不可见,也不太可能对外“宣传”,所以能意识到“不可见”代理类本身就是不容易的。发现的方法 1)熟悉库 2) 文档 3)看头文件。

当我们发现了这样的代理类时,并不意味着我们就抛弃不用auto而走回老路,我们可以采用显示类型转换。例如最前面的例子:

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

需要记住

  • “不可见”代理类会导致auto类型推导和我们预期不一致
  • 显示类型转换可以帮助auto得到我们预期的类型




CH.3 转移到现代C++

C++11和C++14加入了很多新的特性,e.g. auto、智能指针、移动语义、lambda、并发等,使得C++更加现代化,这些新的feature都是值得掌握的。这一章主要讨论了从C++98转移到现代C++的坑。

Item.7 创建对象时分清()和{}

前面已经提到,对于对象初始化C++11增加了使用{}的统一初始化语法,使得总共一起有了4种方式:

int x(0); 		// 括号初始化
int y = 0; 		// 使用=
int z{0}; 		// 大括号
int z = {0};		// 经常和使用大括号一样

上面最后两种编译时候是一样的,后面不再单独讨论,只讨论前面三种。在讨论()和{}之前,先要谈一下初始化中使用“=”的问题,新手经常混淆的地方:

Widget w1;		// 默认初始化函数
Widget w2 = w1;		// !不是赋值,调用复制构造函数
w1 = w2;		// 赋值,调用复制操作符=

C++11添加了统一初始化语法,其目的如其名,在兼容之前()、=初始化的同时也可以做到之前不能的,但也带来一些新的问题使得其名不符实(达不到统一),故这一节为理解、分清{}和()。下面列举一下新添加的统一初始化语法的优缺点:

优点

  • 可以初始化C++98不能表达的
    例如{1,2,3}初始化STL的vector容器,C++98是不能直接初始化的,利用新的语法可以写为std::vector<int> v {1,2,3};
  • 统一初始化 前面提到的()和=两种初始化并不是等价的,有些()可以=不可以,反之亦然,新的语法可以同是兼容两者,达到一定的统一。

    // = 可以,()不可以
    class Widget {
    ...
    private:
        int x { 0 };    // OK,x默认值为0
        int y = 0;      // OK
        int z(0);       // error 错误
    };
    // ()可以,= 不可以(不可复制对象)
    std::atomic<int> ai1{ 0 };  // OK
    std::atomic<int> ai2(0);    // OK
    std::atomic<int> ai3 = 0;   // error 错误
    
  • 阻止缩窄转换(narrowing conversions) 统一初始化语法会阻止内置类型的缩窄转换(narrowing conversions),如果初始化对象不能表达大括号内的值,则不能通过编译。

    double x, y, z;
    ...
    int sum1{ x + y + z };  // error 错误,int不能表达double
    int sum2(x + y + z);    // OK
    int sum3 = x + y + z;   // OK
    
  • 解决C++“最恼人问题”(Most Vexing Parse) C++规定:如果可以解析为声明则必须解析为声明。这点导致了常说的”C++ most vexing parse”,当调用一个构造函数时往往会被解析为函数声明,从而导致编译失败。由于函数不能用大括号{}声明,所以统一初始化语法可以克服这一点。

    Widget w2();    // error,错误,w2解析为返回Widget的函数,可以用 Widget w2 解决
    Widget w3{};    // OK
    Widget w4( Widget(10) );    // error,错误,w4解析为接受Widget参数返回Widget的函数,无法规避
    Widget w5{ Widget(10) };    // OK
    

缺点

统一初始化的缺点由std::initializer_lists导致,两者混乱的关系使得看起来应该这样的代码实际并不是这样。

  • auto Item 2已经提到,用其他方法初始化auto可以推导出直观的类型,但用统一初始化大括号语法则会得到std::initializer_lists
  • 构造函数重载 当构造函数不涉及std::initializer_lists时,()和{}初始化是等价的,没有区别。但是一旦有一个或者多个构造函数接受std::initializer_lists,事情就不一样了,编译器会非常非常倾向于接受std::initializer_lists的构造函数,只要可以转换的都会选择,从而遮蔽了其他的可能更匹配的构造函数。

    class Widget {
    public:
        Widget(int i, bool b);      // c1
        Widget(int i, double d);    // c2
        Widget(std::initializer_list<double> il);  // c3
        ...
    };
    Widget w1(10, true);        // c1 正常  
    Widget w2{10, true};        // !!!c3,10和true会转为double
    Widget w3(10, 5.0);         // c2
    Widget w4{10, 5.0};         // !!!c3,10和5.0转为double
    // l甚至拷贝和移动构造函数都可以被跑步掉
    // 在上面widget加入 operator float() const;
    Widget w5(w4);              // 正常,调用拷贝构造
    Widget w6{w4};              // !!!c3, w4会转为float然后转到double
    Widget w7(std::move(w4));   // 正常,调用移动构造函数
    Widget w8{std::move(w4)};   // !!!c3 同上
    

    编译器甚至产生不能调用的情况(编译失败)也会倾向于接受std::initializer_lists的构造函数,例如替换Widget(std::initializer_list<long double> il);Widget(std::initializer_list<bool> il);,然后调用Widget w4{10, 5.0};依然会选择c3然后编译失败,其会跳过c1和c2选择c3,但是c3需要缩窄转换,于是编译器拒绝报错。另外除非完全没有转换的可能,编译器才会回退到其他的构造函数,例如将上面的构造函数替换为Widget(std::initializer_list<std::string> il);然后调用Widget w4{10, 5.0};会正常选择c2,因为不存在10,5.0到std::string的隐式转换。

  • 空参数问题(Corner case)
    当一个类型同时支持默认构造函数和std::initializer_list构造函数,空的{}意味着什么?规则是使用{}会调用默认构造函数,{}意味着没有参数而不是空的std::initializer_list。如果希望传入空的std::initializer_list给支持std::initializer_list的构造函数,可以使用({}) 或者{{}}。

    class Widget {
    public:
        Widget();   // 1)
        Widget(std::initializer_list<int> il);  // 2)
        ... 
    };
    
    Widget w1;      // calls 1)
    Widget w2{};    // calls 1)
    Widget w3();    // calls 1)
    Widget w4({});  // call 2) with empty std::initializer_list
    Widget w5{{}};  // call 2) with empty std::initializer_list
    

统一初始化语法的特殊性质带来了一些的问题,最经常遇到的就是std::vector<T>,使用()和{}的含义完全不同,std::vector<int> v1(10, 20)意味着创建一个大小为10、每个的值为20的int的vector,std::vector<int> v2{10, 20}则意味着创建一个两个元素,10和20,的int的vector。这一点是明显不符合直觉的(使用()和{}应该是一样的效果)。对于带来的问题,分三个方面来说:

  1. 类的作者:当引入了std::initializer_lists时你需要小心的设计构造函数的重载,防止前者屏蔽了其他的构造函数,你最好设计成用户代码无论是使用()还是{}都不会受影响。可以吸取的经验就是std::vector<T>,其接口已经被证明是一个错误的设计,只能依靠文档要告诉用户。另外在更新一个没有引入std::initializer_lists的类时,除非有足够的把握,否则不建议添加支持std::initializer_lists的构造函数,因为其很可能屏蔽其他的构造函数,且不知道用户是用()还是{}调用构造。
  2. 类的用户:虽然统一初始化语法{}的设计目的是兼容之前的+解决已有问题,但是前面的讨论已经展示了它达到了目的带上又带来了新的问题,所以目前()和{}并不能替代谁,也是这一节的题目是理解而不是优先选择一种。作为类用户,1)需要小心的使用()和{}来构造对象,例如std::vector就必须参考文档才知道如何选择 2)养成一致的习惯,选择一种作为通常的初始化语言,只在特殊情况下时才使用另外一种
  3. 模板的作者:()和{}对于模板作者来说是一个恼人、无解的问题,因为不可能知道用户调用的是哪一个。例如接受任意类型的任意数量的参数来创建一个对象(这里可以使用可变参数模板)

    template<typename T,            // type of object to create
             typename... Ts>        // types of arguments to use
    void doSomeWork(Ts&&... params)
    {
    //create local T object from params...
    // T localObject(std::forward<Ts>(params)...); // 1. using parens
    // T localObject{std::forward<Ts>(params)...}; // 2. using braces
    }
    
    std::vector<int> v;
    doSomeWork<std::vector<int>>(10, 20);
    

    doSomeWork函数内部可以用于1或者2,但是这对于后面的调用来说意味完全不一样,产生的结果也不同,模板作者是不会知道调用者的意图的。这也是标准库STL的std::make_uniquestd::make_shared(参考Item 21)所面对的,其解决办法是内部使用()然后在文档中注明。

需要记住

  • {}统一初始化可以防止缩窄转换,也不受C++恼人问题的影响
  • 使用{}来构造对象,只要有可能则会倾向于std::initializer_list的构造函数
  • 构造两个参数的std::vector<numeric type>是一个区分()和{}很好的例子
  • 在模板内选择()还是{}是一件困难的事

Item.8 优先使用nullptr而非0、NULL

首先需要明确的是字面量0的类型是intNULL是宏,由编译器实现决定,一般是int但是也可能是long。总之这0或者NULL的类型都不是指针类型,在C++中如果遇到需要指针类型而传入了0或者NULL,其会“勉强”的解释为空指针。但是由于0和NULL本身不是指针类型,所以会导致一些问题,而C++11的nullptr可以解决这些问题。nullptr的类型采用了循环定义,nullptr的类型是std::nullptr_t,而后者定义为nullptr的类型。std::nullptr_t可以隐式转换为所有的裸指针,所以nullptr可以作为任何类型的指针。

  1. 函数重载
    C++98即有避免指针和整数类型的重载,因为0和NULL的类型是整形,所以以0或者NULL作为空指针传入会得到意料之外的结果。如下,当整形和指针重载时,传入0不会被当做空指针,而nullptr则不会产生这种歧义。注意,尽管有了nullptr,但是C++11/14仍然需要遵守这条建议,因为仍然有人使用0/NULL作为空指针传入。

    void f(int);    // 1)
    void f(bool);   // 2)
    void f(void*);  // 3)
    
    f(0);       // call 1)
    f(NULL);    // might call 1) or not compile, never call 3)
    f(nullptr); // call 3)
    
  2. 语义不明与auto
    使用nullptr可以使得代码更清晰,可以自我解释,特别是当卷入了auto时。例如auto result = findRecord(); if(result == 0){...},此处对findRecord()的返回值与0比较,我们无法马上、直观的知道其返回值类型,而如果写为if(result == nullptr){...}则会明了了。

  3. 模板
    当使用模板时,nullptr的优势更明显了。如下,我们写一个函数模板,获取到mutex后调用函数,返回其返回值。其中1)和2)的调用均会导致编译失败,在1)调用中,0会推断为int类型,这和f1需要的std::shared_ptr<Widget>类型是不匹配的,2)调用同理,3)调用中nullptr推导为std::nullptr_t进而隐式转换为f3需要的Widget*类型。

    template<typename FuncType,
             typename MuxType,
             typename PtrType>
    auto lockAndCall(FuncType func, MuxType& mutex, PtrType ptr) -> decltype(func(ptr))
    //decltype(auto) lockAndCall(FuncType func, MuxType& mutex, PtrType ptr)      if C++14  
    {
        MuxGuard g(mutex);
        return func(ptr);
    }
    
    int f1(std::shared_ptr<Widget> spw); 
    double f2(std::unique_ptr<Widget> upw); 
    bool f3(Widget* pw);
    
    auto result1 = lockAndCall(f1, f1m, 0);         // !!!error     1)
    auto result2 = lockAndCall(f2, f2m, NULL);      // !!!error     2)
    auto result3 = lockAndCall(f3, f3m, nullptr);   // fine         3)
    
    

需要记住

  • 优先使用nullptr而不是0或者NULL
  • 避免整形和指针类型的重载

Item.9 优先使用别名声明而非typedef

  1. 首先我们都同意STL容器是个好东西,但是例如反复的写std::unique_ptr<std::unordered_map<std::string, std::string>>则显得比较病态,在C++98中我们解决的办法是使用typedef来定义别名(使用宏#define进行字符替换是不好的),C++11给我们提供了更好的选择:别名声明,其和typedef作用一样且更直观,例如:

    typedef std::unique_ptr<std::unordered_map<std::string, std::string>> UPtrMapSS;
    using UPtrMapSS = std::unique_ptr<std::unordered_map<std::string, std::string>>;
    // 定义函数指针别名
    typedef void (*FP)(int, const std::string&);
    using FP = void (*)(int, const std::string&);
    
  2. 除去上面别名声明的优点,其相对typedef更有竞争力的优点是:别名声明可以模板化(别名模板)而typedef不可以。因此C++98中,需要使用嵌套的struct和typedef来hack实现的功能在C++11中可以很直接的实现。例如实现定义一个使用自定义allocator的链表的别名,

    // 别名声明方式
    template<typename T>
    using MyAllocList = std::list<T, MyAlloc<T>>; 
    MyAllocList<Widget> lw;
    
    template<typename T>
    class Widget {
    private:
        MyAllocList<T> list;
        ...
    };
    
    // typedef方式
    template<typename T>
    struct MyAllocList {
        typedef std::list<T, MyAlloc<T>> type; 
    };
    MyAllocList<Widget>::type lw;
    
    template<typename T>
    class Widget {
    private:
        typename MyAllocList<T>::type list; 
        ...
    };
    

    当在模板中需要使用这个类型别名时,使用typedef会更麻烦:需要前置typename来告诉编译器其指代的是一种类型,而不是成员变量,使用C++11的变量声明则不需要、直接使用即可。因为编译器面对MyAllocList<T>::type时是无法判断其是成员变量还是类型(如下例),而实际我们希望的其是一个决定于T的类型,所以安装模板元编程要求我们需要使用typename显示的告诉编译器。

    class Wine { ... };
    template<>
    class MyAllocList<Wine> {       // MyAllocList 特化,T是Wine
    private:
        enum class WineType
        { White, Red, Rose }; 
        WineType type;          // MyAllocList<T>::type是成员变量
        ...
    };
    

如果使用模板元编程(template metaprogramming),我们基本都会使用到需要去掉/添加类型限定符(eg. const, &)功能,C++11给我们提供了type traits形式的转换工具,包含在<type_traits>头文件中。由于历史原因,C++11中的实现使用的是typedef,所以会得到一系列std::transformation<T>::type;意识到别名声明使用更加方便,C++14中提供了对等的std::transformation_t<T>,其实现完全使用的C++11的标准,所以就算使用C++11我们自己也可以实现出来。

std::remove_const<T>::type        // C++11 yields T from const T
std::remove_const_t<T>            // C++14 equivalent

// 自己实现
template <class T>
using remove_const_t = typename remove_const<T>::type;

需要记住

  • typedef不支持模板化,但是别名声明支持
  • 别名声明可以避免::type后缀也不需要在模板中使用typename,而typedef需要
  • C++14提供了所有C++11 type traits转换的别名模板

Item.10 优先使用scoped enums而非unscoped enums

通常,{}内声明的名字的作用域/可见域只在{}内,但是C++的枚举是例外,C++98风格的枚举其名字和*enum*享有同样的作用域,这样的命名为unscoped enums,相对的C++11风格为scoped enums

  1. 命名空间
    如开头定义一样,C++98风格的枚举类型定义会污染命名空间,C++11新添加的scoped enums(在*enum*前面加*class*)解决这一点:

    // C++98 unscoped enums
    enum Color { black, white, red };
    auto white = false;                 // !!! error,white already declared in this scope
    // C++11 scoped enums
    enum class Color { black, white, red };
    auto white = false;                 // fine
    Color c = white;                    // error, no enumerator named "white" is in this scope
    Color c = Color::white;             // fine
    
  2. 隐式转换
    unscoped enums可以隐式转换为整形(由此也可以转浮点),而scoped enums则具有更“强”的类型,不可以进行隐式转换。这特性好处是会让代码更严禁:

    // unscoped enum
    enum Color { black, white, red };
    void foo(std::size_t x);
    Color c = red;
    ...
    if(c < 14.5)                       // fine
    {
        foo(c);                        // fine
    }
    
    // scoped enum
    enum class Color { black, white, red };
    void foo(std::size_t x);
    Color c = red;
    ...
    if(c < 14.5)                       // !!! error, cannot compare Color with double
    {
        foo(c);                        // !!! error, can't pass Color to function expecting std::size_t
    }
    // scoped enum 强制转换
    if (static_cast<double>(c) < 14.5)
    {
        ...
    }
    

    但是有时候目的就是使用其隐式转换则使用scoped enums会显得很繁琐,相反使用unscoped enums显得简洁很多:

    using UserInfo = std::tuple<std::string, std::string, std::size_t>;
    UserInfo uInfo;
    
    // unscoped enum
    enum UserInfoFields { uiName, uiEmail, uiReputation };
    auto val = std::get<uiEmail>(uInfo);
    
    // scoped enum
    auto val = std::get<static_cast<std::size_t>(UserInfoFields::uiEmail)>(uInfo);
    
    // 使用函数模板返回scoped enum的底层数据类型, C++11
    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
    template<typename E>
    constexpr auto toUType(E enumerator) noexcept
    {
        return static_cast<std::underlying_type_t<E>>(enumerator);
    }
    auto val = std::get<toUType(UserInfoFields::uiEmail)>(uInfo);
    
  3. 前置声明
    很多基础类型声明的头文件会被很多文件引用,如果稍微修改一下则会导致所有的关联的都需要花费大量时间重新编译,所以对于明确大小的类型(对编译器而言)可以使用前置声明,即在头文件是只声明而其他源文件中定义,这里的前提是对于编译器来说明确类型的大小

    C++98中enum的每一项可以指定数值,所以在定义之前是无法知道底层类型的大小,从而也无法前置声明。一般C++会默认底层类型为可以覆盖所有项的最小数据类型,例如enum Color { black, white, red };使用char既可以满足,而enum Color { black=0, white=0xFFFF };的范围是0~0xFFFF需要uint16_t才可以,因此在定义之前编译器是无法知道enum的底层类型大小的。C++11的scoped enum底层数据类型默认是int,unscoped enum依然没有,但是scoped enumunscoped enum都可以在声明或定义时指定底层数据类型,因此二者都可以前置声明,只是unscoped enum的前置声明必须要指定底层数据类型。

    // 头文件中
    enum Color;                          // !!! error, C++98/11/14均不可以
    enum Color:std::uint8_t;             // fine, C++11新支持, 指定底层类型为std::uint8_t
    enum class Color;                    // fine
    enum class Color:std::uint8_t;       // fine, 指定底层类型为std::uint8_t
    

需要记住

  • C++98 风格的enum称为unscoped enum
  • scoped enum的枚举成员只enum内可见,转换到其他类型需要使用cast
  • scoped enumunscoped enum都支持指定底层类型,scoped enum默认为int,unscoped enum无默认底层类型
  • scoped enum总是可以前置声明,unscoped enum只有指定了底层类型后才可以前置声明

Item.11 优先使用deleted函数而非私有无定义的

C++中如果我们希望阻止调用某些函数时,C++98的做法是将其声明为*private*然后并不去实现它,这样外部无法调用(或者权限错误)而内部或者友元调用会产生链接错误(因为没有实现)从而达到目的。对于一般的函数其实不存在这个问题,这里需要阻止的一般是C++的特性成员函数由编译器自动生成的函数,大多数我们遇到的是复制构造函数、赋值运算符。

  1. 错误清晰 对于一些对象,其拷贝构造、赋值运算是没有意义或者不需要的,例如STL的unique_ptr和basic_ios的拷贝构造函数,称之为*uncopyable*类型,实现如下例。C++11通过=delete来标记函数,且C++11一般删除函数位于*public*中,因为编译会先检查权限(public/private)然后才是是否删除,而删除函数一般和访问权限关系不大,所以放在public部分可以使得错误更加清晰。总的来说,相对C++98风格,C++11使用=delete的方式可以在编译期间即发现错误,而不是等到链接期间,且错误更加直观。

    // C++98
    template <class charT, class traits = char_traits<charT> >
    class basic_ios : public ios_base {
    public:
        ...
    private:
        basic_ios(const basic_ios& );                       // 无定义
        basic_ios& operator=(const basic_ios&);             // 无定义
    };
    
    // C++11
    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;
        ...
    };
    
  2. 适用范围 C++98声明私有不定义的方式只使用于类成员函数,C++11的删除函数方式可以作用到所有函数上。例如一个bool isLucky(int x);函数,只希望接受整形数字而不是char、bool、float、double等可以隐式转到int的类型,可以使用delete来删除一定的函数达到目的。注意此处float转换到double优先于int,所以第四个函数会同时拒绝float和double。

    bool isLucky(int number);      // original function
    bool isLucky(char) = delete;   // reject chars
    bool isLucky(bool) = delete;   // reject bools
    bool isLucky(double) = delete; // reject doubles and floats
    
  3. 模板 C++11删除函数适用于模板,但是C++98方式不可以。例如我们有一个只接受内置类型指针的函数模板,但是内置类型指针有两个特殊:void*char*,前者无法解引用而后者一般是指C风格的字符串。C++11删除函数可以实现该功能,如果要做到完善还要删除const void*const volatile void*const char*const volatile char*等等;而如果使用C++98的方式,则需要将其放入类中,且实际上依然是不可以的,因为模板特化需要在命名空间下,而不是类空间下,写到private下面访问权限已经不同了,但是delete不存在此问题,所以都可以写在class外部。

    // C++11 删除函数方式
    template<typename T>
    void processPointer(T* ptr);
    template<>
    void processPointer<void>(void*) = delete;
    template<>
    void processPointer<char>(char*) = delete;
    
    // class 中
    // C++98
    class Widget {
    public:
        ...
        template<typename T>
        void processPointer(T* ptr)
        { ... }
    private:
        template<>
        void processPointer<void>(void*);         // !!! error
    };
    // C++11
    class Widget {
    public:
        ...
        template<typename T>
        void processPointer(T* ptr)
        { ... }
        //template<>
        //void Widget::processPointer<void>(void*) = delete;
        ...
    };
    template<>
    void Widget::processPointer<void>(void*) = delete;
    

需要记住

  • 优先使用删除函数而不是声明私有不定义的方式
  • 所有的函数都可以标记为删除,包括非成员函数和模板偏特化

Item.12 使用override声明重写函数

C++的OOP主要围绕着类、继承和虚函数。但是虚函数重写却经常出错,重写的满足的条件有:

  • 基类函数必须是虚的
  • 基类和子类的函数名必须一致(除了析构函数)
  • 基类和子类的函数的参数类型必须一致
  • 基类和子类的函数的常量性必须一致
  • 基类和子类的函数的返回值类型以及异常必须兼容
  • [C++11新增]基类和子类的函数的引用限定符必须一致。成员函数的引用限定符是C++11不怎么普及的新特性,可以使得标记的成员函数只能左值或者右值时使用,void foo()&;只有*this为左值时可以调用,void foo()&&;则只有*this为右值时可以调用。

所有的上述要求都满足时重写才是成功或者我们期待的,但是问题是任意一点不满足时,代码仍然是合法的:基类和子类的函数都保留下来,但是却不是我们希望的,所以经常失之毫厘谬以千里。C++11让我们可以用override来显示的声明重写函数从而让编译器帮助我们检查,如下四个函数在没有override下都是合法可以成功编译的代码,而原本的目的是去重写基类的虚函数,通过添加override可以让编译器直接发现错误。

class Base {
public:
	virtual void mf1() const;
	virtual void mf2(int x);
	virtual void mf3() &;
	void mf4() const;
};
class Derived: public Base {
	virtual void mf1() override;                      // !!!error,缺const
	virtual void mf2(unsigned int x) override;        // !!!error,参数类型不同
	virtual void mf3() && override;                   // !!!error,引用限定符不同
	virtual void mf4() const override;                // !!!error,基类没有声明为虚函数
}

另外所有的子类重写都使用override标记,然后改动基类的虚函数签名,然后重新编译查看报错可以确定这个改变会破坏多少代码,然后决定是否值得修改。如果没有使用override然后修改基类的虚函数签名,则只能希望有足够的单元测试来保证原始功能设计了。C++11引入了两个上下文关键字:overridefinal,这两个关键词只在特定的语境下才有保留意义,例如override只在函数声明的尾部时才有保留含义,而override这个词正常依然可以作为函数名字、变量名等,这样就不会破坏以前的老代码。

最后再次说明一下之前提到的引用限定符,其作用和成员函数最后添加const类似,通过判断调用的*this的性质来采取特定的行为,引用限定符则是根据*this是左值还是右值来决定调用函数的哪一个重载。如下例,Widget有一个vector成员和data()成员函数,我们对data()函数进行重载,则w.data()调用时w为左值则调用了1)从而得到左值引用进而是vector的复制构造函数构造vals1对象,而makeWidget().data()则是产生一个临时Widget对象–右值,所以调用了2),右值对象是临时的之后会销毁,所以可以直接使用移动语义,调用移动构造函数构造vals2来达到更高的效率。

class Widget {
public:
	using DataType = std::vector<double>;
	...
	DataType& data() &              // 1) for lvalue Widgets,return lvalue
	{ return values; } 
	
	DataType data() &&              // 2)for rvalue Widgets,return rvalue
	{ return std::move(values); }
	... 
private:
	DataType values;
};
Widget makeWidget();
Widget w;
...
auto vals1 = w.data();             // calls 1) copy-constructs vals1
auto vals2 = makeWidget().data();  // calls 2) move-constructs vals2

需要记住

  • 使用override来声明重写函数
  • 成员函数的引用限定符可以根据*this是左值或右值产生不同的行为

Item.13 优先使用const_iterator而非iterator

C++中的标准做法是只要可以就一定用*const*,STL中的const_iterator等同于常量指针,也遵从同样的原则–可以用的(不需要修改指向的数据)就尽量用。但是C++98对常量迭代器的支持是不完全的,const_iterator很难获得也很难用,因为无论C++98还是C++11中,常量迭代器和普通迭代器都是不能很简单转换的,C++98中一些函数只接受普通迭代器,这使得C++98中去遵守上述准则变得很困难。而C++11提高了对迭代器的支持,添加了*cbegin*、*cend*等获得常量迭代器的函数,*find*、*erase*、*insert*等函数也支持接受常量迭代器,C++14则更加完善了对其的支持,添加了非成员函数的*cbegin*、*cend*等函数。

对于需要写一些通用代码,C++11没有提供非成员函数的*cbegin*、*cend*(C++14提供),但是我们可以很简单的自己实现如下。对于类似容器结构的数据,container是一个常量引用,从而调用std::begin返回的是常量迭代器;对于内置的数组类型,container是常量数组引用,C++11为返回数组首地址指针的数据提供了一个特化的std::begin,返回一个常量指针。从而这种实现可以达到通用的目的。

template <class C>
auto cbegin(const C& container)->decltype(std::begin(container))
{
	return std::begin(container);
}

需要记住

  • 优先使用常量迭代器而不是普通迭代器
  • 为了最大化通用代码,优先选择非成员函数的beginendrbegin 等,而不是选择成员函数的版本

Item.14 使用noexcept声明不产生异常的函数

C++中关于异常更多的是去知道一个函数可能抛异常还是绝不抛(maybe-or-never),因为当确认函数觉不抛异常时可以针对性的做更多的优化。C++11加入了noexcept来替代C++98风格的throw()用来表示函数绝不抛异常。C++98风格的声明虽然支持但是已经被废弃了,C++11风格更好(理由后续说明)。当我们确认一个函数不会抛异常时,我们应该noexcept声明:

  • C++的异常声明也是接口一部分,当我们确认一个函数绝不抛异常但是我们却不去声明为noexcept则不是良好的接口设计。
  • 使用noexcept声明不抛异常的函数可以让编译器产生更优化的代码(机器码)。如下例,C++11风格声明的函数可以让编译器产生最优的代码。在运行时,声明不会抛异常的函数内部如果抛了异常,C++98的异常标准要求调用栈可以展开到函数的调用者,然后执行一些其他的最后程序结束;C++11的异常标准则只要求栈在程序结束前可能展开就好。在C++11的noexcept声明的函数中,有异常抛出时编译器不需要保存一个可以展开的运行栈,更不需要保证该函数内的对象按构建的逆序销毁,C++98风格的throw()和一般函数则没有这样的优化条件。

    RetType function(params) noexcept;  // C++11风格,最优化
    RetType function(params) throw();   // C++98风格,一般优化
    RetType function(params);           // 一般优化
    
  • 对一些函数性的提升(例如:移动操作符)
    例如想STL的std::vector添加一个新元素时,如果vector空间不足(capacity等于size)时,由于vector存储内存是连续的,所以会新开辟一块更大的内存,将之前的数据转移到新的内存已经尾部加入新的元素。C+98会开辟新的内存,调用复制构造函数来构建新的对象,构建完所有新对象后再销毁老内存处的所有对象。在调用复制构建函数构建新对象时如果抛出异常,由于老内存处的数据并没有修改,从而vector并没有破坏也就有了异常安全保证。进入C++11, 很自然得去使用*移动*代替*复制*来优化这个过程,但是潜在的问题是这样会破坏原来的异常安全保证,因为把元素从老内存移动到新内存地址过程中如果抛出异常,则老内存的数据已经被修改了,把新内存元素移回去也可能产生异常,状态无法还原,vector也就被破坏了。向前兼容是C++很基本的原则,所以C++11不能直接将这里的复制构造替换为移动。但是如果移动构造符已经声明为noexcept则我们可以在保证兼容的情况下使用移动语义来优化性能,这种做法在std::vector::reservestd::deque::insert等中都有实现。
    另一个例子是STL中广泛使用的swap函数,但比较有趣的是它是条件noexcept :它是否为noxecept取决于用户定义的swap是否都为noexcept。另外,函数尾部的noexcept等价于noexcept(true),而noexcept(false)则是双重否定:不是不抛异常,等价于没有noexcept。如下例的pair,其swap函数为noexcept当且仅当pair的first和second的swap函数也都是noexept。故总结为,高层数据的swap函数为noexcept的条件是其低层数据的swap函数都是noexcept(这也促使我们将不抛异常的函数声明为noexcept)。

    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声明时或者修改实现使得和声明不服时,我们往往会破坏客户端代码。另外 1)大多数函数是异常中性的,这些函数不抛异常,但是他们调用的函数可能会抛异常而异常会“穿过”他们继续往上抛,这样的函数不是noexcept。2)一部分函数是其实现天然不抛异常的,以及应该尽量按不抛异常的方式来实现(move、swap等)。但是对于是否抛异常需要更多的思考,不可本末倒置,追求一味的不抛异常或者乱抛异常。 3)一部分函数是需要为noexcept,C++98中约定内存释放函数和析构函数是不抛异常的,C++11则语言层面上隐式声明所有的内存释放函数和析构函数(用户定义+编译器自动生成)为noexcept。唯一的例外:析构函数不是隐式声明为noexcept是当其有成员数据或者继承的数据的析构函数被显示的声明为非noexcept(使用except(false)声明)。但是STL中是没有种情况的,如果将这种数据和STL使用,在析构中抛出异常是非定义行为UB。

最后,编译器对于函数实现是否和noexcept声明一致是不做检查的,即在声明noexcept的函数实现内调用non-noexcept的函数仍然是合法的,这一点和const成员函数不同,后者编译器会检查提示。原因 1)这些non-noexcept函数可能会有文档写明其不抛异常,而只是没有声明出来 2)这些函数可能是C语言的函数或者移到STL命名空间下的C函数 3)C++98下的函数但是没有使用C++98风格的声明,且没有及时改为C++11风格。 等等

需要记住

  • noexcept是接口的一部分,调用者可能依赖它
  • noexpcet函数比*non-noexcept*得到更多优化
  • noexpcetmoveswap 、内存释放函数和构造函数尤其重要
  • 大多数函数是异常中性的然不是noexcept

Item.15 使用constexpr只要可能

constexpr常量表达式可能是C++11最令人疑惑的部分,其看起来有const的性质但是又有不同,不过理解常量表达式是非常重要的且也会是值得的。涉及常量表达式时,记住一个核心是“编译期已知”,常量表达式不仅仅像const一样表示值是常量的,更表示这个值是编译器就已知的。同时常量表达式也可以作用到函数上,但是其作用却已const成员函数不同。

对象
首先,常量表达式对象/实例一定是const,但是不是所有const对象都是常量表达式,因为const对象不需要使用编译期就已知的值来初始化,例如constexpr int a; const int b=a;中b是const但是其值在编译期是不能确定的,所以其不是常量表达式。(常量表达式的值是在翻译期确定的,但是对于一般开发者来说并不需要关心,只需要知道是编译期确定即可)。

编译期可以确定的值是有特殊性的,他们可以放入只读内存中,这对于嵌入式开发者来说是非常重要的。另外,常量表达式整形(常量的、值在编译期已知的整形)可以大量应用到C++需要常量整形表达式的语境中:数组大小、模板整形参数(例如std::array的长度)、枚举量的值、对齐限定等。例如可以constexpr auto arraySize2 = 10;std::array<int, arraySize2> data2; 当然这里可以写const auto arraySize2 = 10;,其初始化值是编译期就已知的所以实际也是constexpr。如果要保证一个常量可以用于前面的的这些语境,那么应该使用constexpr而不是const

函数
constexpr作用在函数上时,将分两种情况: 1. 函数被调用,而传入的参数是编译期值已知的常量时,这个函数会在编译期调用,返回编译期值已知的常量。如果外部希望接受到constexpr但是调用时传入的参数不是都是编译期已知的,编译回报错。 2. 函数被调用,而传入的参数的值有一个或多个是运行期才可以确定,则函数表现为一般函数。

例如我们需要声明一个数组,其大小是3^n,n在编译期已知。C++的库函数std::pow可以用来算幂,但是1)其接受float 2)其不是constexpr,不能保证编译期返回值。我们可以如下实现一个constexpr版本的pow函数,从而可以用来计算数组的大小以及声明数组,同时这个函数在运行期也可以正常调用(相当于一份代码提供了两套函数)。

constexpr
int pow(int base, int exp) noexcept
{
	...
}
constexpr auto numConds = 5; 
std::array<int, pow(3, numConds)> results;

C++11/14区别
当用编译期已知值的参数调用常量表达式函数时其需要可以返回编译期值已知的结果(constexpr),所以其实现会有一些限制且C++11/14有区别。

  1. C++11的constexpr函数不允许超过1条语句(return)而C++14没有限制。C++11只允许一条语句,但是可以使用三目操作符(代替if-else)和递归(代替循环)来实现复杂的功能。上文提到的pow函数则有不同的实现方法:

    // C++11
    constexpr int pow(int base, int exp) noexcept
    {
        return (exp == 0 ? 1 : base * pow(base, exp - 1));
    }
    // C++14
    constexpr int pow(int base, int exp) noexcept
    {
        auto result = 1;
        for (int i = 0; i < exp; ++i) result *= base;
        return result;
    }
    
  2. C++11中void不是字面量类型,constexpr成员函数隐含为const函数,C++14不存在这两条限制。constexpr函数被限制只能接收和返回字面量类型:值可以在编译期确定的类型,C++11中除了void之外的所有内置类型都是字面量类型,另外用户定义的类型也可能是字面量类型–构造函数和其他成员函数是constexpr。如下例,二维点的类的构造函数是常量表达式,当传入给构造函数的值是constexpr时其成员变量是在编译期也是可以确定的,故其可以初始化为constexpr。另外由于X、Y的getter函数也是constexpr,我们可以写出计算中点的函数返回两个constexpr点的constexpr中点。

    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;
    };
    
    constexpr Point midpoint(const Point& p1, const Point& p2) noexcept
    {
        return { (p1.xValue() + p2.xValue()) / 2, (p1.yValue() + p2.yValue()) / 2 }; 
    }
    
    constexpr Point p1(9.4, 27.7);
    constexpr Point p2(28.8, 5.3);
    constexpr auto mid = midpoint(p1, p2);
    

    但是在C++11中,setX和setY并不声明为constexpr,因为1)两个函数返回的void不是字面量类型 2)constexpr成员函数隐含是const函数,所以不允许修改对象的成员变量。而在C++14中这两点限制都取消了,从而setter函数也可以是constexpr函数。

从上面讨论可以看到使用constexpr可以将部分计算从运行期转移到编译期,从而提高我们程序的性能(相应的编译时间会增长)。另外我们可以看到constexpr对象和函数比non-constexpr的应用范围更广,这也是标题“只要可能就使用constexpr”的原因。同时需要注意:constexpr是对象或者函数接口的一部分,它赋予了对象和函数更多的性质–客户代码可能依赖于这些性质,所以当删除constexpr时很可能就会破坏调用的代码。

需要记住

  • constexpr对象是常量且用编译期就已知的值初始化的。
  • constexpr函数被调用时,当传入参数是编译期值已知的,其可以返回编译期已知值的结果
  • constexpr对象和函数比non-constexpr的应用范围更广
  • constexpr是对象或函数接口的一部分

Item.16 让常量成员函数线程安全

一般的const成员函数天然是线程安全的,因为其逻辑上只有读操作没有写操作,所以不会有data race(数据竞争)。但有些时候我们需要对外表现为const成员函数(对外为读操作,对调用对象没有状态改变),而在内部需要一些标记等改变而这些改变是外界无需关心的,这时如果使用const标记成员函数,则会编译报错,因为我们修改了所属对象的一些属性,因此可以使用mutable。例如,一个类的const成员函数需要做一次性计算,其结果可以缓存给以后的调用。如下例,其对外表现依然是const所以使用了mutable来修饰缓存结果和缓存标志,但是引入的mutable使得这个const成员函数不再是线程安全,因为多线程同时调用时会存在同时写这两个变量的情况,其结果是未定义行为Undefined Behaviour。

class Polynomial {
public:
	using RootsType = std::vector<double>;
	RootsType roots() const
	{
		if (!rootsAreValid) {
		...
		rootsAreValid = true;
		}
		return rootVals;
	}
private:
	mutable bool rootsAreValid{ false };
	mutable RootsType rootVals{};
};

上例解决办法: 1)直观的是使用mutex进行加锁保护,这种方式是可以简单实现且有效的,但副作用是引入的mutex只能移动不可复制的,从而使得这个类的对象都不可复制,另外有时mutex是成本比较高、低效的。(注意:上例添加mutex成员变量也需要是mutable,因为加锁会改变其内部状态) 2)轻量级一点是方式是使用std::atomic来保证原子操作,但是对于上例这个方法是无效的,因为std::atomic只能保证对一块内存的操作是原子的,而上例对 rootsAreValidrootVals 使用std::atomic并不能解决问题,因为我们要保证原子操作的是多条语句(rootVals复制和rootsAreValid更改值),而std::atomic只能最多保证每一条是原子的。不过对于下例,其是可以工作的。

class Point {
public:
	...
	double distanceFromOrigin() const noexcept
	{
		++callCount;
		return std::sqrt((x * x) + (y * y));
	}
private:
	mutable std::atomic<unsigned> callCount{ 0 };
	double x, y;
};

需要记住

  • 确保const成员函数是线程安全的,除非可以确定其不会并发调用
  • std::atomic相对于mutex更轻量级、性能更好,但是要确保只把它用来操作一个变量或者内存地址

Item.17 理解特殊成员函数的产生

特殊成员函数是一类当我们用到但没有显示声明时,C++会自动生成的函数。C89有4个特殊函数:默认构造函数、析构函数、复制构造函数和复制赋值操作符,C++11在继承C89的四个特殊成员函数的同时添加了两个:移动构造函数和移动赋值操作符,签名如下。特殊成员函数隐式为public、inline和非虚的,例外是:基类的析构函数是虚函数,子类自动生成的析构函数也是虚函数。

class Widget {
public:
	...
	Widget(Widget&& rhs);              // 移动构造函数
	Widget& operator=(Widget&& rhs);   // 移动赋值操作符
	...
};

移动操作(构造、赋值)特殊函数的生成法则或规律与复制操作类似,但是有很多不同,这也是后文的重点。然后明确一下移动操作,移动操作会基于成员的移动每一个非静态的成员数据,包括基类的部分。但是这里的移动更应该解读为移动请求:支持移动操作符的则是真的移动,不支持的(eg. 大部分C++98的代码)则移动操作退化为复制操作。更多细节可以查看Item 23

总体上,任何时候只要用户声明了移动操作(两个)或者复制操作(两个),则其不会再自动生成了。具体还会有些区别:

  1. C++98和C++11中,两个复制操作都是独立的,用户声明其中一个不会影响编译器自动生成另一个。而两个移动操作则相反不是独立的,用户声明任何一个都会阻止另一个的自动生成,原因是声明任何一个都意味着默认的基于成员的移动是不适合的,那么自然另一个操作符还使用默认的则没有意义了。
  2. 任意一有个复制操作的声明都会阻止移动操作的生成,原因是声明复制操作意味默认的基于成员的复制是不适合的,那么自然对于成员的移动也应该是不适合的。同样,声明任何一个移动操作会使得编译器禁用掉复制操作(通过删除他们的方式,见Item11),理由和前面相同,但是注意这样不会破坏C++98的老代码,因为之前的老代码中不会有声明移动操作,如果老代码修改了或者添加了,那也说明使用了C++11。
  3. Rule of Three:复制构造、复制复制和析构函数,用户声明其中任何一个也应该声明另外两个。这是一条经验归纳出来的规则(非语言层面),原因为移动操作基本用来做资源管理,这意味着 1)一个移动操作中进行资源管理正常另一个也会需要 2)析构函数也经常参与资源管理(释放)。那么用户声明析构函数则说明默认的基于成员的复制操作可能不合适,复制操作应该不再自动生成,但是这一点并不是所有人认同,所以 C++98和C++11(兼容),声明析构函数对复制操作没有影响。但是,C++11中,声明析构函数会阻止自动生成移动操作。

使用= default来标记使用默认生成函数

  1. 上面说的规则类似的可能会推广到复制操作(声明析构函数就不再自动生成复制操作),并且用户声明复制操作或析构函数时C++11已经不鼓励生成复制操作。故当希望使用默认的复制操作而又会声明析构函数或一个复制操作时,应使用=default来消除这种隐式依赖:

    class Widget {
    public:
        ...
        ~Widget();                                    // 用户自定义析构函数
        ...
        Widget(const Widget&) = default;              // 使用默认复制构造函数
        Widget& operator=(const Widget&) = default;   // 使用默认复制赋值函数
    };
    
  2. 使用继承的时候,基类的析构函数经常需要是虚的,而我们依然希望使用默认的析构函数,这时可以使用=default来标记。同时声明析构函数会阻止移动操作,声明移动操作又会阻止复制操作,进而可以用=default将两个操作的4个函数都显示声明标记出来。

  3. 使用=default讲特殊函数都显示标记出来可以使得程序结构更清楚,使得依赖明显,从而避免潜在的bug。例如,修改一个类,添加析构函数并在其中log信息,但是这一行为会影响特殊函数的生成–禁用了移动操作,复制操作没有影响,修改后功能依然正常、测试也正常,但是移动会退化到复制使得性能降低一个数量级。

C++11特殊函数生成规则总结

  1. 构造函数:和C++98一样,用户不声明任何构造函数时候会自动生成
  2. 析构函数:核心和C++98一样,只有在基类的析构是虚的时候才会生成虚的析构函数。唯一区别为Item14析构函数默认是noexcept
  3. 复制构造/赋值:和C++98一样,两个独立。1)用户声明移动操作会删除复制构造和复制赋值 2)用户声明析构函数或一个复制操作,编译器生成另一个复制操作是不鼓励的
  4. 移动构造/赋值:生成的条件是 1)没有声明复制操作 2)没有声明移动操作 3)没有声明析构函数

模板

成员函数模板对特殊函数的生成不产生影响。如下例,T可以取Widget,但是编译器仍然会产生复制和移动操作函数,这点的意义可以在Item26查看。

class Widget {
	...
	template<typename T>
	Widget(const T& rhs);

	template<typename T>
	Widget& operator=(const T& rhs);
};

需要记住

  • 特殊函数是编译器会自动生成的函数:构造函数、析构函数、复制构造、复制赋值、移动构造、移动赋值。
  • 移动操作的生成条件:1)没有声明复制操作 2)没有声明移动操作 3)没有声明析构函数
  • 1)用户没有声明复制构造时才生成复制构造,用户声明移动操作会删除复制构造 2)用户没有声明复制赋值时才生成复制赋值,用户声明移动操作会删除复制赋值 3)声明析构函数仍生成复制操作是不鼓励的
  • 成员函数模板不影响特殊函数的生成




CH.4 智能指针

C语言最强大的地方就是指针,利用好了指针则如神器在手,但是大多数用不好则是泥潭挣扎,下面列举裸指针让人爱不起来的地方:
1. 指针声明无法指示其所指是单个对象还是数组
2. 指针声明无法告诉你是否应该在用完后释放所指对象,也就是无法表明指针释放拥有该对象
3. 当用完要销毁指针指向的时候,无法确认是使用*delete*还是专门的析构函数
4. 如果使用*delete*销毁指针,结合第1条,无法确认是使用*delete*还是delete[]
5. 如果确认指针拥有所指向的资源且也使用*delete*销毁,但很难保证在所有的执行路径向都只销毁一次,零次则资源泄漏,两次及以上则是UB未定义行为
6. 无法知道指针是否悬空,也即是指针指向的内存已经不再保存原本的对象或者数据

C++的智能指针既是来解决这些问题,智能指针是把裸指针包裹一层,对外表现和裸指针相似,保留指针强大功能时候避免其缺陷,因此相对于裸指针应该优先选用智能指针。实际上,智能指针可以干所有裸指针干的,且避免大多数出错的情况。C++11有四种智能指针:std::auto_ptr, std::unique_ptr, std::shared_ptrstd::weak_ptr. 他们都是用来管理动态生成对象的生命周期的,防止资源泄漏。

std::auto_ptr 是继承自C++98,在C++11中不鼓励使用(弃用)的,C++11中更好的选择:std::unique_ptr 完全替代了它。std::auto_ptr 的工作需要移动语义但是在C++98中并没有,于是只能修改复制操作符,结果是产生一些怪异的代码(复制std::auto_ptr 不会报错但会将其设置为NULL)和结果(std::auto_ptr 不能放入容器中)。std::unique_ptrstd::auto_ptr 的升级和替代,除非要在只支持C++98编译器环境下否则不应该再使用*std::auto_ptr*。

Item.18 排他所有权的资源管理使用std::unique_ptr

std::unique_ptr 和裸指针占用大小相同,且大多数操作(包括解引用)的指令相同,这两点使得其可以在小内存、低频率设备(eg 嵌入式)上也可以使用,所以只要可以使用裸指针那么std::unique_ptr 基本也可以使用。std::unique_ptr 具有排他的所有权语义,也就是非空的std::unique_ptr 独自拥有其指向的资源,std::unique_ptr 只能移动不能复制,这样也保证了所有权的排他性。当std::unique_ptr 销毁时也会释放其所指向的资源,默认是使用delete.

  1. std::unique_ptr 一个常用的场景是作为层级结构对象的工厂函数的返回类型,如下例。调用时候auto p=makeInvestment(arg)即可,p获得返回对象的所有权,之后可以通过移动来传递,当传递中断或中止时(eg 异常抛出),std::unique_ptr 销毁时候会调用对象的析构函数(参考Item.14,少数情况下不会调用,例如1)noexcept函数抛异常 2)std::abort或其他exit函数)。另外这里可能返回子类的指针,从而我们会通过调用父类指针来释放子类,所以注意父类的析构函数需要声明为虚函数。

    class Investment { ... };
    class Stock: public Investment { ... };
    class Bond: public Investment { ... };
    class RealEstate: public Investment { ... };
    
    template<typename... Ts>
    std::unique_ptr<Investment> makeInvestment(Ts&&... params);
    
  2. *std::unique_ptr*还经常用来实现Pimpl Idiom,代码不复杂函数也没有那么直观,可以参考Item.22

其他的知识点还有:

  1. std::unique_ptr 不仅可以使用默认的删除器delete, 还可以自定义删除器:任意函数、函数对象、lambda表达式。自定义删除器可以让我们自定义删除过程,例如记载log等,如下例。这里需要注意 1)自定义删除器接受一个裸指针作为参数 2)当使用默认delete删除器时候 std::unique_ptr 和原裸指针一样大小,但是使用自定义删除器时则不一样,a)使用函数指针,则大小增加一个函数指针大小 b)使用函数对象或者lambda表达式则取决于其内部保存的状态,eg 无捕获的lambda不改变其大小 3)不能直接将裸指针赋值给 *std::unique_ptr*,C++11智能指针拒绝这种隐式转换,所以需要显示使用reset

    auto delInvmt = [](Investment* pInvestment)
    {
        makeLogEntry(pInvestment);
        delete pInvestment;
    }; 
    template<typename... Ts>
    std::unique_ptr<Investment, decltype(delInvmt)>
    makeInvestment(Ts&&... params)
    {
        if ( /* a Stock object should be created */ )
        {
        pInv.reset(new Stock(std::forward<Ts>(params)...));
        }
        else if ( /* a Bond object should be created */ )
        {
        pInv.reset(new Bond(std::forward<Ts>(params)...));
        }
        else if ( /* a RealEstate object should be created */ )
        {
        pInv.reset(new RealEstate(std::forward<Ts>(params)...));
        }
        return pInv;
    }
    
  2. std::unique_ptr 有两种形式, 单对象std::unique_ptr 和数组*std::unique_ptr,但这不会带来混淆,例如单对象形式没有索引操作符([]),数组形式没有解引用操作(, ->)。另外,除非要配合C风格API从而使用数组形式的std::unique_ptr,其他应该使用STL的容器(vector,array,string).

  3. std::unique_ptr 可以方便的转换为std::shared_ptr, 例如前面的调用std::shared_ptr<Investment> sp = makeInvestment( arguments ),工厂函数返回时并不知道接受方希望排他持有对象还是共享所有权,这条性质使得代码可以更加灵活,也是非常适合作为工厂函数返回值的原因。

需要记住

  • std::unique_ptr :小、快、只能移动不能复制、排他-专属所有权
  • std::unique_ptr 默认使用*delete*删除器,但支持自定义删除器,但有状态的删除器或者函数指针会增加其大小
  • std::unique_ptr 可以非常简单的转换为std::shared_ptr

Item.19 共享所有权的资源管理使用std::shared_ptr

std::shared_ptr 是C++11新添加的功能,其具有GC自动管理资源/对象生命周期的功能,又避免了GC资源释放的不确定性(调用析构函数,时间可以预测)。std::shared_ptr 指向的对象会由std::shared_ptr 来共享的管理其生命周期,没有任意一个特定的std::shared_ptr 拥有它,管理同一个对象的所有std::shared_ptr 也同时共享一个控制块/Control Block,控制块包含数据有:引用计数、弱引用计数、其他数据(自定义删除器、分配器等),每当有一个指向同一个对象的下新的std::shared_ptr 创建时引用计数+1,而这样的std::shared_ptr 对象销毁时-1,在std::shared_ptr 的析构函数内会检测当引用计数为0时则表示已经没有任何std::shared_ptr 指向这个管理的对象了,此时就会调用销毁/释放这个对象,达到了自动管理的功能。

引用计数的变化规律比较简单,当有新的std::shared_ptr 生成的时候就会+1, 销毁的时候就-1. 1)调用构造函数引用计数会+1, 唯一例外是移动构造函数,因为移动构造时候原std::shared_ptr 的指针会设置nullptr,引用计数加一减一,净变化为零 2)调用析构函数引用计数会-1 。

额外的共享引用计数势必带来新能上的影响:

  1. std::shared_ptr 的大小是裸指针的两倍,因为它内部持有两个指针:指向管理对象的指针、指向控制块的指针(非STL标准要求,但一般实现是这样)
  2. -引用计数部分是和所管理对象关联的,对于所有管理的std::shared_ptr 来说应该是“全局”的,所以控制块不可以存在std::shared_ptr 成员中。动态分配控制块内存有两种方案:1)将控制块的内容移动到所管理对象中,这样只需要一次分配内存,std::shared_ptr 也只存储一份指针,所管对象自身计数。这样虽然效率会高,但是“侵入性”太强,使用std::shared_ptr 的话需要修改原类型的代码,且内置类型 eg. int、double等无法支持。2)独立保存控制块的信息,单独分配内存,其效果和前一种相反,效率稍微降低,但是兼容所有代码,支持所有类型。Item.21会讲到使用std::make_shared函数来创建std::shared_ptr 可以避免一次内存分配的额外消耗,但有些情况使用不了(eg.使用自定义删除器)
  3. 引用计数的加减必须是原子操作。管理同一个对象的std::shared_ptr 可能会在不同的线程中同时使用,所以对共享的引用计数的操作必须是原子的(否则就要加锁),相对非原子操作,成本是增加的。

控制块是std::shared_ptr 用来管理对象/资源的辅助数据结构,无论复制多少std::shared_ptr 同一个对象/资源只对应一个控制块,控制块在生成第一个std::shared_ptr 是生成和设置,但是对于一个裸指针所指的对象,函数无法知道其是否有一个关联的控制块,也就是说用同一个裸指针来生成多个std::shared_ptr 在代码上是合法的,可以编译通过,但是N个std::shared_ptr 最终就会释放N次,对于一个地址释放两次以及以上是UB未定义行为。控制块生成规则:

  1. std::make_shared (见Item.21)总会创建控制块,但是所管理对象也会创建,这种方式是安全的。
  2. 用专有所有权指针(std::unique_ptr 或者std::auto_ptr )构造std::shared_ptr 时会创建控制。注意:std::unique_ptr 可以转换为*std::unique_ptr*,但反之不可以
  3. 用裸指针构造std::shared_ptr 时会创建控制块,用std::shared_ptrstd::weak_ptr 构建std::shared_ptr 时不会创建控制块,因为传入参数已经有控制块了

删除器
std::unique_ptr 类似,std::shared_ptr 使用delete作为默认删除器,但也支持自定义删除器。但两者有部分不同:

  1. std::unique_ptr 中, 删除器类型是std::unique_ptr 类型的一部分,ie. std::unique_ptr<Widget, FooDeleter>std::unique_ptr<Widget, BarDeleter> 是两个不同的类型,而std::shared_ptr 设计更为灵活,其自定义删除器是构造函数,存储在控制块中,eg. std::shared_ptr<Widget> foo(new Widget, FooDeleter); std::shared_ptr<Widget> bar(new Widget, BarDeleter) 中foo、bar具有同样的类型std::shared_ptr<Widget>, 从而也都可以放入std::vector<std::shared_ptr<Widget>>中,而std::unique_ptr 是不可以
  2. std::unique_ptr 指定自定义删除器会改变其大小(Item.18),但std::shared_ptr 不会。如前面所说,std::shared_ptr 的大小是两个指针的大小,删除器是保存在控制块当中,故不同的自定义删除器会改变控制块的大小,但不会影响std::shared_ptr 的大小。

使用规则

auto pw = new Widget;
...
std::shared_ptr<Widget> spw1(pw, loggingDel);
...
std::shared_ptr<Widget> spw2(pw, loggingDel);

上面的代码可以编译通过但运行会报错,其有使用错误还有不推荐用法。1)结合本章开头所说,C++11的推荐做法是使用智能指针而非裸指针,尽量不要出现new,但这两点只是不推荐,并不是错误 2)用同一个对象的裸指针来构建多个std::shared_ptr 是UB未定义行为,每一个都会有控制块,在引用计数为零的时候会去释放管理的对象,上例中pw会被释放两次,这是UB的。因此推荐的做法是:

  1. 可能的话,优先使用std::make_shared()来创建对象和管理对象的shared_ptr,1)效率高,对象和控制块一次分配内存 2)不出现new、不出现裸指针,没有上面的风险
  2. std::make_shared()不能使用的情况下,例如需要传递自定义删除器,应该直接传递new 返回的指针,而不是赋值给裸指针然后构建智能指针,这样也可以避免上面裸指针乱用的风险。eg.std::shared_ptr<Widget> spw1(new Widget, loggingDel); std::shared_ptr<Widget> spw2(spw1);

特殊情况:this

std::vector<std::shared_ptr<Widget>> processedWidgets;
class Widget {
public:
	...
	void process();
	...
};
void Widget::process()
{
	...
	processedWidgets.emplace_back(this);   // 错误,可能导致多次释放,UB
}

上面的代码展示了一个危险的例子,当使用裸指针在其整个生命周期中调用process()一次时,代码是安全的; 但是一旦使用裸指针在生命周期内调用多次、或者外部本身已经归shared_ptr管理然后调用则会再次构建shared_ptr,产生多个控制块导致UB行为。这里可能会想,只要都使用裸指针就不会存在这个问题了,但这个这一章的原则是相抵触的,C++11下应该使用智能指针而不应该用裸指针,且智能指针是有“感染性”的,一旦你的系统一处使用了智能指针,为了安全和它有关联的模块、API接口都会感染上。

对于这种希望被shared_ptr管理的class可以安全的用this指针构建shared_ptr,C++的STL库提供了叫std::enable_shared_from_this 的基类模板,它的类型参数总是那个要继承它的class,如下例。这种模式叫做奇异递归模板模式( Curiously Recurring Template Pattern,CRTP)。通过继承,使用this构造shared_ptr时不再调用构造函数而是使用shared_from_this()函数,shared_from_this()不再创建控制块,而是去找关联到当前对象指针的控制块,因此这里有前提:当前对象已经关联了控制块,也即是当前对象已经被shared_ptr管理着,如果没有控制块那么调用是UB未定义行为(典型的是会抛出异常)。为了避免在还没有shared_ptr之前就调用到了这个函数,可以将其构造函数设置为private对外隐藏,然后提供一个返回shared_ptr的静态工厂函数来解决。

std::vector<std::shared_ptr<Widget>> processedWidgets;
class Widget: public std::enable_shared_from_this<Widget> {
public:
	template<typename... Ts>
	static std::shared_ptr<Widget> create(Ts&&... params);  // 工厂函数
	...
	void process();
	...
private:
	//contructor 构造函数
};
void Widget::process()
{
	...
	processedWidgets.emplace_back(shared_from_this());
}

数组
std::unique_ptr 不同,std::unique_ptr 不支持数组(C风格),std::unique_ptr 的API是针对单个对象设计的,也即是支持std::shared_ptr<T>但是不支持std::shared_ptr<T[]>。有人通过自定义删除器(delete[])使得也可以工作;但是std::shared_ptr不支持随机访问操作符([]),强行使用又要添加“脏”代码;std::shared_ptr支持单对象的子类-基类转换,但不能使用到数组上(std::unique_ptr支持数组std::unique_ptr,禁用了这类转换);最后,在STL提供了大量容器的情况下,去使用数组本身就是不好的设计。

总结
std::shared_ptr的设计决定了其需要控制块,从前面的讨论可以看到代价是: 1)控制块内存要动态分配 2)控制块的大小大多是就几个字节,但是也受分配器和删除器的影响 3)控制块为了保证释放资源,会使用继承甚至虚函数,调用需函数需要查虚表 4)加减引用计数需要使用原子操作。但是这个代价经常是值得的,我们获得了对动态资源的自动生命周期管理,且对于前面的代价:1)使用std::make_shared()不需要给控制器单独分配内存 2)大多是默认使用delete删除器,控制块只有6个字节 3)智能指针解引用不比裸指针解引用花销大 4)引用计数的原子操作虽然比非原子操作花销大,但它也只占单条指令 4)虚函数经常对每个对象也只在其销毁时调用一次。

需要记住

  • std::shared_ptr 提供了类似GC的对任意资源的共享生命周期管理
  • 相比于std::unique_ptr, std::shared_ptr 的大小经常是前者两倍、多了控制块、多了引用计数的原子操作
  • std::shared_ptr 的默认删除器是delete,但支持自定义删除器,自定义删除器不影响std::shared_ptr 的类型
  • 避免用对象的裸指针来构建std::shared_ptr

Item.20 类似std::shared_ptr但可能悬空的指针使用std::weak_ptr

std::weak_ptr 是一种智能指针:类似std::shared_ptr, 但不参与所指资源的共享所有权,但是可以用来处理所指资源指针可能悬空的情况(std::shared_ptr正常使用不会出现这种情况,出现则无法处理UB未定义行为)。std::weak_ptr 不能解引用,因为它不是独立工作的,它是std::shared_ptr辅助/增强,典型的std::weak_ptr 是从std::shared_ptr构造的,他们指向同一个控制块,但std::weak_ptr 不影响引用计数,作用于弱引用计数,两者大小一样(结构一样:一个指向资源的指针,一个指向控制块的指针)。

auto spw = std::make_shared<Widget>();
...
std::weak_ptr<Widget> wpw(spw);
...
spw = nullptr;
if (wpw.expired())
{
// 如果管理的指针没有悬空
}

如上例,std::weak_ptr 从std::shared_ptr构造,然后可以通过expired()函数来判断所指向的资源指针是否仍然有效/悬空,这里简单的想法是先判断资源指针是否悬空,没有悬空就去使用,事实是不可以的:1)std::weak_ptr 没有解引用操作,既是指针有效但也无法使用 2)先判断是否有效然后使用是两步操作,多线程使用的时候是不安全的,两步之间其他线程可能销毁了资源。解决这两个问题的方法是:拿到一个std::shared_ptr + 原子操作。std::weak_ptr 提供了两种操作形式:

  1. std::weak_ptr::lock(),如果资源指针悬空返回nullptr,未悬空则构造一个指向该资源的std::shared_ptr(这样资源引用计数+1,保证不会被释放掉)。如上则代码为std::shared_ptr<Widget> spw1 = wpw.lock();或者auto spw2 = wpw.lock();
  2. 直接用std::weak_ptr 构造std::shared_ptr,如果所指向资源失效悬空则抛出异常。代码:std::shared_ptr<Widget> spw3(wpw);

std::weak_ptr 的作用我们以三个用例来说明:

  1. 考虑一个工厂函数,接受一个ID返回一个指向只读对象的智能指针。根据Item.18我们知道最合适的是std::unique_ptr,可是如果根据ID得到对象的操作可能成本比较高(涉及文件、IO、数据库、网络等),自然的优化是缓存这个结果,每次调用的时候先查看是否有缓存,有则直接返回没有则去哟构造返回;但另一个问题是所有结果都缓存会消耗大量内存也影响查找,降低性能,因此还需要优化:释放掉不再使用的对象资源。由于需要缓存则std::unique_ptr不可行,其会移交所有权,工厂函数内部无法持续持有;std::sahred_ptr可以满足缓存功能,但是工厂函数持有一份std::shared_ptr会使得对象资源不能释放,而正常的函数调用者应该拥有这个对象资源。分析是:工厂函数需要返回一个拥有对象的智能指针,函数内部需要缓存一个智能指针–不拥有资源但可以检测资源是否已经释放,而这正是std::weak_ptr支持的。下例是如上优化的工厂函数的一个简单实现(仍待完善,这个实现会缓存大量的失效std::weak_ptr).

    std::shared_ptr<const Widget> fastLoadWidget(WidgetID id)
    {
        static std::unordered_map<WidgetID, std::weak_ptr<const Widget>> cache; // 缓存的map
    
        auto objPtr = cache[id].lock();     // 有缓存且有效:std::shared_ptr  无缓存或者有缓存但失效:nullptr
        if (!objPtr) 
        {                                   // 如果没有有效缓存则生成对象并缓存 
            objPtr = loadWidget(id);
            cache[id] = objPtr;
        }
        return objPtr;
    }
    
  2. 观察者设计模式。观察者设计模式包含两个主要组件:主题(其状态会变化),观察者(主题的状态变化时会通知它)。大多数的实现是在主题的数据成员中保存一份观察者的指针,当其状态变化的时候方便通知观察者。主题是不管理观察者的生命周期(既不拥有也不少分享),但是主题需要知道观察者指针是否有效/悬空,然后才能安全的通知(调用悬空指针是错误的),如此合理的设计是让主题持有一份观察者的std::weak_ptr。

  3. 循环引用。考虑A、B、C三个对象,A、C共享管理B的std::shared_ptr,某种需求B也需要持有A的指针,该指针可以是:

    1. 裸指针。1)本章强调不推荐裸指针 2)A销毁后由于C仍然持有B的std::shared_ptr,B不会销毁,B持有A的裸指针则无法直销A指针已经悬空,调用产生UB未定义行为
    2. std::shared_ptr。A、B会互相持有对方的std::shared_ptr,从而造成循环引用,最终std::shared_ptr销毁了但是A、B不能销毁,资源泄漏。
    3. std::weak_ptr。解决了上面的问题,B可以知道A指针是否悬空从而决定调用,同时std::weak_ptr也不阻止两者的释放。

    std::weak_ptr 可以很好的解决循环引用的问题,但是大多数数据结构实际上是层级结构的,并不存在循环引用,父节点持有子节点的std::unique_ptr,子节点持有父节点的裸指针,因为父节点的生命周期一定比子节点长,所以这样是安全的。

需要记住

  • 对于std::shared_ptr类似的但可能悬空的指针,使用std::weak_ptr
  • std::weak_ptr的潜在应用场景包括缓存、观察者模式、避免std::shared_ptr循环引用等

Item.21 优先使用std::make_unique和std::make_shared而不是new

C++11开始提供了std::make_sharedstd::make_unique 从C++14开始才提供,但是如下例,可以自己实现一个简单版本的std::make_unique 虽然不支持数组和自定义删除器Item.18(注意:不要将此函数防止到std命名空间下,避免升级编译器是冲突)。 std::make_sharedstd::make_unique 支持接受任意类型/长度参数然后完美转发给构造函数,另外一个make函数是:std::allocate_shared ,其和std::make_shared一样除了第一个参数是内存分配器。

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

优先使用make 系列函数的理由如下(std::make_shared优点同样适用于std::allocate_shared):

  1. 避免重复”写”类型。eg. auto upw1(std::make_unique<Widget>());std::unique_ptr<Widget> upw2(new Widget);,明显前者可以少输入一次Widget类型,这样1)少输入一次 2)减少编译时间 3)避免代码不一致。对std::unique_ptr 同理。
  2. 异常安全。如下例函数,传入一个std::shared_ptr会复制一次(传值),性能稍有影响所以直接传入右值,另外假如priority是直接计算出来的,那么存在如下两种调用。其中方式1)是不安全的,存在资源泄漏的风险,原因是在函数调用前其参数是需要先求值的,从而有3步会执行 a)new Widget b)用new的指针构造一个std::shared_ptr c)调用computePriority。但是这三步的顺序对编译器是没有要求的(b必须在a后除外),所以可能出现a-c-b而在c中抛出异常,则在a中new出来的资源得不到释放–资源泄漏。而调用方式2)则将a-b操作封装在一起,从而消除了c插入两者之前产生资源泄漏的风险。对std::unique_ptr 同理。

    void processWidget(std::shared_ptr<Widget> spw, int priority);
    processWidget(std::shared_ptr<Widget>(new Widget),computePriority());    1)潜在资源泄漏风险
    processWidget(std::make_shared<Widget>(), computePriority());            2)异常安全
    
  3. (仅std::shared_ptr)提高性能。Item.19 解释过std::shared_ptr构建需要动态分配控制块内存,使用new方式则需要调用两次new(对象和控制块),而使用std::make_shared 则只需要一次。这样 1)减少生成的代码 2)提供运行时速度 3)可能减少控制块中保存信息,进而减少内存使用。

使用make 系列函数不是绝对的,有部分情况是不适用的:

  1. 使用自定义删除器时,make 写列函数均无法支持
  2. 当类型构造函数支持std::initializer_list 参数时, make 系列函数完美转发参数时使用()还是{}?二者的结果是不一样的。eg.auto upv = std::make_unique<std::vector<int>>(10, 20); 如果使用()则是10个元素每个的值为20, 如果是{}则是两个元素分别为10,20. 好消息是:其结果是确定的,采用的前一种()方式, 坏消息则是:如果想用列表构建对象则需要使用new。Item.30 解释了{}列表不能完美转发,折中的方式为先用auto的方式构建std::initializer_list对象,然后再传入make 函数中,eg.auto initList = { 10, 20 }; auto spv = std::make_shared<std::vector<int>>(initList);
  3. (仅std::shared_ptr)当类型有自定义的new操作符和delete操作符时,这种情况一般是全局的new、delete对它不适合。这种自定义的内存分配操作符经常只会分配类/结构自身大小的的内存(eg.sizeof(Widget)),结合控制块,可知使用make 函数来构造这种对象是不合适的。
  4. (仅std::shared_ptr)优点3)的副作用,使用make 为控制块和对象只申请一次内存提高了性能,但是原本控制块和对象的独立性也丧失了。使用new方式时,对象和控制块内存为分别申请,故可以分别释放;make 方式中控制块和对象的内存是统一申请的,释放也只能统一释放。从而使用make 时带来的问题是,当不再有std::shared_ptr管理对象时其控制块的引用计数为0,但是如果此时有std::weak_ptr关联控制块则控制块的弱引用计数不为0, 此时对象的内存可以释放但是控制块的内存不可以释放(std::weak_ptr通过控制块查看引用计数,从而知道对象是否有效)。使用new的方式则对象资源释放掉了,而make 方式由于控制块不能释放则对象资源也不能释放,只有当最后一个std::shared_ptr和std::weak_ptr都销毁时才会统一释放控制块和对象。 当对象很大且最后一个std::shared_ptr和std::weak_ptr间隔时间较长时,延时和抖动就会发生。

当不能使用make 系列函数但是有希望异常安全时,可以采用如下方案(使用new时直接用其结果构造智能指针,不要做多余操作),1)和2)均可以实现功能和异常安全,相对来说2)性能更好一点。1)传入左值会多一次复制构造,从而有引用计数的操作,而2)的移动语言Item.23则不存在引用计数的操作,效率更高。

std::shared_ptr<Widget> spw(new Widget, cusDel);    // cusDel自定义删除器
processWidget(spw, computePriority());              // 1)左值
processWidget(std::move(spw), computePriority());   // 2)右值

需要记住

  • 相对于new的方式,make 系列函数消除了代码的重复、提高异常安全、对于std::make_shared和std::allocate_shared生成更小更快的代码
  • 不适合使用make 系列函数的情况包括:使用自定义删除器、希望传递{}列表
  • 对std::shared_ptr另外两种不适合make 系列函数的情况:1)类有自定义的内存管理 2)对于系统内存紧张,类非常大,std::weak_ptr会比其对应的std::shared_ptr存活期长很多

Item.22 使用Pimpl模式时在实现文件中定义特殊成员函数

Pimpl(Private Implementation 或 Pointer to Implementation)是C++中常见的一种基础模式,其通过私有成员指针指向类内部成员数据结构,从而间接访问成员数据而不是直接将成员数据暴露在类中。如下例,将成员数据封装到新的结构中而原来的类中只留下指向新结构的指针。Pimpl的好处有:

  1. 降低模块的耦合。因为使用前置声明,类中只包含了新结构/类的指针,隐藏了原数据成员的类,从而对隐藏类修改不需要重新编译此类
  2. 降低编译依赖,提高编译速度。指针大小是固定的,使用前置声明+指针,使得例如修改Gadget的实现不会影响Widget,则widget的头文件和源文件不用重新编译
  3. 接口与实现分离,提高接口的稳定性。a)指针封装,构造Widget时编译器生成的代码只含有Impl指针(固定大小)不会含有Impl的任何其他信息 b)使用C接口时,通过指针分离数据和操作。
class Widget {
public:
	Widget();
	...
private:
	std::string name;
	std::vector<double> data;
	Gadget g1, g2, g3;
};

改为

class Widget {		          // widget.h
public:
	Widget();
	~Widget();
	...
private:
	struct Impl;
	Impl *pImpl;
};

#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(new Impl){} 
Widget::~Widget()
{ 
	delete pImpl; 
}

而上面的实现是基于裸指针的,在本章更推荐使用智能指针来实现(自动管理资源),对于上面专属所有权选择std::unique_ptr,使用智能指针替换裸指针之外还需要注意特殊成员函数,下例是一个完整的修改示例,很明显的是无论使用默认实现还是自己实现,都需要声明5个特殊成员函数且在实现文件中实现(标注的1、2、3)。

  1. 虽然使用智能指针可以自动管理资源,但是如果不声明析构函数也不在cpp文件中实现,在例如main.cpp中去实例化一个Widget会产生错误。原因是当使用默认的析构函数时编译器会内联代码,Widget的析构函数会调用std::unique_ptr的析构进而对裸指针调用delete,std::unique_ptr默认的删除器的实现通常都会在调用delete之前使用C++11的static_assert来保证裸指针类型是完整类型(widget.h中前置声明的Impl是不完整类型,对于include widget.h的文件,是看不到Impl的具体内容的,是不完整的),从而产生错误。总结原因是:Impl的具体内容/实现只在实现源文件可见(eg. widget.cpp)–在改文件中是完整类型,其余地方均不可见–都是不完整类型。所以需要声明析构函数,在实现cpp文件中实现析构函数(包括使用默认的析构函数)。
  2. 编译器默认生成的移动操作(赋值、构造)是满足需求的,但是由于声明了析构函数会阻止编译器生成移动操作 Item.17,所以也需要声明移动赋值和移动构造函数。但是在头文件类声明中如下Widget(Widget&& rhs) = default; Widget& operator=(Widget&& rhs) = default;声明来使用默认移动操作是不对的,原因同上一条1。移动赋值函数需要在赋值之前销毁pImpl指向的对象,但在头文件中其是不完整类型;移动构造函数则是在其内部有异常产生的时候会销毁pImpl,同样也需要其是完整类型。两者都需要Impl是完整类型,所以应该如下在头文件中声明源文件中定义/实现(包括使用默认生成的移动操作函数)
  3. 需要自己实现复制操作。a)对于只可移动不可复制(eg. std::unique_ptr)的类型编译器不会生成复制操作 b)即使编译器生成,则也只会复制指针(浅拷贝)而不是复制所指内容(深拷贝)。这里同样是头文件声明源文件定义,理由仍然是在头文件中Impl是不完整类型,其复制操作等是不可见的。
class Widget {                        // widget.h
public:
	Widget();
	~Widget();                             // 1
	Widget(const Widget& rhs);             // 2
	Widget& operator=(const Widget& rhs);
	Widget(Widget&& rhs);                  // 3
	Widget& operator=(Widget&& rhs);
private:
	struct Impl;
	std::unique_ptr<Impl> pImpl;
};

#include "widget.h"                   // 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;
Widget::Widget(const Widget& rhs)
	: pImpl(std::make_unique<Impl>(*rhs.pImpl)){}
Widget& Widget::operator=(const Widget& rhs)
{
	*pImpl = *rhs.pImpl;
	return *this;
}

上面是基于std::unique_ptr实现Pimpl需要注意的,当使用std::shared_ptr来实现时则不适用,情况更简单,编译器会自动生成所需的特殊成员函数,如下例即可,源文件也无需添加实现。导致使用两种智能指针情况不一样的原因在于他们对自定义删除器的支持方式不同。std::unique_ptr的删除器类型是智能指针类型的一部分,这样使得编译器可以生成更小的运行数据结构、更快的运行代码,这个性能提高的前提就是编译器生成特殊成员函数时指针所指的类型必须是完整的。相反,std::shared_ptr的删除器不是智能指针类型的一部分,导致更大的运行数据结构和慢一些的运行代码,但是生成特殊成员函数时指针所指类型不需要是完整的。

class Widget {
public:
	Widget();
	...
private:
	struct Impl;
	std::shared_ptr<Impl> pImpl;
};

需要记住

  • Pimpl模式通过降低类直接的依赖,降低了编译时间
  • 使用std::unique_ptr管理pimpl指针时候,需要在头文件声明特殊成员函数在源文件中定义/实现(即使使用默认的)
  • 上一条建议只适用于std::unique_ptr,不适用于std::shared_ptr




CH.5 右值引用、移动语义、完美转发

学习右值引用、移动语义和完美转发,我们会经历三个状态:

  1. 最初的简单明了

    • 移动语义:移动语义可以让编译器用消耗低的移动操作代替昂贵的复制操作,类似复制构造和复制赋值有了移动构造和移动赋值,也引入了只可移动不可复制类型,eg. std::unique_ptr
    • 完美转发:完美转发使得我们可以写出一个函数模板接受任意的参数然后把他们传给目标函数
    • 右值引用:右值引用是语言层面的机制,其把各分离的特性结合起来,同时也是上面移动语义和完美转发的基础
  2. 深入后各种异常不解
    实际应用和深入后会发现上面对这三者的认识还只是冰山一角,实际会更加复杂。例如, std::move可能并不真正的移动,完美转发也不是真正的“完美”,移动操作也不总是比复制成本低,即使支持移动时移动操作也并不一定执行,tyep&&不总是标示右值引用。

  3. 理解后再变得清晰明了 真正的理解后,std::move、std::forward、type&&相关的问题就有明确答案了,再次回到最初的简单。

本章,时刻记住void f(Widget&& w); 的参数总是左值,即使类型是右值引用。

C++11 中引用类型及其可以引用的值类型

引用类型 可以引用的值类型
非常量左值 常量左值 非常量右值 常量右值
非常量左值引用 Y N N N
常量左值引用 Y Y Y Y 全能类型,可用于拷贝语义
非常量右值引用 N N Y N 用于移动语义、完美转发
常量右值引用 N N Y Y 暂无用途

Item.23 理解std::move和std::forward

std::move和std::forward本质是一个执行转换(cast)功能的函数模板,std::move不移动任何东西而是无条件的将传入参数转换为其右值,std::forward也不转发任何东西而是有条件的将传入参数转换为右值,二者都在编译期工作,不产生任何运行时代码。

std::move

// C++11
template<typename T>
typename remove_reference<T>::type&& move(T&& param)
{
	using ReturnType = typename remove_reference<T>::type&&;
	return static_cast<ReturnType>(param);
}
// C++14 
template<typename T>
decltype(auto) move(T&& param)
{
	using ReturnType = remove_reference_t<T>&&;
	return static_cast<ReturnType>(param);
}

上例为一个简化的std::move实现,其接受一个universal引用的对象然后对其static_cast到返回类型再返回。其返回类型中的*&&*表示其是右值引用,Item.28解释到如果T本身是左值引用,那么T&&会变成左值引用,所以这里使用type trait先去掉T中的引用(Item.9) 然后在加上&&, 这样保证返回的是右值引用–函数返回右值引用也是右值,所以std::move将传入参数转换为右值。

样例分析:假如我们有一个Foo类,其有一个std::string成员数据value,其构造参数接受一个std::string参数来初始化value。正常采用传值的方式,由于不修改数据所以自然加上const,则可以申明为explicit Foo(const std::string text);, 为了避免多一次的复制(从text到value)可以改进使用std::move,explicit Foo(const std::string text):value(std::move(text)){},这样的代码可以正常编译、链接和运行,但是text的数据是复制到value中的,而不是预期的移动。原因是std::move的转换中常量属性是不改变的,所以其返回的是常量std::string的右值引用(const std::string&&),std::string可选的构造函数有:复制构造函数string(const string& rhs); 和移动构造函数string(string&& rhs);,明显const std::string&& 不能转换为 std::string&& (常量属性必须得到保证)从而不能选择移动构造函数;另外const std::string&& 也是 const std::string右值,从而可以绑定到const string&常量左值引用上,进而可以调用复制构造函数。移动操作都会修改对象,也既是改对象不可以有常量const属性,所以例如移动构造函数参数申明不可以加上const。

可以吸取两点经验 1)如果允许参数被移动那么不可以添加const声明。对于const对象的移动都会静默的转换为复制操作。 2)std::move不进行任何移动操作,它也不能保证转换后的对象一定可以被移动,它只负责无条件转换到右值。

std::forward
std::forward和std::move很相似,区别在于后者是无条件转换而前者是有条件:如果参数是被右值初始化的则转换其为右值。如下例,我们希望对调用有一个统一预处理(统计时间、log记录等),所以添加了logAndProcess函数并在其内部转发所有接受的参数到目标调用函数。在logAndProcess中,param是函数的参数–左值,如果不使用std::forward而直接调用process(param)则无论调用logAndPorcess时传入的是左值或右值其内部都会调用#1 process左值重载。因此这里需要使用std::forward,其根据初始化param的类型来决定是否转换,用右值初始化param则将其转换为右值。另外param是用左值初始化还是右值初始化的这个信息隐藏在模板类型参数T中,std::forward依靠模板类型参数T来决定转换,具体参考Item.28

void process(const Widget& lvalArg);          // #1 左值调用
void process(Widget&& rvalArg);               // #2 右值调用

template<typename T>
void logAndProcess(T&& param)
{
	auto now = std::chrono::system_clock::now(); 
	makeLogEntry("Calling 'process'", now);
	process(std::forward<T>(param));
}

Widget w;
logAndProcess(w);                   // 传入左值
logAndProcess(std::move(w));        // 传入右值

std::move和std::forward都是进行转换到右值,且后者更灵活(通过模板类型参数T来控制),技术上后者的确可以替代前者,更甚至两者都可以手写cast替代,但是这样做是没有意义的。std::move的优势:1)少一个模板类型参数,少写就少犯错 2)代码更清晰,std::move总是返回右值,而使用std::forward则要根据模板类型参数去判断或者决定对模板类型参数传入何种参数。例如对于最初的例子Foo提供一个移动构造函数,使用std::move可以有Foo(Foo&& rhs):value(std::move(rhs.value)){},使用std::forward则为Foo(Foo&& rhs):value(std::forward<std::string>(rhs.value)){},这里如果模板类型参数误写为std::string&,则value会复制构造而不是移动构造。 最后,std::move无条件转换右值所以其适用于移动move场景,std::forward有条件转换适用于转发forward场景。

需要记住

  • std::move无条件转换参数到右值,但其不移动任何东西
  • std::forward只在参数绑定在右值上才转换参数到右值
  • std::move和std::forward本身在运行期间不做任何事,不产生运行代码

Item.24 分清universal引用和右值引用

T&&有两种含义:1)右值引用。它只能绑定到右值,其主要作用是表明改对象是可移动的 2)universal引用,其和左值引用、右值引用不是一个层面的,其也不是一个具体的引用类型,它可以绑定到左值成为左值引用也可以绑定到右值成为右值引用,同时绑定对象无论是否const、volatile都可以绑定,所以称为universal。虽然两者的形式都是T&&,但是仍然是可以简单区别且需要学会区别:

void f(Widget&& param);         // 1)右值引用
Widget&& var1 = Widget();       // 2)右值引用
template<typename T>            // 3)右值引用
void f(std::vector<T>&& param); 
auto&& var2 = var1;             // 4)universal引用
template<typename T>            // 5)universal引用
void f(T&& param);

右值引用和universal引用的核心区别也是用来区分二者的是:是否有类型推断。代码中遇到T&&形式但是没有类型推导,则既是右值引用,如上例的1、2、3,类型T都是确定的,3)中虽然T没有确定但是整体的类型就是std::vector,仍然是确定的。而4中var2和5中的param的类型都是需要推断的,他们都是universal引用,进而也可以知道universal引用的使用的两个场景:1)如上例中4的函数模板 2)如上例5的auto声明

universal引用不是具体的一种引用,可以视为一个“未定”状态的引用–可以是左值也可以是右值但是还没有确定,其作为引用必须初始化(绑定到一个对象上),初始化也就决定了其是左值引用还是右值引用。当universal引用绑定到左值初始化,例如对上例的4,Widget w; f(w);,则在f中param是左值引用;当universal引用绑定到右值初始化则是右值引用,f(std::move(w));,f中param则是右值引用。

一个引用称为universal引用,类型推断是必须的,但是声明的形式也必须是正确的–严格为T&&(T是类型参数):

  • 上例中3既是一个反例,其类型参数T也会需要推断(此处不考虑直接指定),但是形式不服,其是右值引用,如下std::vector<int> v; f(v);调用3中的f,编译器会报错,因为右值引用无法绑定到v这个左值上。
  • 简单的const等限定符可以不可以。eg,template<typename T> void f(const T&& param);中param是右值引用
  • 在函数模板中看到T&&也不能保证是universal引用,如下例的std::vector的push_back声明,虽然有严格的T&&形式声明,但是分析会发现其并没有类型推断,因为std::vector是一个类模板,在特化时T的类型就已经指定了,例如声明std::vector<int> v;时,其T已经确定为int类型,特化的push_back函数签名为void push_back(int&& x);,其x是右值引用。相反下例中的emplace_back函数的args参数的类型是独立于T的,其类型Args在每次调用的时候都需要推断(Args实际是参数集合,但此处不影响)。

    template<class T, class Allocator = allocator<T>>
    class vector {
    public:
        void push_back(T&& x);
        template <class... Args>
        void emplace_back(Args&&... args);
        ...
    };
    

使用auto&&声明的变量是universal引用,这里类型推断一定会发送。在C++11中出现的可能不多,但C++14中会比较多,因为C++14中的lambda支持auto&&声明变量,例如一个统计函数时间花销的函数,func是universal引用可以绑定到任意可调用对象、右值、左值,params是universal引用参数集合(零个或多个)可以绑定到任意数目任意类型的参数上。使用auto声明universal引用使得timeFuncInvocation可以应用到大多数函数上(大多数而不是所有,查看Item.30)。

auto timeFuncInvocation = [](auto&& func, auto&&... params)
{
	... //start timer;
	std::forward<decltype(func)>(func)(         // 语法参考Item.33
		std::forward<decltype(params)>(params)...
		);
	... //stop timer and record elapsed time;
};

最后universal引用的真正原因是引用折叠Item.28,但是这并不影响上面讲到的,就如牛顿的运动学在现在看是不对或者完美的,但是在常规的低速下仍然是很好用和有效的。

需要记住

  • 如果一个函数模板有推导的类型T然后参数声明为T&& 或者用auto&&声明一个对象,这个参数或对象是universal引用
  • 如果类型声明不是严格的type&& 或者没有类型推导发生,那么是右值引用
  • 如果用右值初始化universal引用则其成为右值引用,反之使用左值初始化则成为左值引用

Item.25 右值引用上使用std::move,universal引用上使用std::forward

右值引用只能绑定到可以移动的对象上,universal引用可能绑定到可以移动的对象(右值)也可能绑定在不可移动的左值上,对比前面讨论,希望通过移动来提高性能,那么自然右值引用使用std::move而universal引用使用std::forward。相反则是不合理的:

  1. 右值引用使用std::forward, 在Item.23中已经讨论过,技术上是没有问题的,但是由于std::forward需要类型参数导致很容易写错、不易理解
  2. universal引用使用std::move也是不对的,因为universal引用可能绑定的是左值,而std::move无条件的将其转换为右值,其后可能会对其有移动操作,而外部传入是认为左值不会被修改的而继续使用改对象,因而有未知风险,如下例对左值调用后其内容被改变了。对下例,可能会想对左值和右值分别重载:void setName(const std::string& newName); void setName(std::string&& newName);,但这并不是很好的方法:

    a. 代码更冗长,不够统一不方便维护
    b. 相比之前universal引用版本可能效率更低。例如调用w.setName("Adela Novak");,universal引用版本会直接将字符串传递进去,调用赋值操作符,赋值给name;重载的版本会先由字符串构造临时对象std::string,然后调用右值重载移动到name,再调用临时对象的析构函数。二者显然花销大于前者,且不同的应用差距可能更大,有的类型可能在构造、析构、移动上会有大量消耗。
    c. 重载版本更严重的问题是这样的设计扩展性太差。上面讨论的例子只是一个参数写出来两个重载函数,对于n个参数的函数,每个参数都有可能左值or右值,那么就会有2^n重载函数,更有甚至如std::make_shared类似函数,接受无限参数。因此他们只能使用universal引用,而内部也肯定使用std::forward.

    class Widget {
    public:
        template<typename T>
        void setName(T&& newName)
        { 
            name = std::move(newName); 
        }
    ...
    private:
        std::string name;
        std::shared_ptr<SomeDataStructure> p;
    };
    
    std::string getWidgetName();
    Widget w;
    auto n = getWidgetName(); 
    w.setName(n);                    // 此处调用后,n的内容已经变化了(被移走了),后续调用是未知的
    ...                          
    

正例1:对于右值或者universal引用参数,我们可能需要先用参数调用一些别的函数,最后才“移动”,这就要保证在最后一次移动前参数绑定的对象是没有移动的。因此可以如下例,参数本身是左值,所以前面调用直接参数,最后一次要移动时调用std::move或者std::forward转换为右值。

template<typename T>
void setSignText(T&& text)
{
	sign.setText(text);
	auto now = std::chrono::system_clock::now();
	signHistory.add(now, std::forward<T>(text));
}

正例2: 当函数返回值且返回的对象绑定到一个右值引用或者universal引用时,应该使用std::move或者std::forward. 如下例,左手的矩阵lhs是右值引用–可以用来存相加的结果,返回时使用std::move则将lhs的结果移动到返回值的地方,即使Matrix不支持移动语义,则会采用复制的方法,依然是安全的;如果使用return lhs;替代,因为lhs是右值则会始终复制到返回值的。比较之下,使用std::move自然会更高效(移动快于复制),即使不支持移动也不影响,而或许添加移动支持快于带来性能提升而不修改代码。该讨论同样适用与universal引用。

Matrix operator+(Matrix&& lhs, const Matrix& rhs)
{
	lhs += rhs;
	return std::move(lhs);
}

反例1: 如果将正例2中的方法进一步推广,如下例,当函数返回局部变量作为返回值的时候也对其使用std::move来用移动代替复制,这样是错误的。原因是这种情况C++标准中已经对其有了返回值优化return value optimization (RVO),使用std::move反而会阻止这种优化造成效率低。RVO优化的方法是避免复制局部变量到返回值处,而是直接在返回值处构造这个局部变量。RVO优化需要保证原来的软件设计行为,所以对于局部变量有限制 1)局部变量的类型和函数返回类型一致 2)局部变量就是函数返回的。而下例中std::move返回的是绑定了局部变量的引用,违背前面的条件2所以不会有RVO优化,编译器会真的将局部变量移动到返回值处。即使有些情况不符合RVO的条件(比如函数有多个执行路径,不同的执行路径会返回不同的局部变量),我们确定不会有RVO优化所以主动的使用std::move来移动局部对象到返回值来避免复制,但是这样仍然是不好的。原因是C++标准中要求编译器在RVO不满足时将返回的局部变量作为右值(隐性的调用的std::move),消除了复制操作。当然也存在需要使用std::move的情况,例如明确知道不会再使用传入的参数,他作为局部变量可以操作。

Widget makeWidget()
{
	Widget w;
	...
	return std::move(w);
}

需要记住

  • 在最后使用时,右值使用std::move、universal引用使用std::forward
  • 对于函数返回右值引用或者universal引用和返回值采用同样的操作
  • 如果符合RVO优化条件,不要对返回局部变量使用std::move或std::forward

Item.26 避免在universal引用上重载

例1: 根据前面讨论可知使用universal引用是有性能上的好处的,如下例需要将传入的名字保存起来,但是有的名字是靠索引直接获取的,从而很容易写出两个重载函数,一个接受universal引用一个接受int索引。但如注释所示,最后传入short参数时报错了,原因是对于重载函数1(接受universal引用)其模板类型参数T会推断为short,因此是匹配的;重载函数2(接受int参数)需要short向上转型为int后可以匹配。按照普通重载函数解析的顺序,前者严格匹配是优于后者向上转型后匹配的,所以出错出调用了universal引用版本的重载函数,而其内部显示是不能处理short类型(std::string没有接受short的构造函数)因此报错。

template<typename T>
void logAndAdd(T&& name)                         // (1)
{
	auto now = std::chrono::system_clock::now();
	log(now, "logAndAdd");
	names.emplace(std::forward<T>(name));
}
void logAndAdd(int idx)                         // (2)
{
	auto now = std::chrono::system_clock::now();
	log(now, "logAndAdd");
	names.emplace(nameFromIdx(idx));
}

std::string petName("Darla");
logAndAdd(petName);                          // OK
logAndAdd(std::string("Persephone"));        // OK
logAndAdd("Patty Dog");                      // OK
logAndAdd(22);                               // OK
short nameIdx = 22;
logAndAdd(nameIdx);                          // Error

通过上面的例子可以看到,universal引用版本的重载函数的“贪婪”的,类型参数根据传入参数推导,所以几乎可以对所有类型产生严格匹配(部分例外在Item.30讨论),因此同时使用重载和universal引用通常是不好的。

例2: 另一个经常出错的例子是使用完美转发的构造函数,对例1中示例稍微修改如下。因为如Item.17讨论过即使类的模板构造函数的实例化产生和复制构造/移动构造一样的签名C++仍会自动生成特殊成员函数,而这些函数是隐性生成的我们看到的只是问题的部分,因此问题更麻烦。如下调用处会编译报错,正常思路是其应该调用复制构造函数来构造另一个对象,但实际调用了完美转发的构造函数,从而用Person类型构造std::string失败报错。原因同上——重载函数的解析顺序,这里p是Person类型左值,对于完美转发的构造函数类型参数推导为Person从而严格匹配,复制构造函数需要在Person类型上添加const约束后匹配,因此前者优先。如果调用改为const Person cp("Nancy");auto cloneOfP(cp);则会调用复制构造函数,此时两者都是严格匹配,另一条重载解析规则:模板函数和非模板函数同样匹配时,非模板函数优先。

class Person {
public:
	template<typename T>
	explicit Person(T&& n): 
		name(std::forward<T>(n)) {}
	explicit Person(int idx): 
		name(nameFromIdx(idx)) {}
	...
	//Person(const Person& rhs); 复制构造函数
	//Person(Person&& rhs);      移动构造函数
private:
	std::string name;
};

Person p("Nancy");		// OK
auto cloneOfP(p);		// Error

例3:例2的情况在有继承时候会麻烦,常规的移动、复制操作会表现的与期望不同。如下例,子类的移动、复制构造调用的不是父类对应的移动、复制构造,而是调用了父类的完美转发构造函数。原因为其传入的参数是子类SpecialPerson类型,从而父类的完美转发构造函数为严格匹配,解析优先,最终产生编译错误。

class SpecialPerson: public Person {
public:
	SpecialPerson(const SpecialPerson& rhs): 	// 复制构造函数,调用基类转发构造函数
		Person(rhs)
	{ ... }
	SpecialPerson(SpecialPerson&& rhs):         // 移动构造函数,调用基类转发构造函数
		Person(std::move(rhs))
	{ ... }
};

需要记住

  • 对universal引用重载的通常结构是universal引用版本被调用的比期望的多
  • 完美转发构造函数是一个特殊的问题,因为他的匹配经常优于接受const左值的复制构造函数,另外也会劫持子类对父类的移动、复制构造函数的调用

Item.27 熟悉在universal引用上重载的替代方法

Item.26 讨论了避免使用universal引用重载,但是有时候对其重载又是很有用的,所以这节讨论如果避免之前说到的问题的同时达到预期同样的效果。

  1. 放弃重载
    重载是保持函数名不变,最简单的方式是不要重载,取另外一个函数名,这样除了调用时的不方便外不会引起任何问题。但是这种方法不能解决上一节的第例2,类构造函数的函数名是固定为类名的。
  2. 传const T& 用传const左值的引用代替传universal引用也可以解决问题,如上一节的例2中说到的。但缺点是这样的代码性能不如我们设计、预期,不过用性能换正确仍然是合理的。
  3. 传值
    使用传引用替代传值常常可以提高性能(减少复制),但是相反,在我们知道参数会被复制时,传值也是一种很好的方法(Item.41)。例如修改上一节的例2为如下,因为没有std::string的构造函数接受int参数,所以下面的重载是安全和符合预期的。虽然0或者NULL表示空指针的时候在这里会产生问题,但这一点前面Item.8已经讨论了:使用nullptr标示空指针而不是-0或者NULL。

    class Person {
    public:
        explicit Person(std::string n): name(std::move(n)) {} 
        explicit Person(int idx): name(nameFromIdx(idx)) {}
        ...
    private:
        std::string name;
    };
    
  4. 使用Tag dispatch
    前面的方法都不支持完美转发,如果我们使用universal引用的目的是完美转发,那么我们就必须使用universal引用,但是我们也需要使用重载,解决的办法是tag dispatch。重载解析的最好匹配是每一个参数都最好匹配,universal模板类型参数经常产生严格匹配,但是我们可以对函数添加一个参数——目的只是用来选择调用哪一个重载无其他作用。例如对于上一节的logAndAdd函数,添加接受int类型的重载,直接重载的结果在上一节已经讨论了,tag dispatch的实现如下。logAndAdd不重载而起到分发的作用(调用重载),真正的重载由logAndAddImpl实现,其还接受另外一个参数用来决定重载调用。logAndAdd调用logAndAddImpl时会转发传入参数外还传入第二个参数表示原来的参数是否为int类型。

    • 这里需要注意不能使用std::is_integral<T>(), 因为int左值传入时T会推导为int&,尽管其值是int类型但是int&表示的是引用不是int,所以std::is_integral<int&>()会返回假,同时意味这任何左值其都会返回假,故要先使用C++的type traitItem.9来移除引用限定符。
    • C++中的布尔类型的true、false是运行时的且是同一个类型,这里我们需要在编译期分开这两种重载——需要真和假为两种不同的类型,C++标准库中提供std::true_type 和 std::false_type,T是整形时传给logAndAddImpl的第二个参数是std::true_type的子类对象,反之则是std::false_type的子类对象。
    • 这样的方式也方便了在logAndAdd中做一些统一的操作(例如log)而不用在每一个logAndAddImpl中去实现。

    这种设计中,std::true_type 和 std::false_type就是tag,他们都不需要形参名且目的只是来决定重载的调用,实际上运行期间并不需要他们,我们更希望编译器可以把他们直接优化掉、不产生运行期代码(部分编译器可以)。这里logAndAdd根据tag将universal引用参数分发(调用)到对应的logAndAddImpl实现,所以叫tag dispatch,其是C++模板元编程的一个标准模块。

    template<typename T>
    void logAndAdd(T&& name)
    {
        logAndAddImpl( std::forward<T>(name),
                        std::is_integral<typename std::remove_reference<T>::type>() );
    }
    
    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));
    }
    
    void logAndAddImpl(int idx, std::true_type)
    {
        logAndAdd(nameFromIdx(idx));
    }
    
  5. 限制接收universal引用的模板
    方法4解决完美转发构造函数是容易的,但是上一节中提到支持universal引用完美转发的构造函数可能劫持复制、移动构造函数的两个问题依然无法处理 1)对于非const的左值(自己类型)会解析到完美转发构造函数而不是复制构造函数 2)基类的构造函数为universal引用完美转发,子类的复制、移动构造函数的常规实现会被基类的构造函数“劫持”,调用基类的构造函数而非预期的基类复制、移动构造函数。对于这样支持univeral引用的函数比我们预期的要“贪婪”(构造函数会劫持复制构造函数)但有没有完全接管所有变成一个单一函数(const 左值类型参数还是会解析到复制构造函数),方法4的tag dispatch是不适用的,处理的方法是使用std::enable_if

    std::enable_if的作用是只有条件为真时模板对于编译器才是可见或存在的,当条件为假时候则模板不可见或者说被禁止掉了。因此对于上面的问题处理办法是,对于构造函数使用std::enable_if,如果univeral引用的参数类型既不是类本身类型又不是其子类类型时才使能模板,否则禁止模板(这样就必须调用复制、移动构造函数)。以上一节的Person类型为例如下,对于std::enable_if的语法和背景技术可以搜索SFINAE和std::enable_if,对于下面的实现有几点需要注意和解释:

    a. 这里std::enable_if的条件没有使用!std::is_same<Person, T>::value,因为这个只是表示T不是Person类型时为真,但是无法处理前面的第二个问题,Peroson的子类实现复制、移动时候调用基类的复制、移动构造函数会被个Person的完美转发构造函数劫持。所以需要的条件是:T既不是Person类型也不是Person的子类类型,因此使用!std::is_base_of::value, 这里T是Person本身也会返回true
    b. 和之前讨论一样,对于universal引用的类型参数我们需要移除引用限定符,但是我们没有使用std::remove_reference::type而使用std::decay::type,理由是这里我们需要的是根本类型而不关心引用、const、volatile限定符,所以这些都需要移除,而std::decay可以起到这样的功能(对于数组和函数类型会变成指针)。
    c. 因为构造函数还对int重载了,所以std::enable_if的条件还要排除掉T是int类型的情况

    // C++11
    class Person {
    public:
        template< typename T,
                  typename = typename std::enable_if< !std::is_base_of<Person, typename std::decay<T>::type>::value 
                                                      &&
                                                      !std::is_integral<std::remove_reference<T>::type>::value
                                                    >::type >
        explicit Person(T&& n): name(std::forward<T>(n))
        {
            // static_assert非必须
            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、3指定了函数接受参数类型而方法4、5没有,因此1、2、3不再支持完美转发而4、5可以,带来的两面性是:

  • 优点:使用完美转发可以避免构造临时对象从而提高性能
  • 缺点
    • 有些类型是不支持完美转发的(Item.30)
    • 支持完美转发时,调用端传入不支持类型参数时的产生的错误信息不直观问题。对于方法1、2、3都会产生很简单、直接的错误信息(xxx类型不能转换到xxx),但是方法4、5则是模板的通病,上例参数会一直传递到std::string的构造函数处才会发现错误然后一层一层抛出错误信息,系统越复杂转发的层数越多则错误信息越多且不可读。稍微有点帮助的办法是方法5中加入的编译期的静态检查,但是由于static_assert在函数体内,name初始化会在其前面,故静态检查输出的消息应该会在大量模板错误消息的后面。

需要记住

  • universal引用和重载组合的替代方法有:修改函数名(放弃重载)、传const左值引用、传值、tag dispatch
  • 通过std::enable_if来约束模板在何种情况下可以使用universal引用重载的方式可以同时支持universal引用和重载
  • universal引用可以带来性能上的提升,但也带来了使用的不便

Item.28 理解引用折叠

前面提过universal引用背后是引用折叠——universal引用最终推导为右值引用还是左值引用。首先,C++中引用的引用是非法的, 所以代码int a; auto& & ra = a;(注意两个&之间的空格)是不能通过编译的。但是编译器内部是可以产生类似的引用的引用的情况,例如接受universal引用的函数模板,其遵守的规则即使引用折叠。真正的引用类型只有两种:左值引用和右值引用,引用的引用则有4种:左值引用的左值引用、左值引用的右值引用、右值引用的左值引用、右值引用的右值引用。引用折叠规则是当引用的引用这种发生时将其折叠到单个引用:

只要有一个左值引用结果就是左值引用,都是右值引用时结果才是右值引用。
例如 T为int&, T& = int&, T&& = int&; T为int&&, T& = int&, T&& = int&&

了解了引用折叠后,则universal引用和std::forward可以很好理解, 如下例给出了一个简单版本的std::forward实现,1)当传入左值Widget调用f()时,T&&整体要是左值引用,T就推导为Widget&,传入到std::forward中实例化极为下例中的1),可见传入左值引用然后转换到左值引用最终返回左值引用,对fParam实质是没有处理的。左值引用即左值,所以std::forward对左值不做处理 2)当传入右值Widget调用f()时,T&&为右值引用则T推导为Widget非引用类型,实例化的forward如下例2),可见传入的左值引用param被转换为Widge右值引用最终返回了此右值引用,注意这里fParam本身是左值。右值引用即右值,std::forward将输入左值引用转换为右值。另外也可以看到universal引用并不是一种具体的引用,它只是利用类型模板或者auto+引用折叠来做到可以根据初始化对象来具体化到左值引用还是右值引用。

template<typename T>
void f(T&& fParam)
{
	...
	someFunc(std::forward<T>(fParam));
}

// 简易版本std::forward
template<typename T> T&& 
forward(typename remove_reference<T>::type& param)
{
	return static_cast<T&&>(param);
}

// 1) 传入Widget左值 调用f()时,forward实例化
Widget& forward(Widget& param)
{ 
	return static_cast<Widget&>(param);
}

// 2) 传入Widget右值 调用f()时,forward实例化
Widget&& forward(Widget& param)
{ 
	return static_cast<Widget&&>(param); 
}

引用折叠有四个场景:

  1. [最常见] 模板实例化,上面例子所见
  2. auto变量的类型生成,具体和模板实例化相同。

    Widget widgetFactory();         
    Widget w;
    
    auto&& w1 = w;                  // auto -> Widget&
    auto&& w2 = widgetFactory();    // auto -> Widget
    
  3. typedef的创造和使用以及别名声明。例如在模板类中使用 typedef T&& newT;

  4. 使用decltype

需要记住

  • 引用折叠的四个场景:模板实例化、auto类型生产、typedef的创造和使用以及别名声明、decltype使用
  • 当编译器产生引用的引用时会将其折叠到单个引用,任意一个引用是左值引用则是左值引用,都是右值引用时才是右值引用
  • [?] 当type推导从右值区别左值且有引用折叠发生时,universal引用是右值引用

Item.29 假设移动操作:不存在、开销不低、没有真正使用

移动语义是C++11的主要特性,它不仅仅是允许编译器优化性能(使用开销小的移动代替开销大的复制)更是要求编译器做对应的优化。所以C++98的代码用支持C++11的编译器重新编译一遍可能就会有很大的性能提升,但是为了兼容,移动代替复制有严格的条件(首先保证正确性),例如给没有移动操作符的类生成移动操作符需要没有声明复制、移动操作、析构,成员数据或者基类禁止了移动操作的也不会生成。即使生成可能性能也并没有提升或者移动操作没有被调用。

C++11中移动语义没有帮助的场景:

  1. 移动操作不存在:C++11的标准库是支持移动语义的,但是 1)自己工作项目的代码库还没有针对C++11升级,没有支持移动语义 2)如前讨论,没有声明移动操作的类型支持C++11的编译器会在满足条件时为其自动生成,但是条件不满足则自然不会有移动操作生成更不能指望提升性能。
  2. 移动没有更快(性能没有期望的提升):C++11标准库中容器都支持移动语义,std::vector的数据保存在堆上,复制需要复制所有内容,移动只需要复制指针即可,因此移动可以有性能的提升;而std::array的数据直接保存结构内的栈上,所以移动或复制都需要复制所有数据,从而移动相对复制来说没有什么性能上的提升;另外std::string采用了小字符串优化small string optimization (SSO),例如字符串长度不超过15时数据保存在结构内的栈上,大于则在堆上,因此(来自于移动语义的性能是否有大的提升取决于字符串的长度了。
  3. 移动不可用(移动没有真正的调用):即使移动操作存在且开销很低可以提高性能,但可能最后调用复制而不是移动。Item.14解释过有些标准库的容器提供了很强的异常安全保证,升级到C++11的时候,为了保证可能依赖于这种异常安全假设的老代码,使得只有在能确定移动操作不抛异常的情况下才会替代复制操作,导致结果是即使有移动操作且开销很小但没有声明noexcept,编译器还是必须使用复制操作。
  4. 源模板是左值:除了少许例外(Item.25)只有右值可以被用来被移动。

对于写通用的代码、模板、在老代码基础上工作或者不稳定代码,不应该依赖于移动语义:假设移动不存在、开销不低、没有真正调用。而当我们明确了解代码中的类型(支持或者不支持移动),我们则不需要这些假设正常使用移动即可。

需要记住

  • 假设移动操作不存在、开销不低、没有真正调用
  • 代码中的类型是否支持移动是明确的时候不需要这些假设

Item.30 熟悉完美转发失败案例

转发——一个函数把接收到的参数原样的传递给目标函数,目标函数接收到的参数和第一个函数接受到的应该是同一个对象,所以 1)传值的方式是不可以的,因为传值既是复制,目标函数接收到的参数不是严格的第一个函数接收到的参数 2)传指针也不好,因为这样会强制要求调用处都必须传指针。所以通用的情况下处理的参数是引用。这里设f为目标函数,转发函数模板是fwd如下。传入同一参数f( expression );fwd( expression ); 行为不一致则为转发失败。

template<typename T>
void fwd(T&& param)
{
	f(std::forward<T>(param));
}
// 可变模板参数
template<typename... Ts>
void fwd(Ts&&... params)
{
	f(std::forward<Ts>(params)...);
}
  1. {}初始化
    如下例,当f声明接受std::vector作为参数时,传入同样的{}列表两者行为是不一样的。1)直接调用f时,编译器会查找对比函数接受的类型,如果可能会进行隐式转换,下例中{1,2,3}会隐式生成一个临时std::vector对象然后v会绑定到该临时对象上。2)通过fwd转发到f时,则编译器不会对比f接受类型参数和传入参数,而是先从传入参数推导参数类型然后比较该参数类型和f的参数声明,这里下面任何一种情况出现转发都会失败:

    a. 编译器不能推导类型:fwd的一个或者多个参数的类型无法推导,则编译失败
    b. 编译器推导出“错误”类型:fwd的一个或者多个参数类型推导“错误”,这里错误也包括使用推导的类型和直接调用f的行为不一致的情况,例如f有重载,则推导到非期望的类型会调用不用f重载函数。

    fwd({ 1, 2, 3 });中,因为fwd的参数没有声明为std::initializer_list标准禁止编译器从{1,2,3}表达式推导类型,自然编译失败。但是Item.2讨论过auto变量用{}初始化是可以类型推导的,std::initializer_list。所以如下有解决办法是先赋值给auto局部变量然后再传入。

    void f(const std::vector<int>& v);
    f({ 1, 2, 3 });       // {1,2,3}隐式转换为std::vector<int>
    fwd({ 1, 2, 3 });     // 编译报错
    // 折中
    auto il = { 1, 2, 3 };
    fwd(il);
    
  2. 0或者NULL作为空指针
    Item.8解释过,传入0或NULL给模板作为空指针,类型推导的结果都是int,从而自然想做为空指针转发会失败。解决办法是传入nullptr。

  3. 只声明的静态常量成员数据 如下例,MinVals既是只声明的静态常量成员数据,一般来说是不需要定义的,因为编译器利用常量传递(const propagation)从而避免为其正真的分配内存(例如宏定义一样,调用处直接替换)。下例中的f(Widget::MinVals);调用则是将MinVals直接替换为28,编译-调用是正常的,但是这样MinVals是没有真正的内存分配的,如果有操作取它的地址,则编译OK但是链接过程会失败,无法找到符号的地址(未定义)。fwd(Widget::MinVals);即是由于这个原因导致的错误,因为转发函数的参数是引用类型,而引用和指针实际是一样的(底层实现都是指针),只是引用会自动解引用,因此通常会导致和前面一样的问题——链接阶段报错。

    尽管C++标准要求将MinVals作为引用传入时需要其被定义,但是不是所有的实现都满足这条。所以这取决以编译器,可能有的编译器和连接器对于这种情况是不报错的,但是为了代码的可移植性,不应该依赖于这种不确定性。解决的办法是在cpp文件中添加定义但不需要再次初始化const std::size_t Widget::MinVals;

    class Widget {
    public:
        static const std::size_t MinVals = 28; 
        ...
    };
    ...
    std::vector<int> widgetData;
    widgetData.reserve(Widget::MinVals);
    
    void f(std::size_t val);
    {
        ...
    }
    f(Widget::MinVals);      // 正常,等同于 f(28)
    fwd(Widget::MinVals);    // 错误,不能链接
    
  4. 重载的函数名和模板名 如下例,f接受一个函数指针作为参数,processVal有两个重载函数,f(processVal);直接调用是正常的,因为编译器可以通过f的参数类型来判断选择processVal的哪一个重载;而传processVal给转发函数时,processVal只是名字没有类型,因此无法推导类型则转发失败,同理fwd(workOnVal);也转发失败,workOnVal是函数模板不是一个函数,fwd无法推导类型自然转发失败。解决的办法类似Case.1,使用给定类型的局部变量来辅助编译器选择重载,或者给定类型利用static_cast来实例化函数模板。

    void f(int (*pf)(int));    // void f(int pf(int)); 相同
    int processVal(int value);
    int processVal(int value, int priority);
    
    f(processVal);             // OK
    fwd(processVal);           // Error
    
    template<typename T>
    T workOnVal(T param)
    { ... }
    fwd(workOnVal);            // Error
    
    using ProcessFuncType = int (*)(int); 
    ProcessFuncType processValPtr = processVal; 
    fwd(processValPtr);                             // OK
    fwd(static_cast<ProcessFuncType>(workOnVal));   // OK
    
  5. 位域 C/C++结构都支持位域,充分利用内存。如下例直接调用f(h.totalLength);是正常的,但是传入转发则失败,原因是h.totalLength是非常量的位域,C++标准要求“非常量引用不可以绑定到位域上”,背后原因很简单——位域可以包含任意的位(例如int32中7位)而C++寻址的基本单位是字节所以位域无法寻址。因为位域既没有引用也没要指针,作为参数传给函数只能传值的方式——位域值的副本;另外一种测试常量引用,标准要求引用绑定的是位域值的一个副本,其类型是标准整形类型(如int)。因此无论那种方法都是取了位域值的副本,所以可以如下自己复制位域然后传入转发函数。

    struct IPv4Header {
        std::uint32_t version:4,
        IHL:4,
        DSCP:6,
        ECN:2,
        totalLength:16;
        ...
    };
    void f(std::size_t sz);
    IPv4Header h;
    ...
    
    f(h.totalLength);       // OK
    fwd(h.totalLength);     // Error
    // Solution
    auto length = static_cast<std::uint16_t>(h.totalLength);
    fwd(length);
    

需要记住

  • 在类型推导失败或者推导到“错误”类型时转发失败
  • 导致转发失败的参数有: {}初始化列表、0或NULL表示的空指针、只声明的静态常量成员数据、模板和重载函数、位域




CH.6 Lambda表达式

Lambda是C++11中新支持的特性,其并没有增强C++语言的表达能力(大多称的语法糖),但是它极大的简化了代码的编写,不必写非常繁琐的代码。另外分清几个概念:

  • Lambda表达式(lambda expression) : 一个表达式,形式为 capture->returnType{ statements };
  • 闭包(closure):lambda创造的运行时对象,其持有捕获数据的引用或者拷贝(取决于捕获模式)
  • 闭包类(closure class):闭包由它实例化(类和对象的关系),编译器会给每个lambda生成唯一的闭包类,lambda的语句就是该类成员函数的执行指令。

一般的lambda会作为函数的参数(例如回调等),但是闭包也是可以复制的,所以对于一个lambda表达式以及对应的闭包类型可以有多个闭包(一个类可以有多个相同的对象),例如auto c1 = [](int a){return a*a;}; auto c2=c1;中c2是c1的拷贝,c1和c2都对应同一个lambda表达式。

Item.31 避免默认捕获模式

C++11的lambda中有两种捕获模式:引用捕获、值捕获,这两种都有自己的默认捕获模式:默认引用捕获模式[&]、默认值捕获模式[=]。引用捕获模式很容易造成悬空引用,可能认为值捕获可以安全的避免这个问题——实际上也有风险。

默认引用捕获模式
引用捕获模式会让闭包closure持有局部变量或者和lambda定义的同一个域的参数的引用,当闭包closure的生命周期长于这个局部变量或者参数的生命周期时悬空引用就出现了。如下例,将lambda加入在filters的容器中,在addDivisorFilter中默认引用捕获的divisor在离开函数后即悬空。这里将默认引用捕获改为显示引用捕获filters.emplace_back([&divisor](int value) { return value % divisor == 0; });虽然仍然不能避免问题,但是显示的标明引用捕获的变量可以表明lambda是依赖于引用捕获变量的生命周期的,也可以提醒我们。因此长远来看,显示的列出所有lambda依赖的局部变量和参数是更好的软件工程实践。

using FilterContainer = std::vector<std::function<bool(int)>>;
FilterContainer filters;

void addDivisorFilter()
{
	auto calc1 = computeSomeValue1();
	auto calc2 = computeSomeValue2();
	auto divisor = computeDivisor(calc1, calc2);
	filters.emplace_back(
		[&](int value) { return value % divisor == 0; }
	);
}

默认值捕获模式
将上例改为默认值捕获模式可以解决引用悬空的问题,filters.emplace_back( [=](int value) { return value % divisor == 0; } );,但是默认值捕获模式并不是我们希望的反悬空引用的灵丹妙药,背后原因是浅拷贝与深拷贝的区别。值捕获模式时闭包closure会保存一份捕获变量或参数的拷贝,当变量或参数是指针时,这里的拷贝是浅拷贝,指针指向的资源在闭包外面是可能被释放的,一旦释放则lambda内保存的指针则悬空。可能根据前面介绍C++11中会使用智能指针而不是裸指针,不会出现这种悬空现象,但是C++的this指针则是无法躲过的。如下例,可以正常编译运行,但是这里默认值捕获的不是divisor,因为divisor不是非静态的局部变量或者参数,隐式值捕获的是this指针,lambda里面使用divisor时会变成this->divisor。因此问题则是捕获的this指针可能会悬空,当lambda的生命周期长于该对象的生命周期时则指针悬空了,后续使用会是未定义的。这里将默认值捕获替换为显示的值捕获或者引用捕获divisor都会导致编译失败,因为lambda只能捕获可见域的非静态局部变量(包括参数),divisor不属于捕获范围的。解决的一个办法是先用局部变量做一次复制拷贝然后lambda值捕获这个局部变量,当然这是默认值捕获也是OK的但依然不推荐——会隐式捕获this指针导致我们认为我们捕获了别的,C++14中有更通用的捕获值初始化但只是对显示捕获,默认捕获模式没有,所以不推荐默认捕获模式依然是成立的。

class Widget {
public:
	...
	void addFilter() const;
private:
	int divisor;
};

void Widget::addFilter() const
{
	filters.emplace_back(
		[=](int value) { return value % divisor == 0; }
	);
}

另外不推荐默认值捕获模式的一个原因是其容易让我们认为lambda是自持的(独立的)而实际又不是。因为值捕获模式下lambda会保存捕获变量的一份拷贝,因此容易误以为lambda是自持的、独立的,但是有时候其并不是因为它不仅仅依赖于非静态局部变量和参数——静态变量。定义于全局、命名空间、类内、函数内、文件域、lambda生成的同级等静态变量是可以在lambda内使用的但不能捕获,使用默认捕获模式会误导以为他们是捕获后的拷贝从而安全使用,而这样的静态变量是外部可以修改的。如下例,没有非静态的局部变量所以lambda什么没有捕获,其内部使用的divisor是外部的静态变量,divisor最后修改后lamdba的行为和预期则不一样了。

void addDivisorFilter()
{
	static auto calc1 = computeSomeValue1();
	static auto calc2 = computeSomeValue2();
	static auto divisor = computeDivisor(calc1, calc2);
	filters.emplace_back(
		[=](int value){ return value % divisor == 0; }
	); 
	++divisor; 
}

需要记住

  • 默认引用捕获会导致悬空引用
  • 默认值捕获容易产生悬空指针(特别是this指针),另外也容易误导以为lambda是自持的

Item.32 使用init capture将对象移动到闭包中

有时值捕获和引用捕获都不是我们想的,例如把只可移动对象(eg. std::unique_ptr,std::future)放入closure,或者将复制开销大的对象移动到closure中,C++11语言上没有提供这样的支持(C++11上做不到),但C++14引入了新的捕获机制:init capture,它不仅仅支持移动对象来捕获,除了C++11的默认捕获模式外其他都可以支持,因此也叫通用捕获。使用init capture可以:

  • 指定数据成员的名字,而不是C++11中固定的和被捕获变量同名
  • 使用表达式初始化数据成员

C++14中使用init capture

如下例是C++14中使用init capture的示例,1、2都展示了使用表达式来初始化closure的成员变量。这里注意的是捕获[]内的等号=左右的域是不一样的,等号=的左边是closure类的域,等号=右边是lambda定义的所在域,lambda{}内和之前一样是closure类的域。

class Widget {
public:
	...
	bool isValidated() const;
	bool isProcessed() const;
	bool isArchived() const;
private:
	...
};
auto pw = std::make_unique<Widget>();
...
auto func = [pw = std::move(pw)] { return pw->isValidated() && pw->isArchived(); };   // 1)

auto func = [pw = std::make_unique<Widget>()]{                                        // 2)
	 return pw->isValidated()&& pw->isArchived(); };

C++11中模拟init capture

C++11中没有这个语法,但是记住C++中的lambda只是语法糖——隐式创建类然后实例化对象,所以lambda可以干的手写类也都可以做到。如下例,如果只是要支持接受移动对象手写类可以达到同样的效果,本质是相同的。

class IsValAndArch {
public:
	using DataType = std::unique_ptr<Widget>;
	explicit IsValAndArch(DataType&& ptr) : pw(std::move(ptr)) {}
	bool operator()() const
	{ 
		return pw->isValidated() && pw->isArchived(); 
	}
private:
	DataType pw;
};

auto func = IsValAndArch(std::make_unique<Widget>());

但是在C++11中如果依然坚持使用lambda(为了方便,不用手写类),支持移动的捕获也可以模拟:

  1. 将对象移动到std::bind生成的函数对象中,被改函数对象捕获
  2. 将上面函数对象捕获的对象的引用传递给lambda

示例如下,std::bind产生和lambda类似的函数对象,其第一个参数是可调用对象,之后所有的参数都是传递给改可调用对象。std::bind的对象会包含所有传入参数的拷贝(包含第一个可调用对象),当传入参数是左值时对应拷贝使用复制构造,右值则是移动构造。所以下例中,data先会移动到std::bind的函数对象中,当std::bind返回的函数对象被调用(调用operator())时其等价于调用其第一个参数,也既是这里的lambda对象,然后将捕获对象的引用传递过去。另外,lambda的closure类的operator()成员函数是const,所以会使得lambda体内的所有成员数据为const,而std::bind捕获的时候并非const(例如这里std::bind返回的函数对象内的data不是const),为了防止data在lambda内被修改所以传入了const引用。如果lambda声明为mutable,则生成closure类的operator()不是const,则传入参数也不需要添加const约束。虽然Item.34讨论是std::bind和lambda中优先选择lambda,但是这里说明了C++11中部分情况下std::bind是更有效的。

std::vector<double> data;
// C++14 init capture
auto func = [data = std::move(data)] 
	{
	 /* uses of data */ 
	};
// C++11 模拟
auto func = std::bind(
	[](const std::vector<double>& data){ /* uses of data */ },
	std::move(data)
);
auto func_mutable = std::bind(
	[](std::vector<double>& data) mutable { /* uses of data */ },
	std::move(data)
);

// C++14 init capture
auto foo = [pw = std::make_unique<Widget>()]
	{ 
		return pw->isValidated() && pw->isArchived(); 
	};
// C++11 模拟
auto foo = std::bind(
	[](const std::unique_ptr<Widget>& pw)
		{ 
			return pw->isValidated()	&& pw->isArchived(); 
		},
	std::make_unique<Widget>()
);

需要明确的几点:

  • C++11不支持移动构造对象到lambda的closure中,但是C++11的std::bind支持
  • 使用std::bind来模拟init capture时,传递给lambda的是引用
  • 因为bind对象的声明周期和内部保存的lambda副本一样,所以可以和对lambda中捕获对象一样对待bind内保存的对象

需要记住

  • 使用C++14的init capture来移动对象到closure中
  • C++11中模拟init capture可以手写类或者使用std::bind

Item.33 auto&&参数使用decltype来转发

通用lambda(generic lambda):参数类型可以使用auto的lambda,是C++14最令人感兴趣的特性之一。参数类型使用auto指定类似以使用模板的类型参数,使得lambda变成函数模板级别,如下例,lambda背后的closure类的operator()变成函数模板。

auto f = [](auto x){ return func(normalize(x)); };
// 等价于
class SomeCompilerGeneratedClassName {
public:
	template<typename T>
	auto operator()(T x) const
	{ 
		return func(normalize(x)); 
	}
	...
};

但是上例中lambda将参数x转发到normalize(),如果normalize对待左值和右值不同则上面的代码是不对的,因为转发给nomalize的x总是左值。所以需要支持完美转发上例需要改变两点:

  1. 参数类型使用universal引用 修改为 auto f = [](auto&& x){ return func(normalize(x)); };
  2. 传递给normalize时通过std::forward()
    理论上修改为 auto f = [](auto&& x) { return func(normalize(std::forward<???>(x))); };, 可是和普通函数模板有类型参数T可以使用不同这里需要给std::forward的类型参数是没有的,无法使用closure类的operator函数的类型参数T。Item.28有讨论过,左值参数传给universal引用后参数类似是左值引用,右值会是右值引用,所以我们可以通过x的类型来区分左值和右值:decltype(x), 左值返回左值引用,右值返回右值引用。再联系之前讨论std::forward<>()的类型参数,左值情况是类型参数应该是左值引用,右值情况时是该类型(无引用),但是结合引用折叠的讨论可以知道,右值情况下类型参数是原始类型和原始类型右值引用是一样的,因此修改为
    auto f = [](auto&& x){ return func(normalize(std::forward<decltype(x)>(x))); };

同理也支持可变参数:

auto f =[](auto&&... params)
{
	return func(normalize(std::forward<decltype(params)>(params)...));
};

需要记住

  • auto&&的参数使用std::forward时使用decltype

Item.34 优先使用Lambda而非std::bind

2005年标准库中就有了非正式的bind(在std::tr1::bind),C++98中有std::bind1st,std::bind2nd,在C++11中引入std::bind替代了他们。bind有很长的历史,但是随着lambda的出现和C++14中lambda的增强,std::bind已经不在推荐使用:

  • lambda比std::bind的代码更间接、可读
    如下例,构建一个1小时后响警报30秒的函数对象,使用lambda可以直观的知道参数的传递和调用,而使用std::bind则 1)容易出现错误,如下展示了一例错误,因为steady_clock::now() + 1h是传递给std::bind所以会立刻求值最后和设计逻辑不符,修正的方法如下使用嵌套另外一个std::bind对象,这样显得会很繁琐 2)对于参数的传递需要靠人脑力去映射占位符和真正的参数位置

    using Time = std::chrono::steady_clock::time_point;
    enum class Sound { Beep, Siren, Whistle };
    using Duration = std::chrono::steady_clock::duration;
    void setAlarm(Time t, Sound s, Duration d)
    {
        // do something 
    }
    
    // Lambda
    auto setSoundL = [](Sound s)
    {
        using namespace std::chrono;
        setAlarm( steady_clock::now() + hours(1), seconds(30) );
    };
    
    using namespace std::literals;
    using namespace std::placeholders; 
    auto setSoundB = std::bind(setAlarm, steady_clock::now() + 1h, _1, 30s);   // Wrong
    // Correct 
    // C++11
    auto setSoundB = std::bind(setAlarm,
        std::bind(std::plus<steady_clock::time_point>(), steady_clock::now(),hours(1)),
        _1, 
        seconds(30));
    // C++14
    auto setSoundB = std::bind(setAlarm,
        std::bind(std::plus<>(), steady_clock::now(),hours(1)),
        _1, 
        seconds(30));
    
  • 重载函数情况时lambda更简洁
    当上例的setAlarm有重载时,eg void setAlarm(Time t, Sound s, Duration d, int volume);,lambda不需要改变依然可以工作,重载函数会正常解析;std::bind则会编译失败,因为std::bind获得的只是函数名而函数名是不能确定函数的,所以使用std::bind还需要强制类型转换

    using SetAlarm3ParamType = void(*)(Time t, Sound s, Duration d);
    auto setSoundB = std::bind(static_cast<SetAlarm3ParamType>(setAlarm), 
        std::bind(std::plus<>(), steady_clock::now(), 1h),
        _1,
        30s);
    
  • 内联导致性能差异
    在上项中当setAlarm有重载时,使用std::bind需要函数指针转换来帮助选择重载函数。调用上面lambda的函数对象时编译器可以如普通一样内联代码,但是std::bind的对象是通过函数指针调用的,而编译器一般是不太喜欢内联通过指针调用的函数的,这也就是通过lambda很可能产生更高效的机器码、性能更好。

  • 扩展性
    如下例是判断一个值是否在两个量之间的函数,使用lambda则清晰明了逻辑简单,而使用std::bind不仅冗长且不方便扩展,例如当这里的逻辑判断更多更复杂时,使用std::bind则要嵌套非常多层。

    // C++11
    auto betweenL = [lowVal, highVal] (int val)
        { return lowVal <= val && val <= highVal; };
    
    using namespace std::placeholders;
    auto betweenB = std::bind( 
        std::logical_and<bool>(),
        std::bind(std::less_equal<int>(), lowVal, _1),
        std::bind(std::less_equal<int>(), _1, highVal) );
    
  • 捕获模式
    lambda的捕获分为值捕获和引用捕获,可以按需求选择,且根据代码可以清晰的知道是二者中的那种;std::bind是值捕获,std::bind的对象会保存一份参数的副本。当要使用大的、复制消耗大的对象时,lambda可以选择引用捕获,而std::bind则正常只能复制一份。

  • 传参模式
    当调用lambda对象的时候,传入的参数是值可以从lambda定义中清晰的获得的(传值或者传引用或者传指针等);std::bind对象调用时直观上是不知道参数是如何传进去的,实际上由于std::bind使用了完美转发,所以是传递的引用,这一点只能记忆。

综合上面的理由可以得到本节的观点,C++14中由于lambda增强了可以完全不使用std::bind,但是C++11中仍然有两个场景需要使用std::bind

  • 移动捕获Item.32中已经讨论过,在C++11中要使lambda支持移动捕获,采用的模拟方法需要使用std::bind,而这一点在C++14中lambda本身支持不需要模拟。
  • 多态函数对象 当绑定对象的调用操作符使用完美转发时候,其可以接受任意类型的参数。这点对于调用符是函数模板时也非常有用,如下例,C++11中的lambda不能支持。

    class PolyWidget {
    public:
        template<typename T>
        void operator()(const T& param);
        ...
    };
    PolyWidget pw;
    auto boundPW = std::bind(pw, _1);
    // 或者C++14
    auto boundPW = [pw](const auto& param) { pw(param); };
    
    boundPW(1930);
    boundPW(nullptr);
    boundPW("ABC");
    

需要记住

  • lambda比std::bind 更易读、表达力更强、可能更高效
  • C++11中,std::bind只有两个场景是必须的:移动捕获和绑定调用符是函数模板的对象




CH.7 并发API

C++11是第一次在语言和库的层面有对并发支持,在此之前C++的并发编程依赖于各平台(例如linux的pthread、windows的Thread等),很难跨平台,从C++11开始可以保证并发在各平台下一致的行为。C++11的标准库添加支持并发开发的元素:tasks, futures, threads, mutexes, condition variables, atomic objects 等,当然C++11为首次支持可以相信之后会支持更好。

标准库中futures对应有两个模板,std::futurestd::shared_future,大多数情况下二者的区别不影响所以本章说futures时同时指这两个。

Item.35 优先使用基于任务而不是基于线程

当我们需要异步鸡执行某个函数int doAsyncWork();时,有两种选择:

  • 基于线程:std::thread t(doAsyncWork);,创建一个线程然后执行我们需要调用的函数。
  • 基于任务:auto fut = std::async(doAsyncWork);, 传递需要异步执行函数(任务)给std::saync。

总的来说,相对于基于线程而言基于任务的方法是更高级(高层次)的,其有更高层次的抽象,不需要人工的去管理线程(开始、运行、结束等),只需要关心所要处理的任务即可;而基于线程的方法则是更底层一点的,底层带来的好处是可以更加灵活的控制每个细节,但是对应的也是当核心任务比较简单时这些细枝末节我们并不关心但是又不能跳过。基于任务的方法的好处:1)可以获得任务doAsyncWork的返回值,基于任务的方法std::async会返回一个future对象,其具有get方法可以得到任务的返回值,而线程的方法是无法获得函数的返回值的 2)当任务doAsyncWork抛出异常时,基于任务的方法可以通过future对象访问这个异常,而基于线程的方法会直接调用std::terminate导致程序结束。

扩展来说,首先要了解C++中“线程”有三个含义:

  • 硬件线程 ,也就是实际计算时运行的线程,当前的架构一般每个CPU核心都提供一个或者两个(例如intel超线程)线程
  • 软件线程 也叫系统线程,操作系统管理的所有进程下的线程并在硬件线程直接调度(决定那个软件线程跑在那个硬件线程上)。典型的操作系统支持的软件线程是比硬件线程多的(一般取决于内存和操作系统),当有线程被阻塞的时候(IO操作、等待互斥mutex、条件变量condition variable等),操作系统可以将硬件线程切换到其他软件线程来提高吞吐量。
  • std::threads 是软件线程的句柄。其可以表示空句柄“null” handles,对应的既是没有软件线程,这种情况可以由 1)默认构造,没有指定需要运行的函数 2)被移动到其他的局部了 3)已经join过了,也即是运行的函数已经结束了 4)已经和软件线程分离detach

在理解上述线程的情况下,使用基于线程的方法会面对下面两个问题:

  • 无软件线程,软件线程的数量是有限的,例如编写操作系统时线程的id类型是uint_8那么则最多256个线程,当试图创建线程从而超过最大值时会抛出std::system_error异常,这个和我们需要运行的函数是否抛异常无关,既是声明noexcept int doAsyncWork() noexcept;不影响这个异常的抛出。要处理这种情况有两种方法:
    • 把我们原本想异步运行的函数在当然线程运行,也既是直接调用doAsyncWork,但这样就破坏了我们需要异步的初衷,负载不均衡,特别是当前线程是GUI线程时很可能造成无响应现象。
    • 等待已有的线程结束后再创建新线程去运行,但可能已有的线程也在等这个任务的操作或者结果,从而形成死锁
  • Oversubscription超额订阅,软件线程的数量没有超过系统允许最大值,但是没有阻塞的线程远远大于CPU的硬件线程时,操作系统基于时间片调度会使得频繁的切换线程而增大管理的开销,总的效率反而降低,当软件线程切换到上次运行不一样的CPU核心时这种切换开销尤其严重。这种情况时候 1)CPU cache缓存对于这个软件线程基本是“冷”的,没有缓存什么有用的数据和指令 2)CPU核心在跑这个软件线程的时候需要重新从内存或者后面级缓存提取数据和指令然后更新缓存,这样会“污染”给老的软件线程的缓存,其很可能被操作系统在下一个时间片调度回来。
    但是克服oversubscription是非常困难的,因为软件线程和硬件线程的最优比例和很多因素相关使得其很难确定,其都可以是动态的,和场景(IO密集、计算密集?)、线程对CPU cache的使用情况、CPU cache本身的情况(大小、速度等)等。另外既是在一个平台可以调整到最优也不能保证其应用到其他平台,跨平台困难。

而使用基于任务的方法时上述的问题可以丢给其他人,线程管理的就由标准库实现者来负责了。例如,上面没有可用软件线程的异常可以大幅降低,因为创建线程这一步可能都没有发生。原因是如前面样例调用std::async时使用了默认的启动策略[Item.36])(#item-36),它不保证一定创建新线程,反而运行线程调度器在请求任务结果(调用future对象的get或者wait)的线程处执行,这样在没有线程可用或者有oversubscription时调度器既可利用。这里可能会有前面提到的负载均衡的问题,但这个由std::async和调度器处理而不是我们,另外负载均衡需要从更高更广的层面来处理,需要对整个机器的情况了解下做最优的选择。 当然在GUI线程这样就会有问题,调度器不知道该现象对时间的要求,从而导致无响应现象。这时使用std::async应该传入std::launch::async策略[Item.36])(#item-36),强制其创建新线程来处理。

顶级的线程调度器会使用系统级别的线程池来避免oversubscription,使用work-stealing算法来提高负载均衡。但是C++标准库没有提供这样的方案,实际上并发标准还使得使用这样的方案更困难了,但是有些厂商提供了这样的实现,如果使用std::async基于任务的方法的话,我们就很容易利用到这样的性能提升;如果使用基于线程的方法来处理的话,那所有的这些都需要自己来处理了(线程耗尽、oversubscription、负载均衡、跨进程等)。

通常情况下都优先选择基于任务的方法,总结直接使用基于线程的方法更合适的场景如下:

  • 需要使用线程底层实现的API时。C++的并发API是基于底层平台特定的API实现的,例如pthreads 或 Windows’ Threads等,底层API会比C++的API更丰富(例如C++中没有线程优先级和affinities)。要使用这样底层实现的API,C++的std::thread大多提供native_handle成员函数,std::futures是没有这个功能的。
  • 我们需要并且有能力根据应用来优化线程使用
  • 需要实现超过C++并发API的线程技术,例如开发线程池。

需要记住

  • std::thread没有办法获得异步执行函数的返回值,如果执行的函数抛异常则程序结束
  • 基于线程的方法需要手动管理 线程耗尽、oversubscription、负载均衡、跨平台等
  • 使用默认策略的std::async的基于任务方法适用大多数场景

Item.36 如果异步关键需要指定std::launch::async

Item.35中已经提到std::async是根据指定策略来执行任务(调用需要执行的函数)的,标准库提供了两个枚举变量表示的策略:

  • std::launch::async 表示强制要求异步执行任务,也即使一定开启新线程并在新线程上执行任务(调用函数)
  • std::launch::deferred 表示任务会被推迟到future的get或者wait函数调用,当这两个中个一个函数调用时候任务会在内部被同步调用,调用get或者wait的地方会阻塞直到任务结束,如果get或者wait一直没有被调用,那么任务也永远不会执行(函数不会被调用)

但是std::async的默认策略不是上面二者中的任何一个,而是二者的,既auto fut1 = std::async(f);等价于auto fut2 = std::async(std::launch::async | std::launch::deferred, f);Item.35中解释了这种方式给了标准库的线程调度器方便来克服oversubscription和负载均衡问题。但是这种或的关系也意味这不确定性,导致了新的问题,当在线程t上调用auto fut = std::async(f);时:

  • 不可能预测f是否和t并行运行,因为f可能被延迟(std::launch::deferred行为)
  • 不可能知道f运行的线程和调用get或者wait的线程是否是同一个线程
  • 不太可能知道f是否已经运行了,因为不太能保证在买一条执行路径path上都会调用get或者wait

其根本原因是标准库可以选择两个策略中的任何一个,但是我们并不能预测其会选择哪一个,从而导致结果不同。比较麻烦的:

  1. 当任务使用线程局部变量(如上函数f读或者写了线程局部存储),当默认策略时,我们无法预测f会在哪一个线程被调用也就无法预测哪儿的线程局部存储会被访问。
  2. 影响使用超时的基于wait方法的循环,因为对延迟的任务调用wait_for或者wait_until,循环可能永远无法退出。例如下例,当运行时标准库调度器选择了std::launch::async创建新线程异步执行时下面的代码是没有问题的,但是如果选择了std::launch::deferred,则下面fut.wait_for返回的总是std::future_status::deferred从而条件一直不满足循环永远不能退出。

    using namespace std::literals;
    void f()
    {
        std::this_thread::sleep_for(1s);
    }
    auto fut = std::async(f); 
    while (fut.wait_for(100ms) != std::future_status::ready)  // 可能变成死循环,不能退出
    {
        ...
    }
    

    这个问题很容易在开发和单元测试的时候被忽略,因为它只会在机器特别繁忙(临近oversubscription或者线程耗尽)时任务才可能被延迟(std::launch::deferred),其他情况下运行时系统没有理由会选择延迟任务也就不会暴露这个问题。解决的办法就是去检查这个任务是否被延迟了,然后分情况写代码处理,不过并没有直接的方法获取任务是否延迟这个状态,间接可以用的是使用超时等待但等待时间为零来检测,如下。

    auto fut = std::async(f);
    
    if (fut.wait_for(0s) == std::future_status::deferred)  // 任务被延迟了
    {
        ...          // 调用 get 或者 wait方法 同步执行任务
    }
    else                                                   // 任务没有延迟(真异步)
    {
        while (fut.wait_for(100ms) != std::future_status::ready) 
        {
                
        }
        ... //任务已经执行完毕
    }
    

上面的这些问题都是因为使用std::async的默认策略,当下面的条件都可以满足时则默认策略也是OK的(不需要担心出现问题):

  • 执行任务的线程不需要和调用get或者wait的线程并行 (运行任务在调用get或者wait的线程执行)
  • 不在意那个线程的局部存储(变量)被读写
  • 保证std::async返回的future对象会调用get或者wait方法 或者 任务可能没有执行也是可以接受的
  • 使用wait_for或者wait_until的代码考虑了任务延迟的情况

上述任何条件不能满足时或者设计需要真正异步时,应该使用std::launch::async策略来调用std::async, auto fut = std::async(std::launch::async, f);,如下可以创建一个以std::launch::async为默认策略的函数:

// C++11
template<typename F, typename... Ts> 
inline std::future<typename std::result_of<F(Ts...)>::type> reallyAsync(F&& f, Ts&&... params)
{
	return std::async(std::launch::async, std::forward<F>(f), std::forward<Ts>(params)...);
}
// C++14
template<typename F, typename... Ts>
inline auto reallyAsync(F&& f, Ts&&... params)
{
	return std::async(std::launch::async, std::forward<F>(f), std::forward<Ts>(params)...);
}

需要记住

  • std::async默认策略同时允许同步和异步执行任务
  • 默认策略的灵活性导致了不确定性:访问线程局部变量、任务可能没有执行、基于超时的程序逻辑
  • 当异步执行很关键时需要指定std::launch::async策略

Item.37 让std::threads在所有路径上unjoinable

每个std::thread对象都处于下列二者之一的状态:

  • joinable可连接 , joinable的std::thread对应底层异步执行线程正在运行或阻塞或等待调度运行
  • unjoinable不可连接,不是joinable的std::thread对象,包含:
    • 默认构造的std::thread对象,这样的std::thread对象没有可以执行的函数,自然没有对应的底层执行线程。
    • std::thread对象已经被移动,std::thread对象被移动到其他的对象后,其对应的底层执行线程也关联到其他的对象,其本身不再关联任何线程。
    • 已经join过的std::thread对象,join之后std::thread对象已经不再对应底层执行线程(该线程已经执行完毕)。
    • 已经detach过的std::thread对象,detach后的std::thread对象和底层执行线程已经分离,不再有对应关系。

std::thread的连接属性joinability重要的原因是一个可连接joinable的std::thread对象的析构函数调用时程序会被终止,如下有问题的示例,线程创建后即是可连接joinable的,conditionsAreSatisfied返回true的时候会有t.join()从而线程变为不可连接unjoinable最终正常退出函数,conditionsAreSatisfied为false的时候函数会直接退出,这时joinable的线程的析构函数会被调用则整个程序会被结束。调用joinable的std::thread的析构函数会导致程序终止是C++标准委员会指定的行为,因为不这样则意味这:

  • 隐式join. 这样的情况下std::thread的析构函数内部会等待异步线程执行完毕,这样虽然合理但是会导致不太理解的性能异常。如下例,conditionsAreSatisfied为false时直觉函数应该立刻返回但却要等待线程结束耗费很长时间。
  • 隐式detach. 这种情况下std::thread的析构函数内会分离底层执行线程和当前对象,当前对象会迅速析构返回,但是问题会更严重,因为底层执行线程仍然在运行。仍然执行的底层线程可以操作之前捕获的内存,如下例,线程仍然在操作栈地址,当下一个函数进栈运行时会发现其数据诡异的被篡改了,这样调试问题会非常头疼。

标准委员会认为这两种都是不能接受的所以选择了直接终止程序,因此要求std::thread对象在出作用域的每条路径上都必须是不可连接的unjoinable。

constexpr auto tenMillion = 10000000;
bool doWork(std::function<bool(int)> filter,
int maxVal = tenMillion)
{
	std::vector<int> goodVals;
	std::thread t( [&filter, maxVal, &goodVals] 
		{
			for (auto i = 0; i <= maxVal; ++i)
			{ 
				if (filter(i)) goodVals.push_back(i); 
			}
		});
	auto nh = t.native_handle();
	...  // 使用原生线程句柄设置线程优先级
	     // 更好的做法是以暂停状态创建线程,设置优先级,然后再启动
	if (conditionsAreSatisfied()) 
	{
		t.join();
		performComputation(goodVals);
		return true;
	}
	return false;
}

程序退出作用域的方式有很多,包括return、goto、continue、break、exception,要保证每一条路径是很困难的,通常要在退出作用域的每一条路径上都采取某一行动的解决办法是把这个行动放在析构函数中,因为每一条路径在退出作用域时都会调用析构函数。这样的对象称为RAII对象,其类叫作RAII类,RAII(Resource Acquisition Is Initialization)意味“资源获取既是初始化”但其核心更多是在销毁而不是初始化。STL中很多都用到RAII,例如智能指针,容器,std::fstream等。但是标准库中没有std::thread的RAII类,因为即使RAII类在析构函数中还是要采取一定的行动,而标准委员会任务join和detach两种行为都是不可行的。但是实现std::thread的RAII类并不困难,如下是一个示例,说明:

  • 构造函数只接受std::thread右值,因为希望把std::thread移动到ThreadRAII内,且std::thread本身是不可复制的
  • 参数顺序问题。构造函数的参数中std::thread在前析构行为在后,这样比较符合调用的直觉;但是参数初始化列表和成员变量声明一直但是和构造函数参数却相反,std::thread在最后,因为std::thread可能创建就立刻开始运行,将其放在其他成员变量都初始化完毕之后可以更安全,因为它依赖于其他变量。
  • ThreadRAII提供了get函数来访问底层的std::thread对象,类似与智能指针,这样可以避免重复很多std::thead的API另外也兼容其他需要std::thread作为参数的代码
  • 析构函数内有对std::thread是否joinable的检查,因为对unjoinable的std::thread调用join或者detach是未定义行为
  • 析构函数内是存在潜在的数据竞争的,即t.joinable()返回true之后,另一处运行的代码调用了线程的join或者detach使得其unjoinable,这里没有对其处理,但更多是客户端调用的问题
class ThreadRAII {
public:
	enum class DtorAction { join, detach };
	ThreadRAII(std::thread&& t, DtorAction a): 
		action(a), 
		t(std::move(t)) {} 

	ThreadRAII(ThreadRAII&&) = default;           // 使用默认移动
	ThreadRAII& operator=(ThreadRAII&&) = default;

	~ThreadRAII()
	{
		if (t.joinable()) 
		{ 
			if (action == DtorAction::join) 
			{
				t.join();
			} 
			else 
			{
				t.detach();
			}
		}
	}
	std::thread& get() { return t; }

private:
	DtorAction action;
	std::thread t;
};

bool doWork(std::function<bool(int)> filter, int maxVal = tenMillion)
{
	std::vector<int> goodVals;
	ThreadRAII t(
		std::thread([&filter, maxVal, &goodVals]
		{
			for (auto i = 0; i <= maxVal; ++i)
			{ if (filter(i)) goodVals.push_back(i); }
		}),
		ThreadRAII::DtorAction::join
	);
	auto nh = t.get().native_handle();
	...
	if (conditionsAreSatisfied()) 
	{
		t.get().join();
		performComputation(goodVals);
		return true;
	}
	return false;
}

另外Item.39展示了使用ThreadRAII的析构join时,不仅带来性能的异常还会暂停挂起程序。更合适一点的方法是可以和异步执行lambda的线程通讯告诉其早点结束返回,不过C++11并不支持中断线程 interruptible thread

需要记住

  • 保证std::thread在每一条路径都unjoinable
  • 析构时join会导致非常难debug的性能异常
  • 析构时detach会导致非常难debug未定义行为
  • 成员变量声明中把std::thread放在最后

Item.38 考虑线程句柄的析构函数的不同行为

上一节Item.37讨论了std::thread对应底层系统线程,非延迟(non-deferred)任务的future和底层系统线程也有类似的关系,所以std::thread和future都可以看做系统线程的句柄。std::thread的析构行为前面已经讨论过了,future的行为则和它不太一样:有时像隐式join、有时隐私detach、有时两者都不像,但是绝对不会终止程序。

分析future的通讯,调用方和被调用方(异步执行)之间有一个通讯的通道,被调用方执行完毕通过这个通道传递计算结果(通常使用std::promise对象;这个通道也有其他作用,此处不讨论),调用方持有future并通过future获得结果。来分析一下这个计算结果存储在哪儿? 1)被调用方。但是在调用方调用future的get方法前,异步被调用方可能已经执行完毕,其局部存储也会销毁,所以不能存储在被调用方的std::promise中 2) 调用方的future中。std::future可以用来构造std::shared_future(转移调用结果的所有权到后者),future销毁后std::shared_future可能多次复制。而考虑到计算的结果可能是任意类型包括只可移动不可复制类型并且计算结果要维持到最后一个关联的future,所以这种方法也是不可行的。 因为这两种都不可行,实际计算结果存储在一个独立的地方称为共享状态shared state , 其一般是位于堆上的,但是标准委员会没有指定它的类型、接口和实现,这些取决于STL的实现者。

共享状态shared state 的存在影响了future的析构行为(其机制可以参考智能指针的引用计数,计数归零时会释放对象而这里是join线程):

  • 指向std::async启动的非延迟任务的shared state的最后一个future的析构函数会阻塞到任务结束。也就是在其析构函数内会隐式join异步执行任务的线程。
  • 所有其他futre对象的析构函数只是简单销毁该future对象,对于异步执行的任务这个类似于隐式detach,和执行任务的线程隐式分离。对于延迟的任务,如果这是最后一个关联的future对象那么意味着这个延迟任务永远都不会再执行了。

规则似乎很复杂(参考智能指针会比较简单),需要面对的就是一种正常情况和一种特殊情况:

  • 正常情况就是销毁future对象本身,其析构函数既没有join也没有detach,只是销毁future对象自己的成员变量(实际上还对shared state的引用计数减一,这个引用计数由关联的future和被调用方的std::promise操作,机制即类似智能指针,当计数归零则可以销毁shared state Item.19)。
  • 特殊情况则是当一个future对象满足所有下列条件时,析构函数的行为是阻塞知道异步任务完成(准确的是:隐式join执行std::async创建的任务的线程)
    • 它指向的shared state是由调用std::async产生的。
    • 该任务的策略是std::launch::async (不管是主动指定还是默认策略但是运行时系统选择的),也就是这个任务是非延迟的、真正异步的(有新线程创建)。
    • 该future是最后一个指向shared state的。std::future总是这个情况,但std::shared_future是可以复制且指向同一个shared state,当不是最后一个时只是普通的future对象析构。

产生这样奇怪规则的原因是因为标准委员想避免隐式detach的问题Item.37,但是又不想使用之前让程序终止的策略,所以妥协的结果是隐式join。这一决议是有争议的,C++14有正式的讨论放弃这种行为,但是最终没有通过。所以future的析构函数行为在C++11、C++14中是一致的。

future没有API来判断是否指向一个由std::async产生的shared state,所以对于一个任意的future对象是无法知道其析构的时候是否会阻塞、等待异步执行线程结束。当然通过程序逻辑是可以来解决的,例如标记由std::async 非延迟任务产生的future等。另外一个问题,shared state不仅仅可以由std::async产生,还可以由其他方法,例如使用std::packaged_task . std::packaged_task可以保证一个函数或者其他可以调用对象并将其返回结果放到shared state中,通过其get_future函数可以获得对应的future对象,回顾之前这里的shared state不是由std::async创建的所以future的析构函数是正常普通行为。std::packaged_task一旦创建就可以传递给线程来运行(也可以通过std::async,但是没有太大的必要这样做,和直接调用std::async作用一样),但是注意其是不可复制的所以是转为右值移动给线程。如下例,也可以直观的看到异步执行的线程和future并没有关联所以其析构函数也自然是普通行为,在(1)有几种选择:

  • 不处理线程t,则退出作用域时joinable的线程t会被析构然后程序中止
  • join线程t,这样future对象的析构中也不需要阻塞,线程已经unjoinable
  • detach线程t,底层线程和std::thread已经分离,future的析构函数中也不需要再detach

综合,由std::packaged_task产生的shared state其对应的future对象在析构函数中不需要采取任何特殊的策略,因为线程处理的相关操作在外部都已经处理了。

{
	int calcValue();                          // 目标任务
	std::packaged_task<int()> pt(calcValue);  // 包装
	auto fut = pt.get_future();               // 获得对应future对象,其关联shared state
	std::thread t(std::move(pt));             // 创建线程,运行
	...	                                      // (1)
}

需要记住

  • Future析构函数支持只是销毁其成员变量
  • 最后一个指向非延迟任务且由std::async创建的shared state的future对象的析构函数会阻塞直到任务完成

Item.39 一次性事件通讯时考虑用空future

有时候一个线程A通知另一个异步线程B某一事件就绪是非常有用的,因为线程B需要等待某一特定的事件发生:数据结构初始化、某一阶段的计算完成、特定传感器的值检测到等等。常用的线程间通讯的方法有哪些、那些最好了?

基于条件变量
最容易想到和最直观的就是使用条件变量了,方法则是A线程调用条件变量的notify而B线程则使用条件变量的wait系列函数阻塞等待,如下:

std::condition_variable cv;
std::mutex m;

// A线程
...                             // 检测事件
cv.notify_one();                // 通知,如果通知多个线程则替换为notify_all()

// B线程
...                             // 准备
{
	std::unique_lock<std::mutex> lk(m);
	cv.wait(lk);                // 阻塞等待
	...                         // m已经lock,处理事件
}
...                             // m已经unlock,继续处理

上例的代码是可以工作的,但是感觉上并不太好,Mutex是用来控制对共享数据的访问的,而这里AB线程间并没有这样的需求。例如,线程A负责一个数据的初始化然后交给B,B使用这个数据,如果A在初始化数据后不再访问它且B在数据初始化前不去访问它,这样通过程序逻辑的设计可以完全排除mutex,但是条件变量的设计却需要它。即便如此这里仍然有问题需要处理:

  • 如果A线程在B线程调用wait前调用了notify,B线程会卡死. 使用条件变量去唤醒另外的线程必须在保证在调用唤醒(notify系列)前另外的线程已经处于等待状态(调用了wait系列),否则等待的线程会永久的等待下去(也既是永久的错过了这次唤醒)。
  • wait语句无法处理虚假唤醒. 在线程API中,可能出现等待条件变量的线程被唤醒但是该条件变量并没有发出通知的情况,这种称为虚假唤醒,这在很多编程语言中都有并不局限于C++。合适的处理办法是等待线程在唤醒后立刻检查等待的条件是否真的发生,这一点C++处理尤其方便,因为其条件变量的wait语句支持接受一个检测的lambda或者其他的可调用对象,从而B线程的wait可以替换为wait(kl, []{return whether the event has occurred;})。使用这种方法需要可以检测条件是否满足,返回true或者false,但是上例的场景,只是线程A通知线程B,条件是否满足只有线程A知道而线程B是无法知道的(也正是线程B需要使用条件变量的原因)。

基于共享标志
另外一种常用的方式则是使用标志位flag,初始化flag为false,当检测到对应的事件时线程A设置flag为true,而对应B线程则持续查询poll这个flag,当查到flag为true时则表示事件发生可以继续,示例如下:

std::atomic<bool> flag(false);

// A线程
...                 // 检测事件
flag = true;        // 事件发生,设置flag
// B线程  
...                 // 准备
while(!flag);       // 持续poll flag,flag为true时才跳过
...                 // 处理事件

  • 优点: 克服了所有基于条件变量方法的缺点,1)不需要mutex 2)不用担心线程A在线程B检测之前设置了flag 3)不受虚假唤醒影响
  • 缺点: 线程B的polling的开销。线程B在等待flag被设置的过程中,程序逻辑上是阻塞block了,但是实际是仍然在运行,它会切实的占用物理硬件线程、增大线程切换开销,还可能会阻止CPU核心降频或者关闭来降低功耗。而真正阻塞的线程是不会有这样的影响的,而这也正是基于条件变量方法的优点,条件变量的wait方法是真正的阻塞。

条件变量与标志组合
这种方法是组合前面两种,flag用来确定事件是否真的发生从而避免虚假唤醒和唤醒早于等待,使用mutex保护flag,因此不需要使用std::atomic直接使用bool即可Item.40

std::condition_variable cv;
std::mutex m;
bool flag(false);

// A线程
...                             // 检测事件
{
	std::lock_guard<std::mutex> g(m);
	flag = true;
}
cv.notify_one();                // 通知,如果通知多个线程则替换为notify_all()

// B线程
...                             // 准备
{
	std::unique_lock<std::mutex> lk(m);
	cv.wait(lk, [] { return flag; });     // 阻塞等待并使用lambda检测是否事件发生
	...                         // m已经lock,处理事件
}
...                             // m已经unlock,继续处理

  • 优点: 1)不要担心线程A通知早于线程B等待 2)不用担心虚假唤醒 3)不需要polling。
  • 缺点: 这个方法工作很正常,但总显得冗长繁杂不是很简洁,我们需要的只是稳定、高效、简洁的线程A可以通知线程B。

future和promise
另一种方法是线程B使用future等待wait而线程A去设置,这样条件变量、mutex和flag都不需要。Item.38中讨论到future作为通讯通道中的调用方一端,用来接受另一端被调用方(通常是异步线程)返回的结果,但是这里线程AB并没有调用-被调用的关系,不过注意通讯通道的另一端是std::promise且也提示过这个通讯通道不止用来传递结果。这里,我们用这个通讯通道在线程AB间传递信息——事件发生。
工作原理很简单,线程A持有一个std::promise对象(通讯通道的发射端),线程B持有对应的future(通讯通道的接收端)。当线程A检测到事件发生时设置std::promise(向通讯通道写信息),同时线程B已经调用future的wait方法阻塞等待着直到std::promise被设置。std::promise 和 futures(包括future和shared_future)都是类模板需要类型参数——通讯通道需要传递数据的类型,但这里我们并不需要任何数据我们只关心future是否设置(事件是否发生),因此这里类型可以使用void,示例如下。

std::promise<void> p;

// A线程
...                 // 检测事件
p.set_value();      // 事件发生,设置std::promise
// B线程  
...                          // 准备
p.get_future().wait();       // 阻塞等待直到std::promise/future被设置
...                          // 处理事件

  • 优点: 1)不需要mutex 2)不用担心线程A设置早于线程B等待 3)不用担心虚假唤醒(只有条件变量的方法才有这个问题)4)线程B调用wait是真正的阻塞,不销毁系统资源
  • 缺点: 1)Item.38已经解释过,std::promise和std::future之间有shared state,其一般是堆上动态分配的,所以会有堆的分配和销毁开销 2)std::promise只能设置一次,所以这个通讯通道是一次性的,而前面的三种方法都是可以反复使用的(条件变量可以反复notify、flag可以反复设置-复位)

一次性这点限制并没有想象的那么严重,假设要创建一个暂定状态的线程,因为 1)希望降低所有线程创建关联的开销,这样需要时可以立刻运行 2)希望先配置一下线程(优先级、affinity)然后在执行。假如只需要暂定线程一次(创建后一次,然后正常执行),则使用void future可以如下示例。需要注意的问题是在(1)(3)处如果抛出异常,则线程会开始析构而此时是joinable故程序会中止,而(2)处抛出异常则会卡死在ThreadRAII的析构函数处,因为其采取join线程而线程一直在等待promise设置但这一点永远无法满足。

std::promise<void> p;
void react();
void detect()
{
	std::thread t([]{ 
		p.get_future().wait();
		react();
		});
	...                    // (1)
	p.set_value();
	...
	t.join();
}
// 使用RAII
void detect()
{
	ThreadRAII tr(
		std::thread( []{
			p.get_future().wait();
			react();
		}),
		ThreadRAII::DtorAction::join
	);
	...                    //(2)
	p.set_value();
	...
}

// 同时暂定和恢复多个线程
void detect()
{
	auto sf = p.get_future().share();
	std::vector<std::thread> vt;

	for (int i = 0; i < threadsToRun; ++i) {
		vt.emplace_back([sf]{ sf.wait();
		react(); });
	}
	...                   //(3)
	p.set_value();
	...

	for (auto& t : vt) {
		t.join();
	}
}

需要记住

  • 简单事件通讯,使用条件变量的方法 1)需要mutex 2)需要保证通知线程和被通知线程之间的时序(不得在被通知wait前唤醒) 3)避免虚假唤醒,被通知线程需要在唤醒后再次确认事件发生
  • 使用flag的方式可以避免上述问题,但是使用了polling而不是阻塞blocking
  • 条件变量和flag组合的方式可以解决问题,但是不是很自然
  • 使用std::promises和futures可以解决问题,但是 1)shared state使用了堆内存 2)通讯是一次性的

Item.40 并发用std::atomic,特殊内存用volatile

volatile关键字一直是被误解和误用最多的,在其他语言它和并发是有关系的,但是在C++中它则和并发没有丝毫关系——但是人们总是错误的将其应用在并发中,虽然有部分编译器内部对去修改给它添加了新的语义来支持并发但是这并不是标准行为。 人们错误用到volatile的地方应该使用的是C++的新特性——std::atomic模板,std::atomic的作用是保证其类型参数的操作(读、写)是原子的,就像这个操作是在mutex保护的临界区内,但其实现使用了特殊的机器指令所以会比mutex更高效。

  • 原子性
    如下例,最终ac的结果一定是2,这里自增是三步:读取值-修改(+1)-写回,称为一种RMW操作,而std::atomic的++自增操作是原子的,两次自增必然是2;相反volatile是没有保证操作原子性的,所以最终vc的结果可能会有很多种且不可预期,例如一种 1)线程1读取vc为0 2)线程2也读取vc为0 3)线程1对vc+1然后写回,vc变为1 4)线程2也vc+1后写回,vc变为1. 故在这种执行顺序下vc的结果为1,而这和我们的设计意图是违背的。这里的问题就是典型的并发开发时的数据竞争(data race),数据竞争下的产生的结果是未定义的因为编译器在这部分code是没有任何限制的,其可以做任何事情可以把它和没有数据竞争的code一样优化。

    std::atomic<int> ac(0);
    volatile int vc(0);
    // 线程1         // 线程2
    ac++;           ac++;
    vc++;           vc++;
    
  • 指令重排reorder
    在上一节中有讨论使用std::atomic作为状态来polling进行两个线程的同步,在第一个线程中可以有如下类似的代码。对人类而言,valAvailable = true必须在imptValue的赋值后面是显而易见的,但是对于编译器而言这两条赋值语句是独立的因为两个变量是独立的,通常编译器是允许重排不相关的赋值的,例如有四个独立变量abxy,a=b;x=y;可能会被编译器重排为x=y;a=b;,即使编译器不重排底层的硬件(CPU)也会(乱序发射功能),因为有时候可以使得代码运行更快。但是如下的代码是不可以重排的,而这则是由std::atomic保证的,其对编译器的重排添加了约束。其中一种既是源代码中在std::atomic变量写操作前的代码绝不会在其后运行,所以下例中后两个语句不仅仅编译器要保留顺序,还必须生成机器码保证底层硬件(CPU)也要保持这个顺序。而这里如果使用volatile则没有这样的约束,所以使用volatile替换std::atomic会产生不可预测的行为。

    std::atomic<bool> valAvailable(false);
    auto imptValue = computeImportantValue();
    valAvailable = true;
    
  • 特殊内存
    “普通内存”指当写一个值到这个内存地址,在下一次写入之前这个内存地址的值不会改变。所以对于一个普通内存的int,int x; auto y=x; y=x;这样的代码编译器自然会通过消除掉一次用x给y赋值来优化,因为给y赋值在这里是冗余的。同样的,auto y=x; y=x; x=10;x=20;会优化为auto y=x; x=20;。普通人的确不太会写出这样冗余的、反复读、反复写的代码,但是编译器实例化模板、内联代码、重排优化等之后,这样情况就比较常见了。所以这种优化就会很好的提升性能,但是这种优化的假设就是“普通内存”所以也只能应用在“普通内存”上。
    相对于“普通内存”的则成为“特殊内存”,最常见的一类是*内存映射I/O*,通常是用来和外设通讯的,例如传感器、打印机、显卡、网络等,而不是和普通内存RAM一样用来读写。如果x是一个位于这样特殊内存的变量,那么auto y=x; y=x;则不再是冗余的反复读了,因为x在两次读之间是可能变化的,例如x是一个温度传感器的值。同样的x=10;x=20;也不是冗余的写操作了,如果x对应一个串口端口那这里则表示传输10、20两个信号。这样的情况下进行上面的优化则破坏的软件的设计,而volatile 就是用来告诉编译器其处理的是特殊内存,不可以使用这样的优化。因此需要声明x为:volatile int x;,y的推导类型则是int(回顾Item.2,非引用非指针的类型推导时const、volatile会被丢弃掉)。
    而std::atomic是没有这样的特性,其允许编译器进行这样的优化,所以冗余的部分会被优化掉。当x声明为std::atomic时,auto y=x;会使得y被推导为std::atomic,但这里的代码会编译失败。因为std::atomic必须保证操作的原子性,所以从x复制构造y也需要是原子的,编译器产生的代码则是读x写y在一个原子操作内,硬件一般是不支持的这样的,所以std::atomic不支持复制构造函数、复制赋值函数(通过delete删除),移动操作没有显示声明,综合std::atomic不支持复制和移动操作。当需要将x赋值给y时,可以使用std::atomic的成员函数loadstore,代码可以写为std::atomic<int> y(x.load()); y.store(x.load());。但是这里x的d读和y的写是两个原子操作并非一个,编译器可以优化为 register = x.load(); std::atomic<int> y(register); y.store(register);,优化掉了一次x的读操作,而这对于特殊内存是应该避免的。

通过上面三条,可以得出:

  • std::atomic: 保证操作的原子性和避免重排,适用用于并发编程,不适用于访问特殊内存
  • volatile: 标记特殊内存,告诉编译器不要做一部分优化,让编译器从内存取值而不是用CPU寄存器,适用于访问内存,不适用于并发编程

同时也可以清楚的看到std::atomic和volatile完全工作于两个领域,所以其也可以同时使用:volatile std::atomic<int> vai;,当vai对应于特殊内存且需要被不同的线程并发访问既可以使用。
最后,部分开发者在既是没有需要的情况下依然选择用std::atomic的load和store成员函数操作,这样的好处是在代码中可以显示的表明操作的不是普通对象,因为std::atomic在有前面提高的好处时付出性能作为代价,这样在代码中显示的表明可以作为一个检查点另外也可以提醒那些需要保护的对像却没有使用std::atomic。

需要记住

  • std::atomic用于不使用mutex下多线程访问数据,适用于并发编程
  • volatile用于内存读写不应该被优化掉的场景,适用于特殊内存




CH.8 调整

C++中的技术和特性都会有一定的适用范围,一些常用的技术和特性在模型情况下很有效但是另外的场景则不是的。本章讨论两个这样和常规思考不同的例外:传值和emplacement.

Item.41 对于可以复制、移动开销也小且总是要复制的参数考虑传值

有些函数的参数注定是要被复制的,例如考虑一个addName的函数需要复制参数字符串到私有成员变量容器中,会有如下几种方案:

  • 重载方案。如下代码,为了效率可以针对左值和右值参数分别实现重载函数,但缺点是:1)需要维护两个函数 2)不能内联时生成的程序会大一些。
    性能分析: 不论左值还是右值参数都是以引用绑定的方式,这一步相对基本没有开销;左值重载中有一次复制构造,右值重载中有一次移动构造。

    class Widget {
    public:
        void addName(const std::string& newName)
        { 
            names.push_back(newName); 
        }
        void addName(std::string&& newName)
        { 
            names.push_back(std::move(newName)); 
        }
        ...
    private:
        std::vector<std::string> names;
    };
    
  • universal引用与模板。 使用模板+universal引用+完美转发在源代码上可以缩减,但缺点 1)模板实现必须在头文件中 2)不同的参数模板会有不同的实例化,std::string和可以转换到std::string的类型会有不同的实例化,生成代码体积会大 3)有些类型不能作为universal引用传递Item.30 4)传入错误类型的参数时编译器产生的错误代码不太可读
    性能分析: 参数作为引用传递,开销基本没有;左值会有一次复制,右值会有一次移动,和重载方式一致。

    class Widget {
    public:
        template<typename T>
        void addName(T&& newName)
        {
            names.push_back(std::forward<T>(newName));
        }
        ...
    }
    
  • 传值。同时有上面两种方式的好处而又能克服其缺点的方法——传值。如下例,这里使用了std::move是合理的因为: 1)newName是由调用实参复制构造的所以独立于实参 2)newName在移动之后不会再使用。只用一个函数实现既减少源代码的重复又减少了程序的footprint,没有使用universal引用所以也不要求放在头文件中也不会有模板不可读的错误信息情况。
    性能分析: C++98的话newName一定是复制构造,C++11的话左值实参会复制构造右值则会移动构造;添加到容器中则是无条件一次移动操作。汇总则是:左值为一次复制一次次移动,右值为两次移动。和前面两个方法相比,左值右值都多一次移动。

    class Widget {
    public:
        void addName(std::string newName)
        { 
            names.push_back(std::move(newName));
        }
        ...
    };
    

回头再看本节的题目,其诸多的定语已经加了表明了很强的限制“对于(可以复制)、(移动开销也小)且(总是要复制)的参数(考虑)传值”:

  1. (考虑): 本节只是建议“考虑”传值的方式而不是必须。传值的方式的确会有前面讨论的优点,但是其开销也会大一点且还有一些其他的问题。
  2. (可以复制): 传值的方式会产生一份拷贝,对于不支持复制的——只可移动的类型,则必须使用移动构造函数,而这是使用重载的方式不需要对左值重载,更简单高效。
  3. (移动开销也小): 如前讨论,传值的方式无论左值右值都多一次移动操作,如果移动操作开销不小,那么传值的性能损耗则不能忽略了(可能都不能接受)。移动操作的开销这时经常近似复制的开销,而C++的第一条建议则是避免复制开销使用引用传参
  4. (总是要复制): 传值的方式一定会有产生一个拷贝(大多是通过复制操作),如果参数不是必须会复制那么应该避免传值产生拷贝(构造、析构)的开销。

既是上面四个条件都满足了,使用传值的方式也不一定完全是合理的。因为复制一个参数有两种方式:复制构造、复制赋值。使用复制构造时的情况和前面讨论一次,无论左值右值都多一次移动操作,但是对于复制赋值则情况会复杂一点。如下例,一个password类有一个改变秘密的成员函数,在下面调用中p.changeTo(newPassword);,左值传入会复制构造一个newPwd形参,然后newPwd会移动到text中,这里newPwd有一次动态内存的分配和释放,text也有一次动态内存的释放(释放老的string)。在这个例子中老的密码长度大于新的,实际上是不需要释放内存,如果使用重载的方式void changeTo(const std::string& newPwd){text = newPwd;}则前面的两次释放和一次分配都不需要了。这里使用传值的方式增加了内存的动态释放和分配,而他们的开销是比string的移动开销高一个量级的。另外这里如果老秘密比新密码短,则两种方式都不能避免内存的动态释放和分配,相对应的速度也基本是相当的。另外这个的性能分析还要考虑std::string的SSO(小字符串优化)的影响,因为这部分是存在栈上而不是堆——没有动态内存的分配和释放问题。再着,影响性能的基本都是传入左值参数,其通常需要复制构造拷贝,而右值则移动构造了,所以分析性能的时候还需要考虑整个系统的左值右值比例。因此,要很好的分析传值的开销是很复杂的,保险的做法是在传值没有展现可以接受的性能情况下先使用重载或者模板的方式。

class Password {
public:
	explicit Password(std::string pwd): 
	text(std::move(pwd)) {} 

	void changeTo(std::string newPwd)
	{ 
		text = std::move(newPwd);
	} 
	...
private:
	std::string text;
};

std::string initPwd("Supercalifragilisticexpialidocious");
Password p(initPwd);
std::string newPassword = "Beware the Jabberwock";
p.changeTo(newPassword);

传值方式的问题是:

  1. 软件总是希望越快越好,传值方式虽然相对另外两种只是多了一次移动操作,但是函数嵌套调用或者调用链时每个函数都如此,则总的开销来说依然是可观的
  2. 无关性能的“切割问题slicing problem”, 使用传值的方式时,传递子类对象给基类类型的形参时,形参只会有基类的部分拷贝子类添加的部分会丢弃掉;使用传引用的方式则不会。这也是C++编程第一条建议为:避免对用户定义类型传值,应传引用。

C++11并没有改变C++98在传参上的哲学,只是C++11中新加的左值、右值、移动语义使得对于特定的情况会有一些更好的处理方法(对于可以复制、移动开销也小且总是要复制的参数考虑传值)。

需要记住

  • 对于可以复制、移动开销也小且总是要复制的参数考虑传值,其能和传引用有一样的性能,实现简单生成代码也小
  • 通过构造函数复制参数的开销可能会比通过赋值函数明显的高
  • 传值的方式会有”切割问题slicing problem”, 对于基类参数类型应该避免传值的方式

Item.42 考虑emplacement而不是insertion

通过insertion(insert, push_front, push_back, for std::forward_list, insert_after)向一个持有类型T的荣器增加元素时,传入的参数类型既是T。例如向一个std::string的容器插入一个字符串,传入的类型也是std::string,但是当传入可以转换到T(std::string)类型的参数时通过编译器隐式转换其也是可以的。例如std::vector<std::string> vs;vs.push_back("xyzzy");,代码是合法且一切正常的。如下显示了std::vector的push_back重载,当编译器检查到”xyzzy”不是要求的std::string类型时会例如传入的字符串生成临时std::string对象然后传给push_back函数。临时对象的生成多了一次改类型的构造和析构,这对于性能是有损耗的。

template <class T, class Allocator = allocator<T>>
class vector {
public:
	...
	void push_back(const T& x);
	void push_back(T&& x);
	...
};

vs.push_back("xyzzy");
//等价于
vs.push_back(std::string("xyzzy"));

提高性能的方法是使用emplacement替代insertion,在这里使用emplace_back替代push_back,emplacement方法接受参数(使用了完美转发,只要不遇到完美转发的限制传入参数数量无限制)后原地直接构造容器内的元素省掉了插入的构建临时对象的开销,因此理论性能会好于插入。除了std::forward_list 和 std::array外,STL的其他容器都支持insert和emplace,另外也提供接受位置迭代器的emplace_hint,std::forward_list有emplace_after和inster_after对应。另外类型也可以由同类型构造,所以emplacement也可以接受同类型的参数,因此insertion可以干的emplacement都可以。

但前面分析的性能提升只是理论上的,实际上由于标准库的实现不同,某些情况下如期望的emplacement比insertion性能好,但是也有的情况下后者更好。这些场景比较难去精确描述因为和很多因素有关,和容器持有的类型、容器的类型、新增元素的位置、插入类型的构造函数是否异常安全、容器是否接受重复值(std::set, std::map, std::unordered_set, std::unordered_map不接受)、新增加的元素是否在容器中已经存在等。通常评估的方法则是直接对两者都跑benchmark。 因此并不能完全抛弃insertion而全面采用emplacement,当下列条件都满足的时候则基本应该是emplacement的性能更好:

  1. 新添加的值是构造到容器中而不是赋值 。当在容器尾部添加元素是调用push_back或者emplace_back都会在尾部新构造元素,但是当向一个已有元素的位置加入新的元素时则情况不一样了。例如vs.emplace(vs.begin(), "xyzzy");,很少有标准库实现会在vs[0]所占内存处重新构造新的加入元素,大多是直接使用移动赋值——>需要源对象——>构造临时对象,临时对象的产生使得emplacement相对insertion没有什么性能优势。 向容器添加新元素是否重新构造是有标准库的实现者决定的,但是基于经验和常识,基于节点的容器(大多数的容器都是这类)添加元素使用的都是新构造,不是基于节点的容器则是std::vector, std::deque, and std::string(std::array不支持emplacement和insertion),他们则只能保证emplace_back,std::deque的emplace_front也可以保证是构造而非赋值。
  2. 传入参数的类型和容器持有的类型不同。emplacement的性能优势来源于消灭了临时对象而减少的开销,当向*container*中添加*T*类型的新值,本身就不会有临时对象产生也自然不会有性能的提升。
  3. 容器不太会因为新加入的值重复而拒绝。这条的意思是容器要么运行重复值存在或者大部分加入容器的值都是唯一的。原因是容器为了检测新加入的值是否重复,emplacement的实现一般是利用参数先构造一个临时节点,然后例如这个临时节点和现有节点比较是否有相同的,如果没有重复则临时节点链接进容器否则放弃并析构掉临时节点。如果出现大比例的放弃临时节点,则会有构造析构的浪费的开销进而不会有性能提升。

当选择emplacement或则insertion时还有另外两点需要考虑:

  1. 资源管理相关。例如我们有一个容器std::list<std::shared_ptr<Widget>> ptrs;,需要添加一个新的shared_ptr且支持自定义删除器void killWidget(Widget* pWidget);,这里参考item.21不能使用std::make_shared而必须直接调用new。如下例,使用insertion的示例为1)2)而emplacement为3)。自然和之前分析一样使用insertion会产生std::shared_ptr的临时对象而emplacement则会避免这个临时对象,但在这里临时对象的生成却是很有价值的。调用push_back时会生成临时对象暂命名为tmp,其自动接管new Widget的对象的生命周期,当push_back分配节点内存来保存tmp的拷贝时,如果出现内存耗尽异常,则异常抛出push_back外时tmp会被析构掉而new出来的Widget也自动会被析构掉,最终没有任何资源泄漏; 当使用emplace_back时则相反,当其出现内存耗尽异常而上抛时,指向堆上分配对象的裸指针会丢失从而出现资源泄漏。这个问题同样适用于std::unique_ptr等其他资源管理类型,这也是为什么资源创建(new得到的裸指针)完应该立刻传递给资源管理对象如此重要。

    // insertion
    ptrs.push_back(std::shared_ptr<Widget>(new Widget, killWidget));   // 1)
    ptrs.push_back({ new Widget, killWidget });                        // 2)
    // emplacement
    ptrs.emplace_back(new Widget, killWidget);                         // 3) dangerous
    
    // 解决办法
    std::shared_ptr<Widget> spw(new Widget, killWidget);
    ptrs.push_back(std::move(spw));
    ptrs.emplace_back(std::move(spw));
    
  2. 与explicit构造函数交互。如下例,向一个正则表达式的容器中添加一个nullptr(逻辑上是无意义且错误的),但是使用emplace_back可以通过编译但是2)3)push_back却不能通过而可以检测处错误。原因是std::regex的构造函数显示explicit的接受const char*参数,而2)3)都有隐式的指针转std::regex过程,构造函数的explicit阻止了这样的转换所以导致编译失败; 1)中的emplace_back的空指针是传递给std::regex构造函数的参数,等价于std::regex r(nullptr);,不存在隐式转换所以编译通过。

    std::vector<std::regex> regexes;
    regexes.emplace_back(nullptr);                // 1)编译OK,运行期错误  
    std::regex r = nullptr;                       // 2)编译失败
    regexes.push_back(nullptr);                   // 3)编译失败
    std::regex upperCaseWord("[A-Z]+");           // 4)编译OK,运行OK
    

    有趣的是std::regex r1 = nullptr;会编译失败,而std::regex r2(nullptr);则编译通过。标准的术语是前者为复制初始化而后者为直接初始化,复制初始化是不允许使用explicit构造函数的但直接初始化却可以,故r1失败而r2通过。回到push_back regexes.push_back(nullptr); 和 emplace_back regexes.emplace_back(nullptr);则比较好理解,push_back使用了复制初始化而emplace_back使用的是直接初始化。这里另外的教训是一定要传入正确的参数,既是explicit编译器也会去想办法去解释其代码是合理的(nullptr到const char*)。

需要记住

  • 原则上,emplacement的性能有时应该优于insertion,但无论何时不会比insertion差
  • 实际上,当满足 1)新加入的值是构造到容器中而不是赋值 2)传入参数类型和容器持有类型不同 3)容器不会因为重复拒绝加入新的值 三条时emplacement基本性能更优
  • emplacement函数可以有insertion函数不可以的类型转换