ModernEffectiveCpp 阅读笔记

- chapter 1 -


item 1 理解模板类型推断

rem

  • 对于引用(模板参数类型是引用)推导时,有引⽤的实参会被视为⽆引⽤,他们的引⽤会被忽略(之后拼接上模板类型的&(若有的话))
  • 对于通⽤引⽤的推导,左值实参会被特殊对待
  • 对于传值类型推导,实参如果具有常量性和易变性会被忽略
  • 在模板类型推导时,数组或者函数实参会退化为指针,除⾮它们被⽤于初始化引⽤

item 2 理解auto推断

对于花括号的处理是auto类型推导和模板类型推导唯⼀不同的地⽅。当使⽤auto的变量使⽤花括号的语,
法进⾏初始化的时候,会推导出std::initializer_list的实例化,但是对于模板类型推导这样就⾏不通:

1
2
3
4
5
6
7
8
9
10
auto x={11,23,9}; //x的类型是std::initializer_list<int> 
template<typename T>
void f(T param);

f({11,23,9}); //错误!不能推导出T

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

以上是cpp11的,但是cpp14中允许函数的形参和返回值都为auto,但是推断还是模板推断那一套:

1
2
3
std::vector<int> v;
auto resetV = [&v](const auto & newValue){v=newValue;}; //C++14 ...
reset({1,2,3}); //错误!推导失败

rem

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

item 3 理解decltype

1 使用它获取你想要的的类型

1
2
3
4
5
6
7
8
9
10
11
template<typename Container,typename Index> //C++ 14版本 
auto authAndAccess(Container& c,Index i)
{ authenticateUser(); return c[i]; }

std::deque<int> d;
...
authAndAccess(d,5)=10; //认证⽤⼾,返回d[5],然后把10赋值给它,⽆法通过编译器!
// --------------------
template<typename Container,typename Index> //最终的C++14版本
decltype(auto) authAndAccess(Container&& c,Index i)
{ authenticateUser(); return std::forward<Container>(c)[i]; }

上⾯的代码尝试把10赋值给右值,C++11禁⽌这样做,所以代码⽆法编译。
下面的改进版本,同时使用万能引用传入左值和右值的功能。

2 唯一需要注意的点

当使⽤decltype(auto) 的时候⼀定要加倍的小⼼,在表达式中看起来⽆⾜轻重的细节将会影响到类型的 推导。为了确认类型推导是否产出了你想要的结果,请参⻅Item4描述的那些技术。

1
2
3
4
5
6
7
8
9
int x;
// x是⼀个变量的名字,所以decltype(x) 是int。但是如果⽤⼀个小括号包覆这个名字,⽐如这样(x),
// 就会产⽣⼀个⽐名字更复杂的表达式。对于名字来说,x是⼀个左值,C++11定义了表达式(x) 则是⼀个左值。因此decltype((x)) 是int&


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

rem

  • decltype总是不加修改的产⽣变量或者表达式的类型。
  • 对于T类型的左值表达式,decltype总是产出T的引⽤即T&。
  • C++14⽀持decltype(auto) ,就像auto⼀样,推导出类型,但是它使⽤⾃⼰的独特规则进⾏推 导。

item 4 学会看推导出来的类型

rem

  • 类型推断可以从IDE看出,从编译器报错看出,从⼀些库的使⽤看出
  • 这些⼯具可能既不准确也⽆帮助,所以理解C++类型推导规则才是最重要的

– chapter 2 –


item 5 优先考虑auto而不是显式类型

⾸先,深呼吸,放松,auto是可选项,不是命令,在某些情况下如果你的专业判断告诉你使⽤显式类型 声明⽐auto要更清晰更易维护,那你就不必再坚持使⽤auto。

1
2
3
4
5
6
7
8
9
10
std::unordered_map<std::string,int> m;
...
// 所以 std::pair 的类型不是 std::pair<std::string,int> 而是 std::pair<const std::string,int> 。
// 编译器会努⼒的找到⼀ 种⽅法把前者转换为后者。它会成功的,因为它会创建⼀个临时对象,这个临时对象的类
// 型是p想绑定到 的对象的类型,即m中元素的类型,然后把p的引⽤绑定到这个临时对象上。在每个循环迭代结束时,
// 临 时对象将会销毁,如果你写了这样的⼀个循环,你可能会对它的⼀些⾏为感到⾮常惊讶,因为你确信你 只是让
// 成为p指向m中各个元素的引⽤而已。
for(const std::pair<std::string,int>& p : m) { ... }
// 使⽤auto可以避免这些很难被意识到的类型不匹配的错误:
for(const auto & p : m) { ... }

rem

  • auto变量必须初始化,通常它可以避免⼀些移植性和效率性的问题,也使得重构更⽅便,还能让你 少打⼏个字。
  • 正如Item2和6讨论的,auto类型的变量可能会踩到⼀些陷阱。

item 6 若非己愿,用显示类型而不是auto

auto错误推到:

1
2
3
4
5
6
7
8
9
10
bool highPriority = features(w)[5]; //显式的声明highPriority的类型,√
auto highPriority = features(w)[5]; //推导highPriority的类型,×
// 调⽤feature将返回⼀个std::vector,这个对象没有名字,为了⽅便我们的讨论,我这⾥叫他temp,
// operator[] 被temp调⽤,然后然后的 std::vector<bool>::reference 包含⼀个指针,这个指针指
// 向⼀个temp⾥⾯的word,加上相应的偏移,。highPriority是⼀个 std::vector<bool>::reference
// 的拷⻉,所以highPriority也包含⼀个指针,指向temp中的⼀个word,加上合适的偏移,这⾥是5.在这个
// 语句解释的时候temp将会被销毁,因为它是⼀个临时变量。因此highPriority包含⼀个悬置的指针,
// 如 果⽤于processWidget调⽤中将会造成未定义⾏为:

processWidget(w,highPriority); //未定义⾏为! //highPriority包含⼀个悬置指针

作为⼀个通则,不可⻅的代理类通常不适⽤于auto。

当你不知道这个类型有没有被代理还想使⽤auto时你就不能单单只⽤⼀ 个auto。auto本⾝没什么问题,问题是auto不会推导出你想要的类型。
解决⽅案是强制使⽤⼀个不同的 类型推导形式,这种⽅法我通常称之为显式类型初始器惯⽤法(the explicitly typed initialized idiom)

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

rem

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

— chapter 3 — cpp11/14新特性


item 7 区别使⽤()和{}创建对象

cpp11使用同一初始化,只有花括号任何地方都能用
仅仅阐述几个平时少见的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

// 不可拷⻉的对象可以使⽤花括号初始化或者小括号初始化,但是不能使⽤"="初始化
std::vector<int> ai1{0}; //没问题,x初始值为0
std::atomic<int> ai2(0); //没问题
std::atomic<int> ai3 = 0; //错误!

// 括号表达式有⼀个异常的特性,它不允许内置类型隐式的变窄转换(narrowing conversion)
double x,y,z; int sum1{x+y+z}; //错误!三个double的和不能⽤来初始化int类型的变量

// 使⽤小括号和"="的初始化不检查是否转换为变窄转换,因为由于历史遗留问题它们必须要兼容⽼旧代码
int sum2(x + y +z); //可以(表达式的值被截为int)
int sum3 = x + y + z; //同上

// C++最令⼈头疼的解析也天⽣免疫
Widget w1(10); //使⽤实参10调⽤Widget的⼀个构造函数
Widget w2(); //最令⼈头疼的解析!声明⼀个函数w2,返回Widget
Widget w3{}; //调⽤没有参数的构造函数构造对象

但是你越喜欢⽤atuo,你就越不能⽤括号初始化,因为编译器热衷于把括号初始化与使
std::initializer_list构造函数匹配了,热衷程度甚⾄超过了最佳匹配,
甚⾄普通的构造函数和移动构造函数都会被std::initializer_list构造函数劫持:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Widget { 
public:
Widget(int i, bool b); // 同上
Widget(int i, double d); // 同上
Widget(std::initializer_list<long double> il); //新添加的 …
};
Widget w1(10, true); // calls first ctor
Widget w2{10, true}; // uses braces, but now calls std::initializer_list ctor (10 and true convert to long double)
Widget w3(10, 5.0); // uses parens and, as before, calls second ctor
Widget w4{10, 5.0}; // uses braces, but now calls std::initializer_list ctor, (10 and 5.0 convert to long double)
Widget w5(w4); // 使⽤小括号,调⽤拷⻉构造函数
Widget w6{w4}; // 使⽤花括号,调⽤std::initializer_list构造函数
Widget w7(std::move(w4)); // 使⽤小括号,调⽤移动构造函数
Widget w8{std::move(w4)}; // 使⽤花括号,调⽤std::initializer_list构造函数

class Widget {
public:
Widget(int i, bool b); Widget(int i, double d);
Widget(std::initializer_list<bool> il); // element type is now bool … // no implicit conversion funcs };
Widget w{10, 5.0}; //错误!要求变窄转换

// 空的花括号意味着没有实参,不是⼀个空的std::initializer_list
class Widget {
public:
Widget();
Widget(std::initializer_list<int> il); ... };
Widget w1; // 调⽤默认构造函数 Widget
w2{}; // 同上
Widget w3(); // 最令⼈头疼的解析!声明⼀个函数
Widget w4({}); // 调⽤std::initializer_list Widget
w5{{}}; // 同上

// 最受影响的vector:
std::vector<int> v1(10, 20); //使⽤⾮std::initializer_list 构造函数创建⼀个包含10个元素的std::vector 所有的元素的值都是20
std::vector<int> v2{10, 20}; //使⽤std::initializer_list 构造函数创建包含两个元素的std::vector 元素的值为10和20

关于花括号和小括号的使⽤没有⼀个⼀致的观点,所以我的建议是⽤⼀个,并坚持使⽤。

rem

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

item 8 优先考虑nullptr而⾮0和NULL

nullptr的优点是它不是整型,同时也可以使代码表意明确,尤其是当和auto⼀起使⽤时。⽼实说它也不是⼀个指针类型,但是你可以把它认为是通⽤类型的指针。
nullptr的真正类型是std::nullptr_t,在⼀个完美的循环定义以后,std::nullptr_t⼜被定义为nullptr。
当模板出现时nullptr就更有⽤了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
int f1(std::shared_ptr<Widget> spw); // 只能被合适的 
double f2(std::unique_ptr<Widget> upw); // 已锁互斥量调
bool f3(Widget* pw);
std::mutex f1m, f2m, f3m; // 互斥量f1m,f2m,f3m,各种⽤于f1,f2,f3函数

// cpp14 封装一个模板lock/call/unlock的过程
template<typename FuncType,
typename MuxType,
typename PtrType>
decltype(auto) lockAndCall(
FuncType func, MuxType& mutex, PtrType ptr)
{
MuxGuard g(mutex);
return func(ptr);
}
// 在第⼀个调⽤中存在的问题是当0被传递给lockAndCall模板,模板类型推导会尝试去推导实参类型,
// 0的类型总是int,所以int版本的实例化中的func会被int类型的实参调⽤。 这与f1期待的参数std::shared_ptr不符。
auto result1 = lockAndCall(f1, f1m, 0); // 错误! …

// 第⼆个使⽤NULL调⽤的分析也是⼀样的。当NULL被传递给lockAndCall,形参ptr被推导为整型(可以是long or int等等,具体看编译器),
// 然后当ptr——⼀个int或者类似int的类型——传递给f2的时候就会出现类型错误。当ptr被传递给f3的时 候,
auto result2 = lockAndCall(f2, f2m, NULL); // 错误! …

// 隐式转换使std::nullptr_t转换为Widget* ,因为std::nullptr_t可以隐式转换为任何指针类型。
auto result3 = lockAndCall(f3, f3m, nullptr); // 没问题

rem

  • 优先考虑nullptr而⾮0和NULL
  • 避免重载指针和整型(因为在cpp98中,绝对会把0当成int,而你用0当成空指针指望着去调用重载的指针函数,你会失败)

item 9 优先考虑别名声明而⾮typedef

简单例子:

1
2
3
4
// FP是⼀个指向函数的指针的同义词,它指向的函数带有int和const std::string&形参,不返回任何东 西
typedef void (*FP)(int, const std::string&); // typedef
//同上
using FP = void (*)(int, const std::string&); // 别名声明

不过有⼀个地⽅使⽤别名声明吸引⼈的理由是存在的:模板。特别的,别名声明可以被模板化但是typedef不行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// c89 只能把typedef嵌套进模板化的struct才能表 达的东西
template<typename T>
struct MyAllocList {
typedef std::list<T, MyAlloc<T>> type;
};
MyAllocList<int>::type lw;

template<typename T>
class Widget {
private:
// 这⾥MyAllocList::type使⽤了⼀个类型,这个类型依赖于模板参数T。 因此MyAllocList::type是⼀个依赖类型,在C++很多讨⼈喜欢的规则中的⼀个提到必须要在依赖类型名 前加上typename。
// cpp标准:对于用于模板定义的依赖于模板参数的名称,只有在实例化的参数中存在这个类型名,或者这个名称前使用了typename关键字来修饰,编译器才会将该名称当成是类型。除了以上这两种情况,绝不会被当成是类型。
typename MyAllocList<T>::type list;

};
// c11 就直接多了
template<typename T>
using MyAllocList = std::list<T, MyAlloc<T>>;
template<typename T>
class Widget {
private:
MyAllocList<T> list;

};

如果你尝试过模板元编程(TMP), 你⼀定会碰到取模板类型参数然后基于它创建另⼀种类型的情况。 举个例⼦,给⼀个类型T,
如果你想去掉T的常量修饰和引⽤修饰,⽐如你想把const std::string&变成const std::string。尽管写了⼀些,但我这⾥不是想给你⼀个关于type traits使⽤的教程。注意类型转换尾部的::type。 如果你在⼀个模板内部使⽤类型参数,你也需要在它们前⾯加上typename。
因为标准委员会没有及时 认识到别名声明是更好的选择,所以直到C++14它们才提供了使⽤别名声明的版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
std::remove_const<T>::type // C++11: const T → T 
std::remove_const_t<T> // C++14 等价形式
std::remove_reference<T>::type // C++11: T&/T&& → T
std::remove_reference_t<T> // C++14 等价形式
std::add_lvalue_reference<T>::type // C++11: T → T&
std::add_lvalue_reference_t<T> // C++14 等价形式

// 如果你有cpp14,然后手动实现从11到14的转变:
template <class T>
using remove_const_t = typename remove_const<T>::type;

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

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

rem

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

item 10 优先考虑限域枚举而⾮未限域枚举

限域枚举相比于非限域枚举的优点如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// 优点1: 限域枚举(scoped enum),它不会导致枚举名泄漏
// 非限域枚举
enum Color { black, white, red }; // black, white, red 和 // Color⼀样都在相同作⽤域
auto white = false; // 错误! white早已在这个作⽤ // 域中存在

// 限域枚举
enum class Color { black, white, red }; // black, white, red // 限制在Color域内
auto white = false; // 没问题,同样域内没有这个名字
Color c = white; //错误,这个域中没有white
Color c = Color::white; // 没问题
auto c = Color::white; // 也没问题(也符合条款5的建议)

// 优点2:在不存在任何隐式转换可以将限域枚举中的枚举名转化为任何其他类型,也就是拒绝隐式转换
enum Color { black, white, red }; // 未限域枚举
std::vector<std::size_t> // func返回x的质因⼦
primeFactors(std::size_t x);
Color c = red;

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

}

enum class Color { black, white, red }; // Color现在是限域枚举
Color c = Color::red; // 和之前⼀样,只是 多了⼀个域修饰符

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

}

// 若真的非常想,需要用类型转化如下:
if (static_cast<double>(c) < 14.5) { // 奇怪的代码,但是有效
auto factors = // suspect, but primeFactors
(static_cast<std::size_t>(c)); // 能通过编译

}

// 为了⾼效使⽤内存,编译器通常在确保能包含所有枚举值的前提下为枚举选择⼀个最小的基础类型。在 ⼀些情况下,
// 编译器 将会优化速度,舍弃⼤小,这种情况下它可能不会选择最小的基础类型,而是选择对优化⼤小有帮助的 类型。为此,C++98
// 只⽀持枚举定义(所有枚举名全部列出来),枚举声明是不被允许的。这使得编译器能为之前使⽤的每 ⼀个枚举选择⼀个基础类型。但这样不能前置申明的缺点很明显,会增加编译依赖,当enum新增一个状态,所有用了这个enum的,都必须重新编译,而cpp11会解决这个问题
// 优点3:限域枚举可以前置声明,但是目前google cpp style推荐使用include
enum class Status {
good = 0, failed = 1, incomplete = 100, corrupt = 200, audited = 500, indeterminate = 0xFFFFFFFF
};

enum class Status; // forward declaration void
continueProcessing(Status s); // use of fwd-declared enum

// 获取enum对应的数字,使用模板
using UserInfo = // 类型别名,参⻅Item 9
std::tuple<std::string, // 名字
std::string, // email地址
std::size_t> ; // 声望

// 需要用static_cast强制转化到size_t,但是
enum class UserInfoFields { uiName, uiEmail, uiReputation }; UserInfo uInfo; // as before

auto val = std::get<static_cast<std::size_t>(UserInfoFields::uiEmail)> (uInfo); // 很啰嗦
// 用模板爽快点
template<typename E> // C++14
constexpr auto toUType(E enumerator) noexcept
{ return static_cast<std::underlying_type_t<E>>(enumerator); }

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

rem

  • C++98的枚举即⾮限域枚举
  • 限域枚举的枚举名仅在enum内可⻅。要转换为其它类型只能使⽤cast。
  • ⾮限域/限域枚举都⽀持基础类型说明语法,限域枚举基础类型默认是 int,⾮限域枚举没有默认 基础类型。
  • 限域枚举总是可以前置声明。⾮限域枚举仅当指定它们的基础类型时才能前置。

item 11 优先考虑使⽤deleted函数而⾮使⽤未定义的私有声明

你想要禁止客户使用某些函数代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// deleted 函数不能以任何⽅式被调⽤,即使你在成员函数或者友元函数⾥⾯调⽤ deleted 函数也不能通过编译。
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;

};
// 强制只有传入int才对
bool isLucky(int number); // 原始版本
bool isLucky(char) = delete; // 拒绝char
bool isLucky(bool) = delete; // 拒绝
bool bool isLucky(double) = delete; // 拒绝float和double

禁止一些模板实例化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// eg1: 
template<typename T> void processPointer(T* ptr);
template<> void processPointer<void>(void*) = delete; template<> void processPointer<char>(char*) = delete;
template<> void processPointer<const void>(const void*) = delete;
template<> void processPointer<const char>(const char*) = delete;
// eg2:
class Widget {
public:

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

private:
template<> // 错误!因为不能给特化的模板函数指定⼀个不同(于函数模板)的访问级别,cpp98的方案已经不行了
void processPointer<void>(void*);
};

class Widget {
public:

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

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

rem

  • ⽐起声明函数为private但不定义,使⽤delete函数更好
  • 任何函数都能 delete ,包括⾮成员函数和模板实例

item 12 使⽤override声明重载函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Base { public:
// 成员函数引⽤限定(reference qualifiers)
void doWork() &; // 只有*this为左值的时候才能被调⽤
void doWork() &&; // 只有*this为右值的时候才能被调⽤
virtual void mf1() const;
virtual void mf2(int x);
virtual void mf3() &;
void mf4() const;
};

class Derived: public Base {
public:
virtual void mf1() override;
virtual void mf2(unsigned int x) override;
virtual void mf3() && override; //
void mf4() const override;
};

// 上述的错误在于: mf1 在基类声明为 const ,但是派⽣类没有这个常量限定符 mf2 在基类声明为接受⼀个 int 参数,但是在派⽣类声明为接受 unsigned
// int 参数 mf3 在基类声明为左值引⽤限定,但是在派⽣类声明为右值引⽤限定 mf4 在基类没有声明为虚函数
// ⽐起让编译器(译注:通过warnings)告诉你"将要"重写实际不会重写,不如给你的派⽣类成员函数全 都加上 override
// 如果你考虑修改修改基类虚函数的函数签名, override 还可以帮你评估后果,对于 override ,它 只在成员函数声明结尾处才被视为关键字。

// 成员函数的引用限定
class Widget {
public:
using DataType = std::vector<double>; …
DataType& data() & // 对于左值Widgets,
{ return values; } // 返回左值
DataType data() && // 对于右值Widgets,
{ return std::move(values); } // 返回右值 …
private: DataType values;
};

auto vals1 = w.data(); //调⽤左值重载版本的Widget::data,拷⻉构造vals1
auto vals2 = makeWidget().data(); //调⽤右值重载版本的Widget::data, 移动构造vals2,否则.data()返回一个临时右值,进行拷贝纯属浪费

rem

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

item 13 优先考虑const_iterator而⾮iterator

rem

  • 优先考虑const_iterator而⾮iterator
  • 在最⼤程度通⽤的代码中,优先考虑⾮成员函数版本的begin,end,rbegin等,而⾮同名成员函 数(因为c11并没有cbegin,那么你可以手动写一个cbegin)

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

函数是否为noexcept和成员函数是否const⼀样重要。如果知道这个函数不会抛异常就 加上noexcept是简单天真的接口说明。
不过这⾥还有给不抛异常的函数加上noexcept的动机:它允许编译器⽣成更好的⽬标代码。

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

希望你能为noexcept提供的优化机会感到⾼兴,同时我还得让你缓⼀缓别太⾼兴了。优化很 重要,但是正确性更重要。些函数很⾃然的不应该抛异常,
更进⼀步值得注意的是移动操作和swap——使其不抛异常有重 ⼤意义,只要可能就应该将它们声明为noexcept。或者像是vector的push_back在发生扩容(能移动就移
动,必要时就复制)的时候,最后一个移动产生异常了,那么导致push_back失败,然后移动了的vec也不容易返回到原来的位置,

rem

  • 在C++98构造函数和析构函数抛出 异常是糟糕的代码设计
  • noexcept是函数接口的⼀部分,这意味着调⽤者会依赖它、
  • noexcept函数较之于⾮noexcept函数更容易优化
  • noexcept对于移动语义,swap,内存释放函数和析构函数⾮常有⽤ ⼤多数函数是异常中⽴的(译注:可能抛也可能不抛异常)而不是noexcept

item 15 尽可能的使⽤constexpr

constexpr表明⼀个值不仅仅是常量,还是编译期可知的。这个表述并不全⾯,因为当constexpr被⽤于函数的时候,事情就有⼀些细微差别了。
你不能假设constexpr函数是const,也不能保证 它们的(译注:返回)值是在编译期可知的。最有意思的是,这些是特性。关于constexpr函数返回的
结果不需要是const,也不需要编译期可知这⼀点是良好的⾏为。

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

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

// 如果你想在这些 context 中使⽤变量,你⼀定会希望将它们声明为constexpr,因为编译器会确保它们是编译期可知 的:
int sz; // 和之前⼀样 const auto arraySize = sz; // 没问题,arraySize是sz的常量复制
std::array<int, arraySize> data; // 错误,arraySize值在编译期不可知

constexpr相当于宣称“我能在C++要求常量表达式的地⽅使⽤它”

rem

  • constexpr对象是cosnt,它的值在编译期可知 当传递编译期可知的值时,
  • cosntexpr函数可以产出编译期可知的结果(越多这样的代码,运行时就会少一些不必要的计算,你的代码就越快)

item 16 让const成员函数线程安全

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
class Polynomial {
public:
using RootsType = std::vector<double>;

RootsType roots() const
{
if (!rootsAreValid) { //如果缓存不可用
//计算根
//用rootVals存储它们
rootsAreValid = true;
}
return rootVals;
}
private:
mutable bool rootsAreValid{ false }; //初始化器(initializer)的
mutable RootsType rootVals{}; //更多信息请查看条款7
};

// 假设现在有两个线程同时调⽤ Polynomial 对象的 roots ⽅法:
/*------ Thread 1 ------*/ /*-------- Thread 2 --------*/
auto rootsOfp = p.roots(); auto valsGivingZero = p.roots();

// 意味 着在没有同步的情况下,这些代码会有不同的线程读写相同的内存,这就是 data race 的定义。这段代 码的⾏为是未定义的。

// ⼀旦你需要对两个以上的变量或内存位置作为⼀个单元来操作的话,就应该使⽤互斥锁
// const成员函数应⽀持并发执⾏,这就是为什么你应该确保const成员函数是线程安 全的
class Widget {
public:

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

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


private:
mutable std::mutex m;
mutable int cachedValue; //不再用atomic
mutable bool cacheValid{ false }; //不再用atomic
};

rem

  • 确保const成员函数线程安全,除⾮你确定它们永远不会在临界区(concurrent context)中 使⽤。
  • std::atomic 可能⽐互斥锁提供更好的性能,但是它只适合操作单个变量或内存位置。

item 17 理解特殊成员函数的⽣成

C++11特殊成员函数俱乐部迎来了两位新会员:移动构造函数和移动赋值运算符。
先简单记住如果⽀持移动就会逐成员移动类成员和基类成 员,如果不⽀持移动就执⾏拷⻉操作就好了,item23会更新!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 默认生成的6个
class Widget {
public:

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

};
// 如果你声明了某个移动函数,编译器就不再⽣成另⼀个移动函数。这与复制函数的⽣成规则不太⼀样: 两个复制函数是独⽴的,声明⼀个不会影响另⼀个的默认⽣成。
// 如果你声明了 某个移动函数,就表明这个类型的移动操作不再是“逐⼀移动成员变量”的语义,即你不需要编译器默认 ⽣成的移动函数的语义,因此编译器也不会为你⽣成另⼀个移动函数。
// 再进⼀步,如果⼀个类显式声明了拷⻉操作,编译器就不会⽣成移动操作。这种限制的解释是如果声明 拷⻉操作就暗⽰着默认逐成员拷⻉操作不适⽤于该类,编译器会明⽩如果默认拷⻉不适⽤于该类,移动 操作也可能是不适⽤的。
// 声明移动操作使得编译器不会⽣成拷⻉操作。

// Rule of Three规则。这个规则告诉我们如果你声明了拷⻉构造函数,拷⻉赋值运算符, 或者析构函数三者之⼀,你应该也声明其余两个
// Rule of Three规则背后的解释依然有效,再加上对声明拷⻉操作阻⽌移动操作隐式⽣成的观察,使得C++11不会为那些有⽤⼾定义的析构函数的类⽣成移动操作。

// 但是你显示的声明了你的析构函数,又想用默认的拷贝
class Widget {
public:

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

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

};

class StringTable {
public:
StringTable()
{ makeLogEntry("Creating StringTable object"); } //增加的

~StringTable() //也是增加的
{ makeLogEntry("Destroying StringTable object"); }
//其他函数同之前一样
private:
std::map<int, std::string> values; //同之前一样
};
// 看起来合情合理,但是声明析构有潜在的副作⽤:它阻⽌了移动操作的⽣成。然而,拷⻉操作的⽣成是 不受影响的。因此代码能通过编译,运⾏,也能通过功能(译注:即打⽇志的功能)测试。功能测试也 包括移动功能,因为即使该类不⽀持移动操作,对该类的移动请求也能通过编译和运⾏。这个请求正如 之前提到的,会转而由拷⻉操作完成。它因为着对StringTable对象的移动实际上是对对象的拷⻉,即 拷⻉⾥⾯的 std::map<int, std::string> 对象。拷⻉ std::map<int, std::string> 对象很可能⽐ 移动慢⼏个数量级。简单的加个析构就引⼊了极⼤的性能问题!对拷⻉和移动操作显式加个 =default ,问题将不再出现。

// 注意没有成员函数模版阻⽌编译器⽣成特殊成员函数的规则

rem

  • 特殊成员函数是编译器可能⾃动⽣成的函数:默认构造,析构,拷⻉操作,移动操作。
  • 移动操作仅当类没有显式声明移动操作,拷⻉操作,析构时才⾃动⽣成。
  • 拷⻉构造仅当类没有显式声明拷⻉构造时才⾃动⽣成,并且如果⽤⼾声明了移动操作,拷⻉构造就 是delete。
  • 拷⻉赋值运算符仅当类没有显式声明拷⻉赋值运算符时才⾃动⽣成,并且如果⽤⼾声明 了移动操作,拷⻉赋值运算符就是delete。当⽤⼾声明了析构函数,拷⻉操作不再⾃动⽣成。

—- chapter 4 —- 智能指针

item 18 对于独占资源使⽤std::unique_ptr

如果原始指针够小够快,那么 std::unique_ptr ⼀样可以。可以移动但是不允许拷贝,不然每个都认为⾃⼰拥有资源,销 毁时就会出现重复销毁。
且unique_ptr适用于工厂函数返回新产生的对象的指针,因为我们并不知道产生的新对象是需要被专有化还是共享,unique_ptr转到shared_ptr非常方便。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
class Investment { ... }; 
class Sock: public Investment {...};
class Bond: public Investment {...};
class RealEstate: public Investment {...};

template<typename... Ts> //返回指向对象的std::unique_ptr,
std::unique_ptr<Investment> //对象使用给定实参创建
makeInvestment(Ts&&... params);
// 调用注意,使用{}做好生命周期控制
{

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

} //销毁 *pInvestment

// 可以自定义unique_ptr退出作用域时刻调用的析构函数
auto delInvmt = [](Investment* pInvestment) //自定义删除器
{ //(lambda表达式)
makeLogEntry(pInvestment);
delete pInvestment;
};

template<typename... Ts>
std::unique_ptr<Investment, decltype(delInvmt)> //更改后的返回类型
makeInvestment(Ts&&... params)
{
std::unique_ptr<Investment, decltype(delInvmt)> //应返回的指针
pInv(nullptr, delInvmt);
if (/*一个Stock对象应被创建*/)
{
pInv.reset(new Stock(std::forward<Ts>(params)...));
}
else if ( /*一个Bond对象应被创建*/ )
{
pInv.reset(new Bond(std::forward<Ts>(params)...));
}
else if ( /*一个RealEstate对象应被创建*/ )
{
pInv.reset(new RealEstate(std::forward<Ts>(params)...));
}
return pInv;
}
// 自定义的几个注意
// 1 当使⽤⾃定义删除器时,必须将其作为第⼆个参数传给 std::unique_ptr
// 2 尝试将原始指针(⽐如new创建)赋值给 std::unique_ptr 通不过编译,因为不存在从原始指针 到智能指针的隐式转换。这种隐式转换会出问题,所以禁⽌。这就是为什么通过 reset 来传递new指针的原因]
// 3 使⽤new时,要使⽤ std::forward 作为参数来完美转发给 makeInvestment
// 4 ⾃定义删除器的参数类型是 Investment* ,尽管真实的对象类型是在 makeInvestment 内部创建 的,它最终通过在lambda表达式中,作为 Investment* 对象被删除。这意味着我们通过基类指针删除派⽣类实例,为此,基类必须是虚函数析构

// 由于cpp14能够推断auto,所以可以使用更加简单的方式书写:
template<typename... Ts>
auto makeInvestment(Ts&&... params) // C++14
{
auto delInvmt = [](Investment* pInvestment) // this is now
{ // inside
makeLogEntry(pInvestment); // make-
delete pInvestment; // Investment
};
... // as before
}

// 当使⽤默认删除器时,可以合理假设 std::unique_ptr 和原始指针⼤小相同。当⾃定义删除器时,删除器是个函数指针,通常会使 std::unique_ptr 的字节从⼀个增加到 两个。对于删除器的函数对象来说,⼤小取决于函数对象中存储的状态多少,⽆状态函数对象(⽐如没 有捕获的lambda表达式)对⼤小没有影响,这意味当⾃定义删除器可以被lambda实现时,尽量使⽤lambda

auto delInvmt1 = [](Investment* pInvestment) //无状态lambda的
{ //自定义删除器
makeLogEntry(pInvestment);
delete pInvestment;
};
template<typename... Ts> //返回类型大小是
std::unique_ptr<Investment, decltype(delInvmt1)> //Investment*的大小
makeInvestment(Ts&&... args);
// ---
void delInvmt2(Investment* pInvestment) //函数形式的
{ //自定义删除器
makeLogEntry(pInvestment);
delete pInvestment;
}
template<typename... Ts> //返回类型大小是
std::unique_ptr<Investment, void (*)(Investment*)> //Investment*的指针
makeInvestment(Ts&&... params); //加至少一个函数指针的大小

// std::unique_ptr 有两种形式,⼀种⽤于单个对象( std::unique_ptr<T> ),⼀种⽤于数组 ( std::unique_ptr<T[]> )。
// 数组的std::unique_ptr不应该被使用,因为有std::array等等去取代

// std::unique_ptr 是C++11中表⽰专有所有权的⽅法,但是其最吸引⼈的功能之⼀是它可以轻松⾼效的 转换为 std::shared_ptr :
std::shared_ptr<Investment> sp = makeInvestment(arguments);
// 这就是为什么 std::unique_ptr ⾮常适合⽤作⼯⼚函数返回类型的关键部分

rem

  • std::unique_ptr 是轻量级、快速的、只能move的管理专有所有权语义资源的智能指针
  • 默认情况,资源销毁通过delete,但是⽀持⾃定义delete函数。有状态的删除器和函数指针会增加
  • std::unique_ptr 的⼤小 将 std::unique_ptr 转化为 std::shared_ptr 是简单的

item 19 对于共享资源使⽤std::shared_ptr

std::shared_ptr 通过引⽤计数来确保它是否是最后⼀个指向某种资源的指针,引⽤计数关联资源并跟 踪有多少 std::shared_ptr 指向该资源。 std::shared_ptr 构造函数递增引⽤计数值(注意是通常,原因是移动构造函数的存在。从另⼀个 std::shared_ptr 移动构造新 std::shared_ptr 会将原来的 std::shared_ptr 设置为null,那意味着⽼的 std::shared_ptr 不再指向资源,同时新的 std::shared_ptr 指向资源。这样的结果就是不需要修改引⽤计数值。因此移动 std::shared_ptr 会 ⽐拷⻉它要快:拷⻉要求递增引⽤计数值,移动不需要。移动赋值运算符同理,所以移动赋值运算符也 ⽐拷⻉赋值运算符快。),析构函数递减值,拷⻉赋值运算符可能递增也可能递减值。(如果sp1和sp2是 std::shared_ptr 并且指向不同对象,赋值运算符 sp1=sp2 会使sp1指向sp2指向的对象。直接效果就 是sp1引⽤计数减⼀,sp2引⽤计数加⼀。)
引⽤计数暗⽰着性能问题:

  • std::shared_ptr ⼤小是原始指针的两倍,因为它内部包含⼀个指向资源的原始指针,还包含⼀ 个资源的引⽤计数值。
  • 引⽤计数必须动态分配。 理论上,引⽤计数与所指对象关联起来,但是被指向的对象不知道这件事情(译注:不知道有指向⾃⼰的指针)。因此它们没有办法存放⼀个引⽤计数值。Item21会解释使 ⽤ std::make_shared 创建 std::shared_ptr 可以避免引⽤计数的动态分配,但是还存在⼀些 std::make_shared 不能使⽤的场景,这时候引⽤计数就会动态分配。
  • 递增递减引⽤计数必须是原⼦性的,因为多个reader、writer可能在不同的线程。⽐如,指向某种 资源的 std::shared_ptr 可能在⼀个线程执⾏析构,在另⼀个不同的线程, std::shared_ptr 指 向相同的对象,但是执⾏的确是拷⻉操作。原⼦操作通常⽐⾮原⼦操作要慢,所以即使是引⽤计 数,你也应该假定读写它们是存在开销的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
auto loggingDel = [](Widget *pw)        //自定义删除器
{ //(和条款18一样)
makeLogEntry(pw);
delete pw;
};

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


// 区别于std::unique_ptr的2点不同:
// 对于 std::unique_ptr 来说,销毁器类型是智能指针类型的⼀部分。对于 std::shared_ptr 则不是,std::shared_ptr 的设计更为灵活。考虑有两个 std::shared_ptr ,每个⾃带不同的销毁器(⽐如通 过lambda表达式⾃定义销毁器):

auto customDeleter1 = [](Widget *pw) { … }; //自定义删除器,
auto customDeleter2 = [](Widget *pw) { … }; //每种类型不同
std::shared_ptr<Widget> pw1(new Widget, customDeleter1);
std::shared_ptr<Widget> pw2(new Widget, customDeleter2);
std::vector<std::shared_ptr<Widget>> vpw{ pw1, pw2 };
// 因为pw1和pw2有相同的类型,所以它们都可以放到存放那个类型的对象的容器中,它们也能相互赋值,也可以传⼊形参为 std::shared_ptr<Widget> 的函数。但是 std::unique_ptr 就 不⾏,因为 std::unique_ptr 把销毁器视作类型的⼀部分。
// 另⼀个不同于 std::unique_ptr 的地⽅是,指定⾃定义销毁器不会改变 std::shared_ptr 对象的⼤ 小。不管销毁器是什么,⼀个 std::shared_ptr 对象都是两个指针⼤小。

// std::shared_ptr 怎么能引⽤⼀个任意⼤的销毁器而不使⽤更多的内存? 它不能。它必须使⽤更多的内存。然而,那部分内存不是 std::shared_ptr 对象的⼀部分。那部分在堆 上⾯,只要 std::shared_ptr ⾃定义了分配器,那部分内存随便在哪都⾏。
// 为引⽤计数是另⼀个更 ⼤的数据结构的⼀部分,那个数据结构通常叫做控制块(control block)。控制块包含除了引⽤计数值 外的⼀个⾃定义销毁器的拷⻉,当然前提是存在⾃定义销毁器。如果⽤⼾还指定了⾃定义分配器,控制 器也会包含⼀个分配器的拷⻉。控制块可能还包含⼀些额外的数据,正如Item21提到的,⼀个次级引⽤ 计数weak count,控制块的创建会遵循下⾯⼏条规则:
// 1 std::make_shared 总是创建⼀个控制块(参⻅Item21)。它创建⼀个指向新对象的指针,所以可以 肯定 std::make_shared 调⽤时对象不存在其他控制块。
// 2 当从独占指针上构造出 std::shared_ptr 时会创建控制块(即 std::unique_ptr 或者 std::auto_ptr )。独占指针没有使⽤控制块,所以指针指向的对象没有关联其他控制块。(作 为构造的⼀部分, std::shared_ptr 侵占独占指针所指向的对象的独占权,所以 std::unique_ptr 被设置为null)
// 3 当从原始指针上构造出 std::shared_ptr 时会创建控制块。如果你想从⼀个早已存在控制块的对 象上创建 std::shared_ptr ,你将假定传递⼀个 std::shared_ptr 或者 std::weak_ptr 作为构 造函数实参,而不是原始指针。⽤ std::shared_ptr 或者 std::weak_ptr 作为构造函数实参创 建 std::shared_ptr 不会创建新控制块,因为它可以依赖传递来的智能指针指向控制块。

// 这些规则造成的后果就是从原始指针上构造超过⼀个 std::shared_ptr 就会让你走上未定义⾏为的快⻋ 道,因为指向的对象有多个控制块关联。多个控制块意味着多个引⽤计数值,多个引⽤计数值意味着对 象将会被销毁多次(每个引⽤计数⼀次)。那意味着下⾯的代码是有问题的,很有问题,问题很⼤:
auto pw = new Widget; //pw是原始指针
std::shared_ptr<Widget> spw1(pw, loggingDel); //为*pw创建控制块
std::shared_ptr<Widget> spw2(pw, loggingDel); //为*pw创建第二个控制块
// 但是将同样的原始指针传递给spw2的构 造函数会再次为 *pw 创建⼀个控制块。因此 *pw 有两个引⽤计数值,每⼀个最后都会变成零,然后最终 导致 *pw 销毁两次。第⼆个销毁会产⽣未定义⾏为。
// std::shared_ptr 给我们上了两堂课。
// 1 第⼀,避免传给 std::shared_ptr 构造函数原始指针。通常替 代⽅案是使⽤ std::make_shared (参⻅Item21),不过上⾯例⼦中,我们使⽤了⾃定义销毁器,⽤ std::make_shared 就没办法做到。
// 2 第⼆,如果你必须传给 std::shared_ptr 构造函数原始指针,直 接传new出来的结果,不要传指针变量。如果上⾯代码第⼀部分这样重写:
std::shared_ptr<Widget> spw1(new Widget, //直接使用new的结果
loggingDel);
std::shared_ptr<Widget> spw2(spw1); // spw2使⽤spw1⼀样的控制块

// shared_ptr指向this发生的错误:
class Widget { public: …void process(); … };
void Widget::process() { … // 处理Widget
processedWidgets.emplace_back(this); // 然后将他加到已处理过的Widget的列表中
// 这是错的 ,不是由于emplace_back
}
// 上⾯的代码可以通过编译,但是向容 器传递⼀个原始指针(this), std::shared_ptr 会由此为指向的对象( *this )创建⼀个控制块。那 看起来没什么问题,直到你意识到如果成员函数外⾯早已存在指向Widget对象的指针,它是未定义⾏为 的,std::enable_shared_from_this 就是用来处理它的,这个标准名字就是奇异递归模板模式(TheCuriously Recurring Template Pattern(CRTP))。

// ⽆论在哪当你想使 ⽤ std::shared_ptr 指向this所指对象时都请使⽤它。这
class Widget: public std::enable_shared_from_this<Widget> {
public:

void process();

};
void Widget::process()
{
//和之前一样,处理Widget

//把指向当前对象的std::shared_ptr加入processedWidgets
processedWidgets.emplace_back(shared_from_this());
}
// 从内部来说, shared_from_this 查找当前对象控制块,然后创建⼀个新的 std::shared_ptr 指向这 个控制块。设计的依据是当前对象已经存在⼀个关联的控制块。要想符合设计依据的情况,必须已经存 在⼀个指向当前对象的 std::shared_ptr (即调⽤shared_from_this的成员函数外⾯已经存在⼀个std::shared_ptr )。如果没有 std::shared_ptr 指向当前对象(即当前对象没有关联控制块),⾏为 是未定义的,shared_from_this通常抛出⼀个异常。

// 再次讨论大小:
// 控制块通常只占⼏个word⼤小,⾃定义销毁器和分配器可能会让它变⼤⼀点。通常控制块的实现⽐你想 的更复杂⼀些。它使⽤继承,甚⾄⾥⾯还有⼀个虚函数(⽤来确保指向的对象被正确销毁)。这意味着 使⽤ std::shared_ptr 还会招致控制块使⽤虚函数带来的成本。
// 在通常情况下, std::shared_ptr 创建控制块会使⽤默认销毁器和默认分配器,控制块只需三个word⼤小。它的分配基本上是⽆开销的。对 std::shared_ptr 解引⽤的开销不 会⽐原始指针⾼。执⾏原⼦引⽤计数修改操作需要承担⼀两个原⼦操作开销,这些操作通常都会⼀⼀映 射到机器指令上,所以即使对⽐⾮原⼦指令来说,原⼦指令开销较⼤,但是它们仍然只是单个指令。对 于每个被 std::shared_ptr 指向的对象来说,控制块中的虚函数机制产⽣的开销通常只需要承受⼀次, 即对象销毁的时候。

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

rem

  • std::shared_ptr 为任意共享所有权的资源⼀种⾃动垃圾回收的便捷⽅式。
  • 较之于 std::unique_ptr , std::shared_ptr 对象通常⼤两倍,控制块会产⽣开销,需要原⼦引 ⽤计数修改操作。
  • 默认资源销毁是通过delete,但是也⽀持⾃定义销毁器。销毁器的类型是什么对于 std::shared_ptr 的类型没有影响。
  • 避免从原始指针变量上创建 std::shared_ptr 。

item 20 当std::shard_ptr可能悬空时使⽤std::weak_ptr

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// std::weak_ptr 通常从 std::shared_ptr 上创建。当从 std::shared_ptr 上创建 std::weak_ptr 时两者指向相同的对象,但是 std::weak_ptr 不会影响所指 对象的引⽤计数:

auto spw = //spw创建之后,指向的Widget的
std::make_shared<Widget>(); //引用计数(ref count,RC)为1。
//std::make_shared的信息参见条款21

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

spw = nullptr; //RC变为0,Widget被销毁。
//wpw现在悬空
if (wpw.expired()) … // if wpw doesn't point to an object


// 从weak_ptr创建shared_ptr
std::shared_ptr<Widget> spw1 = wpw.lock(); // if wpw's expired, spw1 is null
auto spw2 = wpw.lock(); // same as above, but uses auto
std::shared_ptr<Widget> spw3(wpw); // if wpw's expired, throw std::bad_weak_ptr

// 缓存应该使⽤ std::weak_ptr ,这可以知道是否已经悬空。这意味着⼯⼚函数返回 值类型应该是 std::shared_ptr ,因为只有当对象的⽣命周期由 std::shared_ptr 管理时, std::weak_ptr 才能检测到悬空。
std::unique_ptr<const Widget> loadWidget(WidgetID id);

std::shared_ptr<const Widget> fastLoadWidget(WidgetID id)
{
static std::unordered_map<WidgetID,
std::weak_ptr<const Widget>> cache;
//译者注:这里std::weak_ptr<const Widget>是高亮
auto objPtr = cache[id].lock(); //objPtr是去缓存对象的
//std::shared_ptr(或
//当对象不在缓存中时为null)

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

// A有B,B有A,那么A中B,B中A都得用weak_ptr,否则销毁时出现循环,导致资源泄露
// 我写的是 std::weak_ptr 不参与对象的共享所有 权,因此不影响指向对象的引⽤计数。

rem

  • 像 std::shared_ptr 使⽤ std::weak_ptr 可能会悬空。
  • std::weak_ptr 的潜在使⽤场景包括:caching、observer lists、打破 std::shared_ptr 指向循 环。

item 21 优先考虑使⽤std::make_unique和std::make_shared而⾮new

本item的意⻅是,更倾向于使⽤make函数,而不是完全依赖于它们。这是因为有些情况下 它们不能或不应该被使⽤。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
// 1 重复写类型和软件⼯程⾥ ⾯⼀个关键原则相冲突:应该避免重复代码。源代码中的重复增加了编译的时间,会导致⽬标代码冗 余,并且通常会让代码库使⽤更加困难。它经常演变成不⼀致的代码,而代码库中的不⼀致常常导致bug。此外,打两次字⽐⼀次更费⼒,而且谁不喜欢减少打字负担

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

// 2 避免new出来对象和去构造shared_ptr之类的指针这两步之间发生异常导致资源泄漏(异常安全规避)
processWidget(std::shared_ptr<Widget>(new Widget), computePriority()); // potential resource leak!

// 答案和编译器将源码转换为⽬标代码有关。在运⾏时,⼀个函数的参数必须先被计算,才能被调⽤,所 以在调⽤processWidget之前,必须执⾏以下操作,processWidget才开始执⾏:表达式'new Widget'必须计算,例如,⼀个Widget对象必须在堆上被创建 负责管理new出来指针的 std::shared_ptr<Widget> 构造函数必须被执⾏computePriority()必须运⾏
// 编译器不需要按照执⾏顺序⽣成代码。“new Widget"必须在 std::shared_ptr 的构造函数被调⽤前执 ⾏,因为new出来的结果作为构造函数的参数,但compute Priority可能在这之前,之后,或者之间执 ⾏。也就是说,编译器可能按照这个执⾏顺序⽣成代码:
// 1. 执行“`new Widget`”
// 2. 执行`computePriority`
// 3. 运行`std::shared_ptr`构造函数
// 如果按照这样⽣成代码,并且在运⾏是computePriority产⽣了异常,那么第⼀步动态分配的Widget就 会泄露。因为它永远都不会被第三步的 std::shared_ptr 所管理了。于是我们使用make_shared:
processWidget(std::make_shared<Widget>(), computePriority());
// 在运⾏时, std::make_shared 和computePriority会先被调⽤。如果是 std::make_shared ,在computePriority调⽤前,动态分配Widget的原始指针会安全的保存在作为返回值的 std::shared_ptr 中。如果compu tePriority⽣成⼀个异常,那么 std::shared_ptr 析构函数将确保管理的Widget被销 毁。如果⾸先调⽤computePriority并产⽣⼀个异常,那么 std::make_shared 将不会被调⽤,因此也 就不需要担⼼new Widget(会泄露)。

// 3 std::make_shared 的⼀个特性(与直接使⽤new相⽐)得到了效率提升。使⽤ std::make_shared 允许 编译器⽣成更小,更快的代码,并使⽤更简洁的数据结构。
std::shared_ptr<Widget> spw(new Widget);
// 显然,这段代码需要进⾏内存分配,但它实际上执⾏了两次。Item 19解释了每个 std::shared_ptr 指 向⼀个控制块,其中包含被指向对象的引⽤计数。这个控制块的内存在 std::shared_ptr 构造函数中分 配。因此,直接使⽤new需要为Widget分配⼀次内存,为控制块分配再分配⼀次内存。
// 如果使⽤ std::make_shared 代替: auto spw = std::make_shared_ptr<Widget>(); ⼀次分配⾜ 矣。这是因为 std::make_shared 分配⼀块内存,同时容纳了Widget对象和控制块。这种优化减少了程 序的静态⼤小,因为代码只包含⼀个内存分配调⽤,并且它提⾼了可执⾏代码的速度,因为内存只分配 ⼀次。对于 std::make_shared 的效率分析同样适⽤于 std::allocate_shared



// 缺点1 没有make函数允许指定定制的析构,但是new出来的指针则可以
auto widgetDeleter = [](Widget*){...};
std::unique_ptr<Widget, decltype(widgetDeleter)> upw(new Widget, widgetDeleter);
std::shared_ptr<Widget> spw(new Widget, widgetDeleter);

// 缺点2 具体的语法细节 -- std::initializer_list在使用圆括号和大括号调用的构造函数不同
auto upv = std::make_unique<std::vector<int>>(10, 20);
auto spv = std::make_shared<std::vector<int>>(10, 20);
// 两种调⽤都创建了10个元素,每个值为20.这意味着在make函数中,完美转发 使⽤圆括号,而不是⼤括号。坏消息是如果你想⽤⼤括号初始化指向的对象,你必须直接使⽤new。正如item31所说,⼤括号初始化⽆法完美转发。 但是,item30介绍了⼀个变通的⽅法:使⽤auto类型推导从⼤括号初始化创建std::initializer_list对象 (⻅Item 2),然后将auto创建的对象传递给make函数。
// 对于std::unique_ptr,只有这两种情景(定制删除和⼤括号初始化)使⽤make函数有点问题。但是shared_ptr还会有如下2个问题

// 缺点3(仅shared_ptr) 适⽤make函数去创建重载了operator new 和 operator delete类的对象是糟糕想法
// ⼀些类重载了operator new和operator delete。这些函数的存在意味着对这些类型的对象的全局内存分 配和释放是不合常规的。设计这种定制类往往只会精确的分配、释放对象的⼤小。例如,Widget类的operator new和operator delete只会处理sizeof(Widget)⼤小的内存块的分配和释放。这种常识不太适 ⽤于 std::shared_ptr 对定制化分配(通过std::allocate_shared)和释放(通过定制化deleters),因为std::allocate_shared需要的内存总⼤小不等于动态分配的对象⼤小,还需要再加上控制块⼤小。因此, 适⽤make函数去创建重载了operator new 和 operator delete类的对象是个典型的糟糕想法。
// 简言之:就是说shared_ptr调用make的时候会将对象的内存直接分配到控制块里(这也是shared_ptr的大小和速度优势),但是控制块里还有什么weak_ptr的cnt,也就是说当weak_ptr一直在,那么控制块不会释放,导致对象内存也不会释放,但是如果使用new,控制块和对象内存是分离的,当对象的shared_ptr的引用计数为0,但是weak_ptr不是0,那么会里马释放对象的内存
class ReallyBigType { … };

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

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

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

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

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

// 直接只用`new`,一旦最后一个`std::shared_ptr`被销毁,`ReallyBigType`对象的内存就会被释放:
class ReallyBigType { … }; //和之前一样

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

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

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

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

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



// 注意左右值的区别,主要是shared_ptr的拷贝对于引用计数有原子加的操作,带来了性能开销,使用std::move转为右值提高性能
// 异常不安全:回想⼀下:如果computePriority在“new Widget”之后,而在 std::shared_ptr 构造函数之前调⽤,并且 如果computePriority产⽣⼀个异常,那么动态分配的Widget将会泄漏
processWidget(
std::shared_ptr<Widget>(new Widget, cusDel), // arg is rvalue
computePriority()
);
// 异常安全:
std::shared_ptr<Widget> spw(new Widget, cusDel);
processWidget(spw, computePriority()); // 正确,但是没优化,⻅下
// 因为 std::shared_ptr 假定了传递给它的构造函数的原始指针的所有权,即使构造函数产 ⽣了⼀个异常。此例中,如果spw的构造函数抛出异常(即⽆法为控制块动态分配内存),仍然能够保证cusDel会在new Widget产⽣的指针上调⽤。

// ⼀个小小的性能问题是,在异常不安全调⽤中,我们将⼀个右值传递给processWidget,但是在异常安全调⽤中,我们传递了左值;因为processWidget的 std::shared_ptr 参数是传值,传右值给构造函数只需要move,而传递左值需 要拷⻉。对 std::shared_ptr 而⾔,这种区别是有意义的,因为拷⻉ std::shared_ptr 需要对引⽤计 数原⼦加,move则不需要对引⽤计数有操作。为了使异常安全代码达到异常不安全代码的性能⽔平,我 们需要⽤std::move将spw转换为右值.
// 优化版本的异常安全调用
processWidget(std::move(spw), computePriority());

rem

  • 和直接使⽤new相⽐,make函数消除了代码重复,提⾼了异常安全性。
  • 对于 std::make_shared 和 std::allocate_shared ,⽣成的代码更小更快。
  • 不适合使⽤make函数的情况包括需要指定⾃定义删除器和希望⽤⼤括号初始化
  • 对于 std::shared_ptr s, make函数可能不被建议的其他情况包括
    • (1)有⾃定义内存管理的类和
    • (2)特别关注内存的系统,⾮常⼤的对象,以及 std::weak_ptr s⽐对应的 std::shared_ptr s活得 更久

item 22 当使⽤Pimpl惯⽤法,请在实现⽂件中定义特殊成员函数

会对 Pimpl (Pointer to implementation)惯⽤法很熟悉。 凭借 这样⼀种技巧,你可以将⼀个类数据成员替换成⼀个指向包含具体实现的类或结构体的指针, 并将放在主 类(primary class)的数据成员们移动到实现类去(implementation class), 而这些数据成员的访问将通过指针间接访问。 举个例⼦,假如有⼀个类 Widget 看起来如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
class Widget() {                    //定义在头文件“widget.h”
public:
Widget();

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

// 因为类 Widget 的数据成员包含有类型 std::string , std::vector 和 Gadget , 定义有这些类型的头 ⽂件在类 Widget 编译的时候,必须被包含进来,这意味着类 Widget 的使⽤者必须要 #include <string>,<vector> 以及 gadget.h 。这些头⽂件将会增加类 Widget 使⽤者的编译时间,并且让这些 使⽤者依赖于这些头⽂件。

// 解决办法:在C++98中使⽤ Pimpl 惯⽤法,可以把 Widget 的数据成员替换成⼀个原始指针(raw pointer),指向⼀ 个已经被声明过却还未被定义的类,如下:
class Widget //仍然在“widget.h”中
{
public:
Widget();
~Widget(); //析构函数在后面会分析


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

// 将其改成c11风格
class Widget { //在“widget.h”中
public:
Widget();


private:
struct Impl;
std::unique_ptr<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() //根据条款21,通过std::make_unique
: pImpl(std::make_unique<Impl>()) //来创建std::unique_ptr
{}

// 以上的代码能编译,但是,最普通的 Widget ⽤法却会导致编译出错:
#include "widget.h" Wdiget w; //编译出错
// error提示: 会提到⼀些有关于把 sizeof 和 delete 应⽤到未完成类型 incomplete type 上的信息
// 在对象 w 被析构时,例如离开了作⽤域(scope),问题出现了。在这个时候,它的析构函数被调⽤。我们 在类的定义⾥使⽤了 std::unique_ptr ,所以我们没有声明⼀个析构函数,因为我们并没有任何代码需 要写在⾥⾯。根据编译器⾃动⽣成的特殊成员函数的规则(⻅ Item 17),编译器会⾃动为我们⽣成⼀个析 构函数。 在这个析构函数⾥,编译器会插⼊⼀些代码来调⽤类 Widget 的数据成员 Pimpl 的析构函数。 Pimpl 是⼀个 std::unique_ptr<Widget::Impl> ,也就是说,⼀个带有默认销毁器(default deleter)的 std::unique_ptr 。 默认销毁器(default deleter)是⼀个函数,它使⽤ delete 来销毁内置于 std::unique_ptr 的原始指针。然而,在使⽤ delete 之前,通常会使默认销毁器使⽤C++11的特性 static_assert 来确保原始指针指向的类型不是⼀个未完成类型。 当编译器为 Widget w 的析构⽣成代 码时,它会遇到 static_assert 检查并且失败,这通常是错误信息的来源。 这些错误信息只在对象 w 销毁的地⽅出现,因为类 Widget 的析构函数,正如其他的编译器⽣成的特殊成员函数⼀样,是暗含 inline 属性的。 错误信息⾃⾝往往指向对象 w 被创建的那⾏,因为这⾏代码明确地构造了这个对象, 导致了后⾯潜在的析构。
// 为了解决这个问题,你只需要确保在编译器⽣成销毁 std::unique_ptr<Widget::Imple> 的代码之 前, Widget::Impl 已经是⼀个完成类型(complete type)。 当编译器"看到"它的定义的时候,该类型就 成为完成类型了。 但是 Widget::Impl 的定义在 wideget.cpp ⾥。成功编译的关键,就是,在 widget.cpp ⽂件内,让编译器在"看到" Widget 的析构函数实现之前(也即编译器⾃动插⼊销毁 std::unique_ptr 的数据成员的位置),先定义 Wdiget::Impl 。
class Widget { //跟之前一样,在“widget.h”中
public:
Widget();
~Widget(); //只有声明语句


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

#include "widget.h" //跟之前一样,在“widget.cpp”中
#include "gadget.h"
#include <string>
#include <vector>

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

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

Widget::~Widget() //析构函数的定义(译者注:这里高亮)
{}

// 声明⼀个类 Widget 的析构函数会阻 ⽌编译器⽣成移动操作,所以如果你想要⽀持移动操作,你必须⾃⼰声明相关的函数。考虑到编译器⾃ 动⽣成的版本能够正常功能,你可能会被诱使着来这样实现:
class Widget { //仍然在“widget.h”中
public:
Widget();
~Widget();

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


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

// 这样的做法会导致同样的错误,和之前的声明⼀个不带析构函数的类的错误⼀样,并且是因为同样的原 因。 编译器⽣成的移动赋值操作符(move assignment operator),在重新赋值之前,需要先销毁指针 pImpl 指向的对象。然而在 Widget 的头⽂件⾥, pImpl 指针指向的是⼀个未完成类型。情况和移动构 造函数(move constructor)有所不同。 移动构造函数的问题是编译器⾃动⽣成的代码⾥,包含有抛出异 常的事件,在这个事件⾥会⽣成销毁 pImpl 的代码。然而,销毁 pImpl 需要 Impl 是⼀个完成类型。
class Widget { //仍然在“widget.h”中
public:
Widget();
~Widget();

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


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

#include <string> //跟之前一样,仍然在“widget.cpp”中


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

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

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

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

rem

  • pImpl 惯⽤法通过减少在类实现和类使⽤者之间的编译依赖来减少编译时间。
  • 对于 std::unique_ptr 类型的 pImpl 指针,需要在头⽂件的类⾥声明特殊的成员函数,但是在实 现⽂件⾥⾯来实现他们。即使是编译器⾃动⽣成的代码可以⼯作,也要这么做。
  • 以上的建议只适⽤于 std::unique_ptr ,不适⽤于 std::shared_ptr 。

—– chapter 5 —– 右值引用,移动语句和完美转发

在本章的这些小节中,⾮常重要的⼀点是要牢记参数(parameter)永远是左值(lValue),即使它的类型是 ⼀个右值引⽤。⽐如,假设

1
void f(Widget&& w);

item 23 理解std::move和std::forward

为了了解std::movestd::forward,一种有用的方式是从它们不做什么这个角度来了解它们。std::move不移动(move)任何东西,std::forward也不转发(forward)任何东西。在运行时,它们不做任何事情。它们不产生任何可执行代码,一字节也没有。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// 1 std::move的可能实现
template<typename T> //在std命名空间
typename remove_reference<T>::type&&
move(T&& param)
{
using ReturnType = //别名声明,见条款9
typename remove_reference<T>::type&&;

return static_cast<ReturnType>(param);
} // cpp11版本,因为无法推断返回类型
template<typename T>
decltype(auto) move(T&& param) //C++14,仍然在std命名空间
{
using ReturnType = remove_referece_t<T>&&;
return static_cast<ReturnType>(param);
} // c14版本
// 有⼀些提议说它的名字叫 rvalue_cast 可 能会更好。虽然可能确实是这样,但是它的名字已经是 std::move ,所以记住 std::move 做什么和不 做什么很重要。它其实并不移动任何东西。

class Annotation {
public:
explicit Annotation(const std::string text)
value(std::move(text)) //“移动”text到value里;这段代码执行起来
{ … } //并不是看起来那样

private:
std::string value;
};
class string { //std::string事实上是
public: //std::basic_string<char>的类型别名

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

};
// 在类`Annotation`的构造函数的成员初始化列表中,`std::move(text)`的结果是一个`const std::string`的右值。这个右值不能被传递给`std::string`的移动构造函数,因为移动构造函数只接受一个指向**non-`const`**的`std::string`的右值引用。然而,该右值却可以被传递给`std::string`的拷贝构造函数,因为lvalue-reference-to-`const`允许被绑定到一个`const`右值上。因此,`std::string`在成员初始化的过程中调用了**拷贝**构造函数,即使`text`已经被转换成了右值。这样是为了确保维持`const`属性的正确性。从一个对象中移动出某个值通常代表着修改该对象,所以语言不允许`const`对象被传递给可以修改他们的函数(例如移动构造函数)。
// 从这个例子中,可以总结出两点。第一,不要在你希望能移动对象的时候,声明他们为`const`。对`const`对象的移动请求会悄无声息的被转化为拷贝操作。第二点,`std::move`不仅不移动任何东西,而且它也不保证它执行转换的对象可以被移动。关于`std::move`,你能确保的唯一一件事就是将它应用到一个对象上,你能够得到一个右值。

// 关于 std::forward 的故事与 std::move 是相似的,但是与 std::move 总是⽆条件的将它的参数转换 为右值不同, std::forward 只有在满⾜⼀定条件的情况下才执⾏转换。std::forward是有条件的,下面是一个典型用法:
void process(const Widget& lvalArg); //处理左值
void process(Widget&& rvalArg); //处理右值

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

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

logAndProcess(w); //用左值调用
logAndProcess(std::move(w)); //用右值调用
// 函数 process 分别对左值和右值 参数做了重载。当我们使⽤左值来调⽤ logAndProcess 时,⾃然我们期望该左值被当作左值转发给 process 函数,而当我们使⽤右值来调⽤ logAndProcess 函数时,我们期望 process 函数的右值重载 版本被调⽤。
// 由于所有的参数都是左值,所以如果上面不用std::forward,将总是调用左值重载版本的函数,这就是为什么 std::forward 是⼀个有条件的转换:它只把由右值初 始化的参数,转换为右值。
// 考虑到 std::move 和 std::forward 都可以归结于转换,他们唯⼀的区别就是 std::move 总是执⾏转 换,而 std::forward 偶尔为之。你可能会问是否我们可以免于使⽤ std::move 而在任何地⽅只使⽤ std::forward 。 从纯技术的⻆度,答案是yes: std::forward 是可以完全胜任, std::move 并⾮必 须。当然,其实两者中没有哪⼀个函数是真的必须的,因为我们可以到处直接写转换代码,但是我希望 我们能同意:这将相当的,嗯,让⼈恶⼼。

// 统计移动构造函数被调用的次数
class Widget {
public:
Widget(Widget&& rhs)
: s(std::move(rhs.s))
{ ++moveCtorCalls; }

private:
static std::size_t moveCtorCalls;
std::string s;
}; // std::move version
class Widget{
public:
Widget(Widget&& rhs) //不自然,不合理的实现
: s(std::forward<std::string>(rhs.s))
{ ++moveCtorCalls; }

}; // std::forward version
// 为什么使用move的version: 1,根绝了传递错误了理性的可能,forward中传入rhs.s如果是string&类型的话,会导致s被复制而不是移动构造,2,std::move只需要更少的参数
// 更重要的是, std::move 的使⽤代表着⽆条件向右值的转换,而使⽤ std::forward 只对绑定了右值的 引⽤进⾏到右值转换。这是两种完全不同的动作。前者是典型地为了移动操作,而后者只是传递(亦作 转发)⼀个对象到另外⼀个函数,保留它原有的左值属性或右值属性。

rem

  • std::move 执⾏到右值的⽆条件的转换,但就⾃⾝而⾔,它不移动任何东西。
  • std::forward 只有当它的参数被绑定到⼀个右值时,才将参数转换为右值。
  • std::move 和 std::forward 在运⾏期什么也不做。

item 24 区分通用引用和右值已你用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// T&& 有两种不同的意思。第⼀种,当然是右值引⽤。这种引⽤表现得正如你所期待的那样: 它们 只绑定到右值上,并且它们主要的存在原因就是为了声明某个对象可以被移动。
// T&& 的第⼆层意思,是它既可以是⼀个右值引⽤,也可以是⼀个左值引⽤, 此外,它们还可以绑定 到常量(const)和⾮常量(non-const)的对象上,也可以绑定到 volatile 和 non-volatile 的对象上,甚 ⾄可以绑定到即 const ⼜ volatile 的对象上。它们可以绑定到⼏乎任何东西。这种空前灵活的引⽤值 得拥有⾃⼰的名字。我把它叫做通⽤引⽤(universal references)。
// 区分1: 有没有类型推导
// 没有type deduction
void f(Widget&& param); //右值引用
Widget&& var1 = Widget(); //右值引用

// 有type deduction
auto&& var2 = var1; //不是右值引用
template<typename T>
void f(T&& param); //不是右值引用
template<typename MyTemplateType> //param是通用引用
void someFunc(MyTemplateType&& param);

// 区分2:在通用引用的类型推导中,必须是标准的 T&& 的格式
// vector<T>&& param, const T&& param, param都不是通用的
template<typename T>
void f(std::vector<T>&& param); //右值引用
// 但 是参数 param 的类型声明并不是 T&& ,而是⼀个 std::vector<T>&& 。这排除了参数 param 是⼀个通⽤ 引⽤的可能性。 param 因此是⼀个右值引⽤——当你向函数 f 传递⼀个左值时,你的编译器将会开⼼地 帮你确认这⼀点:
std::vector<int> v;
f(v); //错误!不能将左值绑定到右值引用
// 一个const,也失去了通用引用的资格!
template <typename T>
void f(const T&& param); //param是一个右值引用

// 区分2的特例:由于在模板类内部无法保证类型推到发生,所有T&&也有可能不是通用引用
template<class T, class Allocator = allocator<T>> //来自C++标准
class vector
{
public:
void push_back(T&& x);

}
// 为何无法保证,因为这个vector在被实例化之前不可能存在,于是T必定是某种实例化好的类型,所以必然是一个右值引用,也就是:
std::vector<Widget> v; // 导致下面的特例化以后的类
class vector<Widget, allocator<Widget>> {
public:
void push_back(Widget&& x); //右值引用

};
// 然而,它的emplace_back却包含类型推到
template<class T, class Allocator = allocator<T>> //依旧来自C++标准
class vector {
public:
template <class... Args>
void emplace_back(Args&&... args);

};
// 类型参数(type parameter) Args 是独⽴于 vector 的类型参数之外的,所以 Args 会在每次 emplace_back 被调⽤的时候被推导(Okay, Args 实际上是⼀个参数包(parameter pack),而不是⼀个类 型参数

// 通用引用的实例:一个匿名函数记录所有函数的花费时间
auto timeFuncInvocation =
[](auto&& func, auto&&... params) //C++14
{
start timer;
std::forward<decltype(func)>(func)( //对params调用func
std::forward<delctype(params)>(params)...
);
stop timer and record elapsed time;
};
// 。 args 是0个或者多个 通⽤引⽤(也就是说,它是个通⽤引⽤参数包(a universal reference parameter pack)),它可以绑定 到任意数⽬、任意类型的对象上。

牢记整个本小节——通⽤引⽤的基础——是⼀个谎⾔,uhh,⼀个“抽象”。隐藏在其底下的真相被称为”引⽤ 折叠(reference collapsing)”,小节Item 28致⼒于讨论它。而且,通用引用,传入的如果是左右值,你将对应的获得该值的左右值引用

rem

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// 可以将万能引用变成左值右值重载版本,但是这将导致可扩展性差的问题
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(); //n是局部变量
w.setName(n); //把n移动进w!
//现在n的值未知
// 那左右值重载版本:
class Widget {
public:
void setName(const std::string& newName) //用const左值设置
{ name = newName; }
void setName(std::string&& newName) //用右值设置
{ name = std::move(newName); }

};
// 带来可扩展性差的问题:Widget::setName 接受⼀个参数,可以是左值或者右值,因此需要两种重载实现, n 个参数的话, 就要实现2^n种重载。这还不是最坏的。有的函数---函数模板----接受⽆限制参数,每个参数都可以是 左值或者右值。此类函数的例⼦⽐如 std::make_unique 或者 std::make_shared 。查看他们的的重载 声明:
template<class T, class... Args> //来自C++11标准
shared_ptr<T> make_shared(Args&&... args);
template<class T, class... Args> //来自C++14标准
unique_ptr<T> make_unique(Args&&... args);
// 对于这种函数,对于左值和右值分别重载就不能考虑了:通⽤引⽤是仅有的实现⽅案。对这种函数,我 向你保证,肯定使⽤ std::forward 传递通⽤引⽤给其他函数。

// 返回值使用右值可能的收益: 将拷贝构造变为移动构造
Matrix //同之前一样
operator+(Matrix&& lhs, const Matrix& rhs)
{
lhs += rhs;
return lhs; //拷贝lhs到返回值中
}
Matrix //按值返回
operator+(Matrix&& lhs, const Matrix& rhs)
{
lhs += rhs;
return std::move(lhs); //移动lhs到返回值中
}
// 事实上,lhs作为左值,会被编译器拷⻉到返回值的内存空间。假定Matrix⽀持移动操作,并且⽐拷⻉操 作效率更⾼,使⽤ std::move 的代码效率更⾼。 如果Matrix不⽀持移动操作,将其转换为左值不会变差,因为右值可以直接被Matrix的拷⻉构造器使 ⽤。如果Matrix随后⽀持了移动操作, + 操作符的定义将在下⼀次编译时受益。就是这种情况,通过将 std::move 应⽤到返回语句中,不会损失什么,还可能获得收益,但是注意,这是在不考虑编译器的RVO(return value optimization的情况下),因为RVO会在如下两个条件下执行:
// 1. 局部变量与返回值的类型相同;2. 局部变量就是返回值
举个例子:
Widget makeWidget() //makeWidget的“拷贝”版本
{
Widget w;

return w; //“拷贝”w到返回值中
} // 因为满足RVO条件,所以编译器自动优化,省去了拷贝,让代码看起来和下面一样
Widget makeWidget() //makeWidget的移动版本
{
Widget w;

return std::move(w); //移动w到返回值中(不要这样做!)
}
// 返回的已经不是局部对象w,而是局部对象w的引⽤。返回局部对象的引⽤不满⾜RVO的第⼆个条件,所 以编译器必须移动w到函数返回值的位置。开发者试图帮助编译器优化反而限制了编译器的优化选项。意思就是不必要这样写,在有优化选项的情况下,这样的写法多此一举

rem

  • 在右值引⽤上使⽤ std::move ,在通⽤引⽤上使⽤ std::forward
  • 对按值返回的函数返回值,⽆论返回右值引⽤还是通⽤引⽤,执⾏相同的操作
  • 当局部变量就是返回值是,不要使⽤ std::move 或者 std::forward

item 26 避免在通⽤引⽤上重载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
std::multiset<std::string> names;           //全局数据结构
void logAndAdd(const std::string& name)
{
auto now = //获取当前时间
std::chrono::system_clock::now();
log(now, "logAndAdd"); //志记信息
names.emplace(name); //把name加到全局数据结构中;
} //emplace的信息见条款42
std::string petName("Darla");
logAndAdd(petName); //传递左值std::string
logAndAdd(std::string("Persephone")); //传递右值std::string
logAndAdd("Patty Dog"); //传递字符串字面值
// 在第三个调⽤中,参数 name 绑定⼀个右值,但是这次是通过"Patty Dog"隐式创建的临时 std::string 变量。在第⼆个调⽤总, name 被拷⻉到 names ,但是这⾥,传递的是⼀个字符串字⾯量。直接将字符 串字⾯量传递给 emplace ,不会创建 std::string 的临时变量,而是直接在 std::multiset 中通过字 ⾯量构建 std::string 。在第三个调⽤中,我们会消耗 std::string 的拷⻉开销,但是连移动开销都 不想有,更别说拷⻉的,我们可以使用forward优化
template<typename T>
void logAndAdd(T&& name)
{
auto now = std::chrono::system_lock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}
std::string petName("Darla"); //跟之前一样
logAndAdd(petName); //跟之前一样,拷贝右值到multiset
logAndAdd(std::string("Persephone")); //移动右值而不是拷贝它
logAndAdd("Patty Dog"); //在multiset直接创建std::string
//而不是拷贝一个临时std::string
// 此时我们还要求接受int类型,那么重载?
void logAndAdd(int idx) //新的重载
{
auto now = std::chrono::system_lock::now();
log(now, "logAndAdd");
names.emplace(nameFromIdx(idx));
}
short nameIdx;
//给nameIdx一个值
logAndAdd(nameIdx); //错误!
// 根据正常的重载解决规则,精确匹配优 先于类型提升的匹配,所以被调⽤的是通⽤引⽤的重载,那么将short类型的通用引用完美转发到了multiset<string>的emplace里面,失败!所有的原因就是优先精确匹配,之后在类型提升匹配。
// 使⽤通⽤引⽤类型的函数在C++中是贪婪函数。他们机会可以精确匹配任何类型的参数(极少不适⽤的 类型在Item 30中介绍)。这也是组合重载和通⽤引⽤使⽤是糟糕主意的原因:通⽤引⽤的实现会匹配⽐ 开发者预期要多得多的参数类型。

// 如果拷⻉和移动构造被⽣成,Person类看起如下:
class Person {
public:
template<typename T> //完美转发的构造函数
explicit Person(T&& n)
: name(std::forward<T>(n)) {}

explicit Person(int idx); //int的构造函数

Person(const Person& rhs); //拷贝构造函数(编译器生成)
Person(Person&& rhs); //移动构造函数(编译器生成)

private:
std::string name;
};

Person p("Nancy");
auto cloneOfP(p); //从p创建新Person;这通不过编译!

// “为什么?”你可能会疑问,“为什么拷⻉构造会被完美转发构造替代?我们显然想拷⻉Person到另⼀个Person”。确实我们是这样想的,但是编译器严格遵循C++的规则,这⾥的相关规则就是控制对重载函数 调⽤的解析规则。 编译器的理由如下: cloneOfP 被 non-const 左值p初始化,这意味着可以实例化模板构造函数为采⽤ Person 的 non-const 左值。实例化之后, Person 类看起来是这样的:
class Person {
public:
explicit Person(Person& n) //由完美转发模板初始化
: name(std::forward<Person&>(n)) {}

explicit Person(int idx); //同之前一样

Person(const Person& rhs); //拷贝构造函数(编译器生成的)

};
auto cloneOfP(p);
// 那么这句,其中`p`被传递给拷贝构造函数或者完美转发构造函数。调用拷贝构造函数要求在`p`前加上`const`的约束来满足函数形参的类型,而调用完美转发构造不需要加这些东西。从模板产生的重载函数是更好的匹配,所以编译器按照规则:调用最佳匹配的函数。“拷贝”non-`const`左值类型的`Person`交由完美转发构造函数处理,而不是拷贝构造函数。然后我们会发现,尝试将n这个person类型初始化给了一个string类型的name,必然报错,倘若我们如下操作:
const Person cp("Nancy"); //现在对象是const的
auto cloneOfP(cp); //调用拷贝构造函数!
// 根据编译器精确匹配原则,我们会优先调用拷贝构造函数

rem

  • 对通⽤引⽤参数的函数进⾏重载,调⽤机会会⽐你期望的多得多
  • 完美转发构造函数是糟糕的实现,因为对于 non-const 左值不会调⽤拷⻉构造而是完美转发构造, 而且会劫持派⽣类对于基类的拷⻉和移动构造

item 27 熟悉通⽤引⽤重载的替代⽅法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
// 0 先给出三种替换重载的方式:
// 0.1 Abandon overloading(放弃重载)例如两个重载的 logAndAdd 函数,可以分别改名为 logAndAddName 和 logAndAddNameIdx 。但是,这种⽅式不能⽤在第⼆个例⼦,Person构造函数中,因为构造函数的名字 本类名固定了。此外谁愿意放弃重载呢?
// 0.2 Pass by const T&(按照const T& 传参(导致通用应用推断失败)) ⼀种替代⽅案是退回到C++98,然后将通⽤引⽤替换为const的左值引⽤。事实上,这是Item 26中⾸先 考虑的⽅法。缺点是效率不⾼,会有拷⻉的开销。现在我们知道了通⽤引⽤和重载的组合会导致问题, 所以放弃⼀些效率来确保⾏为正确简单可能也是⼀种不错的折中。
// 0.3 Pass by value(按照值传递) 详细见item41


// 1 使用tag dispatch(标签分发)
std::multiset<std::string> names; //全局数据结构

template<typename T> //志记信息,将name添加到数据结构
void logAndAdd(T&& name)
{
auto now = std::chrono::system_clokc::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}
// 如果引⼊⼀个 int 类型的重载,就会重新陷⼊Item 26中描述的 ⿇烦。这个Item的⽬标是避免它。
template<typename T>
void logAndAdd(T&& name)
{
logAndAddImpl(
std::forward<T>(name),
std::is_instegral<typename std::remove_reference<T>::type>()
);
}
// 然后对type这个tag进行两种实现:通过tag来实现重载实现函数的“分发”
template<typename T> //非整型实参:添加到全局数据结构中
void logAndAddImpl(T&& name, std::false_type) //译者注:高亮std::false_type
{
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}
std::string nameFromIdx(int idx); //与条款26一样,整型实参:查找名字并用它调用logAndAdd
void logAndAddImpl(int idx, std::true_type) //译者注:高亮std::true_type
{
logAndAdd(nameFromIdx(idx));
}
// 那么使用tag dispatch最大的有点:tag dispatch的关键是存在单独⼀个函数(没有重载)给客⼾端API。

// 2 约束 使⽤通⽤引⽤的模板
// 实际上,真正的问题不是编译器生成的函数会绕过*tag diapatch*设计,而是不**总**会绕过去。你希望类的拷贝构造函数总是处理该类型的左值拷贝请求,但是如同item26中所述,提供具有通用引用的构造函数,会使通用引用构造函数在拷贝non-`const`左值时被调用(而不是拷贝构造函数)。那个条款还说明了当一个基类声明了完美转发构造函数,派生类实现自己的拷贝和移动构造函数时会调用那个完美转发构造函数,尽管正确的行为是调用基类的拷贝或者移动构造。
// std::enable_if 可以给你提供⼀种强制编译器执⾏⾏为的⽅法,即使特定模板不存在。这种模板也会 被禁⽌。默认情况下,所有模板是启⽤的,但是使⽤ std::enable_if 可以使得仅在条件满⾜时模板才 启⽤。在这个例⼦中,我们只在传递的参数类型不是 Person 使⽤ Person 的完美转发构造函数。如果传 递的参数是 Person ,我们要禁⽌完美转发构造函数(即让编译器忽略它),因此就是拷⻉或者移动构 造函数处理,这就是我们想要使⽤ Person 初始化另⼀个 Person 的初衷。
// 我很遗憾的表⽰你要⾃⾏查询语法含义,因为详细解释需要花费⼀定空 间和时间,而本书并没有⾜够的空间(在你⾃⾏学习过程中,请研究"SFINAE"[https://en.cppreference.com/w/cpp/language/sfinae]以及 std::enable_if , 因为“SFINAE”就是使 std::enable_if 起作⽤的技术)。
// condition: 想表⽰的条件是确认T不是 Person 类型
class Person {
public:
template<typename T,
typename = typename std::enable_if<condition>::type> //译者注:本行高亮,condition为某其他特定条件
explicit Person(T&& n);

};
// 这⾥我们想表⽰的条件是确认T不是 Person 类型,即模板构造函数应该在T不是 Person 类型的时候启 ⽤。因为type trait可以确定两个对象类型是否相同( std::is_same ),看起来我们需要的就 是 !std::is_same<Person, T>::value
Person p("Nancy");
auto cloneOfP(p); // initialize from lvalue
//T的类型在通⽤引⽤的构造函数中被推导为 Person&,我们的比较为: std::is_same<Person, Person&>::value 会是 false 。如果我们更精细考虑仅当T不是 Person 类型才启⽤模板构造函数,我们会意识到当我们查看T时,应该 忽略:
// - **是否是个引用**。对于决定是否通用引用构造函数启用的目的来说,`Person`,`Person&`,`Person&&`都是跟`Person`一样的。
// - **是不是`const`或者`volatile`**。如上所述,`const Person`,`volatile Person` ,`const volatile Person`也是跟`Person`一样的。
// 这意味着我们需要一种方法消除对于`T`的引用,`const`,`volatile`修饰。再次,标准库提供了这样功能的*type trait*,就是`std::decay`。`std::decay<T>::value`与`T`是相同的,只不过会移除引用和cv限定符(*cv-qualifiers*,即`const`或`volatile`标识符)的修饰。
// 那么最终代码变成:
class Person {
public:
template<
typename T,
typename = typename std::enable_if<
!std::is_same<
Person,
typename std::decay<T>::type
>::value
>::type
>
explicit Person(T&& n);

};
// 在上⾯的声明中,使 ⽤ Person 初始化⼀个 Person ----⽆论是左值还是右值, const 还是 volatile 都不会调⽤到通⽤引⽤ 构造函数。

// 假定从 Person 派⽣的类以常规⽅式实现拷⻉和移动操作:
class SpecialPerson: public Person {
public:
SpecialPerson(const SpecialPerson& rhs) //拷贝构造函数,调用基类的
: Person(rhs) //完美转发构造函数!
{ … }

SpecialPerson(SpecialPerson&& rhs) //移动构造函数,调用基类的
: Person(std::move(rhs)) //完美转发构造函数!
{ … }


};
// 因为 SpecialPerson 和 Person 类型不同,所以完美转发构造函数是启⽤的,会实例化为精确匹 配的构造函数。⽣成的精确匹配的构造函数之于重载规则⽐基类的拷⻉或者移动构造函数更优,所以这 ⾥的代码,拷⻉或者移动 SpecialPerson 对象就会调⽤ Person 类的完美转发构造函数来执⾏基类的部 分。跟Item 26的困境⼀样。
// 但是实际上我们在拷贝时只想调用基类的拷贝,移动的时候只想调用基类的移动。
// 现在我们意识到不只是禁⽌ Person 类 型启⽤模板构造器,而是禁⽌ Person 以及任何派⽣⾃ Person 的类型启⽤模板构造器。讨厌的继承!
// 你应该不意外在这⾥看到标准库中也有type trait判断⼀个类型是否继承⾃另⼀个类型,就是 std::is_base_of 。
// 所以使⽤ std::is_base_of 代替 std::is_same 就可 以了:(相比于之前c++11的写法)
class Person { //C++14
public:
template<
typename T,
typename = std::enable_if_t< //这儿更少的代码
!std::is_base_of<Person,
std::decay_t<T> //还有这儿
>::value
> //还有这儿
>
explicit Person(T&& n);

};

// 我们已经知道如何使⽤ std::enable_if 来选择性禁⽌ Person 通⽤引⽤构造器来使得⼀些参数确保使 ⽤到拷⻉或者移动构造器,但是我们还是不知道将其应⽤于区分整型参数和⾮整型参数。毕竟,我们的 原始⽬标是解决构造函数模糊性问题。
// (1)加入一个`Person`构造函数重载来处理整型参数;
// (2)约束模板构造函数使其对于某些实参禁用。使用这些我们讨论过的技术组合起来,就能解决这个问题了:

class Person {
public:
template<
typename T,
typename = std::enable_if_t<
!std::is_base_of<Person, std::decay_t<T>>::value
&&
!std::is_integral<std::remove_reference_t<T>>::value
>
>
explicit Person(T&& n) //对于std::strings和可转化为
: name(std::forward<T>(n)) //std::strings的实参的构造函数
{ … }

// 上面的限定保证了:
// 当T不是base或者base的派生类并且T不是整形的时候,才会使用通用引用的构造函数
explicit Person(int idx) //对于整型实参的构造函数
: name(nameFromIdx(idx))
{ … }

//拷贝、移动构造函数等

private:
std::string name;
};
// 因为使⽤了完美转发,所以具有最⼤效率,因为控制了使⽤通⽤引⽤的范围,可 以避免对于⼤多数参数能实例化精确匹配的滥⽤问题(指都去用通用引用的构造函数了而不是那些拷贝构造和移动构造)。

// 3 tarde-off:
// 通常,完美转发更有效率,因为它避免了仅处于符合参数类型而创建临时对象。在 Person 构造函数的 例⼦中,完美转发允许将 Nancy 这种字符串字⾯量转发到容器内部的 std::string 构造器,不使⽤完美 转发的技术则会创建⼀个临时对象来满⾜传⼊的参数类型。
// 但是完美转发也有缺点。·即使某些类型的参数可以传递给特定类型的参数的函数,也⽆法完美转发。Item 30中探索了这⽅⾯的例⼦。 第⼆个问题是当client传递⽆效参数时错误消息的可理解性。例如假如创建⼀个 Person 对象的client传 递了⼀个由 char16_t (⼀种C++11引⼊的类型表⽰16位字符)而不是 char ( std::string 包含 的):
Person p(u"Konrad Zuse"); // "Konrad Zuse" consists of characters of type const char16_t//
// 本Item中讨论的前三种⽅法(放弃重载,使用const T&,直接传值),编译器将看到可⽤的采⽤ int 或者 std::string 的构造函数,并且 它们或多或少会产⽣错误消息,表⽰没有可以从 const char16_t 转换为 int 或者 std::string 的⽅ 法。
// 但是,基于完美转发的⽅法, const char16_t 不受约束地绑定到构造函数的参数。从那⾥将转发到 Person 的 std::string 的构造函数,在这⾥,调⽤者传⼊的内容( const char16_t 数组)与所需内容 ( std::string 构造器可接受的类型)发⽣的不匹配会被发现。由此产⽣的错误消息会让⼈更容易理解, 在我使⽤的编译器上,会产⽣超过160⾏错误信息。(单单这个问题就可以阻挠很多开发者在性能接口上不用通用引用)
// 在 Person 这个例⼦中,我们知道转发函数的通⽤引⽤参数要⽀持 std::string 的初始化,所以我们可 以⽤ static_assert 来确认是不是⽀持。 std::is_constructible type trait执⾏编译时测试⼀个类 型的对象是否可以构造另⼀个不同类型的对象,所以代码可以这样:
class Person {
public:
template< //同之前一样
typename T,
typename = std::enable_if_t<
!std::is_base_of<Person, std::decay_t<T>>::value
&&
!std::is_integral<std::remove_reference_t<T>>::value
>
>
explicit Person(T&& n)
: name(std::forward<T>(n))
{
//断言可以用T对象创建std::string
static_assert(
std::is_constructible<std::string, T>::value,
"Parameter n can't be used to construct a std::string"
);

//通常的构造函数的工作写在这

}

//Person类的其他东西(同之前一样)
};
// 如果client代码尝试使⽤⽆法构造 std::string 的类型创建 Person ,会导致指定的错误消息。不幸的 是,在这个例⼦中, static_assert 在构造函数体中,但是作为成员初始化列表的部分在检查之前。所 以我使⽤的编译器,结果是由 static_assert 产⽣的清晰的错误消息在常规错误消息(最多160⾏以上 那个)后出现。
// 其实就是报的错误太多了,以至于都不知道是啥错

rem

  • 通⽤引⽤和重载的组合替代⽅案包括使⽤不同的函数名,通过const左值引⽤传参,按值传递参 数,使⽤tag dispatch
  • 通过 std::enable_if 约束模板,允许组合通⽤引⽤和重载使⽤, std::enable_if 可以控制编译 器哪种条件才使⽤通⽤引⽤的实例
  • 通⽤引⽤参数通常具有⾼效率的优势,但是可⽤性就值得斟酌

item 28 理解引⽤折叠

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
// 引用的引用是不允许的
int x;
auto& & rx = x; //error! can't declare reference to reference

// 对通用应用传递左值
template<typename T>
void func(T&& param); //同之前一样
func(w); //用左值调用func;T被推导为Widget&
// 我们会得到:
void func(Widget& && param);
// 引用的引用!但是编译器没有报错。因为通用引用`param`被传入一个左值,所以`param`的类型应该为左值引用,但是编译器如何把`T`推导的类型带入模板变成如下的结果,也就是最终的函数签名?
void func(Widget& param);
// 答案是引⽤折叠。是的,禁⽌你声明引⽤的引⽤,但是编译器会在特定的上下⽂中使⽤,包括模板实例 的例⼦。当编译器⽣成引⽤的引⽤时,引⽤折叠指导下⼀步发⽣什么。存在两种类型的引⽤(左值和右值),所以有四种可能的引⽤组合(左值的左值,左值的右值,右值的 右值,右值的左值)。如果⼀个上下⽂中允许引⽤的引⽤存在(⽐如,模板函数的实例化),引⽤根据 规则折叠为单个引⽤就能够解释清除
// 如果任⼀引⽤为左值引⽤,则结果为左值引⽤。否则(即,如果引⽤都是右值引⽤),结果为右值 引⽤


// 引⽤折叠是 std::forward ⼯作的⼀种关键机制。就像Item25中解释的⼀样, std::forward 应⽤在通 ⽤引⽤参数上,所以经常能看到这样使⽤:
template<typename T>
void f(T&& fParam)
{
//做些工作
someFunc(std::forward<T>(fParam)); //转发fParam到someFunc
}
// 因为fParam是通⽤引⽤,我们知道参数T的类型将在传⼊具体参数时被编码。 std::forward 的作⽤是 当传⼊参数为右值时,即T为⾮引⽤类型,才将fParam(左值)转化为⼀个右值。
template<typename T> //在std命名空间
T&& forward(typename
remove_reference<T>::type& param)
{
return static_cast<T&&>(param);
}
// 这不是标准库版本的实现(忽略了⼀些接口描述),但是为了理解 std::forward 的⾏为,这些差异⽆ 关紧要。


// 看一个传入非右值具体的例子:假设传入到`f`的实参是`Widget`的左值类型。`T`被推导为`Widget&`,然后调用`std::forward`将实例化为`std::forward<Widget&>`。`Widget&`带入到上面的`std::forward`的实现中:
Widget& && forward(typename
remove_reference<Widget&>::type& param)
{ return static_cast<Widget& &&>(param); }
// `std::remove_reference<Widget&>::type`这个*type trait*产生`Widget`也就是
Widget& && forward(Widget& param)
{ return static_cast<Widget& &&>(param); }
// 根据引用折叠规则,返回值和强制转换可以化简,最终版本的`std::forward`调用就是: 也就是
Widget& forward(Widget& param)
{ return static_cast<Widget&>(param); }
// 正如你所看到的,当左值被传⼊到函数模板f时, std::forward 转发和返回的都是左值引⽤。内部的转 换不做任何事,因为param的类型已经是 Widget& ,所以转换没有影响。左值传⼊会返回左值引⽤。通 过定义,左值引⽤就是左值,因此将左值传递给 std::forward 会返回左值,就像说的那样,完美转 发。

// 现在假设⼀下,传递给f的是⼀个 Widget 的右值。在这个例⼦中,T的类型推导就是Widget。内部的 std::forward 因此转发 std::forward<Widget> ,带⼊回 std::forward 实现中:
Widget&& forward(typename
remove_reference<Widget>::type& param)
{ return static_cast<Widget&&>(param); }
// 将 remove_reference 引⽤到⾮引⽤的类型上还是相同的类型,所以化简如下
Widget&& forward(Widget& param)
{ return static_cast<Widget&&>(param); }
// 这⾥没有引⽤的引⽤,所以不需要引⽤折叠,这就是最终版本。 从函数返回的右值引⽤被定义为右值,因此在这种情况下, std::forward 会将f的参数fParam(左 值)转换为右值。最终结果是,传递给f的右值参数将作为右值转发给someFunc,完美转发。
template<typename T> //C++14;仍然在std命名空间
T&& forward(remove_reference_t<T>& param)
{
return static_cast<T&&>(param);
}


// 具体看引用折叠的4个发生情况:
// case1
Widget widgetFactory(); //返回右值的函数
Widget w; //一个变量(左值)
func(w); //用左值调用func;T被推导为Widget&
func(widgetFactory()); //用又值调用func;T被推导为Widget
auto&& w1 = w;
// case2: 在auto的写法中,规则是类似的: auto&& w1 = w; 初始化 w1 为⼀个左值,因此为auto推导出类型 Widget& 。带回去就是 Widget& && w1 = w ,应⽤引⽤折叠规则,就是 Widget& w1 = w ,结果就是 w1 是⼀个左值引⽤。
auto&& w2 = widgetFactory();
// 另⼀⽅⾯, auto&& w2 = widgetFactory(); 使⽤右值初始化 w2 ,⾮引⽤带回 Widget&& w2 = widgetFactory() 。没有引⽤的引⽤,这就是最终结果。

// 现在我们真正理解了Item24中引⼊的通⽤引⽤。通⽤引⽤不是⼀种新的引⽤,它实际上是满⾜两个条件 下的右值引⽤:
// 1. 通过类型推导将左值和右值区分。T类型的左值被推导为&类型,T类型的右值被推导为T
// 2. 引⽤折叠的发⽣
// 通⽤引⽤的概念是有⽤的,因为它使你不必⼀定意识到引⽤折叠的存在,从直觉上判断左值和右值的推 导即可。

// 上面我们讨论了模板实例化和auto推断带来的2种引用折叠的发生情况:
// case3: 是使⽤typedef和别名声明
template<typename T>
class Widget {
public:
typedef T&& RvalueRefToT;

};
// 假设我们使⽤左值引⽤实例化Widget:Widget<int&> w;
typedef int& && RvalueRefToT;
// 引⽤折叠就会发挥作⽤:
typedef int& RvalueRefToT;

// case4: 最后,也是第四种情况是,decltype使⽤的情况,如果在分析decltype期间,出现了引⽤的引⽤,引⽤ 折叠规则就会起作⽤(关于decltype,参⻅Item3)

rem

  • 引⽤折叠发⽣在四种情况:模板实例化;auto类型推导;typedef的创建和别名声明;decltype
  • 当编译器⽣成了引⽤的引⽤时,结果通过引⽤折叠就是单个引⽤。有左值引⽤就是左值引⽤,否则 就是右值引⽤
  • 通⽤引⽤就是通过类型推导区分左值还是右值,并且引⽤折叠出现的右值引⽤

item 29 理智看待std::move Assume that move operations are not present, not cheap, and not used.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 考虑⼀下 std::array ,这是C++11中的新容器。 std::array 本质上是具有STL接口的内置数组。这与 其他标准容器将内容存储在堆内存不同。存储具体数据在堆内存的容器,本⾝只保存了只想堆内存数据 的指针(真正实现当然更复杂⼀些,但是基本逻辑就是这样)。这种实现使得在常数时间移动整个容器 成为可能的,只需要拷⻉容器中保存的指针到⽬标容器,然后将原容器的指针置为空指针就可以了。
std::vector<Widget> vm1;
auto vm2 = std::move(vm1); //把vw1移动到vw2。以常数时间运行。只有vw1和vw2中的指针被改变
// std::array 没有这种指针实现,数据就保存在 std::array 容器中
std::array<Widget, 10000> aw1;
auto aw2 = std::move(aw1); // move aw1 into aw2. Runs in linear time. All elements in aw1 are moved into aw2.
// 注意 aw1 中的元素被移动到了 aw2 中,这⾥假定 Widget 类的移动操作⽐复制操作快。但是使⽤ std::array 的移动操作还是复制操作都将花费线性时间的开销,因为每个容器中的元素终归需要拷⻉ ⼀次,这与“移动⼀个容器就像操作⼏个指针⼀样⽅便”的含义想去甚远。

// 另⼀⽅⾯, std::string 提供了常数时间的移动操作和线性时间的复制操作。这听起来移动⽐复制快多 了,但是可能不⼀定。许多字符串的实现采⽤了small string optimization(SSO)。"small"字符串(⽐如⻓ 度小于15个字符的)存储在了 std::string 的缓冲区中,并没有存储在堆内存,移动这种存储的字符串 并不必复制操作更快。SSO的动机是⼤量证据表明,短字符串是⼤量应⽤使⽤的习惯。使⽤内存缓冲区存储而不分配堆内存空 间,是为了更好的效率。然而这种内存管理的效率导致移动的效率并不必复制操作⾼。

// 因此,存在⼏种情况,C++11的移动语义并⽆优势:
// 1. No move operations:类没有提供移动操作,所以移动的写法也会变成复制操作
// 2. Move not faster:类提供的移动操作并不必复制效率更⾼
// 3. Move not usable:进⾏移动的上下⽂要求移动操作不会抛出异常,但是该操作没有被声明为 noexcept 值得⼀提的是,还有另⼀个场景,会使得移动并没有那么有效率:
// 4. Source object is lvalue:除了极少数的情况外(例如 Item25),只有右值可以作为移动操作的 来源
// 但是该Item的标题是假定不存在移动操作,或者开销不小,不使⽤移动操作。存在典型的场景,就是编 写模板代码,因为你不清楚你处理的具体类型是什么。在这种情况下,你必须像出现移动语义之前那 样,保守地考虑复制操作。不稳定的代码也是如此,类的特性经常被修改导致可能移动操作会有问题。


// 但是,通常,你了解你代码⾥使⽤的类,并且知道是否⽀持快速移动操作。这种情况,你⽆需这个Item的假设,只需要查找所⽤类的移动操作详细信息,并且调⽤移动操作的上下⽂中,可以安全的使⽤快速 移动操作替换复制操作。

rem

  • Assume that move operations are not present, not cheap, and not used.
  • 完全了解的代码可以忽略本Item

item 30 熟悉完美转发的失败case

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
// 完美转发意味着我们不仅转发对象,我们还转发显著的特征:它们的类型,是左值还是右值,是const还 是volatile。结合到我们会处理引⽤参数,这意味着我们将使⽤通⽤引⽤(参⻅Item24),因为通⽤引⽤ 参数被传⼊参数时才确定是左值还是右值。

template<typename... Ts>
void fwd(Ts&&... params) //接受任何实参
{
f(std::forward<Ts>(params)...); //转发给f
}
f( expression ); //如果这个做某件事,
fwd( expression ); //但是这个做另外的某件事,fwd完美转发expression给f会失败


// 以下是几种具体的失败案例:
// case1: 大括号初始化器
void f(const std::vector<int>& v);
f({1,2,3}); // fine "{1,2,3}" implicitly converted to std::vector<int>
fwd({1,2,3}); // error! doesn't compile
// 在对f的直接调⽤(例如f({1,2,3})),编译器看到传⼊的参数是声明中的类 型。如果类型不匹配,就会执⾏隐式转换操作使得调⽤成功。在上⾯的例⼦中,从 {1,2,3} ⽣成了临时 变量 std::vector<int> 对象,因此f的参数会绑定到 std::vector<int> 对象上。
// 当通过调⽤函数模板fwd调⽤f时,编译器不再⽐较传⼊给fwd的参数和f的声明中参数的类型。代替的 是,推导传⼊给fwd的参数类型,然后⽐较推导后的参数类型和f的声明类型。当下⾯情况任何⼀个发⽣ 时,完美转发就会失败:
// 1. 编译器不能推导出⼀个或者多个fwd的参数类型,编译器就会报错
// 2. 编译器将⼀个或者多个fwd的参数类型推导错误。在这⾥,“错误”可能意味着fwd将⽆法使⽤推导出 的类型进⾏编译,但是也可能意味着调⽤者f使⽤fwd的推导类型对⽐直接传⼊参数类型表现出不一致的⾏为。这种不同⾏为的原因可能是因为f的函数重载定义,并且由于是“不正确的”类型推导,在fwd内部调⽤f和直接调⽤f将重载不同的函数。

// 在上⾯的 f({1,2,3}) 例⼦中,问题在于,如标准所⾔,将括号初始化器传递给未声明为 std::initializer_list 的函数模板参数,该标准规定为“⾮推导上下⽂”。简单来讲,这意味着编译器 在对fwd的调⽤中推导表达式 {1,2,3} 的类型,因为fwd的参数没有声明为 std::initializer_list 。 对于fwd参数的推导类型被阻⽌,编译器只能拒绝该调⽤。
// 有趣的是,Item2 说明了使⽤braced initializer的auto的变量初始化的类型推导是成功的。这种变量被 视为 std::initializer_list 对象,在转发函数应推导为 std::initializer_list 类型的情况,这 提供了⼀种简单的解决⽅法----使⽤auto声明⼀个局部变量,然后将局部变量转发:
auto il = {1,2,3}; // il's type deduced to be std::initializer_list<int>
fwd(il); // fine, perfect-forwards il to f


// case2: 0或者NULL作为空指针
// Item8说明当你试图传递0或者NULL作为空指针给模板时,类型推导会出错,推导为⼀个整数类型而不 是指针类型。结果就是不管是0还是NULL都不能被完美转发为空指针。解决⽅法⾮常简单,使⽤nullptr就可以了,具体的细节,参考Item 8.

// case3: 仅声明的整数静态const数据成员
// 通常,⽆需在类中定义整数静态const数据成员;声明就可以了。 That’s because compilers perform const propaga‐tion 对那些整数静态成员执行了“常量 传播”。
class Widget {
public:
static const std::size_t MinVals = 28; //MinVal的声明

};
//没有MinVals定义

std::vector<int> widgetData;
widgetData.reserve(Widget::MinVals); //使用MinVals
// 这⾥,我们使⽤ Widget::MinVals (或者简单点MinVals)来确定 widgetData 的初始容量,即使 MinVals 缺少定义。编译器通过将值28放⼊所有位置来补充缺少的定义。没有为 MinVals 的值留存储 空间是没有问题的。如果要使⽤ MinVals 的地址(例如,有⼈创建了 MinVals 的指针),则 MinVals 需要存储(因为指针总要有⼀个地址),尽管上⾯的代码仍然可以编译,但是链接时就会报错,直到为 MinVals 提供定义。
// 按照这个思路,想象下f(转发参数给fwd的函数)这样声明:
void f(std::size_t val);
f(Widget::MinVals); // fine, treated as "28"
fwd(Widget::MinVals); // error! shouldn't link
// 代码可以编译,但是不能链接。就像使⽤ MinVals 地址表现⼀样,确实,底层的问题是⼀样的。尽管代码中没有使⽤ MinVals 的地址,但是fwd的参数是通⽤引⽤,而引⽤,在编译器⽣成的代码中, 通常被视作指针。在程序的⼆进制底层代码中指针和引⽤是⼀样的。在这个⽔平下,引⽤只是可以⾃动 取消引⽤的指针。在这种情况下,通过引⽤传递 MinVals 实际上与通过指针传递 MinVals 是⼀样的,因 此,必须有内存使得指针可以指向。通过引⽤传递整型static const数据成员,必须定义它们,这个要求 可能会造成完美转发失败,即使等效不使⽤完美转发的代码成功。
// pointer vs reference: reference为变量的别名,然后不能改变它所指向的内容,理解成一个const ptr即可
// 确实,根据标准,通过引⽤传递 MinVals 要求有定义。但不是所有的实现都强制要求这⼀点。所以,取 决于你的编译器和链接器,为了具有可移植性,只要给整型static const提供⼀个定义,⽐如这样:
const std::size_t Widget::MinVals; // in Widget's .cpp file
// 注意定义中不要重复初始化(这个例⼦中就是赋值28)。不要忽略这个细节,否则,编译器就会报错, 提醒你只初始化⼀次。

// case4: 重载的函数名称和模板名称
void f(int (*pf)(int)); // pf = "process function"
void f(int pf(int)); // declares same f as above
int processVal(int value); int processVal(int value, int priority);
f(processVal); // fine
// 但是有⼀点要注意,f要求⼀个函数指针,但是 processVal 不是⼀个函数指针或者⼀个函数,它是两个 同名的函数。但是,编译器可以知道它需要哪个:通过参数类型和数量来匹配。因此选择了⼀个int参数 的 processVal 地址传递给f,⼯作的基本机制是让编译器帮选择f的声明选择⼀个需要的 processVal 。但是,fwd是⼀个函数模板, 没有需要的类型信息,使得编译器不可能帮助⾃动匹配⼀个合适的函数:
fwd(processVal); // error! which processVal?
// processVal 没有类型信息,就不能类型推导,完美转发失败。
// 同样的问题会发⽣在如果我们试图使⽤函数模板代替重载的函数名。⼀个函数模板是未实例化的函数, 表⽰⼀个函数族:
template<typename T> T workOnVal(T param) { ... } // template for processing values
fwd(workOnVal); // error! which workOnVal instantiation ?
// 那么正确的用例有:
using ProcessFuncType = //写个类型定义;见条款9
int (*)(int);
ProcessFuncType processValPtr = processVal; //指定所需的processVal签名
fwd(processValPtr); //可以
fwd(static_cast<ProcessFuncType>(workOnVal)); //也可以


// case5: 位域
// 完美转发最后⼀种失败的情况是函数参数使⽤位域这种类型。为了更直观的解释,IPv4的头部可以如下 定义:
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); //可以
fwd(h.totalLength); //错误!
// 问题在于fwd的参数是引⽤,而h.totalLength是⾮常量位域。听起来并不是那么糟糕,但是C++标准⾮ 常清楚地谴责了这种组合:⾮常量引⽤不应该绑定到位域。禁⽌的理由很充分。位域可能包含了机器字 节的任意部分(⽐如32位int的3-5位),但是⽆法直接定位。我之前提到了在硬件层⾯引⽤和指针时⼀ 样的,所以没有办法创建⼀个指向任意bit的指针(C++规定你可以指向的最小单位是char),所以就没 有办法绑定引⽤到任意bit上。
// ⼀旦意识到接收位域作为参数的函数都将接收位域的副本,就可以轻松解决位域不能完美转发的问题。 毕竟,没有函数可以绑定引⽤到位域,也没有函数可以接受指向位域的指针(不存在这种指针)。这种 位域类型的参数只能按值传递,或者有趣的事,常量引⽤也可以。在按值传递时,被调⽤的函数接受了 ⼀个位域的副本,而且事实表明,位域的常量引⽤也是将其“复制”到普通对象再传递。传递位域给完美转发的关键就是利⽤接收参数函数接受的是⼀个副本的事实。你可以⾃⼰创建副本然后 利⽤副本调⽤完美转发。在IPv4Header的例⼦中,可以如下写法:
// copy bitfield value; see Item6 for info on init. form
auto length = static_cast<std::uint16_t>(h.totalLength);
fwd(length); // forward the copy

rem

  • 完美转发会失败当模板类型推导失败或者推导类型错误
  • 导致完美转发失败的类型有
    • braced initializers,
    • 作为空指针的0或者NULL,
    • 只声明的整型static const数据成员,
    • 模板和重载的函数名
    • 位域

—— chapter 6 —— lambda 表达式

  • 闭包enclosure)是lambda创建的运行时对象。依赖捕获模式,闭包持有被捕获数据的副本或者引用。在上面的std::find_if调用中,闭包是作为第三个实参在运行时传递给std::find_if的对象。

  • 闭包类closure class)是从中实例化闭包的类。每个lambda都会使编译器生成唯一的闭包类。lambda中的语句成为其闭包类的成员函数中的可执行指令。

lambda通常被用来创建闭包,该闭包仅用作函数的实参。上面对std::find_if的调用就是这种情况。然而,闭包通常可以拷贝,所以可能有多个闭包对应于一个lambda。比如下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
{
int x; //x是局部对象


auto c1 = //c1是lambda产生的闭包的副本
[x](int y) { return x * y > 55; };

auto c2 = c1; //c2是c1的拷贝

auto c3 = c2; //c3是c2的拷贝

}

c1c2c3都是lambda产生的闭包的副本。

非正式的讲,模糊lambda,闭包和闭包类之间的界限是可以接受的。但是,在随后的Item中,区分什么存在于编译期(lambdas 和闭包类),什么存在于运行时(闭包)以及它们之间的相互关系是重要的。

item 31 避免使⽤默认捕获模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
// C++11中有两种默认的捕获模式:按引⽤捕获和按值捕获。
// 1 按引⽤捕获的坏处
// 会导致闭包中包含了对局部变量或者某个形参(位于定义lambda的作⽤域)的引⽤,如果该lambda创建的闭包⽣命周期超过了局部变量或者参数的⽣命周期,那么闭包中的引⽤将会变成悬空引 ⽤。举个例⼦,假如我们有⼀个元素是过滤函数的容器,该函数接受⼀个int作为参数,并返回⼀个布尔 值,该布尔值的结果表⽰传⼊的值是否满⾜过滤条件。
using FilterContainer = //“using”参见条款9,
std::vector<std::function<bool(int)>>; //std::function参见条款2

FilterContainer filters; //过滤函数
filters.emplace_back( //emplace_back的信息见条款42
[](int value) { return value % 5 == 0; }
);
void addDivisorFilter()
{
auto calc1 = computeSomeValue1();
auto calc2 = computeSomeValue2();

auto divisor = computeDivisor(calc1, calc2);

filters.emplace_back( //危险!对divisor的引用
[&](int value) { return value % divisor == 0; } //将会悬空!
);
}
// 这个代码实现是⼀个定时炸弹。lambda对局部变量divisor进⾏了引⽤,但该变量的⽣命周期会在addDivisorFilter返回时结束,刚好就是在语句filters.emplace_back返回之后,因此该函数的本质就是 容器添加完,该函数就死亡了。使⽤这个filter会导致未定义⾏为,这是由它被创建那⼀刻起就决定了 的。显示的捕获参数也会有这个问题,但是更容易让人意识到:
filters.emplace_back(
[&divisor](int value) // 危险!对divisor的引用将会悬空!
{ return value % divisor == 0; }
);
// 从⻓期来看,使⽤显式的局部变量和参数引⽤捕获⽅式,是更加符合软件⼯程规范的做法,接下来看一个具体例子:

// 2 按默认按值值捕获的坏处
// 2.1 缺点1: 悬空指针
filters.emplace_back( //现在divisor不会悬空了
[=](int value) { return value % divisor == 0; }
);
// 在通常情况下,按值捕获并不能完全解决悬空引用的问题。这里的问题是如果你按值捕获的是一个指针,你将该指针拷贝到*lambda*对应的闭包里,但这样并不能避免*lambda*外`delete`这个指针的行为,从而导致你的副本指针变成悬空指针。
class Widget {
public:
//构造函数等
void addFilter() const; //向filters添加条目
private:
int divisor; //在Widget的过滤器使用
};
void Widget::addFilter() const
{
filters.emplace_back(
[=](int value) { return value % divisor == 0; }
);
}
// 这个做法看起来是安全的代码。*lambda*依赖于`divisor`,但默认的按值捕获确保`divisor`被拷贝进了*lambda*对应的所有闭包中,对吗?
// 错误,完全错误。
// 捕获只能应用于*lambda*被创建时所在作用域里的non-`static`局部变量(包括形参)。在`Widget::addFilter`的视线里,`divisor`并不是一个局部变量,而是`Widget`类的一个成员变量。它不能被捕获。而如果默认捕获模式被删除,代码就不能编译了:
void Widget::addFilter() const
{
filters.emplace_back( //错误!
[](int value) { return value % divisor == 0; } //divisor不可用
);
}
void Widget::addFilter() const
{
filters.emplace_back(
[divisor](int value) //错误!没有名为divisor局部变量可捕获
{ return value % divisor == 0; }
);
}
// 所以如果默认按值捕获不能捕获`divisor`,而不用默认按值捕获代码就不能编译,这是怎么一回事呢?
// 解释就是这里隐式使用了一个原始指针:`this`。每一个non-`static`成员函数都有一个`this`指针,每次你使用一个类内的数据成员时都会使用到这个指针。例如,在任何`Widget`成员函数中,编译器会在内部将`divisor`替换成`this->divisor`。在默认按值捕获的`Widget::addFilter`版本中,
void Widget::addFilter() const
{
filters.emplace_back(
[=](int value) { return value % divisor == 0; }
);
}
void Widget::addFilter() const
{
auto currentObjectPtr = this;

filters.emplace_back(
[currentObjectPtr](int value)
{ return value % currentObjectPtr->divisor == 0; }
);
}
// 明白了这个就相当于明白了*lambda*闭包的生命周期与`Widget`对象的关系,闭包内含有`Widget`的`this`指针的拷贝。特别是考虑以下的代码
using FilterContainer = //跟之前一样
std::vector<std::function<bool(int)>>;
FilterContainer filters; //跟之前一样
void doSomeWork()
{
auto pw = //创建Widget;std::make_unique
std::make_unique<Widget>(); //见条款21
pw->addFilter(); //添加使用Widget::divisor的过滤器

} //销毁Widget;filters现在持有悬空指针!

// 这个特定的问题可以通过做⼀个局部拷⻉去解决:
void Widget::addFilter() const
{
auto divisorCopy = divisor; //拷贝数据成员

filters.emplace_back(
[divisorCopy](int value) //捕获副本
{ return value % divisorCopy == 0; } //使用副本
);
}
// 事实上如果采⽤这种⽅法,默认的按值捕获也是可⾏的。
void Widget::addFilter() const
{
auto divisorCopy = divisor; //拷贝数据成员

filters.emplace_back(
[=](int value) //捕获副本
{ return value % divisorCopy == 0; } //使用副本
);
}
// 但为什么要冒险呢?当一开始你认为你捕获的是`divisor`的时候,默认捕获模式就是造成可能意外地捕获`this`的元凶。
// C++14中,⼀个更好的捕获成员变量的⽅式时使⽤通⽤的lambda捕获:
void Widget::addFilter() const
{
filters.emplace_back( //C++14:
[divisor = divisor](int value) //拷贝divisor到闭包
{ return value % divisor == 0; } //使用这个副本
);
}
// 这种通⽤的lambda捕获并没有默认的捕获模式,因此在C++14中,避免使⽤默认捕获模式的建议仍然时 成⽴的。

// 2.2 默认的按值捕获还有另外的⼀个缺点, 它们预⽰了相关的闭包是独⽴的并且不受外部数据变化的影 响
// ⼀般来说,这是不对的。lambda并不会独⽴于局部变量和参数,但也没有不受静态存储⽣命周期的 影响。⼀个定义在全局空间或者指定命名空间的全局变量,或者是⼀个声明为static的类内或⽂件内的成 员。这些对象也能在lambda⾥使⽤,但它们不能被捕获。但按值引⽤可能会因此误导你,让你以为捕获 了这些变量。参考下⾯版本的addDivisorFilter()函数:
void addDivisorFilter()
{
static auto calc1 = computeSomeValue1(); //现在是static
static auto calc2 = computeSomeValue2(); //现在是static
static auto divisor = //现在是static
computeDivisor(calc1, calc2);

filters.emplace_back(
[=](int value) //什么也没捕获到!
{ return value % divisor == 0; } //引用上面的static
);

++divisor; //调整divisor
}
// 随意地看了这份代码的读者可能看到"[=]",就会认为“好的,lambda拷⻉了所有使⽤的对象,因此这是 独⽴的”。但上⾯的例⼦就表现了不独⽴闭包的⼀种情况。它没有使⽤任何的⾮static局部变量和形参, 所以它没有捕获任何东西。然而lambda的代码引⽤了静态变量divisor,任何lambda被添加到filters之 后,divisor都会递增。通过这个函数,会把许多lambda都添加到filiters⾥,但每⼀个lambda的⾏为都 是新的(分别对应新的divisor值)。这个lambda是通过引⽤捕获divisor,这和默认的按值捕获表⽰的 含义有着直接的⽭盾。如果你⼀开始就避免使⽤默认的按值捕获模式,你就能解除代码的⻛险。

rem

  • 默认的按引⽤捕获可能会导致悬空引⽤;
  • 默认的按值引⽤对于悬空指针很敏感(尤其是this指针),并且它会误导⼈产⽣lambda是独⽴的想 法;

item 32 使⽤初始化捕(⼴义lambda捕获)获来移动对象到闭包中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// 使⽤初始化捕获可以让你指定: 
// 1. 从lambda⽣成的闭包类中的数据成员名称;
// 2. 初始化该成员的表达式;
class Widget { //一些有用的类型
public:

bool isValidated() const;
bool isProcessed() const;
bool isArchived() const;
private:

};

auto pw = std::make_unique<Widget>(); //创建Widget;使用std::make_unique
//的有关信息参见条款21
//设置*pw
auto func = [pw = std::move(pw)] //使用std::move(pw)初始化闭包数据成员
{ return pw->isValidated()
&& pw->isArchived(); };
// 初始化捕获的使⽤,"="的左侧是指定的闭包类中数据成员的名称,右侧则是初始化表 达式。有趣的是,"="左侧的作⽤范围不同于右侧的作⽤范围。在上⾯的⽰例中,'='左侧的名称 pw 表⽰ 闭包类中的数据成员,而右侧的名称 pw 表⽰在lambda上⽅声明的对象,即由调⽤初始化的变量到调⽤ std::make_unique 。因此, pw = std :: move(pw) 的意思是“在闭包中创建⼀个数据成员pw,并通 过将 std::move 应⽤于局部变量pw的⽅法来初始化该数据成员。
// 这清楚地表明了,这个C ++ 14的捕获概念是从C ++11发展出来的的,在C ++11中,⽆法捕获表达式的 结果。 因此,初始化捕获的另⼀个名称是⼴义lambda捕获。
auto func = [pw = std::make_unique<Widget>()] //使用调用make_unique得到的结果
{ return pw->isValidated() //初始化闭包数据成员
&& pw->isArchived(); };

// 但是,如果您使⽤的⼀个或多个编译器不⽀持C ++ 14的初始捕获怎么办? 如何使⽤不⽀持移动捕获的 语⾔完成移动捕获?
class IsValAndArch { //“is validated and archived”
public:
using DataType = std::unique_ptr<Widget>;

explicit IsValAndArch(DataType&& ptr) //条款25解释了std::move的使用
: pw(std::move(ptr)) {}

bool operator()() const
{ return pw->isValidated() && pw->isArchived(); }

private:
DataType pw;
};

auto func = IsValAndArch(std::make_unique<Widget>());
// 这个代码量⽐lambda表达式要多,但这并不难改变这样⼀个事实,即如果你希望使⽤⼀个C++11的类来 ⽀持其数据成员的移动初始化,那么你唯⼀要做的就是在键盘上多花点时间。

// 如果你坚持要使⽤lambda(并且考虑到它们的便利性,你可能会这样做),可以在C++11中这样使⽤:
// 1. 将要捕获的对象移动到由 std::bind ;
// 2. 将被捕获的对象赋予⼀个引⽤给lambda;

// cpp14:
std::vector<double> data; //要移动进闭包的对象
//填充data
auto func = [data = std::move(data)] //C++14初始化捕获
{ /*使用data*/ };
// C++11的等效代码如下,其 中我强调了相同的关键事项:
auto func =
std::bind( //C++11模拟初始化捕获
[](const std::vector<double>& data) //译者注:本行高亮
{ /*使用data*/ },
std::move(data) //译者注:本行高亮
);
// 如lambda表达式⼀样, std::bind ⽣产了函数对象。我将它称呼为由std::bind所绑定对象返回的函数 对象。 std::bind 的第⼀个参数是可调⽤对象,后续参数表⽰要传递给该对象的值。这种移动构造是模仿移动捕获 的关键,因为将右值移动到绑定对象是我们解决⽆法将右值移动到C++11闭包中的⽅法。

// 默认情况下,从lambda⽣成的闭包类中的 operator() 成员函数为 const 的。这具有在lambda主体内 呈现闭包中的所有数据成员为 const 的效果。但是,绑定对象内部的移动构造数据副本不⼀定是 const 的,因此,为了防⽌在lambda内修改该数据副本,lambda的参数应声明为 const 引⽤。 如果将 lambda 声明为可变的,则不会在其闭包类中将 operator() 声明为const,并且在lambda的参数声明 中省略 const 也是合适的:
auto func =
std::bind( //C++11对mutable lambda
[](std::vector<double>& data) mutable //初始化捕获的模拟
{ /*使用data*/ },
std::move(data)
);
// 如果这是您第⼀次接触 std::bind ,则可能需要先阅读您最喜欢的C ++11参考资料,然后再进⾏讨论所 有详细信息。 即使是这样,这些基本要点也应该清楚:
// 1. ⽆法将移动构造⼀个对象到C ++11闭包,但是可以将对象移动构造为C++11的绑定对象。
// 2. 在C++11中模拟移动捕获包括将对象移动构造为绑定对象,然后通过引⽤将对象移动构造传递给lambda。
// 3. 由于绑定对象的⽣命周期与闭包对象的⽣命周期相同,因此可以将绑定对象中的对象视为闭包中的 对象。
auto func = [pw = std::make_unique<Widget>()] //同之前一样
{ return pw->isValidated() //在闭包中创建pw

// 这是C++11的模拟实现:
auto func = std::bind(
[](const std::unique_ptr<Widget>& pw)
{ return pw->isValidated()
&& pw->isArchived(); },
std::make_unique<Widget>()
);
// 具备讽刺意味的是,这⾥我展⽰了如何使⽤ std::bind 解决C++11 lambda中的限制,但在条款34中, 我却主张在 std::bind 上使⽤lambda。 但是,该条⽬解释的是在C++11中有些情况下 std::bind 可能有⽤,这就是其中⼀种。 (在C++14中, 初始化捕获和⾃动参数等功能使得这些情况不再存在。)

rem

  • 使⽤C ++14的初始化捕获将对象移动到闭包中。
  • 在C ++11中,通过⼿写类或 std::bind 的⽅式来模拟初始化捕获。

item 33 对于std::forward的auto&&形参使⽤decltype

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
auto f = [](auto x){ return func(normalize(x)); };
// 对应的闭包类中的函数调用操作符看来就变成这样:
class SomeCompilerGeneratedClassName {
public:
template<typename T> //auto返回类型见条款3
auto operator()(T x) const
{ return func(normalize(x)); }
//其他闭包类功能
};

// 在这个样例中,*lambda*对变量`x`做的唯一一件事就是把它转发给函数`normalize`。如果函数`normalize`对待左值右值的方式不一样,这个*lambda*的实现方式就不大合适了,因为即使传递到*lambda*的实参是一个右值,*lambda*传递进`normalize`的总是一个左值(形参`x`)。
// 实现这个lambda的正确⽅式是把 x 完美转发给函数 normalize 。这样做需要对代码做两处修改。⾸ 先,x需要改成通⽤引⽤,其次,需要使⽤ std::forward 将 x 转发到函数 normalize 。实际上的修改 如下:
auto f = [](auto&& x)
{ return func(normalize(std::forward<???>(x))); };
// 在理论和实际之间存在⼀个问题:你传递给 std::forward 的参数是什么类型,就决定了上⾯的 ??? 该 怎么修改。 ⼀般来说,当你在使⽤完美转发时,你是在⼀个接受类型参数为 T 的模版函数⾥,所以你可以写 std::forward<T> 。但在泛型lambda中,没有可⽤的类型参数 T 。在lambda⽣成的闭包⾥,模版化 的 operator() 函数中的确有⼀个 T ,但在lambda⾥却⽆法直接使⽤它。
// 前⾯item28解释过在传递给通⽤引⽤的是⼀个左值,那么它会变成左值引⽤。传递的是右值就会变成右 值引⽤。这意味着在这个lambda中,可以通过检查 x 的类型来检查传递进来的实参是⼀个左值还是右 值,decltype就可以实现这样的效果。传递给lambda的是⼀个左值, decltype(x) 就能产⽣⼀个左值 引⽤;如果传递的是⼀个右值, decltype(x) 就会产⽣右值引⽤。
// Item28也解释过在调⽤ std::forward ,传递给它的类型类型参数是⼀个左值引⽤时会返回⼀个左值; 传递的是⼀个⾮引⽤类型时,返回的是⼀个右值引⽤,而不是常规的⾮引⽤。在前⾯的lambda中,如果 x绑定的是⼀个左值引⽤, decltype(x) 就能产⽣⼀个左值引⽤;如果绑定的是⼀个右值, decltype(x) 就会产⽣右值引⽤,而不是常规的⾮引⽤。
// 也就是forward<x的类型&&>(),那么这会改变forward的行为吗?
// 回顾一下cpp14下forward的实现:
template<typename T> //在std命名空间
T&& forward(remove_reference_t<T>& param)
{
return static_cast<T&&>(param);
}
Widget&& forward(Widget& param) //当T是Widget时的std::forward实例
{
return static_cast<Widget&&>(param);
}
Widget&& && forward(Widget& param) //当T是Widget&&时的std::forward实例
{ //(引用折叠之前)
return static_cast<Widget&& &&>(param);
}
Widget&& forward(Widget& param) //当T是Widget&&时的std::forward实例
{ //(引用折叠之后)
return static_cast<Widget&&>(param);
}
// 对比这个实例和用`Widget`设置`T`去实例化产生的结果,它们完全相同。表明用右值引用类型和用非引用类型去初始化`std::forward`产生的相同的结果。

// 那是⼀个很好的消息,引⽤当传递给lambda形参x的是⼀个右值实参时, decltype(x) 可以产⽣⼀个右 值引⽤。前⾯已经确认过,把⼀个左值传给lambda时, decltype(x) 会产⽣⼀个可以传给 std::forward 的常规类型。
// 所以⽆论是左值还 是右值,把 decltype(x) 传递给 std::forward 都能得到我们想要的结果,因此lambda的完美转发可以写成:
auto f =
[](auto&& param)
{
return
func(normalize(std::forward<decltype(param)>(param)));
};
// 写成可变参类型:加上6个点
auto f =
[](auto&&... params)
{
return
func(normalize(std::forward<decltype(params)>(params)...));
};

rem

  • 对 auto&& 参数使⽤ decltype 来( std::forward )转发参数;

item 34 考虑lambda表达式而⾮std::bind

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
// 因为在C ++11中, lambda ⼏ 乎是⽐ std :: bind 更好的选择。 从C++14开始, lambda 的作⽤不仅强⼤,而且是完全值得使⽤的。我们将从 std::bind 返回的函数对象称为绑定对象。 

// case1: 优先lambda而不是 std::bind 的最重要原因是lambda更易读。
// 例如,假设我们有⼀个设置闹钟的函 数
//一个时间点的类型定义(语法见条款9)
using Time = std::chrono::steady_clock::time_point;
//“enum class”见条款10
enum class Sound { Beep, Siren, Whistle };
//时间段的类型定义
using Duration = std::chrono::steady_clock::duration;
//在时间t,使用s声音响铃时长d
void setAlarm(Time t, Sound s, Duration d);
//setSoundL(“L”指代“lambda”)是个函数对象,允许指定一小时后响30秒的警报器的声音
auto setSoundL =
[](Sound s)
{
//使std::chrono部件在不指定限定的情况下可用
using namespace std::chrono;

setAlarm(steady_clock::now() + hours(1), //一小时后响30秒的闹钟
s, //译注:setAlarm三行高亮
seconds(30));
};
// 我们在lambda中突出了对 setAlarm 的调⽤。这看来起是⼀个很正常的函数调⽤,即使是⼏乎没有lambda经验的读者也可以看到:传递给lambda的参数被传递给了 setAlarm 。
// 通过使⽤基于C++11对⽤⼾⾃定义常量的⽀持而建⽴的标准后缀,如秒(s),毫秒(ms)和小时(h)等,我们 可以简化C++14中的代码。这些后缀在 std::literals 命名空间中实现,因此上述代码可以按照以下⽅ 式重写:
auto setSoundL =
[](Sound s)
{
using namespace std::chrono;
using namespace std::literals; //对于C++14后缀

setAlarm(steady_clock::now() + 1h, //C++14写法,但是含义同上
s,
30s);
};
// 我们看一下bind的写法:
using namespace std::chrono; //同上
using namespace std::literals;
using namespace std::placeholders; //“_1”使用需要

auto setSoundB = //“B”代表“bind”
std::bind(setAlarm,
steady_clock::now() + 1h, //不正确!见下
_1,
30s);
// _1是一个占位符,是setSoundB的第一个参数,被用于setAlarm的第二个参数,但正如我所说,代码并不完全正确。在lambda中,表达式 steady_clock::now() + 1h 显然是是 setAlarm 的参数。调⽤ setAlarm 时将对其进⾏计算。这是合理的:我们希望在调⽤ setAlarm 后⼀小 时发出警报。但是,在 std::bind 调⽤中,将 steady_clock::now() + 1h 作为参数传递给了 std::bind,而不是 setAlarm 。这意味着将在调⽤ std::bind 时对表达式进⾏求值,并且该表达式产⽣的时间 将存储在结果绑定对象中。结果,闹钟将被设置为在调⽤ std::bind 后⼀小时发出声⾳,而不是在调⽤ setAlarm`⼀小时后发出。
auto setSoundB =
std::bind(setAlarm,
std::bind(std::plus<>(), steady_clock::now(), 1h),
_1,
30s);
// 要解决此问题,需要告诉 std::bind 推迟对表达式的求值,直到调⽤ setAlarm 为⽌,而这样做的⽅法 是将对 std::bind 的第⼆个调⽤嵌套在第⼀个调⽤中:
auto setSoundB =
std::bind(setAlarm,
std::bind(std::plus<>(), steady_clock::now(), 1h),
_1,
30s);
// 如果你熟悉C++98的`std::plus`模板,你可能会惊讶地发现在此代码中,尖括号之间未指定任何类型,即该代码包含“`std::plus<>`”,而不是“`std::plus<type>`”。 在C++14中,通常可以省略标准运算符模板的模板类型实参,因此无需在此处提供。 C++11没有提供此类功能,因此等效于*lambda*的C++11 `std::bind`为:
using namespace std::chrono; //同上
using namespace std::placeholders;
auto setSoundB =
std::bind(setAlarm,
std::bind(std::plus<steady_clock::time_point>(),
steady_clock::now(),
hours(1)),
_1,
seconds(30));
// 如果此时Lambda看起来不够吸引,那么应该检查⼀下视⼒了。
// 更进一步凸显方便:setAlarm有4参数的重载版本:
enum class Volume { Normal, Loud, LoudPlusPlus }; void setAlarm(Time t, Sound s, Duration d, Volume v);
// lambda能继续像以前⼀样使⽤,因为根据重载规则选择了 setAlarm 的三参数版本:
auto setSoundL = //和之前一样
[](Sound s)
{
using namespace std::chrono;
setAlarm(steady_clock::now() + 1h, //可以,调用三实参版本的setAlarm
s,
30s);
};
// 然而,`std::bind`的调用将会编译失败:
auto setSoundB = //错误!哪个setAlarm?
std::bind(setAlarm,
std::bind(std::plus<>(),
steady_clock::now(),
1h),
_1,
30s);
// 这⾥的问题是,编译器⽆法确定应将两个setAlarm函数中的哪⼀个传递给 std::bind 。 它们仅有的是 ⼀个函数名称,而这个函数名称是不确定的。 要获得对 std::bind 的调⽤能进⾏编译,必须将 setAlarm 强制转换为适当的函数指针类型:
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);
// 但这在 lambda 和 std::bind 的使⽤上带来了另⼀个区别。
// 在 setSoundL 的函数调⽤操作符(即lambda的闭包类对应的函数调⽤操作符)内部,对 setAlarm 的调⽤是正常的函数调⽤,编译器可以按 常规⽅式进⾏内联:
setSoundL(Sound::Siren); // body of setAlarm may be inlined here
// 但是,对 std::bind 的调⽤是将函数指针传递给 setAlarm ,这意味着在 setSoundB 的函数调⽤操作符 (即绑定对象的函数调⽤操作符)内部,对 setAlarm 的调⽤是通过⼀个函数指针。 编译器不太可能通 过函数指针内联函数,这意味着与通过 setSoundL 进⾏调⽤相⽐,通过 setSoundB 对 setAlarm的 调 ⽤,其函数不⼤可能被内联:
setSoundB(Sound::Siren); // body of setAlarm is less likely to be inlined here
// 因此,使⽤ lambda 可能会⽐使⽤ std::bind 能⽣成更快的代码。

// case2: 使用lambda更容易做复杂的事情
// 考虑一个函数:它返回其参数是否在最小值( lowVal )和最⼤值( highVal )之间的 结果,其中 lowVal 和 highVal 是局部变量:
auto betweenL =
[lowVal, highVal]
(const auto& val) //C++14
{ return lowVal <= val && val <= highVal; };
// 考虑bind版本:
using namespace std::placeholders; //同上
auto betweenB =
std::bind(std::logical_and<>(), //C++14
std::bind(std::less_equal<>(), lowVal, _1),
std::bind(std::less_equal<>(), _1, highVal));
// 我希望我们都能同意,lambda版本不仅更短,而且更易于理解和维护。

// case3: lambda能够明确知道是按照值传递还是引用,而bind是按值
// 假设我们有一个函数可以创建`Widget`的压缩副本,
enum class CompLevel { Low, Normal, High }; //压缩等级
Widget compress(const Widget& w, //制作w的压缩副本
CompLevel lev);

// 并且我们想创建一个函数对象,该函数对象允许我们指定`Widget w`的压缩级别。这种使用`std::bind`的话将创建一个这样的对象:
Widget w;
using namespace std::placeholders;
auto compressRateB = std::bind(compress, w, _1);
// 现在,当我们将 w 传递给 std::bind 时,必须将其存储起来,以便以后进⾏压缩。它存储在对象compressRateB中,但是这是如何存储的呢(是通过值还是引⽤)。之所以会有所不同,是因为如果在 对 std::bind 的调⽤与对 compressRateB 的调⽤之间修改了 w ,则按引⽤捕获的 w 将反映其更改,而 按值捕获则不会。
// 答案是它是按值捕获的,但唯⼀知道的⽅法是记住 std::bind 的⼯作⽅式;在对 std::bind 的调⽤中 没有任何迹象。与lambda⽅法相反,其中 w 是通过值还是通过引⽤捕获是显式的:
auto compressRateL = //w是按值捕获,lev是按值传递
[w](CompLevel lev)
{ return compress(w, lev); };
// 同样明确的是如何将参数传递给lambda。 在这⾥,很明显参数 lev 是通过值传递的。 因此:
compressRateL(CompLevel::High); // arg is passed by value
// 但是在对由 std::bind ⽣成的对象调⽤中,参数如何传递?
compressRateB(CompLevel::High); // how is arg passed?
// 同样,唯⼀的⽅法是记住 std::bind 的⼯作⽅式。(答案是传递给绑定对象的所有参数都是通过引⽤传 递的,因为此类对象的函数调⽤运算符使⽤完美转发。)

// 结论:与lambda相⽐,使⽤ std::bind 进⾏编码的代码可读性较低,表达能⼒较低,并且效率可能较低。 在C++14中,没有 std::bind 的合理⽤例。

// 若你只能用cpp11,bind有2种情况有效:
// 1. 移动捕获。 C++11的lambda不提供移动捕获,但是可以通过结合lambda和 std::bind 来模拟。
// 2. 多态函数对象。 因为绑定对象上的函数调⽤运算符使⽤完全转发,所以它可以接受任何类型的参数 (以条款30中描述的完全转发的限制为例⼦)。当您要使⽤模板化函数调⽤运算符来绑定对象时, 此功能很有⽤。 例如这个类,
class PolyWidget {
public:
template<typename T>
void operator()(const T& param);

};
PolyWidget pw;
auto boundPW = std::bind(pw, _1);
boundPW(1930); //传int给PolyWidget::operator()
boundPW(nullptr); //传nullptr给PolyWidget::operator()
boundPW("Rosebud"); //传字面值给PolyWidget::operator()
// 这一点无法使用C++11的*lambda*做到。 但是,在C++14中,可以通过带有`auto`形参的*lambda*轻松实现:
auto boundPW = [pw](const auto& param) //C++14
{ pw(param); };

// 最后在总结:在C ++11中增 加了lambda⽀持,这使得 std::bind ⼏乎已经过时了,从C ++ 14开始,更是没有很好的⽤例了。

rem

  • 与使⽤ std::bind 相⽐,Lambda更易读,更具表达⼒并且可能更⾼效。
  • 只有在C++11中, std::bind 可能对实现移动捕获或使⽤模板化函数调⽤运算符来绑定对象时会很 有⽤。

——- chapter 7 ——- 并发API

C++11的伟大成功之一是将并发整合到语言和库中。熟悉其他线程API(比如pthreads或者Windows threads)的开发者有时可能会对C++提供的斯巴达式(译者注:应该是简陋和严谨的意思)功能集感到惊讶,这是因为C++对于并发的大量支持是在对编译器作者约束的层面。开发者首次通过标准库可以写出跨平台的多线程程序。这为构建表达库奠定了坚实的基础,标准库并发组件(任务tasks,期望futures,线程threads,互斥mutexes,条件变量condition variables,原子对象atomic objects等)仅仅是成为并发软件开发者丰富工具集的基础。

记住标准库有两个future的模板:std::futurestd::shared_future。在许多情况下,区别不重要,所以我们经常简单的混于一谈为futures

item 35 优先基于任务编程而不是基于线程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 如果开发者想要异步执⾏ doAsyncWork 函数,通常有两种⽅式。其⼀是通过创建 std::thread 执⾏ doAsyncWork , ⽐如:
int doAsyncWork();
std::thread t(doAsyncWork);
// 其⼆是将 doAsyncWork 传递给 std::async , ⼀种基于任务的策略:
auto fut = std::async(doAsyncWork); // "fut" for "future"
// 基于任务的⽅法通常⽐基于线程的⽅法更优,原因之⼀上⾯的代码已经表明,基于任务的⽅法代码量更 少。我们假设唤醒 doAsyncWork 的代码对于其提供的返回值是有需求的。基于线程的⽅法对此⽆能为 ⼒,而基于任务的⽅法可以简单地获取 std::async 返回的 future 提供的 get 函数获取这个返回值。 如果 doAsycnWork 发⽣了异常, get 函数就显得更为重要,因为 get 函数可以提供抛出异常的访问,而 基于线程的⽅法,如果 doAsyncWork 抛出了异常,线程会直接终⽌(通过调⽤ std::terminate )。

// 基于线程与基于任务最根本的区别在于抽象层次的⾼低。基于任务的⽅式使得开发者从线程管理的细节 中解放出来,对此在C++并发软件中总结了'thread'的三种含义:
// 1. 硬件线程(Hardware threads)是真实执⾏计算的线程。现代计算机体系结构为每个CPU核⼼提 供⼀个或者多个硬件线程。
// 2. 软件线程(Software threads)(也被称为系统线程)是操作系统管理的在硬件线程上执⾏的线 程。通常可以存在⽐硬件线程更多数量的软件线程,因为当软件线程被⽐如 I/O、同步锁或者条件 变量阻塞的时候,操作系统可以调度其他未阻塞的软件线程执⾏提供吞吐量。
// 3. std::threads 是C++执⾏过程的对象,并作为软件线程的handle(句柄)。 std::threads 存在多 种状态,
// 3.1. null 表⽰空句柄,因为处于默认构造状态(即没有函数来执⾏),因此不对应任何软 件线程。
// 3.2. moved from (moved-to的 std::thread 就对应软件进程开始执⾏)
// 3.3. joined (连接 唤醒与被唤醒的两个线程)
// 3.4. detached (将两个连接的线程分离)

// 软件线程是有限的资源。如果开发者试图创建⼤于系统⽀持的硬件线程数量,会抛出 std::system_error 异常。即使你编写了不抛出异常的代码,这仍然会发⽣,⽐如下⾯的代码,即使 doAsyncWork 是 noexcept
int doAsyncWork() noexcept; // see Item 14 for noexcept
// 这段代码仍然会抛出异常。
std::thread t(doAsyncWork); // throw if no more threads are available

// 设计良好的软件必须有效地处理这种可能性(软件线程资源耗尽),⼀种有效的⽅法是在当前线程执⾏ doAsyncWork ,但是这可能会导致负载不均,而且如果当前线程是GUI线程,可能会导致响应时间过⻓ 的问题;另⼀种⽅法是等待当前运⾏的线程结束之后再创建新的线程,但是仍然有可能当前运⾏的线程 在等待 doAsyncWork 的结果(例如操作得到的变量或者条件变量的通知)。
// 即使没有超出软件线程的限额,仍然可能会遇到资源超额的⿇烦。如果当前准备运⾏的软件线程⼤于硬 件线程的数量,系统的线程调度程序会将硬件核⼼的时间切⽚,当⼀个软件线程的时间⽚执⾏结束,会 让给另⼀个软件线程,即发⽣上下⽂切换。软件线程的上下⽂切换会增加系统的软件线程管理开销,并 且如果发⽣了硬件核⼼漂移,这个开销会更⾼,具体来说,如果发⽣了硬件核⼼漂移,(1)CPU cache
// 中关于上次执⾏线程的数据很少,需要重新加载指令;(2)新线程的cache数据会覆盖⽼线程的数据, 如果将来会再次覆盖⽼线程的数据,显然频繁覆盖增加很多切换开销。 避免资源超额是困难的,因为软件线程之于硬件线程的最佳⽐例取决于软件线程的执⾏频率,(⽐如⼀ 个程序从IO密集型变成计算密集型,执⾏频率是会改变的),而且⽐例还依赖上下⽂切换的开销以及软 件线程对于CPU cache的使⽤效率。此外,硬件线程的数量和CPU cache的速度取决于机器的体系结 构,即使经过调校,软件⽐例在某⼀种机器平台取得较好效果,换⼀个其他类型的机器这个调校并不能 提供较好效果的保证。
auto fut = std::async(doAsyncWork); //线程管理责任交给了标准库的开发者

// 如果考虑⾃⼰实现在等待结果的线程上运⾏输出结果的函数,之前提到了可能引出负载不均衡的问题, std::async 运⾏时的调度程序显然⽐开发者更清楚调度策略的制定,因为运⾏时调度程序管理的是所 有执⾏过程,而不仅仅个别开发者运⾏的代码。
// 最前沿的线程调度算法使⽤线程池来避免资源超额的问题,并且通过窃取算法来提升了跨硬件核⼼(频繁切换上下文,可以想象的导致cache中数据交替载入)的负 载均衡。

// 对⽐基于线程的开发⽅式,基于任务的设计为开发者避免了线程管理的痛苦,并且⾃然提供了⼀种获取 异步执⾏的结果的⽅式。当然,仍然存在⼀些场景直接使⽤ std::thread 会更有优势:
// 1. 需要访问⾮常基础的线程API。C++并发API通常是通过操作系统提供的系统级API(pthreads 或者windows threads)来实现的,系统级API通常会提供更加灵活的操作⽅式,举个例⼦,C++并发API没有线程优先级和affinities的概念。为了提供对底层系统级线程API的访问, std::thread 对象提 供了 native_handle 的成员函数,而在⾼层抽象的⽐如 std::futures 没有这种能⼒。
// 2. 需要优化应⽤的线程使⽤。举个例⼦,只在特定系统平台运⾏的软件,可以调教地⽐使⽤C++并⾏
// 3. API更好的程序性能。 需要实现C++并发API之外的线程技术。举例来说,⾃⾏实现线程池技术。

rem

  • std::thread API不能直接访问异步执⾏的结果,如果执⾏函数有异常抛出,代码会终⽌执⾏
  • 基于线程的编程⽅式关于解决资源超限,负载均衡的⽅案移植性不佳
  • 基于任务的编程⽅式 std::async 会默认解决上⾯两条问题

item 36 必须异步执行,就指定std::launch::async

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// async有两种默认的执行方式:
// std::launch::async 的launch policy意味着f必须异步执⾏,即在不同的线程
// std::launch::deferred 的launch policy意味着f仅仅在当调⽤ get或者wait 要求 std::async 的返回值时才执⾏。这表⽰f推迟到被求值才延迟执⾏(译者注:异步与并发是两个不同概念,这 ⾥侧重于惰性求值)。当 get或wait 被调⽤,f会同步执⾏,即调⽤⽅停⽌直到f运⾏结束。如果 get和wait 都没有被调⽤,f将不会被执⾏

// 有趣的是, std::async 的默认launch policy是以上两种都不是。相反,是求或在⼀起的。下⾯的两种 调⽤含义相同
auto fut1 = std::async(f); // run f using default launch policy
auto fut2 = std::async(std::launch::async | std::launch::deferred, f); // run f either async or defered
// 因此默认策略允许f异步或者同步执⾏。如同Item 35中指出,这种灵活性允许 std::async 和标准库的 线程管理组件(负责线程的创建或销毁)避免超载。这就是使⽤ std::async 并发编程如此⽅便的原 因。
auto fut = std::async(f); // run f using default launch policy
// - ⽆法预测f是否会与t同时运⾏,因为f可能被安排延迟运⾏
// - ⽆法预测f是否会在调⽤ get或wait 的线程上执⾏。如果那个线程是t,含义就是⽆法预测f是否也在 线程t上执⾏
// - ⽆法预测f是否执⾏,因为不能确保 get或者wait 会被调⽤
// 默认启动策略的调度灵活性导致使⽤线程本地变量⽐较⿇烦,因为这意味着如果f读写了线程本地存储 (thread-local storage, TLS),不可能预测到哪个线程的本地变量被访问:
auto fut = std::async(f); // TLS for f possibly for independent thread, but possibly for thread invoking get or wait on fut
// 还会影响到基于超时机制的wait循环,因为在task的 wait_for 或者 wait_until 调⽤中会产⽣延迟求值(当wait或者get被调用才异步执行函数)( std::launch::deferred )。意味着,以下循环看似应该终⽌,但是实际上永 远运⾏:
using namespace std::literals; //为了使用C++14中的时间段后缀;参见条款34

void f() //f休眠1秒,然后返回
{
std::this_thread::sleep_for(1s);
}

auto fut = std::async(f); //异步运行f(理论上)

while (fut.wait_for(100ms) != //循环,直到f完成运行时停止...
std::future_status::ready) //但是有可能永远不会发生!
{

}
// 如果f与调⽤ std::async 的线程同时运⾏(即,如果为f选择的启动策略是 std::launch::async ), 这⾥没有问题(假定f最终执⾏完毕),但是如果f是延迟执⾏, fut.wait_for 将总是返回 std::future_status::deferred 。这表⽰循环会永远执⾏下去。
// 这种错误很容易在开发和单元测试中忽略,因为它可能在负载过⾼时才能显现出来。当机器负载过重 时,任务推迟执⾏才最有可能发⽣。毕竟,如果硬件没有超载,没有理由不安排任务并发执⾏。
// 修复也是很简单的:只需要检查与 std::async 的future是否被延迟执⾏即可,那样就会避免进⼊⽆限 循环。不幸的是,没有直接的⽅法来查看future是否被延迟执⾏。相反,你必须调⽤⼀个超时函数----⽐ 如 wait_for 这种函数。在这个逻辑中,你不想等待任何事,只想查看返回值是否 std::future_status::deferred ,如果是就使⽤0调⽤ wait_for 来终⽌循环。
auto fut = std::async(f); //同上

if (fut.wait_for(0s) == //如果task是deferred(被延迟)状态
std::future_status::deferred)
{
//在fut上调用wait或get来异步调用f
} else { //task没有deferred(被延迟)
while (fut.wait_for(100ms) != //不可能无限循环(假设f完成)
std::future_status::ready) {
//task没deferred(被延迟),也没ready(已准备)
//做并行工作直到已准备
}
//fut是ready(已准备)状态
}

// 这些各种考虑的结果就是,只要满⾜以下条件, std::async 的默认启动策略就可以使⽤:
// - 任务不需要和执行`get`或`wait`的线程并行执行。
// - 读写哪个线程的`thread_local`变量没什么问题。
// - 可以保证会在`std::async`返回的*future*上调用`get`或`wait`,或者该任务可能永远不会执行也可以接受。
// - 使用`wait_for`或`wait_until`编码时考虑到了延迟状态。
// 如果上述条件任何⼀个都满⾜不了,你可能想要保证 std::async 的任务真正的异步执⾏。进⾏此操作 的⽅法是调⽤时,将 std::launch::async 作为第⼀个参数传递:
auto fut = std::async(std::launch::async, f); // launch f asynchronously
事实上,具有类似 std::async ⾏为的函数,但是会⾃动使⽤ std::launch::async 作为启动策略的⼯ 具也是很容易编写的,C++11\14版本如下:
template<typename F, typename... Ts> // cpp11
inline
std::future<typename std::result_of<F(Ts...)>::type>
reallyAsync(F&& f, Ts&&... params) //返回异步调用f(params...)得来的future
{
return std::async(std::launch::async,
std::forward<F>(f),
std::forward<Ts>(params)...);
}
template<typename F, typename... Ts>
inline
auto // C++14
reallyAsync(F&& f, Ts&&... params)
{
return std::async(std::launch::async,
std::forward<F>(f),
std::forward<Ts>(params)...);
}
// 这个版本清楚表明,reallyAsync 除了使⽤ std::launch::async 启动策略之外什么也没有做。

rem

  • std::async 的默认启动策略是异步或者同步的
  • 灵活性导致访问thread_locals的不确定性,隐含了task可能不会被执⾏的意思,会影响程序基于 wait 的超时逻辑
  • 只有确实异步时才指定 std::launch::async

item 37 确保std::threads在所有路径都不可join(也就是确保都被join过了)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// 每个 std::thread 对象处于两个状态之⼀:joinable or unjoinable。joinable状态的 std::thread 对应 于正在运⾏或者可能正在运⾏的异步执⾏线程。⽐如,⼀个blocked或者等待调度的 std::thread 是joinable,已运⾏结束的 std::thread 也可以认为是joinable
// unjoinable的 std::thread 对象⽐如:(译者注: std::thread 可以视作状态保存的对象,保存的状态可能也包括可调⽤对象,有没有具体的 线程承载就是有没有连接)
// - **默认构造的`std::thread`s**。这种`std::thread`没有函数执行,因此没有对应到底层执行线程上。
// - **已经被移动走的`std::thread`对象**。移动的结果就是一个`std::thread`原来对应的执行线程现在对应于另一个`std::thread`。
// - **已经被`join`的`std::thread`** 。在`join`之后,`std::thread`不再对应于已经运行完了的执行线程。
// - **已经被`detach`的`std::thread`** 。`detach`断开了`std::thread`对象与执行线程之间的连接。
constexpr auto tenMillion = 10000000; //constexpr见条款15
bool doWork(std::function<bool(int)> filter, //返回计算是否执行;
int maxVal = tenMillion) //std::function见条款2
{
std::vector<int> goodVals; //满足filter的值

std::thread t([&filter, maxVal, &goodVals] //填充goodVals
{
for (auto i = 0; i <= maxVal; ++i)
{ if (filter(i)) goodVals.push_back(i); }
});

auto nh = t.native_handle(); //使用t的原生句柄
//来设置t的优先级

if (conditionsAreSatisfied()) {
t.join(); //等t完成
performComputation(goodVals);
return true; //执行了计算
}
return false; //未执行计算
}
// 返回 doWork 。如果 conditionsAreSatisfied() 返回真,没什么问题,但是如果返回假或者抛出异 常, std::thread 类型的 t 在 doWork 结束时会调⽤ t 的析构器。这造成程序执⾏中⽌。
// 假设不是终止程序,而是以下两种情况:
// 1. 隐式join: doWork返回前,执行t的析构,那么do work会一直无法返回,由于dowork需要等待t的函数执行完,这是违反直觉的
// 2. 隐式detach: doWork前,t直接和他之前运行的函数detach,那么在栈区的数据会被这个被t detach过的还在运行的函数不断修改,然后如果有其他程序会读这片内存,你可以想象这种调试emmm

// 标准委员会认为,销毁连接中的线程如此可怕以⾄于实际上禁⽌了它(通过指定销毁连接中的线程导致 程序终⽌) 这使你有责任确保使⽤ std::thread 对象时,在所有的路径上最终都是unjoinable的。但是覆盖每条路 径可能很复杂,可能包括 return, continue, break, goto or exception ,有太多可能的路径。
// 每当你想每条路径的块之外执⾏某种操作,最通⽤的⽅式就是将该操作放⼊本地对象的析构函数中。这 些对象称为RAII对象,通过RAII类来实例化。(RAII全称为 Resource Acquisition Is Initialization)。RAII类在标准库中很常⻅。⽐如STL容器,智能指针, std::fstream 类等。但是标准库没有RAII的 std::thread 类,可能是因为标准委员会拒绝将 join和detach 作为默认选项,不知道应该怎么样完成RAII。 幸运的是,完成⾃⾏实现的类并不难。⽐如,下⾯的类实现允许调⽤者指定析构函数 join或者 detach :
class ThreadRAII {
public:
enum class DtorAction { join, detach }; //enum class的信息见条款10

ThreadRAII(std::thread&& t, DtorAction a) //析构函数中对t实行a动作
: action(a), t(std::move(t)) {}

~ThreadRAII()
{ //可结合性测试见下
if (t.joinable()) {
if (action == DtorAction::join) {
t.join();
} else {
t.detach();
}
}
}
// Item 17说明因为 ThreadRAII 声明了⼀个析构函数,因此不会有编译器⽣成移动操作,但是没有理由 ThreadRAII 对象不能移动。所以需要我们显式声明来告诉编译器⾃动⽣成:
ThreadRAII(ThreadRAII&&) = default;
ThreadRAII& operator=(ThreadRAII&&) = default;
std::thread& get() { return t; } //见下

private:
DtorAction action;
std::thread t;
};
// - 构造器只接受 std::thread 右值,因为我们想要move std::thread 对象给 ThreadRAII (再次 强调, std::thread 不可以复制)
// - 构造器的参数顺序设计的符合调⽤者直觉(⾸先传递 std::thread ,然后选择析构执⾏的动 作),但是成员初始化列表设计的匹配成员声明的顺序。将 std::thread 成员放在声明最后。在 这个类中,这个顺序没什么特别之处,调整为其他顺序也没有问题,但是通常,可能⼀个成员的初 始化依赖于另⼀个,因为 std::thread 对象可能会在初始化结束后就⽴即执⾏了,所以在最后声 明是⼀个好习惯。这样就能保证⼀旦构造结束,所有数据成员都初始化完毕可以安全的异步绑定线 程执⾏
// - ThreadRAII 提供了 get 函数访问内部的 std::thread 对象
// - 在 ThreadRAII 析构函数调⽤ std::thread 对象t的成员函数之前,检查t是否joinable。这是必须 的,因为在unjoinbale的 std::thread 上调⽤ join or detach 会导致未定义⾏为。客⼾端可能 会构造⼀个 std::thread t,然后通过t构造⼀个 ThreadRAII ,使⽤ get 获取t,然后移动t,或者 调⽤ join or detach ,每⼀个操作都使得t变为unjoinable
if (t.joinable()) {
if (action == DtorAction::join) {
t.join();
} else {
t.detach();
}
}
// 存在竞争,因为在 t.joinable() 和 t.join or t.detach 执⾏中间,可能有其他线程改变了t为
// unjoinable,你的态度很好,但是这个担⼼不必要。std::thread 只有⾃⼰可以改变 joinable or unjoinable 的状态。在 ThreadRAII 的析构函数中被调⽤时,其他线程不可能做成员函数的调 ⽤。如果同时进⾏调⽤,那肯定是有竞争的,但是不在析构函数中,是在客⼾端代码中试图同时在 ⼀个对象上调⽤两个成员函数(析构函数和其他函数)。通常,仅当所有都为const成员函数时, 在⼀个对象同时调⽤两个成员函数才是安全的。

rem

  • 在所有路径上保证 thread 最终是unjoinable
  • 析构时 join 会导致难以调试的性能异常问题
  • 析构时 detach 会导致难以调试的未定义⾏为
  • 声明类数据成员时,最后声明 std::thread 类型成员(因为最终声明thread可以保证这个变量最后初始化)

item 38 明白不同线程句柄的析构⾏为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// 可以将 std::thread 对象和 future 对象都视作系统线 程的句柄。
// 这里给出future使用的具体示例:
#include <iostream>
#include <future>
#include <thread>

int main()
{
// future from a packagzed_task
std::packaged_task<int()> task([]{ return 7; }); // wrap the function
std::future<int> f1 = task.get_future(); // get a future
std::thread t(std::move(task)); // launch on a thread

// future from an async()
std::future<int> f2 = std::async(std::launch::async, []{ return 8; });

// future from a promise
std::promise<int> p;
std::future<int> f3 = p.get_future();
std::thread( [&p]{ p.set_value_at_thread_exit(9); }).detach();

std::cout << "Waiting..." << std::flush;
f1.wait();
f2.wait();
f3.wait();
std::cout << "Done!\nResults are: "
<< f1.get() << ' ' << f2.get() << ' ' << f3.get() << '\n';
t.join();
}

// 但是被调⽤者的结果存储在哪⾥?
// 被调⽤者会在调⽤者 get 相关的 future 之前执⾏完成,所以结果不 能存储在被调⽤者的 std::promise 。这个对象是局部的,当被调⽤者执⾏结束后,会被销毁。
// 结果同样不能存储在调⽤者的 future ,因为 std::future 可能会被⽤来创建 std::shared_future (这会将被调⽤者的结果所有权从 std::future 转移给 std::shared_future ), 而 std::shared_future 在 std::future 被销毁之后被复制很多次。鉴于不是所有的结果都可以被拷 ⻉(有些只能移动)和结果的声明周期与最后⼀个引⽤它的 future ⼀样⻓,哪个才是被调⽤者⽤来存 储结果的?
// 因为与被调⽤者关联的对象和调⽤者关联的对象都不适合存储这个结果,必须存储在两者之外的位置。 此位置称为共享状态(shared state)。共享状态通常是基于堆的对象,但是标准并未指定其类型、接口 和实现。标准库的作者可以通过任何他们喜欢的⽅式来实现共享状态。
// Non-defered任务(启动参数为 std::launch::async )的最后⼀个关联共享状态的 future 析构 函数会在任务完成之前block住。本质上,这种 future 的析构对执⾏异步任务的线程做了隐式的 join 。
// future 其他对象的析构简单的销毁。对于异步执⾏的任务,就像对底层的线程执⾏ detach 。对 于defered任务的最后⼀种 future ,意味着这个defered任务永远不会执⾏了。

// 这些规则听起来好复杂。我们真正要处理的是⼀个简单的“正常”⾏为以及⼀个单独的例外。正常⾏为是 future 析构函数销毁 future 。那意味着不 join 也不 detach ,只销毁 future 的数据成员(当然,还 做了另⼀件事,就是对于多引⽤的共享状态引⽤计数减⼀。) 正常⾏为的例外情况仅在同时满⾜下列所有情况下才会执⾏:
// - 关联 future 的共享状态是被调⽤了 std::async 创建的
// - 任务的启动策略是 std::launch::async (参⻅Item 36),原因是运⾏时系统选择了该策略,或 者在对 std::async 的调⽤中指定了该策略。
// - future 是关联共享状态的最后⼀个引⽤。对于 std::future ,情况总是如此,对于 std::shared_future ,如果还有其他的 std::shared_future 引⽤相同的共享状态没有销毁, 就不是。
// 只有当上⾯的三个条件都满⾜时, future 的析构函数才会表现“异常”⾏为,就是在异步任务执⾏完之前block住。实际上,这相当于运⾏ std::async 创建的任务的线程隐式 join 。

// 如果你有办法知道给定的 future 不满⾜上⾯条件的任意⼀条,你就可以确定析构函数不会执⾏ “异常”⾏为。⽐如,只有通过 std::async 创建的共享状态才有资格执⾏“异常”⾏为,但是有其他创建共 享状态的⽅式。⼀种是使⽤ std::packaged_task ,⼀个 std::packaged_task 对象准备⼀个函数(或 者其他可调⽤对象)来异步执⾏,然后将其结果放⼊共享状态中。然后通过 std::packaged_task 的 get_future 函数获取有关该共享状态的信息:
{ // begin block
std::packaged_task<int()> pt(calcValue);
auto fut = pt.get_future();
std::thread t(std::move(pt));
...
} // end block

// 此处最有趣的代码是在创建 std::thread 对象t之后的"..."。"..."有三种可能性:
// - 对t不做什么。这种情况,t会在语句块结束joinable,这会使得程序终⽌(参⻅Item 37)
// - 对t调⽤ join 。这种情况,不需要fut的析构函数block,因为 join 被显式调⽤了
// - 对t调⽤ detach 。这种情况,不需要在fut的析构函数执⾏ detach ,因为显式调⽤了


rem

  • future 的正常析构⾏为就是销毁 future 本⾝的成员数据
  • 最后⼀个引⽤ std::async 创建共享状态的 future 析构函数会在任务结束前block

item 39 对于一次性通讯使用返回void的futures

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
// 有一个检测任务,检测到什么然后通知反应任务去执行,使用条件变量完成:

// 检测:
std::condition_variable cv; // condvar for event
std::mutex m; // mutex for use with cv
... // detect event
cv.notify_one(); // tell reacting task
// 如果有多个反应任务需要被通知,使⽤ notify_all()代替notify_one() ,但是这⾥,我们假定只有⼀ 个反应任务需要通知。

// 反应任务:
//反应的准备工作
{ //开启关键部分
std::unique_lock<std::mutex> lk(m); //锁住互斥锁
cv.wait(lk); //等待通知,但是这是错的!
//对事件进行反应(m已经上锁)
} //关闭关键部分;通过lk的析构函数解锁m
//继续反应动作(m现在未上锁)
// 有时候有明显的执行顺序,也就是先检查,后执行,就不存在数据竞争,但是我们使用了这个互斥变量,这是问题其一
// 还有2个问题:
// - 如果检测任务在反应任务 wait 之前通知条件变量,反应任务会挂起。为了能使条件变量唤醒另⼀ 个任务,任务必须等待在条件变量上。如果检测任务在反应任务 wait 之前就通知了条件变量,反 应任务就会丢失这次通知,永远不被唤醒
// - 线程API的存在⼀个事实(不只是C++)即使条件变量没有被通知,也可能被 虚假唤醒,这种唤醒被称为spurious wakeups。正确的代码通过确认条件变量进⾏处理,并将其作 为唤醒后的第⼀个操作。C++条件变量的API使得这种问题很容易解决,因为允许lambda(或者其 他函数对象)来测试等待条件。因此,可以将反应任务这样写:cv.wait(lk, [] { return whether the evet has occurred; });

// 在很多情况下,使⽤条件变量进⾏任务通信⾮常合适,但是也有不那么合适的情况。
// 看一个反例:
std::atomic<bool> flag(false); // shared flag; see Item 40 for std::atomic
... // detect event
flag = true; // tell reacting task

... // prepare
while(!flag); // wait for event
... // react to event
// while会一直占用CPU!

// 将条件变量和flag的设计组合起来很常⽤。⼀个flag表⽰是否发⽣了感兴趣的事件,但是通过互斥锁同步 了对该flag的访问。因为互斥锁阻⽌并发该flag,所以如Item 40所述,不需要将flag设置为 std::atomic 。⼀个简单的bool类型就可以,检测任务代码如下:
// 检测:
std::condition_variable cv; //跟之前一样
std::mutex m;
bool flag(false); //不是std::atomic
//检测某个事件
{
std::lock_guard<std::mutex> g(m); //通过g的构造函数锁住m
flag = true; //通知反应任务(第1部分)
} //通过g的析构函数解锁m
cv.notify_one(); //通知反应任务(第2部分)
// 反应:
//准备作出反应
{ //跟之前一样
std::unique_lock<std::mutex> lk(m); //跟之前一样
cv.wait(lk, [] { return flag; }); //使用lambda来避免虚假唤醒
//对事件作出反应(m被锁定)
}
//继续反应动作(m现在解锁)
// 这种⽅案是可以⼯作的,但是不太优雅。用条件变量和flag两个东西作为桥梁,确实够麻烦的
// ⼀个替代⽅案是让反应任务通过在检测任务设置的future上 wait 来避免使⽤条件变量,互斥锁和flag。Item 38中说明了future代表了从被调⽤⽅(通常是异步的)到 调⽤⽅的通信的接收端,也说说明了发送端是个`std::promise`,接收端是个*future*的通信信道不是只能被用在调用-被调用场景,这样的通信信 道可以被在任何你需要从程序⼀个地⽅传递到另⼀个地⽅的场景。
// ⽅案很简单。检测任务有⼀个 std::promise 对象(通信信道的写⼊),反应任务有对应的 std::future (通信信道的读取)。当反应任务看到事件已经发⽣,设置 std::promise 对象(写⼊到 通信信道)。同时,反应任务在 std::future 上等待。 wait 会锁住反应任务直到 std::promise 被设 置。
// 现在, std::promise和futures(std::future and std::shared_future) 都是需要参数类型的模 板我们需要的类型是表明在 std::promise 和 futures 之间没有数据被传递。所以选择 void 。
// 检测:
std::promise<void> p; //通信信道的promise
//检测某个事件
p.set_value(); //通知反应任务
// 反应:
//准备作出反应
p.get_future().wait(); //等待对应于p的那个future
//对事件作出反应
// 基于 future 的⽅法没有了上述问题,但是有其他新的问题。⽐如,Item 38中说明, std::promise 和 future 之间有共享状态,并且共享状态是动态分配的。因此你应该假定此设计会产⽣基于堆的分配和释放开销。也许更重要的是, std::promise 只能设置⼀次。 std::promise 与 future 之间的通信是⼀次性的: 不能重复使⽤。这是与基于条件变量或者flag的明显差异,条件变量可以被重复通知,flag也可以重复清 除和设置。

// 假定你想创建⼀个挂起的线程以避免想要使⽤⼀个线程执⾏ 程序的时候的线程创建的开销。或者你想在线程运⾏前对其进⾏设置,包括优先级和core affinity。C++并发API没有提供这种设置能⼒,但是提供了 native_handle() 获取原始线程的接口(通常获取的 是POXIC或者Windows的线程),这些低层次的API使你可以对线程设置优先级和 core affinity。 假设你仅仅想要挂起⼀次线程(在创建后,运⾏前),使⽤ void future 就是⼀个⽅案。代码如下:
std::promise<void> p;
void react(); //反应任务的函数
void detect()
{
ThreadRAII tr( //使用RAII对象
std::thread([]
{
p.get_future().wait();
react();
}),
ThreadRAII::DtorAction::join //有危险!(见下)
);
//tr中的线程在这里被挂起
p.set_value(); //解除挂起tr中的线程

}
// 问题在于第⼀个"..."区域(注释了thread inside tr is suspended here),如果异 常发⽣, p.set_value() 永远不会调⽤,这意味着 lambda中的wait 永远不会返回,即lambda不会结 束,问题就是,因为RAII对象tr再析构函数中join。
// 解决方案:http://scottmeyers.blogspot.com/2013/12/threadraii-thread-suspension-trouble.html,将p和thread一起包装一下放在一个类里面,那么如果发生异常,在类的析构函数中会调用set_value,让线程正确结束
// 这⾥,我只想展⽰如何扩 展原始代码(不使⽤RAII类)使其挂起然后取消挂起,这不仅是个例,是个通⽤场景。简单概括,关键 就是在反应任务的代码中使⽤ std::shared_future 代替 std::future。 ⼀旦你知道 std::future 的 share 成员函数将共享状态所有权转移到 std::shared_future 中,代码⾃然就写出来了。唯⼀需要注 意的是,每个反应线程需要处理⾃⼰的 std::shared_future 副本,该副本引⽤共享状态,因此通过 share 获得的 shared_future 要被lambda按值捕获:
std::promise<void> p; //跟之前一样
void detect() //现在针对多个反映线程
{
auto sf = p.get_future().share(); //sf的类型是std::shared_future<void>
std::vector<std::thread> vt; //反应线程容器
for (int i = 0; i < threadsToRun; ++i) {
vt.emplace_back([sf]{ sf.wait(); //在sf的局部副本上wait;
react(); }); //emplace_back见条款42
}
//如果这个“…”抛出异常,detect挂起!
p.set_value(); //所有线程解除挂起

for (auto& t : vt) { //使所有线程不可结合;
t.join(); //“auto&”见条款2
}
}
// 使用*future*的设计可以实现这个功能值得注意,这也是你应该考虑将其应用于一次通信的原因。

rem

  • 对于简单的事件通信,条件变量需要⼀个多余的互斥锁,对检测和反应任务的相对进度有约束,并 且需要反应任务来验证事件是否已发⽣
  • 基于flag的设计避免的上⼀条的问题,但是不是真正的挂起反应任务
  • 组合条件变量和flag使⽤,上⾯的问题都解决了,但是逻辑不让⼈愉快
  • 使⽤ std::promise和future 的⽅案,要考虑堆内存的分配和销毁开销,同时有只能使⽤⼀次通信 的限制

item 40 当需要并发时使⽤ std::atomic ,特定内存才使⽤ volatile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// ⼀旦 std::atomic 对象被构建,在其上的操作使⽤特定的机器指令实现,这⽐锁的 实现更⾼效。分析如下使⽤ std::atmoic 的代码:
std::atomic<int> ai(0); //初始化ai为0
ai = 10; //原子性地设置ai为10
std::cout << ai; //原子性地读取ai的值
++ai; //原子性地递增ai到11
--ai; //原子性地递减ai到10
// ⾸先,在 std::cout << ai; 中, std::atomic 只保证了对 ai 的读取时 原⼦的。没有保证语句的整个执⾏是原⼦的,这意味着在读取 ai 与将其通过 ≤≤ 操作符写⼊到标准输出 之间,另⼀个线程可能会修改 ai 的值。这对于这个语句没有影响,因为 << 操作符是按值传递参数的 (所以输出就是读取到的 ai 的值),但是重要的是要理解原⼦性的范围只保证了读取是原⼦的。
// 第⼆点值得注意的是最后两条语句---关于 ai 的加减。他们都是 read-modify-write(RMW)操作,各⾃ 原⼦执⾏。这是 std::atomic 类型的最优的特性之⼀:⼀旦 std::atomic 对象被构建,所有成员函 数,包括RMW操作,对于其他线程来说保证原⼦执⾏。

// 相反,使用`volatile`在多线程中实际上不保证任何事情:
volatile int vi(0); //初始化vi为0
vi = 10; //设置vi为10
std::cout << vi; //读vi的值
++vi; //递增vi到11
--vi; //递减vi到10
// 代码的执⾏过程中,如果其他线程读取 vi ,可能读到任何值,⽐如-12,68,4090727。这份代码就是 未定义的,因为这⾥的语句修改 vi ,同时其他线程读取,这就是有没有 std::atomic 或者互斥锁保护 的对于内存的同时读写,这就是数据竞争的定义。

// 假定⼀个任务计算第⼆个 任务需要的重要值。当第⼀个任务完成计算,必须传递给第⼆个任务。Item 39表明⼀种使⽤ std::atomic<bool> 的⽅法来使第⼀个任务通知第⼆个任务计算完成。代码如下
std::atomic<bool> valVailable(false);
auto imptValue = coputeImportantValue(); // compute value
valAvailable = true; // tell other task it's vailable
// ⼈类读这份代码,能看到在 valAvailable 赋值true之前对 imptValue 赋值是重要的顺序,但是所有编 译器看到的是⼀对没有依赖关系的赋值操作。通常来说,编译器会被允许重排这对没有关联的操作。这 意味着,给定如下顺序的赋值操作:
a = b; x = y;
// 编译器可能重排为如下顺序:
x = y; a = b;
// 即使编译器没有重排顺序,底层硬件也可能重排,因为有时这样代码执⾏更快。std::atomic 会限制这种重排序,并且这样的限制之⼀是,在源代码中,对 std::atomic 变量 写之前不会有任何操作。这意味对我们的代码
auto impatValue = computeImportantValue();
valVailable = true;
// 编译器不仅要保证赋值顺序,还要保证⽣成的硬件代码不会改变这个顺序。结果就是,将 valAvaliable 声明为 std::atomic 确保了必要的顺序---- 其他线程看到 imptValue 值保证 valVailable 设为true之后。

// 声明为 volatile 不能保证上述顺序:
volatile bool valAvaliable(false);
auto imptValue = computeImportantValue();
valAvailable = true;
// 这份代码编译器可能将赋值顺序对调,也可能在⽣成机器代码时,其他核⼼看到 valVailable 更改在 imptValue 之前。

// 这种有话讲仅仅在内存表现正常时有效。“特殊”的内存不⾏。最常⻅的“特殊”内存是⽤来mapped I/O的内存。这种内存实际上是与外围设备(⽐如外部传感器或者显⽰器,打印机,⽹络端口) 通信,而不是读写(⽐如RAM)。这种情况下,再次考虑多余的代码:
auto y = x; // read x
y = x; // read x again
// 如果x的值是⼀个温度传感器上报的,第⼆次对于x的读取就不是多余的,因为温度可能在第⼀次和第⼆ 次读取之间变化。类似的,写也是⼀样: x = 10; x = 20;
// 如果x与⽆线电发射器的控制端口关联,则代码时控制⽆线电,10和20意味着不同的指令。优化会更改 第⼀条⽆线电指令。
// volatile 是告诉编译器我们正在处理“特殊”内存。意味着告诉编译器“不要对这块内存执⾏任何优化”。 所以如果x对应于特殊内存,应该声明为 volatile :
volatile int x;
auto y = x;
y = x; // can't be optimized away
x = 10; // can't be optimized away
x = 20;
// 在处理特殊内存时,必须保留看似多余的读取或者⽆效存储的事实,顺便说明了为什么 std::atomic 不 适合这种场景。 std::atomic 类型允许编译器消除此类冗余操作。代码的编写⽅式与使⽤ volatile 的 ⽅式完全不同,但是如果我们暂时忽略它,只关注编译器执⾏的操作,则可以说,
std::atomic<int> x;
auto y = x; //概念上会读x(见下)
y = x; //概念上会再次读x(见下)
x = 10; //写x
x = 20; //再次写x
// 原则上,编译器可能会优化为:
auto y = x; // conceptually read x
x = 20; // write x
// 对于特殊内存,显然这是不可接受的

// 现在,就当他没有优化了,但是对于x是 std::atomic<int> 类型来说,下⾯的两条语句都编译不通 过。
auto y = x; // error
y = x; // error
// 这是因为 std::atomic 类型的拷⻉操作时被删除的(参⻅Item 11)。想象⼀下如果y使⽤x来初始化会 发⽣什么。因为x是 std::atomic 类型,y的类型被推导为 std::atomic (参⻅Item 2)。我之前说了 std::atomic 最好的特性之⼀就是所有成员函数都是原⼦的,但是为了执⾏从x到y的拷⻉初始化是原⼦ 的,编译器不得不⽣成读取x和写⼊x为原⼦的代码。硬件通常⽆法做到这⼀点,因此 std::atomic 不⽀ 持拷⻉构造。处于同样的原因,拷⻉赋值也被delete了,这也是为什么从x赋值给y也编译失败。(移动 操作在 std::atomic 没有显式声明,因此对于Item 17中描述的规则来看, std::atomic 既不提移动构 造器也不提供移动赋值能⼒)。
// 可以将x的值传递给y,但是需要使⽤ std::atomic 的 load和store 成员函数。 load 函数原⼦读取, store 原⼦写⼊。要使⽤x初始化y,然后将x的值放⼊y,代码应该这样写:
std::atomic<int> y(x.load());
y.store(x.load());
// 给出的代码,编译器可以通过存储x的值到寄存器代替读取两次来“优化”:
register = x.load(); //把x读到寄存器
std::atomic<int> y(register); //使用寄存器值初始化y
y.store(register); //把寄存器值存储到y
// 结果如你所⻅,仅读取x⼀次,这是对于特殊内存必须避免的优化(这种优化不允许对 volatile 类型值 执⾏)。
// 事情越辩越明:
// - std::atomic ⽤在并发程序中
// - volatile ⽤于特殊内存场景

// 因为 std::atomic 和 volatile ⽤于不同的⽬的,所以可以结合起来使⽤:
volatile std::atomic<int> vai; // operations on vai are atomic and can't be optimized away
// 这可以⽤在⽐如 vai 变量关联了memory-mapped I/O内存并且⽤于并发程序的场景。

// 最后⼀点,⼀些开发者尤其喜欢使⽤ std::atomic 的 load 和 store 函数即使不必要时,因为这在代码 中显式表明了这个变量不“正常”。强调这⼀事实并⾮没有道理。因为访问 std::atomic 确实会更慢⼀ 些,我们也看到了 std::atomic 会阻⽌编译器对代码执⾏顺序重排。调⽤ load 和 store 可以帮助识别 潜在的可扩展性瓶颈。从正确性的⻆度来看,没有看到在⼀个变量上调⽤ store 来与其他线程进⾏通信 (⽐如flag表⽰数据的可⽤性)可能意味着该变量在声明时没有使⽤ std::atomic 。这更多是习惯问 题,但是,⼀定要知道 atomic 和 volatile 的巨⼤不同。

rem

  • std::atomic是⽤在不使⽤锁,来使变量被多个线程访问。是⽤来编写并发程序的
  • volatile 是⽤在特殊内存的场景中,避免被编译器优化内存。

——– chapter 8 ——– 微调

item 41 如果参数可拷⻉并且移动操作开销很低,总是考虑 直接按值传递

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
// 当你的传值代价很小,传值吧!
class Widget {
public:
template<typename T> //接受左值和右值;
void addName(T&& newName) { //拷贝左值,移动右值;
names.push_back(std::forward<T>(newName)); //std::forward的使用见条款25
}

};
// 这减少了源代码的维护⼯作,但是通⽤引⽤会导致其他复杂性。作为模板, addName 的实现必须放置在 头⽂件中。在编译器展开的时候,可能会不⽌为左值和右值实例化为多个函数,也可能为 std::string 和可转换为 std::string 的类型分别实例化为多个函数(参考Item25)。同时有些参数类型不能通过通⽤引⽤传递(参考Item30),而且如果传递了不合法的参数类型,编译器错误会令⼈⽣畏。(参考Item27)
// 是否存在⼀种编写 addName 的⽅法(左值拷⻉,右值移动),而且源代码和⽬标代码中都只有⼀个函 数,避免使⽤通⽤模板这种特性?答案是是的。你要做的就是放弃你学习C++编程的第⼀条规则,就是 ⽤⼾定义的对象避免传值。像是 addName 函数中的 newName 参数,按值传递可能是⼀种完全合理的策 略。
class Widget {
public:
void addName(std::string newName) { //接受左值或右值;移动它
names.push_back(std::move(newName));
}

}

Widget w;

std::string name("Bart");
w.addName(name); //使用左值调用addName

w.addName(name + "Jenne"); //使用右值调用addName(见下)

// 分别考虑虑三种实现中,两种调⽤⽅式,拷⻉和移动操作的开销。会忽略编译器对于移动和拷⻉操作 的优化。
class Widget { //方法1:对左值和右值重载
public:
void addName(const std::string& newName)
{ names.push_back(newName); } // rvalues
void addName(std::string&& newName)
{ names.push_back(std::move(newName)); }

private:
std::vector<std::string> names;
};

class Widget { //方法2:使用通用引用
public:
template<typename T>
void addName(T&& newName)
{ names.push_back(std::forward<T>(newName)); }

};

class Widget { //方法3:传值
public:
void addName(std::string newName)
{ names.push_back(std::move(newName)); }

};
// - Overloading(重载):⽆论传递左值还是传递右值,调⽤都会绑定到⼀种 newName 的引⽤实现 ⽅式上。拷⻉和复制零开销。左值重载中, newName 拷⻉到 Widget::names 中,右值重载中,移 动进去。开销总结:左值⼀次拷⻉,右值⼀次移动。
// - Using a universal reference(通⽤模板⽅式):同重载⼀样,调⽤也绑定到 addName 的引⽤实 现上,没有开销。由于使⽤了 std::forward ,左值参数会复制到 Widget::names ,右值参数移 动进去。开销总结同重载⽅式。
// - Passing by value(按值传递):⽆论传递左值还是右值,都必须构造 newName 参数。如果传递 的是左值,需要拷⻉的开销,如果传递的是右值,需要移动的开销。在函数的实现中, newName 总 是采⽤移动的⽅式到 Widget::names 。开销总结:左值参数,⼀次拷⻉⼀次移动,右值参数两次 移动。对⽐按引动传递的⽅法,对于左值或者右值,均多出⼀次移动操作

// 总是考虑直接按值传递,如果参数可拷⻉并且移动操作开销很低,原因如下:
// 1. 应该仅consider using pass by value。是的,因为只需要编写⼀个函数,同时只会在⽬标代码中⽣ 成⼀个函数。避免了通⽤引⽤⽅式的种种问题。
// 2. 仅考虑对于可拷贝参数按值传递。不符合此条件的的参数必须只有移动构造函数。回忆⼀ 下“重载”⽅案的问题,就是必须编写两个函数来分别处理左值和右值,如果参数没有拷⻉构造函 数,那么只需要编写右值参数的函数,重载⽅案就搞定了。
class Widget {
public:

void setPtr(std::unique_ptr<std::string>&& ptr)
{ p = std::move(ptr); }

private:
std::unique_ptr<std::string> p;
};


// 调用者可能会这样写:
Widget w;

w.setPtr(std::make_unique<std::string>("Modern C++"));
// 这样,从`std::make_unique`返回的右值`std::unique_ptr<std::string>`通过右值引用被传给`setPtr`,然后移动到数据成员`p`中。整体开销就是一次移动。
// 如果`setPtr`使用传值方式接受形参:
class Widget {
public:

void setPtr(std::unique_ptr<std::string> ptr)
{ p = std::move(ptr); }

};
// 同样的调用就会先移动构造`ptr`形参,然后`ptr`再移动赋值到数据成员`p`,整体开销就是两次移动——是“重载”方法开销的两倍。
// 3. 按值传递应该仅应⽤于哪些cheap to move的参数。当移动的开销较低,额外的⼀次移动才能被开 发者接受,但是当移动的开销很⼤,执⾏不必要的移动类似不必要的复制时,这个规则就不适⽤ 了。
// 4. 你应该只对always copied(肯定复制)的参数考虑按值传递。为了看清楚为什么这很重要,假定在 复制参数到 names 容器前, addName 需要检查参数的⻓度是否过⻓或者过短,如果是,就忽略增 加 name 的操作,那么拷贝到参数的那一次拷贝就被白费了,然而引用会节省这一次拷贝
// 所以,正如我所说,当参数通过赋值进⾏拷⻉时,分析按值传递的开销是复杂的。通常,最有效的经验 就是“在证明没问题之前假设有问题”,就是除⾮已证明按值传递会为你需要的参数产⽣可接受开销的执 ⾏效率,否则使⽤重载或者通⽤引⽤的实现⽅式。

rem

  • 对于可复制,移动开销低,而且⽆条件复制的参数,按值传递效率基本与按引⽤传递效率⼀致,而 且易于实现,⽣成更少的⽬标代码
  • 通过构造函数拷⻉参数可能⽐通过赋值拷⻉开销⼤的多 按值传递会引起切⽚问题,所说不适合基类类型的参数

item 42 考虑使⽤emplacement代替insertion

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// 分析一个push_back:
std::vector<std::string> vs; //std::string的容器
vs.push_back("xyzzy"); //添加字符串字面量
template <class T, //来自C++11标准
class Allocator = allocator<T>>
class vector {
public:

void push_back(const T& x); //插入左值
void push_back(T&& x); //插入右值

};
// 在 vs.push_back("xyzzy") 这个调⽤中,编译器看到参数类型(const char[6])和 push_back 采⽤的 参数类型( std::string 的引⽤)之间不匹配。它们通过从字符串字⾯量创建⼀个 std::string 类型 的临时变量来消除不匹配,然后传递临时变量给 push_back 。换句话说,编译器处理的这个调⽤应该像 这样:
vs.push_back(std::string("xyzzy")); //创建临时std::string,把它传给push_back
// 为了创建 std::string 类型的临时变量,调⽤了 std::string 的构造器,但是这份代码并不仅调⽤了 ⼀次构造器,调⽤了两次,而且还调⽤了析构器。这发⽣在 push_back 运⾏时:
// 1. ⼀个 std::string 的临时对象从字⾯量"xyzzy"被创建。这个对象没有名字,我们可以称为temp,temp通过 std::string 构造器⽣成,因为是临时变量,所以temp是右值。
// 2. temp被传递给 push_back 的右值x重载函数。在 std::vector 的内存中⼀个x的副本被创建。这次 构造器是第⼆次调⽤,在 std::vector 内部重新创建⼀个对象。(将x副本复制到 std::vector 内部的构造器是移动构造器,因为x传⼊的是右值,有关将右值引⽤强制转换为右值的信息,请参 ⻅Item25)。
// 3. 在 push_back 返回之后,temp被销毁,调⽤了⼀次 std::string 的析构器。
// 性能执着者不禁注意到是否存在⼀种⽅法可以获取字符串字⾯量并将其直接 传⼊到步骤2中的 std::string 内部构造,可以避免临时对象temp的创建与销毁。这样的效率最好,性 能执着者也不会有什么意⻅了。所以让我来告诉你如何使得 push_back 达到最⾼的效率。就是不使⽤ push_back ,你需要的是 emplace_back 。
// emplace_back 就是像我们想要的那样做的:直接把传递的参数(⽆论是不是 std::string )直接传递 到 std::vector 内部的构造器。没有临时变量会⽣成:
vs.emplace_back("xyzzy"); // construct std::string inside vs directly from "xyzzy"
// emplace_back 使⽤完美转发,因此只要你没有遇到完美转发的限制(参⻅Item30),就可以传递任何 参数以及组合到 emplace_back 。⽐如,如果你在vs传递⼀个字符和⼀个数量给 std::string 构造器创 建 std::string ,代码如下:
vs.emplace_back(50, 'x'); // insert std::string consisting of 50 'x' characters
// emplace_back 可以⽤于每个⽀持 push_back 的容器。类似的,每个⽀持 push_front 的标准容器⽀持 emplace_front 。每个⽀持 insert (除了 std::forward_list 和 std::array )的标准容器⽀持 emplace。 关联容器提供 emplace_hint 来补充带有“hint”迭代器的插⼊函数, std::forward_list 有 emplace_after 来匹配 insert_after 。
// 使得emplacement函数功能优于insertion函数的原因是它们灵活的接口。insertion函数接受对象来插 ⼊,而emplacement函数接受构造器接受的参数插⼊。这种差异允许emplacement函数避免临时对象 的创建和销毁。
std::string queenOfDisco("Donna Summer");
vs.push_back(queenOfDisco); // copy-construct queenOfDisco
vs.emplace_back(queenOfDisco); // ditto
// 因此,emplacement函数可以完成insertion函数的所有功能。并且有时效率更⾼,⾄上在理论上,不会 更低效。那为什么不在所有场合使⽤它们?但是实际,区别还是有的。在当前标准 库的实现下,有些场景,就像预期的那样,emplacement执⾏性能优于insertion,但是,有些场景反而insertion更快。因 此,⼤致的调⽤建议是:通过benchmakr测试来确定emplacment和insertion哪种更快。
// 还有⼀种启发式的⽅法来帮助你确定是否应该使⽤emplacement。 如果下列条件都能满⾜,emplacement会优于insertion:
// case1: 值是通过构造器添加到容器,而不是直接赋值。例⼦就像本Item刚开始的那样(添加"xyzzy"到 std::string的std::vector 中)。
// case2: 传递的参数类型与容器的初始化类型不同。再次强调,emplacement优于insertion通常基于以下 事实:当传递的参数不是容器保存的类型时,接口不需要创建和销毁临时对象。当将类型为T的对 象添加到container时,没有理由期望emplacement⽐insertion运⾏的更快,因为不需要创建临时 对象来满⾜insertion接口。
// case3: 容器不拒绝重复项作为新值。

// 在决定是否使⽤emplacement函数时,需要注意另外两个问题。
// ⾸先是资源管理。
std::list<std::shared_ptr<Widget>> ptrs;
// 然后你想添加⼀个通过⾃定义deleted释放的 std::shared_ptr (参⻅Item 19)。Item 21说明你应该 使⽤ std::make_shared 来创建 std::shared_ptr ,但是它也承认有时你⽆法做到这⼀点。⽐如当你 要指定⼀个⾃定义deleter时。这时,你必须直接创建⼀个原始指针,然后通过 std::shared_ptr 来管 理。
// 然后你想添加⼀个通过⾃定义deleted释放的 std::shared_ptr
// 如果⾃定义deleter是这个函数
void killWidget(Widget* pWidget);
// 使⽤insertion函数的代码如下:
ptrs.push_back(std::shared_ptr<Widget>(new Widget, killWidget));
// 也可以像这样
ptrs.push_back({new Widget, killWidget});

// 即使发⽣了异常,没有资源泄露:在调⽤ push_back 中通过 new Widget 创建的 Widget 在 std::shared_ptr 管理下⾃动销毁。⽣命周期良好。
// 考虑使⽤ emplace_back 代替 push_back:
ptrs.emplace_back(new Widget, killWidget);
// 1. 通过`new Widget`创建的原始指针完美转发给`emplace_back`中,*list*节点被分配的位置。如果分配失败,还是抛出内存溢出异常。
// 2. 当异常从`emplace_back`传播,原始指针是仅有的访问堆上`Widget`的途径,但是因为异常而丢失了,那个`Widget`的资源(以及任何它所拥有的资源)发生了泄漏。
// 在这个场景中,⽣命周期不良好,这个失误不能赖 std::shared_ptr 。 std::unique_ptr 使⽤⾃定义deleter也会有同样的问题。根本上讲,像 std::shared_ptr和std::unique_ptr 这样的资源管理类的 有效性取决于资源被⽴即传递给资源管理对象的构造函数。实际上,这就是 std::make_shared和 std::make_unique 这样的函数如此重要的原因。当根据正确的⽅式确保获取资源和连接到资源管理 对象上之间⽆其他操作,添加资源管理类型对象到容器中,emplacement函数不太可能胜过insertion函 数。

// emplacement函数的第⼆个值得注意的⽅⾯是它们与显式构造函数的交互。对于C++11正则表达式的⽀ 持,假设你创建了⼀个正则表达式的容器: push_back会立马报错,但是emplace不会
regexes.emplace_back(nullptr); // add nullptr to container of regexes?
regexes.push_back(nullptr); // error! won't compile

std::regex r = nullptr; // error! won't compile
regexes.push_back(nullptr); // error
// 在上⾯的代码中,我们要求从指针到 std::regex 的隐式转换,但是显式构造的要求拒绝了此类转换。
std::regex r1 = nullptr; // error ! won't compile
std::regex r2(nullptr); // compiles
// 在标准的官⽅术语中,⽤于初始化r1的语法是所谓的复制初始化。相反,⽤于初始化r2的语法是(也被 称为braces)被称为直接初始化。复制初始化不是显式调⽤构造器的,直接初始化是。这就是r2可以编 译的原因。
// 然后回到 push_back和 emplace_back ,更⼀般来说,insertion函数对⽐emplacment函数。emplacement函数使⽤直接初始化,这意味着使⽤显式构造器。
regexes.emplace_back(nullptr); // compiles. Direct init permits use of explicit std::regex ctor taking a pointer
regexes.push_back(nullptr); // error! copy init forbids use of that ctor
// 要汲取的是,当你使⽤emplacement函数时,请特别小⼼确保传递了正确的参数,因为即使是显式构造 函数,编译器可以尝试解释你的代码称为有效的(译者注:这⾥意思是即使你写的代码逻辑上不对,显 式构造器时编译器可能能解释通过即编译成功)


rem

  • 原则上,emplacement函数有时会⽐insertion函数⾼效,并且不会更差
  • 实际上,当执⾏如下操作时,emplacement函数更快
      1. 值被构造到容器中,而不是直接赋值
      1. 传⼊的类型与容器类型不⼀致
      1. 容器不拒绝已经存在的重复值
  • emplacement函数可能执⾏insertion函数拒绝的显⽰构造