☰
C++形参和实参的底层逻辑:从值传递到移动语义
2026/10/20 9:35:07 网站建设 项目流程

写了几年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 2,a和b根本没被交换。最迷惑的一点在于,如果你在swap里面打印x和y,会发现x确实变成了2,y变成了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::vector<int>,里面包含一百万个数,那就意味着拷贝一百万次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); }

在我机器上输出大致是:

&v=0x7ffd1234aaa0 &r=0x7ffd1234aabc p=0x7ffd1234aabc

&v和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++做法是传引用到数组:

template<size_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);

再现代一点的做法,C++20提供了std::span<T>,它天然携带元素数量和指针,传std::span<int>就能把"指针加长度"打包成一个参数,后面第五章还会讲到。

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::function<int(int)>作为形参,它能同时接受普通函数、函数对象和有捕获的lambda:

void apply(const std::function<int(int)>& func, int x) { std::cout << func(x) << std::endl; } apply([](int v) { return v * 2; }, 5);

std::function的代价是会有一次类型擦除的开销,但如果这个回调调用频率不高,这点开销无所谓。如果对性能极端敏感,又想保持灵活,可以用模板形参:

template<typename 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::vector<int>&混在一起,又不加注释,读起来像在破译密码。明确角色后,参数类型自然就定了,代码自我解释能力大幅提升。

4.2 大对象怎么传才不肉疼

大对象的传参选型,我一般按下面的经验值来排序:

情况推荐写法理由
内置类型、枚举、小POD值传递T v零额外开销,简单直接
中等或大型对象,只读const T&避免拷贝,const保证只读
需要修改外部对象T&直接操作实参,无拷贝
可能为空的只读对象const T*可用nullptr表达"无对象"
需要修改且可能为空T*空指针表达"不修改"
字符串字面量、字符数组std::string_view零拷贝,还能兼容char*
连续数组加长度std::span<T>C++20,替代指针加长度
回调/函数对象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的"一次性过户"套路

C++11引入移动语义后,形参的类型选择又多了一种思维。考虑构造函数接收一个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::forward<T>,可以实现完美转发——把实参的左右值属性原封不动地转给下游函数。

template<typename T> void wrapper(T&& arg) { process(std::forward<T>(arg)); }

调用wrapper(lvalue)时,T推导为T&,std::forward<T>(arg)把arg转回左值引用;调用wrapper(42)时,T推导为T(不带引用),std::forward<T>(arg)把arg转成右值。这样process就能接受到原始实参的种类,避免多一次拷贝。

写万能引用时,有个常见的反模式:

template<typename T> void bad(T&& arg) { process(arg); // 忘了forward }

这里的arg因为有了名字,它本身是左值,直接传给process,会把右值实参变成左值,导致拷贝而不是移动。这是"完美转发"功能失效的经典事故。正确写法必须是:

process(std::forward<T>(arg));

我见过不下三次因为漏掉std::forward导致性能大跳水,或者无法编译的例子。现代C++还有一个和万能引用形参容易搞混的角色——auto&&。在C++14泛型lambda里:

auto lambda = [](auto&& x) { print(std::forward<decltype(x)>(x)); };

这里的auto&&形参也是万能引用。C++20起,auto也可以用在普通函数形参里,表示模板形参,这进一步模糊了"形参类型"和"模板类型推导"的边界。

5.3 C++20的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::span<T>解决数组退化问题。它相当于对连续内存区域的视图,保存一个指针和长度。形参写成std::span<const int>,就可以同时接受数组、std::vector<int>、std::array<int, N>,函数内部可以直接range-for遍历,不用管"指针加长度"的散装参数组合。

#include <span> #include <vector> int sum(std::span<const int> values) { int total = 0; for (int v : values) total += v; return total; } int main() { int arr[] = {1, 2, 3}; std::vector<int> 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,让类型系统提前把错误挡在门外。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询