
你在一行“几秒钟就能完成”的乘法运算里写下一个operator*因为它要返回一个新对象。你嫌按值返回会“多一次拷贝”于是绞尽脑汁改成返回const Rational然后程序在某天深夜崩了。如果这段描述让你后背发凉那么《Effective C》条款二十一的标题一定让你会心一笑必须返回对象时别妄想返回其reference。这条条款不是我读过的第一遍才懂的而是踩了无数次悬垂引用、内存泄漏的坑之后用血泪换来的。这篇文章想把这条条款真正讲透为什么你会想返回引用、返回引用会怎么死、按值返回为什么反而安全高效以及C11之后的世界里这条条款有什么新变化。1. “返回引用”的诱惑以及它隐瞒的代价很多C初学者和八年经验的老手都会在本能里冒出“返回引用更高效”的想法。原因很简单引用是别名不产生新对象不就省了一次复制吗然而这个念头忽略了一个根本前提——引用只是对象的别名它自己不拥有生命周期。当你返回一个引用时编译器只看到你扔出去一个“门牌号”至于门牌后面那间屋子还在不在编译器不负责。1.1 引用是门牌号不是房子本身我们用生活场景来类比。你家小区门口有个公告栏上面写着一个地址“3栋203”。有人拿着这个地址去找你如果那时你还住在里面没问题但如果房子已经被拆了扩建停车场他拿着地址找到的就是一片空地。C里的引用就是那个地址对象才是有内容的房子。如果函数返回引用就是在返回一块写着地址的牌子如果指向的对象已经析构调用者再对这个引用做任何操作就是在一个“已经拆掉的房子”里翻箱倒柜——典型的未定义行为UB。对象生命周期由它的存储位置决定栈上的局部对象离开作用域时析构堆上的对象只有你手动delete才析构静态存储期对象到程序结束才析构这三种存储位置恰恰对应了后面要讲的三种反模式。1.2 “用引用避免拷贝”其实是过度焦虑先摆一个事实现代C编译器在绝大多数情况下不会真的为“按值返回”付出高昂拷贝代价。RVO返回值优化、NRVO具名返回值优化、移动语义以及C17的保证复制省略一层层地把“按值返回”的开销降到接近零。你为了避开那根本不存在的性能瓶颈却选择了一个会直接导致崩溃或泄漏的错误接口这笔账怎么算都不划算。更重要的是接口的语义不该被“想象中的性能差异”绑架。返回引用意味着你在向调用者承诺“这个对象在我返回之后仍然好好活着”而函数内部临时创建的对象显然无法满足这个承诺。你只是在赌调用者不会长时间保留这个引用——但良好的代码不该建立在“对方必须用完即弃”的不安全约定上。2. 五种“返回引用”的典型死法从局部变量到static的连环坑这一节我把这些年见到、踩过的“返回引用反模式”按死因分类。你可以当作一份避坑手册任何一个出现在你的代码里都该立刻报警。2.1 返回局部变量的引用最经典的悬垂引用先看教科书级反例const std::string getGreeting() { std::string s Hello, Effective C; return s; // s 离开函数体立即析构 }调用者拿着这个引用可能一开始还能读到字符串因为栈内存还没被其他变量覆盖但一旦后续任何一次函数调用或栈帧复用这块内存就被改写得面目全非。表现是“间歇性正常突然输出乱码再隔一段时间直接崩溃”。这类问题还常见于返回局部std::array、局部std::vector、局部结构体。只要对象是函数内部的自动变量一律不能返回指向它的引用或指针。2.2 返回new出来的对象的引用内存泄漏的温床有人觉得绕开局部变量用new在堆上创建再返回引用总安全了吧const Rational operator*(const Rational lhs, const Rational rhs) { return *new Rational(lhs.getNum() * rhs.getNum(), lhs.getDen() * rhs.getDen()); }功能上对象确实还活着但问题变成了调用者拿到的是const Rational他很难想起要重新取地址delete。更尴尬的是如果这个引用被赋给其他变量最终没人能再拿到原始指针。每一个这样的调用都会产生一个无法回收的堆对象。很快内存占用就顺着调用次数线性增长直到进程被系统杀掉。除非接口刻意设计成“工厂函数返回智能指针”否则在普通返回对象的场景里用new 引用就是给自己埋雷。2.3 返回函数内部static局部对象的引用语义崩溃与多线程地狱这是很多人在前两种失败后的“终极自救”既然局部变量会死、堆管理麻烦那么用静态变量让它活到程序结束吧const Rational operator*(const Rational lhs, const Rational rhs) { static Rational result Rational(lhs.getNum() * rhs.getNum(), lhs.getDen() * rhs.getDen()); return result; }这个代码乍看能用实际上比前两个更毒。后果有三所有乘积共享同一个对象。你写if ((a * b) (c * d))结果永远为true因为两次operator*返回的是同一个static对象后一次调用把前一次的值覆盖了。要比较两个积数据的值根本留不住。多线程数据竞争。多个线程同时调用这个函数都在写同一个static局部对象轻则结果不确定重则直接触发数据竞争带来难以排查的偶发崩溃。初始化顺序与线程安全C11保证函数内static局部变量的初始化线程安全但这只保证“构造时”的安全不保证同一函数多次调用的互斥。static局部变量构成的“单例式”返回只适合极少数“所有调用者都明确需要同一份状态”的场景例如全局配置、日志句柄。用在普通返回值函数里就是一场语义灾难。2.4 返回临时对象的引用const引用延长生命周期的误解很多C开发者听说过“const引用可以延长临时对象生命周期”的规则于是试图钻空子const Rational operator*(const Rational lhs, const Rational rhs) { return lhs * rhs; // 返回的是临时对象造成的引用 }实际规则是const T ref 临时对象;会让临时对象的生命周期延长到引用变量所在作用域。但这里的“临时对象”必须是直接绑定到引用的那个纯右值prvalue而且延长只针对那个引用本身。你把它放进函数返回值编译器无法知道调用者会怎么接住这个引用。函数返回前临时对象就已经销毁返回的引用是悬垂的。即使调用者用const Rational res a * b;接收也救不回来因为延寿规则不适用于通过函数返回传递的引用。这个坑特别隐蔽因为它可能在你本地的简单测试里运行正常直到输入某些数据导致栈布局变化才露馅。2.5 返回成员变量的引用条款真正的边界在哪注意到条款说的是“必须返回对象”时不要返回引用并不是一棍子打死所有返回引用的函数。返回成员变量的引用是引用返回为数不多的合法场景。templatetypename T T vectorT::operator[](std::size_t n) { return elems[n]; }这里elems[n]本身就是一个已经存在的、生命周期由容器管理的对象不是本函数“必须返回的新对象”。调用者通过引用修改容器元素是合理需求只要容器活着引用就有效。如果你在getter里返回成员引用请确保const版本返回const T还要小心先调用resize或push_back使迭代器失效的情况。这是引用返回的正确用法和条款二十一并不冲突。3. 正确做法按值返回让编译器与移动语义为你打工既然返回引用这么危险那遇到“必须返回新对象”时该怎么办答案很简单按值返回。但很多人的顾虑是“按值返回不是要复制对象吗”下面拆解为什么这个顾虑在当代C里基本是多余的。3.1 按值返回的底层语义从拷贝到移动再到“零拷贝”用C98时代编译器处理Rational operator*(...)的经典方式函数内部构造一个临时对象再调用拷贝构造函数把临时对象复制到调用者的目标存储位置最后析构函数内部临时对象。这个过程确实有一次拷贝一次构造但在C11之后故事变了。首先C11为operator*返回的局部对象提供了移动语义如果Rational实现了移动构造函数那么编译器会把局部对象直接“偷”到返回值里通常只是搬几个指针或数字字段开销极小。其次编译器还有更厉害的大招返回值优化RVO。如果你的return语句返回一个临时对象表达式结果编译器可以直接在调用者要接收的位置构造这个临时对象完全跳过中间拷贝/移动。C11标准并不强制RVO但主流编译器在-O1及以上都会做。C17则更进一步把某些纯右值场景下的复制消除变为了“保证消除”对象直接在目标位置构造连移动都不需要发生。这才是真正的“零拷贝”。3.2 RVO与NRVO编译器如何直接构造返回对象RVO处理的是return T(args);这种直接返回匿名临时对象的场景std::string makeString() { return std::string(Hello); }编译器会把std::string(Hello)直接构造到调用者的栈上不产生额外临时对象。NRVO则处理返回局部具名对象的场景std::string makeString() { std::string result Hello; return result; // 具名返回值优化 }result是具名的NRVO把result的存储空间和调用者的接收对象重叠从而避免复制/移动。NRVO不是标准强制的但主流优化编译器基本都会尽力做。有个关键经验想让编译器更放心地做RVO/NRVO就保持函数体简单不要在return前面对局部对象做“引用逃逸”式的操作。例如将局部对象地址存入外部全局容器等于告诉编译器这个对象可能被观察到优化就很难安全展开。3.3 “用引用优化”不如“先写对再用测试说话”总有人坚持认为“引用返回一定比按值返回快”。这个结论在“必须返回新对象”的语境下几乎不成立。对于复杂类型哪怕编译器没能优化掉一次移动移动的开销也远低于对象悬垂后再花两天排查崩溃的成本。更重要的是真实的性能瓶颈极少出在这种单次对象返回上而是出在循环中的反复构造、算法复杂度、I/O和内存分配模式上。如果你真的在某个热点路径里发现按值返回成为瓶颈正确的做法不是改成返回引用而是重新设计函数签名——例如使用输出参数填充结果或者返回一个持有资源的智能指针甚至直接返回std::optional表示“可能没有值”。先保证语义正确再用量化测试验证热点别凭感觉做“优化”。3.4 函数体内直接返回初始化列表的另一个小技巧在C14之后很多函数可以直接用花括号初始化返回值std::vectorint makeVec() { return {1, 2, 3}; }这里返回的是一个临时vector编译器可以轻松实施RVO。相比先构造一个局部变量再return local这种写法往往更容易触发完全省略。我自己的经验是能用“直接返回表达式/花括号”完成语义就不要引入具名局部对象调试和优化都会更省心。4. 实操判断法则什么时候可以返回引用什么时候必须返回对象很多人读条款时只记住“不要返回引用”结果连本该安全的“返回成员引用”也不敢用了白白写出笨重的拷贝。这里教大家一套判断流程快速分辨手头场景到底怎么选。4.1 先回答三个问题被返回的对象在本函数外面是否已经存在如果存在并且你能确保它的生命周期至少和调用者持有引用一样长比如是某个容器的元素、类成员、由调用者传入的对象那么返回引用是合理的。典型的就是operator[]、成员getter。如果对象是由本函数刚刚创建的它不在外面“已经存在”那必须按值返回。你需要保持多态行为吗如果函数需要返回基类引用指向派生类对象并且对象本身存活于别处那可以用引用。但如果对象是本函数创建的返回引用就是坑按值返回又可能切片此时正确的设计是返回std::unique_ptr或std::shared_ptr。“没有返回值”的情况需要表达吗如果函数可能找不到结果C17之前大家习惯返回空指针或一个bool输出参数C17之后可以返回std::optionalT也可以返回指针指针可以表示“空”。不要用“返回一个空引用”这种不存在的东西欺骗自己。4.2 场景到返回方式的决策表场景示例推荐返回方式原因返回对象的成员变量vectorT::operator[]T或const T成员生命周期随容器返回引用语义清晰返回本函数创建的对象数学运算operator*、拼接字符串按值返回T没有外部对象可引用引用必死移动/省略让它便宜返回可能不存在的查询结果findValueByKeystd::optionalT值语义表达“有/无”避免悬垂返回底层对象需要多态不变式Factory::create()std::unique_ptrT值返回会切片引用无法管理生命周期用输出参数填充结果void getResult(T out)非const引用或指针将对象的存储交给调用方控制这里特别强调首个场景的边界返回成员引用虽然可以但你要保证“成员引用的有效期不超出成员的所属对象”。例如std::string const getName() const调用者如果先delete obj再使用引用依然会崩封装的getter会辅助但无法阻挡“调用者明确乱用”。这不属于条款的范畴那是生命周期契约问题。4.3 实战案例数学运算符、工厂函数与解析结果数学运算符Rational operator*(const Rational lhs, const Rational rhs) { return Rational(lhs.getNum() * rhs.getNum(), lhs.getDen() * rhs.getDen()); }这是标准做法。C17下return Rational(...)通常是保证省略的连移动都没有。而如果这里用引用除了2.1/2.2/2.3的坑还会让表达式a*b*c直接语义错乱。工厂函数std::unique_ptrBase createObject(const std::string type) { if (type derived_a) return std::make_uniqueDerivedA(); if (type derived_b) return std::make_uniqueDerivedB(); return std::make_uniqueBase(); }为什么这里不返回值而返回unique_ptr因为Base可能有虚函数按值返回会切片但也不能返回引用因为右侧对象是新建的、没有外部持有者。智能指针承担了所有权语义。解析函数用C17的std::optional来表示“有结果或没结果”std::optionalint tryParse(const std::string s) { if (s.empty()) return std::nullopt; return std::stoi(s); // 代码简写示意注意异常处理 }调用者直接if (auto v tryParse(42))使用值既安全又直观。4.4 特别警告不要用输出参数“假装避免返回值”有些团队为了“严格避免一切临时对象”把本应返回新对象的函数改成输出参数void multiply(const Rational lhs, const Rational rhs, Rational out);这种做法在C相当正统尤其在高性能数值库里常见。但要注意它把构造函数的选择权让给了调用者调用者必须预先默认构造一个Rational如果Rational没有默认构造你就卡住了。此外函数内out lhs*rhs可能还是需要一次赋值未必比按值返回快。所以输出参数适合“已经有目标存储、想反复利用已有缓冲”的场合正常情况下我建议优先按值返回代码更自然也更容易让编译器做优化。5. 版本演进与新共识从C98到C20的条款现实《Effective C》最初写在C98时代条款二十一在当时的指导意义是“不要因为害怕临时对象就把代码写坏”。随着C11、C17的到来很多人会疑惑既然移动语义这么便宜还有必要讨论“不要返回引用”吗我的结论是这条条款不但没过时反而更有底气。5.1 C11移动语义并未推翻条款而是强化了它移动语义出现后按值返回的代价断崖式下降。如果一个类型定义了正确的移动构造return local_obj时编译器会尽量移动而不是深拷贝。此时返回引用想省下的开销越发微不足道——你为了省几十纳秒却付出悬垂引用和代码不可维护的代价。这是明显不划算的。另一个容易被忽视的点是移动构造本身可能抛出异常。标准库的std::move_if_noexcept会在异常安全策略下选择拷贝还是移动。如果你的类型在移动时抛异常编译器可能退回拷贝此时RVO依然可能兜底。所以你应当保证绝大多数自建类型的移动构造是noexcept的否则在某些容器操作中会遇到难以预料的性能回退。5.2 C17保证复制省略按值返回的黄金时代C17引入了一个概念叫“保证复制省略”guaranteed copy elision。具体来说当一个纯右值prvalue用来初始化一个同类型对象时编译器不再创建一个临时对象再拷贝/移动而是直接把纯右值表达式的结果在当前上下文的存储位置构造。比如auto f() { return T{}; } T t f();这里T{}这个临时对象会被直接构造到t的存储中整个过程没有拷贝也没有移动。函数返回T{}不再有任何可观测的额外开销。所以对于“返回临时对象”的场景条款二十一在C17里变成了绝对零成本的操作。那么更没理由去写那些危险的“返回引用”了。对于“返回具名局部对象”的NRVOC17依然允许但不保证不过绝大多数主流编译器在-O2下都能做好即便NRVO没触发也有移动兜底——这样的性能表现已经足够让“引用返回一把梭”的想法彻底失去借口。5.3 C20/23的进一步视角与代理对象的警示C20带来了概念、协程、范围等新特性C23更是细节满满。但“必须返回对象就返回对象”的准则从未改变。不过有一个例外值得拿出来提醒某些类型会返回“代理对象”proxy比如std::vectorbool::operator[]返回的是std::vectorbool::reference它是一个“假装成引用”的对象而不是真正的bool。这类代理对象的引入是为了模拟引用语义但它们本身必须按值返回而且生命周期短暂。如果你写出auto b vec[0];把它当引用长期持有代理对象析构后你再访问就会踩坑。这条启示是不仅是返回引用要用生命周期思维返回代理对象同样要意识到“引用模拟器”的寿命问题。“必须返回对象”场景下按值返回仍然是最可信的选择。5.4 个人长期实践中的几点补充用这个条款多年我总结出三条日常铁律接口签名优先表达语义不要试图用签名表达“优化意图”。你写const T getValue()读代码的人第一反应是“这个函数返回一个已有对象的引用”如果你实际是想返回一个临时计算的、立刻消失的值这就是在欺骗调用者。宁可写T getValue()让调用者自己决定是否复用对象。如果你真的在某个大型项目里需要极致性能请在编译器的优化报告中验证。用-fno-elide-constructors看看关闭复制消除之后你的函数有多少次移动/拷贝调用如果你发现按值返回在那里确实形成开销那就用“输出参数”或“原地构造”来替换而不是退回“返回引用”。这不是编程风格问题是数据导向的工程方法。对团队成员做代码评审时看到返回引用的函数首先要求对方回答“这个引用指向的对象凭什么还没有死”。答不出这一条的99%都是反模式。能答出来而且理由充分的比如返回成员、返回全局静态配置才放过。条款二十一真正教会我的不是“不许用引用”这个教条而是“引用不是给你用来创建对象的引用只是指定已经存在的对象”。你当然可以创建对象后让引用指向它但那不意味着你能把对象的生命周期压缩进一次函数调用。必须返回新对象时就大大方方按值返回把那些看不见的临时构造、优化决策全部交给编译器——这是现代C里性价比最高的信任。如果以后有人拍着胸脯说“返回引用更好”不妨请他解释一下那个引用指向的对象到底由谁持有答案会说明一切。