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

(本节标题完美揭示了全部主旨,不宜翻译)

问题实例

 
假定当前存在一个Widget class,其成员函数addName接受一个string并将其添加到内部容器中。一般为了保证效率,我们通常会对左值string执行pass-by-const-reference,对右值执行move:

1
2
3
4
5
6
7
8
9
10
class Widget {
public:
void addName(const std::string& newName) // take lvalue;
{ names.push_back(newName); } // copy it
void addName(std::string&& newName) // take rvalue;
{ names.push_back(std::move(newName)); } // move it;

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

以上代码运行地非常良好,就是函数可能太多了一点,我们不得不写下两份声明,两份实现,两份文档。。。诸如此类。此外,目标码中将存在两个函数,这无疑会导致程序的占用空间增大。内联会优化这一行为,但你并不能保证内联的必然执行。

另一种方法是使addName成为一个形参为universal reference的函数模板:

1
2
3
4
5
6
7
8
class Widget {
public:
template<typename T> // take lvalues and rvalues;
void addName(T&& newName) {
// copy lvalues,move rvalues;
names.push_back(std::forward<T>(newName));
}
};

这减少了源码的大小,但可能会造成更加恶劣的情况。作为模板,addName的实现通常必须位于头文件中,因此它可能在目标码中生成相当多的函数,原因在于它不仅对lvalue和rvalue产生不同的实例化,它还对std::string和可转换为std::string的类型产生不同的实例化(见Item25)。此外,有些参数类型无法通过universal reference传递(见Item30),如果客户传递了不正确的参数类型,编译器所提出的错误报告可能会令人头大(见Item27)。


解决方案

 
有没有一种函数,既能针对不同情况作出不同处理(copy左值,移动右值),又能只在源码和目标码中仅仅出现一次,并且还不需要使用universal reference?事实上真的有。我们唯一要做的就是摒弃入门时的第一条C++准则——禁止对自定义类型采用值传递形式。对于Addname中的newname这种参数,采用值传递确实是一种正当选择。以下是值传递版本的实现:

1
2
3
4
5
6
class Widget {
public:
void addName(std::string newName) // take lvalue or rvalue; move it
{ names.push_back(std::move(newName)); }

};

此代码中唯一不太显然之处就是push_back的实参中采用了std::move。通常情况下,move一般与右值一起使用(见Item25),但在本例中我们可以清楚地看出:

  1. newName是完全独立的对象,更改newName不会影响caller。
  2. 这是newName的最后一次使用,移动它并不影响程序的其他地方。

因此使用move不会产生任何问题。


性能剖析

 
在C++98中,pass-by-value将导致昂贵的性能损耗, pass-by-value意味着无论caller传入的是什么都必须经由copy constructor构造。但在C++11中,addName将仅为lvalue执行拷贝构造。 对于右值,它将执行移动构造。例如在如下语句中:

1
2
3
4
5
6
Widget w;

std::string name("Bart");
w.addName(name); // call addName with lvalue

w.addName(name + "Jenne"); // call addName with rvalue(see below)

在第一次调用addName时,newName由左值初始化,因此与C++98一样采用拷贝构造。在第二次调用中,newName由一个匿名std::string对象初始化,该对象是一个右值,因此newName由移动构造。一切都如我们所愿。


方案比较

 
前文讨论的三种方案依次如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Widget { // Approach 1:overload for lvalues and rvalues
public:
void addName(const std::string& newName)
{ names.push_back(newName); }
void addName(std::string&& newName)
{ names.push_back(std::move(newName)); }

private:
std::vector<std::string> names;
};
class Widget { // Approach 2:use universal reference
public:
template<typename T>
void addName(T&& newName)
{ names.push_back(std::forward<T>(newName)); }

};
class Widget { // Approach 3:pass by value
public:
void addName(std::string newName)
{ names.push_back(std::move(newName)); }

};

我们可以将前两种称为“by-reference approaches”,因为它们都通过reference传递参数。现有两种测试场景如下:
1
2
3
4
5
6
Widget w;

std::string name("Bart");
w.addName(name); // pass lvalue

w.addName(name + "Jenne"); // pass rvalue

针对上述三种方案,现有分析如下:

  1. 重载
    无论传入的是左值还是右值,其均被绑定至一个名为newName的引用。将实参绑定至引用没有任何成本。在左值重载函数中,newName被copy至Widget::names中。在右值重载函数中,newName被移动至Widget::names中。总的来说,左值需要一次拷贝,右值需要一次移动。
  2. universal reference
    与重载一样,实参被绑定至引用newName,绑定操作没有成本。由于使用了std::forward,lvalue std::string参数将会被copy到Widget::names中,而ravlue std::string将会被移动到该容器内。总的来说成本与重载一致:左值需要一次拷贝,右值需要一次引用。(在本次分析中我们假设传入的总是string,而非别的类型)。
  3. 传值
    无论传入的是左值还是右值,string对象newName都必须构造,区别在于传入左值则采用拷贝构造,传入右值则采用移动构造。在函数体中,newName将被无条件地移动到Widget::names之中。总的来说,成本总是为一次构造(copy/move)+一次move,这比上两种方案多了一次move。

以传值代替引用的条件

 
现在我们将镜头拉回至标题:Consider pass by value for copyable parameters that are cheap to move
and always copied

该标题实则已经表明了作者的一切立场,以下将一一解读:

  1. 你应当考虑(consider)使用传值。尽管它只需要撰写一次,并且避免了目标码膨胀,但传值比其替代方案成本更高,并且可能存在其他成本(详见下文)。
  2. 传值仅针对可拷贝的对象,move-only类型无法拷贝,在这种情况下,“重载”解决方案只需要一个重载:采用rvalue引用的重载。
    举例而言,当前Widget class内含一个unique_ptr<std::string>对象与一个由于unique_ptr是一个move-only类型,因此重载版本完全可以写为:
    1
    2
    3
    4
    5
    6
    7
    8
    class Widget {
    public:

    void setPtr(std::unique_ptr<std::string>&& ptr)
    { p = std::move(ptr); }
    private:
    std::unique_ptr<std::string> p;
    };
    其调用形式大致类似于:
    1
    2
    3
    Widget w;

    w.setPtr(std::make_unique<std::string>("Modern C++"));
    其总成本为一次移动操作(移动unique_ptr匿名对象至p)。
    如果将该类设计为pass-by-value:
    1
    2
    3
    4
    5
    6
    7
    class Widget {
    public:

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

    };
    则将首先移动构造ptr,然后再将其移入p中,其成本为右值重载的两本。
  3. 仅在移动操作成本较低时选择pass-by-value,否则多付出的一次move操作将导致得不偿失。
  4. 应当考虑为始终执行copy的参数选择pass-by-value。举例而言,假如在加入容器前需要对字符串长度进行判断,符合条件者方可加入:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class Widget {
    public:
    void addName(std::string newName){
    if ((newName.length() >= minLen) &&(newName.length() <= maxLen)){
    names.push_back(std::move(newName));
    }
    }

    private:
    std::vector<std::string> names;
    };
    这种情况下可能会造成不必要的copy操作,因此在使用pass-by-value之前必须确保该参数定会被copy。

赋值拷贝

即使我们真正符合上述条件,pass-by-value也未必是最佳方案。原因在于函数可能会以两种方式拷贝参数:构造(copy构造与move构造)、赋值(copy赋值与move赋值)。
addName成员函数使用构造:其参数newName被传递给vector::push_back,在该函数内部,newName被拷贝构造为vector尾端的一个新元素。本节开篇便分析了这种情况,此时无论传递的是左值还是右值都会导致额外的一次move的开销。
使用赋值copy参数时,情况会变得较为复杂。假设当前存在一个表示密码的类,因为密码可以更改,我们提供了setter函数changeTo,采用pass-by-value策略,该class实现如下:

1
2
3
4
5
6
7
8
class Password {
public:
explicit Password(std::string pwd): text(std::move(pwd)) {} // construct text
void changeTo(std::string newPwd){ text = std::move(newPwd); } // assign text

private:
std::string text; // text of password
};

尽管将密码设为纯文本似乎有些不妥,但请忽略它而将焦点关注于可能发生的问题:
1
2
std::string initPwd("Supercalifragilisticexpialidocious");
Password p(initPwd);

上述程序一切正常,相对于重载或universal reference方案,我们只需要多付出一次move成本而已。但用户可能会试图更改密码,例如他们会写下此类程序:
1
2
std::string newPassword = "Beware the Jabberwock";
p.changeTo(newPassword);

传递给changeTo的参数是一个左值(newPassword),因此在构造参数newPwd时将触发string的拷贝构造函数,该构造函数分配内存以保存新密码,然后将newPwd移动到text,这会导致原有text占用的内存被释放。因此,changeTo中有两个动态内存管理操作:一个为新密码分配内存,另一个为旧密码释放内存。

但事实上,因为旧密码要长于新密码。所以我们本没有必要执行动态内存分配。倘若当前采用的是重载版本,将不会触发任何内存分配行为(因为采取了赋值构造):

1
2
3
4
5
6
7
8
9
10
11
class Password {
public:

// the overload for lvalues can reuse text's memory if text.capacity() >= newPwd.size()
void changeTo(const std::string& newPwd){
text = newPwd;
}

private:
std::string text; // as above
};

执行内存动态分配可能要比move操作多花数个数量级的时间,因此pass-by-value在此场景下并不可取。此外,如果旧text长度小于新text的长度,那么无论采取何种方案,内存动态分配所带来的开销均不可避免。因此,参数复制的成本可能会取决于该参数的值。上述分析只适用于值是保存在堆内存中的,需要动态分配内存的类型,例如std::string与std::vector。

此外,上述分析只适用于实参是左值的情况,如果传递的是右值,那么开销还是很低的,因为不需要进行拷贝构造,省却了拷贝构造带来的内存分配开销(内存分配一般仅发生于copy操作之中)。

总而言之,赋值拷贝性能取决于实参类型,左值与右值的比率,是否存在动态内存分配等等。甚至还需要考虑要传入参数的具体实现方案,以std::string举例,如果它使用了SSO优化进行实现,那么赋值操作会将要赋值的内容放到SSO的buffer中,因此情况又将不同。


最终定论

 
通常情况下,最实际的做法是采用”有罪推定”的策略,也就是平时均使用重载或universal reference方案,除非明显pass-by-value的效率更高。

现如今软件总是越快越好,因此pass-by-value未必是一个良好方案,因为避免一次廉价的move开销也相当重要,我们无法明确一个操作会有多少次这样的move操作。例如Widget::addName通过值传递造成了一次额外的move操作,但是这个函数内部又调用了Widget::validateName,这个函数也是值传递的方式,这就又造成了一次额外的move开销,validateName内部如果再调用其他的函数,并且这个函数同样是值传递的方式呢?,这就造成了累加效应,如果使用引用传递的话就不会有这样的累加效应了。


pass-by-value与slicing

 
如果当前存在一个形参为基类对象或派生类对象的函数,此时应当谨慎使用pass-by-value,因为这会去除派生类的所有特性:

1
2
3
4
5
6
7
class Widget { … }; // base class
class SpecialWidget: public Widget { … }; // derived class
void processWidget(Widget w); // func for any kind of Widget,including derived types;

SpecialWidget sw;

processWidget(sw); // processWidget sees a Widget, not a SpecialWidget!