很多人第一次认真想"函数参数到底是怎么传的",都是因为同一件事:
自己写了个 swap 函数,跑起来一看——
咦?完全没用??
这篇文章就从这个经典大坑聊起,一口气把 C / C++ 里常见的三种传参方式讲清楚:
- 值传递(pass by value)
- 指针传递(pass by pointer)
- 引用传递(pass by reference)
聊完你会发现:参数类型不只是"语法问题",还是在告诉读者"我打算怎么用你的这个变量"。
一个"不干活"的交换函数
先看一段很多人都写过的代码:
void swap(int a, int b) {
int temp = a;
a = b;
b = temp;
}
逻辑看着很顺:
用临时变量存一下 a,把 b 塞进来,再把临时变量还给 b,教科书级别的交换操作。
写个 main 调用一下:
int main() {
int x = 10;
int y = 20;
swap(x, y);
std::cout << "x = " << x << ", y = " << y << '\n';
return 0;
}
很多人第一次跑这段的时候,整个人都不好了——输出是:
x = 10, y = 20
也就是说,swap 里面 a 和 b 的确换了个位置。
但 main 里的 x 和 y:一动不动。
值传递:给你一份拷贝,你随便折腾
问题关键在这儿:C / C++ 默认用的是值传递。
你写 swap(x, y) 的时候,传进函数里的 a 和 b,是 x 和 y 的各自一份拷贝。
你在函数里怎么折腾这两份拷贝,外面的 x 和 y 都不会被波及。
可以把过程拆开看:
void swap(int a, int b) {
// 进入函数时:
// a = 10(x 的副本)
// b = 20(y 的副本)
int temp = a; // temp = 10
a = b; // a = 20(只改了副本)
b = temp; // b = 10(也只改了副本)
// 函数结束,a、b 这两个副本被销毁
}
函数一结束,a、b 这两个小号直接下线。
外面的 x、y 还在自己的世界里,完全不知道刚才发生了什么。
所以:
int x = 10;
int y = 20;
swap(x, y); // 只是把 x、y 的值复制了一份给 a、b
x 和 y 始终保持原样。
指针传递:把地址交出来,直接改原件
如果你真心想在函数里改掉调用者的变量,在 C 里一般会写成"传指针":
void swap_ptr(int* a, int* b) {
int temp = *a;
*a = *b;
*b = temp;
}
调用的时候,要把变量的地址传进去:
int main() {
int x = 10;
int y = 20;
swap_ptr(&x, &y);
std::cout << "x = " << x << ", y = " << y << '\n';
return 0;
}
这回输出就变成:
x = 20, y = 10
原因很好理解:
a和b本身是指针,也就是"地址";*a和*b是对这个地址解引用,直接摸到x、y本尊。
所以你在函数里改的是"原件",不是拷贝。
缺点也很明显:
- 调用时得写
&x、&y,代码看着有点扎眼; - 函数里面又是一堆
*a、*b,一不留神就容易写错。
语法上稍微有点"硌手"。
引用传递:C++ 送你的更顺手用法
到了 C++,多了一件非常顺手的工具:引用(reference)。
同样逻辑,写成引用版本可以是这样:
void swap_ref(int& a, int& b) {
int temp = a;
a = b;
b = temp;
}
注意参数类型后面的 &。
这说明 a 和 b 是引用,不是 x、y 的副本,而是它俩的另一个名字。
你可以直接把它理解成:
a就是x的小号,b就是y的小号。
你怼小号,等于在怼本体。
调用的时候,写法看着就非常自然了:
int main() {
int x = 10;
int y = 20;
swap_ref(x, y); // 看起来跟值传递一模一样
std::cout << "x = " << x << ", y = " << y << '\n';
return 0;
}
输出是:
x = 20, y = 10
从调用者视角:
swap_ref(x, y) 和普通的 swap(x, y) 长得一模一样,很好骗眼睛。
但在函数内部,你拿到的就是 x、y 本尊的"别名",你改 a,外面的 x 就真跟着改了。
三种写法,三种"态度"
站在"函数签名"的角度,这三种写法的态度完全不一样:
void foo(int value); // 值传递:给我一份拷贝,我保证不动你的原件
void foo(int* ptr); // 指针传递:我可能会通过指针改掉那块数据
void foo(int& ref); // 引用传递:我就是要直接动你这个变量
可以简单对比一下:
| 传递方式 | 能不能改原变量 | 调用长啥样 | 函数里面怎么写 |
|---|---|---|---|
| 值传递 | 不能 | foo(x) |
直接用 value |
| 指针传递 | 能 | foo(&x) |
用 *ptr 解引用 |
| 引用传递 | 能 | foo(x) |
直接用 ref |
直白一点:
- 只想看一眼,不想改:用值传递;
- 想改,而且不介意多写几个符号:可以用指针;
- 想改,又想语法看起来清爽一点:C++ 里就很适合用引用。
const 引用:只读 + 不拷贝,谁看了不爱
在 C++ 里,还有一个出镜率超高的组合:const 引用。
比如:
void print_value(const int& value) {
std::cout << value << '\n';
// value = 100; // 解开这行会编译失败
}
const int& 可以拆开理解一下:
&:说明是引用,不做拷贝,直接用原件;const:承诺只读不写,不能改这个变量。
对 int 这种小类型来说,其实拷贝成本非常低,你用不用 const int& 更多是风格问题。
但一到 std::string、std::vector 这种大块头,对象一大起来:
- 拷贝一次就挺费劲;
- 不拷贝,直接引用,又可以保证不改人家数据。
这种场景下,const T& 就非常香:
void print_message(const std::string& msg) {
std::cout << msg << '\n';
// msg = "hello"; // 编译失败:不能改 const 引用
}
调用:
std::string greeting = "Hello, world!";
print_message(greeting); // 没有发生拷贝
调用端写着轻松,性能也比较友好,"我只看不改"的态度也很明确。
实战里怎么选?
落到写代码这件事上,可以有一个很接地气的小准则。
1. 只是想读一读参数,不打算改
- 小类型:比如
int、double、指针这类,体型很苗条。
直接用值传递就行:void foo(int x); - 大对象:比如
std::string、std::vector、自定义大类。
更适合用const T&:void foo(const std::string& s);
不拷贝,更高效,还顺便把"只读"的态度写进类型里。
2. 需要修改调用者的变量
- 用引用:
T&,比如void update(int& x);
这是比较 C++ 风格的写法,语法也清爽。 - 有些老代码或者偏 C 风格的代码,会更倾向用指针
T*。
或者你就想明显表达:"这个东西可能是空的"。
3. 想表达"可能没有这个东西"
- 这种情况就很适合用指针
T*。
因为指针可以是nullptr,很好表达"要么有对象,要么啥也没有"。 - 引用不行,引用必须绑定在一个真实存在的对象上,没法绑定到
nullptr上。
签名就是最好的文档
等你看多了各种函数签名,会慢慢形成一种直觉:
参数类型,其实就是一份"使用说明书"。
- 看到
void foo(int x):
你大概能放心,这个函数不会去动你传进去的变量。 - 看到
void foo(int& x):
心里就有数了,这货多半要改你的值,调用的时候要注意。 - 看到
void foo(const std::string& s):
你知道它只想读字符串,不会改,也不会白白拷贝一遍浪费时间。
当你自己在设计接口的时候,也可以反过来利用这点——
用参数类型,把自己的意图写清楚,让别人一眼就懂你想干嘛。
这种"签名即文档"的思路,会在你写库、写接口、写团队代码的时候,帮上很大忙。