Xander's Wiki


  • Home

  • Tags

  • Categories

  • Archives

  • Search

33.当需要forward auto&&参数时,记得采用decltype

Posted on 2018-07-16 | In Effective Modern C++

问题实例

 
C++14提供了一项新特性:允许开发者们在lambda中使用auto。实现它并不困难,无非就是把closure class的operator()变为了template而已。举例而言,对于下述lambda:

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

其生成的closure class的operator()大致有如下形式:
1
2
3
4
5
6
class SomeCompilerGeneratedClassName {
public:
template<typename T>
auto operator()(T x) const{ return func(normalize(x)); }
…
};

显然,lambda只负责将其参数x转发至normalize。如果normalize区分左右值的话,该lambda无法正确写入,原因在于参数x永远是一个左值,lambda永远将一个左值传递给normalize。

修改方法很容易:使用完美转发。这意味着x将成为一个universal reference,并且需要使用std::forward,不过这些都只是微不足道的小小修改:

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

唯一存在疑问的地方在于,forward的模板参数应该是何种类型?

在通常情况下,完美转发存在于一个模板参数为T的模板函数中,因此我们在使用时只需要编写std::forward <T>即可,但lambda中不存在模板参数,因此我们写不了。


问题剖析与解决

 
众所周知,universal reference的性质由其初始化对象决定,若由左值初始化则变为左值引用,反之则为右值引用,因此我们可以通过判断x的类型来推断传递的参数是左值还是右值,此时decltype帮了大忙。如果传入的是左值,那么x必然是左值引用,反之则必为右值引用。但Item28亦曾提及,若传入左值,那么此时推衍类型T为左值引用,但如果传入右值,则推衍T为non-reference,这与decltype(x)将表现为右值引用有所背离。

如下是C++14中std::forward的实现:

1
2
3
4
template<typename T>
T&& forward(remove_reference_t<T>& param){
return static_cast<T&&>(param);
}

如果当前客户端需要forward一个右值Widget,此时forward将被实例化为:
1
2
3
Widget&& forward(Widget& param){
return static_cast<Widget&&>(param);
}

但如果当T被指定为Widget&&时,forward实例化如下所示:
1
2
3
Widget&& && forward(Widget& param){
return static_cast<Widget&& &&>(param);
}

执行引用塌缩之后,forward转为:
1
2
3
Widget&& forward(Widget& param){
return static_cast<Widget&&>(param);
}

显然,当我们将右值引用传入forward时,其效果与non-reference相同。这无疑是一个好消息,这意味着我们把decltype(x)的结果(右值引用)传递给forward时,其展现出的效果与non-reference一致,因此,完美转发可以被写为:
1
auto f =[](auto&& param){return func(normalize(std::forward<decltype(param)>(param)));};

如果当前需要转发的是一个参数集,那么在C++14中它可以被写为:
1
auto f =[](auto&&... params){return func(normalize(std::forward<decltype(params)>(params)...));};

32.使用init捕获将对象移动到闭包中

Posted on 2018-07-16 | In Effective Modern C++

前言

 
可能在某时你会发现无论是按值捕获还是按引用捕获均非最佳选择。例如C++11无法将一个move-only对象(std::unique_ptr、std::future)移动进入闭包.又或者当前存在一个copy成本极高但move成本极低的对象(例如STL中的大部分容器),C++11也无法将其移动进入闭包。

这项C++14引入的新功能被称为init capture,它几乎可以完成C++11捕获表单可以执行的所有操作,以及更多功能,唯一的缺点是它无法表现地如同默认捕获,但前一节我们介绍了最好不要使用它们。此外,init捕获的语法略显冗长,因此如果C++11中别的捕获模式已经能够完成任务,我们没有必要去使用init捕获。


init捕获

 
init捕获可以帮助我们指定

  1. 由lambda所生成的closure class中data meber的名称
  2. 该data member的初始化表达式

下述实例将展示如何通过init捕获将std::unique_ptr移动入闭包:

1
2
3
4
5
6
7
8
9
10
11
12
class Widget { // some useful type
public:
…
bool isValidated() const;
bool isProcessed() const;
bool isArchived() const;
private:
…
};
auto pw = std::make_unique<Widget>(); // create Widget
… // configure *pw
auto func = [pw = std::move(pw)]{ return pw->isValidated()&& pw->isArchived(); };

其中[pw = std::move(pw)]即为init捕获。“=”左侧是指闭包类中data member的名称,右侧是其初始化表达式。有趣的是,“=”左侧与右侧具备不同的作用域,左侧的作用域为closure class,右侧为lambda定义区。在上述实例中,“=”左侧的名称pw为closure class中的data member,而右侧的名称pw意味着lambda上文中刚刚创建的std::unique_ptr。最终,利用std::move将上文中的std::unique_ptr初始化closure class中的data member。lambda体中的代码处于closure class的作用域内,因此pw代表闭包类数据成员。

在以pw初始化data member之前,我们可能会对堆中的Widget作出修改,这也正对应着注释部分的configur *pw。如果我们当前无需修改,则可省去局部对象pw,直接以make_unique返回值初始化data member:

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

init捕获又被称为广义捕获。


在C++11中实现移动捕获

 
我们明确的是,lambda是一种生成类的方法,那么我们自然可以在C++11中避开lambda直接生成自己需要的class,因此,刚才C++14所写的代码可以被转换为:

1
2
3
4
5
6
7
8
9
class IsValAndArch { 
public:
using DataType = std::unique_ptr<Widget>;
explicit IsValAndArch(DataType&& ptr):pw(std::move(ptr)) {}
bool operator() const{ return pw->isValidated() && pw->isArchived(); }
private:
DataType pw;
};
auto func = IsValAndArch(std::make_unique<Widget>());

虽然多打了很多字,但它至少证明了C++11也能支持移动捕获。

如果你非要既使用C++11的lambda,又需要移动捕获,那你得按照以下步骤行事:

  1. 将要捕获的对象移动到std::bind生成的函数对象中
  2. 给lambda一个对“捕获”对象的引用

假设当前我们需要创建一个局部对象std::vector,将一组适当的值放入其中,然后将其移动到闭包中。上述行为在C++14中很容易实现:

1
2
3
std::vector<double> data; // object to be moved into closure
… // populate data
auto func = [data = std::move(data)]{ /* uses of data */ };// C++14 init capture

在C++11中,同等的实现需要被写为:
1
2
3
std::vector<double> data; // as above
… // as above
auto func =std::bind([](const std::vector<double>& data){ /* uses of data */ },std::move(data));

类似于lambda表达式,std::bind生成函数对象。std :: bind的第一个参数是一个可调用对象,后续参数表示要传递给该对象的值。bind对象在传递参数给第一个可调用对象时,采用左值拷贝构造,右值移动构造的方式。需要注意的是,()中是一个左值引用,原因在于bind将data传递至闭包后,data是一个左值。此外,为了防止修改数据,我们将一个const-refernce绑定至data,这是因为lambda生成的class默认operator()为const。

由于bind对象内含其所有实参的副本,因此其生命周期与闭包的生命周期一致。总而言之,利用bind模拟移动捕获的要点有三:

  1. 在C++11中,无法将一个对象移动进入闭包,但可以将其移动进入一个bind对象。
  2. 在模拟过程中,我们只需要把对象移动进入bind对象,然后以引用形式将其传递给闭包
  3. 因为bind对象的生命周期与闭包的生命周期相同,所以可以将bind对象中的对象视为处于闭包中

31.避免默认捕获模式

Posted on 2018-07-16 | In Effective Modern C++

前言

 
C++11中有两种默认捕获模式:by-reference和by-value。默认的引用捕获可能导致空悬引用。默认的按值捕获让你觉得你的闭包是自包含(self-contained)的(它们可能不是)。


by-reference

 
引用捕获会导致闭包包含对局部变量或lambda的作用域中可用参数的引用,如果从该lambda创建的闭包的生命周期超过局部变量或参数的生命周期,则闭包引用空悬。举例而言,假设当前存在一个容器,其元素为过滤函数,每个函数都接受一个int并返回一个bool,指示传入的值是否满足过滤器:

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

我们向其中添加一个元素:
1
filters.emplace_back([](int value) { return value % 5 == 0; });

但实际上我们可能需要的是一个在运行期决定的除数,即5不可写死在lambda中,因此我们决定这么写:
1
2
3
4
5
6
void addDivisorFilter(){
auto calc1 = computeSomeValue1();
auto calc2 = computeSomeValue2();
auto divisor = computeDivisor(calc1, calc2);
filters.emplace_back([&](int value) { return value % divisor == 0; }); // danger
}

lambda引用了局部变量divisor,但当addDivisorFilter返回时,该变量不再存在,因此我们向容器内部添加了一个错误的函数对象。
显式声明引用捕获divisor也会导致同样的问题:
1
filters.emplace_back([&divisor](int value){ return value % divisor == 0; });

但这种写法更容易看出该lambda的可行性取决于divisor的寿命,它提醒我们要确保divisor至少与lambda的闭包具备一样长的生命周期。

事实上,如果立即使用闭包(例如直接传入STL算法)且不copy它们,一般情况下不会触发引用空悬的情况,因此你可能会争辩说默认的by-reference不可能触发空悬引用。举例而言,我们的过滤lambda只能用作std::all_of的参数,它返回范围内的所有元素是否满足指定条件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template<typename C>
void workWithContainer(const C& container){
auto calc1 = computeSomeValue1(); // as above
auto calc2 = computeSomeValue2(); // as above
auto divisor = computeDivisor(calc1, calc2);
using ContElemT = typename C::value_type;
using std::begin;
using std::end;
if (std::all_of(begin(container), end(container),
[&](const ContElemT& value){ return value % divisor == 0; }
)
){
…
} else {
…
}
}

C++14的新特性可以简化上述程序为:
1
2
if (std::all_of(begin(container), end(container),
[&](const auto& value){ return value % divisor == 0; }))

确实,这是安全的,但它的安全性有些不稳定。如果该发现lambda在其他上下文中发挥作用(例如需要被添加到容器中)并且闭包存在复制行为(且当前局部对象已经析构),那么我们终将雪崩。

从软件工程的角度而言,在lambda中明确标识捕获对象具备更高的鲁棒性(它至少提示了该闭包捕获对象的生存期不应当小于闭包生存期)。


by-value

 
一种解决方案是采用默认的按值传递形式:

1
filters.emplace_back([=](int value) { return value % divisor == 0; });

但按值捕获并不能完全解决空悬问题。举例而言,假设当前你copy了一个指针,你完全无力阻止在lambda之外的程序delete该指针从而导致触发空悬。


在成员函数中使用lambda

假设Widgets可以在过滤器容器中添加条目:

1
2
3
4
5
6
7
class Widget {
public:
… // ctors, etc.
void addFilter() const; // add an entry to filters
private:
int divisor; // used in Widget's filter
};

Widget::addFilter可能被定义为如下形式:
1
2
3
void Widget::addFilter() const{
filters.emplace_back([=](int value) { return value % divisor == 0; });
}

这看起来很安全,但实际上并非如此。

捕获仅适用于在创建lambda范围内可见的非静态局部变量(包括参数)。在Widget::addFilter的主体中,divisor不是局部变量,它是Widget类的数据成员,因此无法捕获。但如果不写默认捕获模式,则下述代码无法编译:

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

此外,如果尝试显式捕获divisor(无论是by-reference还是by-value),代码亦将无法编译,因为divisor既非局部变量亦非参数:
1
2
3
4
5
void Widget::addFilter() const{
filters.emplace_back(
[divisor](int value){ return value % divisor == 0; }
);
}

因此,默认的by-value capture子句并不捕获divisor,但没它又将无法编译,那该如何是好?答案在于this指针,当我们采用默认by-value捕获时:
1
2
3
void Widget::addFilter() const{
filters.emplace_back([=](int value) { return value % divisor == 0; });
}

被捕获的是this指针,而非divisor,编译器会将其视为:
1
2
3
4
5
6
void Widget::addFilter() const{
auto currentObjectPtr = this;
filters.emplace_back(
[currentObjectPtr](int value){ return value % currentObjectPtr->divisor == 0; }
);
}

这一点非常重要,换而言之,它等价于在描述lambda所生成的闭包与该Widget的生存期有关,因为闭包内含this指针的副本,下文代码将使用智能指针来说明这一点:
1
2
3
4
5
6
7
using FilterContainer = std::vector<std::function<bool(int)>>;
FilterContainer filters;
void doSomeWork(){
auto pw = std::make_unique<Widget>();
pw->addFilter(); // add filter that uses Widget::divisor
…
} // destroy Widget; filters now holds dangling pointer!

当dosomework完成后,widget对象析构,容器内加入了一个含有空悬引用的闭包,从而在后续引发雪崩。


解决方案

这一问题可通过制作data member副本,并在捕获时显式拷贝其副本得到解决:

1
2
3
4
5
6
7
void Widget::addFilter() const{
auto divisorCopy = divisor; // copy data member
filters.emplace_back(
[divisorCopy](int value) // capture the copy
{ return value % divisorCopy == 0; } // use the copy
);
}

C++14提出了广义lambda捕获(generalized lambda capture,见Item32),它可以更好地捕获data member:
1
2
3
4
5
6
void Widget::addFilter() const{
filters.emplace_back(
[divisor = divisor](int value) // copy divisor to closure
{ return value % divisor == 0; } // use the copy
);
}


self-contained

 
默认按值捕获的另一大缺点在于它使得闭包看起来具有自包含特性,并且与外界数据的更改产生了隔离。但事实并非如此。lambda不仅可以依赖于局部变量和参数(可以捕获),还可以依赖于具有静态存储持续时间的对象。 此类对象在全局作用域或命名空间内定义,又或者在函数、类、文件中被声明为static。它们可以在lambda中得到使用,但却无法被捕获。但默认按值捕获可能给我们造成了它们已被捕获的错觉,试看实例如下:

1
2
3
4
5
6
7
8
9
10
11
void addDivisorFilter(){
static auto calc1 = computeSomeValue1(); // now static
static auto calc2 = computeSomeValue2(); // now static
static auto divisor = // now static
computeDivisor(calc1, calc2);
filters.emplace_back(
[=](int value) // captures nothing!
{ return value % divisor == 0; } // refers to above static
);
++divisor; // modify divisor
}

某些开发者看到[=]后即认为当前lambda已经具备self-contained属性,不再与外部数据有所交互,实则不然。每一次addDivisorFilter的调用,都会造成容器内部lambda的变化,也就是说,它似乎具备了引用捕获的某些特性。如果不采用默认按值捕获,我们则可以清楚看出当前并未捕获任何对象。


总结

  1. 默认引用捕获可能会导致空悬引用。
  2. 默认按值捕获可能会导致空悬指针(尤其是this指针),并且给人以该lambda自包含的错觉。

Lambda表达式

Posted on 2018-07-16 | In Effective Modern 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中条件变量的谓词规范变得简单明了。

在开始本章之前,我们对lambda进行一个简单的复习:

  1. lambda是一个表达式,当其应用于算法时,其形式大致如下所示:
    1
    2
    std::find_if(container.begin(), container.end(),
    [](int val) { return 0 < val && val < 10; });
  2. 闭包(closure)是一个由lambda创建的运行期对象。
    根据lambda捕获模式的不同,闭包保存捕获数据的副本或引用。在上文对std::find_if的调用中,闭包是运行期find_if所采纳的第三个参数。
  3. 闭包类(closure class)是一个能够实例化闭包的类。
    每个lambda都会导致编译器生成一个唯一的闭包类,lambda中的语句成为其闭包类的成员函数中的可执行指令。

一般而言,lambda用于创建仅用作函数参数的闭包,上文即是这种情况。但闭包具备拷贝性,所以可能会出现对于某些lambda闭包类型的多个闭包(这似乎很容易理解,因为lambda产生了闭包类)。例如在以下代码中:

1
2
3
4
5
6
7
8
{
int x; // x is local variable
…
auto c1 = [x](int y) { return x * y > 55; }; // c1 is copy of the closure produced by the lambda
auto c2 = c1; // c2 is copy of c1
auto c3 = c2; // c3 is copy of c2
…
}

c1,c2和c3都是由lambda所生成的闭包的副本。

在非正式情况下,lambda,闭包和闭包类完全可以等而视之。但在随后的章节中,区分编译期间存在的内容(lambda和闭包类),运行时存在的内容(闭包)以及它们如何相互关联非常重要。

30.了解完美forward的不完美之处

Posted on 2018-07-15 | In Effective Modern C++

前言

 
完美forward是C++11的一大特性,但实际上在某些场合forward并不完美。


完美转发

 
在提及完美转发之前,首先回顾一下转发的定义:一个函数将其参数传递给另一个函数,但关键在于第二个函数接受到的对象必然需要和第一个函数发出的是同一个。这直接排除了传值,因为传值传递的都是副本。指针也被排除,因为我们并不希望调用者试图传递指针。事实上,我们提到的转发一般只与引用有关。

完美转发意味着我们不仅仅转发对象,我们还转发它们的显著特征:type,左右值,以及const或volatile,这直接表明我们只能用universal reference,因为只有它能够对传入参数的左右值加以编码区分。

假设当前有一个转发实例如下:

1
2
3
4
template<typename T>
void fwd(T&& param){
f(std::forward<T>(param)); // forward it to f
}

转发函数在本质上具备通用性。例如,fwd模板接受任何类型的参数,并且它会转发任何类型的参数。这种通用性的逻辑扩展是:转发函数应当成为一个接受可变参数的函数模板,例如fwd的扩展形态应当为:
1
2
3
4
template<typename... Ts>
void fwd(Ts&&... params){
f(std::forward<Ts>(params)...); // forward them to f
}

这种写法广泛见于智能指针工厂函数、std::make_shared、std::make_make_unique。

我们所说的完美转发失败指的是:接受同样的参数,f能够正确执行,fwd则表现地与f有所不同:

1
2
f( expression ); // if this does one thing,
fwd( expression ); // but this does something else

有些参数将造成这些现象,下文将依次探讨并给出解决方案。


Braced initializers

 
假定函数有声明如下:

1
void f(const std::vector<int>& v);

在本案例中,当传入参数为大括号初始化器时,f与fwd表现不同:
1
2
f({ 1, 2, 3 }); // fine, "{1, 2, 3}" implicitly converted to std::vector<int>
fwd({ 1, 2, 3 }); // error! doesn't compile

原因在于大括号初始化器是完美转发的一大失败案例。

在f中,编译器明确了解f需要接受的对象,因此编译器将通过{1,2,3}生成一个临时对象vector,将v绑定至其上。但fwd将推导出{1,2,3}的类型,并将其与f声明的形参相比较,完美转发将会在以下两点情况发生时失效:

  1. 类型推衍失败
    编译器无法推断出fwd接收参数的类型,编译失败。
  2. 类型推衍错误
    这将触发f接纳了编译器推导得到的错误类型,更进一步地触发f函数的某些重载版本。

在上述调用中,fwd无法推衍出{1,2,3}的类型,因为template并不能推衍出initializer_list,因此触发编译错误。

解决方案在Item2中即有所提及:auto能够推衍得到正确的initializer_list,因此我们可以:

1
2
auto il = { 1, 2, 3 }; // il's type deduced to be std::initializer_list<int>
fwd(il); // fine, perfect-forwards il to f


0 or NULL as null pointers

 
在Item8中我们曾经提及,当你传入0或NULL时,编译器将认为它们是一个整形数据而非一个指针,因此转发它们则等价于转发错误的类型(你原本想转发一个指针类型),解决方法十分简单:以nullptr代替它们。


Declaration-only integral static const data members

 
一般来说,我们并不需要在类中定义整型静态const数据成员,单单写出声明足以。原因在于编译器将对这些成员的值执行常量传播,因此无需为它们留出内存,考虑如下代码:

1
2
3
4
5
6
7
8
class Widget {
public:
static const std::size_t MinVals = 28; // MinVals' declaration
…
};
… // no def for MinVals
std::vector<int> widgetData;
widgetData.reserve(Widget::MinVals); // use of MinVals

编译器将会在所有出现MinVals的地方将其替换为28,但如果当前需要采用MinVals的地址(例如创建了一个指向MinVals的指针),那么MinVals将需要存储(以便指针指向某个东西),上述代码能够编译通过,但会在链接期报错。

那么,假设dangqian函数f被声明为:

1
void f(std::size_t val);

那么使用fwd又会导致完美转发失效:
1
2
f(Widget::MinVals); // fine, treated as "f(28)"
fwd(Widget::MinVals); // error! shouldn't link

虽然源代码中没有提及MinVals的地址,但fwd的参数是一个universal reference,编译器在生成代码时通常将引用视作指针,在程序的底层二进制码(以及硬件)中,指针和引用本质上是相同的,但我们并没有在内存中存储MinVal,因此导致了链接期失败。

解决方法很简单,我们不仅仅声明,而且去定义MinVal,这样便可以通过引用转发integral static const data member:

1
const std::size_t Widget::MinVals; // in Widget's .cpp file

值得注意的是该定义并不执行任何初始化操作。


Overloaded function names and template names

 
假定当前f形参为某个函数指针:

1
void f(int (*pf)(int)); // pf = "processing function"

当然,我们也可以用另一种语法来完成声明:
1
void f(int pf(int)); // declares same f as above

更进一步地,假设当前存在重载函数processVal如下所示:
1
2
int processVal(int value);
int processVal(int value, int priority);

我们可以很自然地将其传递给f:
1
f(processVal); // fine

显然,传递至f是参数只有int的重载版本。

但如果将processVal传递给fwd,后者将会产生困惑:究竟传递哪个函数?

1
fwd(processVal); // error! which processVal?

函数模板自然也存在同样的问题,它并不代表某个函数,而是许多函数:
1
2
3
template<typename T>
T workOnVal(T param) { … }
fwd(workOnVal); // error! which workOnVal instantiation?

解决方案十分显然,手动明确传入的是何种重载或实例化即可。例如:

1
2
3
4
using ProcessFuncType = int (*)(int);
ProcessFuncType processValPtr = processVal;
fwd(processValPtr); // fine
fwd(static_cast<ProcessFuncType>(workOnVal)); // also fine


Bitfields

 
假定ipv4头可被建模如下:

1
2
3
4
struct IPv4Header {
std::uint32_t version:4,IHL:4,DSCP:6,ECN:2,totalLength:16;
…
};

若f当前形参为size_t类型,那么以totalLength作为实参传入完全没毛病:
1
2
3
4
void f(std::size_t sz); // function to call
IPv4Header h;
…
f(h.totalLength); // fine

但fwd则可耻地失败了:
1
fwd(h.totalLength); // error!

原因在于fwd的参数是一个引用,而h.totalLength是一个non-const bitfield。C++明确声明:“non-const引用禁止绑定至bitfield。”位域可以由机器字的任意部分组成(例如32位int的3-5位),但我们无法获取指向它们的指针,因此引用绑定到bitfield自然也不行。

解决方案是:创建一个副本以存储Bitfield的值,然后将其传递给fwd:

1
2
3
// copy bitfield value; see Item 6 for info on init. form
auto length = static_cast<std::uint16_t>(h.totalLength);
fwd(length); // forward the copy

29.不必高估move语义

Posted on 2018-07-15 | In Effective Modern C++

前言

 
移动语义可以被认为是C++11的首要特性。它不仅允许编译器用相对便宜的移动替换昂贵的拷贝操作,并且在实际中要求编译器如此运转(在条件满足时),但本节要叙述的是:不要过高地看待移动语义。


类型支持

 
首先,并非所有类型均支持移动语义。在实际使用过程中,可能你当前使用的类型(历史遗留代码)并不支持移动语义。确实,C++11愿意为缺少它们的类生成move操作,但这只发生在声明没有copy操作,移动操作或析构函数的类中(见Item17)。此外,不支持move的data member或base class)也将抑制编译器生成move操作。


支持也未必高效

 
其次,即使该类型明确具备移动语义,它也未必如你所想般高效。举例而言,C++标准库中的所有容器均支持移动语义,但对于某些容器来说,它们的移动语义并不具备真正的低开销,又或者由于容器元素类型的限制,它们无法采用真正高效的move操作。

std::array

以std::array为例,该容器是C++11中的一个新容器。std::array本质上是一个带有STL接口的内置数组。一般而言,STL容器倾向于将其所有元素均保存在堆上,内部元素仅仅是一个指向堆中内容的指针(真实情况较为复杂,但其差异不影响本次讨)。这些指针的存在为移动容器提供了便利:只需要将现有指针指向对应内容,然后将指针置为nullptr即可,现以移动vector为例:

1
2
3
4
5
std::vector<Widget> vw1;
// put data into vw1
…
// move vw1 into vw2. Runs in constant time. Only ptrs in vw1 and vw2 are modified
auto vw2 = std::move(vw1);

image_1cie5hhbvcjm1ukp1m0c1a4713rh9.png-21kB
但std::array将元素直接存储于对象内部,因此其move操作看起来如下所示:

1
2
3
4
5
6
7
std::array<Widget, 10000> aw1;
// put data into aw1
…
// move aw1 into aw2. Runs in
// linear time. All elements in
// aw1 are moved into aw2
auto aw2 = std::move(aw1);

image_1cie5kqjo1pjm150r12rer3g1979m.png-22.1kB

这里需要注意的是,我们必须移动aw1中每一个Widget至aw2,也就是说当前仍然需要线性的时间,可能这快于copy,但也绝非像某些人所说的“移动容器只需要执行拷贝一个指针的时间”。

SSO std::string

又例如,std::string提供常数时间的移动操作与线性时间的拷贝操作,这让我们感觉移动必然快于拷贝,但情况可能并非如此。许多字符串实现采用小字符串优化(SSO)。对于SSO,“小”字符串(例如,容量不超过15个字符的字符串)存储于std::string对象内的buffer中,而并非使用堆中的内存。移动基于SSO技术实现的字符串并不快于拷贝,因为传统仅拷贝一个指针的行为对其并不适用(内容存储于对象内,因此必须对对象有所操作),对于此类对象,copy并不慢于move。


异常安全性

 
即使一切安好,move操作最终也可能会导致copy操作。在Item14中曾经提及,为了不破坏历史遗留代码,C++11中的异常规格必然与C++98中相容,因此,对于某些容器,只有在move操作明确不会抛出异常时才可能会取代copy操作。因此,即使move操作确实优于copy操作且当前环境适用于move(例如参数为右值),编译器在move操作并未声明为noexcept的情况下依然会选择对move视而不见。


总结

  1. 在撰写代码时务必谨慎假设move语义并未发挥作用,但如果你明确当前类型必然支持移动语义,那自然可以大胆使用。

28.了解引用塌缩

Posted on 2018-07-15 | In Effective Modern C++

前言

 
众所周知,当一个左值或右值初始化universal reference时将触发类型推衍,例如:

1
2
template<typename T>
void func(T&& param);

T将会根据传递至param的类型完成推衍、编码。

编码机制十分简单:当传入左值时,T被推导为左值引用。当传入右值时,T被推断为non-reference。因此:

1
2
3
4
Widget widgetFactory(); // function returning rvalue
Widget w; // a variable (an lvalue)
func(w); // call func with lvalue; T deduced to be Widget&
func(widgetFactory()); // call func with rvalue; T deduced to be Widget

显然,传入对象性质决定了universal reference的性质,这也正是forward的工作原理。


引用塌缩

 
在更加深入地了解std::forward与universal refernce之前,我们首先复习一个基础知识:C++禁止出现引用的引用(原因无非是引用并非对象),因此编译器会对如下行为作出警告:

1
2
3
int x;
…
auto& & rx = x; // error! can't declare reference to reference

细想一下,当一个左值引用被传递给形参为universal reference的函数模板时:
1
2
3
template<typename T>
void func(T&& param);
func(w); // invoke func with lvalue;T deduced as Widget&

如果我们推衍出T为Widget&,并以此实例化函数模板,则该模板可表示为:
1
void func(Widget& && param);

这里存在一个引用的引用,这时候问题来了,编译器是如何将函数签名更改为:
1
void func(Widget& param);

答案正是引用塌缩(reference collapsing)。尽管开发者们被禁止声明指向引用的引用,但编译器在特定环境下(例如模板实例化)可以默许这种行为的存在,并通过引用塌缩将其转为正确类型。


引用塌缩法则

由于引用共存在两种(左值、右值),因此引用的引用共存在四种情况:l-l,l-r,r-l,r-r,引用塌缩将按照如下法则将引用的引用转换为单一引用:只要存在左值引用,则最终结果为左值引用,因此仅有r-r为右值引用。


引用塌缩与forward

 
引用塌缩是std::forward工作的核心所在,考虑下述实例:

1
2
3
4
5
template<typename T>
void f(T&& fParam){
… // do some work
someFunc(std::forward<T>(fParam)); // forward fParam to someFunc
}

std::forward的工作是当且仅当T被推衍为non-reference时,将fParam(一个左值)强制转换为右值,其实现可表示为(与标准库相比缺少了一些接口描述):
1
2
3
4
template<typename T>
T&& forward(typename remove_reference<T>::type& param){
return static_cast<T&&>(param);
}

其中,typename remove_reference<T>::type& param保证了param必然以一个左值的身份接受强制转换。当T是一个左值(即类型推衍中T由一个左值初始化)时,T&&仍然是一个左值,因此param被转换为左值引用,返回一个左值。当T是一个non-reference(即类型推衍中T由一个右值初始化时),T&&为右值引用(r-r情况),此时左值引用param被转换为右值引用,而经函数返回的右值引用为右值,最终forward返回了右值。

在C++14中,forward可被更进一步简化为:

1
2
3
4
template<typename T>
T&& forward(remove_reference_t<T>& param){
return static_cast<T&&>(param);
}


引用塌缩的四大应用场景

auto对象生成

auto对象生成也将触发引用塌缩,其具体细节类似于模板实例化,假设当前存在声明与定义如下:

1
2
Widget widgetFactory(); // function returning rvalue
Widget w; // a variable (an lvalue)

当定义w1为:
1
auto&& w1 = w;

此时等价于:
1
Widget& && w1 = w;

触发引用塌缩转为:
1
Widget& w1 = w;

最终,w1为左值引用。

当定义w2为:

1
auto&& w2 = widgetFactory();

由于T被推衍为non-reference,此时并未触发引用塌缩,w2为Widget&&。

时至今日我们终于能够完全理解universal reference,它并非一种新的引用,而是仅在符合2种条件的特定环境下的右值引用:

  1. 区分左右值的类型推衍
    在此条件下,左值将推衍T为T&,右值则推衍T为non-reference。
  2. 触发引用塌缩

typedef与alias declaration

如果在typedef与alias declaration创建中出现引用的引用,则将触发引用塌缩,例如:

1
2
3
4
5
6
template<typename T>
class Widget {
public:
typedef T&& RvalueRefToT;
…
};

如果我们以一个左值引用实例化Widget:
1
Widget<int&> w;

此时RvalueRefToT将被表示为:
1
typedef int& && RvalueRefToT;

最终触发引用塌缩,RvalueRefToT被表示为:
1
typedef int& RvalueRefToT;

这一结果表明Typedef的结果似乎可能并非我们所愿。

decltype

在decltype推衍中如果存在引用的引用,则将利用引用塌缩予以消除。


总结

  1. 引用塌缩触发于四种特定环境:模板实例化、auto类型生成、typedef与alias declaration以及decltype推衍。
  2. 在引用塌缩过程中,只要存在左值引用,则最终结果为左值引用,否则即为右值引用。
  3. universal reference可视为特定环境下的右值引用。

27.universal reference的替代方案与修正

Posted on 2018-07-14 | In Effective Modern C++

前言

 
上一节描述了针对universal reference作重载的函数可能出现的各种问题,本节将在上一节讨论的基础之上将提出各种解决方案。


Abandon overloading

 
简单地来说,可以根据参数名设置不同的函数,例如将logAndAdd写为logAndAddName与logAndAddNameIdx,不过这种方法并不适用于Person,此外,放弃重载并不明智。


Pass by const T&

 
事实上Item26中提出过这种写法,不过对于string来说它可能会生成一个临时对象从而降低效率,权衡利弊后这不失为一种解决方案。


Pass by value

 
Itemn41建议说,如果你明确知道会发生copy操作,请使用pass by value。因此我们试以Person为例执行pass by value:

1
2
3
4
5
6
7
8
class Person {
public:
explicit Person(std::string n): name(std::move(n)) {} // Item 41 for use of std::move
explicit Person(int idx):name(nameFromIdx(idx)){}
…
private:
std::string name;
};

因为没有std::string构造函数只接受一个整数,因此传递给Person构造函数的所有int和intlike参数(例如,std::size_t,short,long)都会触发int重载函数调用。类似地,类型为std::string的所有参数(以及可以创建std::strings的东西,例如const char *)都会触发string重载函数调用。


Use Tag dispatch

 
无论是pass by const T&还是pass by value均不支持forward。如果我们需要使用forward,那么必须配合使用universal reference。有什么方法可以在不放弃使用universal reference的前提下使用重载吗?

解决方法是,查看所有可能使用的实参类型,然后根据它们的类型依次建立最佳重载版本。universal reference通常可以为所有实参提供精确匹配,但如果universal reference是包含非universal refence的其他参数的参数列表的一部分,其匹配优先级将会降低(此即是Tag dispath的技术基础),下文将以实例举证。

首先,这是logAndAdd的实现:

1
2
3
4
5
6
7
std::multiset<std::string> names; // global data structure
template<typename T>
void logAndAdd(T&& name){
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}

这个函数能够正常运行,但如果需要增加int型重载则将陷入Item26所述困境,本节我们将重新实现logAndAdd,以委托两个其他函数的方式(一个接受int,一个接受别的),logAndAdd本身可以接受一切类型的参数。

执行实际工作的两个函数将命名为logAndAddImpl(依然使用了重载),其中一个以universal reference作为参数,因此我们仍然在执行重载与universal reference并行的策略。但本次每个函数都存在一个第二参数,该存在用以表征传入参数是否是一个int类型。也就是说,logAndAddImpl大概长这样:

1
2
3
4
5
template<typename T>
void logAndAdd(T&& name){
logAndAddImpl(std::forward<T>(name),
std::is_integral<T>()); // not quite correct
}

该代码并不保证正确,原因在于当一个左值被传递至universal reference name,那么T将被推衍成为一个左值引用,因此如果一个int型左值作为实参传入,当导致T被推衍为int&,这并非是一个int类型,因此is_integral将永远返回false。当我们意识到这个问题的时候就等价于解决了这个问题,Item9中提及的type trait(std::remove_reference)几乎为此而生(C++14可以使用std::remove_reference_t<T>):
1
2
3
4
5
6
7
template<typename T>
void logAndAdd(T&& name){
logAndAddImpl(
std::forward<T>(name),
std::is_integral<typename std::remove_reference<T>::type>()
);
}

至此,我们可以将注意力集中至logAndAddImpl,针对非整数类型的重载版本如下所示:
1
2
3
4
5
6
template<typename T>
void logAndAddImpl(T&& name, std::false_type){
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}

其中的一大难点在于true和false都是运行期值,而我们需要在编译期明确调用函数,这意味着我们需要一个对应于true的类型和一个对应于false的类型,针对这种常见需求,标准库提供了std::true_type和std::false_type。
第二个重载版本如下所示:
1
2
3
4
std::string nameFromIdx(int idx);
void logAndAddImpl(int idx, std::true_type){
logAndAdd(nameFromIdx(idx));
}

在此实现中,我们避免了在logAndAddImpl中执行log(这一设计十分巧妙)。

在此设计中,类型std::true_type和std::false_type是“tag”,其唯一目的在于强制执行我们需要的重载函数。为了优化性能,我们甚至没有给这些参数命名。这种设计常见于TMP之中,当代C++库函数源码中经常出现这种设计。


抑制带有universal references的模板实例化

 
Tag dispatch的使用前提是客户端具备单一(非重载)函数,这些非重载函数内涵某些重载函数。但在Item26中的Person类中,默认生成的函数将自发与开发者定义的函数形成重载,因此tag dispatch并非完美无缺。针对这种universal refernce总被触发的情况,我们还有一招:std::enable_if。

std::enable_if可以强制令编译器认为某个特化模板并不存在(视作被禁用)。在默认情况下,所有模板都已启用,但仅当满足std::enable_if指定的条件时,才会启用使用std::enable_if的模板。以上一节的实例而言,只有当传递的类型不是Person时,我们才想启用Person forward构造函数。如果传递的类型是Person,我们则需要禁用forward构造函数(即令编译器忽视该函数),以copy ctor或move ctor触发调用。std::enable_if的使用范例如下所示:

1
2
3
4
5
6
class Person {
public:
template<typename T,typename = typename std::enable_if<condition>::type>
explicit Person(T&& n);
…
}

本书并不介绍关于std::enable_if的具体细节,而是将注意力转向其启用条件表达式。我们需要指定的条件是:仅在T并非Person类型时才会触发模板实例化。type traits能够表征两个类型是否相同:
1
!std::is_same<Person,T>::value

但这并不正确,如果universal refernce被左值初始化,那么T必然是一个左值引用,这导致下述代码:
1
2
Person p("Nancy");
auto cloneOfP(p); // initialize from lvalue

此时T将会被推衍为Person&,从而导致is_same返回false。

显然,针对推衍得到的T类型,我们应当忽略其:

  1. reference
    为了确定是否应当启用universal reference构造函数,我们应当将Person&、Person&&统一视为Person。
  2. const and volatile
    我们应当将const Person、volatile Person以及const volatile Person都视为Person。

type traits再一次立了大功,它提供了std::decay<T>以消去refernce、cv限定符(实际上它还能负责将数组、函数退化为指针)。综上,我们可以得到condition表达式:

1
!std::is_same<Person, typename std::decay<T>::type>::value

最终,我们得到转发函数如下所示:
1
2
3
4
5
6
7
8
9
10
11
class Person {
public:
template<
typename T,
typename = typename std::enable_if<
!std::is_same<Person,typename std::decay<T>::type>::value
>::type
>
explicit Person(T&& n);
…
};

继承

假设派生自Person的类以常规方式实现copy与move操作:

1
2
3
4
5
6
class SpecialPerson: public Person {
public:
SpecialPerson(const SpecialPerson& rhs):Person(rhs){ … }
SpecialPerson(SpecialPerson&& rhs):Person(std::move(rhs)){ … }
…
};

在Item26中我们曾经提及,上述copy、move操作可能并不调用基类对应的函数,而是会选择调用转发函数,原因在于universal reference具备更高的匹配程度(足以匹配const)。对于这种情况,发生在基类之中,确切地来说,我们当前认定Person将仅在传入参数不为Person或其派生类型时才会触发forward构造函数。

type traits再次发挥了作用,std::std::is_base_of&T1, T2>::value将在T2为T1的派生类时返回true(T自身亦可视为T的派生类),因此,我们可以利用它改写刚才的condition:

1
2
3
4
5
6
7
8
9
10
11
class Person {
public:
template<
typename T,
typename = typename std::enable_if<
!std::is_base_of<Person,typename std::decay<T>::type>::value
>::type
>
explicit Person(T&& n);
…
};

利用C++14可以保证我们的代码更加优雅:
1
2
3
4
5
6
7
8
9
10
11
class Person {
public:
template<
typename T,
typename = std::enable_if_t<
!std::is_base_of<Person,std::decay_t<T> >::value
>
>
explicit Person(T&& n);
…
};


区分int与non-int

 
最终,我们将实践如何用enable_if区分int与non-int,我们需要做的只有两步:

  1. 添加一个Person构造函数以处理int类型(以重载的形式)
  2. 限制模板实例化,对某些参数禁用该模板

实践效果如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Person {
public:
template<
typename T,
typename = std::enable_if_t<
!std::is_base_of<Person, std::decay_t<T>>::value &&
!std::is_integral<std::remove_reference_t<T>>::value
>
>
explicit Person(T&& n):name(std::forward<T>(n)){ … }
explicit Person(int idx):name(nameFromIdx(idx)){ … }
… // copy and move ctors, etc.
private:
std::string name;
};


比较

 
本节所介绍的前三种技术:abandoning overloading, passing by const T&, and passing by value都需要明确为被调用的函数指明所有潜在参数类型,而后两种技术:tag dispatch,constraining template eligibility则使用转发函数,因此不必说明参数类型。是否说明参数类型这一行为直接决定了这些技术的特性。

一般而言,forward函数更为高效,因为它不必创建临时对象,但它亦存在缺点,例如并非所有参数都能做到完美转发(Item30见对此作出详细探讨),此外,forward函数可能会误将错误信息传递至对应的构造函数,举例而言,假设创建Person对象的客户端传递由char16_t(C++11中引入的类型,代表16位字符)而不是char组成的字符串常量:

1
Person p(u"Konrad Zuse"); // "Konrad Zuse" consists of characters of type const char16_t

前三种技术将会发现当前仅可采用int或string构造Person,因此产生明确的错误信息。而forward函数会自发将char16_t传入string构造函数,从而导致雪崩。一般来说,系统越复杂,转发层级越多,因此故障的排查也将愈加困难,一般而言,保留univeral reference的原因往往在于当前需要效率至上。

对于Person而言,我们明确universal reference应当构成string的初始化器,因此我们可以使用static_assert配合type traits std::is_constructible加以判断,最终形成的判断句如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Person {
public:
template<
typename T,
typename = std::enable_if_t<
!std::is_base_of<Person, std::decay_t<T>>::value &&
!std::is_integral<std::remove_reference_t<T>>::value
>
>
explicit Person(T&& n):name(std::forward<T>(n)){
static_assert(
std::is_constructible<std::string, T>::value,
"Parameter n can't be used to construct a std::string"
);
… // the usual ctor work goes here
}
… // remainder of Person class (as before)
};

26.避免针对universal reference作出重载

Posted on 2018-07-13 | In Effective Modern C++

问题实例

 
假设当前需要一个以name作为参数的函数,该函数负责记录当前日期和时间,然后将name添加到一个全局数据结构中,它的一大可能实现如下所示:

1
2
3
4
5
6
std::multiset<std::string> names; // global data structure
void logAndAdd(const std::string& name){
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd"); // make log entry
names.emplace(name);
}

该程序能够运行,但存在性能上的提升空间,考虑下述三种调用情况:
1
2
3
4
std::string petName("Darla");
logAndAdd(petName); // pass lvalue std::string
logAndAdd(std::string("Persephone")); // pass rvalue std::string
logAndAdd("Patty Dog"); // pass string literal

在第一次调用中,name被绑定于一个左值,转入logAndAdd后,name最终被传入names.emplace。因为name是一个左值,因此最终它被copy至names,我们无法阻止这种拷贝,因为移动左值会导致局部变量(petname)的失效。

在第二次调用中,name被绑定于一个右值,但需要明确的是name自身是一个左值,因此它被copy至names,此处可用move代替copy以提升性能。

在第三次调用中,我们首先从const char *处构造了一个临时string对象,然后用name绑定了该右值,随后将name复制到names,可见这里做了大量的无用功,我们本应直接利用const char*在names中构建string。


解决方案

 
我们可以按照Item24、25中所提及的方法重写logAndAdd:

1
2
3
4
5
6
7
8
9
10
template<typename T>
void logAndAdd(T&& name){
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}
std::string petName("Darla"); // as before
logAndAdd(petName); // as before, copy lvalue into multiset
logAndAdd(std::string("Persephone")); // move rvalue instead of copying it
logAndAdd("Patty Dog"); // create std::string in multiset instead


更进一步的问题实例

 
事实上客户端并非总是能够直接获取name,有些客户端只拥有一个获取对应name的索引,针对这种情况,我们将logAndAdd重载:

1
2
3
4
5
6
std::string nameFromIdx(int idx); // return name corresponding to idx
void logAndAdd(int idx){
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(nameFromIdx(idx));
}

但这个重载版本的鲁棒性不太好:
1
2
3
short nameIdx;
… // give nameIdx a value
logAndAdd(nameIdx); // error!


问题剖析

 
当前存在两个重载版本,带有universal reference的版本可以轻松地推衍出short,然后实现精准匹配,然后雪崩,参数为int的版本根本说不上话。

我们可以认为以universal reference为参数的函数几乎可以与任何参数完成精准匹配(因为它几乎可以绑定至一切类型,不能匹配的几种情况可见Item30),这也说明重载以universal reference的函数并不可取。


转发函数与默认函数

 
我们设想一下当前存在一个class Person:

1
2
3
4
5
6
7
8
9
class Person {
public:
template<typename T>
explicit Person(T&& n):name(std::forward<T>(n)) {}
explicit Person(int idx):name(nameFromIdx(idx)) {}
…
private:
std::string name;
};

类似于logAndAdd,当传入size_t或者short时直接可能会导致Person构造函数雪崩,不过当前问题比那个还要严重。原因在于重载函数的个数远比你肉眼所见的要多。Item17曾经提及在适当的条件下,C++将会自动生成copy constructor与move constructor,即使该class已经具备了能够实例化构造函数的模板。因此,我们可以认为Person实际上如下所示:
1
2
3
4
5
6
7
8
class Person {
public:
template<typename T>
explicit Person(T&& n):name(std::forward<T>(n)) {}
explicit Person(int idx); // int ctor
Person(const Person& rhs); // copy ctor(compiler-generated)
Person(Person&& rhs); // move ctor(compiler-generated)
};

考虑一下以下调用:
1
2
Person p("Nancy");
auto cloneOfP(p); // create new Person from p;this won't compile!

上述操作不会调用copy ctor,而是会调用转发构造函数,然后该函数将尝试使用Person对象p初始化Person的std::string数据成员。std::string没有接受Person类型的构造函数,因此触发雪崩。有人会疑惑编译器为什么不调用copy ctor,这是由C++重载函数调用准则决定的。

cloneOfP正在使用非const左值(p)进行初始化,这表明当前可以实例化出一个以non-const Person为参数的构造函数:

1
2
3
4
5
class Person {
public:
explicit Person(Person& n):name(std::forward<Person&>(n)) {}
explicit Person(int idx);
}

在调用语句中,
1
auto cloneOfP(p);

p似乎既可以触发copy ctor也能触发实例化函数,但实际上调用copy ctor时需要为p添加const,而实例化函数则不需要这项要求,因此编译器选择最佳匹配方案:调用转发构造函数。

如果我们将p改为const,那么copy ctor则可被顺利调用:

1
2
const Person cp("Nancy"); // object is now const
auto cloneOfP(cp); // calls copy constructor!

此时同样会实例化一个以const Person&为参数的转发构造函数,但在C++重载规则中,非模板函数优先级总是高于模板函数,因此本次将调用copy ctor。

一旦引入继承,转发函数与自动生成的函数之间会产生更多的交互,例如:

1
2
3
4
5
class SpecialPerson: public Person {
public:
SpecialPerson(const SpecialPerson& rhs):Person(rhs){ … }
SpecialPerson(SpecialPerson&& rhs):Person(std::move(rhs)){ … }
};

需要注意的是,派生类的拷贝、移动构造函数不会调用它们的基类对应的此类函数,而是将调用基类的转发构造函数。原因在于传入的实参优先匹配于转发函数。


总结

 
本节并未描述问题解决方案,在下一节中我们将对此继续讨论。

  1. universal refernce作为参数的函数不适合被重载。
  2. 转发函数经常会干涉别的函数的调用,并可能会因此产生极为恶劣的后果。

25.对右值引用使用move,对universal reference使用forward

Posted on 2018-07-13 | In Effective Modern C++

move与forward复习

 
右值引用绑定至可移动的对象,如果你当前持有一个右值引用参数,你应当明确该对象具备移动特性:

1
2
3
4
class Widget {
Widget(Widget&& rhs); // rhs definitely refers to an object eligible for moving
...
}

为了充分利用对象的移动特性,我们可以利用std::move将上述移动构造定义为:

1
2
3
4
5
6
7
8
class Widget {
public:
Widget(Widget&& rhs):name(std::move(rhs.name)),p(std::move(rhs.p)){ … }
…
private:
std::string name;
std::shared_ptr<SomeDataStructure> p;
};

universal reference也可绑定至可移动的对象,为了确保它能够正常运转,一般而言需要对它执行std::forward操作:
1
2
3
4
5
6
7
8
class Widget {
public:
template<typename T>
void setName(T&& newName){ // name is a universal reference
name = std::forward<T>(newName);
}
…
};

简而言之,右值引用在转发至其他函数时应当被无条件地转为右值(使用move),而universal reference则需要有条件地转为右值(使用forward),因为它们仅有在被右值初始化时才能体现出右值特性。


问题实例

 
在Item23中曾经提及到forward亦作用于右值,只是使用起来较为繁琐(需要手动声明类型),但相对于繁琐,将move作用于universal reference则可能会导致雪崩,因为它可能会修改某个左值(比如说强行移动了某个局部变量):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Widget {
public:
template<typename T>
void setName(T&& newName) // universal reference
{ name = std::move(newName); } // compiles, but is bad, bad, bad!
…
private:
std::string name;
std::shared_ptr<SomeDataStructure> p;
};
std::string getWidgetName(); // factory function
Widget w;
auto n = getWidgetName(); // n is local variable
w.setName(n); // moves n into w!
… // n's value now unknown

当然,你也可能会争辩说这个程序设计存在问题,setName不会修改参数,因此应当具备const属性从而规避universal reference,正确的写法应当是针对const左值与可被移动的右值作出重载:

1
2
3
4
5
6
7
8
9
10
class Widget {
public:
void setName(const std::string& newName){
name = newName;
}
void setName(std::string&& newName){
name = std::move(newName);
}
…
};

我们可以认为重载版本存在3个缺陷:

  1. 较多的源代码
  2. 性能较低
  3. 较低的扩展性

缺陷1不言自明,下面重点讨论2与3。


性能分析

 
假设当前存在调用如下:

1
w.setName("Adela Novak");

若采用universal reference版本,字符串“Adela Novak”(此时类型是const char* 而非string)将被传递给setName后直接作为string赋值运算符参数,因此,w的name数据成员将直接经由该字符串赋值,不会出现任何临时对象。但重载版本将为setName的参数创建一个临时的std::string对象(因为常量字符串并非string类型),然后将此临时std::string移动到w的数据成员中。因此,对setName的调用将需要执行一个std::string构造函数(用于创建临时对象),一个std::string移动赋值运算符(将newName移动到w.name),以及一个std::string析构函数(用于析构临时对象),这一系列操作必然花费不菲。


扩展性分析

 
setName只存在一个参数,重载版本需要给出两个版本,显然函数参数一旦增长,重载函数的数量将以指数级别增长,此外,某些函数的参数甚至是无限数量,其中的典型案例即为std::make_shared与std::make_unique,它们的声明分别是:

1
2
3
4
template<class T, class... Args>
shared_ptr<T> make_shared(Args&&... args);
template<class T, class... Args>
unique_ptr<T> make_unique(Args&&... args);

此时必须使用universal reference,而forward是它的最佳拍档。


move与forward的使用时机

 
在某些情况下,我们可能会在单个函数中多次使用绑定到右值引用或universal reference的对象,在此情况下,我们需要保证在结束使用前该对象不会被移动。此时,我们只能在最后一次使用中使用move(右值引用)或forward(universal reference):

1
2
3
4
5
6
template<typename T>
void setSignText(T&& text){ // univ reference
sign.setText(text); // use text, but don't modify it
auto now = std::chrono::system_clock::now();// get current time
signHistory.add(now,std::forward<T>(text)); // conditionally cast text to rvalue
}

move使用与之同理,只是有时你需要使用move_if_noexcept,具体原因可见Item14。


move、forward与函数返回值

 
若一个函数需要返回一个绑定至右值引用或universal reference的对象,则在返回语句中需要使用move或forward。

move

试以矩阵加法举例:

1
2
3
4
Matrix operator+(Matrix&& lhs, const Matrix& rhs){
lhs += rhs;
return std::move(lhs); // move lhs into return value
}

如果我们忽视对move的调用:
1
2
3
4
Matrix operator+(Matrix&& lhs, const Matrix& rhs){
lhs += rhs;
return lhs; // copy lhs into return value
}

由于lhs是一个左值,因此编译器只能执行copy操作,这对性能影响较大。就算该类型不支持移动构造,最坏结果也无非就是执行一次copy而已,不会引发任何错误。

forward

forward类似于move。考虑现有一个函数模板reduceAndCopy,它接受一个可能未减少的Fraction对象,函数负责减少它,然后返回减少值的副本(看起来像是后缀自减)。如果原始对象是右值,则应将其值移入返回值(从而避免制作副本的费用),如果原始值为左值,则必须创建副本:

1
2
3
4
5
template<typename T>
Fraction reduceAndCopy(T&& frac){
frac.reduce();
return std::forward<T>(frac); // move rvalue into return value, copy lvalue
}

如果不采用forward,则将无条件执行复制操作。

不适用场景(返回局部变量)

有些开发者可能会刻舟求剑,将上述使用范例推广至不适用之处,他们可能会作出如下判断:“如果利用std::move可以直接移动构建返回值,那返回局部变量时可能亦可使用”,于是他们写出这样的代码:

1
2
3
4
5
Widget makeWidget(){
Widget w; // local variable
… // configure w
return std::move(w); // move w into return value
}

这种行为并不可取,应当采用RVO加以改进(见More Effective C++ Item20)。


总结

  1. 将move应用于右值引用,forward应用于universal refernce。
  2. 当需要返回非局部对象时,采用建议1。
  3. 返回局部对象时使用RVO。
<i class="fa fa-angle-left"></i>1…678…27<i class="fa fa-angle-right"></i>

xander.liu

266 posts
11 categories
36 tags
RSS
GitHub E-Mail
© 2024 xander.liu
Powered by Hexo
|
Theme — NexT.Pisces v5.1.4