Effective Modern C++


参考书籍:《Effective Modern C++: 42 specific ways to improve your use of C++11 and C++14》(美) Scott Meyers [著]

Chapter 1    Deducing Types

Item 1: Understand template type deduction.

对于函数模板

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

和对其的调用

f(expr); // call f with some expression

在编译时期,编译器使用 expr 来推断 T 和 ParamType 的类型,它们的类型通常是不同的,因为 ParamType 通常包含修饰,譬如 const- 或 引用。譬如对于下例

template<typename T>
void f(const T& param); // ParamType is const T&

int x = 0;
f(x); // call f with an int

T 被推导为 int,而 ParamType 被推导为 const int&。
推导出的 T 的类型不仅与 expr 相关,也与 ParamType 的类型相关,存在三种情况:

Case 1    ParamType 是指针或引用类型,但不是 universal reference;

此种情况下,类型推导规则为

  • 如果 expr 的类型是引用,则忽略引用部分;
  • expr 的类型和 ParamType 的类型进行模式匹配来确定 T。

例如对于模板和一些变量声明

template<typename T>
void f(T& param); // param is a reference

int x = 27; // x is an int
const int cx = x; // cx is a const int
const int& rx = x; // rx is a read-only view of x

推导所得的 param 和 T 的类型如下

f(x); // T is int, param's type is int&
f(cx); // T is const int, param's type is const int&
f(rx); // T is const int, param's type is const int&

上述例子展示的都是左值引用参数,但类型推导对于右值引用参数完全一致。
若是将函数 f 的参数类型从 T& 改为 const T&,则情况有些许变化:

template<typename T>
void f(const T& param); // param is now a ref-to-const

int x = 27; // as before
const int cx = x; // as before
const int& rx = x; // as before

f(x); // T is int, param's type is const int&
f(cx); // T is int, param's type is const int&
f(rx); // T is int, param's type is const int&

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

template<typename T>
void f(T* param); // param is now a pointer

int x = 27; // as before
const int *px = &x; // px is a ptr to a read-only view of x

f(&x); // T is int, param's type is int*
f(px); // T is const int, param's type is const int*

C++ 对于引用和指针的类型推导规则是如此地自然,结果正是我们想从类型推导系统中所获得的。

Case 2    ParamType 是 universal reference;

对于接受 universal reference 参数的模板一切就没那么明显了,因为左值参数受到了特殊对待:

  • 如果 expr 是左值,T 和 ParamType 都被推导为左值引用;
  • 如果 expr 是右值,适用通常的类型推导规则。
template<typename T>
void f(T&& param); // param is now a universal reference

int x = 27; // as before
const int cx = x; // as before
const int& rx = x; // as before

f(x); // x is lvalue, so T is int&, param's type is also int&
f(cx); // cx is lvalue, so T is const int&, param's type is also const int&
f(rx); // rx is lvalue, so 1 T is const int&, param's type is also const int&
f(27); // 27 is rvalue, so T is int, param's type is therefore int&&

Case 3    ParamType 既不是指针也不是引用。

ParamType 即不是指针也不是引用时,就是值传递的情况:

template<typename T>
void f(T param); // param is now passed by value

这意味着 param 将会是任何传递进来的对象的一个拷贝,param 将会是一个新的对象的事实驱动着 T 如何从 expr 推导所得的规则:

  • 如果 expr 的类型是引用,忽略引用部分;
  • 在忽略引用之后,如果 expr 是 const,忽略之;expr 是 volatile,也忽略之。

因此

int x = 27; // as before
const int cx = x; // as before
const int& rx = x; // as before

f(x); // T and param are both int
f(cx); // T and param are again both int
f(rx); // T and param are still both int

注意到即使 cx 和 rx 带有 const 修饰,param 不是 const,这是因为 param 是一个完全不依赖于 cx 和 rx 的对象 —— 它们的一个拷贝,cx 和 rx 不能被修改的事实不能说明 param 也应当如此。

考虑 expr 是一个指向只读变量的常指针,能意识到 expr 是值传递给 param 显得很重要:

template<typename T>
void f(T param); // param is still passed by value

const char* const ptr = "Fun with pointers"; // ptr is const pointer to const object
f(ptr); // pass arg of type const char * const

当 ptr 传递给 f 时,指针被拷贝给 param,即指针自身是值传递的,因此指针的 const 特性会被忽略,为 param 推导出的类型为 const char*。

数组参数

当数组作为参数值传递给模板函数时会被推导成为指针类型,即:

template<typename T>
void f(T param); // template with by-value parameter

const char name[] = "J. P. Briggs"; // name's type is const char[13]
f(name); // name is array, but T deduced as const char*

虽然函数不能声明形参为真正的数组,但是能够声明形参为数组的引用,因此:

template<typename T>
void f(T& param); // template with by-reference parameter

f(name); // pass array to f
// T is deduced to be const char [13], and the type of f's parameter (a reference to this array) is const char (&)[13].

声明数组引用使得能够创建一个能够推断出数组所包含元素数目的模板:

template<typename T> // return size of an array as a compile-time constant
constexpr std::size_t arraySize(T (&)[N])
{
    return N;
}

此处使用 constexpr 让函数的返回值能够在编译期确定。有了这个模板,就可以采用一个数组去创建与其 size 相同的数组:

int keyVals[] = { 1, 3, 7, 9, 11, 22, 35 }; // keyVals has 7 elements
int mappedVals[arraySize(keyVals)]; // so does mappedVals
std::array<int, arraySize(keyVals)> mappedVals2; // mappedVals2' size is 7

函数参数

除了数组会被降为指针,函数也会降为函数指针,结果就是:

template<typename T>
void f1(T param); // in f1, param passed by value
template<typename T>
void f2(T& param); // in f2, param passed by ref

void someFunc(int, double); // someFunc is a function; type is void(int, double)
f1(someFunc); // param deduced as ptr-to-func; type is void (*)(int, double)
f2(someFunc); // param deduced as ref-to-func; type is void (&)(int, double)

须记

  • When deducing types for parameters that are pointers and non-universal references, whether the initializing expression is a reference is ignored.
  • When deducing types for parameters that are universal references, lvalue arguments yield lvalue references, and rvalue arguments yield rvalue references.
  • When deducing types for by-value parameters, whether the initializing expression is a reference or is const is ignored.
  • During type deduction, arguments that are array or function names decay to pointers, unless they’re used to initialize references.

Item 2: Understand auto type deduction.

当一个变量使用 auto 声明时,auto 扮演着模板中的 T 的角色,变量的指定类型则相当于 ParamType。这很容易描述:

auto x = 27; // Here, the type specifier for x is simply auto by itself.
const auto cx = x; // In this declaration, the type specifier is const auto.
const auto& rx = x; // And here, the type specifier is const auto&.

为推断 x,cx,rx 的类型,编译器的表现好像这儿的各个声明都存在着一个有着相应初始化表达式函数模板并进行调用:

template<typename T> // conceptual template for deducing x's type
void func_for_x(T param);
func_for_x(27); // conceptual call: param's deduced type is x's type

template<typename T> // conceptual template for deducing cx's type
void func_for_cx(const T param);
func_for_cx(x); // conceptual call: param's deduced type is cx's type

template<typename T> // conceptual template for deducing rx's type
void func_for_rx(const T& param);
func_for_rx(x); // conceptual call: param's deduced type is rx's type

对于 Item 1 中针对模板实例化过程中类型推导的三种情况,auto 也完全适用。只有一种情况例外,C++11 提供了 {} 形式的初始化方式:

int x1 = {27};
int x2{27};

上述无论是 x1 还是 x2 都被声明为了一个整型变量,但若把 int 换成 auto

auto x1 = {27};
auto x2{27};

意义就大相径庭了,这声明了一个类型为 std::initializer_list 的只包含单个元素27的变量。这是由于 auto 的一条特殊类型推导规则所致,当采用 {} 方式初始化 auto 声明的变量时,推导所得的类型是 std::initializer_list,若这样的类型无法被推导出时,编译报错,譬如:

auto x3 = {1, 2, 3.0}; // error! can't deduce T for std::initializer_list<T>

对于 C++14,其允许 auto 来暗示一个函数的返回类型应当被自动推导,并且 C++14 lambda 表达式可以在参数声明中使用 auto,然而这些使用情况下采用模板类型推导的方式,而非 auto 类型推导,因此 {} 初始化方式会导致类型推导失败:

auto createInitList()
{
 return { 1, 2, 3 }; // error: can't deduce type for { 1, 2, 3 }
}

std::vector v;
auto resetV = [&v](const auto& newValue) { v = newValue; }; // C++14 only
resetV( { 1, 2, 3 } ); // error! can't deduce type for { 1, 2, 3 }

须记

  • auto type deduction is normally identical to template type deduction.
  • The sole exception is that in variable declarations using auto and braced initializers, auto deduces std::initializer_lists.
  • Template type deduction fails for braced initializers.

Item 3: Understand decltype.

给定一个变量名或表达式,decltype 展示变量或表达式的类型,通常情况下与预测结果完全一致:

const int i = 0; // decltype(i) is const int
bool f(const Widget& w); // decltype(w) is const Widget&, decltype(f) is bool(const Widget&)
struct Point { int x, y; }; // decltype(Point::x) is int, decltype(Point::y) is int
Widget w; // decltype(w) is Widget
if (f(w)) … // decltype(f(w)) is bool
template<typename T> // simplified version of std::vector
class vector
{
public:
    T& operator[](std::size_t index);
};
vector<int> v; // decltype(v) is vector<int>
if (v[0] == 0) … // decltype(v[i]) is int&

在 C++11 中,decltype 的主要用途在于声明返回类型依赖于参数类型的函数模板,比如对于 std::vector 而言,当 T 不为 bool 的时候,operator[] 返回 T&,当 T 为 bool 的时候,operator[] 返回的并非是 bool&。因此这儿 operator[] 的返回类型依赖于 T 的类型,decltype 能够计算出返回类型,这需要用到 refinement:

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

函数名之前的 auto 与类型推导一点关系都没有,它仅暗示 C++11 的 trailing return type 被使用,即函数的返回类型将在参数列表之后被声明。C++11 允许 single-statement 的 lambda 的返回类型自动推导,并且 C++14 扩展到了所有的 lambdas 和所有函数。在例子 authAndAccess 中,C++14 能省略 trailing return type,仅保留前面的 auto

template<typename Container, typename Index> // C++14 only, and not quite correct
auto authAndAccess(Container& c, Index i)
{
    authenticateUser();
    return c[i]; // return type deduced from c[i]
}

有点儿令人惊奇的是,使用 auto 自动推导函数返回类型的规则与模板类型推导规则相同。正如在 Item 1 中所述,在模板类型推导过程中,初始化表达式的引用特性会被忽略,考虑对于下面的客户代码:

template<typename Container, typename Index> // C++14 only, and not quite correct
auto authAndAccess(Container& c, Index i)
{
    authenticateUser();
    return c[i]; // return type deduced from c[i]
}

这儿 d[5] 返回的是 int&,但 auto 返回类型推导将去除引用,因此产生的返回类型为 int,这个 int 作为函数返回值,是一个右值,对一个右值赋值在 C++ 中是被禁止的。
那么如何才能得到与 c[i] 返回类型完全一致的类型呢?C++14 提供了 decltype(auto) 实际上表达了完美的意义:auto 指明类型应当被自动推导,decltype 表示 decltype 规则应当被应用于推导过程中。于是可将 authAndAccess 写成:

template<typename Container, typename Index> // C++14 only; works, but still requires refinement
decltype(auto) authAndAccess(Container& c, Index i)
{
    authenticateUser();
    return c[i];
}

decltype(auto) 的用途不局限于函数返回类型,它也可以用来方便地声明变量让其与另一个变量的类型一致:

Widget w;
const Widget& cw = w;
auto myWidget1 = cw; // auto type deduction: myWidget1's type is Widget
decltype(auto) myWidget2 = cw; // decltype type deduction: myWidget2's type is const Widget&

上述定义中 container 是以左值非const引用传递地,因为返回的引用允许对容器进行修改,但这意味着不能传递右值容器给这个函数,右值不能绑定到左值引用,除非是左值const引用。
对于下述从临时容器拷贝一个元素的客户代码:

std::deque<std::string> makeStringDeque(); // factory function
// make copy of 5th element of deque returned from makeSrtingDeque
auto s = authAndAccess(makeStringDeque(), 5);

为支持这种使用,需要修改 c 的声明使其能够接受左值和右值,这意味着 c 需要为 universal reference:

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

仅剩的需要对模板实现进行的更新是让其遵从 Item 27 的警告,将 std::forward 应用于 universal references:

template<typename Container, typename Index> // final C++14 version
decltype(auto) authAndAccess(Container&& c, Index i)
{
    authenticateUser();
    return std::forward<Container>(c)[i];
}

template<typename Container, typename Index> // final C++11 version
auto authAndAccess(Container&& c, Index i)
-> decltype(std::forward<Container>(c)[i])
{
    authenticateUser();
    return std::forward<Container>(c)[i];
}

对于左值表达式,decltype 确保其所报告的类型总是左值引用,这通常不会有什么问题,但有一种隐式行为需要注意,在

int x = 0;

中,x 是变量名,因此 decltype 是 int,但给 x 包裹上圆括号,就产生了表达式,伴随着名字,x 是左值,C++ 定义 (x) 也为左值,因此 decltype((x)) 是 int&。给变量名加上圆括号能改变 decltype 报告的类型!在 C++14 中,函数中 return 语句的写法将改变函数的返回类型:

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

decltype(auto) f2()
{
    int x = 0;
    return (x); // decltype((x)) is int&, so f2 returns int&
}
 须记
  • decltype almost always yields the type of a variable or expression without any modifications.
  • For lvalue expressions of type T other than names, decltype always reports a type of T&.
  • C++14 supports decltype(auto), which, like auto, deduces a type from its initializer, but it performs the type deduction using the decltype rules.

 Item 4: Know how to view deduced types.

获知编译器自动推导出的类型信息的途径有三种:编辑代码时、编译代码时、程序运行时。

IDE Editors

某些 IDE(譬如Microsoft Visual Studio)在鼠标悬停在变量名上时会显示变量类型,譬如对于如下代码

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

能够显示 x 的推导类型为 int,y 的推导类型为 const int*

Compiler Diagnostics

当编译器报错时,错误信息中含有类型信息,为演示此过程,声明一个类模板而不去定义它:

template<typename T> // declaration only for TD;
class TD; // TD == "Type Displayer"

试图去实例化这个模板就会得到错误信息,为了显示 x 和 y 的类型,只需试图采用它们的类型去实例化 TD:

TD<decltype(x)> xType; // elicit errors containing x's and y's types;
TD<decltype(y)> yType;

上述代码在一个编译器中显示出的错误信息如下

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

在另外一个编译器中显示出的错误信息为

error: 'xType' uses undefined class 'TD<int>'
error: 'yType' uses undefined class 'TD<const int *>'

Runtime Output

typeid 操作符将产生一个 std::type_info 对象,后者有一个 name 函数可以显示类型:

std::cout << typeid(x).name() << '\n'; // display types for x and y
std::cout << typeid(y).name() << '\n';

由于这种方式显示的类型信息不一定准确可靠,不建议使用。

Compilers Language Extension

gcc 和 clang 编译器提供了一个构造 __PRETTY_FUNCTION__,Microsoft 的编译器提供 __FUNCSIG__,这些构造要么是变量要么是宏,其值是所在函数的签名:

template<typename T>
void f(const T& param)
{
#if defined(__GNUC__) // For gcc and clang
    std::cout << __PRETTY_FUNCTION__ << '\n';
#elif defined(_MSC_VER) // For Microsoft
    std::cout << __FUNCSIG__ << '\n';
#endif
}

std::vector<Widget> createVec(); // factory function
const auto vw = createVec(); // init vw through factory return
if (!vw.empty()) { f(&vw[0]); } // call f

gcc 的结果:

void f(const T&) [with T = const Widget*]

Microsoft’s FUNCSIG 产生的输出:

void __cdecl f<const classWidget*>(const class Widget *const &)

clang 的输出直接显示 param 的类型,但不包含 T 的类型:

void f(const Widget *const &)

须记

  • Deduced types can often be seen using IDE editors, compiler error messages, typeid, and language extensions such as __PRETTY_FUNCTION__ and __FUNCSIG__.
  • The results of such tools may be neither helpful nor accurate, so an understanding of C++’s type deduction rules remains essential.

Chapter 2    auto

Item 5: Prefer auto to explicit type declarations.

 

Item 6: Be aware of the typed initializer idiom.

 

Chapter 3    From C++98 to C++11 and C++14

Item 7: Distinguish () and {} when creating objects.

 

Item 8: Prefer nullptr to 0 and NULL.

 

Item 9: Prefer alias declarations to typedefs.

 

Item 10: Prefer scoped enums to unscoped enums.

Item 11: Prefer deleted functions to private undefined ones.

 

Item 12: Declare overriding functions override.

 

Item 13: Prefer const_iterators to iterators.

 

Item 14: Use constexpr whenever possible.

 

Item 15: Make const member functions thread-safe.

 

Item 16: Declare functions noexcept whenever possible.

 

Item 17: Consider pass by value for cheap-to-move parameters that are always copied.

 

Item 18: Consider emplacement instead of insertion.

 

Item 19: Understand special member function generation.

 

Chapter 4    Smart Pointers

Item 20: Use std::unique_ptr for exclusive-ownership resource management.

 

Item 21: Use std::shared_ptr for shared-ownership resource management.

 

Item 22: Use std::weak_ptr for std::shared_ptr-like pointers that can dangle.

 

Item 23: Prefer std::make_unique and std::make_shared to direct use of new.

 

Item 24: When using the Pimpl Idiom, define special member functions in the implementation file.

 

Chapter 5    Rvalue References, Move Semantics, and Perfect Forwarding

Item 25: Understand std::move and std::forward.

Item 26: Distinguish universal references from rvalue references.

 

Item 27: Use std::move on rvalue references, std::forward on universal references.

 

Item 28: Avoid overloading on universal references.

 

Item 29: Familiarize yourself with alternatives to overloading on universal references.

 

Item 30: Understand reference collapsing.

 

Item 31: Assume that move operations are not present, not cheap, and not used.

 

Item 32: Familiarize yourself with perfect forwarding failure cases.

 

Chapter 6    Lambda Expressions

Item 33: Avoid default capture modes.

 C++11 中的 lambda 存在两种变量捕获方式:值捕获和引用捕获。引用捕获容易导致失效引用,而值引用则不会存在该问题。

如果 lambda 创建的闭包的生存期超过了其所捕获的局部变量引用,那么引用将会失效。假定有一个容器存储了一系列用来 filter 的函数,每个 filter 函数接受一个 int 参数并返回 bool 值

using FilterContainer = std::vector<std::function<bool(int)> >; // typedef
FilterContainer filters; // filtering funcs
void addDivisorFilter()
{
    auto calc1 = computeSomeValue1();
    auto calc2 = computeSomeValue2();
    auto divisor = computeDivisor(calc1, calc2);
    filters.emplace_back([&divisor](int value) { // danger!
        return value % divisor == 0;             // divisor might dangle!
    });
}

函数 addDivisorFilter() 试图向容器中加入动态确定 divisor 的 filter 函数,lambda 引用局部变量 divisor,当函数返回的时候,divisor 就失效了,导致 filter 函数在刚创建的时候就产生了未定义的行为。一个解决办法就是将捕获模式改成值捕获

filters.emplace_back([=](int value) {
    return value % divisor == 0; // divisor can't dangle!
});

通常,值捕获方式并不能完全解决失效问题。在值捕获一个指针的时候,指针被拷贝进了 lambda 生成的闭包中,但是无法阻止 lambda 外的代码 delete 该指针导致指针悬挂。

假定类 Widget 负责向容器 filters 中添加新的 filter 函数:

class Widget
{
public:
    void addFilter() const // add an entry to filters
    {
        filters.emplace_back([=](int value) {
            return value % divisor == 0;
        });
    }

private:
    int divisor; // used in Widget's filter
};

这看上去似乎很安全,然而捕获只适用于在 lambda 创建时其可见的非静态局部变量(包含形参),这儿 divisor 不是局部变量,而是类的数据成员,问题在于这儿存在一个隐式的 raw 指针: this,每个非静态成员函数都有一个 this 指针,在 addFilter() 函数内,编译器将 divisor 替换成了 this->divisor,这相当于:

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

当采用智能指针的时候,譬如

void doSomeWork()
{
    auto pw = std::make_unique<Widget>();
    pw->addFilter(); // add filter that uses Widget::divisor
}

创建的 filter 函数包含一个 Widget 对象的 this 指针,当函数结束的时候, Widget 对象由于 std::unique_ptr 对生存期的管理而被销毁,随后 filters 容器就包含了一个有悬挂指针的函数。可以建立一个想要捕获对象的本地拷贝来解决该问题:

void Widget::addFilter() const
{
    auto divisorCopy = divisor; // copy data member
    filters.emplace_back([divisorCopy](int value) { // capture the copy
        return value % divisorCopy == 0;
    });
}

值捕获模式创建的闭包似乎是 self-contained,然而事实并非如此,除却局部变量和形参外,lambda 也依赖静态存储的对象,包括全局声明的或是静态声明的,这些对象可以在 lambda 中使用,但是不能被捕获。

须记

  • Default by-reference capture can lead to hidden dangling references.
  • Default by-value capture is susceptible to hidden dangling pointers (especially this), and it misleadingly suggests that lambdas are self-contained.

Item 34: Use init capture to move objects into closures.

有时值捕获和引用捕获都无法满足要求,如果想要将一个 move-only 的对象传入闭包,C++11 对此无能为力,但 C++14 提供了解决途径:init capture。使用它使得能够指定 (1) lambda生成的闭包类中数据成员的名称; (2) 一个初始化数据成员的表达式。

能够使用 init capture 将 std::unique_ptr 移入闭包:

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

等号左侧的 pw 是闭包类中的数据成员名称,右侧的 pw 指的是在 lambda 前面声明的对象,左侧的范围域与闭包类一致,右侧的范围域则与 lambda 被定义的地方相同。因此 “pw = std::move(pw)” 含义为 “create a data member pw in the closure, and initialize that data member with the result of applying std::move to the local variable pw.” 上述的注释 “configure *pw” 暗示对象有被进行过某种修改,如果不需要修改刚刚生成的对象,则局部变量 pw 是不必要的:

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

C++14 的 “capture” 可被认为是 C++11 的泛化,因为在 C++11 中不能捕获表达式的结果,因此 init capture 的另一个名称是 “generalized lambda capture“。

倘若编译器停留在仅 C++11 的支持呢?记得 lambda 表达式只是一种生成类和该类型的对象的简单方式,当无法借助 lambda 时,可以手工实现一个类:

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

private:
    DataType pw;
};

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

如果坚持要采用 lambda,移动捕获可以在 C++11 中通过 “(1) 将想要捕获的对象移动到一个由 std::bind 产生的函数对象中; (2) 给 lambda 一个被捕获对象的引用.” 被模拟。

std::vector<double> data; // object to be moved into closure
auto func = [data = std::move(data)] { // C++14 init capture
    /* uses of data */
};
auto func = std::bind([](std::vector<double>& data) { // C++11 emulation of init capture
    /* uses of data */
}, std::move(data));

std::bind 返回的对象称为绑定对象,一个绑定对象包含所有传递给 std::bind 的参数的拷贝,对于左值参数,对应的对象在绑定对象中被拷贝构造,对于右值参数,则被移动构造,上述第二个参数为右值,因此 data 被移动构造到了绑定对象中。

以下三点需要了解清楚:

  • 不可能将一个对象移动构造到 C++11 的闭包中,但是可能将一个对象移动构造到 C++11 的绑定对象中;
  • 在 C++11 中模拟移动捕获包含将一个对象移动构造到一个绑定对象中,随后传递移动构造的对象的引用给 lambda;
  • 因为绑定对象的生存期与闭包的生存期相同,这使得能够认为绑定对象中的对象就好像处于闭包中一样。

第二个 std::unique_ptr 的模拟例子:

auto func = [pw = std::make_unique<Widget>()] { // C++14
    return pw->isValidated() && pw->isArchived();
};
auto func = std::bind([](std::unique_ptr<Widget>& pw) {
    return pw->isValidated() && pw->1 isArchived();
}, std::make_unique<Widget>());

在 Item 36 中,主张使用 lambda 而不是 std::bind,这种情况是两个在 C++11 中的例外,在 C++14 中,init capture 和 auto parameters 消除了这些例外情况。

须记

  • Use C++14’s init capture to move objects into closures.
  • In C++11, emulate init capture via hand-written classes or std::bind.

Item 35: Use decltype on auto&& parameters to std::forward them.

C++14 一个令人兴奋的特性就是 generic lambdas —— 使用 auto 作为参数规格的 lambdas,该 特性通过将 lambda 生成的闭包类的 operator() 声明为模板来实现,例如下面的一个 lambda 及其闭包类的 operator():

auto f = [](auto x) { return func(normalize(x)); };

class SomeCompilerGeneratedClassName
{
public:
    template <typename T>
    auto operator()(T x) const
    { return func(normalize(x)); }
};

上例中 lambda 仅将参数 x 直接传递给 normalize 函数,如果 normalize 对待左值和右值不一致,则上述 lambda 的写法就不对,因为它总是传递左值给 normalize,即便传递给它的是右值,正确的写法方式是将 x perfect-forward 给 normalize,这样做要求 x 首先得是一个 universal 引用,而且还需通过 std::forward 来传递给 normalize,于是

auto f = [](auto&& x) { return func(normalize(std::forward<???>(x))); };

通常,采用 perfect forwarding 时,位于一个模板参数为 T 的模板函数中,于是直接写成 std::forward<T> 即可,但是这里没有 T。幸运的是,decltype 提供了解决方式,如果 x 是一个左值,decltype(x) 将产生左值引用类型,否则 decltype(x) 将产生右值引用类型。Item 30 解释到当调用 std::forward 时,对于左值,类型参数应当为左值引用,对于右值,类型参数应当为 non-reference。这样对于右值,decltype(x) 产生的类型与 std::forward 期望的不一致,但是请看下面这个摘自 Item 30 的例子:

template <typename T>
T&& forward(T&& param)
{
    return static_cast<T&&>(param);
}

如果客户代码想要 perfect forward 右值类型,它将以类型 Widget 来调用 std::forward,模板会被实例化为

Widget&& forward(Widget&& param) // instantiation of std::forward when T is Widget
{
    return static_cast<Widget&&>(param);
}

但当客户代码将 T 指定为了右值引用类型 Widget&&,在实例化之后且在 reference collapsing 之前

Widget&& && forward(Widget&& && param) // instantiation of std::forward when T is Widget&&
{
    return static_cast<Widget&& &&>(param);
}

在 reference-collapsing 之后,则与当 T 为 Widget 时 std::forward 实例化出来的结果一样,这意味着以 non-reference 类型调用 std::forward 产生的结果与以右值引用类型调用它一样。因此无论左值还是右值,decltype(x) 给的结果就是我们想要的,perfect-forwarding lambda 可以写成

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

三个点表示接受任意多个参数,因为 C++14 的 lambda 也同变参模板一样可变。

须记

  • Use decltype on auto&& parameters to std::forward them.

Item 36: Prefer lambdas to std::bind.

偏好 lambdas 的一个重要原因是它们的可读性更好,假定有一个设定闹钟的函数

using Time = std::chrono::time_point<std::chrono::steady_clock>;
using Duration = std::chrono::steady_clock::duration;
enum class Sound { Beep, Siren, Whistle };
void setAlarm(Time t, Sound s, Duration d);

// setSoundL ("L" for "lambda") is a function object allowing a sound
// to be specified for a 30-sec alarm to go off an hour after it's set
auto setSoundL = [](Sound s) {
    using namespace std::chrono;
    setAlarm(steady_clock::now() + hours(1), s, seconds(30));
};

上述参数 s 传递到 setAlarm() 函数中的行为显而易见,对应的 std::bind 版本为

auto setSoundB = std::bind(setAlarm, steady_clock::1 now() + hours(1), std::placeholders::_1, seconds(30));

从上式看不出传递给 setSoundB() 的第二个参数的类型,读者还需查询 setAlarm() 的声明才知。如果 setAlarm() 被重载

enum class Volume { Normal, Loud, LoudPlusPlus };
void setAlarm(Time t, Sound s, Duration d, Volume v);

lambda 版本照常工作,std::bind 版本则编译错误,因为编译器无法决定哪个 setAlarm() 函数应当传递给 std::bind,为了使得编译通过,还需进行强制类型转换

using SetAlarm3ParamType = void(*)(Time t, Sound s, Duration d);
auto setSoundB = std::bind(static_cast<SetAlarm3ParamType>(setAlarm), steady_clock::now() + hours(1), std::placeholders::_1, seconds(30));

这带来了另一个 lambdas 和 std::bind 区别,在 setSoundL() 内,setAlarm() 是普通函数调用,编译器很有可能对其进行内联操作,而在这里的 setSoundB() 内,setAlarm() 是通过函数指针来调用的,因此不会被内联,这意味着 lambda 可能产生更快些的代码。

setAlarm() 是只涉及一个函数调用的例子,当事情更复杂的时候,lambda 的优势更加明显,考虑下面这个 C++14 的 lambda 及完成相同功能的 std::bind 版本

auto betweenL = [lowVal, highVal](int val) {
    return lowVal <= val && val <= highVal;
};

auto betweenB = std::bind(std::logical_and<bool>(),
    std::bind(std::greater_equal<int>(), std::placeholders::_1, lowVal),
    std::bind(std::less_equal<int>(), std::placeholders::_1, highVal));

假定有函数能够用于创建 Widgets 的压缩拷贝

enum class CompLevel { Low, Normal, High }; // compression level
Widget compress(const Widget& w, CompLevel lev); // make compressed copy of w

我们要创建一个函数对象能够指定一个 Widget 对象 w 应当如何压缩,std::bind 能够创建这样一个对象

Widget w;
auto compressRateB = std::bind(compress, w, std::placeholders::_1);

现在我将 w 传递给 std::bind,它被存储在 compressRateB 对象内以便之后调用 compress 时使用,那么它是怎样被存储的,值存储还是引用存储,这有区别,因为如果 w 在调用 std::bind 和调用 compressRateB  之间被修改,通过引用存储将会反映出改变,而值存储不会,答案是它是通过值存储的,对比之下,在 lambda 方式中,如何存储是显而易见的

// w is captured by value; lev is passed by value
auto compressRateL = [w](CompLevel lev) {
    return compress(w, lev);
};

同样地,将参数传递给 lambda 的方式是明显的,而传递给 std::bind 的方式则不明显

compressRateL(CompLevel::High); // arg is passed by value
compressRateB(CompLevel::High); // how is arg passed?

答案是所有传递给绑定对象的参数都是通过引用传递,因为这些对象的 operator() 函数采用 perfect forwarding。相比 lambda,采用 std::bind 可读性差,表达性差,在 C++14 中没有理由使用 std::bind,然而在 C++11 中有两个例外:

  • 移动捕获。C++11 通过 lambda 和 std::bind 来模拟 init capture,如 Item 34 所述;
  • 多态函数对象。因为绑定对象的 operator() 函数采用 perfect forwarding,它能够接受任何类型,而 C++11 的 lambda 不支持 auto 作为参数。

须记

  • Lambas are more readable, more expressive, and more efficient than using std::bind.
  • In C++11 only, std::bind may be useful for implementing move capture or for binding objects with templatized function call operators.

Chapter 7    The Concurrency API

Item 37: Prefer task-based programming to thread-based.

如果想要异步地允许函数 doAsyncWord(),可以有两种途径,一种是创建一个 std::thread,采用基于线程的方式,另一种是将 doAsyncWord 传递给 std::async,采用基于任务的方式:

int doAsyncWork();
std::thread t(doAsyncWork);         // thread-based
auto fut = std::async(doAsyncWork); // task-based

这里 doAsyncWork() 函数有一个返回值,在基于线程的调用中,没有直接的方式来获取到,在基于任务的方式中,从 std::async 返回的 future 提供了 get() 函数,于是可直接获取到,当 doAsyncWork() 函数抛出一个异常的时候,get() 函数变得更为重要,因为它提供了访问方式,然而在基于线程的方式中,若 doAsyncWork() 函数抛出异常,则程序终止(通过调用 std::terminate 或是 std::set_terminate 指定的函数)。

基于线程和基于任务的程序的一个基础区别是基于任务的方式提供了高层次的抽象,使得不用自行管理线程。在并发 C++ 软件中,线程有三个含义:

  • 硬件线程是实际执行计算的线程,机器架构在每个 CPU 核心上提供了一个或多个硬件线程;
  • 软件线程(OS 线程或系统线程)是那些操作系统管理调度在硬件线程上执行的线程,显然可以创建比硬件线程数量更多的软件线程,因为当一个软件线程被阻塞的时候,另一个非阻塞的线程可以被执行;
  • std::threads 是下层软件线程的句柄对象,一些 std::threads 代表 “null” 句柄,即没有软件线程与之对应,譬如它们处于默认构造状态,或是移动到了另一个 std::thread,或是被 joined 或 detached。

软件线程的资源是有限的,如果创建数量超过了系统能提供的上限,一个 std::system_error 异常会被抛出,即便是对想要执行的函数指定了 noexcept 关键字。即便是没有用完线程,也会面临 oversubscription 的问题,当 ready-to-run 的软件线程多于硬件线程时,线程调度器将软件线程时分到硬件上,当一个线程的时间片结束时,另一个开始时,执行一个上下文切换的过程,这样的切换增加了整体线程管理的负载,尤其是一个软件线程运行之上的硬件线程所处的核心与上一个时间片该软件线程对应的不同,此时不但对那个软件线程而言 CPU 缓存通常失效,并且在那个核心上运行一个新的软件线程污染了之前运行的那个很有可能被重新调度到此运行的软件线程的 CPU 缓存。避免 oversubscription 是非常困难的,因为软件线程与硬件线程的最优比例取决于软件线程运行的频率(I/O密集型和计算密集型区别很大),取决于上下文切换的代价和软件线程运用 CPU 缓存的有效性,进一步,硬件线程的数量及 CPU 缓存的细节取决于机器架构。

采用 std::async 就把线程管理的责任交给了 C++ 标准库的实现,此时不必再担心线程用完的异常,因为采用默认 launch policy 时,std::async 并不保证一定创建一个新的软件线程,而是允许调度器安排所指定的函数在当前线程运行,调度器在系统是 oversubscribed 和 out of threads 时有着充分的自由。

基于任务的设计解放了手工的线程管理,并提供了一种自然的检查异步函数的返回结果(返回值或异常),然而下述几种情况应当采用线程更为合适:

  • 需要访问底层线程实现的 API。C++ 并发 API 通常由低层平台相关的 API 来实现,pthreads 或 Windows’ Threads,这些 API 更为丰富(譬如 C++ 未提供线程优先级和亲和性),std::thread 对象为此提供了 native_handle 成员函数,std::futures 没有相应的功能;
  • 需要根据应用最优化线程的使用;
  • 需要实现超出 C++ 并发 API 外的线程技术,譬如线程池。

须记

  • The std::thread API offer no direct way to get return values from asynchronously-run functions, and if those functions throw, the program is terminated.
  • Thread-based programming calls for manual management of thread exhaustion, oversubscription, load balancing, and adaptation to new platforms.
  • Task-based programming via std::async with the default launch policy suffers from none of these drawbacks.

Item 38: Specify std::launch::async if asynchronicity is essential.

存在两种标准的 std::async launch policy,由 std::launch 命名空间里的一个枚举值决定

  • std::launch::async launch policy 意味着任务函数必须被异步运行;
  • std::launch::deferred launch policy 意味着任务函数仅在 std::async 返回的 future 对象调用 get() 或 wait() 函数时才在同个线程中同步执行,如果 get() 或 wait() 函数没有被调用,那么任务函数将永远不会被执行。

std::async’s default launch policy 是上述二个枚举值的“或”,这使得允许任务函数被异步或同步运行,这种灵活性允许线程管理模块拥有完全的线程创建销毁、oversubscription 避免、负载均衡的责任。

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 deferred

默认的 launch policy 无法与线程局部变量混用,因为这意味着 f 读写 thread local storage (TLS),无法预测哪些线程变量会被访问

auto fut = std::async(f); // TLS for f possibly for different thread, but possibly for current thread

在一个 std::launch::deferred 任务上调用 wait_for 或 wait_until 的时候,事情变得更为复杂

void f() // f sleeps for 1 second, then returns
{
    std::this_thread::sleep_for(std::chrono::seconds(1));
}
auto fut = std::async(f); // run f asynchronously (conceptually)
// loop until f has finished running... which may never happen!
while (fut.wait_for(std::chrono::milliseconds(100)) != std::future_status::ready) {}

若 f 是异步运行的,没有任何问题,若 f 是 deferred,fut.wait_for 总是返回 std::future_status::deferred,永远都不会与 std::future_status::ready 相等,因此循环不会终止。

解决方案就是确认对应的 std::async 调用,任务是否被 deferred,如果是,则避免进入 timeout-based 循环:

if (fut.wait_for(std::chrono::seconds(0)) != std::future_status::deferred)
{
    while (fut.wait_for(std::chrono::milliseconds(100)) != std::future_status::ready) {}
}
else
   ...

当下属条件都满足的时候,默认的 launch policy 才运行良好

  • 任务不需要与调用线程异步运行;
  • 哪个线程的线程局部变量被读写无关紧要;
  • 确保 get 和 wait 总会被返回的 future 对象调用,或是可以接受任务不被运行;
  • 使用 wait_for 和 wait_until 的代码总是确认 deferred 状态。

若上述条件不满足,确保任务异步运行的方式就是指定 std::launch::async 作为第一个参数

auto fut = std::async(std::launch::async, f); // launch f asynchronously

须记

  • The default launch policy for std::async permits both asynchronous and synchronous task execution.
  • This flexibility leads to uncertainty when accessing thread_locals, implies that the task may never execute, and complicates program logic for timeout-based wait calls.
  • Specify std::launch::async if asynchronous task execution is essential.

Item 39: Make std::threads unjoinable on all paths.

每个线程对象都有两种状态:joinable 或 unjoinable。一个 joinable 的线程对象对应于一个底层的正在运行或是能够运行异步线程(阻塞或是等待调度),unjoinable 的线程对象包含

  • 默认构造的 std::threads,这些 std::threads 没有函数去运行,因此不对应底层的执行线程;
  • 移动之后的 std::threads,移动的结果就是之前对应的底层的执行线程现在对应于新的 std::thread;
  • 已经被 joined 的 std::threads,经过 join,底层的执行线程已经结束运行;
  • 已经被 detached 的 std::threads,经过 detach,std::thread 对象和对应底层执行线程间的连接被切断。

std::thread 的 joinability 如此重要的一个原因是如果一个 joinable 线程的析构函数被调用时,程序将终止。

constexpr int tenMillion = 10000000;
// returns whether computation was performed
bool doWork(std::function<bool(int)> filter, int maxVal = tenMillion)
{
    std::vector<int> vals; // values that satisfy filter
    std::thread t([&filter, maxVal, &vals] { // compute vals' content
        for (auto i = 0; i <= maxVal; ++i)
            if (filter(i))
                vals.push_back(i);
    });
    if (conditionsAreSatisfied())
    {
        t.join();    // let t finish
        performComputation(vals);
        return true; // computation was performed
    }
    return false; // computation was not performed
}

上述代码中,vals 在执行计算之前需要 filter,而 filtering 和 conditionsAreSatisfied() 都非常费时,因此并行执行它们,如果 conditionsAreSatisfied() 返回 true,代码没有问题,如果其返回 false 或是抛出异常,则 std::thread 对象 t 的析构函数在退出函数被调用时,对象 t 是 joinable,这导致程序终止运行。

为什么 std::thread 的析构函数表现如此,因为另外两种选项更为糟糕:

  • 一个隐式的 join。std::thread 的析构函数等待底层异步线程的执行完成,这导致难以追踪的性能异常。譬如,如果 conditionsAreSatisfied() 已经返回 false 了,而 doWork 仍然在等待所有 vals 的 filter 操作完成,这是不合理的。
  • 一个隐式的 detach。切断 std::thread 对象和其底层对应执行线程的连接,执行线程仍继续运行,这会导致调试问题更为糟糕。譬如,在 doWork 中,vals 是局部变量,它被引用捕获,并在 lambda 中被修改,假定在 lambda 异步运行时,conditionsAreSatisfied() 返回 false,doWork 随即退出,本地变量 vals 被销毁,然而异步线程仍在运行。

这就需要在使用 std::thread 对象时,在其定义域范围内的每条退出路径都使其 unjoinable。正如 Item 37 所述,一种更好的建议就是尽量不用 std::thread,使用 futures 代替它们,futures 在它们的析构函数中不会调用 std::terminate,它们的析构函数的行为将在 Item 40 中阐述。

须记

  • Make std::threads unjoinable on all paths.
  • join-on-destruction can lead to difficult-to-debug performance anomalies.
  • detach-on-destruction can lead to difficult-to-debug undefined behavior.

Item 40: Be aware of varying thread handle destructor behavior.

一个 future 的析构函数有时执行一个隐式的 join,有时执行一个隐式的 detach,有时什么都不做,它永远都不会导致程序终止。

一个 future 是一个通信信道的一端,在此信道上,callee 传输结果给 caller,callee 通常异步运行,它通过 std::promise 对象将计算结果写入通信信道,caller 通过一个 future 对象读取结果。但是 callee 的运行结果保存在哪里?callee 会在 caller 调用对应 future 的 get 之前结束,因此不能保存在 callee 的 std::promise 中,同时因为 std::future 可以被用来创建 std::shared_future,原始的 std::future 被析构后,创建出来的 std::shared_future 或许被多次拷贝,并不是所有的返回结果类型都能被拷贝(譬如 std::unique_ptr),而且结果的生存期必须和最后一个引用它的 future 一样久,那么这么多个 futures 中的哪个应当存储结果?事实上,存储结果的地方在 caller 和 callee 之外的 shared state,它们三者的关系如下图所示:

caller-shared_state-callee

一个 future 的析构函数的行为与 future 相关联的 shared state 密切相关:

  • 对于 std::async 运行 nondeferred 任务时,最后一个引用 shared state 的 future 的析构函数阻塞直到任务完成,实质上,这样一个 future 的析构函数在异步运行任务的线程上执行了一个隐式的 join;
  • 其他所有 futures 的析构函数只是简单地销毁 future 对象,对异步运行的任务,这相当于在底层线程上执行了一个隐式的 detach。对于 deferred 任务,若这是最后一个 future,意味着 deferred 任务永远不会运行。

future 析构函数的通常行为只是销毁 future 的数据成员,并将其对应的 shared state 对象的引用计数减一,仅当下述情况通常行为出现例外:

  • 该 future 对应的 shared state 由于一个 std::async 调用而被创建;
  • 任务的 launch policy 是 std::launch::async;
  • 该 future是最后一个对应于 shared state 的 future,对于 std::shared_futures,如果其他对应相同 shared state 的 std::shared_futures 被销毁时,该 future 按照通常行为方式被销毁。

经常可以听到这样的例外被总结为 “Futures from std::async block in their destructors.”

因为 future 没有提供能够确定某个 future 对应的 shared state 是否是通过 std::async 调用而创建的 API,于是给定一个 future 对象,无法得知其析构函数是否会等待异步任务完成而阻塞,这儿有一些有趣的隐含

// this container might block in its dtor, because one or more contained futures
// could refer to shared state for a non-deferred task launched via std::async
std::vector<std::future<void> > futs;
class Widget // Widget objects might block in their dtors
{
    std::shared_future<double> fut;
};
void doWork(std::future<int> fut); // fut might block in its dtor

只有调用 std::async 产生的 shared state 有这样的特殊行为,但是 std::packaged_task 也可以创建 shared state,一个 std::packaged_task 对象将一个为了异步执行的函数打包,将其运行结果放入一个 shared state,对应于这个 shared state 的 future 可以通过 std::packaged_task 的 get_future 函数来获得:

int calcValue(); // func to run
std::packaged_task<int()> pt(calcValue); // wrap calcValue so it can run asynchronously
auto ptFut = pt.get_future(); // get future for pt

一旦创建,std::packaged 任务 pt 能够在一个线程上运行,std::packaged 不能拷贝,因此需要被转换成右值类型后传递给 std::thread 的构造函数:

std::thread t(std::move(pt)); // run pt on t

这里,我们知道 future 对象 ptFut 不对应于一个 std::async 调用而创建的 shared state,因此其析构函数不会阻塞,std::packaged_task::get_future 返回的 future 对象是一个 move-only 的类型,为使得 doWork 的调用能够通过编译

doWork(std::move(ptFut)); // doWork won't block on t

须记

  • Future destructors normally just destroy the future’s data members.
  • The final future referring to a shared state for a non-deferred task launched via std::async blocks until the task completes.

Item 41: Consider void futures for one-shot event communication.

有些时候需要一个任务告知另一个异步运行的任务一个特定事件已经发生,因为第二个任务在等待该事件发生。在下文中,称前者为检测任务,后者为反应任务。

一种方式是采用条件变量

std::condition_variable cv; // condvar for event
std::mutex m; // mutex for use with cv
task detect
{
    ... // detect event
    cv.notify_one(); // tell reacting task
}
task react
{
    std::unique_lock<std::mutex> lk(m); // lock mutex
    cv.wait(lk); // wait for notify; this isn’t correct!
    ... // react to event (mutex is locked)
}

这样做的第一个问题是代码异味,mutexes 用于控制共享数据的访问,但是这里没有此需要,第二个问题是如果检测任务在反应任务 wait 之前发出通知,反应任务会错过该通知,随后永远等待,第三个问题是 wait 有时会因为 spurious wakeups 而失败,尽管 C++11 提供了 lambda 等可调用实体的方式辨别 wait 的条件

cv.wait(lk, []{ return whether the event has occurred; });

然而这要求反应任务知道什么时候它所等待的条件变为 true,然而这里是检测任务负责辨别事件什么时候发生。

第二种方式是采用共享的布尔标志,初始化为 false,检测任务在检测到事件发生后设置其为 true,另外一方的反应任务通过轮询该标志就能得知事件已经发生

std::atomic<bool> flag(false); // shared flag
task detect
{
    ... // detect event
    flag = true; // tell reacting task
}
task react
{
    ... // prepare to react
    while (!flag); // wait for event
    ... // react to event
}

这种方式是轮询的代价很高,因为运行反应任务的线程一直占用着一个硬件线程。

另外有一种方式能够真正阻塞任务而没有条件变量的问题,即让反应任务等待一个由检测任务来设置的 future,Item 40 解释了 future 是通信信道的接收端,发送端是 std::promise,这样的通信信道能够被用于任何需要传输信息的场合。设计非常直截了当,检测任务有一个 std::promise 对象,反应任务有一个 std::future 对象,当检测任务检测到特殊事件发生时,设置 std::promise(写入通信信道),同时反应任务在等待它的 future,该等待阻塞直至 std::promise 被设置为止。std::promise 和 std::future 都有一个类型参数,该类型参数表明传输数据的类型,然而此处,并没有数据需要被传输,我们需要的类型暗示没有数据要通过通信信道,void 正是这样的类型,于是

std::promise<void> p; // promise for communications channel
task detect
{
    ... // detect event
    p.set_value(); // tell reacting task
}
task react
{
    ... // prepare to react
    p.get_future().wait(); // wait on future corresponding to p
    ... // react to event
}

Item 40 解释了在 std::promise 和 std::future 之间有一个 shared state,而它通常是动态分配的,因此这儿有堆内存分配与回收的代价,更为重要的是,std::promise 只能被设置一次,std::promise 和 std::future 之间的通信信道只能被使用一次,这是这种方式最大的局限性。(一个条件变量可以被重复通知,一个布尔标志可以被反复清除、设置)

这种限制在某些情况下并不要紧,假定需要创建一个刚刚创建就处于挂起状态的线程

std::promise<void> p;
void react(); // func for reacting task
void detect() // func for detecting task
{
    std::thread t([] {
        p.get_future().wait(); // suspend t until future is set
        react();
    });
    ...       // here, t is suspended prior to call to react
    p.set_value(); // unsuspend t (and thus call reset)
    ...       // do additional work
    t.join(); // make t unjoinable
}

若是想要挂起不止一个反应任务,可以采用 std::shared_futures 代替 std::futures,这可以通过 std::futures 的 share() 函数来完成,每个反应线程需要自己的对应于 shared state 的 std::shared_future 拷贝,因此从 share() 获得的 std::shared_future 以值捕获的方式传递给 lambda

void detect() // now for multiple reacting tasks
{
    auto sf = p.get_future().share(); // sf's type is std::shared_future<void>
    std::vector<std::thread> vt;      // container for reacting threads
    for (int i = 0; i < threadsToRun; ++i)
        vt.emplace_back([sf] {
            sf.wait(); // wait on local copy of sf
            react();
        });
    ... // detect hangs if this "..." code throws!
    p.set_value();     // unsuspend all threads
    ...
    for (auto& t : vt) // make all threads
        t.join();
}

须记

  • For simple event communication, condvar-based designs require a superfluous mutex, impose constraints on the relative progress of detecting and reacting tasks, and require reacting tasks to verify that the event has taken place.
  • Designs employing a boolean flag avoid those problems, but are based on polling, not blocking.
  • Using std::promises and futures dodges these issue, but it uses heap memory for shared states, and it’s limited to one-shot communication.

Item 42: Use std::atomic for concurrency, volatile for special memory.

考虑下面使用 std::atomic 的代码:

std::atomic<int> ai(0); // atomically initialize ai to 0
ai = 10;         // atomically set ai to 10
std::cout << ai; // atomically read ai's value
++ai;            // atomically 1 increment ai to 11
ai--;            // atomically decrement ai to 10

在执行这些语句的时候,其他读取 ai 的线程可能看到 0、10 或 11,其他值是不可能的。这个例子中,一个注意点是 “std::cout << ai;” 中读取 ai 值的操作是原子的,整条语句不是原子的,另一个注意点是自增和自减操作是 read-modify-write (RMW) 操作,原子量类型保证其成员函数(包括这些 RMW 操作的)对于其他线程来说是原子的。

std::atomic<int> ac(0); // "atomic counter"
volatile int vc(0);     // "volatile counter"
/*----- Thread 1 ----- */    /*------- Thread 2 ------- */
          ac++;                          ac++;
          vc++;                          vc++;

当两个线程都执行完毕时,ac 的值必然是 2,而 vc 的值则是不可预测的,譬如

  1. 线程 1 读取 vc 的值,是 0;
  2. 线程 2 读取 vc 的值,仍是 0;
  3. 线程 1 增加 0 至 1,写回 vc;
  4. 线程 2 增加 0 值 1,写回 vc。

vc 最终的值为 1,即便它被增加了两次。

下面是一个类似 Item 41 中的通信例子

std::atomic<bool> valAvailable(false);
auto imptValue = computeImportantValue(); // compute value
valAvailable = true; // tell other task it's available

从代码阅读上可知,对 imptValue 的赋值在对 valAvailable 的赋值之前进行,但是对于编译器而言,它看到的是不相关的两个变量的赋值,通常而言,编译器允许对不相关的赋值进行重排序,即便是编译器不做,底层的硬件可能也会做,因为有时这样会使得代码运行地更快。然而,std::atomics 强加了代码能够被怎样重排序的限制,其中一个是源代码中没有在 std::atomic 被写之前的代码发生在 std::atomic 被写之后,也就是说,编译器必须维持对 imptValue 和 valAvailable 赋值的先后顺序,而且生成的代码也必须确保底层的硬件也维持这样的顺序。声明变量为 volatile 则无法强加这样的重排序限制

volatile bool valAvailable(false);
auto imptValue = computeImportantValue();
valAvailable = true; // other threads might see this assignment before the one to imptValue!

这两个问题 —— 无法确保操作的原子性和对代码重排序的限制不充分 —— 解释了为什么 volatile 在并发编程中并不怎么有用。

对原子变量的赋值,需要使用 std::atomic 的两个成员函数 load 和 store

std::atomic<int> x;
std::atomic<int> y(x.load()); // read x
y.store(x.load());            // read x again

编译器基于读取 x 的 x.load() 操作和将值存储到 y 的操作是分离的,因此没有必要将这两个操作合成一个原子操作,编译器或许会将 x 的值存到一个寄存器里来代替两次读取过程

register = x.load(); // read x into register
std::atomic<int> y(register); // init y with register value
y.store(register);   // store register value into y

对于 memory-mapped I/O,这是不允许的优化类型,因为 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 is for data accessed from multiple threads without using mutexes.
  • volatile is for memory where reads and 1 writes should never be optimized away.