条款41 :针对可复制的形参,在移动成本低并且一定会被复制的前提下,考虑将其按值传递
该条款探讨了一种在特定情况下,可以替代“为左值和右值分别重载”这一常规做法的编码技巧。C++98 的一条重要准则是“避免按值传递用户定义类型”,而该条款则解释了在C++11的移动语义下,这条准则何时可以被“打破”。
当一个函数需要持有其参数的一个副本时(例如,将其存入一个数据成员),在 C++11 中最常见的做法是提供两个重载版本:
- 一个版本接受 const T&(左值),并在函数体内复制它。
- 另一个版本接受 T&&(右值),并在函数体内移动它。
这种做法虽然正确且高效,但缺点是需要编写和维护两份几乎相同的代码。
1
2
3
4
5
6
7
8class 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;
};
按值传递的替代方案
条款41提出的替代方案是,编写一个单一的、按值传递的函数,并在函数体内 std::move 这个参数副本。
1 | class Widget { |
当传入一个左值时(例如 std::string name; w.addName(name);):
- 重载方案:const std::string& 版本被调用。成本是 1 次复制(在 push_back 内部)。
- 按值传递方案:形参 newName 通过复制构造函数从 name 创建。 (1 次复制); 在 push_back 内部,newName 被移动到 vector 中。(1 次移动). 总成本:1 次复制 + 1 次移动。
当传入一个右值时(例如 w.addName(“hello”);): - 重载方案:std::string&& 版本被调用。成本是 1 次移动(在 push_back 内部)。 - 按值传递方案:形参 newName 通过移动构造函数从右值实参创建。(1 次移动); 在 push_back 内部,newName 被移动到 vector 中。(1 次移动). 总成本:2 次移动。
因此, 与重载方案相比,按值传递方案在处理左值时多了一次移动,处理右值时也多了一次移动。
按值传递的适用条件
既然按值传递会带来额外的移动开销,那么只有在满足一系列严格条件时,它才是一个值得考虑的选项。这些条件都体现在条款的标题中:
形参是可复制的 (Copyable):如果参数是只移类型(如 std::unique_ptr),那么重载方案只需要写一个 T&& 版本即可(成本1次移动),而按值传递方案则需要2次移动,成本翻倍,因此不适用。
移动成本低廉 (Cheap to Move):按值传递方案的额外成本是一次移动。只有当这个移动操作非常廉价时(例如,对于 std::string 或 std::vector 等大多数标准容器),这笔额外开销才是可以接受的。
形参一定会被复制 (Always Copied):函数必须在所有代码路径上都确实需要这个参数的副本。如果函数中存在某个分支(例如一个检查失败的 if 语句)会提前返回而不使用该副本,那么按值传递方案中提前创建副本的开销就被浪费了。
必须避免切片问题 (Slicing Problem):按值传递不适用于多态场景。如果你按值传递一个基类对象,那么当调用者传入一个派生类对象时,其派生类特有的部分将被“切掉”,这通常会导致非预期的行为。
总之, 按值传递并非要颠覆 C++98 中“避免按值传递”的传统智慧。然而,在现代 C++ 中,对于那些可复制、移动成本低、总是需要被复制且不涉及多态的参数,按值传递提供了一种简洁的替代方案。它用一次额外的廉价移动操作,换取了更少的代码量和更简单的维护,避免了函数重载或模板带来的复杂性。