条款41:对于移动成本低且总是被拷贝的可拷贝形参,考虑按值传递
对移动代价低、总是被拷贝的参数,按值传递更简洁高效,能统一处理左值与右值,避免重复代码和不必要的复制。
条款41:对于移动成本低且总是被拷贝的可拷贝形参,考虑按值传递
条款41:对于移动成本低且总是被拷贝的可拷贝形参,考虑按值传递
背景
许多函数参数是可拷贝类型(如 std::string
),需要将参数拷贝或移动到类成员或容器中。如何高效且简洁地写这类函数是一个常见问题。
传统解决方案有两种:
- 重载:对左值传引用,右值传右值引用。
- 通用引用模板:用模板
T&&
接收任意值,转发实现。
但这两种方式代码重复或复杂,维护成本高。
典型示例
假设类 Widget
中有个 std::vector<std::string>
成员 names
,想实现 addName
,把传入名字添加进去。
重载版本(对左值和右值分别重载)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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;
};
优点
- 性能最佳,左值拷贝一次,右值移动一次。
缺点
- 代码重复,维护麻烦。
- 目标代码会生成两个函数,可能导致代码膨胀。
通用引用模板版本(万能转发)
1
2
3
4
5
6
7
8
9
10
11
class Widget {
public:
template<typename T>
void addName(T&& newName) {
// 使用完美转发,左值拷贝,右值移动
names.push_back(std::forward<T>(newName));
}
private:
std::vector<std::string> names;
};
优点
- 一份代码,支持左值右值多种类型。
缺点
- 模板实现必须放头文件,编译时生成多个实例。
- 编译错误难理解。
- 可能代码膨胀(多种类型、多种值类别实例化)。
- 有些类型不能完美转发。
按值传递版本(只写一个普通函数)
1
2
3
4
5
6
7
8
9
10
class Widget {
public:
void addName(std::string newName) {
// 形参 newName 是函数内独立副本
names.push_back(std::move(newName));
}
private:
std::vector<std::string> names;
};
工作原理
- 传入左值时,形参
newName
拷贝构造。 - 传入右值时,形参
newName
移动构造。 - 函数内对
newName
移动,效率较高。
优点
- 只需写一个函数,维护简单。
- 目标代码只有一个函数,不膨胀。
- 避免模板复杂性。
缺点
- 传左值时,额外多了一次移动开销。
- 传右值时,可能多一次移动开销(两次移动)。
- 不适合只可移动类型(如
unique_ptr
)。 - 可能引起对象切片问题(基类按值传派生类会丢失派生信息)。
性能对比
调用场景 | 重载或通用引用开销 | 按值传递开销 |
---|---|---|
传递左值(如 std::string name ) | 拷贝一次(左值引用绑定) | 拷贝一次 + 移动一次 |
传递右值(如临时字符串) | 移动一次 | 移动两次 |
移动通常比拷贝便宜很多,所以按值传递的性能差异通常不大。
按值传递的适用条件
- 形参类型是可拷贝的(非只可移动)。
- 移动构造和移动赋值操作成本很低(如
std::string
、std::vector
等带移动语义的标准容器)。 - 函数无条件需要复制参数(总是要保存一份),不能避免。
- 不涉及基类多态类型,避免对象切片。
- 允许额外一次移动操作的开销。
示例:只可移动类型(unique_ptr
)不适合按值传递
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Widget {
public:
// 右值引用版本,开销最小
void setPtr(std::unique_ptr<std::string>&& ptr) {
p = std::move(ptr);
}
// 按值传递版本,额外多一次移动开销
void setPtr(std::unique_ptr<std::string> ptr) {
p = std::move(ptr);
}
private:
std::unique_ptr<std::string> p;
};
调用示例:
1
2
Widget w;
w.setPtr(std::make_unique<std::string>("Modern C++"));
按值传递会先移动构造 ptr
,再移动赋值给 p
,移动两次;右值引用版本移动一次,性能更优。
按值传递与赋值操作的复杂性
如果函数内部用赋值操作(非构造)拷贝形参,开销更复杂:
1
2
3
4
5
6
7
8
9
10
11
class Password {
public:
explicit Password(std::string pwd) : text(std::move(pwd)) {}
void changeTo(std::string newPwd) { // 传值
text = std::move(newPwd); // 赋值操作
}
private:
std::string text;
};
调用示例:
1
2
std::string newPassword = "Beware the Jabberwock";
p.changeTo(newPassword);
- 传入左值时,
newPwd
由拷贝构造构造,分配新内存。 - 然后赋值操作
text = std::move(newPwd)
可能释放旧内存,分配新内存。 - 整体造成两次内存分配和释放,开销较大。
相比之下,重载左值版本:
1
2
3
void changeTo(const std::string& newPwd) {
text = newPwd; // 可能复用已有内存,避免多次分配
}
能有效减少内存操作,提升性能。
对象切片问题
“切片”(Object Slicing)是C++中的一个经典问题,指的是将一个派生类对象按值传递(或赋值)给基类类型时,派生类对象中独有的成员和特性会被“切掉”,只保留基类部分。
按值传递基类对象会造成切片,导致派生类特征丢失:
1
2
3
4
5
6
7
class Widget { /* ... */ };
class SpecialWidget : public Widget { /* ... */ };
void processWidget(Widget w); // 按值传递会切片
SpecialWidget sw;
processWidget(sw); // sw 会被切片成 Widget 部分
因此,按值传递不适合基类形参类型。
总结与建议
- 按值传递适合“移动开销小且总是需要复制”的可拷贝类型参数。
- 它让代码简洁,维护方便,避免模板复杂和代码重复。
- 性能上,左值实参会多一次移动,右值实参多一次移动,通常可以接受。
- 不适合只可移动类型(
unique_ptr
等),也不适合多态基类形参(避免切片)。 - 对于函数链调用,每层按值传递会累积移动开销,性能敏感时慎用。
- 如果函数内部赋值(非构造)拷贝形参,可能导致额外内存分配释放,效率更低。
如果编写的函数总是复制形参,且拷贝开销不高,推荐采用按值传递+std::move
的简洁方案。
如果对性能要求极高或参数为只可移动类型,则优先考虑重载或通用引用。
本文由作者按照 CC BY 4.0 进行授权