template<typename T>
Fraction //按值返回
diff --git a/5.RRefMovSemPerfForw/item26.html b/5.RRefMovSemPerfForw/item26.html
index 271112a..566cc11 100644
--- a/5.RRefMovSemPerfForw/item26.html
+++ b/5.RRefMovSemPerfForw/item26.html
@@ -160,7 +160,7 @@ logAndAdd("Patty Dog"); //传递字符串字面值
在第一个调用中,logAndAdd
的形参name
绑定到变量petName
。在logAndAdd
中name
最终传给names.emplace
。因为name
是左值,会拷贝到names
中。没有方法避免拷贝,因为是左值(petName
)传递给logAndAdd
的。
在第二个调用中,形参name
绑定到右值(显式从“Persephone
”创建的临时std::string
)。name
本身是个左值,所以它被拷贝到names
中,但是我们意识到,原则上,它的值可以被移动到names
中。本次调用中,我们有个拷贝代价,但是我们应该能用移动勉强应付。
在第三个调用中,形参name
也绑定一个右值,但是这次是通过“Patty Dog
”隐式创建的临时std::string
变量。就像第二个调用中,name
被拷贝到names
,但是这里,传递给logAndAdd
的实参是一个字符串字面量。如果直接将字符串字面量传递给emplace
,就不会创建std::string
的临时变量,而是直接在std::multiset
中通过字面量构建std::string
。在第三个调用中,我们有个std::string
拷贝开销,但是我们连移动开销都不想要,更别说拷贝的。
-我们可以通过使用通用引用(参见Item24)重写logAndAdd
来使第二个和第三个调用效率提升,按照Item25的说法,std::forward
转发这个引用到emplace
。代码如下:
+我们可以通过使用通用引用(参见Item24)重写logAndAdd
来使第二个和第三个调用效率提升,按照Item25的说法,std::forward
转发这个引用到emplace
。代码如下:
template<typename T>
void logAndAdd(T&& name)
{
@@ -203,7 +203,7 @@ logAndAdd(nameIdx); //错误!
最后一行的注释并不清楚明白,下面让我来说明发生了什么。
有两个重载的logAndAdd
。使用通用引用的那个推导出T
的类型是short
,因此可以精确匹配。对于int
类型参数的重载也可以在short
类型提升后匹配成功。根据正常的重载解决规则,精确匹配优先于类型提升的匹配,所以被调用的是通用引用的重载。
在通用引用那个重载中,name
形参绑定到要传入的short
上,然后name
被std::forward
给names
(一个std::multiset<std::string>
)的emplace
成员函数,然后又被转发给std::string
构造函数。std::string
没有接受short
的构造函数,所以logAndAdd
调用里的multiset::emplace
调用里的std::string
构造函数调用失败。(译者注:这句话比较绕,实际上就是调用链。)所有这一切的原因就是对于short
类型通用引用重载优先于int
类型的重载。
-使用通用引用的函数在C++中是最贪婪的函数。它们几乎可以精确匹配任何类型的实参(极少不适用的实参在Item30中介绍)。这也是把重载和通用引用组合在一块是糟糕主意的原因:通用引用的实现会匹配比开发者预期要多得多的实参类型。
+使用通用引用的函数在C++中是最贪婪的函数。它们几乎可以精确匹配任何类型的实参(极少不适用的实参在Item30中介绍)。这也是把重载和通用引用组合在一块是糟糕主意的原因:通用引用的实现会匹配比开发者预期要多得多的实参类型。
一个更容易掉入这种陷阱的例子是写一个完美转发构造函数。简单对logAndAdd
例子进行改造就可以说明这个问题。不用写接受std::string
或者用索引查找std::string
的自由函数,只是想一个构造函数有着相同操作的Person
类:
class Person {
public:
@@ -219,7 +219,7 @@ private:
std::string name;
};
-就像在logAndAdd
的例子中,传递一个不是int
的整型变量(比如std::size_t
,short
,long
等)会调用通用引用的构造函数而不是int
的构造函数,这会导致编译错误。这里这个问题甚至更糟糕,因为Person
中存在的重载比肉眼看到的更多。在Item17中说明,在适当的条件下,C++会生成拷贝和移动构造函数,即使类包含了模板化的构造函数,模板函数能实例化产生与拷贝和移动构造函数一样的签名,也在合适的条件范围内。如果拷贝和移动构造被生成,Person
类看起来就像这样:
+就像在logAndAdd
的例子中,传递一个不是int
的整型变量(比如std::size_t
,short
,long
等)会调用通用引用的构造函数而不是int
的构造函数,这会导致编译错误。这里这个问题甚至更糟糕,因为Person
中存在的重载比肉眼看到的更多。在Item17中说明,在适当的条件下,C++会生成拷贝和移动构造函数,即使类包含了模板化的构造函数,模板函数能实例化产生与拷贝和移动构造函数一样的签名,也在合适的条件范围内。如果拷贝和移动构造被生成,Person
类看起来就像这样:
class Person {
public:
template<typename T> //完美转发的构造函数
@@ -269,7 +269,7 @@ public:
};
但是没啥影响,因为重载规则规定当模板实例化函数和非模板函数(或者称为“正常”函数)匹配优先级相当时,优先使用“正常”函数。拷贝构造函数(正常函数)因此胜过具有相同签名的模板实例化函数。
-(如果你想知道为什么编译器在生成一个拷贝构造函数时还会模板实例化一个相同签名的函数,参考Item17。)
+(如果你想知道为什么编译器在生成一个拷贝构造函数时还会模板实例化一个相同签名的函数,参考Item17。)
当继承纳入考虑范围时,完美转发的构造函数与编译器生成的拷贝、移动操作之间的交互会更加复杂。尤其是,派生类的拷贝和移动操作的传统实现会表现得非常奇怪。来看一下:
class SpecialPerson: public Person {
public:
@@ -283,7 +283,7 @@ public:
};
如同注释表示的,派生类的拷贝和移动构造函数没有调用基类的拷贝和移动构造函数,而是调用了基类的完美转发构造函数!为了理解原因,要知道派生类将SpecialPerson
类型的实参传递给其基类,然后通过模板实例化和重载解析规则作用于基类Person
。最终,代码无法编译,因为std::string
没有接受一个SpecialPerson
的构造函数。
-我希望到目前为止,已经说服了你,如果可能的话,避免对通用引用形参的函数进行重载。但是,如果在通用引用上重载是糟糕的主意,那么如果需要可转发大多数实参类型的函数,但是对于某些实参类型又要特殊处理应该怎么办?存在多种办法。实际上,下一个条款,Item27专门来讨论这个问题,敬请阅读。
+我希望到目前为止,已经说服了你,如果可能的话,避免对通用引用形参的函数进行重载。但是,如果在通用引用上重载是糟糕的主意,那么如果需要可转发大多数实参类型的函数,但是对于某些实参类型又要特殊处理应该怎么办?存在多种办法。实际上,下一个条款,Item27专门来讨论这个问题,敬请阅读。
请记住:
- 对通用引用形参的函数进行重载,通用引用函数的调用机会几乎总会比你期望的多得多。
diff --git a/5.RRefMovSemPerfForw/item27.html b/5.RRefMovSemPerfForw/item27.html
index 630103c..fd7fb76 100644
--- a/5.RRefMovSemPerfForw/item27.html
+++ b/5.RRefMovSemPerfForw/item27.html
@@ -141,14 +141,14 @@
Item 27: Familiarize yourself with alternatives to overloading on universal references
-Item26中说明了对使用通用引用形参的函数,无论是独立函数还是成员函数(尤其是构造函数),进行重载都会导致一系列问题。但是也提供了一些示例,如果能够按照我们期望的方式运行,重载可能也是有用的。这个条款探讨了几种,通过避免在通用引用上重载的设计,或者通过限制通用引用可以匹配的参数类型,来实现所期望行为的方法。
-讨论基于Item26中的示例,如果你还没有阅读那个条款,请先阅读那个条款再继续。
+Item26中说明了对使用通用引用形参的函数,无论是独立函数还是成员函数(尤其是构造函数),进行重载都会导致一系列问题。但是也提供了一些示例,如果能够按照我们期望的方式运行,重载可能也是有用的。这个条款探讨了几种,通过避免在通用引用上重载的设计,或者通过限制通用引用可以匹配的参数类型,来实现所期望行为的方法。
+讨论基于Item26中的示例,如果你还没有阅读那个条款,请先阅读那个条款再继续。
-在Item26中的第一个例子中,logAndAdd
是许多函数的代表,这些函数可以使用不同的名字来避免在通用引用上的重载的弊端。例如两个重载的logAndAdd
函数,可以分别改名为logAndAddName
和logAndAddNameIdx
。但是,这种方式不能用在第二个例子,Person
构造函数中,因为构造函数的名字被语言固定了(译者注:即构造函数名与类名相同)。此外谁愿意放弃重载呢?
+在Item26中的第一个例子中,logAndAdd
是许多函数的代表,这些函数可以使用不同的名字来避免在通用引用上的重载的弊端。例如两个重载的logAndAdd
函数,可以分别改名为logAndAddName
和logAndAddNameIdx
。但是,这种方式不能用在第二个例子,Person
构造函数中,因为构造函数的名字被语言固定了(译者注:即构造函数名与类名相同)。此外谁愿意放弃重载呢?
-一种替代方案是退回到C++98,然后将传递通用引用替换为传递lvalue-refrence-to-const
。事实上,这是Item26中首先考虑的方法。缺点是效率不高。现在我们知道了通用引用和重载的相互关系,所以放弃一些效率来确保行为正确简单可能也是一种不错的折中。
+一种替代方案是退回到C++98,然后将传递通用引用替换为传递lvalue-refrence-to-const
。事实上,这是Item26中首先考虑的方法。缺点是效率不高。现在我们知道了通用引用和重载的相互关系,所以放弃一些效率来确保行为正确简单可能也是一种不错的折中。
-通常在不增加复杂性的情况下提高性能的一种方法是,将按传引用形参替换为按值传递,这是违反直觉的。该设计遵循Item41中给出的建议,即在你知道要拷贝时就按值传递,因此会参考那个条款来详细讨论如何设计与工作,效率如何。这里,在Person
的例子中展示:
+通常在不增加复杂性的情况下提高性能的一种方法是,将按传引用形参替换为按值传递,这是违反直觉的。该设计遵循Item41中给出的建议,即在你知道要拷贝时就按值传递,因此会参考那个条款来详细讨论如何设计与工作,效率如何。这里,在Person
的例子中展示:
class Person {
public:
explicit Person(std::string n) //代替T&&构造函数,
@@ -162,7 +162,7 @@ private:
std::string name;
};
-因为没有std::string
构造函数可以接受整型参数,所有int
或者其他整型变量(比如std::size_t
、short
、long
等)都会使用int
类型重载的构造函数。相似的,所有std::string
类似的实参(还有可以用来创建std::string
的东西,比如字面量“Ruth
”等)都会使用std::string
类型的重载构造函数。没有意外情况。我想你可能会说有些人使用0
或者NULL
指代空指针会调用int
重载的构造函数让他们很吃惊,但是这些人应该参考Item8反复阅读直到使用0
或者NULL
作为空指针让他们恶心。
+因为没有std::string
构造函数可以接受整型参数,所有int
或者其他整型变量(比如std::size_t
、short
、long
等)都会使用int
类型重载的构造函数。相似的,所有std::string
类似的实参(还有可以用来创建std::string
的东西,比如字面量“Ruth
”等)都会使用std::string
类型的重载构造函数。没有意外情况。我想你可能会说有些人使用0
或者NULL
指代空指针会调用int
重载的构造函数让他们很吃惊,但是这些人应该参考Item8反复阅读直到使用0
或者NULL
作为空指针让他们恶心。
传递lvalue-reference-to-const
以及按值传递都不支持完美转发。如果使用通用引用的动机是完美转发,我们就只能使用通用引用了,没有其他选择。但是又不想放弃重载。所以如果不放弃重载又不放弃通用引用,如何避免在通用引用上重载呢?
实际上并不难。通过查看所有重载的所有形参以及调用点的所有传入实参,然后选择最优匹配的函数——考虑所有形参/实参的组合。通用引用通常提供了最优匹配,但是如果通用引用是包含其他非通用引用的形参列表的一部分,则非通用引用形参的较差匹配会使有一个通用引用的重载版本不被运行。这就是tag dispatch方法的基础,下面的示例会使这段话更容易理解。
@@ -177,8 +177,8 @@ void logAndAdd(T&& name)
names.emplace(std::forward<T>(name));
}
-就其本身而言,功能执行没有问题,但是如果引入一个int
类型的重载来用索引查找对象,就会重新陷入Item26中描述的麻烦。这个条款的目标是避免它。不通过重载,我们重新实现logAndAdd
函数分拆为两个函数,一个针对整型值,一个针对其他。logAndAdd
本身接受所有实参类型,包括整型和非整型。
-这两个真正执行逻辑的函数命名为logAndAddImpl
,即我们使用重载。其中一个函数接受通用引用。所以我们同时使用了重载和通用引用。但是每个函数接受第二个形参,表征传入的实参是否为整型。这第二个形参可以帮助我们避免陷入到Item26中提到的麻烦中,因为我们将其安排为第二个实参决定选择哪个重载函数。
+就其本身而言,功能执行没有问题,但是如果引入一个int
类型的重载来用索引查找对象,就会重新陷入Item26中描述的麻烦。这个条款的目标是避免它。不通过重载,我们重新实现logAndAdd
函数分拆为两个函数,一个针对整型值,一个针对其他。logAndAdd
本身接受所有实参类型,包括整型和非整型。
+这两个真正执行逻辑的函数命名为logAndAddImpl
,即我们使用重载。其中一个函数接受通用引用。所以我们同时使用了重载和通用引用。但是每个函数接受第二个形参,表征传入的实参是否为整型。这第二个形参可以帮助我们避免陷入到Item26中提到的麻烦中,因为我们将其安排为第二个实参决定选择哪个重载函数。
是的,我知道,“不要在啰嗦了,赶紧亮出代码”。没有问题,代码如下,这是最接近正确版本的:
template<typename T>
void logAndAdd(T&& name)
@@ -187,8 +187,8 @@ void logAndAdd(T&& name)
std::is_integral<T>()); //不那么正确
}
-这个函数转发它的形参给logAndAddImpl
函数,但是多传递了一个表示形参T
是否为整型的实参。至少,这就是应该做的。对于右值的整型实参来说,这也是正确的。但是如同Item28中说明,如果左值实参传递给通用引用name
,对T
类型推断会得到左值引用。所以如果左值int
被传入logAndAdd
,T
将被推断为int&
。这不是一个整型类型,因为引用不是整型类型。这意味着std::is_integral<T>
对于任何左值实参返回false,即使确实传入了整型值。
-意识到这个问题基本相当于解决了它,因为C++标准库有一个type trait(参见Item9),std::remove_reference
,函数名字就说明做了我们希望的:移除类型的引用说明符。所以正确实现的代码应该是这样:
+这个函数转发它的形参给logAndAddImpl
函数,但是多传递了一个表示形参T
是否为整型的实参。至少,这就是应该做的。对于右值的整型实参来说,这也是正确的。但是如同Item28中说明,如果左值实参传递给通用引用name
,对T
类型推断会得到左值引用。所以如果左值int
被传入logAndAdd
,T
将被推断为int&
。这不是一个整型类型,因为引用不是整型类型。这意味着std::is_integral<T>
对于任何左值实参返回false,即使确实传入了整型值。
+意识到这个问题基本相当于解决了它,因为C++标准库有一个type trait(参见Item9),std::remove_reference
,函数名字就说明做了我们希望的:移除类型的引用说明符。所以正确实现的代码应该是这样:
template<typename T>
void logAndAdd(T&& name)
{
@@ -198,7 +198,7 @@ void logAndAdd(T&& name)
);
}
-这个代码很巧妙。(在C++14中,你可以通过std::remove_reference_t<T>
来简化写法,参看Item9)
+这个代码很巧妙。(在C++14中,你可以通过std::remove_reference_t<T>
来简化写法,参看Item9)
处理完之后,我们可以将注意力转移到名为logAndAddImpl
的函数上了。有两个重载函数,第一个仅用于非整型类型(即std::is_integral<typename std::remove_reference<T>::type>
是false):
template<typename T> //非整型实参:添加到全局数据结构中
void logAndAddImpl(T&& name, std::false_type) //译者注:高亮std::false_type
@@ -218,13 +218,13 @@ void logAndAddImpl(int idx, std::true_type) //译者注:高亮std::true_type
通过索引找到对应的name
,然后让logAndAddImpl
传递给logAndAdd
(名字会被再std::forward
给另一个logAndAddImpl
重载),我们避免了将日志代码放入这个logAndAddImpl
重载中。
在这个设计中,类型std::true_type
和std::false_type
是“标签”(tag),其唯一目的就是强制重载解析按照我们的想法来执行。注意到我们甚至没有对这些参数进行命名。他们在运行时毫无用处,事实上我们希望编译器可以意识到这些标签形参没被使用,然后在程序执行时优化掉它们。(至少某些时候有些编译器会这样做。)通过创建标签对象,在logAndAdd
内部将重载实现函数的调用“分发”(dispatch)给正确的重载。因此这个设计名称为:tag dispatch。这是模板元编程的标准构建模块,你对现代C++库中的代码了解越多,你就会越多遇到这种设计。
-就我们的目的而言,tag dispatch的重要之处在于它可以允许我们组合重载和通用引用使用,而没有Item26中提到的问题。分发函数——logAndAdd
——接受一个没有约束的通用引用参数,但是这个函数没有重载。实现函数——logAndAddImpl
——是重载的,一个接受通用引用参数,但是重载规则不仅依赖通用引用形参,还依赖新引入的标签形参,标签值设计来保证有不超过一个的重载是合适的匹配。结果是标签来决定采用哪个重载函数。通用引用参数可以生成精确匹配的事实在这里并不重要。(译者注:这里确实比较啰嗦,如果理解了上面的内容,这段完全可以没有。)
+就我们的目的而言,tag dispatch的重要之处在于它可以允许我们组合重载和通用引用使用,而没有Item26中提到的问题。分发函数——logAndAdd
——接受一个没有约束的通用引用参数,但是这个函数没有重载。实现函数——logAndAddImpl
——是重载的,一个接受通用引用参数,但是重载规则不仅依赖通用引用形参,还依赖新引入的标签形参,标签值设计来保证有不超过一个的重载是合适的匹配。结果是标签来决定采用哪个重载函数。通用引用参数可以生成精确匹配的事实在这里并不重要。(译者注:这里确实比较啰嗦,如果理解了上面的内容,这段完全可以没有。)
-tag dispatch的关键是存在单独一个函数(没有重载)给客户端API。这个单独的函数分发给具体的实现函数。创建一个没有重载的分发函数通常是容易的,但是Item26中所述第二个问题案例是Person
类的完美转发构造函数,是个例外。编译器可能会自行生成拷贝和移动构造函数,所以即使你只写了一个构造函数并在其中使用tag dispatch,有一些对构造函数的调用也被编译器生成的函数处理,绕过了分发机制。
-实际上,真正的问题不是编译器生成的函数会绕过tag dispatch设计,而是不总会绕过去。你希望类的拷贝构造函数总是处理该类型的左值拷贝请求,但是如同Item26中所述,提供具有通用引用的构造函数,会使通用引用构造函数在拷贝non-const
左值时被调用(而不是拷贝构造函数)。那个条款还说明了当一个基类声明了完美转发构造函数,派生类实现自己的拷贝和移动构造函数时会调用那个完美转发构造函数,尽管正确的行为是调用基类的拷贝或者移动构造。
+tag dispatch的关键是存在单独一个函数(没有重载)给客户端API。这个单独的函数分发给具体的实现函数。创建一个没有重载的分发函数通常是容易的,但是Item26中所述第二个问题案例是Person
类的完美转发构造函数,是个例外。编译器可能会自行生成拷贝和移动构造函数,所以即使你只写了一个构造函数并在其中使用tag dispatch,有一些对构造函数的调用也被编译器生成的函数处理,绕过了分发机制。
+实际上,真正的问题不是编译器生成的函数会绕过tag dispatch设计,而是不总会绕过去。你希望类的拷贝构造函数总是处理该类型的左值拷贝请求,但是如同Item26中所述,提供具有通用引用的构造函数,会使通用引用构造函数在拷贝non-const
左值时被调用(而不是拷贝构造函数)。那个条款还说明了当一个基类声明了完美转发构造函数,派生类实现自己的拷贝和移动构造函数时会调用那个完美转发构造函数,尽管正确的行为是调用基类的拷贝或者移动构造。
这种情况,采用通用引用的重载函数通常比期望的更加贪心,虽然不像单个分派函数一样那么贪心,而又不满足使用tag dispatch的条件。你需要另外的技术,可以让你确定允许使用通用引用模板的条件。朋友,你需要的就是std::enable_if
。
std::enable_if
可以给你提供一种强制编译器执行行为的方法,像是特定模板不存在一样。这种模板被称为被禁止(disabled)。默认情况下,所有模板是启用的(enabled),但是使用std::enable_if
可以使得仅在std::enable_if
指定的条件满足时模板才启用。在这个例子中,我们只在传递的类型不是Person
时使用Person
的完美转发构造函数。如果传递的类型是Person
,我们要禁止完美转发构造函数(即让编译器忽略它),因为这会让拷贝或者移动构造函数处理调用,这是我们想要使用Person
初始化另一个Person
的初衷。
-这个主意听起来并不难,但是语法比较繁杂,尤其是之前没有接触过的话,让我慢慢引导你。有一些std::enbale_if
的contidion(条件)部分的样板,让我们从这里开始。下面的代码是Person
完美转发构造函数的声明,多展示std::enable_if
的部分来简化使用难度。我仅展示构造函数的声明,因为std::enable_if
的使用对函数实现没影响。实现部分跟Item26中没有区别。
+这个主意听起来并不难,但是语法比较繁杂,尤其是之前没有接触过的话,让我慢慢引导你。有一些std::enbale_if
的contidion(条件)部分的样板,让我们从这里开始。下面的代码是Person
完美转发构造函数的声明,多展示std::enable_if
的部分来简化使用难度。我仅展示构造函数的声明,因为std::enable_if
的使用对函数实现没影响。实现部分跟Item26中没有区别。
class Person {
public:
template<typename T,
@@ -234,7 +234,7 @@ public:
};
为了理解高亮部分发生了什么,我很遗憾的表示你要自行参考其他代码,因为详细解释需要花费一定空间和时间,而本书并没有足够的空间(在你自行学习过程中,请研究“SFINAE”以及std::enable_if
,因为“SFINAE”就是使std::enable_if
起作用的技术)。这里我想要集中讨论条件的表示,该条件表示此构造函数是否启用。
-这里我们想表示的条件是确认T
不是Person
类型,即模板构造函数应该在T
不是Person
类型的时候启用。多亏了type trait可以确定两个对象类型是否相同(std::is_same
),看起来我们需要的就是!std::is_same<Person, T>::value
(注意语句开始的!
,我们想要的是不相同)。这很接近我们想要的了,但是不完全正确,因为如同Item28中所述,使用左值来初始化通用引用的话会推导成左值引用,比如这个代码:
+这里我们想表示的条件是确认T
不是Person
类型,即模板构造函数应该在T
不是Person
类型的时候启用。多亏了type trait可以确定两个对象类型是否相同(std::is_same
),看起来我们需要的就是!std::is_same<Person, T>::value
(注意语句开始的!
,我们想要的是不相同)。这很接近我们想要的了,但是不完全正确,因为如同Item28中所述,使用左值来初始化通用引用的话会推导成左值引用,比如这个代码:
Person p("Nancy");
auto cloneOfP(p); //用左值初始化
@@ -244,10 +244,10 @@ auto cloneOfP(p); //用左值初始化
是否是个引用。对于决定是否通用引用构造函数启用的目的来说,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
标识符)的修饰。(这里我没有说出另外的真相,std::decay
如同其名一样,可以将数组或者函数退化成指针,参考Item1,但是在这里讨论的问题中,它刚好合适)。我们想要控制构造函数是否启用的条件可以写成:
+这意味着我们需要一种方法消除对于T
的引用,const
,volatile
修饰。再次,标准库提供了这样功能的type trait,就是std::decay
。std::decay<T>::value
与T
是相同的,只不过会移除引用和cv限定符(cv-qualifiers,即const
或volatile
标识符)的修饰。(这里我没有说出另外的真相,std::decay
如同其名一样,可以将数组或者函数退化成指针,参考Item1,但是在这里讨论的问题中,它刚好合适)。我们想要控制构造函数是否启用的条件可以写成:
!std::is_same<Person, typename std::decay<T>::type>::value
-即Person
和T
的类型不同,忽略了所有引用和cv限定符。(如Item9所述,std::decay
前的“typename
”是必需的,因为std::decay<T>::type
的类型取决于模板形参T
。)
+即Person
和T
的类型不同,忽略了所有引用和cv限定符。(如Item9所述,std::decay
前的“typename
”是必需的,因为std::decay<T>::type
的类型取决于模板形参T
。)
将其带回上面std::enable_if
样板的代码中,加上调整一下格式,让各部分如何组合在一起看起来更容易,Person
的完美转发构造函数的声明如下:
class Person {
public:
@@ -265,7 +265,7 @@ public:
如果你之前从没有看到过这种类型的代码,那你可太幸福了。最后才放出这种设计是有原因的。当你有其他机制来避免同时使用重载和通用引用时(你总会这样做),确实应该那样做。不过,一旦你习惯了使用函数语法和尖括号的使用,也不坏。此外,这可以提供你一直想要的行为表现。在上面的声明中,使用Person
初始化一个Person
——无论是左值还是右值,const
还是non-const
,volatile
还是non-volatile
——都不会调用到通用引用构造函数。
成功了,对吗?确实!
-啊,不对。等会再庆祝。Item26还有一个情景需要解决,我们需要继续探讨下去。
+啊,不对。等会再庆祝。Item26还有一个情景需要解决,我们需要继续探讨下去。
假定从Person
派生的类以常规方式实现拷贝和移动操作:
class SpecialPerson: public Person {
public:
@@ -280,7 +280,7 @@ public:
…
};
-这和Item26中的代码是一样的,包括注释也是一样。当我们拷贝或者移动一个SpecialPerson
对象时,我们希望调用基类对应的拷贝和移动构造函数,来拷贝或者移动基类部分,但是这里,我们将SpecialPerson
传递给基类的构造函数,因为SpecialPerson
和Person
类型不同(在应用std::decay
后也不同),所以完美转发构造函数是启用的,会实例化为精确匹配SpecialPerson
实参的构造函数。相比于派生类到基类的转化——这个转化对于在Person
拷贝和移动构造函数中把SpecialPerson
对象绑定到Person
形参非常重要,生成的精确匹配是更优的,所以这里的代码,拷贝或者移动SpecialPerson
对象就会调用Person
类的完美转发构造函数来执行基类的部分。跟Item26的困境一样。
+这和Item26中的代码是一样的,包括注释也是一样。当我们拷贝或者移动一个SpecialPerson
对象时,我们希望调用基类对应的拷贝和移动构造函数,来拷贝或者移动基类部分,但是这里,我们将SpecialPerson
传递给基类的构造函数,因为SpecialPerson
和Person
类型不同(在应用std::decay
后也不同),所以完美转发构造函数是启用的,会实例化为精确匹配SpecialPerson
实参的构造函数。相比于派生类到基类的转化——这个转化对于在Person
拷贝和移动构造函数中把SpecialPerson
对象绑定到Person
形参非常重要,生成的精确匹配是更优的,所以这里的代码,拷贝或者移动SpecialPerson
对象就会调用Person
类的完美转发构造函数来执行基类的部分。跟Item26的困境一样。
派生类仅仅是按照常规的规则生成了自己的移动和拷贝构造函数,所以这个问题的解决还要落实在基类,尤其是控制是否使用Person
通用引用构造函数启用的条件。现在我们意识到不只是禁止Person
类型启用模板构造函数,而是禁止Person
**以及任何派生自Person
**的类型启用模板构造函数。讨厌的继承!
你应该不意外在这里看到标准库中也有type trait判断一个类型是否继承自另一个类型,就是std::is_base_of
。如果std::is_base_of<T1, T2>
是true就表示T2
派生自T1
。类型也可被认为是从他们自己派生,所以std::is_base_of<T, T>::value
总是true。这就很方便了,我们想要修正控制Person
完美转发构造函数的启用条件,只有当T
在消除引用和cv限定符之后,并且既不是Person
又不是Person
的派生类时,才满足条件。所以使用std::is_base_of
代替std::is_same
就可以了:
class Person {
@@ -343,7 +343,7 @@ private:
本条款提到的前三个技术——放弃重载、传递const T&、传值——在函数调用中指定每个形参的类型。后两个技术——tag dispatch和限制模板适用范围——使用完美转发,因此不需要指定形参类型。这一基本决定(是否指定类型)有一定后果。
通常,完美转发更有效率,因为它避免了仅仅去为了符合形参声明的类型而创建临时对象。在Person
构造函数的例子中,完美转发允许将“Nancy
”这种字符串字面量转发到Person
内部的std::string
的构造函数,不使用完美转发的技术则会从字符串字面值创建一个临时std::string
对象,来满足Person
构造函数指定的形参要求。
-但是完美转发也有缺点。即使某些类型的实参可以传递给接受特定类型的函数,也无法完美转发。Item30中探索了完美转发失败的例子。
+但是完美转发也有缺点。即使某些类型的实参可以传递给接受特定类型的函数,也无法完美转发。Item30中探索了完美转发失败的例子。
第二个问题是当客户传递无效参数时错误消息的可理解性。例如假如客户传递了一个由char16_t
(一种C++11引入的类型表示16位字符)而不是char
(std::string
包含的)组成的字符串字面值来创建一个Person
对象:
Person p(u"Konrad Zuse"); //“Konrad Zuse”由const char16_t类型字符组成
diff --git a/5.RRefMovSemPerfForw/item28.html b/5.RRefMovSemPerfForw/item28.html
index befa0eb..3cc2956 100644
--- a/5.RRefMovSemPerfForw/item28.html
+++ b/5.RRefMovSemPerfForw/item28.html
@@ -141,7 +141,7 @@
Item 28: Understand reference collapsing
-Item23中指出,当实参传递给模板函数时,被推导的模板形参T
根据实参是左值还是右值来编码。但是那条款并没有提到只有当实参被用来实例化通用引用形参时,上述推导才会发生,但是有充分的理由忽略这一点:因为通用引用是Item24中才提到。回过头来看,对通用引用和左值/右值编码的观察意味着对于这个模板,
+Item23中指出,当实参传递给模板函数时,被推导的模板形参T
根据实参是左值还是右值来编码。但是那条款并没有提到只有当实参被用来实例化通用引用形参时,上述推导才会发生,但是有充分的理由忽略这一点:因为通用引用是Item24中才提到。回过头来看,对通用引用和左值/右值编码的观察意味着对于这个模板,
template<typename T>
void func(T&& param);
@@ -167,7 +167,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)。是的,禁止你声明引用的引用,但是编译器会在特定的上下文中产生这些,模板实例化就是其中一种情况。当编译器生成引用的引用时,引用折叠指导下一步发生什么。
@@ -176,7 +176,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)
{
@@ -199,7 +199,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); }
@@ -226,7 +226,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&
@@ -249,13 +249,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:
@@ -273,7 +273,7 @@ public:
typedef int& RvalueRefToT;
这清楚表明我们为typedef
选择的名字可能不是我们希望的那样:当使用左值引用类型实例化Widget
时,RvalueRefToT
是左值引用的typedef
。
-最后一种引用折叠发生的情况是,decltype
使用的情况。如果在分析decltype
期间,出现了引用的引用,引用折叠规则就会起作用(关于decltype
,参见Item3)
+最后一种引用折叠发生的情况是,decltype
使用的情况。如果在分析decltype
期间,出现了引用的引用,引用折叠规则就会起作用(关于decltype
,参见Item3)
请记住:
- 引用折叠发生在四种情况下:模板实例化,
auto
类型推导,typedef
与别名声明的创建和使用,decltype
。
diff --git a/5.RRefMovSemPerfForw/item29.html b/5.RRefMovSemPerfForw/item29.html
index 5754bdd..dcd628b 100644
--- a/5.RRefMovSemPerfForw/item29.html
+++ b/5.RRefMovSemPerfForw/item29.html
@@ -143,7 +143,7 @@
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;
@@ -168,7 +168,7 @@ auto aw2 = std::move(aw1);
注意aw1
中的元素被移动到了aw2
中。假定Widget
类的移动操作比复制操作快,移动Widget
的std::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的移动语义并无优势:
- 没有移动操作:要移动的对象没有提供移动操作,所以移动的写法也会变成复制操作。
@@ -177,7 +177,7 @@ auto aw2 = std::move(aw1);
值得一提的是,还有另一个场景,会使得移动并没有那么有效率:
-- 源对象是左值:除了极少数的情况外(例如Item25),只有右值可以作为移动操作的来源。
+- 源对象是左值:除了极少数的情况外(例如Item25),只有右值可以作为移动操作的来源。
但是该条款的标题是假定移动操作不存在,成本高,未被使用。这就是通用代码中的典型情况,比如编写模板代码,因为你不清楚你处理的具体类型是什么。在这种情况下,你必须像出现移动语义之前那样,像在C++98里一样保守地去复制对象。“不稳定的”代码也是如此,即那些由于经常被修改导致类型特性变化的源代码。
但是,通常,你了解你代码里使用的类型,依赖他们的特性不变性(比如是否支持快速移动操作)。这种情况,你无需这个条款的假设,只需要查找所用类型的移动操作详细信息。如果类型提供了快速移动操作,并且在调用移动操作的上下文中使用对象,可以安全的使用快速移动操作替换复制操作。
diff --git a/5.RRefMovSemPerfForw/item30.html b/5.RRefMovSemPerfForw/item30.html
index f5f557f..8b1e141 100644
--- a/5.RRefMovSemPerfForw/item30.html
+++ b/5.RRefMovSemPerfForw/item30.html
@@ -143,7 +143,7 @@
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) //接受任何实参
@@ -158,7 +158,7 @@ void fwd(Ts&&... params) //接受任何实参
f(std::forward<Ts>(params)...); //转发给f
}
-这种形式你会在标准化容器置入函数(emplace functions)中(参见Item42)和智能指针的工厂函数std::make_unique
和std::make_shared
中(参见Item21)看到,当然还有其他一些地方。
+这种形式你会在标准化容器置入函数(emplace functions)中(参见Item42)和智能指针的工厂函数std::make_unique
和std::make_shared
中(参见Item21)看到,当然还有其他一些地方。
给定我们的目标函数f
和转发函数fwd
,如果f
使用某特定实参会执行某个操作,但是fwd
使用相同的实参会执行不同的操作,完美转发就会失败
f( expression ); //调用f执行某个操作
fwd( expression ); //但调用fwd执行另一个操作,则fwd不能完美转发expression给f
@@ -182,12 +182,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
-Item8说明当你试图传递0
或者NULL
作为空指针给模板时,类型推导会出错,会把传来的实参推导为一个整型类型(典型情况为int
)而不是指针类型。结果就是不管是0
还是NULL
都不能作为空指针被完美转发。解决方法非常简单,传一个nullptr
而不是0
或者NULL
。具体的细节,参考Item8。
+Item8说明当你试图传递0
或者NULL
作为空指针给模板时,类型推导会出错,会把传来的实参推导为一个整型类型(典型情况为int
)而不是指针类型。结果就是不管是0
还是NULL
都不能作为空指针被完美转发。解决方法非常简单,传一个nullptr
而不是0
或者NULL
。具体的细节,参考Item8。
通常,无需在类中定义整型static const
数据成员;声明就可以了。这是因为编译器会对此类成员实行常量传播(const propagation),因此消除了保留内存的需要。比如,考虑下面的代码:
class Widget {
diff --git a/6.LambdaExpressions/item31.html b/6.LambdaExpressions/item31.html
index e5e33f5..d17bad8 100644
--- a/6.LambdaExpressions/item31.html
+++ b/6.LambdaExpressions/item31.html
@@ -141,7 +141,7 @@
CHAPTER 6 Lambda Expressions
-lambda表达式是C++编程中的游戏规则改变者。这有点令人惊讶,因为它没有给语言带来新的表达能力。lambda可以做的所有事情都可以通过其他方式完成。但是lambda是创建函数对象相当便捷的一种方法,对于日常的C++开发影响是巨大的。没有lambda时,STL中的“_if
”算法(比如,std::find_if
,std::remove_if
,std::count_if
等)通常需要繁琐的谓词,但是当有lambda可用时,这些算法使用起来就变得相当方便。用比较函数(比如,std::sort
,std::nth_element
,std::lower_bound
等)来自定义算法也是同样方便的。在STL外,lambda可以快速创建std::unique_ptr
和std::shared_ptr
的自定义删除器(见Item18和19),并且使线程API中条件变量的谓词指定变得同样简单(参见Item39)。除了标准库,lambda有利于即时的回调函数,接口适配函数和特定上下文中的一次性函数。lambda确实使C++成为更令人愉快的编程语言。
+lambda表达式是C++编程中的游戏规则改变者。这有点令人惊讶,因为它没有给语言带来新的表达能力。lambda可以做的所有事情都可以通过其他方式完成。但是lambda是创建函数对象相当便捷的一种方法,对于日常的C++开发影响是巨大的。没有lambda时,STL中的“_if
”算法(比如,std::find_if
,std::remove_if
,std::count_if
等)通常需要繁琐的谓词,但是当有lambda可用时,这些算法使用起来就变得相当方便。用比较函数(比如,std::sort
,std::nth_element
,std::lower_bound
等)来自定义算法也是同样方便的。在STL外,lambda可以快速创建std::unique_ptr
和std::shared_ptr
的自定义删除器(见Item18和19),并且使线程API中条件变量的谓词指定变得同样简单(参见Item39)。除了标准库,lambda有利于即时的回调函数,接口适配函数和特定上下文中的一次性函数。lambda确实使C++成为更令人愉快的编程语言。
与lambda相关的词汇可能会令人疑惑,这里做一下简单的回顾:
-
@@ -246,7 +246,7 @@ void workWithContainer(const C& container)
);
这足以满足本实例的要求,但在通常情况下,按值捕获并不能完全解决悬空引用的问题。这里的问题是如果你按值捕获的是一个指针,你将该指针拷贝到lambda对应的闭包里,但这样并不能避免lambda外delete
这个指针的行为,从而导致你的副本指针变成悬空指针。
-也许你要抗议说:“这不可能发生。看过了第4章,我对智能指针的使用非常热衷。只有那些失败的C++98的程序员才会用裸指针和delete
语句。”这也许是正确的,但却是不相关的,因为事实上你的确会使用裸指针,也的确存在被你delete
的可能性。只不过在现代的C++编程风格中,不容易在源代码中显露出来而已。
+也许你要抗议说:“这不可能发生。看过了第4章,我对智能指针的使用非常热衷。只有那些失败的C++98的程序员才会用裸指针和delete
语句。”这也许是正确的,但却是不相关的,因为事实上你的确会使用裸指针,也的确存在被你delete
的可能性。只不过在现代的C++编程风格中,不容易在源代码中显露出来而已。
假设在一个Widget
类,可以实现向过滤器的容器添加条目:
class Widget {
public:
@@ -303,7 +303,7 @@ private:
);
}
-明白了这个就相当于明白了lambda闭包的生命周期与Widget
对象的关系,闭包内含有Widget
的this
指针的拷贝。特别是考虑以下的代码,参考第4章的内容,只使用智能指针:
+明白了这个就相当于明白了lambda闭包的生命周期与Widget
对象的关系,闭包内含有Widget
的this
指针的拷贝。特别是考虑以下的代码,参考第4章的内容,只使用智能指针:
using FilterContainer = //跟之前一样
std::vector<std::function<bool(int)>>;
@@ -319,7 +319,7 @@ void doSomeWork()
…
} //销毁Widget;filters现在持有悬空指针!
-当调用doSomeWork
时,就会创建一个过滤器,其生命周期依赖于由std::make_unique
产生的Widget
对象,即一个含有指向Widget
的指针——Widget
的this
指针——的过滤器。这个过滤器被添加到filters
中,但当doSomeWork
结束时,Widget
会由管理它的std::unique_ptr
来销毁(见Item18)。从这时起,filter
会含有一个存着悬空指针的条目。
+当调用doSomeWork
时,就会创建一个过滤器,其生命周期依赖于由std::make_unique
产生的Widget
对象,即一个含有指向Widget
的指针——Widget
的this
指针——的过滤器。这个过滤器被添加到filters
中,但当doSomeWork
结束时,Widget
会由管理它的std::unique_ptr
来销毁(见Item18)。从这时起,filter
会含有一个存着悬空指针的条目。
这个特定的问题可以通过给你想捕获的数据成员做一个局部副本,然后捕获这个副本去解决:
void Widget::addFilter() const
{
diff --git a/6.LambdaExpressions/item32.html b/6.LambdaExpressions/item32.html
index eb22cbf..d79e0e8 100644
--- a/6.LambdaExpressions/item32.html
+++ b/6.LambdaExpressions/item32.html
@@ -143,7 +143,7 @@
Item 32: Use init capture to move objects into closures
在某些场景下,按值捕获和按引用捕获都不是你所想要的。如果你有一个只能被移动的对象(例如std::unique_ptr
或std::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的捕获模式能完成所需功能的情况下,使用它是完全合理的)。
使用初始化捕获可以让你指定:
- 从lambda生成的闭包类中的数据成员名称;
@@ -223,7 +223,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
也是合适的:
@@ -254,7 +254,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的初始化捕获将对象移动到闭包中。
diff --git a/6.LambdaExpressions/item33.html b/6.LambdaExpressions/item33.html
index f0086bb..0e97a9c 100644
--- a/6.LambdaExpressions/item33.html
+++ b/6.LambdaExpressions/item33.html
@@ -154,15 +154,15 @@ public:
};
在这个样例中,lambda对变量x
做的唯一一件事就是把它转发给函数normalize
。如果函数normalize
对待左值右值的方式不一样,这个lambda的实现方式就不大合适了,因为即使传递到lambda的实参是一个右值,lambda传递进normalize
的总是一个左值(形参x
)。
-实现这个lambda的正确方式是把x
完美转发给函数normalize
。这样做需要对代码做两处修改。首先,x
需要改成通用引用(见Item24),其次,需要使用std::forward
将x
转发到函数normalize
(见Item25)。理论上,这都是小改动:
+实现这个lambda的正确方式是把x
完美转发给函数normalize
。这样做需要对代码做两处修改。首先,x
需要改成通用引用(见Item24),其次,需要使用std::forward
将x
转发到函数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)
{
diff --git a/6.LambdaExpressions/item34.html b/6.LambdaExpressions/item34.html
index f58181e..36411e6 100644
--- a/6.LambdaExpressions/item34.html
+++ b/6.LambdaExpressions/item34.html
@@ -143,7 +143,7 @@
Item 34: Prefer lambdas to std::bind
C++11中的std::bind
是C++98的std::bind1st
和std::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;
@@ -310,8 +310,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不提供移动捕获,但是可以通过结合lambda和
std::bind
来模拟。 有关详细信息,请参阅Item32,该条款还解释了在C++14中,lambda对初始化捕获的支持消除了这个模拟的需求。
-- 多态函数对象。因为bind对象上的函数调用运算符使用完美转发,所以它可以接受任何类型的实参(以Item30中描述的完美转发的限制为界限)。当你要绑定带有模板化函数调用运算符的对象时,此功能很有用。 例如这个类,
+- 移动捕获。C++11的lambda不提供移动捕获,但是可以通过结合lambda和
std::bind
来模拟。 有关详细信息,请参阅Item32,该条款还解释了在C++14中,lambda对初始化捕获的支持消除了这个模拟的需求。
+- 多态函数对象。因为bind对象上的函数调用运算符使用完美转发,所以它可以接受任何类型的实参(以Item30中描述的完美转发的限制为界限)。当你要绑定带有模板化函数调用运算符的对象时,此功能很有用。 例如这个类,
class PolyWidget {
public:
diff --git a/7.TheConcurrencyAPI/Item35.html b/7.TheConcurrencyAPI/Item35.html
index 942c6c7..590d7d5 100644
--- a/7.TheConcurrencyAPI/Item35.html
+++ b/7.TheConcurrencyAPI/Item35.html
@@ -172,9 +172,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
会更有优势:
diff --git a/7.TheConcurrencyAPI/item36.html b/7.TheConcurrencyAPI/item36.html
index 5abef25..bb212d1 100644
--- a/7.TheConcurrencyAPI/item36.html
+++ b/7.TheConcurrencyAPI/item36.html
@@ -141,10 +141,10 @@
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
推迟到存在这样的调用时才执行(译者注:异步与并发是两个不同概念,这里侧重于惰性求值)。当get
或wait
被调用,f
会同步执行,即调用方被阻塞,直到f
运行结束。如果get
和wait
都没有被调用,f
将不会被执行。(这是个简化说法。关键点不是要在其上调用get
或wait
的那个future,而是future引用的那个共享状态。(Item38讨论了future与共享状态的关系。)因为std::future
支持移动,也可以用来构造std::shared_future
,并且因为std::shared_future
可以被拷贝,对共享状态——对f
传到的那个std::async
进行调用产生的——进行引用的future对象,有可能与std::async
返回的那个future对象不同。这非常绕口,所以经常回避这个事实,简称为在std::async
返回的future上调用get
或wait
。)
+std::launch::deferred
启动策略意味着f
仅当在std::async
返回的future上调用get
或者wait
时才执行。这表示f
推迟到存在这样的调用时才执行(译者注:异步与并发是两个不同概念,这里侧重于惰性求值)。当get
或wait
被调用,f
会同步执行,即调用方被阻塞,直到f
运行结束。如果get
和wait
都没有被调用,f
将不会被执行。(这是个简化说法。关键点不是要在其上调用get
或wait
的那个future,而是future引用的那个共享状态。(Item38讨论了future与共享状态的关系。)因为std::future
支持移动,也可以用来构造std::shared_future
,并且因为std::shared_future
可以被拷贝,对共享状态——对f
传到的那个std::async
进行调用产生的——进行引用的future对象,有可能与std::async
返回的那个future对象不同。这非常绕口,所以经常回避这个事实,简称为在std::async
返回的future上调用get
或wait
。)
可能让人惊奇的是,std::async
的默认启动策略——你不显式指定一个策略时它使用的那个——不是上面中任意一个。相反,是求或在一起的。下面的两种调用含义相同:
auto fut1 = std::async(f); //使用默认启动策略运行f
@@ -152,7 +152,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
@@ -165,7 +165,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秒,然后返回
@@ -220,7 +220,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抛出异常它也会抛出
diff --git a/7.TheConcurrencyAPI/item37.html b/7.TheConcurrencyAPI/item37.html
index 6694469..483b240 100644
--- a/7.TheConcurrencyAPI/item37.html
+++ b/7.TheConcurrencyAPI/item37.html
@@ -151,7 +151,7 @@
(译者注: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
@@ -181,7 +181,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
析构的行为是这样的,那是因为另外两种显而易见的方式更糟:
@@ -195,7 +195,7 @@ bool doWork(std::function<bool(int)> filter, //返回计算是否执行
标准委员会认为,销毁可结合的线程如此可怕以至于实际上禁止了它(规定销毁可结合的线程导致程序终止)。
这使你有责任确保使用std::thread
对象时,在所有的路径上超出定义所在的作用域时都是不可结合的。但是覆盖每条路径可能很复杂,可能包括自然执行通过作用域,或者通过return
,continue
,break
,goto
或异常跳出作用域,有太多可能的路径。
-每当你想在执行跳至块之外的每条路径执行某种操作,最通用的方式就是将该操作放入局部对象的析构函数中。这些对象称为RAII对象(RAII objects),从RAII类中实例化。(RAII全称为 “Resource Acquisition Is Initialization”(资源获得即初始化),尽管技术关键点在析构上而不是实例化上)。RAII类在标准库中很常见。比如STL容器(每个容器析构函数都销毁容器中的内容物并释放内存),标准智能指针(Item18-20解释了,std::uniqu_ptr
的析构函数调用他指向的对象的删除器,std::shared_ptr
和std::weak_ptr
的析构函数递减引用计数),std::fstream
对象(它们的析构函数关闭对应的文件)等。但是标准库没有std::thread
的RAII类,可能是因为标准委员会拒绝将join
和detach
作为默认选项,不知道应该怎么样完成RAII。
+每当你想在执行跳至块之外的每条路径执行某种操作,最通用的方式就是将该操作放入局部对象的析构函数中。这些对象称为RAII对象(RAII objects),从RAII类中实例化。(RAII全称为 “Resource Acquisition Is Initialization”(资源获得即初始化),尽管技术关键点在析构上而不是实例化上)。RAII类在标准库中很常见。比如STL容器(每个容器析构函数都销毁容器中的内容物并释放内存),标准智能指针(Item18-20解释了,std::uniqu_ptr
的析构函数调用他指向的对象的删除器,std::shared_ptr
和std::weak_ptr
的析构函数递减引用计数),std::fstream
对象(它们的析构函数关闭对应的文件)等。但是标准库没有std::thread
的RAII类,可能是因为标准委员会拒绝将join
和detach
作为默认选项,不知道应该怎么样完成RAII。
幸运的是,完成自行实现的类并不难。比如,下面的类实现允许调用者指定ThreadRAII
对象(一个std::thread
的RAII对象)析构时,调用join
或者detach
:
class ThreadRAII {
public:
@@ -275,8 +275,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 }; //跟之前一样
diff --git a/7.TheConcurrencyAPI/item38.html b/7.TheConcurrencyAPI/item38.html
index 72e9360..d9e77e6 100644
--- a/7.TheConcurrencyAPI/item38.html
+++ b/7.TheConcurrencyAPI/item38.html
@@ -141,9 +141,9 @@
Item 38:Be aware of varying thread handle destructor behavior
-Item37中说明了可结合的std::thread
对应于执行的系统线程。未延迟(non-deferred)任务的future(参见Item36)与系统线程有相似的关系。因此,可以将std::thread
对象和future对象都视作系统线程的句柄(handles)。
-从这个角度来说,有趣的是std::thread
和future在析构时有相当不同的行为。在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::thread
和future在析构时有相当不同的行为。在Item37中说明,可结合的std::thread
析构会终止你的程序,因为两个其他的替代选择——隐式join
或者隐式detach
都是更加糟糕的。但是,future的析构表现有时就像执行了隐式join
,有时又像是隐式执行了detach
,有时又没有执行这两个选择。它永远不会造成程序终止。这个线程句柄多种表现值得研究一下。
+我们可以观察到实际上future是通信信道的一端,被调用者通过该信道将结果发送给调用者。(Item39说,与future有关的这种通信信道也可以被用于其他目的。但是对于本条款,我们只考虑它们作为这样一个机制的用法,即被调用者传送结果给调用者。)被调用者(通常是异步执行)将计算结果写入通信信道中(通常通过std::promise
对象),调用者使用future读取结果。你可以想象成下面的图示,虚线表示信息的流动方向:
但是被调用者的结果存储在哪里?被调用者会在调用者get
相关的future之前执行完成,所以结果不能存储在被调用者的std::promise
。这个对象是局部的,当被调用者执行结束后,会被销毁。
结果同样不能存储在调用者的future,因为(当然还有其他原因)std::future
可能会被用来创建std::shared_future
(这会将被调用者的结果所有权从std::future
转移给std::shared_future
),而std::shared_future
在std::future
被销毁之后可能被复制很多次。鉴于不是所有的结果都可以被拷贝(即只可移动类型),并且结果的生命周期至少与最后一个引用它的future一样长,这些潜在的future中哪个才是被调用者用来存储结果的?
@@ -155,16 +155,16 @@
引用了共享状态——使用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启动的
//未延迟任务创建出来的共享状态
@@ -185,7 +185,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的析构函数的正常行为有一些了解,但是将这些语句放在一个作用域的语句块里更容易看:
@@ -201,7 +201,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
,因为显式调用了。
diff --git a/7.TheConcurrencyAPI/item39.html b/7.TheConcurrencyAPI/item39.html
index 6d35efd..a5d13a4 100644
--- a/7.TheConcurrencyAPI/item39.html
+++ b/7.TheConcurrencyAPI/item39.html
@@ -190,7 +190,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
@@ -211,7 +211,7 @@ cv.notify_one(); //通知反应任务(第2部分)
… //继续反应动作(m现在解锁)
这份代码解决了我们一直讨论的问题。无论在检测线程对条件变量发出通知之前反应线程是否调用了wait
都可以工作,即使出现了虚假唤醒也可以工作,而且不需要轮询。但是仍然有些古怪,因为检测任务通过奇怪的方式与反应线程通信。(译者注:下面的话挺绕的,可以参考原文)检测任务通过通知条件变量告诉反应线程,等待的事件可能发生了,但是反应线程必须通过检查flag来确保事件发生了。检测线程置位flag来告诉反应线程事件确实发生了,但是检测线程仍然还要先需要通知条件变量,以唤醒反应线程来检查flag。这种方案是可以工作的,但是不太优雅。
-一个替代方案是让反应任务通过在检测任务设置的future上wait
来避免使用条件变量,互斥锁和flag。这可能听起来也是个古怪的方案。毕竟,Item38中说明了future代表了从被调用方到(通常是异步的)调用方的通信信道的接收端,这里的检测任务和反应任务没有调用-被调用的关系。然而,Item38中也说说明了发送端是个std::promise
,接收端是个future的通信信道不是只能用在调用-被调用场景。这样的通信信道可以用在任何你需要从程序一个地方传递信息到另一个地方的场景。这里,我们用来在检测任务和反应任务之间传递信息,传递的信息就是感兴趣的事件已经发生。
+一个替代方案是让反应任务通过在检测任务设置的future上wait
来避免使用条件变量,互斥锁和flag。这可能听起来也是个古怪的方案。毕竟,Item38中说明了future代表了从被调用方到(通常是异步的)调用方的通信信道的接收端,这里的检测任务和反应任务没有调用-被调用的关系。然而,Item38中也说说明了发送端是个std::promise
,接收端是个future的通信信道不是只能用在调用-被调用场景。这样的通信信道可以用在任何你需要从程序一个地方传递信息到另一个地方的场景。这里,我们用来在检测任务和反应任务之间传递信息,传递的信息就是感兴趣的事件已经发生。
方案很简单。检测任务有一个std::promise
对象(即通信信道的写入端),反应任务有对应的future。当检测任务看到事件已经发生,设置std::promise
对象(即写入到通信信道)。同时,wait
会阻塞住反应任务直到std::promise
被设置。
现在,std::promise
和futures(即std::future
和std::shared_future
)都是需要类型参数的模板。形参表明通过通信信道被传递的信息的类型。在这里,没有数据被传递,只需要让反应任务知道它的future已经被设置了。我们在std::promise
和future模板中需要的东西是表明通信信道中没有数据被传递的一个类型。这个类型就是void
。检测任务使用std::promise<void>
,反应任务使用std::future<void>
或者std::shared_future<void>
。当感兴趣的事件发生时,检测任务设置std::promise<void>
,反应任务在future上wait
。尽管反应任务不从检测任务那里接收任何数据,通信信道也可以让反应任务知道,检测任务什么时候已经通过对std::promise<void>
调用set_value
“写入”了void
数据。
所以,有
@@ -227,7 +227,7 @@ p.get_future().wait(); //等待对应于p的那个future
… //对事件作出反应
像使用flag的方法一样,此设计不需要互斥锁,无论在反应线程调用wait
之前检测线程是否设置了std::promise
都可以工作,并且不受虚假唤醒的影响(只有条件变量才容易受到此影响)。与基于条件变量的方法一样,反应任务在调用wait
之后是真被阻塞住的,不会一直占用系统资源。是不是很完美?
-当然不是,基于future的方法没有了上述问题,但是有其他新的问题。比如,Item38中说明,std::promise
和future之间有个共享状态,并且共享状态是动态分配的。因此你应该假定此设计会产生基于堆的分配和释放开销。
+当然不是,基于future的方法没有了上述问题,但是有其他新的问题。比如,Item38中说明,std::promise
和future之间有个共享状态,并且共享状态是动态分配的。因此你应该假定此设计会产生基于堆的分配和释放开销。
也许更重要的是,std::promise
只能设置一次。std::promise
和future之间的通信是一次性的:不能重复使用。这是与基于条件变量或者基于flag的设计的明显差异,条件变量和flag都可以通信多次。(条件变量可以被重复通知,flag也可以重复清除和设置。)
一次通信可能没有你想象中那么大的限制。假定你想创建一个挂起的系统线程。就是,你想避免多次线程创建的那种经常开销,以便想要使用这个线程执行程序时,避免潜在的线程创建工作。或者你想创建一个挂起的线程,以便在线程运行前对其进行设置这样的设置包括优先级或者核心亲和性(core affinity)。C++并发API没有提供这种设置能力,但是std::thread
提供了native_handle
成员函数,它的结果就是提供给你对平台原始线程API的访问(通常是POSIX或者Windows的线程)。这些低层次的API使你可以对线程设置优先级和亲和性。
假设你仅仅想要对某线程挂起一次(在创建后,运行线程函数前),使用void
的future就是一个可行方案。这是这个技术的关键点:
@@ -246,7 +246,7 @@ void detect() //检测任务的函数
t.join(); //使t不可结合(见条款37)
}
-因为所有离开detect
的路径中t
都要是不可结合的,所以使用类似于Item37中ThreadRAII
的RAII类很明智。代码如下:
+因为所有离开detect
的路径中t
都要是不可结合的,所以使用类似于Item37中ThreadRAII
的RAII类很明智。代码如下:
void detect()
{
ThreadRAII tr( //使用RAII对象
diff --git a/7.TheConcurrencyAPI/item40.html b/7.TheConcurrencyAPI/item40.html
index 04f0210..fd09763 100644
--- a/7.TheConcurrencyAPI/item40.html
+++ b/7.TheConcurrencyAPI/item40.html
@@ -179,7 +179,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; //告诉另一个任务,值可用了
@@ -249,7 +249,7 @@ x = 10; //写x(不会被优化掉)
x = 20; //再次写x
如果x
是内存映射的(或者已经映射到跨进程共享的内存位置等),这正是我们想要的。
-突击测试!在最后一段代码中,y
是什么类型:int
还是volatile int
?(y
的类型使用auto
类型推导,所以使用Item2中的规则。规则上说非引用非指针类型的声明(就是y
的情况),const
和volatile
限定符被拿掉。y
的类型因此仅仅是int
。这意味着对y
的冗余读取和写入可以被消除。在例子中,编译器必须执行对y
的初始化和赋值两个语句,因为x
是volatile
的,所以第二次对x
的读取可能会产生一个与第一次不同的值。)
+突击测试!在最后一段代码中,y
是什么类型:int
还是volatile int
?(y
的类型使用auto
类型推导,所以使用Item2中的规则。规则上说非引用非指针类型的声明(就是y
的情况),const
和volatile
限定符被拿掉。y
的类型因此仅仅是int
。这意味着对y
的冗余读取和写入可以被消除。在例子中,编译器必须执行对y
的初始化和赋值两个语句,因为x
是volatile
的,所以第二次对x
的读取可能会产生一个与第一次不同的值。)
在处理特殊内存时,必须保留看似冗余访问和无用存储的事实,顺便说明了为什么std::atomic
不适合这种场景。编译器被允许消除对std::atomic
的冗余操作。代码的编写方式与volatile
那些不那么相同,但是如果我们暂时忽略它,只关注编译器执行的操作,则概念上可以说,编译器看到这个,
std::atomic<int> x;
auto y = x; //概念上会读x(见下)
@@ -267,7 +267,7 @@ x = 20; //写x
auto y = x; //错误
y = x; //错误
-这是因为std::atomic
类型的拷贝操作是被删除的(参见Item11)。因为有个很好的理由删除。想象一下如果y
使用x
来初始化会发生什么。因为x
是std::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
来初始化会发生什么。因为x
是std::atomic
类型,y
的类型被推导为std::atomic
(参见Item2)。我之前说了std::atomic
最好的特性之一就是所有成员函数都是原子性的,但是为了使从x
拷贝初始化y
的过程是原子性的,编译器不得不生成代码,把读取x
和写入y
放在一个单独的原子性操作中。硬件通常无法做到这一点,因此std::atomic
不支持拷贝构造。出于同样的原因,拷贝赋值也被删除了,这也是为什么从x
赋值给y
也编译失败。(移动操作在std::atomic
没有显式声明,因此根据Item17中描述的规则来看,std::atomic
不支持移动构造和移动赋值)。
可以将x
的值传递给y
,但是需要使用std::atomic
的load
和store
成员函数。load
函数原子性地读取,store
原子性地写入。要使用x
初始化y
,然后将x
的值放入y
,代码应该这样写:
std::atomic<int> y(x.load()); //读x
y.store(x.load()); //再次读x
diff --git a/8.Tweaks/item41.html b/8.Tweaks/item41.html
index 59fa288..f9b2e71 100644
--- a/8.Tweaks/item41.html
+++ b/8.Tweaks/item41.html
@@ -144,7 +144,7 @@
对于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) //接受左值;拷贝它
@@ -159,7 +159,7 @@ private:
这是可行的,但是需要编写两个函数来做本质相同的事。这有点让人难受:两个函数声明,两个函数实现,两个函数写说明,两个函数的维护。唉。
此外,目标代码中会有两个函数——你可能会担心程序的空间占用。这种情况下,两个函数都可能内联,可能会避免同时两个函数同时存在导致的代码膨胀问题,但是一旦没有被内联,目标代码就会出现两个函数。
-另一种方法是使addName
函数成为具有通用引用的函数模板(参考Item24):
+另一种方法是使addName
函数成为具有通用引用的函数模板(参考Item24):
class Widget {
public:
template<typename T> //接受左值和右值;
@@ -169,7 +169,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 {
@@ -344,7 +344,7 @@ private:
这种情况下,按值传递的开销包括了内存分配和内存销毁——可能会比std::string
的移动操作高出几个数量级。
有趣的是,如果旧密码短于新密码,在赋值过程中就不可避免要销毁、分配内存,这种情况,按值传递跟按引用传递的效率是一样的。因此,基于赋值的形参拷贝操作开销取决于具体的实参的值,这种分析适用于在动态分配内存中存值的形参类型。不是所有类型都满足,但是很多——包括std::string
和std::vector
——是这样。
这种潜在的开销增加仅在传递左值实参时才适用,因为执行内存分配和释放通常发生在真正的拷贝操作(即,不是移动)中。对右值实参,移动几乎就足够了。
-结论是,使用通过赋值拷贝一个形参进行按值传递的函数的额外开销,取决于传递的类型,左值和右值的比例,这个类型是否需要动态分配内存,以及,如果需要分配内存的话,赋值操作符的具体实现,还有赋值目标占的内存至少要跟赋值源占的内存一样大。对于std::string
来说,开销还取决于实现是否使用了小字符串优化(SSO——参考Item29),如果是,那么要赋值的值是否匹配SSO缓冲区。
+结论是,使用通过赋值拷贝一个形参进行按值传递的函数的额外开销,取决于传递的类型,左值和右值的比例,这个类型是否需要动态分配内存,以及,如果需要分配内存的话,赋值操作符的具体实现,还有赋值目标占的内存至少要跟赋值源占的内存一样大。对于std::string
来说,开销还取决于实现是否使用了小字符串优化(SSO——参考Item29),如果是,那么要赋值的值是否匹配SSO缓冲区。
所以,正如我所说,当形参通过赋值进行拷贝时,分析按值传递的开销是复杂的。通常,最有效的经验就是“在证明没问题之前假设有问题”,就是除非已证明按值传递会为你需要的形参类型产生可接受的执行效率,否则使用重载或者通用引用的实现方式。
到此为止,对于需要运行尽可能快的软件来说,按值传递可能不是一个好策略,因为避免即使开销很小的移动操作也非常重要。此外,有时并不能清楚知道会发生多少次移动操作。在Widget::addName
例子中,按值传递仅多了一次移动操作,但是如果Widget::addName
调用了Widget::validateName
,这个函数也是按值传递。(假定它有理由总是拷贝它的形参,比如把验证过的所有值存在一个数据结构中。)并假设validateName
调用了第三个函数,也是按值传递……
可以看到这将会通向何方。在调用链中,每个函数都使用传值,因为“只多了一次移动的开销”,但是整个调用链总体就会产生无法忍受的开销,通过引用传递,调用链不会增加这种开销。
diff --git a/8.Tweaks/item42.html b/8.Tweaks/item42.html
index f700676..8110828 100644
--- a/8.Tweaks/item42.html
+++ b/8.Tweaks/item42.html
@@ -168,7 +168,7 @@ public:
为了在std::string
容器中创建新元素,调用了std::string
的构造函数,但是这份代码并不仅调用了一次构造函数,而是调用了两次,而且还调用了std::string
析构函数。下面是在push_back
运行时发生了什么:
- 一个
std::string
的临时对象从字面量“xyzzy
”被创建。这个对象没有名字,我们可以称为temp
。temp
的构造是第一次std::string
构造。因为是临时变量,所以temp
是右值。
-temp
被传递给push_back
的右值重载函数,绑定到右值引用形参x
。在std::vector
的内存中一个x
的副本被创建。这次构造——也是第二次构造——在std::vector
内部真正创建一个对象。(将x
副本拷贝到std::vector
内部的构造函数是移动构造函数,因为x
在它被拷贝前被转换为一个右值,成为右值引用。有关将右值引用形参强制转换为右值的信息,请参见Item25)。
+temp
被传递给push_back
的右值重载函数,绑定到右值引用形参x
。在std::vector
的内存中一个x
的副本被创建。这次构造——也是第二次构造——在std::vector
内部真正创建一个对象。(将x
副本拷贝到std::vector
内部的构造函数是移动构造函数,因为x
在它被拷贝前被转换为一个右值,成为右值引用。有关将右值引用形参强制转换为右值的信息,请参见Item25)。
- 在
push_back
返回之后,temp
立刻被销毁,调用了一次std::string
的析构函数。
对于性能执着的人不禁注意到是否存在一种方法可以获取字符串字面量并将其直接传入到步骤2里在std::vector
内构造std::string
的代码中,可以避免临时对象temp
的创建与销毁。这样的效率最好,对于性能执着的人也不会有什么意见了。
@@ -176,7 +176,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_list
和std::array
)的标准容器支持emplace
。关联容器提供emplace_hint
来补充接受“hint”迭代器的insert
函数,std::forward_list
有emplace_after
来匹配insert_after
。
@@ -217,7 +217,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);
@@ -244,7 +244,7 @@ vs.emplace_back(50, 'x'); //同上
在这个场景中,生命周期不良好,这个失误不能赖std::shared_ptr
。使用带自定义删除器的std::unique_ptr
也会有同样的问题。根本上讲,像std::shared_ptr
和std::unique_ptr
这样的资源管理类的高效性是以资源(比如从new
来的原始指针)被立即传递给资源管理对象的构造函数为条件的。实际上,std::make_shared
和std::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右值
diff --git a/print.html b/print.html
index 63fdf30..687551e 100644
--- a/print.html
+++ b/print.html
@@ -263,7 +263,7 @@ f(x); //用一个int类型的变量调用f
T
被推导为int
,ParamType
却被推导为const int&
我们可能很自然的期望T
和传递进函数的实参是相同的类型,也就是,T
为expr
的类型。在上面的例子中,事实就是那样:x
是int
,T
被推导为int
。但有时情况并非总是如此,T
的类型推导不仅取决于expr
的类型,也取决于ParamType
的类型。这里有三种情况:
-ParamType
是一个指针或引用,但不是通用引用(关于通用引用请参见Item24。在这里你只需要知道它存在,而且不同于左值引用和右值引用)
+ParamType
是一个指针或引用,但不是通用引用(关于通用引用请参见Item24。在这里你只需要知道它存在,而且不同于左值引用和右值引用)
ParamType
一个通用引用
ParamType
既不是指针也不是引用
@@ -321,7 +321,7 @@ f(px); //T是const int,param的类型是const int*
到现在为止,你会发现你自己打哈欠犯困,因为C++的类型推导规则对引用和指针形参如此自然,书面形式来看这些非常枯燥。所有事情都那么理所当然!那正是在类型推导系统中你所想要的。
-模板使用通用引用形参的话,那事情就不那么明显了。这样的形参被声明为像右值引用一样(也就是,在函数模板中假设有一个类型形参T
,那么通用引用声明形式就是T&&
),它们的行为在传入左值实参时大不相同。完整的叙述请参见Item24,在这有些最必要的你还是需要知道:
+模板使用通用引用形参的话,那事情就不那么明显了。这样的形参被声明为像右值引用一样(也就是,在函数模板中假设有一个类型形参T
,那么通用引用声明形式就是T&&
),它们的行为在传入左值实参时大不相同。完整的叙述请参见Item24,在这有些最必要的你还是需要知道:
- 如果
expr
是左值,T
和ParamType
都会被推导为左值引用。这非常不寻常,第一,这是模板类型推导中唯一一种T
被推导为引用的情况。第二,虽然ParamType
被声明为右值引用类型,但是最后推导的结果是左值引用。
- 如果
expr
是右值,就使用正常的(也就是情景一)推导规则
@@ -346,7 +346,7 @@ f(rx); //rx是左值,所以T是const int&,
f(27); //27是右值,所以T是int,
//param类型就是int&&
-Item24详细解释了为什么这些例子是像这样发生的。这里关键在于通用引用的类型推导规则是不同于普通的左值或者右值引用的。尤其是,当通用引用被使用时,类型推导会区分左值实参和右值实参,但是对非通用引用时不会区分。
+Item24详细解释了为什么这些例子是像这样发生的。这里关键在于通用引用的类型推导规则是不同于普通的左值或者右值引用的。尤其是,当通用引用被使用时,类型推导会区分左值实参和右值实参,但是对非通用引用时不会区分。
当ParamType
既不是指针也不是引用时,我们通过传值(pass-by-value)的方式处理:
template<typename T>
@@ -355,7 +355,7 @@ void f(T param); //以传值的方式处理param
这意味着无论传递什么param
都会成为它的一份拷贝——一个完整的新对象。事实上param
成为一个新对象这一行为会影响T
如何从expr
中推导出结果。
- 和之前一样,如果
expr
的类型是一个引用,忽略这个引用部分
-- 如果忽略
expr
的引用性(reference-ness)之后,expr
是一个const
,那就再忽略const
。如果它是volatile
,也忽略volatile
(volatile
对象不常见,它通常用于驱动程序的开发中。关于volatile
的细节请参见Item40)
+- 如果忽略
expr
的引用性(reference-ness)之后,expr
是一个const
,那就再忽略const
。如果它是volatile
,也忽略volatile
(volatile
对象不常见,它通常用于驱动程序的开发中。关于volatile
的细节请参见Item40)
因此
int x=27; //如之前一样
@@ -417,7 +417,7 @@ constexpr std::size_t arraySize(T (&)[N]) noexcept //constexpr
return N; //的信息
} //请看下面
-在Item15提到将一个函数声明为constexpr
使得结果在编译期间可用。这使得我们可以用一个花括号声明一个数组,然后第二个数组可以使用第一个数组的大小作为它的大小,就像这样:
+在Item15提到将一个函数声明为constexpr
使得结果在编译期间可用。这使得我们可以用一个花括号声明一个数组,然后第二个数组可以使用第一个数组的大小作为它的大小,就像这样:
int keyVals[] = { 1, 3, 7, 9, 11, 22, 35 }; //keyVals有七个元素
int mappedVals[arraySize(keyVals)]; //mappedVals也有七个
@@ -425,7 +425,7 @@ int mappedVals[arraySize(keyVals)]; //mappedVals也有七个
当然作为一个现代C++程序员,你自然应该想到使用std::array
而不是内置的数组:
std::array<int, arraySize(keyVals)> mappedVals; //mappedVals的大小为7
-至于arraySize
被声明为noexcept
,会使得编译器生成更好的代码,具体的细节请参见Item14。
+至于arraySize
被声明为noexcept
,会使得编译器生成更好的代码,具体的细节请参见Item14。
在C++中不只是数组会退化为指针,函数类型也会退化为一个函数指针,我们对于数组类型推导的全部讨论都可以应用到函数类型推导和退化为函数指针上来。结果是:
void someFunc(int, double); //someFunc是一个函数,
@@ -443,7 +443,7 @@ f2(someFunc); //param被推导为指向函数的引用,
//类型是void(&)(int, double)
这个实际上没有什么不同,但是如果你知道数组退化为指针,你也会知道函数退化为指针。
-这里你需要知道:auto
依赖于模板类型推导。正如我在开始谈论的,在大多数情况下它们的行为很直接。在通用引用中对于左值的特殊处理使得本来很直接的行为变得有些污点,然而,数组和函数退化为指针把这团水搅得更浑浊。有时你只需要编译器告诉你推导出的类型是什么。这种情况下,翻到item4,它会告诉你如何让编译器这么做。
+这里你需要知道:auto
依赖于模板类型推导。正如我在开始谈论的,在大多数情况下它们的行为很直接。在通用引用中对于左值的特殊处理使得本来很直接的行为变得有些污点,然而,数组和函数退化为指针把这团水搅得更浑浊。有时你只需要编译器告诉你推导出的类型是什么。这种情况下,翻到item4,它会告诉你如何让编译器这么做。
请记住:
- 在模板类型推导时,有引用的实参会被视为无引用,他们的引用会被忽略
@@ -453,9 +453,9 @@ f2(someFunc); //param被推导为指向函数的引用,
Item 2: Understand auto
type deduction
-如果你已经读过Item1的模板类型推导,那么你几乎已经知道了auto
类型推导的大部分内容,至于为什么不是全部是因为这里有一个auto
不同于模板类型推导的例外。但这怎么可能?模板类型推导包括模板,函数,形参,但auto
不处理这些东西啊。
+如果你已经读过Item1的模板类型推导,那么你几乎已经知道了auto
类型推导的大部分内容,至于为什么不是全部是因为这里有一个auto
不同于模板类型推导的例外。但这怎么可能?模板类型推导包括模板,函数,形参,但auto
不处理这些东西啊。
你是对的,但没关系。auto
类型推导和模板类型推导有一个直接的映射关系。它们之间可以通过一个非常规范非常系统化的转换流程来转换彼此。
-在Item1中,模板类型推导使用下面这个函数模板
+在Item1中,模板类型推导使用下面这个函数模板
template<typename T>
void f(ParmaType param);
@@ -492,7 +492,7 @@ func_for_rx(x); //概念化调用:
//param的推导类型是rx的类型
正如我说的,auto
类型推导除了一个例外(我们很快就会讨论),其他情况都和模板类型推导一样。
-Item1基于ParamType
——在函数模板中param
的类型说明符——的不同特征,把模板类型推导分成三个部分来讨论。在使用auto
作为类型说明符的变量声明中,类型说明符代替了ParamType
,因此Item1描述的三个情景稍作修改就能适用于auto:
+Item1基于ParamType
——在函数模板中param
的类型说明符——的不同特征,把模板类型推导分成三个部分来讨论。在使用auto
作为类型说明符的变量声明中,类型说明符代替了ParamType
,因此Item1描述的三个情景稍作修改就能适用于auto:
- 情景一:类型说明符是一个指针或引用但不是通用引用
- 情景二:类型说明符一个通用引用
@@ -511,7 +511,7 @@ auto&& uref2 = cx; //cx是const int左值,
auto&& uref3 = 27; //27是int右值,
//所以uref3类型为int&&
-Item1讨论并总结了对于non-reference类型说明符,数组和函数名如何退化为指针。那些内容也同样适用于auto
类型推导:
+Item1讨论并总结了对于non-reference类型说明符,数组和函数名如何退化为指针。那些内容也同样适用于auto
类型推导:
const char name[] = //name的类型是const char[13]
"R. N. Briggs";
@@ -535,7 +535,7 @@ int x2(27);
int x4{ 27 };
总之,这四种不同的语法只会产生一个相同的结果:变量类型为int
值为27
-但是Item5解释了使用auto
说明符代替指定类型说明符的好处,所以我们应该很乐意把上面声明中的int
替换为auto
,我们会得到这样的代码:
+但是Item5解释了使用auto
说明符代替指定类型说明符的好处,所以我们应该很乐意把上面声明中的int
替换为auto
,我们会得到这样的代码:
auto x1 = 27;
auto x2(27);
auto x3 = { 27 };
@@ -568,8 +568,8 @@ f({ 11, 23, 9 }); //T被推导为int,initList的类型为
//std::initializer_list<int>
因此auto
类型推导和模板类型推导的真正区别在于,auto
类型推导假定花括号表示std::initializer_list
而模板类型推导不会这样(确切的说是不知道怎么办)。
-你可能想知道为什么auto
类型推导和模板类型推导对于花括号有不同的处理方式。我也想知道。哎,我至今没找到一个令人信服的解释。但是规则就是规则,这意味着你必须记住如果你使用auto
声明一个变量,并用花括号进行初始化,auto
类型推导总会得出std::initializer_list
的结果。如果你使用**uniform initialization(花括号的方式进行初始化)**用得很爽你就得记住这个例外以免犯错,在C++11编程中一个典型的错误就是偶然使用了std::initializer_list<T>
类型的变量,这个陷阱也导致了很多C++程序员抛弃花括号初始化,只有不得不使用的时候再做考虑。(在Item7讨论了必须使用时该怎么做)
-对于C++11故事已经说完了。但是对于C++14故事还在继续,C++14允许auto
用于函数返回值并会被推导(参见Item3),而且C++14的lambda函数也允许在形参声明中使用auto
。但是在这些情况下auto
实际上使用模板类型推导的那一套规则在工作,而不是auto
类型推导,所以说下面这样的代码不会通过编译:
+你可能想知道为什么auto
类型推导和模板类型推导对于花括号有不同的处理方式。我也想知道。哎,我至今没找到一个令人信服的解释。但是规则就是规则,这意味着你必须记住如果你使用auto
声明一个变量,并用花括号进行初始化,auto
类型推导总会得出std::initializer_list
的结果。如果你使用**uniform initialization(花括号的方式进行初始化)**用得很爽你就得记住这个例外以免犯错,在C++11编程中一个典型的错误就是偶然使用了std::initializer_list<T>
类型的变量,这个陷阱也导致了很多C++程序员抛弃花括号初始化,只有不得不使用的时候再做考虑。(在Item7讨论了必须使用时该怎么做)
+对于C++11故事已经说完了。但是对于C++14故事还在继续,C++14允许auto
用于函数返回值并会被推导(参见Item3),而且C++14的lambda函数也允许在形参声明中使用auto
。但是在这些情况下auto
实际上使用模板类型推导的那一套规则在工作,而不是auto
类型推导,所以说下面这样的代码不会通过编译:
auto createInitList()
{
return { 1, 2, 3 }; //错误!不能推导{ 1, 2, 3 }的类型
@@ -591,7 +591,7 @@ resetV({ 1, 2, 3 }); //错误!不能推导{ 1, 2, 3 }的类型
Item 3: Understand decltype
decltype
是一个奇怪的东西。给它一个名字或者表达式decltype
就会告诉你这个名字或者表达式的类型。通常,它会精确的告诉你你想要的结果。但有时候它得出的结果也会让你挠头半天,最后只能求助网上问答或参考资料寻求启示。
-我们将从一个简单的情况开始,没有任何令人惊讶的情况。相比模板类型推导和auto
类型推导(参见Item1和Item2),decltype
只是简单的返回名字或者表达式的类型:
+我们将从一个简单的情况开始,没有任何令人惊讶的情况。相比模板类型推导和auto
类型推导(参见Item1和Item2),decltype
只是简单的返回名字或者表达式的类型:
const int i = 0; //decltype(i)是const int
bool f(const Widget& w); //decltype(w)是const Widget&
@@ -619,7 +619,7 @@ if (v[0] == 0)… //decltype(v[0])是int&
看见了吧?没有任何奇怪的东西。
在C++11中,decltype
最主要的用途就是用于声明函数模板,而这个函数返回类型依赖于形参类型。举个例子,假定我们写一个函数,一个形参为容器,一个形参为索引值,这个函数支持使用方括号的方式(也就是使用“[]
”)访问容器中指定索引值的数据,然后在返回索引操作的结果前执行认证用户操作。函数的返回类型应该和索引操作返回的类型相同。
-对一个T
类型的容器使用operator[]
通常会返回一个T&
对象,比如std::deque
就是这样。但是std::vector
有一个例外,对于std::vector<bool>
,operator[]
不会返回bool&
,它会返回一个全新的对象(译注:MSVC的STL实现中返回的是std::_Vb_reference<std::_Wrap_alloc<std::allocator<unsigned int>>>
对象)。关于这个问题的详细讨论请参见Item6,这里重要的是我们可以看到对一个容器进行operator[]
操作返回的类型取决于容器本身。
+对一个T
类型的容器使用operator[]
通常会返回一个T&
对象,比如std::deque
就是这样。但是std::vector
有一个例外,对于std::vector<bool>
,operator[]
不会返回bool&
,它会返回一个全新的对象(译注:MSVC的STL实现中返回的是std::_Vb_reference<std::_Wrap_alloc<std::allocator<unsigned int>>>
对象)。关于这个问题的详细讨论请参见Item6,这里重要的是我们可以看到对一个容器进行operator[]
操作返回的类型取决于容器本身。
使用decltype
使得我们很容易去实现它,这是我们写的第一个版本,使用decltype
计算返回类型,这个模板需要改良,我们把这个推迟到后面:
template<typename Container, typename Index> //可以工作,
auto authAndAccess(Container& c, Index i) //但是需要改良
@@ -639,7 +639,7 @@ auto authAndAccess(Container& c, Index i) //不那么正确
return c[i]; //从c[i]中推导返回类型
}
-Item2解释了函数返回类型中使用auto
,编译器实际上是使用的模板类型推导的那套规则。如果那样的话这里就会有一些问题。正如我们之前讨论的,operator[]
对于大多数T
类型的容器会返回一个T&
,但是Item1解释了在模板类型推导期间,表达式的引用性(reference-ness)会被忽略。基于这样的规则,考虑它会对下面用户的代码有哪些影响:
+Item2解释了函数返回类型中使用auto
,编译器实际上是使用的模板类型推导的那套规则。如果那样的话这里就会有一些问题。正如我们之前讨论的,operator[]
对于大多数T
类型的容器会返回一个T&
,但是Item1解释了在模板类型推导期间,表达式的引用性(reference-ness)会被忽略。基于这样的规则,考虑它会对下面用户的代码有哪些影响:
std::deque<int> d;
…
authAndAccess(d, 5) = 10; //认证用户,返回d[5],
@@ -679,12 +679,12 @@ decltype(auto) authAndAccess(Container& c, Index i);
//从makeStringDeque中获得第五个元素的拷贝并返回
auto s = authAndAccess(makeStringDeque(), 5);
-要想支持这样使用authAndAccess
我们就得修改一下当前的声明使得它支持左值和右值。重载是一个不错的选择(一个函数重载声明为左值引用,另一个声明为右值引用),但是我们就不得不维护两个重载函数。另一个方法是使authAndAccess
的引用可以绑定左值和右值,Item24解释了那正是通用引用能做的,所以我们这里可以使用通用引用进行声明:
+要想支持这样使用authAndAccess
我们就得修改一下当前的声明使得它支持左值和右值。重载是一个不错的选择(一个函数重载声明为左值引用,另一个声明为右值引用),但是我们就不得不维护两个重载函数。另一个方法是使authAndAccess
的引用可以绑定左值和右值,Item24解释了那正是通用引用能做的,所以我们这里可以使用通用引用进行声明:
template<typename Containter, typename Index> //现在c是通用引用
decltype(auto) authAndAccess(Container&& c, Index i);
-在这个模板中,我们不知道我们操纵的容器的类型是什么,那意味着我们同样不知道它使用的索引对象(index objects)的类型,对一个未知类型的对象使用传值通常会造成不必要的拷贝,对程序的性能有极大的影响,还会造成对象切片行为(参见item41),以及给同事落下笑柄。但是就容器索引来说,我们遵照标准模板库对于索引的处理是有理由的(比如std::string
,std::vector
和std::deque
的operator[]
),所以我们坚持传值调用。
-然而,我们还需要更新一下模板的实现,让它能听从Item25的告诫应用std::forward
实现通用引用:
+在这个模板中,我们不知道我们操纵的容器的类型是什么,那意味着我们同样不知道它使用的索引对象(index objects)的类型,对一个未知类型的对象使用传值通常会造成不必要的拷贝,对程序的性能有极大的影响,还会造成对象切片行为(参见item41),以及给同事落下笑柄。但是就容器索引来说,我们遵照标准模板库对于索引的处理是有理由的(比如std::string
,std::vector
和std::deque
的operator[]
),所以我们坚持传值调用。
+然而,我们还需要更新一下模板的实现,让它能听从Item25的告诫应用std::forward
实现通用引用:
template<typename Container, typename Index> //最终的C++14版本
decltype(auto)
authAndAccess(Container&& c, Index i)
@@ -725,7 +725,7 @@ decltype(auto) f2()
}
注意不仅f2
的返回类型不同于f1
,而且它还引用了一个局部变量!这样的代码将会把你送上未定义行为的特快列车,一辆你绝对不想上第二次的车。
-当使用decltype(auto)
的时候一定要加倍的小心,在表达式中看起来无足轻重的细节将会影响到decltype(auto)
的推导结果。为了确认类型推导是否产出了你想要的结果,请参见Item4描述的那些技术。
+当使用decltype(auto)
的时候一定要加倍的小心,在表达式中看起来无足轻重的细节将会影响到decltype(auto)
的推导结果。为了确认类型推导是否产出了你想要的结果,请参见Item4描述的那些技术。
同时你也不应该忽略decltype
这块大蛋糕。没错,decltype
(单独使用或者与auto
一起用)可能会偶尔产生一些令人惊讶的结果,但那毕竟是少数情况。通常,decltype
都会产生你想要的结果,尤其是当你对一个名字使用decltype
时,因为在这种情况下,decltype
只是做一件本分之事:它产出名字的声明类型。
请记住:
@@ -809,7 +809,7 @@ param = PK6Widget
param = class Widget const *
这三个独立的编译器产生了相同的信息并表示信息非常准确,当然看起来不是那么准确。在模板f
中,param
的声明类型是const T&
。难道你们不觉得T
和param
类型相同很奇怪吗?比如T
是int
,param
的类型应该是const int&
而不是相同类型才对吧。
-遗憾的是,事实就是这样,std::type_info::name
的结果并不总是可信的,就像上面一样,三个编译器对param
的报告都是错误的。因为它们本质上可以不正确,因为std::type_info::name
规范批准像传值形参一样来对待这些类型。正如Item1提到的,如果传递的是一个引用,那么引用部分(reference-ness)将被忽略,如果忽略后还具有const
或者volatile
,那么常量性const
ness或者易变性volatile
ness也会被忽略。那就是为什么param
的类型const Widget * const &
会输出为const Widget *
,首先引用被忽略,然后这个指针自身的常量性const
ness被忽略,剩下的就是指针指向一个常量对象。
+遗憾的是,事实就是这样,std::type_info::name
的结果并不总是可信的,就像上面一样,三个编译器对param
的报告都是错误的。因为它们本质上可以不正确,因为std::type_info::name
规范批准像传值形参一样来对待这些类型。正如Item1提到的,如果传递的是一个引用,那么引用部分(reference-ness)将被忽略,如果忽略后还具有const
或者volatile
,那么常量性const
ness或者易变性volatile
ness也会被忽略。那就是为什么param
的类型const Widget * const &
会输出为const Widget *
,首先引用被忽略,然后这个指针自身的常量性const
ness被忽略,剩下的就是指针指向一个常量对象。
同样遗憾的是,IDE编辑器显示的类型信息也不总是可靠的,或者说不总是有用的。还是一样的例子,一个IDE编辑器可能会把T
的类型显示为(我没有胡编乱造):
const
std::_Simple_types<std::_Wrap_alloc<std::_Vec_base_types<Widget,
@@ -857,7 +857,7 @@ param = Widget const * const&
T = class Widget const *
param = class Widget const * const &
-这样近乎一致的结果是很不错的,但是请记住IDE,编译器错误诊断或者像Boost.TypeIndex这样的库只是用来帮助你理解编译器推导的类型是什么。它们是有用的,但是作为本章结束语我想说它们根本不能替代你对Item1-3提到的类型推导的理解。
+这样近乎一致的结果是很不错的,但是请记住IDE,编译器错误诊断或者像Boost.TypeIndex这样的库只是用来帮助你理解编译器推导的类型是什么。它们是有用的,但是作为本章结束语我想说它们根本不能替代你对Item1-3提到的类型推导的理解。
请记住:
- 类型推断可以从IDE看出,从编译器报错看出,从Boost TypeIndex库的使用看出
@@ -904,7 +904,7 @@ void dwim(It b,It e)
}
}
-因为使用Item2所述的auto
类型推导技术,它甚至能表示一些只有编译器才知道的类型:
+因为使用Item2所述的auto
类型推导技术,它甚至能表示一些只有编译器才知道的类型:
auto derefUPLess =
[](const std::unique_ptr<Widget> &p1, //用于std::unique_ptr
const std::unique_ptr<Widget> &p2) //指向的Widget类型的
@@ -933,7 +933,7 @@ derefUPLess = [](const std::unique_ptr<Widget> &p1,
const std::unique_ptr<Widget> &p2)
{ return *p1 < *p2; };
-语法冗长不说,还需要重复写很多形参类型,使用std::function
还不如使用auto
。用auto
声明的变量保存一个和闭包一样类型的(新)闭包,因此使用了与闭包相同大小存储空间。实例化std::function
并声明一个对象这个对象将会有固定的大小。这个大小可能不足以存储一个闭包,这个时候std::function
的构造函数将会在堆上面分配内存来存储,这就造成了使用std::function
比auto
声明变量会消耗更多的内存。并且通过具体实现我们得知通过std::function
调用一个闭包几乎无疑比auto
声明的对象调用要慢。换句话说,std::function
方法比auto
方法要更耗空间且更慢,还可能有out-of-memory异常。并且正如上面的例子,比起写std::function
实例化的类型来,使用auto
要方便得多。在这场存储闭包的比赛中,auto
无疑取得了胜利(也可以使用std::bind
来生成一个闭包,但在Item34我会尽我最大努力说服你使用lambda表达式代替std::bind
)
+语法冗长不说,还需要重复写很多形参类型,使用std::function
还不如使用auto
。用auto
声明的变量保存一个和闭包一样类型的(新)闭包,因此使用了与闭包相同大小存储空间。实例化std::function
并声明一个对象这个对象将会有固定的大小。这个大小可能不足以存储一个闭包,这个时候std::function
的构造函数将会在堆上面分配内存来存储,这就造成了使用std::function
比auto
声明变量会消耗更多的内存。并且通过具体实现我们得知通过std::function
调用一个闭包几乎无疑比auto
声明的对象调用要慢。换句话说,std::function
方法比auto
方法要更耗空间且更慢,还可能有out-of-memory异常。并且正如上面的例子,比起写std::function
实例化的类型来,使用auto
要方便得多。在这场存储闭包的比赛中,auto
无疑取得了胜利(也可以使用std::bind
来生成一个闭包,但在Item34我会尽我最大努力说服你使用lambda表达式代替std::bind
)
使用auto
除了可以避免未初始化的无效变量,省略冗长的声明类型,直接保存闭包外,它还有一个好处是可以避免一个问题,我称之为与类型快捷方式(type shortcuts)有关的问题。你将看到这样的代码——甚至你会这么写:
std::vector<int> v;
…
@@ -962,18 +962,18 @@ for(const std::pair<std::string, int>& p : m)
这样无疑更具效率,且更容易书写。而且,这个代码有一个非常吸引人的特性,如果你获取p
的地址,你确实会得到一个指向m
中元素的指针。在没有auto
的版本中p
会指向一个临时变量,这个临时变量在每次迭代完成时会被销毁。
后面这两个例子——应当写std::vector<int>::size_type
时写了unsigned
,应当写std::pair<const std::string, int>
时写了std::pair<std::string, int>
——说明了显式的指定类型可能会导致你不像看到的类型转换。如果你使用auto
声明目标变量你就不必担心这个问题。
-基于这些原因我建议你优先考虑auto
而非显式类型声明。然而auto
也不是完美的。每个auto
变量都从初始化表达式中推导类型,有一些表达式的类型和我们期望的大相径庭。关于在哪些情况下会发生这些问题,以及你可以怎么解决这些问题我们在Item2和6讨论,所以这里我不再赘述。我想把注意力放到你可能关心的另一点:使用auto代替传统类型声明对源码可读性的影响。
+基于这些原因我建议你优先考虑auto
而非显式类型声明。然而auto
也不是完美的。每个auto
变量都从初始化表达式中推导类型,有一些表达式的类型和我们期望的大相径庭。关于在哪些情况下会发生这些问题,以及你可以怎么解决这些问题我们在Item2和6讨论,所以这里我不再赘述。我想把注意力放到你可能关心的另一点:使用auto代替传统类型声明对源码可读性的影响。
首先,深呼吸,放松,auto
是可选项,不是命令,在某些情况下如果你的专业判断告诉你使用显式类型声明比auto
要更清晰更易维护,那你就不必再坚持使用auto
。但是要牢记,C++没有在其他众所周知的语言所拥有的类型推导(type inference)上开辟新土地。其他静态类型的过程式语言(如C#、D、Sacla、Visual Basic)或多或少都有等价的特性,更不必提那些静态类型的函数式语言了(如ML、Haskell、OCaml、F#等)。在某种程度上,这是因为动态类型语言,如Perl、Python、Ruby等的成功;在这些语言中,几乎没有显式的类型声明。软件开发社区对于类型推导有丰富的经验,他们展示了在维护大型工业强度的代码上使用这种技术没有任何争议。
-一些开发者也担心使用auto
就不能瞥一眼源代码便知道对象的类型,然而,IDE扛起了部分担子(也考虑到了Item4中提到的IDE类型显示问题),在很多情况下,少量显示一个对象的类型对于知道对象的确切类型是有帮助的,这通常已经足够了。举个例子,要想知道一个对象是容器还是计数器还是智能指针,不需要知道它的确切类型。一个适当的变量名称就能告诉我们大量的抽象类型信息。
+一些开发者也担心使用auto
就不能瞥一眼源代码便知道对象的类型,然而,IDE扛起了部分担子(也考虑到了Item4中提到的IDE类型显示问题),在很多情况下,少量显示一个对象的类型对于知道对象的确切类型是有帮助的,这通常已经足够了。举个例子,要想知道一个对象是容器还是计数器还是智能指针,不需要知道它的确切类型。一个适当的变量名称就能告诉我们大量的抽象类型信息。
真正的问题是显式指定类型可以避免一些微妙的错误,以及更具效率和正确性,而且,如果初始化表达式的类型改变,则auto
推导出的类型也会改变,这意味着使用auto
可以帮助我们完成一些重构工作。举个例子,如果一个函数返回类型被声明为int
,但是后来你认为将它声明为long
会更好,调用它作为初始化表达式的变量会自动改变类型,但是如果你不使用auto
你就不得不在源代码中挨个找到调用地点然后修改它们。
请记住:
auto
变量必须初始化,通常它可以避免一些移植性和效率性的问题,也使得重构更方便,还能让你少打几个字。
-- 正如Item2和6讨论的,
auto
类型的变量可能会踩到一些陷阱。
+- 正如Item2和6讨论的,
auto
类型的变量可能会踩到一些陷阱。
Item 6: Use the explicitly typed initializer idiom when auto
deduces undesired types
-在Item5中解释了比起显式指定类型使用auto
声明变量有若干技术优势,但是有时当你想向左转auto
却向右转。举个例子,假如我有一个函数,参数为Widget
,返回一个std::vector<bool>
,这里的bool
表示Widget
是否提供一个独有的特性。
+在Item5中解释了比起显式指定类型使用auto
声明变量有若干技术优势,但是有时当你想向左转auto
却向右转。举个例子,假如我有一个函数,参数为Widget
,返回一个std::vector<bool>
,这里的bool
表示Widget
是否提供一个独有的特性。
std::vector<bool> features(const Widget& w);
更进一步假设第5个bit表示Widget
是否具有高优先级,我们可以写这样的代码:
@@ -1004,7 +1004,7 @@ processWidget(w, highPriority); //根据它的优先级处理w
processWidget(w, highPriority); //未定义行为!
//highPriority包含一个悬置指针!
-std::vector<bool>::reference
是一个代理类(proxy class)的例子:所谓代理类就是以模仿和增强一些类型的行为为目的而存在的类。很多情况下都会使用代理类,std::vector<bool>::reference
展示了对std::vector<bool>
使用operator[]
来实现引用bit这样的行为。另外,C++标准模板库中的智能指针(见第4章)也是用代理类实现了对原始指针的资源管理行为。代理类的功能已被大家广泛接受。事实上,“Proxy”设计模式是软件设计这座万神庙中一直都存在的高级会员。
+std::vector<bool>::reference
是一个代理类(proxy class)的例子:所谓代理类就是以模仿和增强一些类型的行为为目的而存在的类。很多情况下都会使用代理类,std::vector<bool>::reference
展示了对std::vector<bool>
使用operator[]
来实现引用bit这样的行为。另外,C++标准模板库中的智能指针(见第4章)也是用代理类实现了对原始指针的资源管理行为。代理类的功能已被大家广泛接受。事实上,“Proxy”设计模式是软件设计这座万神庙中一直都存在的高级会员。
一些代理类被设计于用以对客户可见。比如std::shared_ptr
和std::unique_ptr
。其他的代理类则或多或少不可见,比如std::vector<bool>::reference
就是不可见代理类的一个例子,还有它在std::bitset
的胞弟std::bitset::reference
。
在后者的阵营(注:指不可见代理类)里一些C++库也是用了表达式模板(expression templates)的黑科技。这些库通常被用于提高数值运算的效率。给出一个矩阵类Matrix
和矩阵对象m1
,m2
,m3
,m4
,举个例子,这个表达式
Matrix sum = m1 + m2 + m3 + m4;
@@ -1014,7 +1014,7 @@ processWidget(w, highPriority); //根据它的优先级处理w
因此你想避开这种形式的代码:
auto someVar = expression of "invisible" proxy class type;
-但是你怎么能意识到你正在使用代理类?应用他们的软件不可能宣告它们的存在。它们被设计为不可见,至少概念上说是这样!每当你发现它们,你真的应该舍弃Item5演示的auto
所具有的诸多好处吗?
+但是你怎么能意识到你正在使用代理类?应用他们的软件不可能宣告它们的存在。它们被设计为不可见,至少概念上说是这样!每当你发现它们,你真的应该舍弃Item5演示的auto
所具有的诸多好处吗?
让我们首先回到如何找到它们的问题上。虽然“不可见”代理类都在程序员日常使用的雷达下方飞行,但是很多库都证明它们可以上方飞行。当你越熟悉你使用的库的基本设计理念,你的思维就会越活跃,不至于思维僵化认为代理类只能在这些库中使用。
当缺少文档的时候,可以去看看头文件。很少会出现源代码全都用代理对象,它们通常用于一些函数的返回类型,所以通常能从函数签名中看出它们的存在。这里有一份std::vector<bool>::operator[]
的说明书:
namespace std{ //来自于C++标准库
@@ -1097,7 +1097,7 @@ private:
int z(0); //错误!
}
-另一方面,不可拷贝的对象(例如std::atomic
——见Item40)可以使用花括号初始化或者小括号初始化,但是不能使用"="初始化:
+另一方面,不可拷贝的对象(例如std::atomic
——见Item40)可以使用花括号初始化或者小括号初始化,但是不能使用"="初始化:
std::atomic<int> ai1{ 0 }; //没问题
std::atomic<int> ai2(0); //没问题
std::atomic<int> ai3 = 0; //错误!
@@ -1123,7 +1123,7 @@ int sum3 = x + y + z; //同上
Widget w3{}; //调用没有参数的构造函数构造对象
关于括号初始化还有很多要说的。它的语法能用于各种不同的上下文,它防止了隐式的变窄转换,而且对于C++最令人头疼的解析也天生免疫。既然好到这个程度那为什么这个条款不叫“Prefer braced initialization syntax”呢?
-括号初始化的缺点是有时它有一些令人惊讶的行为。这些行为使得括号初始化、std::initializer_list
和构造函数重载决议本来就不清不楚的暧昧关系进一步混乱。把它们放到一起会让看起来应该左转的代码右转。举个例子,Item2解释了当auto
声明的变量使用花括号初始化,变量类型就会被推导为std::initializer_list
,尽管使用相同内容的其他初始化方式会产生正常的结果。所以,你越喜欢用auto
,你就越不能用括号初始化。
+括号初始化的缺点是有时它有一些令人惊讶的行为。这些行为使得括号初始化、std::initializer_list
和构造函数重载决议本来就不清不楚的暧昧关系进一步混乱。把它们放到一起会让看起来应该左转的代码右转。举个例子,Item2解释了当auto
声明的变量使用花括号初始化,变量类型就会被推导为std::initializer_list
,尽管使用相同内容的其他初始化方式会产生正常的结果。所以,你越喜欢用auto
,你就越不能用括号初始化。
在构造函数调用中,只要不包含std::initializer_list
形参,那么花括号初始化和小括号初始化都会产生一样的结果:
class Widget {
public:
@@ -1245,7 +1245,7 @@ void doSomeWork(Ts&&... params)
…
}
-在现实中我们有两种方式实现这个伪代码(关于std::forward
请参见Item25):
+在现实中我们有两种方式实现这个伪代码(关于std::forward
请参见Item25):
T localObject(std::forward<Ts>(params)...); //使用小括号
T localObject{std::forward<Ts>(params)...}; //使用花括号
@@ -1255,7 +1255,7 @@ T localObject{std::forward<Ts>(params)...}; //使用花括号
doSomeWork<std::vector<int>>(10, 20);
如果doSomeWork
创建localObject
时使用的是小括号,std::vector
就会包含10个元素。如果doSomeWork
创建localObject
时使用的是花括号,std::vector
就会包含2个元素。哪个是正确的?doSomeWork
的作者不知道,只有调用者知道。
-这正是标准库函数std::make_unique
和std::make_shared
(参见Item21)面对的问题。它们的解决方案是使用小括号,并被记录在文档中作为接口的一部分。(注:更灵活的设计——允许调用者决定从模板来的函数应该使用小括号还是花括号——是有可能的。详情参见Andrzej’s C++ blog在2013年6月5日的文章,“Intuitive interface — Part I.”)
+这正是标准库函数std::make_unique
和std::make_shared
(参见Item21)面对的问题。它们的解决方案是使用小括号,并被记录在文档中作为接口的一部分。(注:更灵活的设计——允许调用者决定从模板来的函数应该使用小括号还是花括号——是有可能的。详情参见Andrzej’s C++ blog在2013年6月5日的文章,“Intuitive interface — Part I.”)
请记住:
- 括号初始化是最广泛使用的初始化语法,它防止变窄转换,并且对于C++最令人头疼的解析有天生的免疫性
@@ -1335,7 +1335,7 @@ auto lockAndCall(FuncType func,
return func(ptr);
}
-如果你对函数返回类型(auto ... -> decltype(func(ptr))
)感到困惑不解,Item3可以帮助你。在C++14中代码的返回类型还可以被简化为decltype(auto)
:
+如果你对函数返回类型(auto ... -> decltype(func(ptr))
)感到困惑不解,Item3可以帮助你。在C++14中代码的返回类型还可以被简化为decltype(auto)
:
template<typename FuncType,
typename MuxType,
typename PtrType>
@@ -1365,7 +1365,7 @@ auto result3 = lockAndCall(f3, f3m, nullptr); //没问题
Item 9: Prefer alias declarations to typedef
s
-我相信每个人都同意使用STL容器是个好主意,并且我希望Item18能说服你让你觉得使用std:unique_ptr
也是个好主意,但我猜没有人喜欢写上几次 std::unique_ptr<std::unordered_map<std::string, std::string>>
这样的类型,它可能会让你患上腕管综合征的风险大大增加。
+我相信每个人都同意使用STL容器是个好主意,并且我希望Item18能说服你让你觉得使用std:unique_ptr
也是个好主意,但我猜没有人喜欢写上几次 std::unique_ptr<std::unordered_map<std::string, std::string>>
这样的类型,它可能会让你患上腕管综合征的风险大大增加。
避免上述医疗悲剧也很简单,引入typedef
即可:
typedef
std::unique_ptr<std::unordered_map<std::string, std::string>>
@@ -1436,7 +1436,7 @@ private:
};
就像你看到的,MyAllocList<Wine>::type
不是一个类型。如果Widget
使用Wine
实例化,在Widget
模板中的MyAllocList<Wine>::type
将会是一个数据成员,不是一个类型。在Widget
模板内,MyAllocList<T>::type
是否表示一个类型取决于T
是什么,这就是为什么编译器会坚持要求你在前面加上typename
。
-如果你尝试过模板元编程(template metaprogramming,TMP), 你一定会碰到取模板类型参数然后基于它创建另一种类型的情况。举个例子,给一个类型T
,如果你想去掉T
的常量修饰和引用修饰(const
- or reference qualifiers),比如你想把const std::string&
变成std::string
。又或者你想给一个类型加上const
或变为左值引用,比如把Widget
变成const Widget
或Widget&
。(如果你没有用过模板元编程,太遗憾了,因为如果你真的想成为一个高效C++程序员,你需要至少熟悉C++在这方面的基本知识。你可以看看在Item23,27里的TMP的应用实例,包括我提到的类型转换)。
+如果你尝试过模板元编程(template metaprogramming,TMP), 你一定会碰到取模板类型参数然后基于它创建另一种类型的情况。举个例子,给一个类型T
,如果你想去掉T
的常量修饰和引用修饰(const
- or reference qualifiers),比如你想把const std::string&
变成std::string
。又或者你想给一个类型加上const
或变为左值引用,比如把Widget
变成const Widget
或Widget&
。(如果你没有用过模板元编程,太遗憾了,因为如果你真的想成为一个高效C++程序员,你需要至少熟悉C++在这方面的基本知识。你可以看看在Item23,27里的TMP的应用实例,包括我提到的类型转换)。
C++11在type traits(类型特性)中给了你一系列工具去实现类型转换,如果要使用这些模板请包含头文件<type_traits>
。里面有许许多多type traits,也不全是类型转换的工具,也包含一些可预测接口的工具。给一个你想施加转换的类型T
,结果类型就是std::
transformation<T>::type
,比如:
std::remove_const<T>::type //从const T中产出T
std::remove_reference<T>::type //从T&和T&&中产出T
@@ -1618,8 +1618,8 @@ auto val =
std::get<static_cast<std::size_t>(UserInfoFields::uiEmail)>
(uInfo);
-为避免这种冗长的表示,我们可以写一个函数传入枚举名并返回对应的std::size_t
值,但这有一点技巧性。std::get
是一个模板(函数),需要你给出一个std::size_t
值的模板实参(注意使用<>
而不是()
),因此将枚举名变换为std::size_t
值的函数必须在编译期产生这个结果。如Item15提到的,那必须是一个constexpr
函数。
-事实上,它也的确该是一个constexpr
函数模板,因为它应该能用于任何enum
。如果我们想让它更一般化,我们还要泛化它的返回类型。较之于返回std::size_t
,我们更应该返回枚举的底层类型。这可以通过std::underlying_type
这个type trait获得。(参见Item9关于type trait的内容)。最终我们还要再加上noexcept
修饰(参见Item14),因为我们知道它肯定不会产生异常。根据上述分析最终得到的toUType
函数模板在编译期接受任意枚举名并返回它的值:
+为避免这种冗长的表示,我们可以写一个函数传入枚举名并返回对应的std::size_t
值,但这有一点技巧性。std::get
是一个模板(函数),需要你给出一个std::size_t
值的模板实参(注意使用<>
而不是()
),因此将枚举名变换为std::size_t
值的函数必须在编译期产生这个结果。如Item15提到的,那必须是一个constexpr
函数。
+事实上,它也的确该是一个constexpr
函数模板,因为它应该能用于任何enum
。如果我们想让它更一般化,我们还要泛化它的返回类型。较之于返回std::size_t
,我们更应该返回枚举的底层类型。这可以通过std::underlying_type
这个type trait获得。(参见Item9关于type trait的内容)。最终我们还要再加上noexcept
修饰(参见Item14),因为我们知道它肯定不会产生异常。根据上述分析最终得到的toUType
函数模板在编译期接受任意枚举名并返回它的值:
template<typename E>
constexpr typename std::underlying_type<E>::type
toUType(E enumerator) noexcept
@@ -1629,7 +1629,7 @@ constexpr typename std::underlying_type<E>::type
std::underlying_type<E>::type>(enumerator);
}
-在C++14中,toUType
还可以进一步用std::underlying_type_t
(参见Item9)代替typename std::underlying_type<E>::type
打磨:
+在C++14中,toUType
还可以进一步用std::underlying_type_t
(参见Item9)代替typename std::underlying_type<E>::type
打磨:
template<typename E> //C++14
constexpr std::underlying_type_t<E>
toUType(E enumerator) noexcept
@@ -1637,7 +1637,7 @@ constexpr std::underlying_type_t<E>
return static_cast<std::underlying_type_t<E>>(enumerator);
}
-还可以再用C++14 auto
(参见Item3)打磨一下代码:
+还可以再用C++14 auto
(参见Item3)打磨一下代码:
template<typename E> //C++14
constexpr auto
toUType(E enumerator) noexcept
@@ -1659,7 +1659,7 @@ constexpr auto
Item 11: Prefer deleted functions to private undefined ones.
如果你写的代码要被其他人使用,你不想让他们调用某个特殊的函数,你通常不会声明这个函数。无声明,不函数。简简单单!但有时C++会给你自动声明一些函数,如果你想防止客户调用这些函数,事情就不那么简单了。
-上述场景见于特殊的成员函数,即当有必要时C++自动生成的那些函数。Item17详细讨论了这些函数,但是现在,我们只关心拷贝构造函数和拷贝赋值运算符重载。本节主要致力于讨论C++98中那些被C++11所取代的最佳实践,而且在C++98中,你想要禁止使用的成员函数,几乎总是拷贝构造函数或者赋值运算符,或者两者都是。
+上述场景见于特殊的成员函数,即当有必要时C++自动生成的那些函数。Item17详细讨论了这些函数,但是现在,我们只关心拷贝构造函数和拷贝赋值运算符重载。本节主要致力于讨论C++98中那些被C++11所取代的最佳实践,而且在C++98中,你想要禁止使用的成员函数,几乎总是拷贝构造函数或者赋值运算符,或者两者都是。
在C++98中防止调用这些函数的方法是将它们声明为私有(private
)成员函数并且不定义。举个例子,在C++ 标准库iostream继承链的顶部是模板类basic_ios
。所有istream和ostream类都继承此类(直接或者间接)。拷贝istream和ostream是不合适的,因为这些操作应该怎么做是模棱两可的。比如一个istream
对象,代表一个输入值的流,流中有一些已经被读取,有一些可能马上要被读取。如果一个istream被拷贝,需要拷贝将要被读取的值和已经被读取的值吗?解决这个问题最好的方法是不定义这个操作。直接禁止拷贝流。
要使这些istream和ostream类不可拷贝,basic_ios
在C++98中是这样声明的(包括注释):
template <class charT, class traits = char_traits<charT> >
@@ -1707,7 +1707,7 @@ bool isLucky(double) = delete; //拒绝float和double
if (isLucky(true)) … //错误!
if (isLucky(3.5f)) … //错误!
-另一个deleted函数用武之地(private
成员函数做不到的地方)是禁止一些模板的实例化。假如你要求一个模板仅支持原生指针(尽管第四章建议使用智能指针代替原生指针):
+另一个deleted函数用武之地(private
成员函数做不到的地方)是禁止一些模板的实例化。假如你要求一个模板仅支持原生指针(尽管第四章建议使用智能指针代替原生指针):
template<typename T>
void processPointer(T* ptr);
@@ -1969,7 +1969,7 @@ ConstIterT ci =
values.insert(static_cast<IterT>(ci), 1998); //可能无法通过编译,
//原因见下
-typedef
不是强制的,但是可以让代码中的cast更好写。(你可能想知道为什么我使用typedef
而不是Item9提到的别名声明,因为这段代码在演示C++98做法,别名声明是C++11加入的特性)
+typedef
不是强制的,但是可以让代码中的cast更好写。(你可能想知道为什么我使用typedef
而不是Item9提到的别名声明,因为这段代码在演示C++98做法,别名声明是C++11加入的特性)
之所以std::find
的调用会出现类型转换是因为在C++98中values
是non-const
容器,没办法简简单单的从non-const
容器中获取const_iterator
。严格来说类型转换不是必须的,因为用其他方法获取const_iterator
也是可以的(比如你可以把values
绑定到reference-to-const
变量上,然后再用这个变量代替values
),但不管怎么说,从non-const
容器中获取const_iterator
的做法都有点别扭。
当你费劲地获得了const_iterator
,事情可能会变得更糟,因为C++98中,插入操作(以及删除操作)的位置只能由iterator
指定,const_iterator
是不被接受的。这也是我在上面的代码中,将const_iterator
(我那么小心地从std::find
搞出来的东西)转换为iterator
的原因,因为向insert
传入const_iterator
不能通过编译。
老实说,上面的代码也可能无法编译,因为没有一个可移植的从const_iterator
到iterator
的方法,即使使用static_cast
也不行。甚至传说中的牛刀reinterpret_cast
也杀不了这条鸡。(它不是C++98的限制,也不是C++11的限制,只是const_iterator
就是不能转换为iterator
,不管看起来对它们施以转换是有多么合理。)不过有办法生成一个iterator
,使其指向和const_iterator
指向相同,但是看起来不明显,也没有广泛应用,在这本书也不值得讨论。除此之外,我希望目前我陈述的观点是清晰的:const_iterator
在C++98中会有很多问题,不如它的兄弟(译注:指iterator
)有用。最终,开发者们不再相信能加const
就加它的教条,而是只在实用的地方加它,C++98的const_iterator
不是那么实用。
@@ -2037,11 +2037,11 @@ Widget w;
vw.push_back(w); //把w添加进vw
…
-假设这个代码能正常工作,你也无意修改为C++11风格。但是你确实想要C++11移动语义带来的性能优势,毕竟这里的类型是可以移动的(move-enabled types)。因此你需要确保Widget
有移动操作,可以手写代码也可以让编译器自动生成,当然前提是能满足自动生成的条件(参见Item17)。
+假设这个代码能正常工作,你也无意修改为C++11风格。但是你确实想要C++11移动语义带来的性能优势,毕竟这里的类型是可以移动的(move-enabled types)。因此你需要确保Widget
有移动操作,可以手写代码也可以让编译器自动生成,当然前提是能满足自动生成的条件(参见Item17)。
当新元素添加到std::vector
,std::vector
可能没地方放它,换句话说,std::vector
的大小(size)等于它的容量(capacity)。这时候,std::vector
会分配一个新的更大块的内存用于存放其中元素,然后将元素从老内存区移动到新内存区,然后析构老内存区里的对象。在C++98中,移动是通过复制老内存区的每一个元素到新内存区完成的,然后老内存区的每个元素发生析构。这种方法使得push_back
可以提供很强的异常安全保证:如果在复制元素期间抛出异常,std::vector
状态保持不变,因为老内存元素析构必须建立在它们已经成功复制到新内存的前提下。
在C++11中,一个很自然的优化就是将上述复制操作替换为移动操作。但是很不幸运,这会破坏push_back
的异常安全保证。如果n个元素已经从老内存移动到了新内存区,但异常在移动第n+1个元素时抛出,那么push_back
操作就不能完成。但是原始的std::vector
已经被修改:有n个元素已经移动走了。恢复std::vector
至原始状态也不太可能,因为从新内存移动到老内存本身又可能引发异常。
这是个很严重的问题,因为老代码可能依赖于push_back
提供的强烈的异常安全保证。因此,C++11版本的实现不能简单的将push_back
里面的复制操作替换为移动操作,除非知晓移动操作绝不抛异常,这时复制替换为移动就是安全的,唯一的副作用就是性能得到提升。
-std::vector::push_back
受益于“如果可以就移动,如果必要则复制”策略,并且它不是标准库中唯一采取该策略的函数。C++98中还有一些函数(如std::vector::reverse
,std::deque::insert
等)也受益于这种强异常保证。对于这个函数只有在知晓移动不抛异常的情况下用C++11的移动操作替换C++98的复制操作才是安全的。但是如何知道一个函数中的移动操作是否产生异常?答案很明显:它检查这个操作是否被声明为noexcept
。(这个检查非常弯弯绕。像是std::vector::push_back
之类的函数调用std::move_if_noexcept
,这是个std::move
的变体,根据其中类型的移动构造函数是否为noexcept
的,视情况转换为右值或保持左值(参见Item23)。反过来,std::move_if_noexcept
查阅std::is_nothrow_move_constructible
这个type trait,基于移动构造函数是否有noexcept
(或者throw()
)的设计,编译器设置这个type trait的值。)
+std::vector::push_back
受益于“如果可以就移动,如果必要则复制”策略,并且它不是标准库中唯一采取该策略的函数。C++98中还有一些函数(如std::vector::reverse
,std::deque::insert
等)也受益于这种强异常保证。对于这个函数只有在知晓移动不抛异常的情况下用C++11的移动操作替换C++98的复制操作才是安全的。但是如何知道一个函数中的移动操作是否产生异常?答案很明显:它检查这个操作是否被声明为noexcept
。(这个检查非常弯弯绕。像是std::vector::push_back
之类的函数调用std::move_if_noexcept
,这是个std::move
的变体,根据其中类型的移动构造函数是否为noexcept
的,视情况转换为右值或保持左值(参见Item23)。反过来,std::move_if_noexcept
查阅std::is_nothrow_move_constructible
这个type trait,基于移动构造函数是否有noexcept
(或者throw()
)的设计,编译器设置这个type trait的值。)
swap
函数是noexcept
的另一个绝佳用地。swap
是STL算法实现的一个关键组件,它也常用于拷贝运算符重载中。它的广泛使用意味着对其施加不抛异常的优化是非常有价值的。有趣的是,标准库的swap
是否noexcept
有时依赖于用户定义的swap
是否noexcept
。比如,数组和std::pair
的swap
声明如下:
template <class T, size_t N>
void swap(T (&a)[N],
@@ -2284,7 +2284,7 @@ private:
std::mutex m
被声明为mutable
,因为锁定和解锁它的都是non-const
成员函数。在roots
(const
成员函数)中,m
却被视为const
对象。
值得注意的是,因为std::mutex
是一种只可移动类型(move-only type,一种可以移动但不能复制的类型),所以将m
添加进Polynomial
中的副作用是使Polynomial
失去了被复制的能力。不过,它仍然可以移动。
-在某些情况下,互斥量的副作用显会得过大。例如,如果你所做的只是计算成员函数被调用了多少次,使用std::atomic
修饰的计数器(保证其他线程视它的操作为不可分割的整体,参见item40)通常会是一个开销更小的方法。(然而它是否轻量取决于你使用的硬件和标准库中互斥量的实现。)以下是如何使用std::atomic
来统计调用次数。
+在某些情况下,互斥量的副作用显会得过大。例如,如果你所做的只是计算成员函数被调用了多少次,使用std::atomic
修饰的计数器(保证其他线程视它的操作为不可分割的整体,参见item40)通常会是一个开销更小的方法。(然而它是否轻量取决于你使用的硬件和标准库中互斥量的实现。)以下是如何使用std::atomic
来统计调用次数。
class Point { //2D点
public:
…
@@ -2395,12 +2395,12 @@ public:
};
掌控它们生成和行为的规则类似于拷贝系列。移动操作仅在需要的时候生成,如果生成了,就会对类的non-static数据成员执行逐成员的移动。那意味着移动构造函数根据rhs
参数里面对应的成员移动构造出新non-static部分,移动赋值运算符根据参数里面对应的non-static成员移动赋值。移动构造函数也移动构造基类部分(如果有的话),移动赋值运算符也是移动赋值基类部分。
-现在,当我对一个数据成员或者基类使用移动构造或者移动赋值时,没有任何保证移动一定会真的发生。逐成员移动,实际上,更像是逐成员移动请求,因为对不可移动类型(即对移动操作没有特殊支持的类型,比如大部分C++98传统类)使用“移动”操作实际上执行的是拷贝操作。逐成员移动的核心是对对象使用std::move
,然后函数决议时会选择执行移动还是拷贝操作。Item23包括了这个操作的细节。本条款中,简单记住如果支持移动就会逐成员移动类成员和基类成员,如果不支持移动就执行拷贝操作就好了。
+现在,当我对一个数据成员或者基类使用移动构造或者移动赋值时,没有任何保证移动一定会真的发生。逐成员移动,实际上,更像是逐成员移动请求,因为对不可移动类型(即对移动操作没有特殊支持的类型,比如大部分C++98传统类)使用“移动”操作实际上执行的是拷贝操作。逐成员移动的核心是对对象使用std::move
,然后函数决议时会选择执行移动还是拷贝操作。Item23包括了这个操作的细节。本条款中,简单记住如果支持移动就会逐成员移动类成员和基类成员,如果不支持移动就执行拷贝操作就好了。
像拷贝操作情况一样,如果你自己声明了移动操作,编译器就不会生成。然而它们生成的精确条件与拷贝操作的条件有点不同。
两个拷贝操作是独立的:声明一个不会限制编译器生成另一个。所以如果你声明一个拷贝构造函数,但是没有声明拷贝赋值运算符,如果写的代码用到了拷贝赋值,编译器会帮助你生成拷贝赋值运算符。同样的,如果你声明拷贝赋值运算符但是没有拷贝构造函数,代码用到拷贝构造函数时编译器就会生成它。上述规则在C++98和C++11中都成立。
两个移动操作不是相互独立的。如果你声明了其中一个,编译器就不再生成另一个。如果你给类声明了,比如,一个移动构造函数,就表明对于移动操作应怎样实现,与编译器应生成的默认逐成员移动有些区别。如果逐成员移动构造有些问题,那么逐成员移动赋值同样也可能有问题。所以声明移动构造函数阻止移动赋值运算符的生成,声明移动赋值运算符同样阻止编译器生成移动构造函数。
再进一步,如果一个类显式声明了拷贝操作,编译器就不会生成移动操作。这种限制的解释是如果声明拷贝操作(构造或者赋值)就暗示着平常拷贝对象的方法(逐成员拷贝)不适用于该类,编译器会明白如果逐成员拷贝对拷贝操作来说不合适,逐成员移动也可能对移动操作来说不合适。
-这是另一个方向。声明移动操作(构造或赋值)使得编译器禁用拷贝操作。(编译器通过给拷贝操作加上delete来保证,参见Item11。)(译注:禁用的是自动生成的拷贝操作,对于用户声明的拷贝操作不受影响)毕竟,如果逐成员移动对该类来说不合适,也没有理由指望逐成员拷贝操作是合适的。听起来会破坏C++98的某些代码,因为C++11中拷贝操作可用的条件比C++98更受限,但事实并非如此。C++98的代码没有移动操作,因为C++98中没有移动对象这种概念。只有一种方法能让老代码使用用户声明的移动操作,那就是使用C++11标准然后添加这些操作,使用了移动语义的类必须接受C++11特殊成员函数生成规则的限制。
+这是另一个方向。声明移动操作(构造或赋值)使得编译器禁用拷贝操作。(编译器通过给拷贝操作加上delete来保证,参见Item11。)(译注:禁用的是自动生成的拷贝操作,对于用户声明的拷贝操作不受影响)毕竟,如果逐成员移动对该类来说不合适,也没有理由指望逐成员拷贝操作是合适的。听起来会破坏C++98的某些代码,因为C++11中拷贝操作可用的条件比C++98更受限,但事实并非如此。C++98的代码没有移动操作,因为C++98中没有移动对象这种概念。只有一种方法能让老代码使用用户声明的移动操作,那就是使用C++11标准然后添加这些操作,使用了移动语义的类必须接受C++11特殊成员函数生成规则的限制。
也许你早已听过_Rule of Three_规则。这个规则告诉我们如果你声明了拷贝构造函数,拷贝赋值运算符,或者析构函数三者之一,你应该也声明其余两个。它来源于长期的观察,即用户接管拷贝操作的需求几乎都是因为该类会做其他资源的管理,这也几乎意味着(1)无论哪种资源管理如果在一个拷贝操作内完成,也应该在另一个拷贝操作内完成(2)类的析构函数也需要参与资源的管理(通常是释放)。通常要管理的资源是内存,这也是为什么标准库里面那些管理内存的类(如会动态内存管理的STL容器)都声明了“the big three”:拷贝构造,拷贝赋值和析构。
Rule of Three带来的后果就是只要出现用户定义的析构函数就意味着简单的逐成员拷贝操作不适用于该类。那意味着如果一个类声明了析构,拷贝操作可能不应该自动生成,因为它们做的事情可能是错误的。在C++98提出的时候,上述推理没有得倒足够的重视,所以C++98用户声明析构函数不会左右编译器生成拷贝操作的意愿。C++11中情况仍然如此,但仅仅是因为限制拷贝操作生成的条件会破坏老代码。
Rule of Three规则背后的解释依然有效,再加上对声明拷贝操作阻止移动操作隐式生成的观察,使得C++11不会为那些有用户定义的析构函数的类生成移动操作。
@@ -2464,7 +2464,7 @@ private:
C++11对于特殊成员函数处理的规则如下:
- 默认构造函数:和C++98规则相同。仅当类不存在用户声明的构造函数时才自动生成。
-- 析构函数:基本上和C++98相同;稍微不同的是现在析构默认
noexcept
(参见Item14)。和C++98一样,仅当基类析构为虚函数时该类析构才为虚函数。
+- 析构函数:基本上和C++98相同;稍微不同的是现在析构默认
noexcept
(参见Item14)。和C++98一样,仅当基类析构为虚函数时该类析构才为虚函数。
- 拷贝构造函数:和C++98运行时行为一样:逐成员拷贝non-static数据。仅当类没有用户定义的拷贝构造时才生成。如果类声明了移动操作它就是delete的。当用户声明了拷贝赋值或者析构,该函数自动生成已被废弃。
- 拷贝赋值运算符:和C++98运行时行为一样:逐成员拷贝赋值non-static数据。仅当类没有用户定义的拷贝赋值时才生成。如果类声明了移动操作它就是delete的。当用户声明了拷贝构造或者析构,该函数自动生成已被废弃。
- 移动构造函数和移动赋值运算符:都对非static数据执行逐成员移动。仅当类没有用户定义的拷贝操作,移动操作或析构时才自动生成。
@@ -2480,7 +2480,7 @@ private:
…
};
-编译器仍会生成移动和拷贝操作(假设正常生成它们的条件满足),即使可以模板实例化产出拷贝构造和拷贝赋值运算符的函数签名。(当T
为Widget
时。)很可能你会觉得这是一个不值得承认的边缘情况,但是我提到它是有道理的,Item26将会详细讨论它可能带来的后果。
+编译器仍会生成移动和拷贝操作(假设正常生成它们的条件满足),即使可以模板实例化产出拷贝构造和拷贝赋值运算符的函数签名。(当T
为Widget
时。)很可能你会觉得这是一个不值得承认的边缘情况,但是我提到它是有道理的,Item26将会详细讨论它可能带来的后果。
请记住:
- 特殊成员函数是编译器可能自动生成的函数:默认构造函数,析构函数,拷贝操作,移动操作。
@@ -2529,7 +2529,7 @@ makeInvestment(Ts&&... params);
…
} //销毁 *pInvestment
-但是也可以在所有权转移的场景中使用它,比如将工厂返回的std::unique_ptr
移入容器中,然后将容器元素移入一个对象的数据成员中,然后对象过后被销毁。发生这种情况时,这个对象的std::unique_ptr
数据成员也被销毁,并且智能指针数据成员的析构将导致从工厂返回的资源被销毁。如果所有权链由于异常或者其他非典型控制流出现中断(比如提前从函数return或者循环中的break
),则拥有托管资源的std::unique_ptr
将保证指向内容的析构函数被调用,销毁对应资源。(这个规则也有些例外。大多数情况发生于不正常的程序终止。如果一个异常传播到线程的基本函数(比如程序初始线程的main
函数)外,或者违反noexcept
说明(见Item14),局部变量可能不会被销毁;如果std::abort
或者退出函数(如std::_Exit
,std::exit
,或std::quick_exit
)被调用,局部变量一定没被销毁。)
+但是也可以在所有权转移的场景中使用它,比如将工厂返回的std::unique_ptr
移入容器中,然后将容器元素移入一个对象的数据成员中,然后对象过后被销毁。发生这种情况时,这个对象的std::unique_ptr
数据成员也被销毁,并且智能指针数据成员的析构将导致从工厂返回的资源被销毁。如果所有权链由于异常或者其他非典型控制流出现中断(比如提前从函数return或者循环中的break
),则拥有托管资源的std::unique_ptr
将保证指向内容的析构函数被调用,销毁对应资源。(这个规则也有些例外。大多数情况发生于不正常的程序终止。如果一个异常传播到线程的基本函数(比如程序初始线程的main
函数)外,或者违反noexcept
说明(见Item14),局部变量可能不会被销毁;如果std::abort
或者退出函数(如std::_Exit
,std::exit
,或std::quick_exit
)被调用,局部变量一定没被销毁。)
默认情况下,销毁将通过delete
进行,但是在构造过程中,std::unique_ptr
对象可以被设置为使用(对资源的)自定义删除器:当资源需要销毁时可调用的任意函数(或者函数对象,包括lambda表达式)。如果通过makeInvestment
创建的对象不应仅仅被delete
,而应该先写一条日志,makeInvestment
可以以如下方式实现。(代码后有说明,别担心有些东西的动机不那么明显。)
auto delInvmt = [](Investment* pInvestment) //自定义删除器
{ //(lambda表达式)
@@ -2565,7 +2565,7 @@ makeInvestment(Ts&&... params)
delInvmt
是从makeInvestment
返回的对象的自定义的删除器。所有的自定义的删除行为接受要销毁对象的原始指针,然后执行所有必要行为实现销毁操作。在上面情况中,操作包括调用makeLogEntry
然后应用delete
。使用lambda创建delInvmt
是方便的,而且,正如稍后看到的,比编写常规的函数更有效。
-当使用自定义删除器时,删除器类型必须作为第二个类型实参传给std::unique_ptr
。在上面情况中,就是delInvmt
的类型,这就是为什么makeInvestment
返回类型是std::unique_ptr<Investment, decltype(delInvmt)>
。(对于decltype
,更多信息查看Item3)
+当使用自定义删除器时,删除器类型必须作为第二个类型实参传给std::unique_ptr
。在上面情况中,就是delInvmt
的类型,这就是为什么makeInvestment
返回类型是std::unique_ptr<Investment, decltype(delInvmt)>
。(对于decltype
,更多信息查看Item3)
makeInvestment
的基本策略是创建一个空的std::unique_ptr
,然后指向一个合适类型的对象,然后返回。为了将自定义删除器delInvmt
与pInv
关联,我们把delInvmt
作为pInv
构造函数的第二个实参。
@@ -2574,7 +2574,7 @@ makeInvestment(Ts&&... params)
尝试将原始指针(比如new
创建)赋值给std::unique_ptr
通不过编译,因为是一种从原始指针到智能指针的隐式转换。这种隐式转换会出问题,所以C++11的智能指针禁止这个行为。这就是通过reset
来让pInv
接管通过new
创建的对象的所有权的原因。
-使用new
时,我们使用std::forward
把传给makeInvestment
的实参完美转发出去(查看Item25)。这使调用者提供的所有信息可用于正在创建的对象的构造函数。
+使用new
时,我们使用std::forward
把传给makeInvestment
的实参完美转发出去(查看Item25)。这使调用者提供的所有信息可用于正在创建的对象的构造函数。
自定义删除器的一个形参,类型是Investment*
,不管在makeInvestment
内部创建的对象的真实类型(如Stock
,Bond
,或RealEstate
)是什么,它最终在lambda表达式中,作为Investment*
对象被删除。这意味着我们通过基类指针删除派生类实例,为此,基类Investment
必须有虚析构函数:
@@ -2587,7 +2587,7 @@ public:
-在C++14中,函数的返回类型推导存在(参阅Item3),意味着makeInvestment
可以以更简单,更封装的方式实现:
+在C++14中,函数的返回类型推导存在(参阅Item3),意味着makeInvestment
可以以更简单,更封装的方式实现:
template<typename... Ts>
auto makeInvestment(Ts&&... params) //C++14
{
@@ -2635,14 +2635,14 @@ std::unique_ptr<Investment, void (*)(Investment*)> //Investment*的指针
makeInvestment(Ts&&... params); //加至少一个函数指针的大小
具有很多状态的自定义删除器会产生大尺寸std::unique_ptr
对象。如果你发现自定义删除器使得你的std::unique_ptr
变得过大,你需要审视修改你的设计。
-工厂函数不是std::unique_ptr
的唯一常见用法。作为实现Pimpl Idiom(译注:pointer to implementation,一种隐藏实际实现而减弱编译依赖性的设计思想,《Effective C++》条款31对此有过叙述)的一种机制,它更为流行。代码并不复杂,但是在某些情况下并不直观,所以这安排在Item22的专门主题中。
+工厂函数不是std::unique_ptr
的唯一常见用法。作为实现Pimpl Idiom(译注:pointer to implementation,一种隐藏实际实现而减弱编译依赖性的设计思想,《Effective C++》条款31对此有过叙述)的一种机制,它更为流行。代码并不复杂,但是在某些情况下并不直观,所以这安排在Item22的专门主题中。
std::unique_ptr
有两种形式,一种用于单个对象(std::unique_ptr<T>
),一种用于数组(std::unique_ptr<T[]>
)。结果就是,指向哪种形式没有歧义。std::unique_ptr
的API设计会自动匹配你的用法,比如operator[]
就是数组对象,解引用操作符(operator*
和operator->
)就是单个对象专有。
你应该对数组的std::unique_ptr
的存在兴趣泛泛,因为std::array
,std::vector
,std::string
这些更好用的数据容器应该取代原始数组。std::unique_ptr<T[]>
有用的唯一情况是你使用类似C的API返回一个指向堆数组的原始指针,而你想接管这个数组的所有权。
std::unique_ptr
是C++11中表示专有所有权的方法,但是其最吸引人的功能之一是它可以轻松高效的转换为std::shared_ptr
:
std::shared_ptr<Investment> sp = //将std::unique_ptr
makeInvestment(arguments); //转为std::shared_ptr
-这就是std::unique_ptr
非常适合用作工厂函数返回类型的原因的关键部分。 工厂函数无法知道调用者是否要对它们返回的对象使用专有所有权语义,或者共享所有权(即std::shared_ptr
)是否更合适。 通过返回std::unique_ptr
,工厂为调用者提供了最有效的智能指针,但它们并不妨碍调用者用其更灵活的兄弟替换它。(有关std::shared_ptr
的信息,请转到Item19。)
+这就是std::unique_ptr
非常适合用作工厂函数返回类型的原因的关键部分。 工厂函数无法知道调用者是否要对它们返回的对象使用专有所有权语义,或者共享所有权(即std::shared_ptr
)是否更合适。 通过返回std::unique_ptr
,工厂为调用者提供了最有效的智能指针,但它们并不妨碍调用者用其更灵活的兄弟替换它。(有关std::shared_ptr
的信息,请转到Item19。)
请记住:
std::unique_ptr
是轻量级、快速的、只可移动(move-only)的管理专有所有权语义资源的智能指针
@@ -2657,12 +2657,12 @@ makeInvestment(Ts&&... params); //加至少一个函
引用计数暗示着性能问题:
std::shared_ptr
大小是原始指针的两倍,因为它内部包含一个指向资源的原始指针,还包含一个指向资源的引用计数值的原始指针。(这种实现法并不是标准要求的,但是我(指原书作者Scott Meyers)熟悉的所有标准库都这样实现。)
-- 引用计数的内存必须动态分配。 概念上,引用计数与所指对象关联起来,但是实际上被指向的对象不知道这件事情(译注:不知道有一个关联到自己的计数值)。因此它们没有办法存放一个引用计数值。(一个好消息是任何对象——甚至是内置类型的——都可以由
std::shared_ptr
管理。)Item21会解释使用std::make_shared
创建std::shared_ptr
可以避免引用计数的动态分配,但是还存在一些std::make_shared
不能使用的场景,这时候引用计数就会动态分配。
+- 引用计数的内存必须动态分配。 概念上,引用计数与所指对象关联起来,但是实际上被指向的对象不知道这件事情(译注:不知道有一个关联到自己的计数值)。因此它们没有办法存放一个引用计数值。(一个好消息是任何对象——甚至是内置类型的——都可以由
std::shared_ptr
管理。)Item21会解释使用std::make_shared
创建std::shared_ptr
可以避免引用计数的动态分配,但是还存在一些std::make_shared
不能使用的场景,这时候引用计数就会动态分配。
- 递增递减引用计数必须是原子性的,因为多个reader、writer可能在不同的线程。比如,指向某种资源的
std::shared_ptr
可能在一个线程执行析构(于是递减指向的对象的引用计数),在另一个不同的线程,std::shared_ptr
指向相同的对象,但是执行的却是拷贝操作(因此递增了同一个引用计数)。原子操作通常比非原子操作要慢,所以即使引用计数通常只有一个word大小,你也应该假定读写它们是存在开销的。
我写道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
会比拷贝它要快:拷贝要求递增引用计数值,移动不需要。移动赋值运算符同理,所以移动构造比拷贝构造快,移动赋值运算符也比拷贝赋值运算符快。
-类似std::unique_ptr
(参见Item18),std::shared_ptr
使用delete
作为资源的默认销毁机制,但是它也支持自定义的删除器。这种支持有别于std::unique_ptr
。对于std::unique_ptr
来说,删除器类型是智能指针类型的一部分。对于std::shared_ptr
则不是:
+类似std::unique_ptr
(参见Item18),std::shared_ptr
使用delete
作为资源的默认销毁机制,但是它也支持自定义的删除器。这种支持有别于std::unique_ptr
。对于std::unique_ptr
来说,删除器类型是智能指针类型的一部分。对于std::shared_ptr
则不是:
auto loggingDel = [](Widget *pw) //自定义删除器
{ //(和条款18一样)
makeLogEntry(pw);
@@ -2686,13 +2686,13 @@ std::shared_ptr<Widget> pw2(new Widget, customDeleter2);
它们也能相互赋值,也可以传入一个形参为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
创建者利用std::shared_ptr
对自定义分配器的支持能力,那部分内存随便在哪都行。我前面提到了std::shared_ptr
对象包含了所指对象的引用计数的指针。没错,但是有点误导人。因为引用计数是另一个更大的数据结构的一部分,那个数据结构通常叫做控制块(control block)。每个std::shared_ptr
管理的对象都有个相应的控制块。控制块除了包含引用计数值外还有一个自定义删除器的拷贝,当然前提是存在自定义删除器。如果用户还指定了自定义分配器,控制块也会包含一个分配器的拷贝。控制块可能还包含一些额外的数据,正如Item21提到的,一个次级引用计数weak count,但是目前我们先忽略它。我们可以想象std::shared_ptr
对象在内存中是这样:
+它不能。它必须使用更多的内存。然而,那部分内存不是std::shared_ptr
对象的一部分。那部分在堆上面,或者std::shared_ptr
创建者利用std::shared_ptr
对自定义分配器的支持能力,那部分内存随便在哪都行。我前面提到了std::shared_ptr
对象包含了所指对象的引用计数的指针。没错,但是有点误导人。因为引用计数是另一个更大的数据结构的一部分,那个数据结构通常叫做控制块(control block)。每个std::shared_ptr
管理的对象都有个相应的控制块。控制块除了包含引用计数值外还有一个自定义删除器的拷贝,当然前提是存在自定义删除器。如果用户还指定了自定义分配器,控制块也会包含一个分配器的拷贝。控制块可能还包含一些额外的数据,正如Item21提到的,一个次级引用计数weak count,但是目前我们先忽略它。我们可以想象std::shared_ptr
对象在内存中是这样:
当指向对象的std::shared_ptr
一创建,对象的控制块就建立了。至少我们期望是如此。通常,对于一个创建指向对象的std::shared_ptr
的函数来说不可能知道是否有其他std::shared_ptr
早已指向那个对象,所以控制块的创建会遵循下面几条规则:
-std::make_shared
(参见Item21)总是创建一个控制块。它创建一个要指向的新对象,所以可以肯定std::make_shared
调用时对象不存在其他控制块。
+std::make_shared
(参见Item21)总是创建一个控制块。它创建一个要指向的新对象,所以可以肯定std::make_shared
调用时对象不存在其他控制块。
- 当从独占指针(即
std::unique_ptr
或者std::auto_ptr
)上构造出std::shared_ptr
时会创建控制块。独占指针没有使用控制块,所以指针指向的对象没有关联控制块。(作为构造的一部分,std::shared_ptr
侵占独占指针所指向的对象的独占权,所以独占指针被设置为null)
-- 当从原始指针上构造出
std::shared_ptr
时会创建控制块。如果你想从一个早已存在控制块的对象上创建std::shared_ptr
,你将假定传递一个std::shared_ptr
或者std::weak_ptr
(参见Item20)作为构造函数实参,而不是原始指针。用std::shared_ptr
或者std::weak_ptr
作为构造函数实参创建std::shared_ptr
不会创建新控制块,因为它可以依赖传递来的智能指针指向控制块。
+- 当从原始指针上构造出
std::shared_ptr
时会创建控制块。如果你想从一个早已存在控制块的对象上创建std::shared_ptr
,你将假定传递一个std::shared_ptr
或者std::weak_ptr
(参见Item20)作为构造函数实参,而不是原始指针。用std::shared_ptr
或者std::weak_ptr
作为构造函数实参创建std::shared_ptr
不会创建新控制块,因为它可以依赖传递来的智能指针指向控制块。
这些规则造成的后果就是从原始指针上构造超过一个std::shared_ptr
就会让你走上未定义行为的快车道,因为指向的对象有多个控制块关联。多个控制块意味着多个引用计数值,多个引用计数值意味着对象将会被销毁多次(每个引用计数一次)。那意味着像下面的代码是有问题的,很有问题,问题很大:
auto pw = new Widget; //pw是原始指针
@@ -2701,9 +2701,9 @@ std::shared_ptr<Widget> spw1(pw, loggingDel); //为*pw创建控制块
…
std::shared_ptr<Widget> spw2(pw, loggingDel); //为*pw创建第二个控制块
-创建原始指针pw
指向动态分配的对象很糟糕,因为它完全背离了这章的建议:倾向于使用智能指针而不是原始指针。(如果你忘记了该建议的动机,请翻到本章开头)。撇开那个不说,创建pw
那一行代码虽然让人厌恶,但是至少不会造成未定义程序行为。
+创建原始指针pw
指向动态分配的对象很糟糕,因为它完全背离了这章的建议:倾向于使用智能指针而不是原始指针。(如果你忘记了该建议的动机,请翻到本章开头)。撇开那个不说,创建pw
那一行代码虽然让人厌恶,但是至少不会造成未定义程序行为。
现在,传给spw1
的构造函数一个原始指针,它会为指向的对象创建一个控制块(因此有个引用计数值)。这种情况下,指向的对象是*pw
(即pw
指向的对象)。就其本身而言没什么问题,但是将同样的原始指针传递给spw2
的构造函数会再次为*pw
创建一个控制块(所以也有个引用计数值)。因此*pw
有两个引用计数值,每一个最后都会变成零,然后最终导致*pw
销毁两次。第二个销毁会产生未定义行为。
-std::shared_ptr
给我们上了两堂课。第一,避免传给std::shared_ptr
构造函数原始指针。通常替代方案是使用std::make_shared
(参见Item21),不过上面例子中,我们使用了自定义删除器,用std::make_shared
就没办法做到。第二,如果你必须传给std::shared_ptr
构造函数原始指针,直接传new
出来的结果,不要传指针变量。如果上面代码第一部分这样重写:
+std::shared_ptr
给我们上了两堂课。第一,避免传给std::shared_ptr
构造函数原始指针。通常替代方案是使用std::make_shared
(参见Item21),不过上面例子中,我们使用了自定义删除器,用std::make_shared
就没办法做到。第二,如果你必须传给std::shared_ptr
构造函数原始指针,直接传new
出来的结果,不要传指针变量。如果上面代码第一部分这样重写:
std::shared_ptr<Widget> spw1(new Widget, //直接使用new的结果
loggingDel);
@@ -2728,7 +2728,7 @@ public:
processedWidgets.emplace_back(this); //然后将它加到已处理过的Widget
} //的列表中,这是错的!
-注释已经说了这是错的——或者至少大部分是错的。(错误的部分是传递this
,而不是使用了emplace_back
。如果你不熟悉emplace_back
,参见Item42)。上面的代码可以通过编译,但是向std::shared_ptr
的容器传递一个原始指针(this
),std::shared_ptr
会由此为指向的Widget
(*this
)创建一个控制块。那看起来没什么问题,直到你意识到如果成员函数外面早已存在指向那个Widget
对象的指针,它是未定义行为的Game, Set, and Match(译注:一部关于网球的电影,但是译者没看过。句子本意“压倒性胜利;比赛结束”)。
+注释已经说了这是错的——或者至少大部分是错的。(错误的部分是传递this
,而不是使用了emplace_back
。如果你不熟悉emplace_back
,参见Item42)。上面的代码可以通过编译,但是向std::shared_ptr
的容器传递一个原始指针(this
),std::shared_ptr
会由此为指向的Widget
(*this
)创建一个控制块。那看起来没什么问题,直到你意识到如果成员函数外面早已存在指向那个Widget
对象的指针,它是未定义行为的Game, Set, and Match(译注:一部关于网球的电影,但是译者没看过。句子本意“压倒性胜利;比赛结束”)。
std::shared_ptr
API已有处理这种情况的设施。它的名字可能是C++标准库中最奇怪的一个:std::enable_shared_from_this
。如果你想创建一个用std::shared_ptr
管理的类,这个类能够用this
指针安全地创建一个std::shared_ptr
,std::enable_shared_from_this
就可作为基类的模板类。在我们的例子中,Widget
将会继承自std::enable_shared_from_this
:
class Widget: public std::enable_shared_from_this<Widget> {
public:
@@ -2763,7 +2763,7 @@ private:
现在,你可能隐约记得我们讨论控制块的动机是想了解有关std::shared_ptr
的成本。既然我们已经知道了怎么避免创建过多控制块,就让我们回到原来的主题。
控制块通常只占几个word大小,自定义删除器和分配器可能会让它变大一点。通常控制块的实现比你想的更复杂一些。它使用继承,甚至里面还有一个虚函数(用来确保指向的对象被正确销毁)。这意味着使用std::shared_ptr
还会招致控制块使用虚函数带来的成本。
-了解了动态分配控制块,任意大小的删除器和分配器,虚函数机制,原子性的引用计数修改,你对于std::shared_ptr
的热情可能有点消退。可以理解,对每个资源管理问题来说都没有最佳的解决方案。但就它提供的功能来说,std::shared_ptr
的开销是非常合理的。在通常情况下,使用默认删除器和默认分配器,使用std::make_shared
创建std::shared_ptr
,产生的控制块只需三个word大小。它的分配基本上是无开销的。(开销被并入了指向的对象的分配成本里。细节参见Item21)。对std::shared_ptr
解引用的开销不会比原始指针高。执行需要原子引用计数修改的操作需要承担一两个原子操作开销,这些操作通常都会一一映射到机器指令上,所以即使对比非原子指令来说,原子指令开销较大,但是它们仍然只是单个指令上的。对于每个被std::shared_ptr
指向的对象来说,控制块中的虚函数机制产生的开销通常只需要承受一次,即对象销毁的时候。
+了解了动态分配控制块,任意大小的删除器和分配器,虚函数机制,原子性的引用计数修改,你对于std::shared_ptr
的热情可能有点消退。可以理解,对每个资源管理问题来说都没有最佳的解决方案。但就它提供的功能来说,std::shared_ptr
的开销是非常合理的。在通常情况下,使用默认删除器和默认分配器,使用std::make_shared
创建std::shared_ptr
,产生的控制块只需三个word大小。它的分配基本上是无开销的。(开销被并入了指向的对象的分配成本里。细节参见Item21)。对std::shared_ptr
解引用的开销不会比原始指针高。执行需要原子引用计数修改的操作需要承担一两个原子操作开销,这些操作通常都会一一映射到机器指令上,所以即使对比非原子指令来说,原子指令开销较大,但是它们仍然只是单个指令上的。对于每个被std::shared_ptr
指向的对象来说,控制块中的虚函数机制产生的开销通常只需要承受一次,即对象销毁的时候。
作为这些轻微开销的交换,你得到了动态分配的资源的生命周期自动管理的好处。大多数时候,比起手动管理,使用std::shared_ptr
管理共享性资源都是非常合适的。如果你还在犹豫是否能承受std::shared_ptr
带来的开销,那就再想想你是否需要共享所有权。如果独占资源可行或者可能可行,用std::unique_ptr
是一个更好的选择。它的性能表现更接近于原始指针,并且从std::unique_ptr
升级到std::shared_ptr
也很容易,因为std::shared_ptr
可以从std::unique_ptr
上创建。
反之不行。当你的资源由std::shared_ptr
管理,现在又想修改资源生命周期管理方式是没有办法的。即使引用计数为一,你也不能重新修改资源所有权,改用std::unique_ptr
管理它。资源和指向它的std::shared_ptr
的签订的所有权协议是“除非死亡否则永不分开”。不能分离,不能废除,没有特许。
std::shared_ptr
不能处理的另一个东西是数组。和std::unique_ptr
不同的是,std::shared_ptr
的API设计之初就是针对单个对象的,没有办法std::shared_ptr<T[]>
。一次又一次,“聪明”的程序员踌躇于是否该使用std::shared_ptr<T>
指向数组,然后传入自定义删除器来删除数组(即delete []
)。这可以通过编译,但是是一个糟糕的主意。一方面,std::shared_ptr
没有提供operator[]
,所以数组索引操作需要借助怪异的指针算术。另一方面,std::shared_ptr
支持转换为指向基类的指针,这对于单个对象来说有效,但是当用于数组类型时相当于在类型系统上开洞。(出于这个原因,std::unique_ptr<T[]>
API禁止这种转换。)更重要的是,C++11已经提供了很多内置数组的候选方案(比如std::array
,std::vector
,std::string
)。声明一个指向傻瓜数组的智能指针(译注:也是”聪明的指针“之意)几乎总是表示着糟糕的设计。
@@ -2776,7 +2776,7 @@ private:
Item 20: Use std::weak_ptr
for std::shared_ptr
-like pointers that can dangle
-自相矛盾的是,如果有一个像std::shared_ptr
(见Item19)的但是不参与资源所有权共享的指针是很方便的。换句话说,是一个类似std::shared_ptr
但不影响对象引用计数的指针。这种类型的智能指针必须要解决一个std::shared_ptr
不存在的问题:可能指向已经销毁的对象。一个真正的智能指针应该跟踪所指对象,在悬空时知晓,悬空(dangle)就是指针指向的对象不再存在。这就是对std::weak_ptr
最精确的描述。
+自相矛盾的是,如果有一个像std::shared_ptr
(见Item19)的但是不参与资源所有权共享的指针是很方便的。换句话说,是一个类似std::shared_ptr
但不影响对象引用计数的指针。这种类型的智能指针必须要解决一个std::shared_ptr
不存在的问题:可能指向已经销毁的对象。一个真正的智能指针应该跟踪所指对象,在悬空时知晓,悬空(dangle)就是指针指向的对象不再存在。这就是对std::weak_ptr
最精确的描述。
你可能想知道什么时候该用std::weak_ptr
。你可能想知道关于std::weak_ptr
API的更多。它什么都好除了不太智能。std::weak_ptr
不能解引用,也不能测试是否为空值。因为std::weak_ptr
不是一个独立的智能指针。它是std::shared_ptr
的增强。
这种关系在它创建之时就建立了。std::weak_ptr
通常从std::shared_ptr
上创建。当从std::shared_ptr
上创建std::weak_ptr
时两者指向相同的对象,但是std::weak_ptr
不会影响所指对象的引用计数:
auto spw = //spw创建之后,指向的Widget的
@@ -2800,7 +2800,7 @@ auto spw2 = wpw.lock(); //同上,但是使用auto
另一种形式是以std::weak_ptr
为实参构造std::shared_ptr
。这种情况中,如果std::weak_ptr
过期,会抛出一个异常:
std::shared_ptr<Widget> spw3(wpw); //如果wpw过期,抛出std::bad_weak_ptr异常
-但是你可能还想知道为什么std::weak_ptr
就有用了。考虑一个工厂函数,它基于一个唯一ID从只读对象上产出智能指针。根据Item18的描述,工厂函数会返回一个该对象类型的std::unique_ptr
:
+但是你可能还想知道为什么std::weak_ptr
就有用了。考虑一个工厂函数,它基于一个唯一ID从只读对象上产出智能指针。根据Item18的描述,工厂函数会返回一个该对象类型的std::unique_ptr
:
std::unique_ptr<const Widget> loadWidget(WidgetID id);
如果调用loadWidget
是一个昂贵的操作(比如它操作文件或者数据库I/O)并且重复使用ID很常见,一个合理的优化是再写一个函数除了完成loadWidget
做的事情之外再缓存它的结果。当每个请求获取的Widget
阻塞了缓存也会导致本身性能问题,所以另一个合理的优化可以是当Widget
不再使用的时候销毁它的缓存。
@@ -2836,7 +2836,7 @@ auto spw2 = wpw.lock(); //同上,但是使用auto
使用std::weak_ptr
显然是这些选择中最好的。但是,需要注意使用std::weak_ptr
打破std::shared_ptr
循环并不常见。在严格分层的数据结构比如树中,子节点只被父节点持有。当父节点被销毁时,子节点就被销毁。从父到子的链接关系可以使用std::unique_ptr
很好的表征。从子到父的反向连接可以使用原始指针安全实现,因为子节点的生命周期肯定短于父节点。因此没有子节点解引用一个悬垂的父节点指针这样的风险。
当然,不是所有的使用指针的数据结构都是严格分层的,所以当发生这种情况时,比如上面所述缓存和观察者列表的实现之类的,知道std::weak_ptr
随时待命也是不错的。
-从效率角度来看,std::weak_ptr
与std::shared_ptr
基本相同。两者的大小是相同的,使用相同的控制块(参见Item19),构造、析构、赋值操作涉及引用计数的原子操作。这可能让你感到惊讶,因为本条款开篇就提到std::weak_ptr
不影响引用计数。我写的是std::weak_ptr
不参与对象的共享所有权,因此不影响指向对象的引用计数。实际上在控制块中还是有第二个引用计数,std::weak_ptr
操作的是第二个引用计数。想了解细节的话,继续看Item21吧。
+从效率角度来看,std::weak_ptr
与std::shared_ptr
基本相同。两者的大小是相同的,使用相同的控制块(参见Item19),构造、析构、赋值操作涉及引用计数的原子操作。这可能让你感到惊讶,因为本条款开篇就提到std::weak_ptr
不影响引用计数。我写的是std::weak_ptr
不参与对象的共享所有权,因此不影响指向对象的引用计数。实际上在控制块中还是有第二个引用计数,std::weak_ptr
操作的是第二个引用计数。想了解细节的话,继续看Item21吧。
请记住:
- 用
std::weak_ptr
替代可能会悬空的std::shared_ptr
。
@@ -2851,7 +2851,7 @@ std::unique_ptr<T> make_unique(Ts&&... params)
return std::unique_ptr<T>(new T(std::forward<Ts>(params)...));
}
-正如你看到的,make_unique
只是将它的参数完美转发到所要创建的对象的构造函数,从new
产生的原始指针里面构造出std::unique_ptr
,并返回这个std::unique_ptr
。这种形式的函数不支持数组和自定义析构(见Item18),但它给出了一个示范:只需一点努力就能写出你想要的make_unique
函数。(要想实现一个特性完备的make_unique
,就去找提供这个的标准化文件吧,然后拷贝那个实现。你想要的这个文件是N3656,是Stephan T. Lavavej写于2013-04-18的文档。)需要记住的是,不要把它放到std
命名空间中,因为你可能并不希望看到升级C++14标准库的时候你放进std
命名空间的内容和编译器供应商提供的std
命名空间的内容发生冲突。
+正如你看到的,make_unique
只是将它的参数完美转发到所要创建的对象的构造函数,从new
产生的原始指针里面构造出std::unique_ptr
,并返回这个std::unique_ptr
。这种形式的函数不支持数组和自定义析构(见Item18),但它给出了一个示范:只需一点努力就能写出你想要的make_unique
函数。(要想实现一个特性完备的make_unique
,就去找提供这个的标准化文件吧,然后拷贝那个实现。你想要的这个文件是N3656,是Stephan T. Lavavej写于2013-04-18的文档。)需要记住的是,不要把它放到std
命名空间中,因为你可能并不希望看到升级C++14标准库的时候你放进std
命名空间的内容和编译器供应商提供的std
命名空间的内容发生冲突。
std::make_unique
和std::make_shared
是三个make函数 中的两个:接收任意的多参数集合,完美转发到构造函数去动态分配一个对象,然后返回这个指向这个对象的指针。第三个make
函数是std::allocate_shared
。它行为和std::make_shared
一样,只不过第一个参数是用来动态分配内存的allocator对象。
即使通过用和不用make
函数来创建智能指针的一个小小比较,也揭示了为何使用make
函数更好的第一个原因。例如:
auto upw1(std::make_unique<Widget>()); //使用make函数
@@ -2863,7 +2863,7 @@ std::shared_ptr<Widget> spw2(new Widget); //不使用make函数
第二个使用make
函数的原因和异常安全有关。假设我们有个函数按照某种优先级处理Widget
:
void processWidget(std::shared_ptr<Widget> spw, int priority);
-值传递std::shared_ptr
可能看起来很可疑,但是Item41解释了,如果processWidget
总是复制std::shared_ptr
(例如,通过将其存储在已处理的Widget
的一个数据结构中),那么这可能是一个合理的设计选择。
+值传递std::shared_ptr
可能看起来很可疑,但是Item41解释了,如果processWidget
总是复制std::shared_ptr
(例如,通过将其存储在已处理的Widget
的一个数据结构中),那么这可能是一个合理的设计选择。
现在假设我们有一个函数来计算相关的优先级,
int computePriority();
@@ -2894,14 +2894,14 @@ std::shared_ptr<Widget> spw2(new Widget); //不使用make函数
std::make_shared
的一个特性(与直接使用new
相比)是效率提升。使用std::make_shared
允许编译器生成更小,更快的代码,并使用更简洁的数据结构。考虑以下对new的直接使用:
std::shared_ptr<Widget> spw(new Widget);
-显然,这段代码需要进行内存分配,但它实际上执行了两次。Item19解释了每个std::shared_ptr
指向一个控制块,其中包含被指向对象的引用计数,还有其他东西。这个控制块的内存在std::shared_ptr
构造函数中分配。因此,直接使用new
需要为Widget
进行一次内存分配,为控制块再进行一次内存分配。
+显然,这段代码需要进行内存分配,但它实际上执行了两次。Item19解释了每个std::shared_ptr
指向一个控制块,其中包含被指向对象的引用计数,还有其他东西。这个控制块的内存在std::shared_ptr
构造函数中分配。因此,直接使用new
需要为Widget
进行一次内存分配,为控制块再进行一次内存分配。
如果使用std::make_shared
代替:
auto spw = std::make_shared<Widget>();
一次分配足矣。这是因为std::make_shared
分配一块内存,同时容纳了Widget
对象和控制块。这种优化减少了程序的静态大小,因为代码只包含一个内存分配调用,并且它提高了可执行代码的速度,因为内存只分配一次。此外,使用std::make_shared
避免了对控制块中的某些簿记信息的需要,潜在地减少了程序的总内存占用。
对于std::make_shared
的效率分析同样适用于std::allocate_shared
,因此std::make_shared
的性能优势也扩展到了该函数。
更倾向于使用make
函数而不是直接使用new
的争论非常激烈。尽管它们在软件工程、异常安全和效率方面具有优势,但本条款的建议是,更倾向于使用make
函数,而不是完全依赖于它们。这是因为有些情况下它们不能或不应该被使用。
-例如,make
函数都不允许指定自定义删除器(见Item18和19),但是std::unique_ptr
和std::shared_ptr
有构造函数这么做。有个Widget
的自定义删除器:
+例如,make
函数都不允许指定自定义删除器(见Item18和19),但是std::unique_ptr
和std::shared_ptr
有构造函数这么做。有个Widget
的自定义删除器:
auto widgetDeleter = [](Widget* pw) { … };
创建一个使用它的智能指针只能直接使用new
:
@@ -2911,12 +2911,12 @@ std::shared_ptr<Widget> spw2(new Widget); //不使用make函数
std::shared_ptr<Widget> spw(new Widget, widgetDeleter);
对于make
函数,没有办法做同样的事情。
-make
函数第二个限制来自于其实现中的语法细节。Item7解释了,当构造函数重载,有使用std::initializer_list
作为参数的重载形式和不用其作为参数的的重载形式,用花括号创建的对象更倾向于使用std::initializer_list
作为形参的重载形式,而用小括号创建对象将调用不用std::initializer_list
作为参数的的重载形式。make
函数会将它们的参数完美转发给对象构造函数,但是它们是使用小括号还是花括号?对某些类型,问题的答案会很不相同。例如,在这些调用中,
+make
函数第二个限制来自于其实现中的语法细节。Item7解释了,当构造函数重载,有使用std::initializer_list
作为参数的重载形式和不用其作为参数的的重载形式,用花括号创建的对象更倾向于使用std::initializer_list
作为形参的重载形式,而用小括号创建对象将调用不用std::initializer_list
作为参数的的重载形式。make
函数会将它们的参数完美转发给对象构造函数,但是它们是使用小括号还是花括号?对某些类型,问题的答案会很不相同。例如,在这些调用中,
auto upv = std::make_unique<std::vector<int>>(10, 20);
auto spv = std::make_shared<std::vector<int>>(10, 20);
生成的智能指针指向带有10个元素的std::vector
,每个元素值为20,还是指向带有两个元素的std::vector
,其中一个元素值10,另一个为20?或者结果是不确定的?
-好消息是这并非不确定:两种调用都创建了10个元素,每个值为20的std::vector
。这意味着在make
函数中,完美转发使用小括号,而不是花括号。坏消息是如果你想用花括号初始化指向的对象,你必须直接使用new
。使用make
函数会需要能够完美转发花括号初始化的能力,但是,正如Item30所说,花括号初始化无法完美转发。但是,Item30介绍了一个变通的方法:使用auto
类型推导从花括号初始化创建std::initializer_list
对象(见Item2),然后将auto
创建的对象传递给make
函数。
+好消息是这并非不确定:两种调用都创建了10个元素,每个值为20的std::vector
。这意味着在make
函数中,完美转发使用小括号,而不是花括号。坏消息是如果你想用花括号初始化指向的对象,你必须直接使用new
。使用make
函数会需要能够完美转发花括号初始化的能力,但是,正如Item30所说,花括号初始化无法完美转发。但是,Item30介绍了一个变通的方法:使用auto
类型推导从花括号初始化创建std::initializer_list
对象(见Item2),然后将auto
创建的对象传递给make
函数。
//创建std::initializer_list
auto initList = { 10, 20 };
//使用std::initializer_list为形参的构造函数创建std::vector
@@ -2925,7 +2925,7 @@ auto spv = std::make_shared<std::vector<int>>(initList);
对于std::unique_ptr
,只有这两种情景(自定义删除器和花括号初始化)使用make
函数有点问题。对于std::shared_ptr
和它的make
函数,还有2个问题。都属于边缘情况,但是一些开发者常碰到,你也可能是其中之一。
一些类重载了operator new
和operator delete
。这些函数的存在意味着对这些类型的对象的全局内存分配和释放是不合常规的。设计这种定制操作往往只会精确的分配、释放对象大小的内存。例如,Widget
类的operator new
和operator delete
只会处理sizeof(Widget)
大小的内存块的分配和释放。这种系列行为不太适用于std::shared_ptr
对自定义分配(通过std::allocate_shared
)和释放(通过自定义删除器)的支持,因为std::allocate_shared
需要的内存总大小不等于动态分配的对象大小,还需要再加上控制块大小。因此,使用make
函数去创建重载了operator new
和operator delete
类的对象是个典型的糟糕想法。
与直接使用new
相比,std::make_shared
在大小和速度上的优势源于std::shared_ptr
的控制块与指向的对象放在同一块内存中。当对象的引用计数降为0,对象被销毁(即析构函数被调用)。但是,因为控制块和对象被放在同一块分配的内存块中,直到控制块的内存也被销毁,对象占用的内存才被释放。
-正如我说,控制块除了引用计数,还包含簿记信息。引用计数追踪有多少std::shared_ptr
s指向控制块,但控制块还有第二个计数,记录多少个std::weak_ptr
s指向控制块。第二个引用计数就是weak count。(实际上,weak count的值不总是等于指向控制块的std::weak_ptr
的数目,因为库的实现者找到一些方法在weak count中添加附加信息,促进更好的代码产生。为了本条款的目的,我们会忽略这一点,假定weak count的值等于指向控制块的std::weak_ptr
的数目。)当一个std::weak_ptr
检测它是否过期时(见Item19),它会检测指向的控制块中的引用计数(而不是weak count)。如果引用计数是0(即对象没有std::shared_ptr
再指向它,已经被销毁了),std::weak_ptr
就已经过期。否则就没过期。
+正如我说,控制块除了引用计数,还包含簿记信息。引用计数追踪有多少std::shared_ptr
s指向控制块,但控制块还有第二个计数,记录多少个std::weak_ptr
s指向控制块。第二个引用计数就是weak count。(实际上,weak count的值不总是等于指向控制块的std::weak_ptr
的数目,因为库的实现者找到一些方法在weak count中添加附加信息,促进更好的代码产生。为了本条款的目的,我们会忽略这一点,假定weak count的值等于指向控制块的std::weak_ptr
的数目。)当一个std::weak_ptr
检测它是否过期时(见Item19),它会检测指向的控制块中的引用计数(而不是weak count)。如果引用计数是0(即对象没有std::shared_ptr
再指向它,已经被销毁了),std::weak_ptr
就已经过期。否则就没过期。
只要std::weak_ptr
s引用一个控制块(即weak count大于零),该控制块必须继续存在。只要控制块存在,包含它的内存就必须保持分配。通过std::shared_ptr
的make
函数分配的内存,直到最后一个std::shared_ptr
和最后一个指向它的std::weak_ptr
已被销毁,才会释放。
如果对象类型非常大,而且销毁最后一个std::shared_ptr
和销毁最后一个std::weak_ptr
之间的时间很长,那么在销毁对象和释放它所占用的内存之间可能会出现延迟。
class ReallyBigType { … };
@@ -2989,7 +2989,7 @@ processWidget(spw, computePriority()); // 正确,但是没优化,见下
但是在异常安全调用中,我们传递了左值:
processWidget(spw, computePriority()); //实参是左值
-因为processWidget
的std::shared_ptr
形参是传值,从右值构造只需要移动,而传递左值构造需要拷贝。对std::shared_ptr
而言,这种区别是有意义的,因为拷贝std::shared_ptr
需要对引用计数原子递增,移动则不需要对引用计数有操作。为了使异常安全代码达到非异常安全代码的性能水平,我们需要用std::move
将spw
转换为右值(见Item23):
+因为processWidget
的std::shared_ptr
形参是传值,从右值构造只需要移动,而传递左值构造需要拷贝。对std::shared_ptr
而言,这种区别是有意义的,因为拷贝std::shared_ptr
需要对引用计数原子递增,移动则不需要对引用计数有操作。为了使异常安全代码达到非异常安全代码的性能水平,我们需要用std::move
将spw
转换为右值(见Item23):
processWidget(std::move(spw), computePriority()); //高效且异常安全
这很有趣,也值得了解,但通常是无关紧要的,因为您很少有理由不使用make
函数。除非你有令人信服的理由这样做,否则你应该使用make
函数。
@@ -3048,7 +3048,7 @@ Widget::~Widget() //销毁数据成员
{ delete pImpl; }
在这里我把#include
命令写出来是为了明确一点,对于std::string
,std::vector
和Gadget
的头文件的整体依赖依然存在。 然而,这些依赖从头文件widget.h
(它被所有Widget
类的使用者包含,并且对他们可见)移动到了widget.cpp
(该文件只被Widget
类的实现者包含,并只对他可见)。 我高亮了其中动态分配和回收Impl
对象的部分(译者注:markdown高亮不了,实际高亮的是new Impl
和delete pImpl;
两个语句)。这就是为什么我们需要Widget
的析构函数——我们需要Widget
被销毁时回收该对象。
-但是,我展示给你们看的是一段C++98的代码,散发着一股已经过去了几千年的腐朽气息。 它使用了原始指针,原始的new
和原始的delete
,一切都让它如此的...原始。这一章建立在“智能指针比原始指针更好”的主题上,并且,如果我们想要的只是在类Widget
的构造函数动态分配Widget::impl
对象,在Widget
对象销毁时一并销毁它, std::unique_ptr
(见Item18)是最合适的工具。在头文件中用std::unique_ptr
替代原始指针,就有了头文件中如下代码:
+但是,我展示给你们看的是一段C++98的代码,散发着一股已经过去了几千年的腐朽气息。 它使用了原始指针,原始的new
和原始的delete
,一切都让它如此的...原始。这一章建立在“智能指针比原始指针更好”的主题上,并且,如果我们想要的只是在类Widget
的构造函数动态分配Widget::impl
对象,在Widget
对象销毁时一并销毁它, std::unique_ptr
(见Item18)是最合适的工具。在头文件中用std::unique_ptr
替代原始指针,就有了头文件中如下代码:
class Widget { //在“widget.h”中
public:
Widget();
@@ -3083,7 +3083,7 @@ Widget w; //错误!
你所看到的错误信息根据编译器不同会有所不同,但是其文本一般会提到一些有关于“把sizeof
或delete
应用到未完成类型上”的信息。对于未完成类型,使用以上操作是禁止的。
在Pimpl惯用法中使用std::unique_ptr
会抛出错误,有点惊悚,因为第一std::unique_ptr
宣称它支持未完成类型,第二Pimpl惯用法是std::unique_ptr
的最常见的使用情况之一。 幸运的是,让这段代码能正常运行很简单。 只需要对上面出现的问题的原因有一个基础的认识就可以了。
-在对象w
被析构时(例如离开了作用域),问题出现了。在这个时候,它的析构函数被调用。我们在类的定义里使用了std::unique_ptr
,所以我们没有声明一个析构函数,因为我们并没有任何代码需要写在里面。根据编译器自动生成的特殊成员函数的规则(见 Item17),编译器会自动为我们生成一个析构函数。 在这个析构函数里,编译器会插入一些代码来调用类Widget
的数据成员pImpl
的析构函数。 pImpl
是一个std::unique_ptr<Widget::Impl>
,也就是说,一个使用默认删除器的std::unique_ptr
。 默认删除器是一个函数,它使用delete
来销毁内置于std::unique_ptr
的原始指针。然而,在使用delete
之前,通常会使默认删除器使用C++11的特性static_assert
来确保原始指针指向的类型不是一个未完成类型。 当编译器为Widget w
的析构生成代码时,它会遇到static_assert
检查并且失败,这通常是错误信息的来源。 这些错误信息只在对象w
销毁的地方出现,因为类Widget
的析构函数,正如其他的编译器生成的特殊成员函数一样,是暗含inline
属性的。 错误信息自身往往指向对象w
被创建的那行,因为这行代码明确地构造了这个对象,导致了后面潜在的析构。
+在对象w
被析构时(例如离开了作用域),问题出现了。在这个时候,它的析构函数被调用。我们在类的定义里使用了std::unique_ptr
,所以我们没有声明一个析构函数,因为我们并没有任何代码需要写在里面。根据编译器自动生成的特殊成员函数的规则(见 Item17),编译器会自动为我们生成一个析构函数。 在这个析构函数里,编译器会插入一些代码来调用类Widget
的数据成员pImpl
的析构函数。 pImpl
是一个std::unique_ptr<Widget::Impl>
,也就是说,一个使用默认删除器的std::unique_ptr
。 默认删除器是一个函数,它使用delete
来销毁内置于std::unique_ptr
的原始指针。然而,在使用delete
之前,通常会使默认删除器使用C++11的特性static_assert
来确保原始指针指向的类型不是一个未完成类型。 当编译器为Widget w
的析构生成代码时,它会遇到static_assert
检查并且失败,这通常是错误信息的来源。 这些错误信息只在对象w
销毁的地方出现,因为类Widget
的析构函数,正如其他的编译器生成的特殊成员函数一样,是暗含inline
属性的。 错误信息自身往往指向对象w
被创建的那行,因为这行代码明确地构造了这个对象,导致了后面潜在的析构。
为了解决这个问题,你只需要确保在编译器生成销毁std::unique_ptr<Widget::Impl>
的代码之前, Widget::Impl
已经是一个完成类型(complete type)。 当编译器“看到”它的定义的时候,该类型就成为完成类型了。 但是 Widget::Impl
的定义在widget.cpp
里。成功编译的关键,就是在widget.cpp
文件内,让编译器在“看到” Widget
的析构函数实现之前(也即编译器插入的,用来销毁std::unique_ptr
这个数据成员的代码的,那个位置),先定义Widget::Impl
。
做出这样的调整很容易。只需要先在widget.h
里,只声明类Widget
的析构函数,但不要在这里定义它:
class Widget { //跟之前一样,在“widget.h”中
@@ -3119,7 +3119,7 @@ Widget::~Widget() //析构函数的定义(译者注:这里
这样就可以了,并且这样增加的代码也最少,你声明Widget
析构函数只是为了在 Widget 的实现文件中(译者注:指widget.cpp
)写出它的定义,但是如果你想强调编译器自动生成的析构函数会做和你一样正确的事情,你可以直接使用“= default
”定义析构函数体
Widget::~Widget() = default; //同上述代码效果一致
-使用了Pimpl惯用法的类自然适合支持移动操作,因为编译器自动生成的移动操作正合我们所意:对其中的std::unique_ptr
进行移动。 正如Item17所解释的那样,声明一个类Widget
的析构函数会阻止编译器生成移动操作,所以如果你想要支持移动操作,你必须自己声明相关的函数。考虑到编译器自动生成的版本会正常运行,你可能会很想按如下方式实现它们:
+使用了Pimpl惯用法的类自然适合支持移动操作,因为编译器自动生成的移动操作正合我们所意:对其中的std::unique_ptr
进行移动。 正如Item17所解释的那样,声明一个类Widget
的析构函数会阻止编译器生成移动操作,所以如果你想要支持移动操作,你必须自己声明相关的函数。考虑到编译器自动生成的版本会正常运行,你可能会很想按如下方式实现它们:
class Widget { //仍然在“widget.h”中
public:
Widget();
@@ -3195,7 +3195,7 @@ Widget& Widget::operator=(const Widget& rhs) //拷贝operator=
return *this;
}
-两个函数的实现都比较中规中矩。 在每个情况中,我们都只从源对象(rhs
)中,复制了结构体Impl
的内容到目标对象中(*this
)。我们利用了编译器会为我们自动生成结构体Impl
的复制操作函数的机制,而不是逐一复制结构体Impl
的成员,自动生成的复制操作能自动复制每一个成员。 因此我们通过调用编译器生成的Widget::Impl
的复制操作函数来实现了类Widget
的复制操作。 在复制构造函数中,注意,我们仍然遵从了Item21的建议,使用std::make_unique
而非直接使用new
。
+两个函数的实现都比较中规中矩。 在每个情况中,我们都只从源对象(rhs
)中,复制了结构体Impl
的内容到目标对象中(*this
)。我们利用了编译器会为我们自动生成结构体Impl
的复制操作函数的机制,而不是逐一复制结构体Impl
的成员,自动生成的复制操作能自动复制每一个成员。 因此我们通过调用编译器生成的Widget::Impl
的复制操作函数来实现了类Widget
的复制操作。 在复制构造函数中,注意,我们仍然遵从了Item21的建议,使用std::make_unique
而非直接使用new
。
为了实现Pimpl惯用法,std::unique_ptr
是我们使用的智能指针,因为位于对象内部的pImpl
指针(例如,在类Widget
内部),对所指向的对应实现的对象的享有独占所有权。然而,有趣的是,如果我们使用std::shared_ptr
而不是std::unique_ptr
来做pImpl
指针, 我们会发现本条款的建议不再适用。 我们不需要在类Widget
里声明析构函数,没有了用户定义析构函数,编译器将会愉快地生成移动操作,并且将会如我们所期望般工作。widget.h
里的代码如下,
class Widget { //在“widget.h”中
public:
@@ -3254,9 +3254,9 @@ move(T&& param)
return static_cast<ReturnType>(param);
}
-我为你们高亮了这段代码的两部分(译者注:高亮的部分为函数名move
和static_cast<ReturnType>(param)
)。一个是函数名字,因为函数的返回值非常具有干扰性,而且我不想你们被它搞得晕头转向。另外一个高亮的部分是包含这段函数的本质的转换。正如你所见,std::move
接受一个对象的引用(准确的说,一个通用引用(universal reference),见Item24),返回一个指向同对象的引用。
-该函数返回类型的&&
部分表明std::move
函数返回的是一个右值引用,但是,正如Item28所解释的那样,如果类型T
恰好是一个左值引用,那么T&&
将会成为一个左值引用。为了避免如此,type trait(见Item9)std::remove_reference
应用到了类型T
上,因此确保了&&
被正确的应用到了一个不是引用的类型上。这保证了std::move
返回的真的是右值引用,这很重要,因为函数返回的右值引用是右值。因此,std::move
将它的实参转换为一个右值,这就是它的全部作用。
-此外,std::move
在C++14中可以被更简单地实现。多亏了函数返回值类型推导(见Item3)和标准库的模板别名std::remove_reference_t
(见Item9),std::move
可以这样写:
+我为你们高亮了这段代码的两部分(译者注:高亮的部分为函数名move
和static_cast<ReturnType>(param)
)。一个是函数名字,因为函数的返回值非常具有干扰性,而且我不想你们被它搞得晕头转向。另外一个高亮的部分是包含这段函数的本质的转换。正如你所见,std::move
接受一个对象的引用(准确的说,一个通用引用(universal reference),见Item24),返回一个指向同对象的引用。
+该函数返回类型的&&
部分表明std::move
函数返回的是一个右值引用,但是,正如Item28所解释的那样,如果类型T
恰好是一个左值引用,那么T&&
将会成为一个左值引用。为了避免如此,type trait(见Item9)std::remove_reference
应用到了类型T
上,因此确保了&&
被正确的应用到了一个不是引用的类型上。这保证了std::move
返回的真的是右值引用,这很重要,因为函数返回的右值引用是右值。因此,std::move
将它的实参转换为一个右值,这就是它的全部作用。
+此外,std::move
在C++14中可以被更简单地实现。多亏了函数返回值类型推导(见Item3)和标准库的模板别名std::remove_reference_t
(见Item9),std::move
可以这样写:
template<typename T>
decltype(auto) move(T&& param) //C++14,仍然在std命名空间
{
@@ -3267,7 +3267,7 @@ decltype(auto) move(T&& param) //C++14,仍然在std命名空
看起来更简单,不是吗?
因为std::move
除了转换它的实参到右值以外什么也不做,有一些提议说它的名字叫rvalue_cast
之类可能会更好。虽然可能确实是这样,但是它的名字已经是std::move
,所以记住std::move
做什么和不做什么很重要。它只进行转换,不移动任何东西。
当然,右值本来就是移动操作的候选者,所以对一个对象使用std::move
就是告诉编译器,这个对象很适合被移动。所以这就是为什么std::move
叫现在的名字:更容易指定可以被移动的对象。
-事实上,右值只不过经常是移动操作的候选者。假设你有一个类,它用来表示一段注解。这个类的构造函数接受一个包含有注解的std::string
作为形参,然后它复制该形参到数据成员。假设你了解Item41,你声明一个值传递的形参:
+事实上,右值只不过经常是移动操作的候选者。假设你有一个类,它用来表示一段注解。这个类的构造函数接受一个包含有注解的std::string
作为形参,然后它复制该形参到数据成员。假设你了解Item41,你声明一个值传递的形参:
class Annotation {
public:
explicit Annotation(std::string text); //将会被复制的形参,
@@ -3281,7 +3281,7 @@ public:
…
};
-当复制text
到一个数据成员的时候,为了避免一次复制操作的代价,你仍然记得来自Item41的建议,把std::move
应用到text
上,因此产生一个右值:
+当复制text
到一个数据成员的时候,为了避免一次复制操作的代价,你仍然记得来自Item41的建议,把std::move
应用到text
上,因此产生一个右值:
class Annotation {
public:
explicit Annotation(const std::string text)
@@ -3328,7 +3328,7 @@ logAndProcess(std::move(w)); //用右值调用
在logAndProcess
函数的内部,形参param
被传递给函数process
。函数process
分别对左值和右值做了重载。当我们使用左值来调用logAndProcess
时,自然我们期望该左值被当作左值转发给process
函数,而当我们使用右值来调用logAndProcess
函数时,我们期望process
函数的右值重载版本被调用。
但是param
,正如所有的其他函数形参一样,是一个左值。每次在函数logAndProcess
内部对函数process
的调用,都会因此调用函数process
的左值重载版本。为防如此,我们需要一种机制:当且仅当传递给函数logAndProcess
的用以初始化param
的实参是一个右值时,param
会被转换为一个右值。这就是std::forward
做的事情。这就是为什么std::forward
是一个有条件的转换:它的实参用右值初始化时,转换为一个右值。
-你也许会想知道std::forward
是怎么知道它的实参是否是被一个右值初始化的。举个例子,在上述代码中,std::forward
是怎么分辨param
是被一个左值还是右值初始化的? 简短的说,该信息藏在函数logAndProcess
的模板参数T
中。该参数被传递给了函数std::forward
,它解开了含在其中的信息。该机制工作的细节可以查询Item28。
+你也许会想知道std::forward
是怎么知道它的实参是否是被一个右值初始化的。举个例子,在上述代码中,std::forward
是怎么分辨param
是被一个左值还是右值初始化的? 简短的说,该信息藏在函数logAndProcess
的模板参数T
中。该参数被传递给了函数std::forward
,它解开了含在其中的信息。该机制工作的细节可以查询Item28。
考虑到std::move
和std::forward
都可以归结于转换,它们唯一的区别就是std::move
总是执行转换,而std::forward
偶尔为之。你可能会问是否我们可以免于使用std::move
而在任何地方只使用std::forward
。 从纯技术的角度,答案是yes:std::forward
是可以完全胜任,std::move
并非必须。当然,其实两者中没有哪一个函数是真的必须的,因为我们可以到处直接写转换代码,但是我希望我们能同意:这将相当的,嗯,让人恶心。
std::move
的吸引力在于它的便利性:减少了出错的可能性,增加了代码的清晰程度。考虑一个类,我们希望统计有多少次移动构造函数被调用了。我们只需要一个static
的计数器,它会在移动构造的时候自增。假设在这个类中,唯一一个非静态的数据成员是std::string
,一种经典的移动构造函数(即,使用std::move
)可以被实现如下:
class Widget {
@@ -3355,7 +3355,7 @@ public:
}
-注意,第一,std::move
只需要一个函数实参(rhs.s
),而std::forward
不但需要一个函数实参(rhs.s
),还需要一个模板类型实参std::string
。其次,我们传递给std::forward
的类型应当是一个non-reference,因为惯例是传递的实参应该是一个右值(见Item28)。同样,这意味着std::move
比起std::forward
来说需要打更少的字,并且免去了传递一个表示我们正在传递一个右值的类型实参。同样,它根绝了我们传递错误类型的可能性(例如,std::string&
可能导致数据成员s
被复制而不是被移动构造)。
+注意,第一,std::move
只需要一个函数实参(rhs.s
),而std::forward
不但需要一个函数实参(rhs.s
),还需要一个模板类型实参std::string
。其次,我们传递给std::forward
的类型应当是一个non-reference,因为惯例是传递的实参应该是一个右值(见Item28)。同样,这意味着std::move
比起std::forward
来说需要打更少的字,并且免去了传递一个表示我们正在传递一个右值的类型实参。同样,它根绝了我们传递错误类型的可能性(例如,std::string&
可能导致数据成员s
被复制而不是被移动构造)。
更重要的是,std::move
的使用代表着无条件向右值的转换,而使用std::forward
只对绑定了右值的引用进行到右值转换。这是两种完全不同的动作。前者是典型地为了移动操作,而后者只是传递(亦为转发)一个对象到另外一个函数,保留它原有的左值属性或右值属性。因为这些动作实在是差异太大,所以我们拥有两个不同的函数(以及函数名)来区分这些动作。
请记住:
@@ -3378,7 +3378,7 @@ template<typename T>
void f(T&& param); //不是右值引用
事实上,“T&&
”有两种不同的意思。第一种,当然是右值引用。这种引用表现得正如你所期待的那样:它们只绑定到右值上,并且它们主要的存在原因就是为了识别可以移动操作的对象。
-“T&&
”的另一种意思是,它既可以是右值引用,也可以是左值引用。这种引用在源码里看起来像右值引用(即“T&&
”),但是它们可以表现得像是左值引用(即“T&
”)。它们的二重性使它们既可以绑定到右值上(就像右值引用),也可以绑定到左值上(就像左值引用)。 此外,它们还可以绑定到const
或者non-const
的对象上,也可以绑定到volatile
或者non-volatile
的对象上,甚至可以绑定到既const
又volatile
的对象上。它们可以绑定到几乎任何东西。这种空前灵活的引用值得拥有自己的名字。我把它叫做通用引用(universal references)。(Item25解释了std::forward
几乎总是可以应用到通用引用上,并且在这本书即将出版之际,一些C++社区的成员已经开始将这种通用引用称之为转发引用(forwarding references))。
+“T&&
”的另一种意思是,它既可以是右值引用,也可以是左值引用。这种引用在源码里看起来像右值引用(即“T&&
”),但是它们可以表现得像是左值引用(即“T&
”)。它们的二重性使它们既可以绑定到右值上(就像右值引用),也可以绑定到左值上(就像左值引用)。 此外,它们还可以绑定到const
或者non-const
的对象上,也可以绑定到volatile
或者non-volatile
的对象上,甚至可以绑定到既const
又volatile
的对象上。它们可以绑定到几乎任何东西。这种空前灵活的引用值得拥有自己的名字。我把它叫做通用引用(universal references)。(Item25解释了std::forward
几乎总是可以应用到通用引用上,并且在这本书即将出版之际,一些C++社区的成员已经开始将这种通用引用称之为转发引用(forwarding references))。
在两种情况下会出现通用引用。最常见的一种是函数模板形参,正如在之前的示例代码中所出现的例子:
template<typename T>
void f(T&& param); //param是一个通用引用
@@ -3460,8 +3460,8 @@ void someFunc(MyTemplateType&& param);
stop timer and record elapsed time;
};
-如果你对lambda里的代码“std::forward<decltype(blah blah blah)>
”反应是“这是什么鬼...?!”,只能说你可能还没有读Item33。别担心。在本条款,重要的事是lambda表达式中声明的auto&&
类型的形参。func
是一个通用引用,可以被绑定到任何可调用对象,无论左值还是右值。args
是0个或者多个通用引用(即它是个通用引用parameter pack),它可以绑定到任意数目、任意类型的对象上。多亏了auto
类型的通用引用,函数timeFuncInvocation
可以对近乎任意(pretty much any)函数进行计时。(如果你想知道任意(any)和近乎任意(pretty much any)的区别,往后翻到Item30)。
-牢记整个本条款——通用引用的基础——是一个谎言,噢不,是一个“抽象”。其底层真相被称为引用折叠(reference collapsing),Item28的专题将致力于讨论它。但是这个真相并不降低该抽象的有用程度。区分右值引用和通用引用将会帮助你更准确地阅读代码(“究竟我眼前的这个T&&
是只绑定到右值还是可以绑定任意对象呢?”),并且,当你在和你的合作者交流时,它会帮助你避免歧义(“在这里我在用一个通用引用,而非右值引用”)。它也可以帮助你弄懂Item25和26,它们依赖于右值引用和通用引用的区别。所以,拥抱这份抽象,陶醉于它吧。就像牛顿的力学定律(本质上不正确),比起爱因斯坦的广义相对论(这是真相)而言,往往更简单,更易用。所以通用引用的概念,相较于穷究引用折叠的细节而言,是更合意之选。
+如果你对lambda里的代码“std::forward<decltype(blah blah blah)>
”反应是“这是什么鬼...?!”,只能说你可能还没有读Item33。别担心。在本条款,重要的事是lambda表达式中声明的auto&&
类型的形参。func
是一个通用引用,可以被绑定到任何可调用对象,无论左值还是右值。args
是0个或者多个通用引用(即它是个通用引用parameter pack),它可以绑定到任意数目、任意类型的对象上。多亏了auto
类型的通用引用,函数timeFuncInvocation
可以对近乎任意(pretty much any)函数进行计时。(如果你想知道任意(any)和近乎任意(pretty much any)的区别,往后翻到Item30)。
+牢记整个本条款——通用引用的基础——是一个谎言,噢不,是一个“抽象”。其底层真相被称为引用折叠(reference collapsing),Item28的专题将致力于讨论它。但是这个真相并不降低该抽象的有用程度。区分右值引用和通用引用将会帮助你更准确地阅读代码(“究竟我眼前的这个T&&
是只绑定到右值还是可以绑定任意对象呢?”),并且,当你在和你的合作者交流时,它会帮助你避免歧义(“在这里我在用一个通用引用,而非右值引用”)。它也可以帮助你弄懂Item25和26,它们依赖于右值引用和通用引用的区别。所以,拥抱这份抽象,陶醉于它吧。就像牛顿的力学定律(本质上不正确),比起爱因斯坦的广义相对论(这是真相)而言,往往更简单,更易用。所以通用引用的概念,相较于穷究引用折叠的细节而言,是更合意之选。
请记住:
- 如果一个函数模板形参的类型为
T&&
,并且T
需要被推导得知,或者如果一个对象被声明为auto&&
,这个形参或者对象就是一个通用引用。
@@ -3476,7 +3476,7 @@ void someFunc(MyTemplateType&& param);
…
};
-这是个例子,你将希望传递这样的对象给其他函数,允许那些函数利用对象的右值性(rvalueness)。这样做的方法是将绑定到此类对象的形参转换为右值。如Item23中所述,这不仅是std::move
所做,而且它的创建就是为了这个目的:
+这是个例子,你将希望传递这样的对象给其他函数,允许那些函数利用对象的右值性(rvalueness)。这样做的方法是将绑定到此类对象的形参转换为右值。如Item23中所述,这不仅是std::move
所做,而且它的创建就是为了这个目的:
class Widget {
public:
Widget(Widget&& rhs) //rhs是右值引用
@@ -3489,7 +3489,7 @@ private:
std::shared_ptr<SomeDataStructure> p;
};
-另一方面(查看Item24),通用引用可能绑定到有资格移动的对象上。通用引用使用右值初始化时,才将其强制转换为右值。Item23阐释了这正是std::forward
所做的:
+另一方面(查看Item24),通用引用可能绑定到有资格移动的对象上。通用引用使用右值初始化时,才将其强制转换为右值。Item23阐释了这正是std::forward
所做的:
class Widget {
public:
template<typename T>
@@ -3500,7 +3500,7 @@ public:
};
总而言之,当把右值引用转发给其他函数时,右值引用应该被无条件转换为右值(通过std::move
),因为它们总是绑定到右值;当转发通用引用时,通用引用应该有条件地转换为右值(通过std::forward
),因为它们只是有时绑定到右值。
-Item23解释说,可以在右值引用上使用std::forward
表现出适当的行为,但是代码较长,容易出错,所以应该避免在右值引用上使用std::forward
。更糟的是在通用引用上使用std::move
,这可能会意外改变左值(比如局部变量):
+Item23解释说,可以在右值引用上使用std::forward
表现出适当的行为,但是代码较长,容易出错,所以应该避免在右值引用上使用std::forward
。更糟的是在通用引用上使用std::move
,这可能会意外改变左值(比如局部变量):
class Widget {
public:
template<typename T>
@@ -3524,7 +3524,7 @@ w.setName(n); //把n移动进w!
… //现在n的值未知
上面的例子,局部变量n
被传递给w.setName
,调用方可能认为这是对n
的只读操作——这一点倒是可以被原谅。但是因为setName
内部使用std::move
无条件将传递的引用形参转换为右值,n
的值被移动进w.name
,调用setName
返回时n
最终变为未定义的值。这种行为使得调用者蒙圈了——还有可能变得狂躁。
-你可能争辩说setName
不应该将其形参声明为通用引用。此类引用不能使用const
(见Item24),但是setName
肯定不应该修改其形参。你可能会指出,如果为const
左值和为右值分别重载setName
可以避免整个问题,比如这样:
+你可能争辩说setName
不应该将其形参声明为通用引用。此类引用不能使用const
(见Item24),但是setName
肯定不应该修改其形参。你可能会指出,如果为const
左值和为右值分别重载setName
可以避免整个问题,比如这样:
class Widget {
public:
void setName(const std::string& newName) //用const左值设置
@@ -3539,8 +3539,8 @@ public:
这样的话,当然可以工作,但是有缺点。首先编写和维护的代码更多(两个函数而不是单个模板);其次,效率下降。比如,考虑如下场景:
w.setName("Adela Novak");
-使用通用引用的版本的setName
,字面字符串“Adela Novak
”可以被传递给setName
,再传给w
内部std::string
的赋值运算符。w
的name
的数据成员通过字面字符串直接赋值,没有临时std::string
对象被创建。但是,setName
重载版本,会有一个临时std::string
对象被创建,setName
形参绑定到这个对象,然后这个临时std::string
移动到w
的数据成员中。一次setName
的调用会包括std::string
构造函数调用(创建中间对象),std::string
赋值运算符调用(移动newName
到w.name
),std::string
析构函数调用(析构中间对象)。这比调用接受const char*
指针的std::string
赋值运算符开销昂贵许多。增加的开销根据实现不同而不同,这些开销是否值得担心也跟应用和库的不同而有所不同,但是事实上,将通用引用模板替换成对左值引用和右值引用的一对函数重载在某些情况下会导致运行时的开销。如果把例子泛化,Widget
数据成员是任意类型(而不是知道是个std::string
),性能差距可能会变得更大,因为不是所有类型的移动操作都像std::string
开销较小(参看Item29)。
-但是,关于对左值和右值的重载函数最重要的问题不是源代码的数量,也不是代码的运行时性能。而是设计的可扩展性差。Widget::setName
有一个形参,因此需要两种重载实现,但是对于有更多形参的函数,每个都可能是左值或右值,重载函数的数量几何式增长:n个参数的话,就要实现2n种重载。这还不是最坏的。有的函数——实际上是函数模板——接受无限制个数的参数,每个参数都可以是左值或者右值。此类函数的典型代表是std::make_shared
,还有对于C++14的std::make_unique
(见Item21)。查看他们的的重载声明:
+使用通用引用的版本的setName
,字面字符串“Adela Novak
”可以被传递给setName
,再传给w
内部std::string
的赋值运算符。w
的name
的数据成员通过字面字符串直接赋值,没有临时std::string
对象被创建。但是,setName
重载版本,会有一个临时std::string
对象被创建,setName
形参绑定到这个对象,然后这个临时std::string
移动到w
的数据成员中。一次setName
的调用会包括std::string
构造函数调用(创建中间对象),std::string
赋值运算符调用(移动newName
到w.name
),std::string
析构函数调用(析构中间对象)。这比调用接受const char*
指针的std::string
赋值运算符开销昂贵许多。增加的开销根据实现不同而不同,这些开销是否值得担心也跟应用和库的不同而有所不同,但是事实上,将通用引用模板替换成对左值引用和右值引用的一对函数重载在某些情况下会导致运行时的开销。如果把例子泛化,Widget
数据成员是任意类型(而不是知道是个std::string
),性能差距可能会变得更大,因为不是所有类型的移动操作都像std::string
开销较小(参看Item29)。
+但是,关于对左值和右值的重载函数最重要的问题不是源代码的数量,也不是代码的运行时性能。而是设计的可扩展性差。Widget::setName
有一个形参,因此需要两种重载实现,但是对于有更多形参的函数,每个都可能是左值或右值,重载函数的数量几何式增长:n个参数的话,就要实现2n种重载。这还不是最坏的。有的函数——实际上是函数模板——接受无限制个数的参数,每个参数都可以是左值或者右值。此类函数的典型代表是std::make_shared
,还有对于C++14的std::make_unique
(见Item21)。查看他们的的重载声明:
template<class T, class... Args> //来自C++11标准
shared_ptr<T> make_shared(Args&&... args);
@@ -3562,7 +3562,7 @@ void setSignText(T&& text) //text是通用引用
}
这里,我们想要确保text
的值不会被sign.setText
改变,因为我们想要在signHistory.add
中继续使用。因此std::forward
只在最后使用。
-对于std::move
,同样的思路(即最后一次用右值引用的时候再调用std::move
),但是需要注意,在有些稀少的情况下,你需要调用std::move_if_noexcept
代替std::move
。要了解何时以及为什么,参考Item14。
+对于std::move
,同样的思路(即最后一次用右值引用的时候再调用std::move
),但是需要注意,在有些稀少的情况下,你需要调用std::move_if_noexcept
代替std::move
。要了解何时以及为什么,参考Item14。
如果你在按值返回的函数中,返回值绑定到右值引用或者通用引用上,需要对返回的引用使用std::move
或者std::forward
。要了解原因,考虑两个矩阵相加的operator+
函数,左侧的矩阵为右值(可以被用来保存求值之后的和):
Matrix //按值返回
operator+(Matrix&& lhs, const Matrix& rhs)
@@ -3580,7 +3580,7 @@ operator+(Matrix&& lhs, const Matrix& rhs)
}
lhs
是个左值的事实,会强制编译器拷贝它到返回值的内存空间。假定Matrix
支持移动操作,并且比拷贝操作效率更高,在return
语句中使用std::move
的代码效率更高。
-如果Matrix
不支持移动操作,将其转换为右值不会变差,因为右值可以直接被Matrix
的拷贝构造函数拷贝(见Item23)。如果Matrix
随后支持了移动操作,operator+
将在下一次编译时受益。就是这种情况,通过将std::move
应用到按值返回的函数中要返回的右值引用上,不会损失什么(还可能获得收益)。
+如果Matrix
不支持移动操作,将其转换为右值不会变差,因为右值可以直接被Matrix
的拷贝构造函数拷贝(见Item23)。如果Matrix
随后支持了移动操作,operator+
将在下一次编译时受益。就是这种情况,通过将std::move
应用到按值返回的函数中要返回的右值引用上,不会损失什么(还可能获得收益)。
使用通用引用和std::forward
的情况类似。考虑函数模板reduceAndCopy
收到一个未规约(unreduced)对象Fraction
,将其规约,并返回一个规约后的副本。如果原始对象是右值,可以将其移动到返回值中(避免拷贝开销),但是如果原始对象是左值,必须创建副本,因此如下代码:
template<typename T>
Fraction //按值返回
@@ -3681,7 +3681,7 @@ logAndAdd("Patty Dog"); //传递字符串字面值
在第一个调用中,logAndAdd
的形参name
绑定到变量petName
。在logAndAdd
中name
最终传给names.emplace
。因为name
是左值,会拷贝到names
中。没有方法避免拷贝,因为是左值(petName
)传递给logAndAdd
的。
在第二个调用中,形参name
绑定到右值(显式从“Persephone
”创建的临时std::string
)。name
本身是个左值,所以它被拷贝到names
中,但是我们意识到,原则上,它的值可以被移动到names
中。本次调用中,我们有个拷贝代价,但是我们应该能用移动勉强应付。
在第三个调用中,形参name
也绑定一个右值,但是这次是通过“Patty Dog
”隐式创建的临时std::string
变量。就像第二个调用中,name
被拷贝到names
,但是这里,传递给logAndAdd
的实参是一个字符串字面量。如果直接将字符串字面量传递给emplace
,就不会创建std::string
的临时变量,而是直接在std::multiset
中通过字面量构建std::string
。在第三个调用中,我们有个std::string
拷贝开销,但是我们连移动开销都不想要,更别说拷贝的。
-我们可以通过使用通用引用(参见Item24)重写logAndAdd
来使第二个和第三个调用效率提升,按照Item25的说法,std::forward
转发这个引用到emplace
。代码如下:
+我们可以通过使用通用引用(参见Item24)重写logAndAdd
来使第二个和第三个调用效率提升,按照Item25的说法,std::forward
转发这个引用到emplace
。代码如下:
template<typename T>
void logAndAdd(T&& name)
{
@@ -3724,7 +3724,7 @@ logAndAdd(nameIdx); //错误!
最后一行的注释并不清楚明白,下面让我来说明发生了什么。
有两个重载的logAndAdd
。使用通用引用的那个推导出T
的类型是short
,因此可以精确匹配。对于int
类型参数的重载也可以在short
类型提升后匹配成功。根据正常的重载解决规则,精确匹配优先于类型提升的匹配,所以被调用的是通用引用的重载。
在通用引用那个重载中,name
形参绑定到要传入的short
上,然后name
被std::forward
给names
(一个std::multiset<std::string>
)的emplace
成员函数,然后又被转发给std::string
构造函数。std::string
没有接受short
的构造函数,所以logAndAdd
调用里的multiset::emplace
调用里的std::string
构造函数调用失败。(译者注:这句话比较绕,实际上就是调用链。)所有这一切的原因就是对于short
类型通用引用重载优先于int
类型的重载。
-使用通用引用的函数在C++中是最贪婪的函数。它们几乎可以精确匹配任何类型的实参(极少不适用的实参在Item30中介绍)。这也是把重载和通用引用组合在一块是糟糕主意的原因:通用引用的实现会匹配比开发者预期要多得多的实参类型。
+使用通用引用的函数在C++中是最贪婪的函数。它们几乎可以精确匹配任何类型的实参(极少不适用的实参在Item30中介绍)。这也是把重载和通用引用组合在一块是糟糕主意的原因:通用引用的实现会匹配比开发者预期要多得多的实参类型。
一个更容易掉入这种陷阱的例子是写一个完美转发构造函数。简单对logAndAdd
例子进行改造就可以说明这个问题。不用写接受std::string
或者用索引查找std::string
的自由函数,只是想一个构造函数有着相同操作的Person
类:
class Person {
public:
@@ -3740,7 +3740,7 @@ private:
std::string name;
};
-就像在logAndAdd
的例子中,传递一个不是int
的整型变量(比如std::size_t
,short
,long
等)会调用通用引用的构造函数而不是int
的构造函数,这会导致编译错误。这里这个问题甚至更糟糕,因为Person
中存在的重载比肉眼看到的更多。在Item17中说明,在适当的条件下,C++会生成拷贝和移动构造函数,即使类包含了模板化的构造函数,模板函数能实例化产生与拷贝和移动构造函数一样的签名,也在合适的条件范围内。如果拷贝和移动构造被生成,Person
类看起来就像这样:
+就像在logAndAdd
的例子中,传递一个不是int
的整型变量(比如std::size_t
,short
,long
等)会调用通用引用的构造函数而不是int
的构造函数,这会导致编译错误。这里这个问题甚至更糟糕,因为Person
中存在的重载比肉眼看到的更多。在Item17中说明,在适当的条件下,C++会生成拷贝和移动构造函数,即使类包含了模板化的构造函数,模板函数能实例化产生与拷贝和移动构造函数一样的签名,也在合适的条件范围内。如果拷贝和移动构造被生成,Person
类看起来就像这样:
class Person {
public:
template<typename T> //完美转发的构造函数
@@ -3790,7 +3790,7 @@ public:
};
但是没啥影响,因为重载规则规定当模板实例化函数和非模板函数(或者称为“正常”函数)匹配优先级相当时,优先使用“正常”函数。拷贝构造函数(正常函数)因此胜过具有相同签名的模板实例化函数。
-(如果你想知道为什么编译器在生成一个拷贝构造函数时还会模板实例化一个相同签名的函数,参考Item17。)
+(如果你想知道为什么编译器在生成一个拷贝构造函数时还会模板实例化一个相同签名的函数,参考Item17。)
当继承纳入考虑范围时,完美转发的构造函数与编译器生成的拷贝、移动操作之间的交互会更加复杂。尤其是,派生类的拷贝和移动操作的传统实现会表现得非常奇怪。来看一下:
class SpecialPerson: public Person {
public:
@@ -3804,7 +3804,7 @@ public:
};
如同注释表示的,派生类的拷贝和移动构造函数没有调用基类的拷贝和移动构造函数,而是调用了基类的完美转发构造函数!为了理解原因,要知道派生类将SpecialPerson
类型的实参传递给其基类,然后通过模板实例化和重载解析规则作用于基类Person
。最终,代码无法编译,因为std::string
没有接受一个SpecialPerson
的构造函数。
-我希望到目前为止,已经说服了你,如果可能的话,避免对通用引用形参的函数进行重载。但是,如果在通用引用上重载是糟糕的主意,那么如果需要可转发大多数实参类型的函数,但是对于某些实参类型又要特殊处理应该怎么办?存在多种办法。实际上,下一个条款,Item27专门来讨论这个问题,敬请阅读。
+我希望到目前为止,已经说服了你,如果可能的话,避免对通用引用形参的函数进行重载。但是,如果在通用引用上重载是糟糕的主意,那么如果需要可转发大多数实参类型的函数,但是对于某些实参类型又要特殊处理应该怎么办?存在多种办法。实际上,下一个条款,Item27专门来讨论这个问题,敬请阅读。
请记住:
- 对通用引用形参的函数进行重载,通用引用函数的调用机会几乎总会比你期望的多得多。
@@ -3812,14 +3812,14 @@ public:
Item 27: Familiarize yourself with alternatives to overloading on universal references
-Item26中说明了对使用通用引用形参的函数,无论是独立函数还是成员函数(尤其是构造函数),进行重载都会导致一系列问题。但是也提供了一些示例,如果能够按照我们期望的方式运行,重载可能也是有用的。这个条款探讨了几种,通过避免在通用引用上重载的设计,或者通过限制通用引用可以匹配的参数类型,来实现所期望行为的方法。
-讨论基于Item26中的示例,如果你还没有阅读那个条款,请先阅读那个条款再继续。
+Item26中说明了对使用通用引用形参的函数,无论是独立函数还是成员函数(尤其是构造函数),进行重载都会导致一系列问题。但是也提供了一些示例,如果能够按照我们期望的方式运行,重载可能也是有用的。这个条款探讨了几种,通过避免在通用引用上重载的设计,或者通过限制通用引用可以匹配的参数类型,来实现所期望行为的方法。
+讨论基于Item26中的示例,如果你还没有阅读那个条款,请先阅读那个条款再继续。
-在Item26中的第一个例子中,logAndAdd
是许多函数的代表,这些函数可以使用不同的名字来避免在通用引用上的重载的弊端。例如两个重载的logAndAdd
函数,可以分别改名为logAndAddName
和logAndAddNameIdx
。但是,这种方式不能用在第二个例子,Person
构造函数中,因为构造函数的名字被语言固定了(译者注:即构造函数名与类名相同)。此外谁愿意放弃重载呢?
+在Item26中的第一个例子中,logAndAdd
是许多函数的代表,这些函数可以使用不同的名字来避免在通用引用上的重载的弊端。例如两个重载的logAndAdd
函数,可以分别改名为logAndAddName
和logAndAddNameIdx
。但是,这种方式不能用在第二个例子,Person
构造函数中,因为构造函数的名字被语言固定了(译者注:即构造函数名与类名相同)。此外谁愿意放弃重载呢?
-一种替代方案是退回到C++98,然后将传递通用引用替换为传递lvalue-refrence-to-const
。事实上,这是Item26中首先考虑的方法。缺点是效率不高。现在我们知道了通用引用和重载的相互关系,所以放弃一些效率来确保行为正确简单可能也是一种不错的折中。
+一种替代方案是退回到C++98,然后将传递通用引用替换为传递lvalue-refrence-to-const
。事实上,这是Item26中首先考虑的方法。缺点是效率不高。现在我们知道了通用引用和重载的相互关系,所以放弃一些效率来确保行为正确简单可能也是一种不错的折中。
-通常在不增加复杂性的情况下提高性能的一种方法是,将按传引用形参替换为按值传递,这是违反直觉的。该设计遵循Item41中给出的建议,即在你知道要拷贝时就按值传递,因此会参考那个条款来详细讨论如何设计与工作,效率如何。这里,在Person
的例子中展示:
+通常在不增加复杂性的情况下提高性能的一种方法是,将按传引用形参替换为按值传递,这是违反直觉的。该设计遵循Item41中给出的建议,即在你知道要拷贝时就按值传递,因此会参考那个条款来详细讨论如何设计与工作,效率如何。这里,在Person
的例子中展示:
class Person {
public:
explicit Person(std::string n) //代替T&&构造函数,
@@ -3833,7 +3833,7 @@ private:
std::string name;
};
-因为没有std::string
构造函数可以接受整型参数,所有int
或者其他整型变量(比如std::size_t
、short
、long
等)都会使用int
类型重载的构造函数。相似的,所有std::string
类似的实参(还有可以用来创建std::string
的东西,比如字面量“Ruth
”等)都会使用std::string
类型的重载构造函数。没有意外情况。我想你可能会说有些人使用0
或者NULL
指代空指针会调用int
重载的构造函数让他们很吃惊,但是这些人应该参考Item8反复阅读直到使用0
或者NULL
作为空指针让他们恶心。
+因为没有std::string
构造函数可以接受整型参数,所有int
或者其他整型变量(比如std::size_t
、short
、long
等)都会使用int
类型重载的构造函数。相似的,所有std::string
类似的实参(还有可以用来创建std::string
的东西,比如字面量“Ruth
”等)都会使用std::string
类型的重载构造函数。没有意外情况。我想你可能会说有些人使用0
或者NULL
指代空指针会调用int
重载的构造函数让他们很吃惊,但是这些人应该参考Item8反复阅读直到使用0
或者NULL
作为空指针让他们恶心。
传递lvalue-reference-to-const
以及按值传递都不支持完美转发。如果使用通用引用的动机是完美转发,我们就只能使用通用引用了,没有其他选择。但是又不想放弃重载。所以如果不放弃重载又不放弃通用引用,如何避免在通用引用上重载呢?
实际上并不难。通过查看所有重载的所有形参以及调用点的所有传入实参,然后选择最优匹配的函数——考虑所有形参/实参的组合。通用引用通常提供了最优匹配,但是如果通用引用是包含其他非通用引用的形参列表的一部分,则非通用引用形参的较差匹配会使有一个通用引用的重载版本不被运行。这就是tag dispatch方法的基础,下面的示例会使这段话更容易理解。
@@ -3848,8 +3848,8 @@ void logAndAdd(T&& name)
names.emplace(std::forward<T>(name));
}
-就其本身而言,功能执行没有问题,但是如果引入一个int
类型的重载来用索引查找对象,就会重新陷入Item26中描述的麻烦。这个条款的目标是避免它。不通过重载,我们重新实现logAndAdd
函数分拆为两个函数,一个针对整型值,一个针对其他。logAndAdd
本身接受所有实参类型,包括整型和非整型。
-这两个真正执行逻辑的函数命名为logAndAddImpl
,即我们使用重载。其中一个函数接受通用引用。所以我们同时使用了重载和通用引用。但是每个函数接受第二个形参,表征传入的实参是否为整型。这第二个形参可以帮助我们避免陷入到Item26中提到的麻烦中,因为我们将其安排为第二个实参决定选择哪个重载函数。
+就其本身而言,功能执行没有问题,但是如果引入一个int
类型的重载来用索引查找对象,就会重新陷入Item26中描述的麻烦。这个条款的目标是避免它。不通过重载,我们重新实现logAndAdd
函数分拆为两个函数,一个针对整型值,一个针对其他。logAndAdd
本身接受所有实参类型,包括整型和非整型。
+这两个真正执行逻辑的函数命名为logAndAddImpl
,即我们使用重载。其中一个函数接受通用引用。所以我们同时使用了重载和通用引用。但是每个函数接受第二个形参,表征传入的实参是否为整型。这第二个形参可以帮助我们避免陷入到Item26中提到的麻烦中,因为我们将其安排为第二个实参决定选择哪个重载函数。
是的,我知道,“不要在啰嗦了,赶紧亮出代码”。没有问题,代码如下,这是最接近正确版本的:
template<typename T>
void logAndAdd(T&& name)
@@ -3858,8 +3858,8 @@ void logAndAdd(T&& name)
std::is_integral<T>()); //不那么正确
}
-这个函数转发它的形参给logAndAddImpl
函数,但是多传递了一个表示形参T
是否为整型的实参。至少,这就是应该做的。对于右值的整型实参来说,这也是正确的。但是如同Item28中说明,如果左值实参传递给通用引用name
,对T
类型推断会得到左值引用。所以如果左值int
被传入logAndAdd
,T
将被推断为int&
。这不是一个整型类型,因为引用不是整型类型。这意味着std::is_integral<T>
对于任何左值实参返回false,即使确实传入了整型值。
-意识到这个问题基本相当于解决了它,因为C++标准库有一个type trait(参见Item9),std::remove_reference
,函数名字就说明做了我们希望的:移除类型的引用说明符。所以正确实现的代码应该是这样:
+这个函数转发它的形参给logAndAddImpl
函数,但是多传递了一个表示形参T
是否为整型的实参。至少,这就是应该做的。对于右值的整型实参来说,这也是正确的。但是如同Item28中说明,如果左值实参传递给通用引用name
,对T
类型推断会得到左值引用。所以如果左值int
被传入logAndAdd
,T
将被推断为int&
。这不是一个整型类型,因为引用不是整型类型。这意味着std::is_integral<T>
对于任何左值实参返回false,即使确实传入了整型值。
+意识到这个问题基本相当于解决了它,因为C++标准库有一个type trait(参见Item9),std::remove_reference
,函数名字就说明做了我们希望的:移除类型的引用说明符。所以正确实现的代码应该是这样:
template<typename T>
void logAndAdd(T&& name)
{
@@ -3869,7 +3869,7 @@ void logAndAdd(T&& name)
);
}
-这个代码很巧妙。(在C++14中,你可以通过std::remove_reference_t<T>
来简化写法,参看Item9)
+这个代码很巧妙。(在C++14中,你可以通过std::remove_reference_t<T>
来简化写法,参看Item9)
处理完之后,我们可以将注意力转移到名为logAndAddImpl
的函数上了。有两个重载函数,第一个仅用于非整型类型(即std::is_integral<typename std::remove_reference<T>::type>
是false):
template<typename T> //非整型实参:添加到全局数据结构中
void logAndAddImpl(T&& name, std::false_type) //译者注:高亮std::false_type
@@ -3889,13 +3889,13 @@ void logAndAddImpl(int idx, std::true_type) //译者注:高亮std::true_type
通过索引找到对应的name
,然后让logAndAddImpl
传递给logAndAdd
(名字会被再std::forward
给另一个logAndAddImpl
重载),我们避免了将日志代码放入这个logAndAddImpl
重载中。
在这个设计中,类型std::true_type
和std::false_type
是“标签”(tag),其唯一目的就是强制重载解析按照我们的想法来执行。注意到我们甚至没有对这些参数进行命名。他们在运行时毫无用处,事实上我们希望编译器可以意识到这些标签形参没被使用,然后在程序执行时优化掉它们。(至少某些时候有些编译器会这样做。)通过创建标签对象,在logAndAdd
内部将重载实现函数的调用“分发”(dispatch)给正确的重载。因此这个设计名称为:tag dispatch。这是模板元编程的标准构建模块,你对现代C++库中的代码了解越多,你就会越多遇到这种设计。
-就我们的目的而言,tag dispatch的重要之处在于它可以允许我们组合重载和通用引用使用,而没有Item26中提到的问题。分发函数——logAndAdd
——接受一个没有约束的通用引用参数,但是这个函数没有重载。实现函数——logAndAddImpl
——是重载的,一个接受通用引用参数,但是重载规则不仅依赖通用引用形参,还依赖新引入的标签形参,标签值设计来保证有不超过一个的重载是合适的匹配。结果是标签来决定采用哪个重载函数。通用引用参数可以生成精确匹配的事实在这里并不重要。(译者注:这里确实比较啰嗦,如果理解了上面的内容,这段完全可以没有。)
+就我们的目的而言,tag dispatch的重要之处在于它可以允许我们组合重载和通用引用使用,而没有Item26中提到的问题。分发函数——logAndAdd
——接受一个没有约束的通用引用参数,但是这个函数没有重载。实现函数——logAndAddImpl
——是重载的,一个接受通用引用参数,但是重载规则不仅依赖通用引用形参,还依赖新引入的标签形参,标签值设计来保证有不超过一个的重载是合适的匹配。结果是标签来决定采用哪个重载函数。通用引用参数可以生成精确匹配的事实在这里并不重要。(译者注:这里确实比较啰嗦,如果理解了上面的内容,这段完全可以没有。)
-tag dispatch的关键是存在单独一个函数(没有重载)给客户端API。这个单独的函数分发给具体的实现函数。创建一个没有重载的分发函数通常是容易的,但是Item26中所述第二个问题案例是Person
类的完美转发构造函数,是个例外。编译器可能会自行生成拷贝和移动构造函数,所以即使你只写了一个构造函数并在其中使用tag dispatch,有一些对构造函数的调用也被编译器生成的函数处理,绕过了分发机制。
-实际上,真正的问题不是编译器生成的函数会绕过tag dispatch设计,而是不总会绕过去。你希望类的拷贝构造函数总是处理该类型的左值拷贝请求,但是如同Item26中所述,提供具有通用引用的构造函数,会使通用引用构造函数在拷贝non-const
左值时被调用(而不是拷贝构造函数)。那个条款还说明了当一个基类声明了完美转发构造函数,派生类实现自己的拷贝和移动构造函数时会调用那个完美转发构造函数,尽管正确的行为是调用基类的拷贝或者移动构造。
+tag dispatch的关键是存在单独一个函数(没有重载)给客户端API。这个单独的函数分发给具体的实现函数。创建一个没有重载的分发函数通常是容易的,但是Item26中所述第二个问题案例是Person
类的完美转发构造函数,是个例外。编译器可能会自行生成拷贝和移动构造函数,所以即使你只写了一个构造函数并在其中使用tag dispatch,有一些对构造函数的调用也被编译器生成的函数处理,绕过了分发机制。
+实际上,真正的问题不是编译器生成的函数会绕过tag dispatch设计,而是不总会绕过去。你希望类的拷贝构造函数总是处理该类型的左值拷贝请求,但是如同Item26中所述,提供具有通用引用的构造函数,会使通用引用构造函数在拷贝non-const
左值时被调用(而不是拷贝构造函数)。那个条款还说明了当一个基类声明了完美转发构造函数,派生类实现自己的拷贝和移动构造函数时会调用那个完美转发构造函数,尽管正确的行为是调用基类的拷贝或者移动构造。
这种情况,采用通用引用的重载函数通常比期望的更加贪心,虽然不像单个分派函数一样那么贪心,而又不满足使用tag dispatch的条件。你需要另外的技术,可以让你确定允许使用通用引用模板的条件。朋友,你需要的就是std::enable_if
。
std::enable_if
可以给你提供一种强制编译器执行行为的方法,像是特定模板不存在一样。这种模板被称为被禁止(disabled)。默认情况下,所有模板是启用的(enabled),但是使用std::enable_if
可以使得仅在std::enable_if
指定的条件满足时模板才启用。在这个例子中,我们只在传递的类型不是Person
时使用Person
的完美转发构造函数。如果传递的类型是Person
,我们要禁止完美转发构造函数(即让编译器忽略它),因为这会让拷贝或者移动构造函数处理调用,这是我们想要使用Person
初始化另一个Person
的初衷。
-这个主意听起来并不难,但是语法比较繁杂,尤其是之前没有接触过的话,让我慢慢引导你。有一些std::enbale_if
的contidion(条件)部分的样板,让我们从这里开始。下面的代码是Person
完美转发构造函数的声明,多展示std::enable_if
的部分来简化使用难度。我仅展示构造函数的声明,因为std::enable_if
的使用对函数实现没影响。实现部分跟Item26中没有区别。
+这个主意听起来并不难,但是语法比较繁杂,尤其是之前没有接触过的话,让我慢慢引导你。有一些std::enbale_if
的contidion(条件)部分的样板,让我们从这里开始。下面的代码是Person
完美转发构造函数的声明,多展示std::enable_if
的部分来简化使用难度。我仅展示构造函数的声明,因为std::enable_if
的使用对函数实现没影响。实现部分跟Item26中没有区别。
class Person {
public:
template<typename T,
@@ -3905,7 +3905,7 @@ public:
};
为了理解高亮部分发生了什么,我很遗憾的表示你要自行参考其他代码,因为详细解释需要花费一定空间和时间,而本书并没有足够的空间(在你自行学习过程中,请研究“SFINAE”以及std::enable_if
,因为“SFINAE”就是使std::enable_if
起作用的技术)。这里我想要集中讨论条件的表示,该条件表示此构造函数是否启用。
-这里我们想表示的条件是确认T
不是Person
类型,即模板构造函数应该在T
不是Person
类型的时候启用。多亏了type trait可以确定两个对象类型是否相同(std::is_same
),看起来我们需要的就是!std::is_same<Person, T>::value
(注意语句开始的!
,我们想要的是不相同)。这很接近我们想要的了,但是不完全正确,因为如同Item28中所述,使用左值来初始化通用引用的话会推导成左值引用,比如这个代码:
+这里我们想表示的条件是确认T
不是Person
类型,即模板构造函数应该在T
不是Person
类型的时候启用。多亏了type trait可以确定两个对象类型是否相同(std::is_same
),看起来我们需要的就是!std::is_same<Person, T>::value
(注意语句开始的!
,我们想要的是不相同)。这很接近我们想要的了,但是不完全正确,因为如同Item28中所述,使用左值来初始化通用引用的话会推导成左值引用,比如这个代码:
Person p("Nancy");
auto cloneOfP(p); //用左值初始化
@@ -3915,10 +3915,10 @@ auto cloneOfP(p); //用左值初始化
是否是个引用。对于决定是否通用引用构造函数启用的目的来说,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
标识符)的修饰。(这里我没有说出另外的真相,std::decay
如同其名一样,可以将数组或者函数退化成指针,参考Item1,但是在这里讨论的问题中,它刚好合适)。我们想要控制构造函数是否启用的条件可以写成:
+这意味着我们需要一种方法消除对于T
的引用,const
,volatile
修饰。再次,标准库提供了这样功能的type trait,就是std::decay
。std::decay<T>::value
与T
是相同的,只不过会移除引用和cv限定符(cv-qualifiers,即const
或volatile
标识符)的修饰。(这里我没有说出另外的真相,std::decay
如同其名一样,可以将数组或者函数退化成指针,参考Item1,但是在这里讨论的问题中,它刚好合适)。我们想要控制构造函数是否启用的条件可以写成:
!std::is_same<Person, typename std::decay<T>::type>::value
-即Person
和T
的类型不同,忽略了所有引用和cv限定符。(如Item9所述,std::decay
前的“typename
”是必需的,因为std::decay<T>::type
的类型取决于模板形参T
。)
+即Person
和T
的类型不同,忽略了所有引用和cv限定符。(如Item9所述,std::decay
前的“typename
”是必需的,因为std::decay<T>::type
的类型取决于模板形参T
。)
将其带回上面std::enable_if
样板的代码中,加上调整一下格式,让各部分如何组合在一起看起来更容易,Person
的完美转发构造函数的声明如下:
class Person {
public:
@@ -3936,7 +3936,7 @@ public:
如果你之前从没有看到过这种类型的代码,那你可太幸福了。最后才放出这种设计是有原因的。当你有其他机制来避免同时使用重载和通用引用时(你总会这样做),确实应该那样做。不过,一旦你习惯了使用函数语法和尖括号的使用,也不坏。此外,这可以提供你一直想要的行为表现。在上面的声明中,使用Person
初始化一个Person
——无论是左值还是右值,const
还是non-const
,volatile
还是non-volatile
——都不会调用到通用引用构造函数。
成功了,对吗?确实!
-啊,不对。等会再庆祝。Item26还有一个情景需要解决,我们需要继续探讨下去。
+啊,不对。等会再庆祝。Item26还有一个情景需要解决,我们需要继续探讨下去。
假定从Person
派生的类以常规方式实现拷贝和移动操作:
class SpecialPerson: public Person {
public:
@@ -3951,7 +3951,7 @@ public:
…
};
-这和Item26中的代码是一样的,包括注释也是一样。当我们拷贝或者移动一个SpecialPerson
对象时,我们希望调用基类对应的拷贝和移动构造函数,来拷贝或者移动基类部分,但是这里,我们将SpecialPerson
传递给基类的构造函数,因为SpecialPerson
和Person
类型不同(在应用std::decay
后也不同),所以完美转发构造函数是启用的,会实例化为精确匹配SpecialPerson
实参的构造函数。相比于派生类到基类的转化——这个转化对于在Person
拷贝和移动构造函数中把SpecialPerson
对象绑定到Person
形参非常重要,生成的精确匹配是更优的,所以这里的代码,拷贝或者移动SpecialPerson
对象就会调用Person
类的完美转发构造函数来执行基类的部分。跟Item26的困境一样。
+这和Item26中的代码是一样的,包括注释也是一样。当我们拷贝或者移动一个SpecialPerson
对象时,我们希望调用基类对应的拷贝和移动构造函数,来拷贝或者移动基类部分,但是这里,我们将SpecialPerson
传递给基类的构造函数,因为SpecialPerson
和Person
类型不同(在应用std::decay
后也不同),所以完美转发构造函数是启用的,会实例化为精确匹配SpecialPerson
实参的构造函数。相比于派生类到基类的转化——这个转化对于在Person
拷贝和移动构造函数中把SpecialPerson
对象绑定到Person
形参非常重要,生成的精确匹配是更优的,所以这里的代码,拷贝或者移动SpecialPerson
对象就会调用Person
类的完美转发构造函数来执行基类的部分。跟Item26的困境一样。
派生类仅仅是按照常规的规则生成了自己的移动和拷贝构造函数,所以这个问题的解决还要落实在基类,尤其是控制是否使用Person
通用引用构造函数启用的条件。现在我们意识到不只是禁止Person
类型启用模板构造函数,而是禁止Person
**以及任何派生自Person
**的类型启用模板构造函数。讨厌的继承!
你应该不意外在这里看到标准库中也有type trait判断一个类型是否继承自另一个类型,就是std::is_base_of
。如果std::is_base_of<T1, T2>
是true就表示T2
派生自T1
。类型也可被认为是从他们自己派生,所以std::is_base_of<T, T>::value
总是true。这就很方便了,我们想要修正控制Person
完美转发构造函数的启用条件,只有当T
在消除引用和cv限定符之后,并且既不是Person
又不是Person
的派生类时,才满足条件。所以使用std::is_base_of
代替std::is_same
就可以了:
class Person {
@@ -4014,7 +4014,7 @@ private:
本条款提到的前三个技术——放弃重载、传递const T&、传值——在函数调用中指定每个形参的类型。后两个技术——tag dispatch和限制模板适用范围——使用完美转发,因此不需要指定形参类型。这一基本决定(是否指定类型)有一定后果。
通常,完美转发更有效率,因为它避免了仅仅去为了符合形参声明的类型而创建临时对象。在Person
构造函数的例子中,完美转发允许将“Nancy
”这种字符串字面量转发到Person
内部的std::string
的构造函数,不使用完美转发的技术则会从字符串字面值创建一个临时std::string
对象,来满足Person
构造函数指定的形参要求。
-但是完美转发也有缺点。即使某些类型的实参可以传递给接受特定类型的函数,也无法完美转发。Item30中探索了完美转发失败的例子。
+但是完美转发也有缺点。即使某些类型的实参可以传递给接受特定类型的函数,也无法完美转发。Item30中探索了完美转发失败的例子。
第二个问题是当客户传递无效参数时错误消息的可理解性。例如假如客户传递了一个由char16_t
(一种C++11引入的类型表示16位字符)而不是char
(std::string
包含的)组成的字符串字面值来创建一个Person
对象:
Person p(u"Konrad Zuse"); //“Konrad Zuse”由const char16_t类型字符组成
@@ -4057,7 +4057,7 @@ public:
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
类的移动操作比复制操作快,移动Widget
的std::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_unique
和std::make_shared
中(参见Item21)看到,当然还有其他一些地方。
+这种形式你会在标准化容器置入函数(emplace functions)中(参见Item42)和智能指针的工厂函数std::make_unique
和std::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
-Item8说明当你试图传递0
或者NULL
作为空指针给模板时,类型推导会出错,会把传来的实参推导为一个整型类型(典型情况为int
)而不是指针类型。结果就是不管是0
还是NULL
都不能作为空指针被完美转发。解决方法非常简单,传一个nullptr
而不是0
或者NULL
。具体的细节,参考Item8。
+Item8说明当你试图传递0
或者NULL
作为空指针给模板时,类型推导会出错,会把传来的实参推导为一个整型类型(典型情况为int
)而不是指针类型。结果就是不管是0
还是NULL
都不能作为空指针被完美转发。解决方法非常简单,传一个nullptr
而不是0
或者NULL
。具体的细节,参考Item8。
通常,无需在类中定义整型static const
数据成员;声明就可以了。这是因为编译器会对此类成员实行常量传播(const propagation),因此消除了保留内存的需要。比如,考虑下面的代码:
class Widget {
@@ -4395,7 +4395,7 @@ fwd(length); //转发这个副本
CHAPTER 6 Lambda Expressions
-lambda表达式是C++编程中的游戏规则改变者。这有点令人惊讶,因为它没有给语言带来新的表达能力。lambda可以做的所有事情都可以通过其他方式完成。但是lambda是创建函数对象相当便捷的一种方法,对于日常的C++开发影响是巨大的。没有lambda时,STL中的“_if
”算法(比如,std::find_if
,std::remove_if
,std::count_if
等)通常需要繁琐的谓词,但是当有lambda可用时,这些算法使用起来就变得相当方便。用比较函数(比如,std::sort
,std::nth_element
,std::lower_bound
等)来自定义算法也是同样方便的。在STL外,lambda可以快速创建std::unique_ptr
和std::shared_ptr
的自定义删除器(见Item18和19),并且使线程API中条件变量的谓词指定变得同样简单(参见Item39)。除了标准库,lambda有利于即时的回调函数,接口适配函数和特定上下文中的一次性函数。lambda确实使C++成为更令人愉快的编程语言。
+lambda表达式是C++编程中的游戏规则改变者。这有点令人惊讶,因为它没有给语言带来新的表达能力。lambda可以做的所有事情都可以通过其他方式完成。但是lambda是创建函数对象相当便捷的一种方法,对于日常的C++开发影响是巨大的。没有lambda时,STL中的“_if
”算法(比如,std::find_if
,std::remove_if
,std::count_if
等)通常需要繁琐的谓词,但是当有lambda可用时,这些算法使用起来就变得相当方便。用比较函数(比如,std::sort
,std::nth_element
,std::lower_bound
等)来自定义算法也是同样方便的。在STL外,lambda可以快速创建std::unique_ptr
和std::shared_ptr
的自定义删除器(见Item18和19),并且使线程API中条件变量的谓词指定变得同样简单(参见Item39)。除了标准库,lambda有利于即时的回调函数,接口适配函数和特定上下文中的一次性函数。lambda确实使C++成为更令人愉快的编程语言。
与lambda相关的词汇可能会令人疑惑,这里做一下简单的回顾:
-
@@ -4500,7 +4500,7 @@ void workWithContainer(const C& container)
);
这足以满足本实例的要求,但在通常情况下,按值捕获并不能完全解决悬空引用的问题。这里的问题是如果你按值捕获的是一个指针,你将该指针拷贝到lambda对应的闭包里,但这样并不能避免lambda外delete
这个指针的行为,从而导致你的副本指针变成悬空指针。
-也许你要抗议说:“这不可能发生。看过了第4章,我对智能指针的使用非常热衷。只有那些失败的C++98的程序员才会用裸指针和delete
语句。”这也许是正确的,但却是不相关的,因为事实上你的确会使用裸指针,也的确存在被你delete
的可能性。只不过在现代的C++编程风格中,不容易在源代码中显露出来而已。
+也许你要抗议说:“这不可能发生。看过了第4章,我对智能指针的使用非常热衷。只有那些失败的C++98的程序员才会用裸指针和delete
语句。”这也许是正确的,但却是不相关的,因为事实上你的确会使用裸指针,也的确存在被你delete
的可能性。只不过在现代的C++编程风格中,不容易在源代码中显露出来而已。
假设在一个Widget
类,可以实现向过滤器的容器添加条目:
class Widget {
public:
@@ -4557,7 +4557,7 @@ private:
);
}
-明白了这个就相当于明白了lambda闭包的生命周期与Widget
对象的关系,闭包内含有Widget
的this
指针的拷贝。特别是考虑以下的代码,参考第4章的内容,只使用智能指针:
+明白了这个就相当于明白了lambda闭包的生命周期与Widget
对象的关系,闭包内含有Widget
的this
指针的拷贝。特别是考虑以下的代码,参考第4章的内容,只使用智能指针:
using FilterContainer = //跟之前一样
std::vector<std::function<bool(int)>>;
@@ -4573,7 +4573,7 @@ void doSomeWork()
…
} //销毁Widget;filters现在持有悬空指针!
-当调用doSomeWork
时,就会创建一个过滤器,其生命周期依赖于由std::make_unique
产生的Widget
对象,即一个含有指向Widget
的指针——Widget
的this
指针——的过滤器。这个过滤器被添加到filters
中,但当doSomeWork
结束时,Widget
会由管理它的std::unique_ptr
来销毁(见Item18)。从这时起,filter
会含有一个存着悬空指针的条目。
+当调用doSomeWork
时,就会创建一个过滤器,其生命周期依赖于由std::make_unique
产生的Widget
对象,即一个含有指向Widget
的指针——Widget
的this
指针——的过滤器。这个过滤器被添加到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_ptr
或std::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的捕获模式能完成所需功能的情况下,使用它是完全合理的)。
使用初始化捕获可以让你指定:
- 从lambda生成的闭包类中的数据成员名称;
@@ -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::forward
将x
转发到函数normalize
(见Item25)。理论上,这都是小改动:
+实现这个lambda的正确方式是把x
完美转发给函数normalize
。这样做需要对代码做两处修改。首先,x
需要改成通用引用(见Item24),其次,需要使用std::forward
将x
转发到函数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::bind1st
和std::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不提供移动捕获,但是可以通过结合lambda和
std::bind
来模拟。 有关详细信息,请参阅Item32,该条款还解释了在C++14中,lambda对初始化捕获的支持消除了这个模拟的需求。
-- 多态函数对象。因为bind对象上的函数调用运算符使用完美转发,所以它可以接受任何类型的实参(以Item30中描述的完美转发的限制为界限)。当你要绑定带有模板化函数调用运算符的对象时,此功能很有用。 例如这个类,
+- 移动捕获。C++11的lambda不提供移动捕获,但是可以通过结合lambda和
std::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);
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
推迟到存在这样的调用时才执行(译者注:异步与并发是两个不同概念,这里侧重于惰性求值)。当get
或wait
被调用,f
会同步执行,即调用方被阻塞,直到f
运行结束。如果get
和wait
都没有被调用,f
将不会被执行。(这是个简化说法。关键点不是要在其上调用get
或wait
的那个future,而是future引用的那个共享状态。(Item38讨论了future与共享状态的关系。)因为std::future
支持移动,也可以用来构造std::shared_future
,并且因为std::shared_future
可以被拷贝,对共享状态——对f
传到的那个std::async
进行调用产生的——进行引用的future对象,有可能与std::async
返回的那个future对象不同。这非常绕口,所以经常回避这个事实,简称为在std::async
返回的future上调用get
或wait
。)
+std::launch::deferred
启动策略意味着f
仅当在std::async
返回的future上调用get
或者wait
时才执行。这表示f
推迟到存在这样的调用时才执行(译者注:异步与并发是两个不同概念,这里侧重于惰性求值)。当get
或wait
被调用,f
会同步执行,即调用方被阻塞,直到f
运行结束。如果get
和wait
都没有被调用,f
将不会被执行。(这是个简化说法。关键点不是要在其上调用get
或wait
的那个future,而是future引用的那个共享状态。(Item38讨论了future与共享状态的关系。)因为std::future
支持移动,也可以用来构造std::shared_future
,并且因为std::shared_future
可以被拷贝,对共享状态——对f
传到的那个std::async
进行调用产生的——进行引用的future对象,有可能与std::async
返回的那个future对象不同。这非常绕口,所以经常回避这个事实,简称为在std::async
返回的future上调用get
或wait
。)
可能让人惊奇的是,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
对象时,在所有的路径上超出定义所在的作用域时都是不可结合的。但是覆盖每条路径可能很复杂,可能包括自然执行通过作用域,或者通过return
,continue
,break
,goto
或异常跳出作用域,有太多可能的路径。
-每当你想在执行跳至块之外的每条路径执行某种操作,最通用的方式就是将该操作放入局部对象的析构函数中。这些对象称为RAII对象(RAII objects),从RAII类中实例化。(RAII全称为 “Resource Acquisition Is Initialization”(资源获得即初始化),尽管技术关键点在析构上而不是实例化上)。RAII类在标准库中很常见。比如STL容器(每个容器析构函数都销毁容器中的内容物并释放内存),标准智能指针(Item18-20解释了,std::uniqu_ptr
的析构函数调用他指向的对象的删除器,std::shared_ptr
和std::weak_ptr
的析构函数递减引用计数),std::fstream
对象(它们的析构函数关闭对应的文件)等。但是标准库没有std::thread
的RAII类,可能是因为标准委员会拒绝将join
和detach
作为默认选项,不知道应该怎么样完成RAII。
+每当你想在执行跳至块之外的每条路径执行某种操作,最通用的方式就是将该操作放入局部对象的析构函数中。这些对象称为RAII对象(RAII objects),从RAII类中实例化。(RAII全称为 “Resource Acquisition Is Initialization”(资源获得即初始化),尽管技术关键点在析构上而不是实例化上)。RAII类在标准库中很常见。比如STL容器(每个容器析构函数都销毁容器中的内容物并释放内存),标准智能指针(Item18-20解释了,std::uniqu_ptr
的析构函数调用他指向的对象的删除器,std::shared_ptr
和std::weak_ptr
的析构函数递减引用计数),std::fstream
对象(它们的析构函数关闭对应的文件)等。但是标准库没有std::thread
的RAII类,可能是因为标准委员会拒绝将join
和detach
作为默认选项,不知道应该怎么样完成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::thread
和future在析构时有相当不同的行为。在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::thread
和future在析构时有相当不同的行为。在Item37中说明,可结合的std::thread
析构会终止你的程序,因为两个其他的替代选择——隐式join
或者隐式detach
都是更加糟糕的。但是,future的析构表现有时就像执行了隐式join
,有时又像是隐式执行了detach
,有时又没有执行这两个选择。它永远不会造成程序终止。这个线程句柄多种表现值得研究一下。
+我们可以观察到实际上future是通信信道的一端,被调用者通过该信道将结果发送给调用者。(Item39说,与future有关的这种通信信道也可以被用于其他目的。但是对于本条款,我们只考虑它们作为这样一个机制的用法,即被调用者传送结果给调用者。)被调用者(通常是异步执行)将计算结果写入通信信道中(通常通过std::promise
对象),调用者使用future读取结果。你可以想象成下面的图示,虚线表示信息的流动方向:
但是被调用者的结果存储在哪里?被调用者会在调用者get
相关的future之前执行完成,所以结果不能存储在被调用者的std::promise
。这个对象是局部的,当被调用者执行结束后,会被销毁。
结果同样不能存储在调用者的future,因为(当然还有其他原因)std::future
可能会被用来创建std::shared_future
(这会将被调用者的结果所有权从std::future
转移给std::shared_future
),而std::shared_future
在std::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。这种方案是可以工作的,但是不太优雅。
-一个替代方案是让反应任务通过在检测任务设置的future上wait
来避免使用条件变量,互斥锁和flag。这可能听起来也是个古怪的方案。毕竟,Item38中说明了future代表了从被调用方到(通常是异步的)调用方的通信信道的接收端,这里的检测任务和反应任务没有调用-被调用的关系。然而,Item38中也说说明了发送端是个std::promise
,接收端是个future的通信信道不是只能用在调用-被调用场景。这样的通信信道可以用在任何你需要从程序一个地方传递信息到另一个地方的场景。这里,我们用来在检测任务和反应任务之间传递信息,传递的信息就是感兴趣的事件已经发生。
+一个替代方案是让反应任务通过在检测任务设置的future上wait
来避免使用条件变量,互斥锁和flag。这可能听起来也是个古怪的方案。毕竟,Item38中说明了future代表了从被调用方到(通常是异步的)调用方的通信信道的接收端,这里的检测任务和反应任务没有调用-被调用的关系。然而,Item38中也说说明了发送端是个std::promise
,接收端是个future的通信信道不是只能用在调用-被调用场景。这样的通信信道可以用在任何你需要从程序一个地方传递信息到另一个地方的场景。这里,我们用来在检测任务和反应任务之间传递信息,传递的信息就是感兴趣的事件已经发生。
方案很简单。检测任务有一个std::promise
对象(即通信信道的写入端),反应任务有对应的future。当检测任务看到事件已经发生,设置std::promise
对象(即写入到通信信道)。同时,wait
会阻塞住反应任务直到std::promise
被设置。
现在,std::promise
和futures(即std::future
和std::shared_future
)都是需要类型参数的模板。形参表明通过通信信道被传递的信息的类型。在这里,没有数据被传递,只需要让反应任务知道它的future已经被设置了。我们在std::promise
和future模板中需要的东西是表明通信信道中没有数据被传递的一个类型。这个类型就是void
。检测任务使用std::promise<void>
,反应任务使用std::future<void>
或者std::shared_future<void>
。当感兴趣的事件发生时,检测任务设置std::promise<void>
,反应任务在future上wait
。尽管反应任务不从检测任务那里接收任何数据,通信信道也可以让反应任务知道,检测任务什么时候已经通过对std::promise<void>
调用set_value
“写入”了void
数据。
所以,有
@@ -5500,7 +5500,7 @@ p.get_future().wait(); //等待对应于p的那个future
… //对事件作出反应
像使用flag的方法一样,此设计不需要互斥锁,无论在反应线程调用wait
之前检测线程是否设置了std::promise
都可以工作,并且不受虚假唤醒的影响(只有条件变量才容易受到此影响)。与基于条件变量的方法一样,反应任务在调用wait
之后是真被阻塞住的,不会一直占用系统资源。是不是很完美?
-当然不是,基于future的方法没有了上述问题,但是有其他新的问题。比如,Item38中说明,std::promise
和future之间有个共享状态,并且共享状态是动态分配的。因此你应该假定此设计会产生基于堆的分配和释放开销。
+当然不是,基于future的方法没有了上述问题,但是有其他新的问题。比如,Item38中说明,std::promise
和future之间有个共享状态,并且共享状态是动态分配的。因此你应该假定此设计会产生基于堆的分配和释放开销。
也许更重要的是,std::promise
只能设置一次。std::promise
和future之间的通信是一次性的:不能重复使用。这是与基于条件变量或者基于flag的设计的明显差异,条件变量和flag都可以通信多次。(条件变量可以被重复通知,flag也可以重复清除和设置。)
一次通信可能没有你想象中那么大的限制。假定你想创建一个挂起的系统线程。就是,你想避免多次线程创建的那种经常开销,以便想要使用这个线程执行程序时,避免潜在的线程创建工作。或者你想创建一个挂起的线程,以便在线程运行前对其进行设置这样的设置包括优先级或者核心亲和性(core affinity)。C++并发API没有提供这种设置能力,但是std::thread
提供了native_handle
成员函数,它的结果就是提供给你对平台原始线程API的访问(通常是POSIX或者Windows的线程)。这些低层次的API使你可以对线程设置优先级和亲和性。
假设你仅仅想要对某线程挂起一次(在创建后,运行线程函数前),使用void
的future就是一个可行方案。这是这个技术的关键点:
@@ -5519,7 +5519,7 @@ void detect() //检测任务的函数
t.join(); //使t不可结合(见条款37)
}
-因为所有离开detect
的路径中t
都要是不可结合的,所以使用类似于Item37中ThreadRAII
的RAII类很明智。代码如下:
+因为所有离开detect
的路径中t
都要是不可结合的,所以使用类似于Item37中ThreadRAII
的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
的情况),const
和volatile
限定符被拿掉。y
的类型因此仅仅是int
。这意味着对y
的冗余读取和写入可以被消除。在例子中,编译器必须执行对y
的初始化和赋值两个语句,因为x
是volatile
的,所以第二次对x
的读取可能会产生一个与第一次不同的值。)
+突击测试!在最后一段代码中,y
是什么类型:int
还是volatile int
?(y
的类型使用auto
类型推导,所以使用Item2中的规则。规则上说非引用非指针类型的声明(就是y
的情况),const
和volatile
限定符被拿掉。y
的类型因此仅仅是int
。这意味着对y
的冗余读取和写入可以被消除。在例子中,编译器必须执行对y
的初始化和赋值两个语句,因为x
是volatile
的,所以第二次对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
来初始化会发生什么。因为x
是std::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
来初始化会发生什么。因为x
是std::atomic
类型,y
的类型被推导为std::atomic
(参见Item2)。我之前说了std::atomic
最好的特性之一就是所有成员函数都是原子性的,但是为了使从x
拷贝初始化y
的过程是原子性的,编译器不得不生成代码,把读取x
和写入y
放在一个单独的原子性操作中。硬件通常无法做到这一点,因此std::atomic
不支持拷贝构造。出于同样的原因,拷贝赋值也被删除了,这也是为什么从x
赋值给y
也编译失败。(移动操作在std::atomic
没有显式声明,因此根据Item17中描述的规则来看,std::atomic
不支持移动构造和移动赋值)。
可以将x
的值传递给y
,但是需要使用std::atomic
的load
和store
成员函数。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::string
和std::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
运行时发生了什么:
- 一个
std::string
的临时对象从字面量“xyzzy
”被创建。这个对象没有名字,我们可以称为temp
。temp
的构造是第一次std::string
构造。因为是临时变量,所以temp
是右值。
-temp
被传递给push_back
的右值重载函数,绑定到右值引用形参x
。在std::vector
的内存中一个x
的副本被创建。这次构造——也是第二次构造——在std::vector
内部真正创建一个对象。(将x
副本拷贝到std::vector
内部的构造函数是移动构造函数,因为x
在它被拷贝前被转换为一个右值,成为右值引用。有关将右值引用形参强制转换为右值的信息,请参见Item25)。
+temp
被传递给push_back
的右值重载函数,绑定到右值引用形参x
。在std::vector
的内存中一个x
的副本被创建。这次构造——也是第二次构造——在std::vector
内部真正创建一个对象。(将x
副本拷贝到std::vector
内部的构造函数是移动构造函数,因为x
在它被拷贝前被转换为一个右值,成为右值引用。有关将右值引用形参强制转换为右值的信息,请参见Item25)。
- 在
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_list
和std::array
)的标准容器支持emplace
。关联容器提供emplace_hint
来补充接受“hint”迭代器的insert
函数,std::forward_list
有emplace_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_ptr
和std::unique_ptr
这样的资源管理类的高效性是以资源(比如从new
来的原始指针)被立即传递给资源管理对象的构造函数为条件的。实际上,std::make_shared
和std::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右值