C++形参和实参的底层逻辑:从值传递到移动语义
写了几年C见过最冤的一个Bug是在函数里交换两个变量的值回到main()后两个变量纹丝不动。代码短得不能再短可就是有人找不出原因最后发现是典型的把形参当实参、把值传递当引用传递问题。形参parameter和实参argument的区别一句话就能说清形参是函数定义里的占位符实参是调用函数时真正传给它的值。但这句话背后的内存行为、拷贝时机、生命周期以及现代C里那些花式传参方式才是真正值得掰开揉碎的地方。这篇文章不打算照着教科书念概念我会从一个真实翻车场景入手把值传递、指针传递、引用传递的底层逻辑讲透再聊数组退化、const引用、移动语义这些高阶话题顺便分享一些我在代码评审里反复遇到的坑。1. 从一次参数传错引发的血案开始为什么你必须理解形参和实参1.1 一个swap函数引发的半小时排查先看这段代码我一个朋友当年在项目里写了一个交换函数意图很明确把外部两个整型变量的值换过来。#include iostream void swap(int x, int y) { int tmp x; x y; y tmp; } int main() { int a 1, b 2; swap(a, b); std::cout a b std::endl; // 输出 1 2 return 0; }输出是1 2a和b根本没被交换。最迷惑的一点在于如果你在swap里面打印x和y会发现x确实变成了2y变成了1——交换明明成功了可一回到main()a和b还是老样子。这就是形参和实参最经典的冲突现场。函数定义里的int x, int y是形参它们在函数被调用时才会创建main里调用swap(a, b)时传入的a和b是实参。C默认采用值传递系统会把a和b的值拷贝一份分别赋给x和y。也就是说x和y是a和b的复制品住在不同的内存地址里。swap内部交换的是x和y这两个私人副本副本怎么折腾都影响不到远在main()里的原件。如果你想真正交换外部变量就必须让函数操作变量本身也就是传递引用或指针。void swap2(int x, int y) { int tmp x; x y; y tmp; }这里的int x是引用形参它不拷贝任何东西而是直接绑定到实参a上。函数里写x y等价于写a b。swap2(a, b)调用之后a和b才会真正交换。这个例子看起来小儿科但它是理解之后所有内容的地基形参只是局部变量它的初始值来自实参但和实参是否共享内存取决于传递方式。1.2 形参是函数的私人变量实参是调用方的实际值我们把概念再往深处推一层。在C标准里形参有一个更准确的说法它是函数作用域内的变量函数体执行期间它拥有自己的存储空间引用形参除外引用本身不独立存储这个后面讲。每次调用函数形参都会被重新初始化初始值来自实参表达式。所以形参的生存周期与一次函数调用绑定调用结束形参销毁而实参的生命周期由调用方的上下文决定两者天然不同。实参本身可以是任意求值结果为T或能转换成T的表达式比如变量、常量、函数返回值、算术表达式甚至逗号表达式。举个简单例子void print(int value); print(42); // 字面量作为实参 print(1 2); // 表达式作为实参 print(globalValue); // 全局变量作为实参 print(getValue()); // 函数返回值作为实参无论实参多复杂值传递时最终被拷进形参的只是那个值。这个概念区分开以后你就能看懂很多报错信息了。比如编译器说initialization of parameter 1 of void print(int)模糊不清它是在告诉你说我不知道该用哪个重载版本的形参来匹配你给的这个实参。理解了形参和实参的对应关系读编译器错误也会顺很多。还有一个容易忽略的点C和C语言在形参命名上有不同的做法。在C里如果某个形参在函数体内用不到可以不写名字只留类型比如void f(int)。调用时实参照传但函数内部无法访问它。这在实现某些接口、需要满足虚函数签名时会用到。我见过很多人非要写个void f(int unused)然后用(void)unused;来静默默认其实直接不写形参名更干净。2. 形参和实参的内存真相值拷贝、指针拷贝与真实对象2.1 三种传递方式在内层到底发生了什么C里传参方式虽然花样繁多但本质只有三种值传递、指针传递、引用传递。现代C加上了移动语义但移动本质上也是一种特殊的值传递我放到第5节单独说。先把三种传统的讲清楚。值传递按值传参形参是实参的副本实参的存储区域和形参的存储区域完全是两块内存。内置类型比如int、double、bool拷贝非常便宜因为就几个字节。但如果是自定义类对象值传递会调用拷贝构造函数构造一个新的完整对象这个操作可能非常昂贵比如拷贝一个std::vectorint里面包含一百万个数那就意味着拷贝一百万次int。所以值传递的关键信息是形参拥有独立内存修改形参不影响实参拷贝开销由实参类型决定。指针传递按指针传参形参是一个指针变量实参是某个对象的地址。调用时拷贝的是地址值而地址指向的那个对象是实参和形参共享的。看这段void inc(int* p) { (*p); // 改的是p指向的对象外部变量会变 p nullptr; // 改的是p本身外部指针变量不会变 }p是形参它和实参指针变量各存一份地址但指向同一个int对象。通过*p修改等于直接修改main里的那个int但是把p本身改成nullptr并不会改变main里那个指针变量的值。这里特别容易犯错指针传递能不能改外部指针变量不能。想改指针变量本身得用二级指针或指针的引用void replace(int* ptr);这在实际写链表、树、哈希表时会遇到。我自己就栽过一次想在一个函数里把Node* head重新指向新节点传了个Node* head形参函数里head new Node回到外面head还是空的。这就是典型的指针变量自己没被传引用。引用传递按引用传参引用形参不是副本它是实参的别名语言层面上它和实参是同一个对象。函数里对引用的任何读写都直接作用于实参。引用在绑定之前必须初始化而且一旦绑定就不能再绑定其他对象。void reset(int x) { x 0; }调用reset(n)后n变成0。引用的本质是什么在C标准里引用是另一个变量的别名不要求占用存储。但在实际底层实现中绝大多数编译器会用指针来模拟引用。所以从效率上讲引用传递和指针传递开销类似都是传一个地址那么大小的数据不会拷贝整个对象。从语言语义上讲引用传递比指针更安全引用不能为空不能玩指针算术语法上也省去了*p的麻烦。2.2 哪个是同一个对象地址才是硬道理判断一个函数到底改没改外部数据有一个最直接的办法打印地址。#include iostream void check(int v, int r, int* p) { std::cout main的对象地址: std::endl; std::cout v v std::endl; std::cout r r std::endl; std::cout p p std::endl; } int main() { int x 123; check(x, x, x); }在我机器上输出大致是v0x7ffd1234aaa0 r0x7ffd1234aabc p0x7ffd1234aabcv和main里x的地址完全不同说明v是拷贝r和p则和x的地址一致说明引用和指针都指向同一个对象。看地址是我在实际调试里最推荐的验证手段比在脑内模拟快得多。很多疑似参数没传进去的问题打印一下地址一秒定位。这里还要提一个常被误解的结论很多人以为引用传递不产生拷贝所以一定比值传递快其实不绝对。对于int这类内置类型值传递和引用传递在汇编层面几乎没有差别可能值传递还要快一点因为少了间接寻址。真正体现引用优势的是那些拷贝成本高的类型比如std::string、容器、大型结构体。3. 数组、函数名和const引用三个极易被绕进去的参数陷阱3.1 数组参数的真实身份首地址的另一种写法数组作为实参传进函数是C/C里最具迷惑性的场景。看这段常见代码void printSize(int arr[]) { std::cout sizeof(arr) / sizeof(arr[0]) std::endl; } int main() { int nums[10] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; printSize(nums); }你以为形参int arr[]是一个数组类型于是在函数里用sizeof(arr)想算出数组大小。结果在64位系统上sizeof(arr)是8因为arr的真实类型是int*它退化为指针了。在C标准里数组实参传参时数组名会隐式转换成指向首元素的指针形参int arr[]和int* arr完全等价。所以sizeof根本没机会知道数组总大小它只能得到指针变量的大小。那怎么在函数里拿到数组真实大小最干净的C做法是传引用到数组templatesize_t N void printSize(const int (arr)[N]) { std::cout N std::endl; }const int (arr)[N]是一个对数组的引用实参传进来时N由模板自动推导arr保留了完整的数组类型信息sizeof(arr)就是整个数组的字节数。这种写法在泛型代码里很常用。如果不想写模板那就老老实实多传一个长度参数void printSize(const int* arr, size_t len);再现代一点的做法C20提供了std::spanT它天然携带元素数量和指针传std::spanint就能把指针加长度打包成一个参数后面第五章还会讲到。3.2 const引用和临时变量引用延长了谁的生命周期const T是很多初学者的盲区。先看这段void log(const std::string msg); log(std::string(hello)); log(hello world);第二行调用实参是const char*而形参是const std::string这看起来不匹配const引用怎么绑定到一个需要临时构造的string上答案是编译器会创建一个临时std::string对象然后用这个临时对象去初始化引用。关键在于这个临时对象的生命周期会被引用延长到引用所在的作用域结束。这就是const引用延长临时对象生命周期的机制。为什么要强调const因为非const引用不允许绑定到临时对象C标准明确禁止std::string直接绑定右值临时对象。如果你写void log2(std::string msg)然后调用log2(std::string(hello))编译直接报错。原因是一个非const引用意味着你可以修改外部对象但临时对象马上销毁修改它没有意义编译器干脆禁止这种写法。而const引用允许绑定临时对象因为只读不写临时对象活多久都无所谓。这个机制在实际工程里非常重要接收字符串、大对象的只读参数时用const std::string而不是std::string可以避免一次拷贝还能顺便接受字符串字面量、字符数组等实参类型非常灵活。代价是隐藏了一次临时对象构造但通常比拷贝成本低很多。3.3 函数名作为实参时的降级与恢复另一个容易踩的坑是函数作为实参。C里函数名本身也是一个可传递的值当你把一个函数名传给形参时函数名会退化成函数指针。比如#include iostream int addOne(int x) { return x 1; } void apply(int (*func)(int), int x) { std::cout func(x) std::endl; } int main() { apply(addOne, 5); // 输出6 }这里apply(addOne, 5)实参是函数addOne它会退化成int (*)(int)函数指针和形参int (*func)(int)匹配。函数指针作为形参的坑有两个一是阅读性差一堆指针、括号、类型标识很难看二是不能直接捕获带状态的lambda。工程上更推荐用std::functionint(int)作为形参它能同时接受普通函数、函数对象和有捕获的lambdavoid apply(const std::functionint(int) func, int x) { std::cout func(x) std::endl; } apply([](int v) { return v * 2; }, 5);std::function的代价是会有一次类型擦除的开销但如果这个回调调用频率不高这点开销无所谓。如果对性能极端敏感又想保持灵活可以用模板形参templatetypename Callable void apply(Callable func, int x);这就是万能引用切入泛型回调是C模板编程的另一个话题。形参概念在这里被扩展了模板形参本身就是为了接收类型而不是接收值。理解形参和实参还要理解类型实参这件事模板里的typename T和普通函数里的int x一样都是形参只是前者接收类型后者接收值。4. 写代码时如何决定用哪种形参从性能、语义和工程质量三个维度看4.1 先问角色只读、修改还是接管每次写一个函数我都会先问自己一个问题这个参数在函数里扮演什么角色角色只有三种只读、修改、接管。只读参数函数只想用参数的值或状态不打算改变它。如果参数是内置类型或者很小的对象用值传递如果是大对象用const T或std::string_view。修改参数函数必须把外部变量改掉。这时候用引用T如果这个参数可能是空的、可选修改用指针T*。为什么不用指针更彻底因为引用不能为空一旦用了T调用方必须提供一个真实对象函数里不用判空少一堆if (ptr ! nullptr)。但如果你的函数语义上允许不传某个对象比如一个可选的回调上下文那指针更合适因为nullptr可以表达没有。接管参数函数要把传入的对象变成自己的资源或内部状态。比如构造函数接收一个字符串并保存到成员变量这叫接管。接管的正确写法一般能接受两种const std::string拷贝一份或者std::string移动一份。现代C还有一种更简单的写法按值传再move进去后面会展开。用角色来决策好处是语义清楚代码评审时别人一眼能看出意图。我最怕看到的代码是一个函数签名里全是int*、std::string、const std::vectorint混在一起又不加注释读起来像在破译密码。明确角色后参数类型自然就定了代码自我解释能力大幅提升。4.2 大对象怎么传才不肉疼大对象的传参选型我一般按下面的经验值来排序情况推荐写法理由内置类型、枚举、小POD值传递T v零额外开销简单直接中等或大型对象只读const T避免拷贝const保证只读需要修改外部对象T直接操作实参无拷贝可能为空的只读对象const T*可用nullptr表达无对象需要修改且可能为空T*空指针表达不修改字符串字面量、字符数组std::string_view零拷贝还能兼容char*连续数组加长度std::spanTC20替代指针加长度回调/函数对象std::function或模板F灵活或高性能二选一很多人看到const T就认为一定比值传递好但有一个概念叫引用糊味reference smell如果函数体根本不需要修改参数却被设计成非常量引用调用方会误以为它要改东西。比如void printString(std::string s);函数只打印但形参是非const引用调用方看到这个签名会吓一跳生怕s被改了。所以只读场景务必加上const。还有一个性能上的经典误区为了省一次拷贝把大对象传T却在函数里不知不觉改了它导致调用方数据被破坏。这种事情在多人协作里屡见不鲜。我的原则是能const就const不想改就不要用可变引用。这是靠签名传达契约比注释可靠得多因为编译器会强制执行。4.3 一些值得固化成团队规范的习惯在代码评审里我常提几条关于形参实参的规则它们不一定写进教科书但很实用第一不要在同一个函数里混用太多指针和引用尤其是指针的const位置。const int* p和int* const p完全是两回事前者是指向常量的指针后者是常量指针。形参里最常见的是const int*表示指向的数据不能改而int* const这种带const的指针本身在形参列表里几乎没意义因为指针形参本来就是拷贝。所以团队规范里可以约定形参尽量用引用除非必须表达空才用指针。第二值传递的形参函数体里如果需要修改副本直接修改形参即可不用再复制一份。比如void normalize(std::string s) // 值传递 { // 直接在s上trim、lowercase因为s只是副本改坏了不影响外面 }这种情况下形参就是你的局部变量别一进来又std::string tmp s;白白多一次拷贝。第三构造函数接参数时要格外小心。如果一个类保存了指针、引用或非拷贝类型成员形参类型的错误选型可能让类变得无法赋值、无法移动或者产生悬垂引用。我的经验是成员的初始化列表里写std::move(x)的形参多半就应该是按值传的写x的直接拷贝的形参就应该传const T。5. 现代C对形参的改造移动语义、万能引用与新式传参5.1 按值传参加move的一次性过户套路C11引入移动语义后形参的类型选择又多了一种思维。考虑构造函数接收一个std::string并保存到成员class Widget { public: explicit Widget(std::string s) : name_(std::move(s)) { } private: std::string name_; };这个写法看起来很奇怪既然是按值传形参如果调用方传入左值不是要拷贝一次到s然后又把s移动给name_吗一次拷贝加一次移动比直接const std::string接住再拷贝给name_不是多了一次移动吗确实多了一次移动但移动一个string通常是常数时间的指针交换几乎可以忽略。换来的是代码极简一个形参同时兼容左值和右值右值构造时只有一次移动左值时一次拷贝加一次移动。对比传统写法Widget(const std::string s) : name_(s) {} Widget(std::string s) noexcept : name_(std::move(s)) {}要写两个构造函数重载代码膨胀一倍。所以现代C社区有句名言要不传const T要不传T并按值move不要传T。T只应该在移动构造函数、移动赋值运算符这些特定地方使用或者配合模板做完美转发。5.2 万能引用和完美转发形参还能更聪明模板里出现的T是一个特殊形参叫万能引用也叫转发引用。它和普通右值引用不同T能同时绑定左值和右值。配合标准库std::forwardT可以实现完美转发——把实参的左右值属性原封不动地转给下游函数。templatetypename T void wrapper(T arg) { process(std::forwardT(arg)); }调用wrapper(lvalue)时T推导为Tstd::forwardT(arg)把arg转回左值引用调用wrapper(42)时T推导为T不带引用std::forwardT(arg)把arg转成右值。这样process就能接受到原始实参的种类避免多一次拷贝。写万能引用时有个常见的反模式templatetypename T void bad(T arg) { process(arg); // 忘了forward }这里的arg因为有了名字它本身是左值直接传给process会把右值实参变成左值导致拷贝而不是移动。这是完美转发功能失效的经典事故。正确写法必须是process(std::forwardT(arg));我见过不下三次因为漏掉std::forward导致性能大跳水或者无法编译的例子。现代C还有一个和万能引用形参容易搞混的角色——auto。在C14泛型lambda里auto lambda [](auto x) { print(std::forwarddecltype(x)(x)); };这里的auto形参也是万能引用。C20起auto也可以用在普通函数形参里表示模板形参这进一步模糊了形参类型和模板类型推导的边界。5.3 C20的span和string_view告别指针加长度最后聊聊近几年最值得在形参里用的两个标准库类型。std::string_view替代只读字符串形参。它保存一个字符指针和长度不拥有字符串数据。传参时不管你传std::string、const char*还是字符串字面量都不会拷贝字符串内容只复制指针和长度两个数字。但要特别注意生命周期string_view不会延长它所指向字符串的生命周期被指向的字符数组必须在函数调用结束后仍然有效。比如std::string_view getView() { std::string local hello; return local; // 危险local销毁后string_view悬垂 }这种错误编译器不会警告运行时可能随机输出乱码。所以规则是string_view适合当函数参数不适合当函数返回值除非返回的是静态存储或外部传入的字符串。std::spanT解决数组退化问题。它相当于对连续内存区域的视图保存一个指针和长度。形参写成std::spanconst int就可以同时接受数组、std::vectorint、std::arrayint, N函数内部可以直接range-for遍历不用管指针加长度的散装参数组合。#include span #include vector int sum(std::spanconst int values) { int total 0; for (int v : values) total v; return total; } int main() { int arr[] {1, 2, 3}; std::vectorint vec {4, 5, 6}; sum(arr); // 数组退化后仍能构造span sum(vec); // vector直接传给span }std::span和std::string_view一样不拥有数据所以适用同样的生命周期规则被引用的容器或数组必须在调用期间活着。这是现代C传递可观测的一段内存的最优雅写法比C风格的指针加长度安全得多也比你写一大堆模板推导数组大小更简洁。我在实际使用中养成了一个习惯写新函数时凡是参数列表里出现const char*加size_t或者const int*加size_t一律换成std::span凡是只读字符串参数一律考虑std::string_view。这不是炫技是真的能省掉一堆边界检查和长度推断的麻烦也让形参这个占位符变得更直观一眼就能看出它期望拿到什么样的实参。形参和实参的细节看起来多但核心就一句话搞清楚形参是副本还是同一个东西的别名再决定用什么方式传。我在给新同事做Code Review时最常说的就是这句。如果你在调试过程中遇到函数明明改了外面却没变先别急着怀疑编译器和运行时打印一下参数地址百分之八十的问题当场水落石出。最后分享一个实用小技巧写一个函数之前先把形参类型写在注释里然后问自己调用方如果传错编译器会拦得住吗——如果拦不住说明形参类型不够严格那就改成引用或者加const让类型系统提前把错误挡在门外。