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 时:

  1. 调用 Student 的拷贝构造函数
  2. Student 的拷贝构造函数调用 Person 的拷贝构造函数
  3. Person 的拷贝构造函数拷贝 3 个 std::string
  4. 然后拷贝 school_(又一个 std::string
  5. 然后拷贝 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 需要修改参数 可修改原对象 副作用风险

📌 核心原则

  1. 对于自定义类型,默认使用 const T&
  2. 对于内置类型和 STL 迭代器,使用 T
  3. 如果涉及多态,必须使用引用或指针
  4. 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 章:函数

如果这篇文章对你有帮助,欢迎点赞 👍、收藏 ⭐、评论 💬!你的支持是我持续创作的动力!

更多推荐