Item21–必须返回对象时,别妄想返回其 reference

1. 核心矛盾:我们为什么想返回引用?

学习了 Item 20 后,你可能觉得:“传值(pass-by-value)太慢了,有构造和析构的开销。那我干脆把函数的返回值也改成引用吧!”

场景设定

假设你要实现一个有理数类 Rational,并重载乘法运算符:

class Rational {
public:
    Rational(int numerator = 0, int denominator = 1);
    // ...
private:
    int n, d;
    // 友元函数,用于支持乘法
    friend const Rational operator*(const Rational& lhs, const Rational& rhs);
};

我们的目标是让 Rational a = b * c; 能够运行。 这时候,如果你试图将 operator* 的返回值声明为 const Rational&,你会遇到一个逻辑上的死胡同:这个被引用的对象,到底存在于哪里?

Item 21 列举了三种常见的错误尝试,说明为什么这样做是行不通的。


2. 错误尝试一:返回局部变量的引用 (On the Stack)

这是最致命的错误。你可能试图在函数内部创建一个对象,然后返回它的引用:

// ❌ 绝对错误:返回局部对象的引用
const Rational& operator*(const Rational& lhs, const Rational& rhs) {
    Rational result(lhs.n * rhs.n, lhs.d * rhs.d); // 局部对象,在栈上
    return result; 
}

为什么不行?

  • 生命周期问题result 是一个局部变量。当函数 operator* 执行结束时,result 会被自动销毁(析构函数被调用)。

  • 后果:你返回的引用指向了一块“已经销毁”的内存。调用者拿到的引用是 Dangling Reference(悬空引用)。一旦使用它,就会导致未定义行为(程序崩溃或数据错乱)。


3. 错误尝试二:返回堆对象的引用 (On the Heap)

既然栈上的对象会被销毁,那你可能想到用 new 在堆上创建对象:

// ❌ 绝对错误:内存泄漏的温床
const Rational& operator*(const Rational& lhs, const Rational& rhs) {
    Rational* result = new Rational(lhs.n * rhs.n, lhs.d * rhs.d); // 堆上创建
    return *result; 
}

为什么不行?

  • 内存泄漏:C++ 中谁申请谁释放。这里虽然对象活着,但谁来负责 delete 它呢

  • 无法挽回的场景

    Rational w, x, y, z;
    w = x * y * z; 

    这行代码实际上是 operator*(x, y) 返回一个引用,然后再和 z 乘。这里发生了两次 new 调用,但没有指针变量能让你去 delete 它们。这些内存永远泄露了。


4. 错误尝试三:返回静态局部变量的引用 (Static Local Variable)

这是为了避免前两个问题而产生的“投机取巧”写法:

// ❌ 错误:逻辑错误 + 线程不安全
const Rational& operator*(const Rational& lhs, const Rational& rhs) {
    static Rational result; // 静态变量,全剧只有一份
    result = /* 计算 lhs * rhs 的值 */;
    return result;
}

为什么不行?

  1. 线程安全性:静态变量是共享的,多线程环境下如果不加锁,会引发严重的数据竞争。

  2. 逻辑正确性:即使是单线程,它也会导致荒谬的结果。请看下面的比较操作:

    Rational a, b, c, d;
    if ((a * b) == (c * d)) {
        // ...
    }

    结果:这个 if 判断永远为 true原因

    • a * b 修改了静态变量 result 并返回其引用。

    • c * d 再次修改了同一个静态变量 result 并返回其引用。

    • 最终比较的是 result == result,当然永远相等!

解决方法

1. 移动语义 (Move Semantics, C++11)

这是现代 C++ 最重要的特性之一。如果编译器无法消除临时对象(即无法使用 RVO),它不再进行“深拷贝”,而是进行“移动”。

以前的痛点 (C++98)

当你 return result; 时,如果 result 是一个包含巨大数组的对象(比如 std::vector),编译器必须申请新内存,把数组里的数据一个个复制过去(Deep Copy)。

现在的做法 (C++11 及以后)

编译器意识到 result 是一个即将销毁的右值 (Rvalue)。它会调用移动构造函数 (Move Constructor),而不是拷贝构造函数。

  • 操作:仅仅是把 result 内部的指针“偷”过来,赋给接收者,并将 result 内部置空。

  • 代价:几次指针赋值的开销(接近 0),无论对象有多大。

class BigObject {
    int* data;
public:
    // 移动构造函数
    BigObject(BigObject&& other) noexcept : data(other.data) {
        other.data = nullptr; // 偷走资源,置空原对象
    }
    // ...
};

BigObject createHugeObject() {
    BigObject temp;
    // ... 填充数据 ...
    return temp; // 触发移动构造,瞬间完成,无需深拷贝
}

2. 强制返回值优化 (Guaranteed Copy Elision, C++17)

C++17 将以前编译器的“可选优化”变成了“语言标准”。

在很多情况下,现代 C++ 根本不会调用拷贝构造函数,甚至连移动构造函数都不调用。对象是直接在调用者的栈空间上构造的。这被称为 Zero-Copy Pass-By-Value

场景 A:返回临时对象 (RVO)

// C++17 标准保证:这里没有拷贝,也没有移动
Rational operator*(const Rational& lhs, const Rational& rhs) {
    return Rational(lhs.n * rhs.n, lhs.d * rhs.d); 
}

// 调用
Rational r = a * b; 

解析:编译器看到 return Rational(...),它直接在 r 的内存地址上执行构造函数。中间没有任何临时对象产生。

场景 B:具名返回值优化 (NRVO)

虽然 C++17 标准只强制了 RVO(返回临时量),但主流编译器(GCC, Clang, MSVC)对 NRVO(返回局部变量)的优化也非常成熟。

Rational func() {
    Rational result; // 局部变量
    // ... 操作 result ...
    return result;   // 现代编译器通常直接将 result 构造在接收者的位置
}

3. 智能指针 (Smart Pointers)

如果你确实需要在堆(Heap)上创建对象并返回(比如工厂模式,或者对象太大不适合放在栈上),Item 21 中提到的 new 导致的内存泄漏问题,现在用 std::unique_ptr 完美解决。

// ✅ 现代写法:返回 unique_ptr
std::unique_ptr<Window> createWindow(string type) {
    if (type == "scroll")
        return std::make_unique<WindowWithScrollBars>(); // 自动管理内存
    else
        return std::make_unique<Window>();
}

// 调用
auto w = createWindow("scroll"); 
// 即使 w 离开作用域,unique_ptr 会自动 delete,没有内存泄漏风险
// unique_ptr 也是通过“移动语义”返回的,效率极高

4. 常见误区:千万不要画蛇添足

在学习了移动语义后,很多初学者会犯一个典型的错误:显式调用 std::move

// ❌ 错误做法:阻碍了 RVO/NRVO
Rational func() {
    Rational result;
    return std::make_move_iterator(result); // 或者是 return std::move(result);
}

为什么这是错的?

  • 这会强制编译器使用“移动构造”。

  • 但是,“移动”虽然便宜,依然有开销(指针赋值)。

  • 而编译器的 RVO/NRVO 是零开销(直接构造)。

  • 显式写 std::move 会破坏 RVO 的条件,导致性能反而下降。

总结:现代 C++ 对 Item 21 的补充

Item 21 的核心建议 “必须返回对象时,就返回对象值,不要返回引用” 依然是金科玉律。

但在现代 C++ 中,你不必再为这个建议感到内疚,因为:

  1. C++17 保证:返回临时对象是零拷贝(Zero-Copy)。

  2. C++11 保证:即使需要拷贝,优先使用移动(Move),代价极低。

  3. 智能指针:让返回堆对象变得安全且无泄漏。

结论:大胆地写 return val; 吧,现代编译器和 C++ 标准会帮你处理好剩下的一切。

发表评论