41 对于可拷贝的形参,如果移动成本低且一定会被拷贝则考虑传值
- 一些函数的形参本身就是用于拷贝的,比如下面的成员函数addName会把形参拷贝到私有容器中。考虑性能,对左值实参应该执行拷贝,对右值实参应该执行移动
class Widget {
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 {
public:
template<typename T>
void addName(T&& newName)
{
names.push_back(std::forward<T>(newName)); // move rvalues;
}
…
};
- 但转发引用会导致其他方面的复杂性。模板一般要在头文件中实现,它可能在对象代码中产生多个函数,因为除了为左值和右值产生不同的实例化结果,对std::string和可以转为std::string的类型也会有不同的实例化(条款25)。此外,有些类型不能传引用(条款30),如果传入了不正确的实参类型,编译器可能产生冗长的错误信息(条款27)
- 所以最期望的方法是:针对左值拷贝,针对右值移动,在源码和目标代码中只需要处理一个函数,还能避开转发引用。而这种方法就是传值,对于类似于addName函数中,类似于newName的形参,传值完全合理
class Widget {
public:
void addName(std::string newName) // 可接受左值和右值,对右值移动
{ names.push_back(std::move(newName)); }
…
};
- 在C++98中,这样仍然会有效率问题,无论传入什么,newName都会由拷贝构造。但在C++11中,newName只在传入左值时被拷贝构造,如果传入右值则被移动构造
Widget w;
…
std::string name("Bart");
w.addName(name); // 以传左值的方式调用
…
w.addName(name + "Jenne"); // 以传右值的方式调用
- 再次回顾三个版本的实现:针对左值和右值重载、使用转发引用、传值
class Widget {
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 {
public:
template<typename T>
void addName(T&& newName)
{ names.push_back(std::forward<T>(newName)); }
…
};
class Widget {
public:
void addName(std::string newName)
{ names.push_back(std::move(newName)); }
…
};
- 考虑之前的调用场景带来的成本
Widget w;
…
std::string name("Bart");
w.addName(name); // pass lvalue
…
w.addName(name + "Jenne"); // pass rvalue
-
重载:实参直接绑定到引用上,无拷贝或移动成本。接受左值的重载版本中,newName被拷贝到Widget::names,右值版本则是移动。总计:对左值一次拷贝,对右值一次移动
-
转发引用:与重载版本一致。总计:对左值一次拷贝,对右值一次移动。此外条款25提过,如果传递的实参不是std::string类型,它将被转发到std::string的某个适当的构造函数,于是这样可能一次拷贝或移动都不需要。但这里为了分析简化情况,只假设传入std::string
-
传值:传值必定会对形参newName进行一次构造,传左值是一次拷贝构造,传右值是一次移动构造,形参之后将移动进容器。因合计:对左值一次拷贝一次移动,对右值两次移动
-
传值对比前两种方法,都存在一次额外的移动。回顾标题:对于可拷贝的形参,如果移动成本低且一定会被拷贝则考虑传值。下面将解释此标题
-
传值确实避免了很多麻烦,但成本确实高一些
-
可拷贝的形参才考虑传值,因为move-only类型只需要一个处理右值类型的函数
class Widget {
public:
…
void setPtr(std::unique_ptr<std::string>&& ptr)
{ p = std::move(ptr); }
private:
std::unique_ptr<std::string> p;
};
Widget w;
…
w.setPtr(std::make_unique<std::string>("Modern C++"));
- 如果使用传值,则同样的调用需要先移动构造形参,再将其移入p,多出了一次移动
class Widget {
public:
…
void setPtr(std::unique_ptr<std::string> ptr)
{ p = std::move(ptr); }
…
};
- 只有当移动成本低时,多出的一次移动才值得考虑,因此应该只对一定会被拷贝的形参传值。比如假设在形参拷贝到容器之前,addName要先验证该名字长度是否合适
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;
};
- 这样即使没有添加到容器,也会导致构造和析构newName,而传引用就可以避免此问题
- 但即使函数针对可拷贝类型执行无条件拷贝,并且移动成本低,也存在不适合传值的情况,原因在于函数有构造和赋值这两种拷贝方式。之前分析的addName采用的是构造方式,newName被push_back到容器,对于构造来说传值会导致一次额外的移动成本。但采用赋值来执行拷贝,情况将更复杂
- 假设有一个表示密码的类,提供一个采用赋值来修改密码的函数changeTo
class Password {
public:
explicit Password(std::string pwd)
: text(std::move(pwd)) {} // 对text采用构造
void changeTo(std::string newPwd)
{ text = std::move(newPwd); } // 对text采用赋值
…
private:
std::string text;
};
- 使用构造时并不会发生意外
std::string initPwd("Supercalifragilisticexpialidocious");
Password p(initPwd);
- 如果修改密码
std::string newPassword = "Beware the Jabberwock";
p.changeTo(newPassword);
- 这里旧密码比新密码更长,所以不需要重新分配(allocate)或回收(deallocate),因此如果把changeTo改为重载版,则很可能不需要任何动态管理内存的行为。此场景下,传值就会有额外的分配和回收内存的成本,此成本可能远高于std::string的移动操作成本
class Password {
public:
…
void changeTo(const std::string& newPwd) // 用于左值的重载版本
{
text = newPwd; // can reuse text's memory if text.capacity() >= newPwd.size()
}
…
private:
std::string text;
};
- 但如果旧密码比新密码短,则赋值一般会出现分配和回收操作。此场景下,传值和传引用的运行速度大致相同
- 可见,通过赋值拷贝形参的成本,可能取决于赋值对象的取值。这适用于可能在动态分配的内存中持有值的任何形参类型,不适用于所有类型,但适用于大多数类型,比如std::string和std::vector。这样的潜在成本一般只在传左值实参时发生,因为内存分配和回收通常只在真正的拷贝时发生,对于右值实参只需要移动
- 总之,以赋值方式拷贝形参的函数,其传值的额外成本取决于传入类型、左值和右值实参的占比、类型是否使用动态分配内存、使用动态分配内存时赋值运算符的实现、赋值目标和源对象的内存大小。对于std::string,还取决于实现是否使用了SSO(small string optimization,条款29),若使用SSO,所赋的值是否放得进SSO缓冲区
- 因此,对于必须运行得尽可能快的软件来说,传值可能不是一个可行的策略,因为避免移动仍然重要,即使移动成本低
- 传值还有一个与效率无关的问题,不同于传引用,传值容易遇见切片问题(the slicing problem)。如果函数接受一个基类或其派生的任何类型的形参,传值会导致派生类对象的独有特征被切割掉
class Widget { … }; // 基类
class SpecialWidget: public Widget { … }; // 派生类
void processWidget(Widget w); // 接受任何Widget或其派生类
…
SpecialWidget sw;
…
processWidget(sw); // processWidget只能看到一个Widget而不是一个SpecialWidget
42 使用emplace操作替代insert操作
- 将字符串字面值push_back到std::vector<std::string>
std::vector<std::string> vs; // container of std::string
vs.push_back("xyzzy"); // add string literal
- 而字符串字面值不是std::string,不同于容器元素类型,该字面值类型是const char[6]。std::vector的push_back对左值和右值的重载版本为
template <class T, class Allocator = allocator<T>>
class vector {
public:
…
void push_back(const T& x); // insert lvalue
void push_back(T&& x); // insert rvalue
…
};
- 因此将字符串字面值push_back到容器中,会先创建一个std::string的临时对象,即调用如下
vs.push_back(std::string("xyzzy"));
- 这段代码中,首先创建了一个std::string的临时对象,为右值,这是第一次构造
- 临时对象传递给push_back的右值重载版本,绑定到形参x上,然后会在std::vector的内存中构造一个x的拷贝。这个构造(第二次的构造)将在std::vector中创建一个新对象(将x拷贝进std::vector的构造函数是移动构造函数)
- push_back返回的时刻,临时对象被析构,这需要调用一次std::string的析构函数
- 如果能直接将字符串字面值传递到在std::vector内构造std::string类型对象的代码,就可以避免构造和析构临时对象。这个方法就是使用emplace_back,它使用传入的任何实参在std::vector中构造一个std::string,不涉及任何临时对象
vs.emplace_back("xyzzy");
- emplace_back使用了完美转发,只要没有完美转发的限制(条款30),就可以通过emplace_back传递任意数量的实参和任意类型的组合
vs.emplace_back(50, 'x'); // insert std::string consisting of 50 'x' characters
- 所有insert操作都有emplace操作的替代,比如push_back可用emplace_back替代,push_front可用emplace_front替代。如何支持insertion操作的容器(即除std::forward_list和std::array外的所有容器)都支持emplacement。关联容器提供了empalce_hint来补充带有hint迭代器的insert函数,std::forward_list有emplace_after来对应insert_after
- emplace函数比insert函数提供了更灵活的接口,insert函数接受的是待插入对象,而emplace函数接受的是待插入对象的构造函数的实参,这让emplace函数避免了临时对象的创建和析构,而insert函数无法避免
- 即使insert函数不需要创建临时对象,也可以用emplace函数,此时两者本质上做的是同样的事
std::string queenOfDisco("Donna Summer");
// 下面两个调用的效果相同
vs.push_back(queenOfDisco);
vs.emplace_back(queenOfDisco);
- 这样,emplace函数就能做到insert函数能做的所有事,有时甚至做得更好
- 但并不是总应该使用emplace函数,从标准库的当前实现来看,还是存在insert函数运行得更快的情况,具体来说,取决于实参类型、容器种类、添加元素的位置、持有类型构造函数的异常安全性、对于不能出现重复值的容器(std::set、std::map等)是否已存在要添加的值。因此,一般的性能调优建议是,对insert和emplace进行基准测试
- 这个建议并不让人满意,这里提供一些启发性思路,如果下列情况都成立,则emplace几乎总会比insert高效:
- 新值以构造而非赋值的方式添加到容器中。之前的例子中,值被添加到容器末尾,此位置不存在对象,因此新值会使用构造方式。但如果把新值添加到已有对象占据的位置,则会采用赋值的方式。下面的代码中,采用的就是移动赋值,于是必须创建一个临时对象作为移动的源对象,此时emplace并不会比insert高效
std::vector<std::string> vs; … // 添加元素到容器中 vs.emplace(vs.begin(), "xyzzy"); // 将"xyzzy"添加到容器的开头
- 实参类型与容器元素类型不同
- 容器不太可能因为重复而拒绝添加新值。要检查某个值是否在容器中,emplace的实现通常会为新值创建一个node,以便能与容器中已存在的node进行比较。如果值不存在,则将node链接到容器中。如果值已存在,emplace就会中止,node会被析构,这意味着构造和析构的成本被浪费了
- 新值以构造而非赋值的方式添加到容器中。之前的例子中,值被添加到容器末尾,此位置不存在对象,因此新值会使用构造方式。但如果把新值添加到已有对象占据的位置,则会采用赋值的方式。下面的代码中,采用的就是移动赋值,于是必须创建一个临时对象作为移动的源对象,此时emplace并不会比insert高效
- 在决定是否要使用emplace函数时,还有两个问题需要关心:第一个和资源管理相关,第二个和explicit构造函数相关
- 假设有一个容器,元素类型为std::shared_ptr<Widget>
std::list<std::shared_ptr<Widget>> ptrs;
- 现在想添加一个自定义删除器的std::shared_ptr对象,自定义删除器的std::shared_ptr对象不能用std::make_shared创建,只能通过new创建
void killWidget(Widget* pWidget);
ptrs.push_back(std::shared_ptr<Widget>(new Widget, killWidget));
// 或者如下,意义相同
ptrs.push_back({ new Widget, killWidget });
- 这样在push_back之前会创建一个std::shared_ptr类型的临时对象,如果使用emplace_back本可以创建临时对象,但在这里,临时对象带来的收益远超其成本。考虑如下可能发生的事件序列:
- 构造一个std::shared_ptr<Widget>的临时对象
- push_back以引用方式接受临时对象,在为list node分配内存时抛出了内存不足的异常
- 异常传到push_back之外,临时对象被析构,于是删除器被调用,Widget被释放
- 即使发生异常,也没有资源泄露。push_back的调用中,由new构造的Widget会在临时对象被析构时释放
- 如果使用的是emplace_back,new创建的原始指针被完美转发到emplace_back为list node分配内存的执行点。如果内存分配失败,抛出内存不足的异常,异常传到emplace_back外,唯一可以获取堆上的Widget的原始指针丢失,于是这个Widget(以及它拥有的任何资源)就发生了泄漏
- 这个场景换成std::unique_ptr的自定义删除器,也会出现一样的问题。根本原因在于,像std::shared_ptr和std::unique_ptr这样的资源管理类的效率,依赖于资源(比如new创建的原始指针)立即传给资源管理对象的构造函数,这正是std::make_shared和std::make_unique所做的事
- 调用元素为资源管理对象的容器的insert函数时,函数的形参类型通常能确保在资源获取和资源管理对象的构造之间没有其他动作。而在emplace函数中,完美转发会推迟资源管理对象的创建,直到它们能在容器的内存中被构造为止,这就为异常导致的资源泄漏开了一个后门
- 坦诚地说,不应该把new Widget这样的表达式传递给emplace_back、push_back或其他大部分函数,要关闭上述的后门,就应该先把new创建的原始指针在独立语句中转交给资源管理对象,随后将对象作为右值传递给函数。因此,使用push_back的写法如下
std::shared_ptr<Widget> spw(new Widget, killWidget);
ptrs.push_back(std::move(spw));
- 使用emplace_back的写法类似
std::shared_ptr<Widget> spw(new Widget, killWidget);
ptrs.emplace_back(std::move(spw));
- 无论使用push_back还是emplace_back,都会产生构造和析构spw的成本,此时emplace并不比insert高效
- emplace函数的另一个问题与explicit构造函数相关。假设有一个元素为正则表达式对象的容器
std::vector<std::regex> regexes;
- 无意中写下了如下无意义的代码
regexes.emplace_back(nullptr);
- 而这段代码会被编译器接受,不会提示错误信息,而是抛出异常,这可能导致耗费大量时间调试此问题。令人疑惑的是,如果直接给正则表达式用指针赋值无法通过编译,将报错
std::regex r = nullptr; // 编译错误
- 使用push_back同样会报错
regexes.push_back(nullptr); // 编译错误
- 这个问题的原因在于,std::regex对象可以由字符串构造
std::regex upperCaseWord("[A-Z]+"); // OK
- 而由字符串构造会导致高运行成本,因此为了尽量减少导致这种开销的可能,接受const char*的std::regex的构造函数用explicit声明。直接用nullptr赋值或者push_back要求一次nullptr到std::regex的隐式转换,而explicit阻止了隐式转换,因此不能通过编译。而emplace_back没有声明传递的是std::regex对象,而是直接传递构造函数实参,这个行为在编译器看来等同于
std::regex r(nullptr); // 能编译但会引发异常
- 这虽然可以通过编译,但却是未定义行为,因为接受const char*指针的std::regex的构造函数要求字符串是一个有意义的正则表达式,空指针不符合此要求。编译这样的代码,能指望的最好结果就是运行时崩溃,否则很难通过调试找到错误
网友评论