条款41:对于移动成本低且总是被拷贝的可拷贝形参,请考虑传递值
Effective Modern C++之条款41
·
1 三种实现方式
- 有些函数的形参本来就是打算被拷贝的。为了效率,这样的函数应该拷贝左值参数,但移动右值参数:
//这是可行的,但它需要编写两个基本上做相同事情的函数
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;
};
另一种方法是将addName作为一个通用引用的函数模板:
class Widget {
public:
template<typename T> // 接受左值和右值
void addName(T&& newName) { // 拷贝左值,移动右值
names.push_back(std::forward<T>(newName));
}
// ...
};
//它不仅针对左值和右值进行实例化,还针对std::string和可转换为std::string的类型进行实例化。同时,有些参数类型不能通过通用引用传递,如果客户端传递不适当的参数类型,编译器错误消息可能会令人望而生畏
更好的方案是放弃一条作为C++程序员学到的最基本规则:避免按值传递自定义类型的对象,对于像addName这样的函数中的newName,按值传递可能是一种完全合理的策略。
class Widget {
public:
void addName(std::string newName) { // 接受左值或右值
names.push_back(std::move(newName)); // 移动它
}
...
};
//(1)newName完全独立,改变newName不会影响调用者;(2)这是对newName的最后一次使用,移动它对函数的其他部分没任何影响
这种设计的效率如何呢?
1)在 C++98 中,无论调用者传入什么,参数newName都将通过拷贝构造创建。
2)在 C++11 中,addName仅对左值进行拷贝构造。对于右值,它将通过移动构造。
Widget w;
...
std::string name("Bart");
//形参newName用一个左值初始化。因此,newName通过拷贝构造
w.addName(name); // 用左值调用 addName
...
//形参newName用对std::string的operator+产生的临时对象初始化,因此newName通过移动构造
w.addName(name + "Jenne"); // 用右值调用 addName
2 性能分析
- 三种实现方式中,对于拷贝和移动操作的性能分析:
1) 重载:不管是传递左值还是右值,调用者的参数都会绑定到一个名为newName的引用。这不会产生任何拷贝或移动操作的成本。在左值重载中,newName会拷贝值到Widget::names中。而在右值重载中,会被移动。成本总结:对于左值需要一次拷贝操作,对于右值需要一次移动操作。
2)万能引用:与重载类似,调用者的参数也会绑定到引用newName上,这是一个零成本的操作。由于使用了std::forward,左值std::string参数会被拷贝到Widget::names中,而右值std::string参数会被移动。对于std::string参数的成本总结与重载相同:对于左值需要一次拷贝操作,对于右值需要一次移动操作。
3)传值方式:不管是传递左值还是右值,参数newName都必须被构造。如果传递的是左值,则需要进行一次拷贝构造;如果传递的是右值,则需要进行一次移动构造。在函数体内,newName会被无条件地移动到Widget::names中。因此,对于左值需要一次拷贝和一次移动操作,对于右值需要两次移动操作。(与传引用的方式相比,传值方式对于左值和右值都需要额外进行一次移动操作)
3 传递值
为什么对于移动成本低且总是被拷贝的可拷贝形参,请考虑传递值?
- 只需考虑按值传递,只需要编写一个函数。在目标代码中只生成一个函数,它避免了通用引用相关的问题。但它的成本比其他选项更高。
- 只需考虑对可拷贝形参使用按值传递。对于只移动类型,函数也还是需要一个副本的,则必须通过移动构造创建副本。对于只移动的类型,不需要为左值参数提供重载,只移动类型的拷贝构造函数是禁用的。“重载”解决方案只需要一个重载:接受右值引用的那个。
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++"));
如果setPtr函数通过按值来接收参数:
class Widget {
public:
// ...
void setPtr(std::unique_ptr<std::string> ptr) { // 注意这里是通过值传递
p = std::move(ptr);
}
// ...
private:
std::unique_ptr<std::string> p;
};
//在调用setPtr时,会发生两次移动操作:
//在函数调用时,创建形参ptr的过程中。调用者使用std::move将其拥有的unique_ptr的所有权转移给ptr
//在函数体内,std::move将参数ptr的所有权转移给p
- 通过值传递通常只适用于那些移动成本较低的参数。在C++中,如果一个类型的移动构造函数和移动赋值运算符是高效的,那么通过值传递可能是合理的,因为编译器可以自动利用移动语义来优化性能。然而,如果移动操作开销很大,那么进行不必要的移动就与进行不必要的拷贝一样低效。
- 只在形参总是会被拷贝的情况下考虑按值传递。设想在将形参拷贝到names容器之前,addName函数会检查新名称是否过短或过长:
class Widget {
public:
void addName(std::string newName) { // 通过值传递 std::string
if ((newName.length() >= minLen) && (newName.length() <= maxLen)) {
names.push_back(std::move(newName));// 如果满足,移动 newName 到 names 容器中
}
// 如果条件不满足,newName 将在函数结束时被销毁,没有任何操作
}
// ...
private:
std::vector<std::string> names;
// 假设还有 minLen 和 maxLen 这两个成员变量或者常量
};
//即使newName最终没有被使用,它也付出了被构造和销毁的成本
就算上面的有利条件都满足,有时通过值传递也可能不是最合适的选择。函数可以通过两种方式拷贝参数:构造(即拷贝构造或移动构造)和赋值(即拷贝赋值或移动赋值)。
1)addName函数使用了构造方式:它的参数newName被传递给vector::push_back,在该函数内部,newName通过拷贝构造成为在std::vector末尾创建的新元素。
2)当参数使用赋值进行拷贝时,情况就更为复杂了。例如,假设有一个表示密码的类。因为密码可以被更改,所以提供了一个设置函数changeTo:
class Password {
public:
explicit Password(std::string pwd) // 通过值传递
: text(std::move(pwd)) {} // 构造 text
//newPwd 通过移动赋值给 text,这会导致已经由 text 持有的内存被释放。在 changeTo 函数内部有两个动态内存管理操作:一个为新密码分配内存,另一个为旧密码释放内存。但旧密码比新密码更长,没有必要分配或释放任何内存
void changeTo(std::string newPwd) // 通过值传递
{ text = std::move(newPwd); } // 赋值给 text
// ...
private:
//p.text 是使用给定的密码构造的,并且在构造函数中使用按值传递会多产生 std::string 移动构造的成本
std::string text; // 密码的文本
};
如果采用重载方法,很可能不会发生任何内存分配或释放操作:
class Password {
public:
// ...
void changeTo(const std::string& newPwd) {// 重载版本,用于左值
text = newPwd; // 如果 text.capacity() >= newPwd.size(),则重用 text 的内存
}
// ...
private:
std::string text; // 如上所述
};
当参数通过赋值进行拷贝时,分析按值传递的成本是复杂的。通常,最实用的方法是采用**“疑罪从有”**的策略,即除非已证明按值传递对于所需的参数类型能产生可接受的高效代码,否则使用重载或通用引用。
- 当存在函数调用链时,每个链都使用按值传递,因为“它只花费一个廉价的移动”,整个调用链的成本可能是不能承受的。使用按引用传递,调用链不会产生这种累积的开销。
- 还有一个与性能无关的问题是,按值传递与按引用传递不同,它容易受到截断问题(slicing problem)的影响。如果有一个函数,它被设计成接受基类类型或其任何派生类型的参数,那么不应该声明一个该类型的按值传递参数,因为会“切掉”任何可能传入的派生类型对象的派生类特性:
class Widget { /* ... */ }; // 基类
class SpecialWidget: public Widget { /* ... */ }; // 派生类
void processWidget(Widget w); // 可用于任何类型的Widget的函数,包括派生类型;但存在截断问题
// ...
SpecialWidget sw;
// ...
processWidget(sw); // processWidget看到的是一个Widget,而不是SpecialWidget!
4 要点速记
- 对于可拷贝、移动成本低且总是被拷贝的参数,按值传递的效率可能几乎与按引用传递相当,它更容易实现,并且可以生成更少的目标代码。
- 通过构造函数拷贝参数可能比通过赋值拷贝它们要昂贵得多。
- 按值传递会受到截断问题的影响,因此对于基类参数类型,它通常是不合适的。
更多推荐
所有评论(0)