资讯动态

打破直觉:C++中何时该按值传递而非用const T?

发布时间:2026/10/19 4:01:11 来源:尧图企业网站定制
如果有人说在 C 里写函数时主动选择按值传递而不是老老实实传const T你的第一反应是不是和我当初一样这人是不是疯了毕竟我们刚学 C 时就被反复叮嘱“传引用别传值、避免不必要拷贝”怎么《Effective Modern C》第 41 条反而说“对于移动成本低且总是被拷贝的可拷贝形参考虑按值传递”这条准则乍看反直觉细想很有味道但放到真实项目里又很容易被误用。我在团队 Code Review 里见过不少把它当成“万能按值传参”的写法结果性能不升反降也见过明明该按值传递的场景因为担心“多一次移动”而写了近十行重载代码维护成本陡增。这篇文章围绕这条准则把成立条件、成本账目、误判陷阱和落地约定一次性讲透适合正在用 C11 及以上标准写业务代码、想把手动重载和拷贝逻辑减干净的工程师。1. 为什么“按值传递”听起来像开倒车三个限定条件缺一不可1.1 先承认直觉是对的C98 时代按值传参确实要谨慎在 C98 时代没有移动语义按值传递代表的就是实打实的拷贝实参拷贝到形参函数体再用这个形参拷贝给别的目标一路拷贝到底。所以当时的编码规范几乎统一是“非内置类型一律传const T”谁按值传大对象是要被批的。进入 C11 之后移动语义引入了结论开始分化。一个std::vector的移动构造只涉及三个指针的交换代价极低而拷贝构造要逐个元素复制代价随规模线性增长。两者相差若干数量级。所以“按值传递是否吃亏”取决于形参到底是“被拷贝进来的”还是“被移动进来的”以及类型本身的拷贝/移动成本差异。C17 之后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::vectorstd::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临时量方案Aconst 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::arrayT, N是静态数组包装元素直接内嵌在对象内存里。它的“移动构造”本质上是对 N 个元素逐个移动复杂度严格 O(N)。如果 N 很大移动成本跟拷贝几乎同一量级。有人会想当然地写void process(std::arrayint, 1024 arr)觉得既然是 C11 容器应该移动便宜——错了。按值传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还有个隐蔽影响容器扩容时如果元素移动构造不声明noexceptstd::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 是 C11 之后公认最简洁、性能几乎无损的构造写法。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::vectorstd::string strings_; };有一点我一直提醒合作过的同事std::move之后不要再用那个形参。虽然标准只说“移动后对象处于有效但未指定状态”实际实现往往就是空字符串但如果依赖这一点代码就背上未定义行为的达摩克利斯之剑。规范做法是只在最后一次使用形参时 move。就我个人的项目体验来说第 41 条这条准则最有价值的不是“性能优化”而是“代码减重”。在构造函数和少量简单的 setter /store 类接口里按值传参消灭重载、消灭手写const T T的样板文本让接口意图更清晰。它也确实逼着团队把“移动成本”纳入了日常讨论而不是只停留在“引用比传值好”的教条上。如果你正维护一套以拷贝大对象为常态的代码库不妨从最典型的std::string成员存储场景开始试水跑一遍单测和 benchmark再用决策表铺开——你会很快体会到这条被误解多年的“反直觉准则”真正解决的是什么问题。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑