Item 21:必须返回对象时,不要试图返回引用

对齐 Effective C++ 第三版 Item 21:分析 operator* 等新值结果为何必须按值返回,排除局部引用、heap 引用和 static 引用,并建立 value、owner、reference 与 view 的生命周期决策。

学习目标

  • 能解释 local object、heap object 和 static object 引用返回分别导致的悬空、泄漏与共享状态
  • 能实现 operator* 等独立计算结果的安全值返回,并说明 copy elision 的作用
  • 能判断 value、typed owner、reference、optional/result 和 view 各自的 ownership/lifetime 契约
Return contract decision maplocal object · heap object · operator* · fresh result valuelocal objectreturn reference→ dangling referenceheap objectreference hides owner→ leak / unknown deleteoperator*return object by value→ copy elision / NRVO先验收 ownership,再谈优化lifetimeowner / deleteridentity / aliasvalue cost返回类型必须与结果真实的 ownership、identity 和 lifetime 一致
局部引用悬空、堆引用隐藏 owner;独立计算结果按值返回,让语言和编译器处理对象传递成本。

返回契约实验

结果该由谁拥有

先预测返回对象在表达式结束后是否仍有 owner,再切换方案查看生命周期和地址证据。

storage / ownership

operator* 在函数体创建 local object,离开 block 时 destructor 结束其 lifetime。

失败模式

返回引用指向已结束的 storage,后续读取是 dangling reference / undefined behavior。

当前场景 · local object → dangling reference

返回引用指向已结束的 storage,后续读取是 dangling reference / undefined behavior。

从“省一次复制”的错误目标开始

假设 Rational 乘法产生一个新有理数:

class Rational {
public:
    Rational(int numerator = 0, int denominator = 1);
};
 
const Rational& operator*(const Rational& lhs, const Rational& rhs);

返回 reference 看似能避免复制,但问题先于性能:这个引用究竟绑定谁?结果既不是 lhs,也不是 rhs,而是一个必须拥有独立生命周期的新值。

Item 21 的原则是 Do not try to return a reference when you must return an object(必须返回对象时,不要试图返回引用)。

若语义要求新对象,就按值返回对象;不要为了猜测优化而虚构 owner。

返回局部对象引用会立即悬空

最直接的错误是返回 local object(局部对象)的引用:

const Rational& operator*(const Rational& lhs, const Rational& rhs) {
    Rational result(lhs.numerator() * rhs.numerator(),
                    lhs.denominator() * rhs.denominator());
    return result;
}

函数退出时 result destructor 执行,调用者得到 dangling reference(悬空引用)。后续读取已经结束生命周期的 storage 是 undefined behavior。

编译器警告不等于运行期保障。stack memory 可能暂时保留旧字节,让测试偶尔“正常”,随后被另一次调用覆盖。

返回 heap 引用会隐藏 ownership

有人把对象放到 heap,试图延长生命周期:

const Rational& operator*(const Rational& lhs, const Rational& rhs) {
    return *(new Rational(lhs.numerator() * rhs.numerator(),
                          lhs.denominator() * rhs.denominator()));
}

引用有效了,但调用语法 Rational product = a * b; 没有告诉用户需要 delete。更糟的是表达式 a * b * c 产生的中间引用没有可靠位置释放。

返回 unique_ptr<Rational> 虽能表达 ownership,但仍不符合普通乘法的 value semantics,并迫使每个算术表达式处理间接对象。只有结果本质是动态资源时才应返回 owner。

static 对象把结果变成共享槽位

第三种绕路是返回 function-local static:

const Rational& operator*(const Rational& lhs, const Rational& rhs) {
    static Rational result;
    result = Rational(lhs.numerator() * rhs.numerator(),
                      lhs.denominator() * rhs.denominator());
    return result;
}

对象不会析构过早,也没有每次泄漏,但所有调用共享同一个 result。const Rational& x = a*b; const Rational& y = c*d; 后,x 和 y 指向同一对象,第二次调用覆盖第一次结果。

线程安全初始化不等于线程安全修改;并发乘法会 data race。递归、重入和嵌套表达式同样破坏结果。

operator* 产生的是独立值

乘法操作符的自然语义是输入不变、产生新值。正确签名直接返回 Rational:

Rational operator*(const Rational& lhs, const Rational& rhs) {
    return Rational(lhs.numerator() * rhs.numerator(),
                    lhs.denominator() * rhs.denominator());
}

operator*(乘法操作符)按值返回,调用者可以自然组合、存储或丢弃结果,不承担 hidden delete,也不会与后续调用共享 identity。

让 copy elision 处理实现成本

现代 C++ 对许多 prvalue 返回保证 copy elision;即使未强制消除,编译器可做 NRVO,或使用 move constructor。

Rational multiply(Rational lhs, Rational rhs) {
    Rational result = normalize(lhs, rhs);
    return result; // NRVO candidate
}

不要写 return std::move(result) 企图帮助优化;它可能阻止 NRVO。先写正确 value semantics,再用 compiler、benchmark 与 profile 验证成本。

引用返回在借用既存对象时合理

规则不是“永远不返回引用”。assignment operator 返回 *this,container operator[] 返回既存 element,stream insertion 返回 stream;这些结果都已由其他对象拥有。

Widget& Widget::operator=(const Widget& rhs) {
    // replace state
    return *this;
}
 
const Item& Catalog::at(Index index) const {
    return items_.at(index);
}

关键是文档和类型能回答 owner、lifetime、invalidation 和 mutability。

Catalog 引用不能比 Catalog 活得久;vector 扩容可能让 element reference 失效;返回 non-const reference 还允许用户修改 invariant。

返回输入引用也要防临时对象

identity、min/max 风格函数可能返回某个输入引用:

const Record& chooseBetter(const Record& lhs, const Record& rhs);

若调用者传临时对象并把结果保存,临时在完整表达式末尾析构,引用悬空。

可通过 API 限制 rvalue overload、返回值、或使用语言 lifetime annotation/static analysis。不能只写一句“不要传临时值”就认为契约安全。

view 返回同样是 lifetime 契约

string_viewspan、iterator range 按值返回描述符,但底层数据仍由其他对象拥有。

std::string_view User::displayName() const noexcept {
    return display_name_;
}

view 对象本身按值返回不会悬空,但 User 销毁或 string 被修改重分配后,底层引用失效。接口文档与测试必须覆盖 owner mutation。

可空与失败不应伪装成引用

C++ reference 不能合法表示“没有对象”。若查找可能无结果,可返回 pointer、optional value、optional reference wrapper、iterator 或 Result,按 ownership 选择。

std::optional<User> loadUser(UserId id);          // 独立 value
const User* findCachedUser(UserId id) noexcept;   // nullable borrow
Result<Document, LoadError> loadDocument(Path);   // value or error

返回 pointer 作为 borrow 时仍需说明 lifetime;返回 optional value 则由调用者拥有独立结果。

用地址与生命周期轨迹验证

先预测结果 storage 在每个 sequence point 后是否仍有 owner,再建立测试:

  • compiler warnings 与 sanitizer 捕获 return-local-address 和 use-after-scope。
  • allocation ledger 验证 operator 不产生无人负责的 heap object。
  • 连续、嵌套、递归和多线程调用证明结果不共享 static slot。
  • copy/move counters 与 compiler optimization report 验证 value return 成本。
  • reference/view tests 覆盖 owner 销毁、container reallocation 和 mutation invalidation。
  • temporary inputs 检查 input-bounded reference 不被保存。
  • API static checks 确认 nullable/failed result 使用 pointer、optional 或 Result 表达。

正确性门禁先证明没有悬空、泄漏和共享覆盖,再讨论是否值得优化。

小结

  • 新计算结果需要独立对象时,返回 reference 会制造错误 lifetime 或 ownership
  • local object 引用立即悬空,heap 引用隐藏释放责任,static 引用共享并覆盖结果
  • operator* 应按值返回 Rational,让 copy elision/NRVO 处理传递成本
  • 返回引用只适用于借用既存对象,并明确 owner、invalidation、mutability 和输入 lifetime
  • view、pointer、optional、Result 与 typed owner 各自表达不同返回契约
  • return lifetime trace 应覆盖临时、嵌套、并发、重分配和失败路径

资料与写作方式声明

本章以Effective C++, Third Edition, Item 21权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

名词解释

本章出现的专业名词,用大白话再讲一遍。

fresh result value

函数新计算且不从属于输入的独立结果。

return-contract fidelity

返回形式与真实 ownership/lifetime 一致。

local object

离开 block 时通常结束生命周期的自动对象。

dangling reference

所指对象生命周期已结束的引用。

undefined behavior

不再受标准行为约束的执行。

heap object

动态分配且需要明确 owner 释放的对象。

hidden ownership obligation

未由返回类型表达的释放责任。

typed owner return

携带唯一释放责任与 deleter 的返回值。

shared result slot

多次调用返回同一可覆盖对象的设计。

reentrant call

函数完成前再次进入自身的调用。

operator result semantics

operator 产生独立结果值的契约。

copy elision

直接在最终位置构造并省略中间传递。

NRVO
对具名局部返回对象的复制消除优化。
semantics-first return design

先保证语义再测量返回成本。

borrowed reference return

借用由其他 owner 管理的既存对象。

reference invalidation contract

规定引用在哪些事件后失效。

input-bounded lifetime

返回借用的 lifetime 不超过输入对象。

lifetime annotation

机器可识别的借用与输入 lifetime 关系。

borrowed view return

返回非 owning 数据范围描述符。

dangling view

view 存活但底层 storage 已失效。

absence-aware return

显式表达存在、缺失或失败的返回类型。

return lifetime trace

记录结果 storage、owner 与失效事件的轨迹。

练习

  1. 问题 1:修复 Rational operator(return a reference when you must return an object、local object、heap object、operator)。* 比较返回 local reference、heap reference、static reference 与 value 的行为。
  1. 问题 2:审查 Catalog::at(borrowed reference return、reference invalidation contract、dangling reference)。 它返回内部 vector element 的 const reference,列出完整 lifetime 契约和安全替代。
  1. 问题 3:设计查找结果(typed owner return、borrowed view return、absence-aware return)。 缓存查找可能无结果,磁盘加载可能失败,分别选择返回类型。

讨论

评论区加载中…