Effective C++ 条款20:宁以 pass-by-reference-to-const 替换 pass-by-value
Effective C++ 条款20:宁以 pass-by-reference-to-const 替换 pass-by-value
🎯 核心观点:缺省情况下,C++ 以 by value 方式传递对象。但除非对于内置类型、STL 迭代器和函数对象,否则 pass-by-reference-to-const 往往比 pass-by-value 更高效,并且可以避免对象切割问题。
一、pass-by-value 的隐藏代价
在 C++ 中,如果不加任何修饰,函数参数默认按值传递。这意味着每次调用函数时,编译器都会调用参数的拷贝构造函数创建一个副本。
1.1 一个简单的例子
#include <iostream>
#include <string>
class Person {
private:
std::string name_;
std::string address_;
std::string phone_;
public:
Person(const std::string& name, const std::string& addr, const std::string& phone)
: name_(name), address_(addr), phone_(phone) {
std::cout << "Person 构造\n";
}
Person(const Person& other)
: name_(other.name_), address_(other.address_), phone_(other.phone_) {
std::cout << "Person 拷贝构造\n";
}
~Person() {
std::cout << "Person 析构\n";
}
};
// ❌ 按值传递:每次调用都发生拷贝
void processPersonByValue(Person p) {
std::cout << "处理中...\n";
} // p 在这里析构
// ✅ 按 const 引用传递:零拷贝
void processPersonByRef(const Person& p) {
std::cout << "处理中...\n";
}
int main() {
Person alice("Alice", "Beijing", "1234567890");
std::cout << "=== 按值传递 ===\n";
processPersonByValue(alice);
std::cout << "\n=== 按引用传递 ===\n";
processPersonByRef(alice);
return 0;
}
输出:
Person 构造
=== 按值传递 ===
Person 拷贝构造
处理中...
Person 析构
=== 按引用传递 ===
处理中...
按值传递时,发生了一次拷贝构造和一次析构。如果 Person 类更复杂(包含更多成员、更深的继承层次),这个代价会成倍增长。
1.2 继承体系中的代价放大
class Student : public Person {
private:
std::string school_;
std::vector<double> grades_;
public:
Student(const std::string& name, const std::string& school)
: Person(name, "", ""), school_(school) {
std::cout << "Student 构造\n";
}
Student(const Student& other)
: Person(other), school_(other.school_), grades_(other.grades_) {
std::cout << "Student 拷贝构造\n";
}
};
// 按值传递 Student
void processStudentByValue(Student s) {
std::cout << "处理学生...\n";
}
当按值传递 Student 时:
- 调用
Student的拷贝构造函数 Student的拷贝构造函数调用Person的拷贝构造函数Person的拷贝构造函数拷贝 3 个std::string- 然后拷贝
school_(又一个std::string) - 然后拷贝
grades_(整个vector,涉及堆内存分配)
总共发生了多少次内存分配? 远超你的想象!
二、pass-by-reference-to-const 的优势
2.1 性能优势
void processByConstRef(const Person& p);
这种方式的优势:
| 方面 | pass-by-value | pass-by-reference-to-const |
|---|---|---|
| 拷贝开销 | 调用拷贝构造函数 | 无(传递地址,通常 4/8 字节) |
| 析构开销 | 函数返回时析构副本 | 无 |
| 堆内存分配 | 取决于对象成员 | 无 |
| 修改安全性 | 函数内可修改副本 | const 保证不可修改原对象 |
2.2 避免对象切割问题
这是 pass-by-value 更严重的隐患——对象切割(Object Slicing)。
#include <iostream>
class Window {
public:
virtual void display() const {
std::cout << "显示基础窗口\n";
}
virtual ~Window() = default;
};
class WindowWithScrollBar : public Window {
public:
void display() const override {
std::cout << "显示带滚动条的窗口\n";
}
};
// ❌ 按值传递:发生对象切割!
void showWindowBad(Window w) { // WindowWithScrollBar 被切割成 Window!
w.display(); // 输出:"显示基础窗口"(多态失效!)
}
// ✅ 按引用传递:保持多态性
void showWindowGood(const Window& w) {
w.display(); // 正确调用派生类的版本
}
int main() {
WindowWithScrollBar myWindow;
std::cout << "=== 按值传递(切割)===\n";
showWindowBad(myWindow); // ❌ 输出:显示基础窗口
std::cout << "\n=== 按引用传递(多态)===\n";
showWindowGood(myWindow); // ✅ 输出:显示带滚动条的窗口
return 0;
}
对象切割的本质:
当 WindowWithScrollBar 对象按值传递给 Window 参数时,编译器只会拷贝 Window 子对象的部分。派生类特有的成员(如滚动条数据)被切掉了。同时,vptr 也被重置为 Window 的虚函数表,导致多态性完全丧失。
⚠️ 对象切割是静默的灾难:代码能编译,能运行,但行为完全错误。这种问题极难调试。
三、例外情况:什么时候用 pass-by-value?
虽然条款标题说"宁以 pass-by-reference-to-const 替换 pass-by-value",但也有明确的例外:
3.1 内置类型
// ✅ 内置类型按值传递更高效
void process(int value); // 4 字节拷贝,非常快
void process(double value); // 8 字节拷贝
void process(char value); // 1 字节拷贝
void process(bool value); // 1 字节拷贝
// ❌ 按引用传递内置类型反而更慢(需要解引用)
void processBad(const int& value); // 不必要的间接访问
原因:内置类型的拷贝就是简单的位复制,通常只需一个 CPU 指令。而引用传递需要额外的间接寻址。
3.2 STL 迭代器
#include <vector>
// ✅ 迭代器按值传递
void processIterator(std::vector<int>::iterator it);
// ✅ 函数对象(仿函数)按值传递
template<typename Func>
void applyOperation(Func f); // Func 是函数对象
原因:迭代器和函数对象通常设计为轻量级、可高效拷贝的类型。它们往往就是指针大小或包含少量状态。
3.3 小型且拷贝廉价的类型
// ✅ 小型类可以按值传递
struct Point2D {
float x, y;
};
void movePoint(Point2D p, float dx, float dy); // 8 字节,拷贝极快
struct Color {
uint8_t r, g, b, a;
};
void setColor(Color c); // 4 字节
💡 判断标准:如果类型的拷贝构造函数本质上就是
memcpy(没有堆分配、没有复杂逻辑),且大小不超过 2-3 个指针,按值传递通常是可接受的。
四、现代 C++:移动语义的影响
C++11 引入的移动语义改变了游戏规则:
#include <vector>
#include <string>
class BigData {
private:
std::vector<double> data_;
std::string metadata_;
public:
BigData() = default;
// 拷贝构造(深拷贝,昂贵)
BigData(const BigData& other)
: data_(other.data_), metadata_(other.metadata_) {}
// 移动构造(浅拷贝,廉价)
BigData(BigData&& other) noexcept
: data_(std::move(other.data_)),
metadata_(std::move(other.metadata_)) {}
};
// 旧方式:总是 const 引用
void processOld(const BigData& data);
// 新方式:按值传递,利用移动语义(C++11 起)
void processNew(BigData data); // 调用者可以 move 进来
// 使用
BigData bigData;
processNew(std::move(bigData)); // 移动,零拷贝
processNew(BigData()); // 直接从临时对象构造,零拷贝
💡 现代建议:对于可移动的类型,在某些场景下按值传递配合
std::move可以达到与引用传递相同的性能,同时代码更简洁。但这需要谨慎使用,且不适用于多态场景。
五、实际应用场景
5.1 场景:游戏引擎中的实体处理
class GameEntity {
public:
virtual void update(float deltaTime) = 0;
virtual void render() const = 0;
virtual ~GameEntity() = default;
};
class Player : public GameEntity { /* ... */ };
class Enemy : public GameEntity { /* ... */ };
class Item : public GameEntity { /* ... */ };
// ❌ 危险:对象切割 + 性能低下
void updateEntityBad(GameEntity entity); // 千万别这样!
// ✅ 正确:保持多态,零拷贝
void updateEntityGood(const GameEntity& entity);
// ✅ 如果需要修改实体
void updateEntityMutable(GameEntity& entity);
// 游戏主循环
void gameLoop(const std::vector<std::unique_ptr<GameEntity>>& entities) {
for (const auto& entity : entities) {
updateEntityGood(*entity); // 多态调用,高效传递
}
}
5.2 场景:图像处理中的大矩阵
#include <vector>
class ImageMatrix {
private:
std::vector<uint8_t> pixels_;
int width_, height_;
public:
ImageMatrix(int w, int h) : width_(w), height_(h), pixels_(w * h * 3) {}
// 拷贝构造会复制整个像素数组!
ImageMatrix(const ImageMatrix& other) = default;
int width() const { return width_; }
int height() const { return height_; }
const uint8_t* data() const { return pixels_.data(); }
};
// ❌ 灾难:4K 图像 (3840x2160x3 ≈ 24MB) 被完整拷贝
void applyFilterBad(ImageMatrix image);
// ✅ 高效:只传递引用
void applyFilterGood(const ImageMatrix& image);
// ✅ 如果需要输出结果,可以传入输出参数
void applyFilterInPlace(const ImageMatrix& input, ImageMatrix& output);
5.3 场景:配置对象的传递
class AppConfig {
public:
std::string appName;
std::string logPath;
std::string dbConnectionString;
int maxThreads;
int timeoutSeconds;
std::vector<std::string> pluginPaths;
// ... 可能还有很多字段
};
// ❌ 每次调用都拷贝整个配置对象
void initializeModuleBad(AppConfig config);
// ✅ 只读访问用 const 引用
void initializeModuleGood(const AppConfig& config);
// 应用启动时
void startup() {
AppConfig config = loadConfigFromFile("app.conf");
initializeModuleGood(config); // 模块 A
initializeModuleGood(config); // 模块 B
initializeModuleGood(config); // 模块 C
// 零额外拷贝!
}
六、决策流程图
选择参数传递方式:
│
├─ 类型是内置类型?(int, double, char, bool, 指针等)
│ └─ ✅ 按值传递
│
├─ 类型是 STL 迭代器或函数对象?
│ └─ ✅ 按值传递
│
├─ 类型是小型且拷贝廉价的结构体(<= 2-3 个指针大小)?
│ └─ ✅ 按值传递
│
├─ 函数需要修改参数?
│ ├─ 是 → 按非 const 引用传递
│ └─ 否 → 继续判断
│
├─ 参数可能为 null?
│ ├─ 是 → 考虑 const 指针(const T*)
│ └─ 否 → ✅ 按 const 引用传递(const T&)
七、总结
| 传递方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| pass-by-value | 内置类型、迭代器、小型对象 | 简单、安全(无副作用) | 拷贝开销、对象切割 |
| pass-by-reference-to-const | 大多数自定义类型 | 零拷贝、防切割、const 安全 | 语法稍复杂 |
| pass-by-reference | 需要修改参数 | 可修改原对象 | 副作用风险 |
📌 核心原则:
- 对于自定义类型,默认使用
const T&- 对于内置类型和 STL 迭代器,使用
T- 如果涉及多态,必须使用引用或指针
- C++11 后,对于可移动类型,某些场景可以考虑按值传递配合
std::move
八、延伸阅读
- Effective C++ 条款03:尽可能使用 const
- Effective C++ 条款19:设计 class 犹如设计 type
- Effective C++ 条款21:必须返回对象时,别妄想返回其 reference
- C++ Core Guidelines:F.16 - For “in” parameters, pass cheaply-copied types by value and others by reference to
const - 《C++ Primer》第 6 章:函数
如果这篇文章对你有帮助,欢迎点赞 👍、收藏 ⭐、评论 💬!你的支持是我持续创作的动力!
更多推荐


所有评论(0)