如果有人说,在 C++ 里写函数时主动选择按值传递,而不是老老实实传const T&,你的第一反应是不是和我当初一样:这人是不是疯了?毕竟我们刚学 C++ 时,就被反复叮嘱“传引用别传值、避免不必要拷贝”,怎么《Effective Modern C++》第 41 条反而说“对于移动成本低且总是被拷贝的可拷贝形参,考虑按值传递”?
这条准则乍看反直觉,细想很有味道,但放到真实项目里又很容易被误用。我在团队 Code Review 里见过不少把它当成“万能按值传参”的写法,结果性能不升反降;也见过明明该按值传递的场景,因为担心“多一次移动”而写了近十行重载代码,维护成本陡增。这篇文章围绕这条准则,把成立条件、成本账目、误判陷阱和落地约定一次性讲透,适合正在用 C++11 及以上标准写业务代码、想把手动重载和拷贝逻辑减干净的工程师。
1. 为什么“按值传递”听起来像开倒车:三个限定条件缺一不可
1.1 先承认直觉是对的:C++98 时代按值传参确实要谨慎
在 C++98 时代,没有移动语义,按值传递代表的就是实打实的拷贝:实参拷贝到形参,函数体再用这个形参拷贝给别的目标,一路拷贝到底。所以当时的编码规范几乎统一是“非内置类型一律传const T&”,谁按值传大对象是要被批的。
进入 C++11 之后,移动语义引入了,结论开始分化。一个std::vector的移动构造只涉及三个指针的交换,代价极低;而拷贝构造要逐个元素复制,代价随规模线性增长。两者相差若干数量级。所以“按值传递是否吃亏”,取决于形参到底是“被拷贝进来的”还是“被移动进来的”,以及类型本身的拷贝/移动成本差异。
C++17 之后,copy elision 规则进一步收紧,纯右值可以直接在目标位置构造,按值传参的成本模型又多了一层变化。但万变不离其宗:按值传递的收益全都建立在“移动很便宜”这一前提下。
1.2 第 41 条准则到底在说什么:三个限定词逐个拆解
这条准则精简一下就是:对满足“可拷贝”“移动成本低”“总是被拷贝”三个条件的形参,传值比传引用更省事,且性能可接受甚至更优。
- 可拷贝:类型必须有拷贝构造函数,能正常复制。这排除了
std::unique_ptr这类 move-only 类型。 - 移动成本低:移动操作耗时接近 O(1),且常数很小。比如
std::vector、std::string(大字符串场景)、std::shared_ptr都属于这一类。std::array不属于,因为它本质上没有“移动”,移动它等于移动每个元素,成本 O(N)。 - 总是被拷贝:函数体内无论走哪条分支,最终都会把形参复制一份保存下来,比如插入容器、赋值给成员变量、作为副本传给其他场景。这时拷贝是“必然开销”,按值传参正好把这份必然开销提前到参数构造阶段,让右值时有机会变成移动。
如果三个条件缺一个,结论就完全不同。比如函数只是“读一下”参数就返回,不保留副本,按值传入就是纯浪费;又比如参数移动成本高,按值传入左值时平白多一次大对象移动,账就算不过来了。
2. 把账算清楚:三种传参方式在左值与右值场景下的成本对比
2.1 先看一段最常见的“拷贝进容器”代码
假设我们要写一个函数,把字符串追加到类成员的std::vector<std::string>里。目标是既要支持左值传入,也要支持右值临时对象传入,且最终容器里必须持有一份副本。
三种常规写法如下:
// 方案A:只传const引用,内部拷贝 void append(const std::string& s) { vec_.push_back(s); } // 方案B:左右值双重重载 void append(const std::string& s) { vec_.push_back(s); } void append(std::string&& s) { vec_.push_back(std::move(s)); } // 方案C:按值传递后move进容器 void append(std::string s) { vec_.push_back(std::move(s)); }三种写法目标一致,但拷贝/移动次数差异很大。我写过一张对比表,贴在工位旁边:
| 实现方式 | 传入左值(如变量str) | 传入右值(如"hello"临时量) |
|---|---|---|
方案A:const T& | 形参层 0 次,push_back 1 次拷贝 | 形参层 0 次,push_back 1 次拷贝(临时量被当做左值,丢失移动机会) |
| 方案B:左右值重载 | 形参层 0 次,push_back 1 次拷贝 | 形参层 0 次,push_back 1 次移动 |
| 方案C:按值传参 | 实参→形参 1 次拷贝,形参→容器 1 次移动 | 实参→形参 1 次移动,形参→容器 1 次移动 |
2.2 为什么“多一次移动”通常可以忽略不计
从表里能看出,方案C对比方案B,在左值场景多了一次移动,在右值场景两者打平。
关键在于:方案C多出来的那一次移动,代价受“移动成本低”这一条件的约束。std::string大字符串移动就是交换指针和长度字段,几乎等于拷贝三四个指针;std::vector同理。在堆分配、元素复制面前,这次移动的开销小到难以在性能分析里单独分辨。
方案B虽然左值时比方案C少一次移动,但它要求写两个函数体。如果函数体不止一行push_back,而是几十行业务逻辑,那重复代码就非常可观。更麻烦的是,当参数有多个、且每个都要区分左右值时,重载组合会爆炸:两个参数要四个重载,三个参数要八个重载。方案C把这些重载全消灭了,编译器自动根据调用点是左值还是右值决定走拷贝构造还是移动构造。
“多一次移动”换来“消灭重载”,这就是第 41 条准则的核心性价比判断。
2.3 只读不拷贝时,按值传递纯属浪费
必须强调:上述结论的前提是“总是被拷贝”。如果函数体只是检查字符串是否以某个前缀开头,用完即弃,那么写void check(const std::string& s)最合理。按值传参会强制在调用处构造一次新字符串,左值场景就多了一次完全没必要的拷贝。
所以这条准则的适用面不是“所有参数都可以按值传”,而是“必须拷贝的参数里,移动便宜的优先按值传”。判断起点永远是函数体内部对参数的使用方式,而不是调用点长什么样。
3. “移动成本低”没那么简单:四类容易被误判的类型
3.1 小字符串优化(SSO)与 std::string 的两副面孔
std::string常被当作“移动便宜”的典型,但它内部通常有小字符串优化(SSO):短字符串直接存在对象内部的缓冲区里,不涉及堆分配。这种情况下,移动操作根本不会“把指针搬走”,而是像拷贝一样把十几个字节逐字节复制过去。
也就是说,std::string只有在字符串超过 SSO 阈值、真正持有堆内存时,移动才是 O(1) 的指针交换。对于平均长度远小于阈值的短字符串场景,移动和拷贝的成本相差不大,按值传参带来的额外移动也就没有想象中那么“便宜”。当然,SSO 缓冲区的复制成本本身也很低,这种场景下按值传参即便不是最省,也不会造成严重性能事故,但如果你在性能敏感热路径上处理大量短字符串,就得重新掂量了。
3.2 std::array 根本没有“移走”这回事
std::array<T, N>是静态数组包装,元素直接内嵌在对象内存里。它的“移动构造”本质上是对 N 个元素逐个移动,复杂度严格 O(N)。如果 N 很大,移动成本跟拷贝几乎同一量级。
有人会想当然地写void process(std::array<int, 1024> arr),觉得既然是 C++11 容器应该移动便宜——错了。按值传std::array和按值传一个自定义结构体没有本质区别,纯按位复制。这种情况下应该继续使用const std::array&,或者考虑用std::span/ 指针传递视图。
3.3 shared_ptr 的原子计数与 unique_ptr 的“不可拷贝”界限
std::shared_ptr的移动构造只是把指针和引用计数指针搬走,O(1),但它拷贝时需要对控制块做原子自增,成本虽也是 O(1),却比普通指针拷贝多一些同步开销。如果按值传shared_ptr且每次都拷贝一次,那原子操作计数就会多一轮。好在现代 x86/ARM 下原子自增几十纳秒级别,多数业务代码无感,但不意味着可以在高频回调里放任不管。
至于std::unique_ptr,它根本不可拷贝,天然不符合“可拷贝形参”前提。按值传unique_ptr相当于强制调用方std::move交出所有权,语义完全变了——这不是“传递参数”而是“转移所有权”。因此准则对它不适用,应该继续用非 const 引用或指针明确表达“借用但不拥有”。
3.4 自定义对象:移动构造是否为 noexcept 必须确认
对自定义类型,判断“移动成本低”首先要看移动构造函数是否被显式定义,以及它是否被标记为noexcept。如果移动构造内部只是指针/句柄的索取,且不抛异常,那它就是理想的低移动成本对象;如果移动构造里做了深拷贝或可能抛异常,那它的“移动”名不副实。
noexcept还有个隐蔽影响:容器扩容时,如果元素移动构造不声明noexcept,std::vector保守地选择拷贝构造而不是移动构造。换句话说,你按值传参时省下的移动,可能在你把参数塞进容器的瞬间因为容器自身的扩容逻辑又变回拷贝。定义移动构造时记得加noexcept,这是很多经验不足的开发容易漏掉的一环。
4. “总是被拷贝”的含义与三个容易误用的边界
4.1 条件拷贝:按值传参的“固定成本”亏了
“总是被拷贝”必须是所有控制流路径上的必然操作,而不是“偶尔拷贝”。看这个例子:
void maybeStore(std::string s) { if (condition) { cache_.push_back(std::move(s)); } }当condition为假时,传入的左值白白拷贝构造了s,最后函数结束又析构掉;右值临时对象倒还好,只多一次移动。这种“可能存储也可能不存储”的场景,按值传入相当于对每条调用路径都收取入场费,而真正的消费方只有在条件成立时才出现。这是典型的误用:应该改成const std::string&,并在条件成立时内部拷贝。
更准确的判断方法是问自己:把函数体抽出来看,是否每一条执行路径最终都会消费一份参数副本?只要存在一条“光看不存”的路径,按值传递的经济账就已经不成立了。
4.2 构造函数与成员初始化:按值传递的最佳实践场景
按值传参在构造函数里表现最好,因为它能同时满足“需要保存副本”和“消灭重载”两个诉求。典型写法:
class Widget { public: // 左值时:1次拷贝构造 + 1次移动构造 // 右值时:2次移动构造 Widget(std::string name) : name_(std::move(name)) {} private: std::string name_; };对比老派写法Widget(const std::string& name) : name_(name) {},右值传入时丢移动机会;对比双重重载版本,代码又显得冗余。这里我并不是说构造函数只能按值传——如果你确认成员后面还有复杂的初始化逻辑,或者想支持直接聚合初始化,还是应该具体情况具体分析。但 pass-by-value-and-move 是 C++11 之后公认最简洁、性能几乎无损的构造写法。
4.3 多态与对象切片:按值传基类是危险边界
按值传参有一个引用传递天然免疫的致命问题:对象切片。当形参类型是基类Base,调用点传入派生类对象时,按值传入会强制对Base做切片构造,派生部分的成员全部丢弃。多个派生类的区别消失,虚函数表指针也被重置为基类版本。
这在传引用的方案里不会发生,因为引用绑定的是完整对象,虚函数派发正常。所以凡是涉及多态、虚函数、继承体系内的类型,都不应该用按值传参,哪怕其移动成本再低。准则里的“可拷贝”只保证了拷贝构造可用,并不保证类型语义适合切片。这也是我在评审中最常拦下的按值误用之一。
4.4 函数返回副本时的 NRVO 博弈
还有一种容易算错的场景:函数不仅接收副本,还要把这个副本通过返回值交出去。如果函数只有一条返回路径,编译器很可能做 NRVO(命名返回值优化),直接在原值位置上构造,省掉返回阶段的拷贝/移动。这种情况下,你在函数内写的return s;往往是最优的。
但如果函数存在多条返回路径、且有条件返回其中某个形参,NRVO 的省略就不可行了。此时按值形参配合return std::move(s);能把最后一次拷贝变为移动,反而是省开销的做法。所以看到“按值传参后还要 return 形参”的代码,先别急着改写成return s;,要想一想编译器能否在此函数中完成 NRVO。标准随时在演进,但“能用语言特性表达清楚意图,就别赌编译器的省略优化”这一原则不会变。
5. 落到代码里:决策规则、评审要点与代码模板
5.1 一张可以贴在工位上的决策表
我在内部研讨和评审标准里沉淀了一张简化决策表,逐条判断即可得出是否应该按值传参:
| 判断问题 | 结论 |
|---|---|
| 类型可拷贝吗? | 否 → 使用引用/移动语义,不适用本准则 |
| 函数体每条路径都会保存副本吗? | 否 → 使用const T& |
移动成本低且移动构造noexcept吗? | 否 → 使用const T&或重载 |
| 涉及多态/继承体系吗? | 是 → 使用引用或智能指针,禁用按值 |
| 以上全符合? | 是 → 按值传递 +std::move到最终目标 |
只要前四条里有任意一条不满足,就别用按值传递。这张表看起来简单,但执行起来能拦住绝大多数误用。
5.2 我在 Code Review 中看到的高频误用模式
在实际评审中,我见过三类高频误用,几乎每次都要提醒:
- 第一类,函数末尾存在
if (fail) return;这样的提前退出路径,意味着并不总是保存副本,但作者还是按值传了参数。这会让失败路径也白白构造一次形参。 - 第二类,形参类型是第三方库自定义的大对象,看起来“内部有堆指针”就默认移动便宜,实际上它的移动构造函数要么没定义,要么内部仍在做深拷贝。这时按值传参等于原价买进拷贝,还得被一眼看不出成本的移动代码迷惑。
- 第三类,处理
std::string的短字符串热路径。正如前面说的 SSO,短字符串移动竟然是逐字节复制,按值传入在左值场景下其实是“拷贝一次 + 再复制十几个字节”,性能敏感时不该被忽略。处理这类热路径,我建议先 profile,再决定按值还是传引用。
5.3 与完美转发共存:各司其职
有人可能会问:“既然按值传递能区分左右值,为什么不干脆用完美转发模板(universal reference)完全替代?”两者解决的问题不同:完美转发能让形参类型和值类别完全保持调用点的原貌,适合“把参数再转手传给下游”的场景;而按值传参制造了一个独立的、可修改的副本,适合“函数要把副本保存下来自己用”的场景。
在模板代码里使用按值传参要格外谨慎,因为模板能接受的类型范围完全不可控,移动成本、是否切片都无从谈起。所以第 41 条通常针对具体类型,比如已知的std::string、std::vector、std::shared_ptr;在模板命名空间里,我倾向于要么完美转发,要么干脆传引用,避免按值。
5.4 一个可复制的标准实现模板
最后,把最常用的“按值传参 + 保存副本”模板贴出来,作为一个稳妥的起点:
class DataSink { public: // 满足条件时:简单、统一、可读性高 void addString(std::string value) { strings_.push_back(std::move(value)); } // 注意:这里不要写 value 的其他使用逻辑 // 一旦 move 之后,原对象处于有效但未指定状态 private: std::vector<std::string> strings_; };有一点我一直提醒合作过的同事:std::move之后不要再用那个形参。虽然标准只说“移动后对象处于有效但未指定状态”,实际实现往往就是空字符串,但如果依赖这一点,代码就背上未定义行为的达摩克利斯之剑。规范做法是只在最后一次使用形参时 move。
就我个人的项目体验来说,第 41 条这条准则最有价值的不是“性能优化”,而是“代码减重”。在构造函数和少量简单的 setter /store 类接口里,按值传参消灭重载、消灭手写const T& + T&&的样板文本,让接口意图更清晰。它也确实逼着团队把“移动成本”纳入了日常讨论,而不是只停留在“引用比传值好”的教条上。如果你正维护一套以拷贝大对象为常态的代码库,不妨从最典型的std::string成员存储场景开始试水,跑一遍单测和 benchmark,再用决策表铺开——你会很快体会到这条被误解多年的“反直觉准则”真正解决的是什么问题。