
当年我在排查一个偶现的线上崩溃时顺着堆栈钻进了一个自定义类的拷贝构造函数发现这个对象在主链路里竟然被悄悄复制了二十多次。内存碎片、分配风暴、性能抖动最后全部指向同一个原因一个该用引用传递的地方写成了值传递。值传递与引用传递这两个名词几乎所有 C/C 学习者都能背出来可一旦把问题放到底层内存的视角下很多写了几年代码的人也会栽跟头。今天我就用庖丁解牛的方式沿着骨头缝下刀把调用栈、寄存器搬运、对象生命周期、编译器优化这几层外壳一层层剥开把这台传递机器的运转机制彻底讲透。这篇文章适合谁读刚学 C/C 想搞懂指针、引用、参数传递区别的同学写基础库、中间件、引擎这类性能敏感代码的工程师准备面试时总被为什么函数里改了参数外面没变这种问题卡住的求职者。读完你会建立起一套关于传递语义的直觉而不是再靠背结论过活。1. 先从改不动的值说起值传递到底传了什么1.1 一个经典到不能再经典的 swap 失败现场几乎所有 C/C 学习者都见过这段代码也都亲手踩过同一个坑#include cstdio void swap(int a, int b) { int tmp a; a b; b tmp; } int main() { int x 3, y 5; swap(x, y); printf(x%d, y%d\n, x, y); return 0; }运行结果永远是x3, y5。如果你觉得我明明交换了为什么没生效那说明还没有真正理解函数调用发生的那一刻内存里发生了什么。说白了就一句话swap(x, y)传进去的不是 x 和 y 本身而是它们各自的一份复印件。在main的栈帧里x 和 y 是两块独立的 4 字节内存分别在rbp-4和rbp-8的位置。调用swap时编译器生成指令把 x 和 y 的当前值搬运到swap自己的栈帧或者寄存器中这些复制出来的值才成为形参 a 和 b。从那之后a、b 与 x、y 就完完全全没有关系了。swap内部交换的 tmp、a、b 这三块内存从头到尾没有碰过 x、y 的地址。这个例子可以类比成你把自己桌上的两份文件复印了副本交给同事同事在副本上画了圈、交换了两份副本的位置你桌上的原件当然纹丝不动。这个类比几乎适用于所有基本类型的值传递。1.2 反汇编值传递的每一刀切在哪里只看高级语言的行为很多人还是觉得隔了一层。那就切进汇编层看值传递在 CPU 眼里到底是什么样的。下面是一份简化过的 x86-64 伪汇编用来演示main调用swap(x, y)时发生的事情# main 中调用 swap(x, y) movl -4(%rbp), %eax # 先把 x 的值读入 eax 寄存器 movl -8(%rbp), %ecx # 再把 y 的值读入 ecx 寄存器 call swap # 调用函数 # swap 函数入口参数 a、b 已经在寄存器/栈上 movl %eax, -4(%rbp) # a x 的副本 movl %ecx, -8(%rbp) # b y 的副本 movl -4(%rbp), %edx # tmp a movl -8(%rbp), %eax # a b movl %edx, -8(%rbp) # b tmp这里最关键的一句是movl -4(%rbp), %eax。它把一个内存地址里的值读出来而不是把地址本身传走。后续在 swap 内部做的所有操作都是对这个被复制出来的值在折腾。所以值传递的本质就是在调用边界做一次内存拷贝。这个结论对于int成立对于结构体也成立对于std::string、std::vector同样成立——差别只在于拷贝的代价不同。理解了这一点后面所有关于性能的讨论都有了地基。另外注意一个细节函数返回后swap 的栈帧被弹出栈指针回退a、b、tmp 所在的栈内存逻辑上被释放了。但物理内存里的旧数据并不会被立刻清零它们还静静躺在那里。这个物理残留 逻辑失效的错位是后面讲悬垂引用时的一个重要伏笔你先把它记在脑子里。1.3 位拷贝、浅拷贝、深拷贝三种复制根本不是一回事既然值传递的本质是拷贝那拷贝本身也有不同层次。这是很多新手混淆的地方我必须拆开讲清楚。位拷贝bitwise copy对int、double、裸指针这类基本类型以及不含资源的 POD 类型编译器直接生成mov系列指令把若干字节从一块内存搬到另一块内存。速度快没有副作用在寄存器层面几条指令就结束了。浅拷贝shallow copy类类型默认的拷贝语义。编译器逐个拷贝每个数据成员的值。如果成员里有一个指针拷贝完成后两个对象的指针成员指向的是同一块内存。这就是最常见的坑函数内部通过形参修改了数据外面的实参对象里也能看到变化析构时两个对象还会去释放同一块内存造成 double free。深拷贝deep copystd::string、std::vector这类 RAII 容器重写了拷贝逻辑申请新的内存把每个元素逐一复制过去。你自定义的拷贝构造函数也可以实现同样的深拷贝逻辑。写一个带资源的类时这条规则几乎是必修课class Buffer { public: Buffer() : ptr_(new int[1024]) {} // 深拷贝给新对象重新开辟独立内存 Buffer(const Buffer other) : ptr_(new int[1024]) { std::copy(other.ptr_, other.ptr_ 1024, ptr_); } // 这里还应该实现析构函数、移动构造、拷贝赋值否则资源管理不完整 private: int* ptr_; };按值传递Buffer时编译器会调用拷贝构造函数堆上就会多出一块 1024 个 int 的新内存。如果错误地用了浅拷贝直接ptr_ other.ptr_函数内部对数据的修改会穿透到原对象析构时还会二次释放。很多值传递导致的诡异崩溃根源就在这里你以为是传值隔离实际上传的是共享指针。2. 引用传递的本质绑定、别名与那些看不见的地址2.1 引用到底占不占内存标准与实现的博弈要说清楚引用传递得先从引用本身讲起。C 标准里对引用的定义是绑定到另一个对象的别名。既然是别名标准就不规定引用是否要占用独立的内存空间它只保证你对引用的操作等同于对原对象的操作。这就导致了引用在语义层和实现层之间的一套微妙关系。你可以自己验证int a 10; int ref a; // 语义上 ref 就是 a std::cout sizeof(ref) \n; // 输出 4和 sizeof(a) 一样 std::cout (ref a) \n; // 输出 1两者地址完全相同但是当你把引用放进结构体时编译器就藏不住了struct Holder { int r; }; std::cout sizeof(Holder) \n; // 在 64 位平台上通常是 8这个Holder里必须存储某种能定位到被引用对象的信息实现上通常就是一个指针。所以更准确的说法是引用在纯变量场景下往往被编译器优化得完全消失但在函数参数、结构体成员等场景下底层就是一个指针的马甲。这里要强调一个很多人容易犯迷糊的点引用一旦初始化就永远绑定在同一个对象上。对引用赋值改的是被引用对象的值不是让引用改绑到新对象。这一点和指针天生不同也正是它更安全的来源。2.2 反汇编对比引用的隐藏指针身份写两个功能相同的函数一个用值传递一个用引用传递void modify_val(int r) { r 42; } void modify_ref(int r) { r 42; }在 x86-64 下它们的汇编完全不同。值传递版本modify_val(int): movl %edi, -4(%rbp) # r 保存在局部栈槽 movl $42, -4(%rbp) # 改的是局部副本 ret引用传递版本modify_ref(int): movq -8(%rbp), %rax # 取出 r 内部保存的地址 movl $42, (%rax) # 间接写入调用方的内存 ret第二段代码里那句movq -8(%rbp), %rax暴露了引用的真实身份它保存的是一个对象的内存地址。movl $42, (%rax)则通过这个地址直接往调用方的变量内存里写数据。这就是为什么引用传递能改得动外面的实参——函数拿到了实参的地址并基于这个地址去做间接访问。从调用方看传引用时通常会发生一次lea类指令把实参的地址放到寄存器里然后作为参数传给函数。传值时则是把实参的值拷到寄存器。一个传地址一个传值这就是两者在机器层面的核心分水岭。有人会问那指针呢传指针也是传地址引用和指针在汇编层其实长得几乎一样。这个问题很好下一节专门讲。2.3 引用和指针同一把刀不同的用法指针和引用在底层都是地址传递但语言层面把它们设计成了性格迥异的两个东西。我把高频区别整理成一张表对比维度指针T*引用T是否能为空可以nullptr合法不允许必须初始化绑定是否可重新绑定可以改指其他对象不行绑定后终身不变取地址运算得到指针变量自己的地址得到被引用对象的地址是否需要解引用访问对象要写*p直接用不需要解引用视觉混淆调用处要写obj调用处和值传递长得一样适用场景可选参数、动态数据结构必选参数、运算符重载这张表背后的工程含义相当现实。比如可选参数一个函数可能接受不存在的对象那T*配合nullptr判断就是最直白的方案引用在此场景下无解。再比如容器std::vectorT是编译不过的因为引用不是可复制可赋值的常规对象不满足容器的元素要求这时候你得改用std::reference_wrapperT。还有一个在代码评审里高频出现的点函数传const std::unique_ptrT和传裸指针T*怎么选前者语义更明确——调用方已经持有了所有权函数只是临时借用。后者更像我不关心所有权你给我一个可空地址就行。这两种写法底层都是传地址但表达的所有权语义完全不同。从这一节你应该得出一个结论引用不是更好的指针它是一种有约束的别名工具。约束带来安全也带来局限。深刻理解这套差异才能在不同的场景里选对工具。3. 性能账本拷贝成本、拷贝省略与移动语义的三方博弈3.1 一个 vector 引发的血案深拷贝的开销回到我开头说的那个崩溃场景。如果有人写了一个这样的函数去处理数据void computeTotal(std::vectorint nums) { long long sum 0; for (int n : nums) { sum n; } // 函数结束nums 析构释放自己分配的内存 }如果传入的nums有 100 万个元素按值传递会发生什么调用拷贝构造函数、分配大约 4MB 的堆内存、把 100 万个 int 逐个复制、函数结束时再把这 4MB 释放。一次调用一次完整的堆内存过山车。而如果形参改成const std::vectorint函数只需要接收一个 8 字节的地址循环逻辑完全不变堆分配一次都不发生。这不是理论推演而是我实际排查过的线上问题。一个看起来人畜无害的按值传参在热路径上被调用几千次内存分配器和缓存被反复折腾服务 RT 直接抬上去一截。当时我把这个函数从按值改成const引用后接口耗时就降了一半多。这里必须补充一个细节现代 C 里有移动语义情况并没有那么绝对。如果调用方传的是一个右值临时对象按值接收会触发移动构造移动一个vector本质上是交换内部指针和容量几乎不复制元素std::vectorint buildBigVector(); computeTotal(std::move(big)); // 触发移动构造内部指针被搬走按值传参在右值场景下可能也很便宜。关键问题是如果调用方传的是左值按值接收就一定会触发拷贝除非编译器帮你省略掉。所以谈性能之前得先分清实参是左值还是右值这是所有判断的前提。3.2 C17 拷贝省略编译器的隐形刀法讨论值传递性能时还有一个绕不开的话题拷贝省略copy elision。C17 引入了强制省略规则当用一个纯右值直接初始化对象时比如std::string s hello;编译器必须直接在目标内存上构造对象不允许先构造临时对象再拷贝/移动一次。这段代码std::string makeName() { return std::string(zhangsan); } auto name makeName();在 C17 下从返回临时对象到name 初始化的整个链条理论上可以做到零拷贝零移动字符串对象直接构造在name的内存位置上。这就是著名的 RVO返回值优化场景。不过要小心边界条件函数内构造了一个具名局部变量再返回它属于 NRVO具名返回值优化C 标准并不强制编译器做。也就是说std::string makeName() { std::string local zhangsan; return local; // NRVO优化器大概率做但不强制 }这句话的实际含义是什么你写代码时不能依赖 NRVO 做拷贝消除因为它不是语言保证的。但主流编译器在-O2以上几乎都会执行这个优化所以实际运行中你可能根本观察不到那次拷贝。这就造成了一种错觉有些人觉得自己按值传返回也没多慢其实很大程度是编译器在替你把拷贝省掉了而不是语言本身免费。还有一点需要注意传参场景不享受 C17 的强制省略。如果你把一个具名左值按值传给函数该拷贝就是实打实的语义要求优化器在某些条件下可以 elide但那是优化不是保证。什么时候该依赖编译器、什么时候该靠代码设计来避免拷贝这里面的分寸感就是老手和新手的分界线。3.3 什么时候传值反而更划算讲了这么多拷贝开销别急着一律用引用。传值在不少场景下不但不亏甚至更合理。我总结出三条典型场景第一对象很小且没有堆资源。比如int、double、不超过 16 字节的 POD 结构体。传值只需要几条mov指令而传引用反而要先传地址再间接解引用性能上多出一次指针访问的开销。对于几个字节的小对象按值传递几乎总是更好。第二函数本来就需要在内部保存一份副本。典型的是构造函数class Person { public: // 按值接收右值实参走移动左值实参走拷贝然后统一搬进成员 Person(std::string name) : name_(std::move(name)) {} private: std::string name_; };这种写法叫 sink 模式函数吞掉参数。传入左值时拷贝一次传入右值时直接移动成本最小代码也简洁。如果改成const std::string你反而要在内部手动name_ name;再拷贝一次遇到右值还得额外写一个重载区分繁琐得多。第三多态对象绝对不能按值传。按值传递基类对象会发生对象切片slicing派生类特有的部分被切掉虚函数也会退化成基类版本。遇到多态必须用Base或const Base或者指针传递让动态类型完整保留。反过来什么时候必须用引用大对象只读访问用const T需要修改实参用T需要表达没有对象用指针或std::optional。这些规则拼在一起就是你做传参决策时的完整地图。4. 生命周期暗坑悬垂引用、临时对象延长与重载的隐藏分支4.1 悬垂引用栈帧已碎但幽灵数据还在这是我在文章开头埋的那个伏笔现在正式引爆。看这段代码int dangling() { int x 42; return x; // 典型错误返回局部变量的引用 } int main() { int r dangling(); printf(%d\n, r); // 有时是 42有时是垃圾甚至直接崩溃 }x是dangling函数栈帧里的局部变量。函数返回时栈帧逻辑上被销毁x的生命周期结束。但你在main里拿到的是指向这块已失效内存的引用。物理内存里的 42 并不会立刻消失它静静地躺在原来的栈地址上。如果你在第一次访问时读到 42别高兴那是运气。下一次任何一个函数调用都可能复用这块栈内存把旧值覆盖成完全不相干的数据。这就是未定义行为的典型特征它偶尔正常、偶尔崩溃、偶发在一个看似毫无关联的模块里。我见过有人排查这种问题排查了一整周最后靠 AddressSanitizer 才定位到是函数返回了局部引用。类似的情况在容器里也常见std::vectorint v{1, 2, 3}; int first v[0]; v.push_back(10); // 如果触发了扩容v 内部缓冲区被整体迁移 first 100; // first 已经悬垂写操作是 UB关键在于引用只是别名别名本身没有任何生命周期管理能力。对象死了别名还留在你手里用它就是在踏雷。写代码时的基本防线是永远不要从函数返回局部对象的引用或指针也永远不要在容器可能扩容之后继续持有它的元素引用。如果确实需要返回对象内部的一部分请确保这个对象的生命周期比引用更长否则就要考虑返回副本或std::shared_ptr这类共享所有权方案。4.2 const 引用的寿命外挂临时对象生命周期延长C 里有一条体贴的规则当临时对象被绑定到const T或者T时临时对象的生命周期会被延长到引用变量的生命周期。所以这段代码是合法的const std::string name std::string(hello); std::cout name \n; // 安全临时字符串的寿命被延长到了 name 作用域结束这个机制很实用比如你在写一个接收const std::string的函数时可以放心地传一个字面量进去编译器会创建一个临时std::string并把它延长到调用结束保证函数内部安全访问。但如果把形参改成非 const 左值引用foo(42)这种调用直接编译失败因为非 const 引用不允许绑定到右值/临时对象。这也是为什么const T形参的兼容性最好它能同时接收左值、右值、字面量、临时对象。但这条规则有一个非常隐蔽的例外很多人踩进去就出不来了。看这个例子struct S { std::string m; }; S makeS() { return S{test}; } const std::string r makeS().m; // 悬垂为什么这条延长失效了因为生命周期延长只适用于临时对象本身不适用于临时对象的子对象成员。makeS()返回的临时S在完整表达式结束时就被析构了它的成员m自然也跟着销毁可r这个引用还孤零零地挂在原地。这行代码表面上看着四平八稳实际上是标准的悬垂引用。真实项目里跨函数返回成员引用然后崩溃的案子十有八九和这个规则有关。我的建议是看到临时对象的成员引用一律按悬垂处理直接重写实现。4.3 重载与模板推导值、引用、const 引用的选择博弈传参方式一旦和函数重载放在一起就又热闹了几分。看这个重载组void foo(int v) { puts(value); } void foo(int v) { puts(lvalue ref); } void foo(const int v) { puts(const ref); } int a 1; foo(a); // 实参是具名左值编译器优先选择 foo(int)对一个非 const 左值变量来说foo(int)是最直接的匹配不需要任何额外的拷贝或 const 转换所以重载决议会优先生效。这在设计接口时是很有用的工具如果你想让某个函数能改就改不能改就报编译错误那就只提供T版本像foo(const int)这种通吃型形参就不要挂上去否则重载会悄悄绕过你的本意。另一个每天都能遇到的场景是模板推导。模板里的T并不一定是右值引用它可能是万能引用template typename T void pass(T t); int a 1; pass(a); // T 被推导为 int引用折叠后形参是 int可以修改左值 pass(1); // T 被推导为 int形参是 int绑定右值这里起作用的机制叫引用折叠。T遇上左值实参推导出的T是int然后int 折叠成int遇上右值实参T是int形参就是int。这个规则构成完美转发std::forward的基础。实际开发中如果你不想参与这场博弈就远离T老老实实写const T如果你想写一个既能高效处理左值又能高效处理右值的通用转发函数那就要真正吃透引用折叠而不是靠猜。5. 把传递语义变成直觉判断框架、争议点与检查习惯5.1 传参方式的速查表文章看到这里你已经有了底层原理的支撑。我把常见场景整理成一张速查表可以直接贴在工位上使用场景推荐形参形式底层理由只读小对象≤16 字节且无堆资源T值传递几条 mov 指令无解引用开销只读大对象string、vector、自定义类const T避免深拷贝与堆分配函数需要修改实参本身T直接写入调用方内存可选实参可能没有T*或std::optionalT用空指针/空 optional 表达无接收右值并接管所有权T或T值 std::move移动构造通常只交换内部指针函数内部要保存一份副本const T 内部拷贝或T值 std::move左值拷贝一次右值零拷贝搬入多态对象只读访问const Base/Base避免对象切片保住动态类型这个表不是死教条。比如大对象到底多大才算大我的经验阈值是超过两个寄存器的宽度64 位平台上约 16 字节、或者内部管理了堆内存的类都算大对象别按值传。这个阈值来自机器指令的直觉一次真正便宜的拷贝应该在寄存器层面完成一旦牵涉堆分配成本就会上升几个数量级。5.2 几个高频争议点const string 还是 string业内讨论最热烈的两个争议点这里也一并给你我的判断。第一个const std::string还是std::string如果函数内部只需要读取字符串const std::string最安全它不区分调用方传的是字面量、左值还是右值全部通吃。如果函数内部要保存一份副本构造函数最常见那按值 std::move的 sink 模式是现代 C 的推荐做法。还有一个时代因素早年没有移动语义时按值传std::string传一个字面量都要触发堆分配所以大家习惯性地到处写const std::string。现在有了 SSO 短字符串优化和移动构造按值传的代价大幅下降但读取只用引用存储才按值这条大方向依然没变。第二个争议点引用在汇编层面就是指针这句话对吗我的答案是通常对但不能下死结论。编译器完全可以把引用内联优化掉不生成任何取地址指令而且引用比指针的语义约束更强——非空、不可改绑、天生绑定——这给了优化器更多激进空间。所以准确地说引用在实现层往往以指针形式存在在语义层比指针更安全也因此更容易被优化。面试时如果你能说出这一层差别会明显拉高印象分。5.3 Code Review 时我必查的几个点理论归理论落到日常工作里我沉淀了几个固定的 Code Review 检查习惯每次都能揪出问题看到大对象按值传参且参数在函数内只读我会在评论里直接标注copy on call要求改成const T。看到返回局部对象引用或指针的函数直接标红一律让作者重写。这类代码即使当前没崩也是随时可能引爆的未定义行为。看到构造函数形参是const std::string且成员需要保存副本会建议改成按值 std::move既简洁又能在右值场景下省掉一次拷贝。遇到持续崩溃但复现不稳定的问题我会先让作者开 AddressSanitizer 编译一遍测试套件悬垂引用和越界访问通常立刻暴露。最后再分享一个我自己的习惯新代码默认用const T做只读形参只有函数确实需要副本才改成按值 std::move只有函数明确要写回实参才用T。这条规则帮我挡掉了绝大多数的传参问题。别把这些细节当成填空题背回到内存视角去理解每一次传递背后到底搬了什么——地址还是数据、拷贝还是移动、生命周期够不够长——你自然就能在写代码的那一刻选对那把刀。