From 0919dc53c3aee2e563199b8f2e7e18a036d25317 Mon Sep 17 00:00:00 2001 From: kelthuzadx Date: Fri, 25 Nov 2022 05:43:31 +0000 Subject: [PATCH] deploy: cb23d19dba5cd1cbc71a9e37bc78a3ffc663ba0b --- 1.DeducingTypes/item1.html | 14 +- 1.DeducingTypes/item2.html | 14 +- 1.DeducingTypes/item3.html | 14 +- 1.DeducingTypes/item4.html | 4 +- 2.Auto/item5.html | 10 +- 2.Auto/item6.html | 6 +- 3.MovingToModernCpp/item10.html | 8 +- 3.MovingToModernCpp/item11.html | 4 +- 3.MovingToModernCpp/item13.html | 2 +- 3.MovingToModernCpp/item14.html | 4 +- 3.MovingToModernCpp/item16.html | 2 +- 3.MovingToModernCpp/item17.html | 8 +- 3.MovingToModernCpp/item7.html | 8 +- 3.MovingToModernCpp/item8.html | 2 +- 3.MovingToModernCpp/item9.html | 4 +- 4.SmartPointers/item18.html | 12 +- 4.SmartPointers/item19.html | 18 +- 4.SmartPointers/item20.html | 6 +- 4.SmartPointers/item21.html | 16 +- 4.SmartPointers/item22.html | 8 +- 5.RRefMovSemPerfForw/item23.html | 14 +- 5.RRefMovSemPerfForw/item24.html | 6 +- 5.RRefMovSemPerfForw/item25.html | 16 +- 5.RRefMovSemPerfForw/item26.html | 10 +- 5.RRefMovSemPerfForw/item27.html | 42 ++-- 5.RRefMovSemPerfForw/item28.html | 16 +- 5.RRefMovSemPerfForw/item29.html | 6 +- 5.RRefMovSemPerfForw/item30.html | 8 +- 6.LambdaExpressions/item31.html | 8 +- 6.LambdaExpressions/item32.html | 6 +- 6.LambdaExpressions/item33.html | 8 +- 6.LambdaExpressions/item34.html | 6 +- 7.TheConcurrencyAPI/Item35.html | 4 +- 7.TheConcurrencyAPI/item36.html | 10 +- 7.TheConcurrencyAPI/item37.html | 10 +- 7.TheConcurrencyAPI/item38.html | 16 +- 7.TheConcurrencyAPI/item39.html | 8 +- 7.TheConcurrencyAPI/item40.html | 6 +- 8.Tweaks/item41.html | 8 +- 8.Tweaks/item42.html | 8 +- print.html | 380 +++++++++++++++---------------- 41 files changed, 380 insertions(+), 380 deletions(-) diff --git a/1.DeducingTypes/item1.html b/1.DeducingTypes/item1.html index 5fae3da..21957b3 100644 --- a/1.DeducingTypes/item1.html +++ b/1.DeducingTypes/item1.html @@ -166,7 +166,7 @@ f(x); //用一个int类型的变量调用f

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

我们可能很自然的期望T和传递进函数的实参是相同的类型,也就是,Texpr的类型。在上面的例子中,事实就是那样:xintT被推导为int。但有时情况并非总是如此,T的类型推导不仅取决于expr的类型,也取决于ParamType的类型。这里有三种情况:

@@ -224,7 +224,7 @@ f(px); //T是const int,param的类型是const int*

到现在为止,你会发现你自己打哈欠犯困,因为C++的类型推导规则对引用和指针形参如此自然,书面形式来看这些非常枯燥。所有事情都那么理所当然!那正是在类型推导系统中你所想要的。

情景二:ParamType是一个通用引用

-

模板使用通用引用形参的话,那事情就不那么明显了。这样的形参被声明为像右值引用一样(也就是,在函数模板中假设有一个类型形参T,那么通用引用声明形式就是T&&),它们的行为在传入左值实参时大不相同。完整的叙述请参见Item24,在这有些最必要的你还是需要知道:

+

模板使用通用引用形参的话,那事情就不那么明显了。这样的形参被声明为像右值引用一样(也就是,在函数模板中假设有一个类型形参T,那么通用引用声明形式就是T&&),它们的行为在传入左值实参时大不相同。完整的叙述请参见Item24,在这有些最必要的你还是需要知道:

条款二十八:理解引用折叠

Item 28: Understand reference collapsing

-

Item23中指出,当实参传递给模板函数时,被推导的模板形参T根据实参是左值还是右值来编码。但是那条款并没有提到只有当实参被用来实例化通用引用形参时,上述推导才会发生,但是有充分的理由忽略这一点:因为通用引用是Item24中才提到。回过头来看,对通用引用和左值/右值编码的观察意味着对于这个模板,

+

Item23中指出,当实参传递给模板函数时,被推导的模板形参T根据实参是左值还是右值来编码。但是那条款并没有提到只有当实参被用来实例化通用引用形参时,上述推导才会发生,但是有充分的理由忽略这一点:因为通用引用是Item24中才提到。回过头来看,对通用引用和左值/右值编码的观察意味着对于这个模板,

template<typename T>
 void func(T&& param);
 
@@ -4083,7 +4083,7 @@ func(w); //用左值调用func;T被推导为Widget&

如果我们用T推导出来的类型(即Widget&)初始化模板,会得到:

void func(Widget& && param);
 
-

引用的引用!但是编译器没有报错。我们从Item24中了解到因为通用引用param被传入一个左值,所以param的类型应该为左值引用,但是编译器如何把T推导的类型带入模板变成如下的结果,也就是最终的函数签名?

+

引用的引用!但是编译器没有报错。我们从Item24中了解到因为通用引用param被传入一个左值,所以param的类型应该为左值引用,但是编译器如何把T推导的类型带入模板变成如下的结果,也就是最终的函数签名?

void func(Widget& param);
 

答案是引用折叠reference collapsing)。是的,禁止声明引用的引用,但是编译器会在特定的上下文中产生这些,模板实例化就是其中一种情况。当编译器生成引用的引用时,引用折叠指导下一步发生什么。

@@ -4092,7 +4092,7 @@ func(w); //用左值调用func;T被推导为Widget&

如果任一引用为左值引用,则结果为左值引用。否则(即,如果引用都是右值引用),结果为右值引用。

在我们上面的例子中,将推导类型Widget&替换进模板func会产生对左值引用的右值引用,然后引用折叠规则告诉我们结果就是左值引用。

-

引用折叠是std::forward工作的一种关键机制。就像Item25中解释的一样,std::forward应用在通用引用参数上,所以经常能看到这样使用:

+

引用折叠是std::forward工作的一种关键机制。就像Item25中解释的一样,std::forward应用在通用引用参数上,所以经常能看到这样使用:

template<typename T>
 void f(T&& fParam)
 {
@@ -4115,7 +4115,7 @@ T&& forward(typename
                        remove_reference<Widget&>::type& param)
 { return static_cast<Widget& &&>(param); }
 
-

std::remove_reference<Widget&>::type这个type trait产生Widget(查看Item9),所以std::forward成为:

+

std::remove_reference<Widget&>::type这个type trait产生Widget(查看Item9),所以std::forward成为:

Widget& && forward(Widget& param)
 { return static_cast<Widget& &&>(param); }
 
@@ -4142,7 +4142,7 @@ T&& forward(remove_reference_t<T>& param) return static_cast<T&&>(param); }
-

引用折叠发生在四种情况下。第一,也是最常见的就是模板实例化。第二,是auto变量的类型生成,具体细节类似于模板,因为auto变量的类型推导基本与模板类型推导雷同(参见Item2)。考虑本条款前面的例子:

+

引用折叠发生在四种情况下。第一,也是最常见的就是模板实例化。第二,是auto变量的类型生成,具体细节类似于模板,因为auto变量的类型推导基本与模板类型推导雷同(参见Item2)。考虑本条款前面的例子:

Widget widgetFactory();     //返回右值的函数
 Widget w;                   //一个变量(左值)
 func(w);                    //用左值调用func;T被推导为Widget&
@@ -4165,13 +4165,13 @@ func(widgetFactory());      //用又值调用func;T被推导为Widget
 
Widget&& w2 = widgetFactory()
 

没有引用的引用,这就是最终结果,w2是个右值引用。

-

现在我们真正理解了Item24中引入的通用引用。通用引用不是一种新的引用,它实际上是满足以下两个条件下的右值引用:

+

现在我们真正理解了Item24中引入的通用引用。通用引用不是一种新的引用,它实际上是满足以下两个条件下的右值引用:

  • 类型推导区分左值和右值T类型的左值被推导为T&类型,T类型的右值被推导为T
  • 发生引用折叠

通用引用的概念是有用的,因为它使你不必一定意识到引用折叠的存在,从直觉上推导左值和右值的不同类型,在凭直觉把推导的类型代入到它们出现的上下文中之后应用引用折叠规则。

-

我说了有四种情况会发生引用折叠,但是只讨论了两种:模板实例化和auto的类型生成。第三种情况是typedef和别名声明的产生和使用中(参见Item9)。如果,在创建或者评估typedef过程中出现了引用的引用,则引用折叠就会起作用。举例子来说,假设我们有一个Widget的类模板,该模板具有右值引用类型的嵌入式typedef

+

我说了有四种情况会发生引用折叠,但是只讨论了两种:模板实例化和auto的类型生成。第三种情况是typedef和别名声明的产生和使用中(参见Item9)。如果,在创建或者评估typedef过程中出现了引用的引用,则引用折叠就会起作用。举例子来说,假设我们有一个Widget的类模板,该模板具有右值引用类型的嵌入式typedef

template<typename T>
 class Widget {
 public:
@@ -4189,7 +4189,7 @@ public:
 
typedef int& RvalueRefToT;
 

这清楚表明我们为typedef选择的名字可能不是我们希望的那样:当使用左值引用类型实例化Widget时,RvalueRefToT左值引用typedef

-

最后一种引用折叠发生的情况是,decltype使用的情况。如果在分析decltype期间,出现了引用的引用,引用折叠规则就会起作用(关于decltype,参见Item3

+

最后一种引用折叠发生的情况是,decltype使用的情况。如果在分析decltype期间,出现了引用的引用,引用折叠规则就会起作用(关于decltype,参见Item3

请记住:

  • 引用折叠发生在四种情况下:模板实例化,auto类型推导,typedef与别名声明的创建和使用,decltype
  • @@ -4200,7 +4200,7 @@ public:

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

    移动语义可以说是C++11最主要的特性。你可能会见过这些类似的描述“移动容器和拷贝指针一样开销小”, “拷贝临时对象现在如此高效,写代码避免这种情况简直就是过早优化”。这种情绪很容易理解。移动语义确实是这样重要的特性。它不仅允许编译器使用开销小的移动操作代替大开销的复制操作,而且默认这么做(当特定条件满足的时候)。以C++98的代码为基础,使用C++11重新编译你的代码,然后,哇,你的软件运行的更快了。

    移动语义确实可以做这些事,这把这个特性封为一代传说。但是传说总有些夸大成分。这个条款的目的就是给你泼一瓢冷水,保持理智看待移动语义。

    -

    让我们从已知很多类型不支持移动操作开始这个过程。为了升级到C++11,C++98的很多标准库做了大修改,为很多类型提供了移动的能力,这些类型的移动实现比复制操作更快,并且对库的组件实现修改以利用移动操作。但是很有可能你工作中的代码没有完整地利用C++11。对于你的应用中(或者代码库中)的类型,没有适配C++11的部分,编译器即使支持移动语义也是无能为力的。的确,C++11倾向于为缺少移动操作的类生成它们,但是只有在没有声明复制操作,移动操作,或析构函数的类中才会生成移动操作(参考Item17)。数据成员或者某类型的基类禁止移动操作(比如通过delete移动操作,参考Item11),编译器不生成移动操作的支持。对于没有明确支持移动操作的类型,并且不符合编译器默认生成的条件的类,没有理由期望C++11会比C++98进行任何性能上的提升。

    +

    让我们从已知很多类型不支持移动操作开始这个过程。为了升级到C++11,C++98的很多标准库做了大修改,为很多类型提供了移动的能力,这些类型的移动实现比复制操作更快,并且对库的组件实现修改以利用移动操作。但是很有可能你工作中的代码没有完整地利用C++11。对于你的应用中(或者代码库中)的类型,没有适配C++11的部分,编译器即使支持移动语义也是无能为力的。的确,C++11倾向于为缺少移动操作的类生成它们,但是只有在没有声明复制操作,移动操作,或析构函数的类中才会生成移动操作(参考Item17)。数据成员或者某类型的基类禁止移动操作(比如通过delete移动操作,参考Item11),编译器不生成移动操作的支持。对于没有明确支持移动操作的类型,并且不符合编译器默认生成的条件的类,没有理由期望C++11会比C++98进行任何性能上的提升。

    即使显式支持了移动操作,结果可能也没有你希望的那么好。比如,所有C++11的标准库容器都支持了移动操作,但是认为移动所有容器的开销都非常小是个错误。对于某些容器来说,压根就不存在开销小的方式来移动它所包含的内容。对另一些容器来说,容器的开销真正小的移动操作会有些容器元素不能满足的注意条件。

    考虑一下std::array,这是C++11中的新容器。std::array本质上是具有STL接口的内置数组。这与其他标准容器将内容存储在堆内存不同。存储具体数据在堆内存的容器,本身只保存了指向堆内存中容器内容的指针(真正实现当然更复杂一些,但是基本逻辑就是这样)。这个指针的存在使得在常数时间移动整个容器成为可能,只需要从源容器拷贝保存指向容器内容的指针到目标容器,然后将源指针置为空指针就可以了:

    std::vector<Widget> vm1;
    @@ -4225,7 +4225,7 @@ auto aw2 = std::move(aw1);
     

    注意aw1中的元素被移动到了aw2中。假定Widget类的移动操作比复制操作快,移动Widgetstd::array就比复制要快。所以std::array确实支持移动操作。但是使用std::array的移动操作还是复制操作都将花费线性时间的开销,因为每个容器中的元素终归需要拷贝或移动一次,这与“移动一个容器就像操作几个指针一样方便”的含义相去甚远。

    另一方面,std::string提供了常数时间的移动操作和线性时间的复制操作。这听起来移动比复制快多了,但是可能不一定。许多字符串的实现采用了小字符串优化(small string optimization,SSO)。“小”字符串(比如长度小于15个字符的)存储在了std::string的缓冲区中,并没有存储在堆内存,移动这种存储的字符串并不必复制操作更快。

    SSO的动机是大量证据表明,短字符串是大量应用使用的习惯。使用内存缓冲区存储而不分配堆内存空间,是为了更好的效率。然而这种内存管理的效率导致移动的效率并不必复制操作高,即使一个半吊子程序员也能看出来对于这样的字符串,拷贝并不比移动慢。

    -

    即使对于支持快速移动操作的类型,某些看似可靠的移动操作最终也会导致复制。Item14解释了原因,标准库中的某些容器操作提供了强大的异常安全保证,确保依赖那些保证的C++98的代码在升级到C++11且仅当移动操作不会抛出异常,从而可能替换操作时,不会不可运行。结果就是,即使类提供了更具效率的移动操作,而且即使移动操作更合适(比如源对象是右值),编译器仍可能被迫使用复制操作,因为移动操作没有声明noexcept

    +

    即使对于支持快速移动操作的类型,某些看似可靠的移动操作最终也会导致复制。Item14解释了原因,标准库中的某些容器操作提供了强大的异常安全保证,确保依赖那些保证的C++98的代码在升级到C++11且仅当移动操作不会抛出异常,从而可能替换操作时,不会不可运行。结果就是,即使类提供了更具效率的移动操作,而且即使移动操作更合适(比如源对象是右值),编译器仍可能被迫使用复制操作,因为移动操作没有声明noexcept

    因此,存在几种情况,C++11的移动语义并无优势:

    • 没有移动操作:要移动的对象没有提供移动操作,所以移动的写法也会变成复制操作。
    • @@ -4234,7 +4234,7 @@ auto aw2 = std::move(aw1);

    值得一提的是,还有另一个场景,会使得移动并没有那么有效率:

      -
    • 源对象是左值:除了极少数的情况外(例如Item25),只有右值可以作为移动操作的来源。
    • +
    • 源对象是左值:除了极少数的情况外(例如Item25),只有右值可以作为移动操作的来源。

    但是该条款的标题是假定移动操作不存在,成本高,未被使用。这就是通用代码中的典型情况,比如编写模板代码,因为你不清楚你处理的具体类型是什么。在这种情况下,你必须像出现移动语义之前那样,像在C++98里一样保守地去复制对象。“不稳定的”代码也是如此,即那些由于经常被修改导致类型特性变化的源代码。

    但是,通常,你了解你代码里使用的类型,依赖他们的特性不变性(比如是否支持快速移动操作)。这种情况,你无需这个条款的假设,只需要查找所用类型的移动操作详细信息。如果类型提供了快速移动操作,并且在调用移动操作的上下文中使用对象,可以安全的使用快速移动操作替换复制操作。

    @@ -4247,7 +4247,7 @@ auto aw2 = std::move(aw1);

    Item 30: Familiarize yourself with perfect forwarding failure cases

    C++11最显眼的功能之一就是完美转发功能。完美转发,太完美了!哎,开始使用,你就发现“完美”,理想与现实还是有差距。C++11的完美转发是非常好用,但是只有当你愿意忽略一些误差情况(译者注:就是完美转发失败的情况),这个条款就是使你熟悉这些情形。

    在我们开始误差探索之前,有必要回顾一下“完美转发”的含义。“转发”仅表示将一个函数的形参传递——就是转发——给另一个函数。对于第二个函数(被传递的那个)目标是收到与第一个函数(执行传递的那个)完全相同的对象。这规则排除了按值传递的形参,因为它们是原始调用者传入内容的拷贝。我们希望被转发的函数能够使用最开始传进来的那些对象。指针形参也被排除在外,因为我们不想强迫调用者传入指针。关于通常目的的转发,我们将处理引用形参。

    -

    完美转发perfect forwarding)意味着我们不仅转发对象,我们还转发显著的特征:它们的类型,是左值还是右值,是const还是volatile。结合到我们会处理引用形参,这意味着我们将使用通用引用(参见Item24),因为通用引用形参被传入实参时才确定是左值还是右值。

    +

    完美转发perfect forwarding)意味着我们不仅转发对象,我们还转发显著的特征:它们的类型,是左值还是右值,是const还是volatile。结合到我们会处理引用形参,这意味着我们将使用通用引用(参见Item24),因为通用引用形参被传入实参时才确定是左值还是右值。

    假定我们有一些函数f,然后想编写一个转发给它的函数(事实上是一个函数模板)。我们需要的核心看起来像是这样:

    template<typename T>
     void fwd(T&& param)             //接受任何实参
    @@ -4262,7 +4262,7 @@ void fwd(Ts&&... params)            //接受任何实参
         f(std::forward<Ts>(params)...); //转发给f
     }
     
    -

    这种形式你会在标准化容器置入函数(emplace functions)中(参见Item42)和智能指针的工厂函数std::make_uniquestd::make_shared中(参见Item21)看到,当然还有其他一些地方。

    +

    这种形式你会在标准化容器置入函数(emplace functions)中(参见Item42)和智能指针的工厂函数std::make_uniquestd::make_shared中(参见Item21)看到,当然还有其他一些地方。

    给定我们的目标函数f和转发函数fwd,如果f使用某特定实参会执行某个操作,但是fwd使用相同的实参会执行不同的操作,完美转发就会失败

    f( expression );        //调用f执行某个操作
     fwd( expression );		//但调用fwd执行另一个操作,则fwd不能完美转发expression给f
    @@ -4286,12 +4286,12 @@ fwd( expression );		//但调用fwd执行另一个操作,则fwd不能完美转
     
  • **编译器推导“错”了fwd的一个或者多个形参类型。**在这里,“错误”可能意味着fwd的实例将无法使用推导出的类型进行编译,但是也可能意味着使用fwd的推导类型调用f,与用传给fwd的实参直接调用f表现出不一致的行为。这种不同行为的原因可能是因为f是个重载函数的名字,并且由于是“不正确的”类型推导,在fwd内部调用的f重载和直接调用的f重载不一样。

在上面的fwd({ 1, 2, 3 })例子中,问题在于,将花括号初始化传递给未声明为std::initializer_list的函数模板形参,被判定为——就像标准说的——“非推导上下文”。简单来讲,这意味着编译器不准在对fwd的调用中推导表达式{ 1, 2, 3 }的类型,因为fwd的形参没有声明为std::initializer_list。对于fwd形参的推导类型被阻止,编译器只能拒绝该调用。

-

有趣的是,Item2说明了使用花括号初始化的auto的变量的类型推导是成功的。这种变量被视为std::initializer_list对象,在转发函数应推导出类型为std::initializer_list的情况,这提供了一种简单的解决方法——使用auto声明一个局部变量,然后将局部变量传进转发函数:

+

有趣的是,Item2说明了使用花括号初始化的auto的变量的类型推导是成功的。这种变量被视为std::initializer_list对象,在转发函数应推导出类型为std::initializer_list的情况,这提供了一种简单的解决方法——使用auto声明一个局部变量,然后将局部变量传进转发函数:

auto il = { 1, 2, 3 };  //il的类型被推导为std::initializer_list<int>
 fwd(il);                //可以,完美转发il给f
 

0或者NULL作为空指针

-

Item8说明当你试图传递0或者NULL作为空指针给模板时,类型推导会出错,会把传来的实参推导为一个整型类型(典型情况为int)而不是指针类型。结果就是不管是0还是NULL都不能作为空指针被完美转发。解决方法非常简单,传一个nullptr而不是0或者NULL。具体的细节,参考Item8

+

Item8说明当你试图传递0或者NULL作为空指针给模板时,类型推导会出错,会把传来的实参推导为一个整型类型(典型情况为int)而不是指针类型。结果就是不管是0还是NULL都不能作为空指针被完美转发。解决方法非常简单,传一个nullptr而不是0或者NULL。具体的细节,参考Item8

仅有声明的整型static const数据成员

通常,无需在类中定义整型static const数据成员;声明就可以了。这是因为编译器会对此类成员实行常量传播const propagation),因此消除了保留内存的需要。比如,考虑下面的代码:

class Widget {
@@ -4395,7 +4395,7 @@ fwd(length);                    //转发这个副本
 
 

第6章 lambda表达式

CHAPTER 6 Lambda Expressions

-

lambda表达式是C++编程中的游戏规则改变者。这有点令人惊讶,因为它没有给语言带来新的表达能力。lambda可以做的所有事情都可以通过其他方式完成。但是lambda是创建函数对象相当便捷的一种方法,对于日常的C++开发影响是巨大的。没有lambda时,STL中的“_if”算法(比如,std::find_ifstd::remove_ifstd::count_if等)通常需要繁琐的谓词,但是当有lambda可用时,这些算法使用起来就变得相当方便。用比较函数(比如,std::sortstd::nth_elementstd::lower_bound等)来自定义算法也是同样方便的。在STL外,lambda可以快速创建std::unique_ptrstd::shared_ptr的自定义删除器(见Item1819),并且使线程API中条件变量的谓词指定变得同样简单(参见Item39)。除了标准库,lambda有利于即时的回调函数,接口适配函数和特定上下文中的一次性函数。lambda确实使C++成为更令人愉快的编程语言。

+

lambda表达式是C++编程中的游戏规则改变者。这有点令人惊讶,因为它没有给语言带来新的表达能力。lambda可以做的所有事情都可以通过其他方式完成。但是lambda是创建函数对象相当便捷的一种方法,对于日常的C++开发影响是巨大的。没有lambda时,STL中的“_if”算法(比如,std::find_ifstd::remove_ifstd::count_if等)通常需要繁琐的谓词,但是当有lambda可用时,这些算法使用起来就变得相当方便。用比较函数(比如,std::sortstd::nth_elementstd::lower_bound等)来自定义算法也是同样方便的。在STL外,lambda可以快速创建std::unique_ptrstd::shared_ptr的自定义删除器(见Item1819),并且使线程API中条件变量的谓词指定变得同样简单(参见Item39)。除了标准库,lambda有利于即时的回调函数,接口适配函数和特定上下文中的一次性函数。lambda确实使C++成为更令人愉快的编程语言。

lambda相关的词汇可能会令人疑惑,这里做一下简单的回顾:

  • @@ -4500,7 +4500,7 @@ void workWithContainer(const C& container) );

这足以满足本实例的要求,但在通常情况下,按值捕获并不能完全解决悬空引用的问题。这里的问题是如果你按值捕获的是一个指针,你将该指针拷贝到lambda对应的闭包里,但这样并不能避免lambdadelete这个指针的行为,从而导致你的副本指针变成悬空指针。

-

也许你要抗议说:“这不可能发生。看过了第4章,我对智能指针的使用非常热衷。只有那些失败的C++98的程序员才会用裸指针和delete语句。”这也许是正确的,但却是不相关的,因为事实上你的确会使用裸指针,也的确存在被你delete的可能性。只不过在现代的C++编程风格中,不容易在源代码中显露出来而已。

+

也许你要抗议说:“这不可能发生。看过了第4章,我对智能指针的使用非常热衷。只有那些失败的C++98的程序员才会用裸指针和delete语句。”这也许是正确的,但却是不相关的,因为事实上你的确会使用裸指针,也的确存在被你delete的可能性。只不过在现代的C++编程风格中,不容易在源代码中显露出来而已。

假设在一个Widget类,可以实现向过滤器的容器添加条目:

class Widget {
 public:
@@ -4557,7 +4557,7 @@ private:
     );
 }
 
-

明白了这个就相当于明白了lambda闭包的生命周期与Widget对象的关系,闭包内含有Widgetthis指针的拷贝。特别是考虑以下的代码,参考第4章的内容,只使用智能指针:

+

明白了这个就相当于明白了lambda闭包的生命周期与Widget对象的关系,闭包内含有Widgetthis指针的拷贝。特别是考虑以下的代码,参考第4章的内容,只使用智能指针:

using FilterContainer = 					//跟之前一样
     std::vector<std::function<bool(int)>>;
 
@@ -4573,7 +4573,7 @@ void doSomeWork()
     …
 }                                           //销毁Widget;filters现在持有悬空指针!
 
-

当调用doSomeWork时,就会创建一个过滤器,其生命周期依赖于由std::make_unique产生的Widget对象,即一个含有指向Widget的指针——Widgetthis指针——的过滤器。这个过滤器被添加到filters中,但当doSomeWork结束时,Widget会由管理它的std::unique_ptr来销毁(见Item18)。从这时起,filter会含有一个存着悬空指针的条目。

+

当调用doSomeWork时,就会创建一个过滤器,其生命周期依赖于由std::make_unique产生的Widget对象,即一个含有指向Widget的指针——Widgetthis指针——的过滤器。这个过滤器被添加到filters中,但当doSomeWork结束时,Widget会由管理它的std::unique_ptr来销毁(见Item18)。从这时起,filter会含有一个存着悬空指针的条目。

这个特定的问题可以通过给你想捕获的数据成员做一个局部副本,然后捕获这个副本去解决:

void Widget::addFilter() const
 {
@@ -4633,7 +4633,7 @@ void doSomeWork()
 

Item 32: Use init capture to move objects into closures

在某些场景下,按值捕获和按引用捕获都不是你所想要的。如果你有一个只能被移动的对象(例如std::unique_ptrstd::future)要进入到闭包里,使用C++11是无法实现的。如果你要复制的对象复制开销非常高,但移动的成本却不高(例如标准库中的大多数容器),并且你希望的是宁愿移动该对象到闭包而不是复制它。然而C++11却无法实现这一目标。

但那是C++11的时候。到了C++14就另一回事了,它能支持将对象移动到闭包中。如果你的编译器兼容支持C++14,那么请愉快地阅读下去。如果你仍然在使用仅支持C++11的编译器,也请愉快阅读,因为在C++11中有很多方法可以实现近似的移动捕获。

-

缺少移动捕获被认为是C++11的一个缺点,直接的补救措施是将该特性添加到C++14中,但标准化委员会选择了另一种方法。他们引入了一种新的捕获机制,该机制非常灵活,移动捕获是它可以执行的技术之一。新功能被称作初始化捕获init capture),C++11捕获形式能做的所有事它几乎可以做,甚至能完成更多功能。你不能用初始化捕获表达的东西是默认捕获模式,但Item31说明提醒了你无论如何都应该远离默认捕获模式。(在C++11捕获模式所能覆盖的场景里,初始化捕获的语法有点不大方便。因此在C++11的捕获模式能完成所需功能的情况下,使用它是完全合理的)。

+

缺少移动捕获被认为是C++11的一个缺点,直接的补救措施是将该特性添加到C++14中,但标准化委员会选择了另一种方法。他们引入了一种新的捕获机制,该机制非常灵活,移动捕获是它可以执行的技术之一。新功能被称作初始化捕获init capture),C++11捕获形式能做的所有事它几乎可以做,甚至能完成更多功能。你不能用初始化捕获表达的东西是默认捕获模式,但Item31说明提醒了你无论如何都应该远离默认捕获模式。(在C++11捕获模式所能覆盖的场景里,初始化捕获的语法有点不大方便。因此在C++11的捕获模式能完成所需功能的情况下,使用它是完全合理的)。

使用初始化捕获可以让你指定:

  1. 从lambda生成的闭包类中的数据成员名称
  2. @@ -4713,7 +4713,7 @@ auto func = );

lambda表达式一样,std::bind产生函数对象。我将由std::bind返回的函数对象称为bind对象bind objects)。std::bind的第一个实参是可调用对象,后续实参表示要传递给该对象的值。

-

一个bind对象包含了传递给std::bind的所有实参的副本。对于每个左值实参,bind对象中的对应对象都是复制构造的。对于每个右值,它都是移动构造的。在此示例中,第二个实参是一个右值(std::move的结果,请参见Item23),因此将data移动构造到绑定对象中。这种移动构造是模仿移动捕获的关键,因为将右值移动到bind对象是我们解决无法将右值移动到C++11闭包中的方法。

+

一个bind对象包含了传递给std::bind的所有实参的副本。对于每个左值实参,bind对象中的对应对象都是复制构造的。对于每个右值,它都是移动构造的。在此示例中,第二个实参是一个右值(std::move的结果,请参见Item23),因此将data移动构造到绑定对象中。这种移动构造是模仿移动捕获的关键,因为将右值移动到bind对象是我们解决无法将右值移动到C++11闭包中的方法。

当“调用”bind对象(即调用其函数调用运算符)时,其存储的实参将传递到最初传递给std::bind的可调用对象。在此示例中,这意味着当调用func(bind对象)时,func中所移动构造的data副本将作为实参传递给std::bind中的lambda

lambda与我们在C++14中使用的lambda相同,只是添加了一个形参data来对应我们的伪移动捕获对象。此形参是对bind对象中data副本的左值引用。(这不是右值引用,因为尽管用于初始化data副本的表达式(std::move(data))为右值,但data副本本身为左值。)因此,lambda将对绑定在对象内部的移动构造的data副本进行操作。

默认情况下,从lambda生成的闭包类中的operator()成员函数为const的。这具有在lambda主体内把闭包中的所有数据成员渲染为const的效果。但是,bind对象内部的移动构造的data副本不是const的,因此,为了防止在lambda内修改该data副本,lambda的形参应声明为reference-to-const。 如果将lambda声明为mutable,则闭包类中的operator()将不会声明为const,并且在lambda的形参声明中省略const也是合适的:

@@ -4744,7 +4744,7 @@ auto func = std::make_unique<Widget>() );
-

具备讽刺意味的是,这里我展示了如何使用std::bind解决C++11 lambda中的限制,因为在Item34中,我主张使用lambda而不是std::bind。但是,该条款解释的是在C++11中有些情况下std::bind可能有用,这就是其中一种。 (在C++14中,初始化捕获和auto形参等特性使得这些情况不再存在。)

+

具备讽刺意味的是,这里我展示了如何使用std::bind解决C++11 lambda中的限制,因为在Item34中,我主张使用lambda而不是std::bind。但是,该条款解释的是在C++11中有些情况下std::bind可能有用,这就是其中一种。 (在C++14中,初始化捕获和auto形参等特性使得这些情况不再存在。)

请记住:

  • 使用C++14的初始化捕获将对象移动到闭包中。
  • @@ -4765,15 +4765,15 @@ public: };

在这个样例中,lambda对变量x做的唯一一件事就是把它转发给函数normalize。如果函数normalize对待左值右值的方式不一样,这个lambda的实现方式就不大合适了,因为即使传递到lambda的实参是一个右值,lambda传递进normalize的总是一个左值(形参x)。

-

实现这个lambda的正确方式是把x完美转发给函数normalize。这样做需要对代码做两处修改。首先,x需要改成通用引用(见Item24),其次,需要使用std::forwardx转发到函数normalize(见Item25)。理论上,这都是小改动:

+

实现这个lambda的正确方式是把x完美转发给函数normalize。这样做需要对代码做两处修改。首先,x需要改成通用引用(见Item24),其次,需要使用std::forwardx转发到函数normalize(见Item25)。理论上,这都是小改动:

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就可以实现这样的效果(见Item3)。传递给lambda的是一个左值,decltype(x)就能产生一个左值引用;如果传递的是一个右值,decltype(x)就会产生右值引用。

-

Item28也解释过在调用std::forward时,惯例决定了类型实参是左值引用时来表明要传进左值,类型实参是非引用就表明要传进右值。在前面的lambda中,如果x绑定的是一个左值,decltype(x)就能产生一个左值引用。这符合惯例。然而如果x绑定的是一个右值,decltype(x)就会产生右值引用,而不是常规的非引用。

-

再看一下Item28中关于std::forward的C++14实现:

+

Item28解释过如果一个左值实参被传给通用引用的形参,那么形参类型会变成左值引用。传递的是右值,形参就会变成右值引用。这意味着在这个lambda中,可以通过检查形参x的类型来确定传递进来的实参是一个左值还是右值,decltype就可以实现这样的效果(见Item3)。传递给lambda的是一个左值,decltype(x)就能产生一个左值引用;如果传递的是一个右值,decltype(x)就会产生右值引用。

+

Item28也解释过在调用std::forward时,惯例决定了类型实参是左值引用时来表明要传进左值,类型实参是非引用就表明要传进右值。在前面的lambda中,如果x绑定的是一个左值,decltype(x)就能产生一个左值引用。这符合惯例。然而如果x绑定的是一个右值,decltype(x)就会产生右值引用,而不是常规的非引用。

+

再看一下Item28中关于std::forward的C++14实现:

template<typename T>                        //在std命名空间
 T&& forward(remove_reference_t<T>& param)
 {
@@ -4823,7 +4823,7 @@ T&& forward(remove_reference_t<T>& param)
 

Item 34: Prefer lambdas to std::bind

C++11中的std::bind是C++98的std::bind1ststd::bind2nd的后续,但在2005年已经非正式成为了标准库的一部分。那时标准化委员采用了TR1的文档,其中包含了bind的规范。(在TR1中,bind位于不同的命名空间,因此它是std::tr1::bind,而不是std::bind,接口细节也有所不同)。这段历史意味着一些程序员有十年及以上的std::bind使用经验。如果你是其中之一,可能会不愿意放弃一个对你有用的工具。这是可以理解的,但是在这种情况下,改变是更好的,因为在C++11中,lambda几乎总是比std::bind更好的选择。 从C++14开始,lambda的作用不仅强大,而且是完全值得使用的。

这个条款假设你熟悉std::bind。 如果不是这样,你将需要获得基本的了解,然后再继续。 无论如何,这样的理解都是值得的,因为你永远不知道何时会在阅读或维护的代码库中遇到std::bind

-

Item32中一样,我们将从std::bind返回的函数对象称为bind对象bind objects)。

+

Item32中一样,我们将从std::bind返回的函数对象称为bind对象bind objects)。

优先lambda而不是std::bind的最重要原因是lambda更易读。 例如,假设我们有一个设置警报器的函数:

//一个时间点的类型定义(语法见条款9)
 using Time = std::chrono::steady_clock::time_point;
@@ -4990,8 +4990,8 @@ auto compressRateB = std::bind(compress, w, _1);
 

同样,唯一的方法是记住std::bind的工作方式。(答案是传递给bind对象的所有实参都是通过引用传递的,因为此类对象的函数调用运算符使用完美转发。)

lambda相比,使用std::bind进行编码的代码可读性较低,表达能力较低,并且效率可能较低。 在C++14中,没有std::bind的合理用例。 但是,在C++11中,可以在两个受约束的情况下证明使用std::bind是合理的:

    -
  • 移动捕获。C++11的lambda不提供移动捕获,但是可以通过结合lambdastd::bind来模拟。 有关详细信息,请参阅Item32,该条款还解释了在C++14中,lambda对初始化捕获的支持消除了这个模拟的需求。
  • -
  • 多态函数对象。因为bind对象上的函数调用运算符使用完美转发,所以它可以接受任何类型的实参(以Item30中描述的完美转发的限制为界限)。当你要绑定带有模板化函数调用运算符的对象时,此功能很有用。 例如这个类,
  • +
  • 移动捕获。C++11的lambda不提供移动捕获,但是可以通过结合lambdastd::bind来模拟。 有关详细信息,请参阅Item32,该条款还解释了在C++14中,lambda对初始化捕获的支持消除了这个模拟的需求。
  • +
  • 多态函数对象。因为bind对象上的函数调用运算符使用完美转发,所以它可以接受任何类型的实参(以Item30中描述的完美转发的限制为界限)。当你要绑定带有模板化函数调用运算符的对象时,此功能很有用。 例如这个类,
class PolyWidget {
 public:
@@ -5053,9 +5053,9 @@ std::thread t(doAsyncWork);
 

如果你把这些问题推给另一个人做,你就会变得很轻松,而使用std::async就做了这件事:

auto fut = std::async(doAsyncWork); //线程管理责任交给了标准库的开发者
 
-

这种调用方式将线程管理的职责转交给C++标准库的开发者。举个例子,这种调用方式会减少抛出资源超额异常的可能性,因为这个调用可能不会开启一个新的线程。你会想:“怎么可能?如果我要求比系统可以提供的更多的软件线程,创建std::thread和调用std::async为什么会有区别?”确实有区别,因为以这种形式调用(即使用默认启动策略——见Item36)时,std::async不保证会创建新的软件线程。然而,他们允许通过调度器来将特定函数(本例中为doAsyncWork)运行在等待此函数结果的线程上(即在对fut调用get或者wait的线程上),合理的调度器在系统资源超额或者线程耗尽时就会利用这个自由度。

+

这种调用方式将线程管理的职责转交给C++标准库的开发者。举个例子,这种调用方式会减少抛出资源超额异常的可能性,因为这个调用可能不会开启一个新的线程。你会想:“怎么可能?如果我要求比系统可以提供的更多的软件线程,创建std::thread和调用std::async为什么会有区别?”确实有区别,因为以这种形式调用(即使用默认启动策略——见Item36)时,std::async不保证会创建新的软件线程。然而,他们允许通过调度器来将特定函数(本例中为doAsyncWork)运行在等待此函数结果的线程上(即在对fut调用get或者wait的线程上),合理的调度器在系统资源超额或者线程耗尽时就会利用这个自由度。

如果考虑自己实现“在等待结果的线程上运行输出结果的函数”,之前提到了可能引出负载不均衡的问题,这问题不那么容易解决,因为应该是std::async和运行时的调度程序来解决这个问题而不是你。遇到负载不均衡问题时,对机器内发生的事情,运行时调度程序比你有更全面的了解,因为它管理的是所有执行过程,而不仅仅个别开发者运行的代码。

-

有了std::async,GUI线程中响应变慢仍然是个问题,因为调度器并不知道你的哪个线程有高响应要求。这种情况下,你会想通过向std::async传递std::launch::async启动策略来保证想运行函数在不同的线程上执行(见Item36)。

+

有了std::async,GUI线程中响应变慢仍然是个问题,因为调度器并不知道你的哪个线程有高响应要求。这种情况下,你会想通过向std::async传递std::launch::async启动策略来保证想运行函数在不同的线程上执行(见Item36)。

最前沿的线程调度器使用系统级线程池(thread pool)来避免资源超额的问题,并且通过工作窃取算法(work-stealing algorithm)来提升了跨硬件核心的负载均衡。C++标准实际上并不要求使用线程池或者工作窃取,实际上C++11并发规范的某些技术层面使得实现这些技术的难度可能比想象中更有挑战。不过,库开发者在标准库实现中采用了这些技术,也有理由期待这个领域会有更多进展。如果你当前的并发编程采用基于任务的方式,在这些技术发展中你会持续获得回报。相反如果你直接使用std::thread编程,处理线程耗尽、资源超额、负责均衡问题的责任就压在了你身上,更不用说你对这些问题的解决方法与同机器上其他程序采用的解决方案配合得好不好了。

对比基于线程的编程方式,基于任务的设计为开发者避免了手动线程管理的痛苦,并且自然提供了一种获取异步执行程序的结果(即返回值或者异常)的方式。当然,仍然存在一些场景直接使用std::thread会更有优势:

    @@ -5072,10 +5072,10 @@ std::thread t(doAsyncWork);

条款三十六:如果有异步的必要请指定std::launch::async

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

-

当你调用std::async执行函数时(或者其他可调用对象),你通常希望异步执行函数。但是这并不一定是你要求std::async执行的操作。你事实上要求这个函数按照std::async启动策略来执行。有两种标准策略,每种都通过std::launch这个限域enum的一个枚举名表示(关于枚举的更多细节参见Item10)。假定一个函数f传给std::async来执行:

+

当你调用std::async执行函数时(或者其他可调用对象),你通常希望异步执行函数。但是这并不一定是你要求std::async执行的操作。你事实上要求这个函数按照std::async启动策略来执行。有两种标准策略,每种都通过std::launch这个限域enum的一个枚举名表示(关于枚举的更多细节参见Item10)。假定一个函数f传给std::async来执行:

  • std::launch::async启动策略意味着f必须异步执行,即在不同的线程。
  • -
  • std::launch::deferred启动策略意味着f仅当在std::async返回的future上调用get或者wait时才执行。这表示f推迟到存在这样的调用时才执行(译者注:异步与并发是两个不同概念,这里侧重于惰性求值)。当getwait被调用,f会同步执行,即调用方被阻塞,直到f运行结束。如果getwait都没有被调用,f将不会被执行。(这是个简化说法。关键点不是要在其上调用getwait的那个future,而是future引用的那个共享状态。(Item38讨论了future与共享状态的关系。)因为std::future支持移动,也可以用来构造std::shared_future,并且因为std::shared_future可以被拷贝,对共享状态——对f传到的那个std::async进行调用产生的——进行引用的future对象,有可能与std::async返回的那个future对象不同。这非常绕口,所以经常回避这个事实,简称为在std::async返回的future上调用getwait。)
  • +
  • std::launch::deferred启动策略意味着f仅当在std::async返回的future上调用get或者wait时才执行。这表示f推迟到存在这样的调用时才执行(译者注:异步与并发是两个不同概念,这里侧重于惰性求值)。当getwait被调用,f会同步执行,即调用方被阻塞,直到f运行结束。如果getwait都没有被调用,f将不会被执行。(这是个简化说法。关键点不是要在其上调用getwait的那个future,而是future引用的那个共享状态。(Item38讨论了future与共享状态的关系。)因为std::future支持移动,也可以用来构造std::shared_future,并且因为std::shared_future可以被拷贝,对共享状态——对f传到的那个std::async进行调用产生的——进行引用的future对象,有可能与std::async返回的那个future对象不同。这非常绕口,所以经常回避这个事实,简称为在std::async返回的future上调用getwait。)

可能让人惊奇的是,std::async的默认启动策略——你不显式指定一个策略时它使用的那个——不是上面中任意一个。相反,是求或在一起的。下面的两种调用含义相同:

auto fut1 = std::async(f);                      //使用默认启动策略运行f
@@ -5083,7 +5083,7 @@ auto fut2 = std::async(std::launch::async |     //使用async或者deferred运
                        std::launch::deferred,
                        f);
 
-

因此默认策略允许f异步或者同步执行。如同Item35中指出,这种灵活性允许std::async和标准库的线程管理组件承担线程创建和销毁的责任,避免资源超额,以及平衡负载。这就是使用std::async并发编程如此方便的原因。

+

因此默认策略允许f异步或者同步执行。如同Item35中指出,这种灵活性允许std::async和标准库的线程管理组件承担线程创建和销毁的责任,避免资源超额,以及平衡负载。这就是使用std::async并发编程如此方便的原因。

但是,使用默认启动策略的std::async也有一些有趣的影响。给定一个线程t执行此语句:

auto fut = std::async(f);   //使用默认启动策略运行f
 
@@ -5096,7 +5096,7 @@ auto fut2 = std::async(std::launch::async | //使用async或者deferred运
auto fut = std::async(f);   //f的TLS可能是为单独的线程建的,
                             //也可能是为在fut上调用get或者wait的线程建的
 
-

这还会影响到基于wait的循环使用超时机制,因为在一个延时的任务(参见Item35)上调用wait_for或者wait_until会产生std::launch::deferred值。意味着,以下循环看似应该最终会终止,但可能实际上永远运行:

+

这还会影响到基于wait的循环使用超时机制,因为在一个延时的任务(参见Item35)上调用wait_for或者wait_until会产生std::launch::deferred值。意味着,以下循环看似应该最终会终止,但可能实际上永远运行:

using namespace std::literals;      //为了使用C++14中的时间段后缀;参见条款34
 
 void f()                            //f休眠1秒,然后返回
@@ -5151,7 +5151,7 @@ reallyAsync(F&& f, Ts&&... params)          //返回异步调用
                       std::forward<Ts>(params)...);
 }
 
-

这个函数接受一个可调用对象f和0或多个形参params,然后完美转发(参见Item25)给std::async,使用std::launch::async作为启动策略。就像std::async一样,返回std::future作为用params调用f得到的结果。确定结果的类型很容易,因为type trait std::result_of可以提供给你。(参见Item9关于type trait的详细表述。)

+

这个函数接受一个可调用对象f和0或多个形参params,然后完美转发(参见Item25)给std::async,使用std::launch::async作为启动策略。就像std::async一样,返回std::future作为用params调用f得到的结果。确定结果的类型很容易,因为type trait std::result_of可以提供给你。(参见Item9关于type trait的详细表述。)

reallyAsync就像std::async一样使用:

auto fut = reallyAsync(f); //异步运行f,如果std::async抛出异常它也会抛出
 
@@ -5185,7 +5185,7 @@ reallyAsync(F&& f, Ts&&... params)

(译者注:std::thread可以视作状态保存的对象,保存的状态可能也包括可调用对象,有没有具体的线程承载就是有没有连接)

std::thread的可结合性如此重要的原因之一就是当可结合的线程的析构函数被调用,程序执行会终止。比如,假定有一个函数doWork,使用一个过滤函数filter,一个最大值maxVal作为形参。doWork检查是否满足计算所需的条件,然后使用在0到maxVal之间的通过过滤器的所有值进行计算。如果进行过滤非常耗时,并且确定doWork条件是否满足也很耗时,则将两件事并发计算是很合理的。

-

我们希望为此采用基于任务的设计(参见Item35),但是假设我们希望设置做过滤的线程的优先级。Item35阐释了那需要线程的原生句柄,只能通过std::thread的API来完成;基于任务的API(比如future)做不到。所以最终采用基于线程而不是基于任务。

+

我们希望为此采用基于任务的设计(参见Item35),但是假设我们希望设置做过滤的线程的优先级。Item35阐释了那需要线程的原生句柄,只能通过std::thread的API来完成;基于任务的API(比如future)做不到。所以最终采用基于线程而不是基于任务。

我们可能写出以下代码:

代码如下:

constexpr auto tenMillion = 10000000;           //constexpr见条款15
@@ -5215,7 +5215,7 @@ bool doWork(std::function<bool(int)> filter,    //返回计算是否执行
 

在解释这份代码为什么有问题之前,我先把tenMillion的初始化值弄得更可读一些,这利用了C++14的能力,使用单引号作为数字分隔符:

constexpr auto tenMillion = 10'000'000;         //C++14
 
-

还要指出,在开始运行之后设置t的优先级就像把马放出去之后再关上马厩门一样(译者注:太晚了)。更好的设计是在挂起状态时开始t(这样可以在执行任何计算前调整优先级),但是我不想你为考虑那些代码而分心。如果你对代码中忽略的部分感兴趣,可以转到Item39,那个Item告诉你如何以开始那些挂起状态的线程。

+

还要指出,在开始运行之后设置t的优先级就像把马放出去之后再关上马厩门一样(译者注:太晚了)。更好的设计是在挂起状态时开始t(这样可以在执行任何计算前调整优先级),但是我不想你为考虑那些代码而分心。如果你对代码中忽略的部分感兴趣,可以转到Item39,那个Item告诉你如何以开始那些挂起状态的线程。

返回doWork。如果conditionsAreSatisfied()返回true,没什么问题,但是如果返回false或者抛出异常,在doWork结束调用t的析构函数时,std::thread对象t会是可结合的。这造成程序执行中止。

你可能会想,为什么std::thread析构的行为是这样的,那是因为另外两种显而易见的方式更糟:

    @@ -5229,7 +5229,7 @@ bool doWork(std::function<bool(int)> filter, //返回计算是否执行

标准委员会认为,销毁可结合的线程如此可怕以至于实际上禁止了它(规定销毁可结合的线程导致程序终止)。

这使你有责任确保使用std::thread对象时,在所有的路径上超出定义所在的作用域时都是不可结合的。但是覆盖每条路径可能很复杂,可能包括自然执行通过作用域,或者通过returncontinuebreakgoto或异常跳出作用域,有太多可能的路径。

-

每当你想在执行跳至块之外的每条路径执行某种操作,最通用的方式就是将该操作放入局部对象的析构函数中。这些对象称为RAII对象RAII objects),从RAII类中实例化。(RAII全称为 “Resource Acquisition Is Initialization”(资源获得即初始化),尽管技术关键点在析构上而不是实例化上)。RAII类在标准库中很常见。比如STL容器(每个容器析构函数都销毁容器中的内容物并释放内存),标准智能指针(Item18-20解释了,std::uniqu_ptr的析构函数调用他指向的对象的删除器,std::shared_ptrstd::weak_ptr的析构函数递减引用计数),std::fstream对象(它们的析构函数关闭对应的文件)等。但是标准库没有std::thread的RAII类,可能是因为标准委员会拒绝将joindetach作为默认选项,不知道应该怎么样完成RAII。

+

每当你想在执行跳至块之外的每条路径执行某种操作,最通用的方式就是将该操作放入局部对象的析构函数中。这些对象称为RAII对象RAII objects),从RAII类中实例化。(RAII全称为 “Resource Acquisition Is Initialization”(资源获得即初始化),尽管技术关键点在析构上而不是实例化上)。RAII类在标准库中很常见。比如STL容器(每个容器析构函数都销毁容器中的内容物并释放内存),标准智能指针(Item18-20解释了,std::uniqu_ptr的析构函数调用他指向的对象的删除器,std::shared_ptrstd::weak_ptr的析构函数递减引用计数),std::fstream对象(它们的析构函数关闭对应的文件)等。但是标准库没有std::thread的RAII类,可能是因为标准委员会拒绝将joindetach作为默认选项,不知道应该怎么样完成RAII。

幸运的是,完成自行实现的类并不难。比如,下面的类实现允许调用者指定ThreadRAII对象(一个std::thread的RAII对象)析构时,调用join或者detach

class ThreadRAII {
 public:
@@ -5309,8 +5309,8 @@ private:
 }
 

这种情况下,我们选择在ThreadRAII的析构函数对异步执行的线程进行join,因为在先前分析中,detach可能导致噩梦般的调试过程。我们之前也分析了join可能会导致表现异常(坦率说,也可能调试困难),但是在未定义行为(detach导致),程序终止(使用原生std::thread导致),或者表现异常之间选择一个后果,可能表现异常是最好的那个。

-

哎,Item39表明了使用ThreadRAII来保证在std::thread的析构时执行join有时不仅可能导致程序表现异常,还可能导致程序挂起。“适当”的解决方案是此类程序应该和异步执行的lambda通信,告诉它不需要执行了,可以直接返回,但是C++11中不支持可中断线程interruptible threads)。可以自行实现,但是这不是本书讨论的主题。(关于这一点,Anthony Williams的《C++ Concurrency in Action》(Manning Publications,2012)的section 9.2中有详细讨论。)(译者注:此书中文版已出版,名为《C++并发编程实战》,且本文翻译时(2020)已有第二版出版。)

-

Item17说明因为ThreadRAII声明了一个析构函数,因此不会有编译器生成移动操作,但是没有理由ThreadRAII对象不能移动。如果要求编译器生成这些函数,函数的功能也正确,所以显式声明来告诉编译器自动生成也是合适的:

+

哎,Item39表明了使用ThreadRAII来保证在std::thread的析构时执行join有时不仅可能导致程序表现异常,还可能导致程序挂起。“适当”的解决方案是此类程序应该和异步执行的lambda通信,告诉它不需要执行了,可以直接返回,但是C++11中不支持可中断线程interruptible threads)。可以自行实现,但是这不是本书讨论的主题。(关于这一点,Anthony Williams的《C++ Concurrency in Action》(Manning Publications,2012)的section 9.2中有详细讨论。)(译者注:此书中文版已出版,名为《C++并发编程实战》,且本文翻译时(2020)已有第二版出版。)

+

Item17说明因为ThreadRAII声明了一个析构函数,因此不会有编译器生成移动操作,但是没有理由ThreadRAII对象不能移动。如果要求编译器生成这些函数,函数的功能也正确,所以显式声明来告诉编译器自动生成也是合适的:

class ThreadRAII {
 public:
     enum class DtorAction { join, detach };         //跟之前一样
@@ -5342,9 +5342,9 @@ private: // as before
 
 

条款三十八:关注不同线程句柄的析构行为

Item 38:Be aware of varying thread handle destructor behavior

-

Item37中说明了可结合的std::thread对应于执行的系统线程。未延迟(non-deferred)任务的future(参见Item36)与系统线程有相似的关系。因此,可以将std::thread对象和future对象都视作系统线程的句柄handles)。

-

从这个角度来说,有趣的是std::threadfuture在析构时有相当不同的行为。在Item37中说明,可结合的std::thread析构会终止你的程序,因为两个其他的替代选择——隐式join或者隐式detach都是更加糟糕的。但是,future的析构表现有时就像执行了隐式join,有时又像是隐式执行了detach,有时又没有执行这两个选择。它永远不会造成程序终止。这个线程句柄多种表现值得研究一下。

-

我们可以观察到实际上future是通信信道的一端,被调用者通过该信道将结果发送给调用者。(Item39说,与future有关的这种通信信道也可以被用于其他目的。但是对于本条款,我们只考虑它们作为这样一个机制的用法,即被调用者传送结果给调用者。)被调用者(通常是异步执行)将计算结果写入通信信道中(通常通过std::promise对象),调用者使用future读取结果。你可以想象成下面的图示,虚线表示信息的流动方向:

+

Item37中说明了可结合的std::thread对应于执行的系统线程。未延迟(non-deferred)任务的future(参见Item36)与系统线程有相似的关系。因此,可以将std::thread对象和future对象都视作系统线程的句柄handles)。

+

从这个角度来说,有趣的是std::threadfuture在析构时有相当不同的行为。在Item37中说明,可结合的std::thread析构会终止你的程序,因为两个其他的替代选择——隐式join或者隐式detach都是更加糟糕的。但是,future的析构表现有时就像执行了隐式join,有时又像是隐式执行了detach,有时又没有执行这两个选择。它永远不会造成程序终止。这个线程句柄多种表现值得研究一下。

+

我们可以观察到实际上future是通信信道的一端,被调用者通过该信道将结果发送给调用者。(Item39说,与future有关的这种通信信道也可以被用于其他目的。但是对于本条款,我们只考虑它们作为这样一个机制的用法,即被调用者传送结果给调用者。)被调用者(通常是异步执行)将计算结果写入通信信道中(通常通过std::promise对象),调用者使用future读取结果。你可以想象成下面的图示,虚线表示信息的流动方向:

item38_fig1

但是被调用者的结果存储在哪里?被调用者会在调用者get相关的future之前执行完成,所以结果不能存储在被调用者的std::promise。这个对象是局部的,当被调用者执行结束后,会被销毁。

结果同样不能存储在调用者的future,因为(当然还有其他原因)std::future可能会被用来创建std::shared_future(这会将被调用者的结果所有权从std::future转移给std::shared_future),而std::shared_futurestd::future被销毁之后可能被复制很多次。鉴于不是所有的结果都可以被拷贝(即只可移动类型),并且结果的生命周期至少与最后一个引用它的future一样长,这些潜在的future中哪个才是被调用者用来存储结果的?

@@ -5356,16 +5356,16 @@ private: // as before
  • 引用了共享状态——使用std::async启动的未延迟任务建立的那个——的最后一个future的析构函数会阻塞住,直到任务完成。本质上,这种future的析构函数对执行异步任务的线程执行了隐式的join
  • 其他所有future的析构函数简单地销毁future对象。对于异步执行的任务,就像对底层的线程执行detach。对于延迟任务来说如果这是最后一个future,意味着这个延迟任务永远不会执行了。
  • -

    这些规则听起来好复杂。我们真正要处理的是一个简单的“正常”行为以及一个单独的例外。正常行为是future析构函数销毁future。就是这样。那意味着不join也不detach,也不运行什么,只销毁future的数据成员(当然,还做了另一件事,就是递减了共享状态中的引用计数,这个共享状态是由引用它的future和被调用者的std::promise共同控制的。这个引用计数让库知道共享状态什么时候可以被销毁。对于引用计数的一般信息参见Item19。)

    +

    这些规则听起来好复杂。我们真正要处理的是一个简单的“正常”行为以及一个单独的例外。正常行为是future析构函数销毁future。就是这样。那意味着不join也不detach,也不运行什么,只销毁future的数据成员(当然,还做了另一件事,就是递减了共享状态中的引用计数,这个共享状态是由引用它的future和被调用者的std::promise共同控制的。这个引用计数让库知道共享状态什么时候可以被销毁。对于引用计数的一般信息参见Item19。)

    正常行为的例外情况仅在某个future同时满足下列所有情况下才会出现:

    • 它关联到由于调用std::async而创建出的共享状态
    • -
    • 任务的启动策略是std::launch::async(参见Item36),原因是运行时系统选择了该策略,或者在对std::async的调用中指定了该策略。
    • +
    • 任务的启动策略是std::launch::async(参见Item36),原因是运行时系统选择了该策略,或者在对std::async的调用中指定了该策略。
    • 这个future是关联共享状态的最后一个future。对于std::future,情况总是如此,对于std::shared_future,如果还有其他的std::shared_future,与要被销毁的future引用相同的共享状态,则要被销毁的future遵循正常行为(即简单地销毁它的数据成员)。

    只有当上面的三个条件都满足时,future的析构函数才会表现“异常”行为,就是在异步任务执行完之前阻塞住。实际上,这相当于对由于运行std::async创建出任务的线程隐式join

    通常会听到将这种异常的析构函数行为称为“std::async来的futures阻塞了它们的析构函数”。作为近似描述没有问题,但是有时你不只需要一个近似描述。现在你已经知道了其中真相。

    -

    你可能想要了解更加深入。比如“为什么由std::async启动的未延迟任务的共享状态,会有这么个特殊规则”,这很合理。据我所知,标准委员会希望避免隐式detach(参见Item37)的有关问题,但是不想采取强制程序终止这种激进的方案(就像对可结合的sth::thread做的那样(译者注:指析构时std::thread若可结合则调用std::terminal终止程序),同样参见Item37),所以妥协使用隐式join。这个决定并非没有争议,并且认真讨论过在C++14中放弃这种行为。最后,决定先不改变,所以C++11和C++14中这里的行为是一致的。

    +

    你可能想要了解更加深入。比如“为什么由std::async启动的未延迟任务的共享状态,会有这么个特殊规则”,这很合理。据我所知,标准委员会希望避免隐式detach(参见Item37)的有关问题,但是不想采取强制程序终止这种激进的方案(就像对可结合的sth::thread做的那样(译者注:指析构时std::thread若可结合则调用std::terminal终止程序),同样参见Item37),所以妥协使用隐式join。这个决定并非没有争议,并且认真讨论过在C++14中放弃这种行为。最后,决定先不改变,所以C++11和C++14中这里的行为是一致的。

    future的API没办法确定是否future引用了一个std::async调用产生的共享状态,因此给定一个任意的future对象,无法判断会不会阻塞析构函数从而等待异步任务的完成。这就产生了有意思的事情:

    //这个容器可能在析构函数处阻塞,因为其中至少一个future可能引用由std::async启动的
     //未延迟任务创建出来的共享状态
    @@ -5386,7 +5386,7 @@ auto fut = pt.get_future();     //从pt获取future
     

    此时,我们知道future没有关联std::async创建的共享状态,所以析构函数肯定正常方式执行。

    一旦被创建,std::packaged_task类型的pt就可以在一个线程上执行。(也可以通过调用std::async运行,但是如果你想使用std::async运行任务,没有理由使用std::packaged_task,因为在std::packaged_task安排任务并执行之前,std::async会做std::packaged_task做的所有事。)

    -

    std::packaged_task不可拷贝,所以当pt被传递给std::thread构造函数时,必须先转为右值(通过std::move,参见Item23):

    +

    std::packaged_task不可拷贝,所以当pt被传递给std::thread构造函数时,必须先转为右值(通过std::move,参见Item23):

    std::thread t(std::move(pt));   //在t上运行pt
     

    这个例子是你对于future的析构函数的正常行为有一些了解,但是将这些语句放在一个作用域的语句块里更容易看:

    @@ -5402,7 +5402,7 @@ auto fut = pt.get_future(); //从pt获取future

    此处最有趣的代码是在创建std::thread对象t之后,代码块结束前的“”。使代码有趣的事是,在“”中t上会发生什么。有三种可能性:

      -
    • t什么也不做。这种情况,t会在语句块结束时是可结合的,这会使得程序终止(参见Item37)。
    • +
    • t什么也不做。这种情况,t会在语句块结束时是可结合的,这会使得程序终止(参见Item37)。
    • t调用join。这种情况,不需要fut在它的析构函数处阻塞,因为join被显式调用了。
    • t调用detach。这种情况,不需要在fut的析构函数执行detach,因为显式调用了。
    @@ -5463,7 +5463,7 @@ while (!flag); //等待事件

    这种方法不存在基于条件变量的设计的缺点。不需要互斥锁,在反应任务开始轮询之前检测任务就对flag置位也不会出现问题,并且不会出现虚假唤醒。好,好,好。

    不好的一点是反应任务中轮询的开销。在任务等待flag被置位的时间里,任务基本被阻塞了,但是一直在运行。这样,反应线程占用了可能能给另一个任务使用的硬件线程,每次启动或者完成它的时间片都增加了上下文切换的开销,并且保持核心一直在运行状态,否则的话本来可以停下来省电。一个真正阻塞的任务不会发生上面的任何情况。这也是基于条件变量的优点,因为wait调用中的任务真的阻塞住了。

    -

    将条件变量和flag的设计组合起来很常用。一个flag表示是否发生了感兴趣的事件,但是通过互斥锁同步了对该flag的访问。因为互斥锁阻止并发访问该flag,所以如Item40所述,不需要将flag设置为std::atomic。一个简单的bool类型就可以,检测任务代码如下:

    +

    将条件变量和flag的设计组合起来很常用。一个flag表示是否发生了感兴趣的事件,但是通过互斥锁同步了对该flag的访问。因为互斥锁阻止并发访问该flag,所以如Item40所述,不需要将flag设置为std::atomic。一个简单的bool类型就可以,检测任务代码如下:

    std::condition_variable cv;             //跟之前一样
     std::mutex m;
     bool flag(false);                       //不是std::atomic
    @@ -5484,7 +5484,7 @@ cv.notify_one();                        //通知反应任务(第2部分)
     …                                       //继续反应动作(m现在解锁)
     

    这份代码解决了我们一直讨论的问题。无论在检测线程对条件变量发出通知之前反应线程是否调用了wait都可以工作,即使出现了虚假唤醒也可以工作,而且不需要轮询。但是仍然有些古怪,因为检测任务通过奇怪的方式与反应线程通信。(译者注:下面的话挺绕的,可以参考原文)检测任务通过通知条件变量告诉反应线程,等待的事件可能发生了,但是反应线程必须通过检查flag来确保事件发生了。检测线程置位flag来告诉反应线程事件确实发生了,但是检测线程仍然还要先需要通知条件变量,以唤醒反应线程来检查flag。这种方案是可以工作的,但是不太优雅。

    -

    一个替代方案是让反应任务通过在检测任务设置的futurewait来避免使用条件变量,互斥锁和flag。这可能听起来也是个古怪的方案。毕竟,Item38中说明了future代表了从被调用方到(通常是异步的)调用方的通信信道的接收端,这里的检测任务和反应任务没有调用-被调用的关系。然而,Item38中也说说明了发送端是个std::promise,接收端是个future的通信信道不是只能用在调用-被调用场景。这样的通信信道可以用在任何你需要从程序一个地方传递信息到另一个地方的场景。这里,我们用来在检测任务和反应任务之间传递信息,传递的信息就是感兴趣的事件已经发生。

    +

    一个替代方案是让反应任务通过在检测任务设置的futurewait来避免使用条件变量,互斥锁和flag。这可能听起来也是个古怪的方案。毕竟,Item38中说明了future代表了从被调用方到(通常是异步的)调用方的通信信道的接收端,这里的检测任务和反应任务没有调用-被调用的关系。然而,Item38中也说说明了发送端是个std::promise,接收端是个future的通信信道不是只能用在调用-被调用场景。这样的通信信道可以用在任何你需要从程序一个地方传递信息到另一个地方的场景。这里,我们用来在检测任务和反应任务之间传递信息,传递的信息就是感兴趣的事件已经发生。

    方案很简单。检测任务有一个std::promise对象(即通信信道的写入端),反应任务有对应的future。当检测任务看到事件已经发生,设置std::promise对象(即写入到通信信道)。同时,wait会阻塞住反应任务直到std::promise被设置。

    现在,std::promisefutures(即std::futurestd::shared_future)都是需要类型参数的模板。形参表明通过通信信道被传递的信息的类型。在这里,没有数据被传递,只需要让反应任务知道它的future已经被设置了。我们在std::promisefuture模板中需要的东西是表明通信信道中没有数据被传递的一个类型。这个类型就是void。检测任务使用std::promise<void>,反应任务使用std::future<void>或者std::shared_future<void>。当感兴趣的事件发生时,检测任务设置std::promise<void>,反应任务在futurewait。尽管反应任务不从检测任务那里接收任何数据,通信信道也可以让反应任务知道,检测任务什么时候已经通过对std::promise<void>调用set_value“写入”了void数据。

    所以,有

    @@ -5500,7 +5500,7 @@ p.get_future().wait(); //等待对应于p的那个future … //对事件作出反应

    像使用flag的方法一样,此设计不需要互斥锁,无论在反应线程调用wait之前检测线程是否设置了std::promise都可以工作,并且不受虚假唤醒的影响(只有条件变量才容易受到此影响)。与基于条件变量的方法一样,反应任务在调用wait之后是真被阻塞住的,不会一直占用系统资源。是不是很完美?

    -

    当然不是,基于future的方法没有了上述问题,但是有其他新的问题。比如,Item38中说明,std::promisefuture之间有个共享状态,并且共享状态是动态分配的。因此你应该假定此设计会产生基于堆的分配和释放开销。

    +

    当然不是,基于future的方法没有了上述问题,但是有其他新的问题。比如,Item38中说明,std::promisefuture之间有个共享状态,并且共享状态是动态分配的。因此你应该假定此设计会产生基于堆的分配和释放开销。

    也许更重要的是,std::promise只能设置一次。std::promisefuture之间的通信是一次性的:不能重复使用。这是与基于条件变量或者基于flag的设计的明显差异,条件变量和flag都可以通信多次。(条件变量可以被重复通知,flag也可以重复清除和设置。)

    一次通信可能没有你想象中那么大的限制。假定你想创建一个挂起的系统线程。就是,你想避免多次线程创建的那种经常开销,以便想要使用这个线程执行程序时,避免潜在的线程创建工作。或者你想创建一个挂起的线程,以便在线程运行前对其进行设置这样的设置包括优先级或者核心亲和性(core affinity)。C++并发API没有提供这种设置能力,但是std::thread提供了native_handle成员函数,它的结果就是提供给你对平台原始线程API的访问(通常是POSIX或者Windows的线程)。这些低层次的API使你可以对线程设置优先级和亲和性。

    假设你仅仅想要对某线程挂起一次(在创建后,运行线程函数前),使用voidfuture就是一个可行方案。这是这个技术的关键点:

    @@ -5519,7 +5519,7 @@ void detect() //检测任务的函数 t.join(); //使t不可结合(见条款37) }
    -

    因为所有离开detect的路径中t都要是不可结合的,所以使用类似于Item37ThreadRAII的RAII类很明智。代码如下:

    +

    因为所有离开detect的路径中t都要是不可结合的,所以使用类似于Item37ThreadRAII的RAII类很明智。代码如下:

    void detect()
     {
         ThreadRAII tr(                      //使用RAII对象
    @@ -5602,7 +5602,7 @@ volatile int vc(0);             //“volatile计数器”
     
     

    vc的最后结果是1,即使看起来自增了两次。

    不仅只有这一种可能的结果,通常来说vc的最终结果是不可预测的,因为vc会发生数据竞争,对于数据竞争造成未定义行为,标准规定表示编译器生成的代码可能是任何逻辑。当然,编译器不会利用这种行为来作恶。但是它们通常做出一些没有数据竞争的程序中才有效的优化,这些优化在存在数据竞争的程序中会造成异常和不可预测的行为。

    -

    RMW操作不是仅有的std::atomic在并发中有效而volatile无效的例子。假定一个任务计算第二个任务需要的一个重要的值。当第一个任务完成计算,必须传递给第二个任务。Item39表明一种使用std::atomic<bool>的方法来使第一个任务通知第二个任务计算完成。计算值的任务的代码如下:

    +

    RMW操作不是仅有的std::atomic在并发中有效而volatile无效的例子。假定一个任务计算第二个任务需要的一个重要的值。当第一个任务完成计算,必须传递给第二个任务。Item39表明一种使用std::atomic<bool>的方法来使第一个任务通知第二个任务计算完成。计算值的任务的代码如下:

    std::atomic<bool> valVailable(false); 
     auto imptValue = computeImportantValue();   //计算值
     valAvailable = true;                        //告诉另一个任务,值可用了
    @@ -5672,7 +5672,7 @@ x = 10;                                 //写x(不会被优化掉)
     x = 20;                                 //再次写x
     

    如果x是内存映射的(或者已经映射到跨进程共享的内存位置等),这正是我们想要的。

    -

    突击测试!在最后一段代码中,y是什么类型:int还是volatile int?(y的类型使用auto类型推导,所以使用Item2中的规则。规则上说非引用非指针类型的声明(就是y的情况),constvolatile限定符被拿掉。y的类型因此仅仅是int。这意味着对y的冗余读取和写入可以被消除。在例子中,编译器必须执行对y的初始化和赋值两个语句,因为xvolatile的,所以第二次对x的读取可能会产生一个与第一次不同的值。)

    +

    突击测试!在最后一段代码中,y是什么类型:int还是volatile int?(y的类型使用auto类型推导,所以使用Item2中的规则。规则上说非引用非指针类型的声明(就是y的情况),constvolatile限定符被拿掉。y的类型因此仅仅是int。这意味着对y的冗余读取和写入可以被消除。在例子中,编译器必须执行对y的初始化和赋值两个语句,因为xvolatile的,所以第二次对x的读取可能会产生一个与第一次不同的值。)

    在处理特殊内存时,必须保留看似冗余访问和无用存储的事实,顺便说明了为什么std::atomic不适合这种场景。编译器被允许消除对std::atomic的冗余操作。代码的编写方式与volatile那些不那么相同,但是如果我们暂时忽略它,只关注编译器执行的操作,则概念上可以说,编译器看到这个,

    std::atomic<int> x;
     auto y = x;                             //概念上会读x(见下)
    @@ -5690,7 +5690,7 @@ x = 20;                                 //写x
     
    auto y = x;                             //错误
     y = x;                                  //错误
     
    -

    这是因为std::atomic类型的拷贝操作是被删除的(参见Item11)。因为有个很好的理由删除。想象一下如果y使用x来初始化会发生什么。因为xstd::atomic类型,y的类型被推导为std::atomic(参见Item2)。我之前说了std::atomic最好的特性之一就是所有成员函数都是原子性的,但是为了使从x拷贝初始化y的过程是原子性的,编译器不得不生成代码,把读取x和写入y放在一个单独的原子性操作中。硬件通常无法做到这一点,因此std::atomic不支持拷贝构造。出于同样的原因,拷贝赋值也被删除了,这也是为什么从x赋值给y也编译失败。(移动操作在std::atomic没有显式声明,因此根据Item17中描述的规则来看,std::atomic不支持移动构造和移动赋值)。

    +

    这是因为std::atomic类型的拷贝操作是被删除的(参见Item11)。因为有个很好的理由删除。想象一下如果y使用x来初始化会发生什么。因为xstd::atomic类型,y的类型被推导为std::atomic(参见Item2)。我之前说了std::atomic最好的特性之一就是所有成员函数都是原子性的,但是为了使从x拷贝初始化y的过程是原子性的,编译器不得不生成代码,把读取x和写入y放在一个单独的原子性操作中。硬件通常无法做到这一点,因此std::atomic不支持拷贝构造。出于同样的原因,拷贝赋值也被删除了,这也是为什么从x赋值给y也编译失败。(移动操作在std::atomic没有显式声明,因此根据Item17中描述的规则来看,std::atomic不支持移动构造和移动赋值)。

    可以将x的值传递给y,但是需要使用std::atomicloadstore成员函数。load函数原子性地读取,store原子性地写入。要使用x初始化y,然后将x的值放入y,代码应该这样写:

    std::atomic<int> y(x.load());           //读x
     y.store(x.load());                      //再次读x
    @@ -5723,7 +5723,7 @@ y.store(register);                      //把寄存器值存储到y
     

    对于C++中的通用技术和特性,总是存在适用和不适用的场景。除了本章覆盖的两个例外,描述什么场景使用哪种通用技术通常来说很容易。这两个例外是传值(pass by value)和安置(emplacement)。决定何时使用这两种技术受到多种因素的影响,本书提供的最佳建议是在使用它们的同时仔细考虑清楚,尽管它们都是高效的现代C++编程的重要角色。接下来的条款提供了使用它们来编写软件是否合适的所需信息。

    条款四十一:对于移动成本低且总是被拷贝的可拷贝形参,考虑按值传递

    Item 41: Consider pass by value for copyable parameters that are cheap to move and always copied

    -

    有些函数的形参是可拷贝的。(在本条款中,“拷贝”一个形参通常意思是,将形参作为拷贝或移动操作的源对象。简介中提到,C++没有术语来区分拷贝操作得到的副本和移动操作得到的副本。)比如说,addName成员函数可以拷贝自己的形参到一个私有容器。为了提高效率,应该拷贝左值,移动右值。

    +

    有些函数的形参是可拷贝的。(在本条款中,“拷贝”一个形参通常意思是,将形参作为拷贝或移动操作的源对象。简介中提到,C++没有术语来区分拷贝操作得到的副本和移动操作得到的副本。)比如说,addName成员函数可以拷贝自己的形参到一个私有容器。为了提高效率,应该拷贝左值,移动右值。

    class Widget {
     public:
         void addName(const std::string& newName)    //接受左值;拷贝它
    @@ -5738,7 +5738,7 @@ private:
     

    这是可行的,但是需要编写两个函数来做本质相同的事。这有点让人难受:两个函数声明,两个函数实现,两个函数写说明,两个函数的维护。唉。

    此外,目标代码中会有两个函数——你可能会担心程序的空间占用。这种情况下,两个函数都可能内联,可能会避免同时两个函数同时存在导致的代码膨胀问题,但是一旦没有被内联,目标代码就会出现两个函数。

    -

    另一种方法是使addName函数成为具有通用引用的函数模板(参考Item24):

    +

    另一种方法是使addName函数成为具有通用引用的函数模板(参考Item24):

    class Widget {
     public:
         template<typename T>                            //接受左值和右值;
    @@ -5748,7 +5748,7 @@ public:
         …
     };
     
    -

    这减少了源代码的维护工作,但是通用引用会导致其他复杂性。作为模板,addName的实现必须放置在头文件中。在编译器展开的时候,可能会产生多个函数,因为不止为左值和右值实例化,也可能为std::string和可转换为std::string的类型分别实例化为多个函数(参考Item25)。同时有些实参类型不能通过通用引用传递(参考Item30),而且如果传递了不合法的实参类型,编译器错误会令人生畏(参考Item27)。

    +

    这减少了源代码的维护工作,但是通用引用会导致其他复杂性。作为模板,addName的实现必须放置在头文件中。在编译器展开的时候,可能会产生多个函数,因为不止为左值和右值实例化,也可能为std::string和可转换为std::string的类型分别实例化为多个函数(参考Item25)。同时有些实参类型不能通过通用引用传递(参考Item30),而且如果传递了不合法的实参类型,编译器错误会令人生畏(参考Item27)。

    是否存在一种编写addName的方法,可以左值拷贝,右值移动,只用处理一个函数(源代码和目标代码中),且避免使用通用引用?答案是是的。你要做的就是放弃你学习C++编程的第一条规则。这条规则是避免在传递用户定义的对象时使用传值方式。像是addName函数中的newName形参,按值传递可能是一种完全合理的策略。

    在我们讨论为什么对于addName中的newName按值传递非常合理之前,让我们来考虑该会怎样实现:

    class Widget {
    @@ -5923,7 +5923,7 @@ private:
     

    这种情况下,按值传递的开销包括了内存分配和内存销毁——可能会比std::string的移动操作高出几个数量级。

    有趣的是,如果旧密码短于新密码,在赋值过程中就不可避免要销毁、分配内存,这种情况,按值传递跟按引用传递的效率是一样的。因此,基于赋值的形参拷贝操作开销取决于具体的实参的值,这种分析适用于在动态分配内存中存值的形参类型。不是所有类型都满足,但是很多——包括std::stringstd::vector——是这样。

    这种潜在的开销增加仅在传递左值实参时才适用,因为执行内存分配和释放通常发生在真正的拷贝操作(即,不是移动)中。对右值实参,移动几乎就足够了。

    -

    结论是,使用通过赋值拷贝一个形参进行按值传递的函数的额外开销,取决于传递的类型,左值和右值的比例,这个类型是否需要动态分配内存,以及,如果需要分配内存的话,赋值操作符的具体实现,还有赋值目标占的内存至少要跟赋值源占的内存一样大。对于std::string来说,开销还取决于实现是否使用了小字符串优化(SSO——参考Item29),如果是,那么要赋值的值是否匹配SSO缓冲区。

    +

    结论是,使用通过赋值拷贝一个形参进行按值传递的函数的额外开销,取决于传递的类型,左值和右值的比例,这个类型是否需要动态分配内存,以及,如果需要分配内存的话,赋值操作符的具体实现,还有赋值目标占的内存至少要跟赋值源占的内存一样大。对于std::string来说,开销还取决于实现是否使用了小字符串优化(SSO——参考Item29),如果是,那么要赋值的值是否匹配SSO缓冲区。

    所以,正如我所说,当形参通过赋值进行拷贝时,分析按值传递的开销是复杂的。通常,最有效的经验就是“在证明没问题之前假设有问题”,就是除非已证明按值传递会为你需要的形参类型产生可接受的执行效率,否则使用重载或者通用引用的实现方式。

    到此为止,对于需要运行尽可能快的软件来说,按值传递可能不是一个好策略,因为避免即使开销很小的移动操作也非常重要。此外,有时并不能清楚知道会发生多少次移动操作。在Widget::addName例子中,按值传递仅多了一次移动操作,但是如果Widget::addName调用了Widget::validateName,这个函数也是按值传递。(假定它有理由总是拷贝它的形参,比如把验证过的所有值存在一个数据结构中。)并假设validateName调用了第三个函数,也是按值传递……

    可以看到这将会通向何方。在调用链中,每个函数都使用传值,因为“只多了一次移动的开销”,但是整个调用链总体就会产生无法忍受的开销,通过引用传递,调用链不会增加这种开销。

    @@ -5974,7 +5974,7 @@ public:

    为了在std::string容器中创建新元素,调用了std::string的构造函数,但是这份代码并不仅调用了一次构造函数,而是调用了两次,而且还调用了std::string析构函数。下面是在push_back运行时发生了什么:

    1. 一个std::string的临时对象从字面量“xyzzy”被创建。这个对象没有名字,我们可以称为temptemp的构造是第一次std::string构造。因为是临时变量,所以temp是右值。
    2. -
    3. temp被传递给push_back的右值重载函数,绑定到右值引用形参x。在std::vector的内存中一个x的副本被创建。这次构造——也是第二次构造——在std::vector内部真正创建一个对象。(将x副本拷贝到std::vector内部的构造函数是移动构造函数,因为x在它被拷贝前被转换为一个右值,成为右值引用。有关将右值引用形参强制转换为右值的信息,请参见Item25)。
    4. +
    5. temp被传递给push_back的右值重载函数,绑定到右值引用形参x。在std::vector的内存中一个x的副本被创建。这次构造——也是第二次构造——在std::vector内部真正创建一个对象。(将x副本拷贝到std::vector内部的构造函数是移动构造函数,因为x在它被拷贝前被转换为一个右值,成为右值引用。有关将右值引用形参强制转换为右值的信息,请参见Item25)。
    6. push_back返回之后,temp立刻被销毁,调用了一次std::string的析构函数。

    对于性能执着的人不禁注意到是否存在一种方法可以获取字符串字面量并将其直接传入到步骤2里在std::vector内构造std::string的代码中,可以避免临时对象temp的创建与销毁。这样的效率最好,对于性能执着的人也不会有什么意见了。

    @@ -5982,7 +5982,7 @@ public:

    emplace_back就是像我们想要的那样做的:使用传递给它的任何实参直接在std::vector内部构造一个std::string。没有临时变量会生成:

    vs.emplace_back("xyzzy");           //直接用“xyzzy”在vs内构造std::string
     
    -

    emplace_back使用完美转发,因此只要你没有遇到完美转发的限制(参见Item30),就可以传递任何实参以及组合到emplace_back。比如,如果你想通过接受一个字符和一个数量的std::string构造函数,在vs中创建一个std::string,代码如下:

    +

    emplace_back使用完美转发,因此只要你没有遇到完美转发的限制(参见Item30),就可以传递任何实参以及组合到emplace_back。比如,如果你想通过接受一个字符和一个数量的std::string构造函数,在vs中创建一个std::string,代码如下:

    vs.emplace_back(50, 'x');           //插入由50个“x”组成的一个std::string
     

    emplace_back可以用于每个支持push_back的标准容器。类似的,每个支持push_front的标准容器都支持emplace_front。每个支持insert(除了std::forward_liststd::array)的标准容器支持emplace。关联容器提供emplace_hint来补充接受“hint”迭代器的insert函数,std::forward_listemplace_after来匹配insert_after

    @@ -6023,7 +6023,7 @@ vs.emplace_back(50, 'x'); //同上

    在决定是否使用置入函数时,需要注意另外两个问题。首先是资源管理。假定你有一个盛放std::shared_ptr<Widget>s的容器,

    std::list<std::shared_ptr<Widget>> ptrs;
     
    -

    然后你想添加一个通过自定义删除器释放的std::shared_ptr(参见Item19)。Item21说明你应该使用std::make_shared来创建std::shared_ptr,但是它也承认有时你无法做到这一点。比如当你要指定一个自定义删除器时。这时,你必须直接new一个原始指针,然后通过std::shared_ptr来管理。

    +

    然后你想添加一个通过自定义删除器释放的std::shared_ptr(参见Item19)。Item21说明你应该使用std::make_shared来创建std::shared_ptr,但是它也承认有时你无法做到这一点。比如当你要指定一个自定义删除器时。这时,你必须直接new一个原始指针,然后通过std::shared_ptr来管理。

    如果自定义删除器是这个函数,

    void killWidget(Widget* pWidget);
     
    @@ -6050,7 +6050,7 @@ vs.emplace_back(50, 'x'); //同上

    在这个场景中,生命周期不良好,这个失误不能赖std::shared_ptr。使用带自定义删除器的std::unique_ptr也会有同样的问题。根本上讲,像std::shared_ptrstd::unique_ptr这样的资源管理类的高效性是以资源(比如从new来的原始指针)被立即传递给资源管理对象的构造函数为条件的。实际上,std::make_sharedstd::make_unique这样的函数自动做了这些事,是使它们如此重要的原因。

    在对存储资源管理类对象的容器(比如std::list<std::shared_ptr<Widget>>)调用插入函数时,函数的形参类型通常确保在资源的获取(比如使用new)和资源管理对象的创建之间没有其他操作。在置入函数中,完美转发推迟了资源管理对象的创建,直到可以在容器的内存中构造它们为止,这给“异常导致资源泄漏”提供了可能。所有的标准库容器都容易受到这个问题的影响。在使用资源管理对象的容器时,必须注意确保在使用置入函数而不是插入函数时,不会为提高效率带来的降低异常安全性付出代价。

    -

    坦白说,无论如何,你不应该将“new Widget”之类的表达式传递给emplace_back或者push_back或者大多数这种函数,因为,就像Item21中解释的那样,这可能导致我们刚刚讨论的异常安全性问题。消除资源泄漏可能性的方法是,使用独立语句把从“new Widget”获取的指针传递给资源管理类对象,然后这个对象作为右值传递给你本来想传递“new Widget”的函数(Item21有这个观点的详细讨论)。使用push_back的代码应该如下:

    +

    坦白说,无论如何,你不应该将“new Widget”之类的表达式传递给emplace_back或者push_back或者大多数这种函数,因为,就像Item21中解释的那样,这可能导致我们刚刚讨论的异常安全性问题。消除资源泄漏可能性的方法是,使用独立语句把从“new Widget”获取的指针传递给资源管理类对象,然后这个对象作为右值传递给你本来想传递“new Widget”的函数(Item21有这个观点的详细讨论)。使用push_back的代码应该如下:

    std::shared_ptr<Widget> spw(new Widget,      //创建Widget,让spw管理它
                                 killWidget);
     ptrs.push_back(std::move(spw));              //添加spw右值