1. 为什么“形参、实参”这个基础概念最容易被疏忽
1.1 从一次“莫名其妙的数组被改动”说起
先讲一个让我印象挺深的排查经历。当时在做一个支付相关的回调模块,C++写的,回调函数里需要把上游返回的一串数据解析后塞进一个全局的结构体数组里。代码写完后一跑,发现第一次回调数据正常,第二次回调一来,前面那一批数据里的一部分字段就变成了乱码。我当时的第一反应是“谁动了我的全局数组”,于是满世界找野指针、找越界,折腾了大半天,最后在同事的提醒下才发现问题出在一个毫不起眼的函数签名上——我当时把一个指针类型的形参直接赋值给了另一个局部指针,然后通过这个局部指针去改数据,看似改的是原数组,实际上因为形参被重新赋值了,地址早就偏了。
这件事让我意识到,形参和实参这个基础概念,平时觉得不值一提,可一旦碰到指针、数组、引用混在一起的时候,几乎所有人都会翻车。尤其对于刚接触C/C++的同学,函数调用时你传进去的那个变量,和你函数内部拿到的那个“东西”,到底是不是同一个,这个问题不管看多少遍书,都不如在调试器里亲眼看一眼栈帧来得清楚。
1.2 形参叫“形式”,实参叫“实际”,名字本身就是提示
很多人学这两个词的时候就是背定义:形参是函数定义时写的参数,实参是调用时传过去的参数。但如果你细品中文翻译,“形式参数”和“实际参数”这六个字其实已经把最关键的信息说透了:形参只是一个“形式上的占位符”,它代表的是“将来这里会有一个值”,但它本身和外面那个变量之间没有任何绑定关系;实参才是“实际参与这次调用的值”。
我习惯把形参比作餐厅门口的叫号牌。你在门口登记的是“张先生”,叫号牌上写“张先生,3位”,但等真正落座吃饭的是张先生这个人,而不是叫号牌上那几个字。函数调用也一样:实参就是“张先生本人”,形参就是“叫号牌”,调用开始时系统把“张先生”的名字抄到叫号牌上,之后就全是叫号牌的事了,你本人怎么变化,叫号牌上是不会同步更新的——除非你把自己的地址写在叫号牌上,服务生才能顺着地址找到你本人。
这个类比可以解释后面所有的坑。值传递、指针传递、引用传递,本质上都是“抄到叫号牌上”的方式不同:值传递抄的是你本人的名字,指针传递抄的是你家的门牌号,引用传递干脆把叫号牌撕下来贴在你脑门上。三种方式对函数内部“能不能找到你本人”的影响完全不同,这就是C/C++参数机制的全部秘密。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 值传递底层到底发生了什么:副本、栈帧与地址
2.1 函数调用时栈上发生了什么
很多人知道“值传递是拷贝”,但不知道拷贝到底发生在哪、发生在什么时候。我建议每个学C/C++的人都亲手做一次这件事:在函数外面定义一个变量 int a = 100;,调用函数 void func(int x),在函数里打印 &a 和 &x。你会发现两个地址完全不同,哪怕 x 的值确实等于100。这一步看懂的人,基本上就理解了值传递的七成。
从计算机底层的角度看,函数调用时操作系统会为每个调用的函数分配一段栈空间,这段空间叫栈帧。形参变量就住在被调函数的栈帧里。调用发生时,实参的值被复制到形参的内存单元中,从这一刻开始,函数体内的所有操作都只针对栈帧里那个形参副本,和外部实参再无关系。函数返回时,栈帧被收回,形参也就随之消亡。
这也是为什么下面这段代码跑完,a 的值依然是10:
c复制void add(int x) {
x = x + 5;
}
int main() {
int a = 10;
add(a);
// 这里 a 仍然是 10
return 0;
}
你可能会说“这么简单的我知道啊”。别急,接下来的问题才是重点:如果把 int 换成一个结构体,再把结构体传进去,里面的数组、字符串、嵌套结构体会不会排除在拷贝之外?答案是不会。C/C++的值传递是逐字节整体复制,结构体里哪怕有10万个字节,也会全部复制到函数栈里。这也是为什么大结构体传值会导致性能骤降——你以为传的是个“指针”,实际上函数入口处就默默地发生了一次完整的深拷贝。
2.2 传指针“改不了”原变量的三种典型场景
很多人学完指针传递后形成了条件反射:只要想改函数外的变量,就传指针,传了指针就能改。这句话在80%的情况下是成立的,但剩下的20%恰恰是全世界的坑。我整理过自己带的学生和同事丢过的三类典型场景。
场景一:对指针形参重新赋值
c复制void swap(int* p1, int* p2) {
int* tmp = p1;
p1 = p2;
p2 = tmp;
// 这里只交换了形参指针本身,外面实参指针没有变化
}
这个例子比较直白,但放到链表中就容易暴露:你写了一个 Node* p = findNode(head, 5);,想通过函数把 p 挪到下一个节点,于是写了 void moveNext(Node* current) { current = current->next; },调用完你发现外面的 p 纹丝不动。原因就是“指针的值被复制了,但副本指向的位置被改了,原指针的值没变”。
场景二:形参指针指向了别处,然后函数返回
这在处理动态内存时尤其常见。你写了一个初始化函数:
c复制void initPointer(int* p) {
p = (int*)malloc(sizeof(int));
*p = 42;
}
int main() {
int* ptr = NULL;
initPointer(ptr);
// 这里 ptr 仍然是 NULL
return 0;
}
函数内部确实给 p 分配了一块内存,但 p 是 ptr 的副本,函数返回后这块内存的地址就像丢失的钥匙——内存泄漏已经发生,可外面的 ptr 还是NULL。这就是典型的“指针传到函数里却改不了指针本身”的场景,解决方法是二级指针 int** p,或者C++里传引用 int*& p,或者更简单的做法:直接让函数返回分配好的地址。
场景三:通过指针读取了内容,但没写回
这种更隐蔽。比如你想让函数读一个结构体并修改它:
c复制typedef struct {
int x;
int y;
} Point;
void reset(Point* p) {
p = &(Point){0, 0}; // 错误:局部结构体,函数结束就没了
// 真正应该做的是 p->x = 0; p->y = 0;
}
上面这种“把指针重新指向一个新的局部对象”的做法,不仅改不了外面,还会因为局部变量生命周期结束留下悬垂指针。只要记住一件事就够了:指针形参携带的价值是“地址”,函数里通过地址去修改“地址指向的内容”才能影响到外部;如果函数里改的是“地址本身”,它只影响副本。
2.3 二级指针到底解决了什么问题
既然指针本身也是值传递的副本,那要让函数修改一个指针变量的值,就需要再传一层——把指针的地址传进去。这就是二级指针 int** 存在的理由:第一层 * 是“指针变量的值”,第二层 * 才是“通过地址找到原指针”。说得朴素一点,int** pp 里的 pp 持有的地址,是那个你想要修改的指针变量本尊的地址,你通过它写 *pp = newPtr,修改的就是外面那个指针。
一段相对正确的初始化函数应该这样写:
c复制void safeInit(int** p) {
*p = (int*)malloc(sizeof(int));
if (*p) {
**p = 42;
}
}
int main() {
int* ptr = NULL;
safeInit(&ptr);
// 现在 ptr 指向一块合法内存,*ptr == 42
free(ptr);
return 0;
}
很多人第一次看二级指针觉得绕,我的理解方法是把“指针”当成一个装地址的盒子。传一级指针给函数,是把盒子里的地址抄了一份交给函数;传二级指针,是把你手里这个盒子所在的位置告诉函数——函数打开盒子,才能修改盒子里的地址。链表里在头节点处插入节点时为什么要用 Node**,原因完全一样:因为你要修改的是头指针本身,而不只是它指向的节点。
3. const形参和常引用:写接口时最省心的防守
3.1 const保护的不只是数据,还有调用方对你的信任
函数签名里加不加 const,看似只是多写了五个字母,实际上决定了你的代码能不能大规模协作。我见过不少人写接口时一律不写 const,理由是“反正项目里只有我一个人用”。但当代码量上来之后,你会发现 const 形参传递的信号远比表面重要:它在告诉调用方“这个函数绝对不会修改你的数据”。
举一个很常见的例子。你封装了一个打印日志的函数,参数是一个结构体:
c复制void printConfig(Config cfg); // 传值:大结构体拷贝,浪费
void printConfig(Config* cfg); // 传指针:但不小心在函数内写了 cfg->port = 8080,外部竟然也跟着变了
void printConfig(const Config* cfg); // 好:传指针且声明不改数据
void printConfig(const Config& cfg); // C++更好:语法上按引用传,但承诺不改
第四种写法是最理想的状态,即所谓“常引用”。它在底层不会发生拷贝,同时又通过类型系统约束了函数体内部的行为,任何想通过 cfg 修改外部数据的操作都会在编译阶段被拦截。这种“把问题扼杀在编译期”的思路,是 C/C++ 工程实践里最值得养成的习惯。
还有个细节经常被忽略:如果你把形参声明成 const char* p,函数里想用 strlen、strcmp 之类的库函数,这些函数的形参恰好也都有 const 修饰,所以完全兼容。反过来,如果函数参数是 char*,而你只持有一个 const char* 指向的字符串字面量,编译器会给出警告甚至报错——C字符串字面量本质是 const char[],把它传给 char* 等于主动放弃了类型保护。
3.2 引用形参的隐藏开销与必须用常引用的场景
C++ 的引用形参在语义上是“给实参起别名”,但底层实现绝大多数字节码与指针传递完全一致(引用本质是指针的语法糖)。所以 const Type& 和 const Type* 在性能上几乎没差别,但前者写起来更自然,调用方也不用取地址。
我列一个自己的选型对照表,纯个人实践总结,可以当作参考:
| 场景 | 推荐写法 | 原因 |
|---|---|---|
| 基本类型(int, char, double等) | 值传递 | 拷贝成本极低,传引用反而多了间接寻址 |
| 大结构体/类对象 | const 引用 | 避免一次昂贵的拷贝构造 |
| 需要修改外部对象 | 非 const 引用(或指针) | 语义清晰,按引用修改 |
| 对象可能为空(可选参数) | 指针 | 引用无法表示“空”,指针置空可以 |
| 接受字符串参数 | std::string_view 或 const std::string& | 兼容 C 风格字符串和 std::string |
| 回调场景 | 函数指针或 std::function | 详见第4节 |
特别说一下“需要修改外部对象”这个场景。C++ 里如果函数要修改外部变量,我倾向优先使用非 const 引用而不是指针,原因是调用方代码更清晰——调用 func(obj) 时你一眼能看出来可能被改的是 obj,而 func(&obj) 则让很多人误以为它只是在传地址读取数据。这种语义上的误解在 Code Review 中非常常见,引用比指针在语义上更直白。
但引用有一个绕不开的局限:引用必须绑定到一个真实存在的对象,不能为NULL。所以如果参数是“可选”的、可能不传,那还是老实用指针,并显式检查 if (p != nullptr)。
4. 数组参数和函数指针参数:两个被教材一句话带过的坑
4.1 数组退化成指针之后,sizeof结果让人疑惑
C/C++ 函数形参里写 int arr[] 和写 int* arr,编译器眼里是同一个东西。这是C语言设计者当年为了效率做的妥协:数组当成整体传递需要复制所有元素,太浪费了,于是退化为指针,函数里拿到的是首元素地址。直到今天这个规则依然有效。
但这个规则带来一个非常经典的问题——sizeof 的误导:
c复制void func(int arr[]) {
// 这里的 sizeof(arr) 是指针大小:64位系统上是8
printf("%zu\n", sizeof(arr));
}
int main() {
int arr[100] = {0};
printf("%zu\n", sizeof(arr)); // 400:整个数组的大小
func(arr); // 函数内部打印 8
return 0;
}
同一个变量名,在 main 里 sizeof 算出400,进了函数就变成8。原因就是函数内的 arr 已经退化成指针了。很多人写“求数组长度”的宏 #define LEN(a) (sizeof(a)/sizeof(a[0])),在函数内部套用时会得到 8/4=2 这种离谱结果,就是这个道理。
因此在实际工程里,我建议不要依赖“数组长度自动传递”的幻觉。正确做法有两种:一种是额外传一个表示长度的形参,比如 void func(int arr[], size_t len);另一种是C++里用 std::array、std::vector 这类容器对象,它们内部拥有自己的大小,传引用也不会有退化问题。
4.2 二维数组传参的维度陷阱
二维数组传参是一个更隐蔽的坑。很多人以为只要传数组名就行:
c复制void printMatrix(int matrix[][], int rows, int cols); // 编译报错
编译器会直接报错,因为第二维(列数)对于指针偏移的计算是必需的。写成这样才行:
c复制void printMatrix(int matrix[][4], int rows); // 必须指明第二维
void printMatrix(int (*matrix)[4], int rows); // 等价写法
注意第二维必须是编译期常量。如果需要动态指定列数,就得换思路:要么用一维数组手动计算下标 matrix[row * cols + col],要么用 std::vector<std::vector<int>>,要么用指针的指针 int**。每种方式在内存布局上都不一样,选择时首先要搞清楚你的数据是不是“物理连续”的。
这里强烈建议:C++里优先用 std::vector 嵌套或者自定义二维数组类,别在二维裸数组传参上浪费生命。如果因为历史原因必须用裸数组,至少把行数和列数作为独立参数传进来,并在函数开头用断言约束范围,避免越界后排查半天。
4.3 函数指针参数:把函数当作参数传递
函数指针作为形参,是C语言里最有趣也最容易劝退新人的部分。但理解了你会发现,它不过是“把函数的地址当作实参传给另一个函数”,和传一个整数在机制上没有任何区别。函数名本身就是一个指向函数入口地址的指针常量,所以传函数参数时直接写函数名就行。
一个比较典型的场景是回调排序:
c复制int ascending(int a, int b) { return a - b; }
int descending(int a, int b) { return b - a; }
void bubbleSort(int arr[], int n, int (*compare)(int, int)) {
for (int i = 0; i < n - 1; ++i) {
for (int j = 0; j < n - 1 - i; ++j) {
if (compare(arr[j], arr[j + 1]) > 0) {
int tmp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = tmp;
}
}
}
}
排序时传入不同的比较函数,就实现了完全不同的排序规则。这就是C语言实现“策略模式”的原始方式。在C++里,我们会用 std::function 或者模板参数来做同样的事,但底层思想一脉相承:函数指针作为形参,把一段行为逻辑以参数的形式传入函数,实现了“数据与操作更灵活的分离”。
用函数指针时要特别注意函数签名必须严格匹配,包括返回类型和每个参数的类型。C语言里允许把函数指针转成 void* 吗?标准C是不保证的,但在POSIX环境下常见,能不用就别用,可移植性太差了。
5. C++对形参体系的扩充:引用、默认参数与可变参数
5.1 引用形参相比指针的核心优势
C++ 在 C 的参数体系上最重要的扩充就是引用。前面说过,引用在底层就是指针的语法糖,但它在语言层面补齐了好几个指针做不到的事。
第一,引用必须在定义时初始化且不能更换绑定对象。这个限制看起来很死板,但正是它保证了使用引用的时候“一定有一个真实对象”,不会出现空引用。第二,引用天然解决了“对指针本身修改”的问题:
cpp复制void swapPtr(int*& p1, int*& p2) {
int* tmp = p1;
p1 = p2;
p2 = tmp;
}
当我们要交换两个指针变量本身的值时,C语言需要写 void swapPtr(int** p1, int** p2),调用时还得 swapPtr(&a, &b);C++直接写 int*& 引用绑定实参指针,调用时 swapPtr(a, b),可读性好得多。
5.2 默认参数:声明与定义的处理方式
C++ 中函数声明可以给形参指定默认值,调用时可以省略对应的实参。规则上有一条大家很容易踩:默认值只能从右往左连续提供。你不能写 void func(int a = 1, int b) 然后只传一个实参让 b 默认,因为默认实参必须能让编译器唯一确定省略的是哪些参数。
另一个常见坑是默认参数在“声明”和“定义”中不能重复设定:
cpp复制// 头文件 goo.h
void goo(int x, int y = 10);
// 实现文件 goo.cpp
void goo(int x, int y = 10) { // 重复默认参数,编译错误
...
}
正确做法是只在声明处写默认值,定义处不再写。如果你习惯把函数定义写在头文件里(比如inline函数),那就没有这个问题。还有一个容易忽略的点:默认参数值可以是一个表达式,但它在调用点被求值,不是函数定义处。而且如果实参是一个局部变量,默认参数对它的“捕获”并不是按引用,而是按值或按声明时的语义,写复杂表达式时要小心。
5.3 可变参数:从C风格...到initializer_list
C语言的 printf 是可变参数的始祖。它的机制是参数从右向左依次压栈,函数内用 va_list 逐个取出。但C风格可变参数有两个大坑:第一,它不知道参数个数,得靠格式化字符串里的 %d 之类自己数;第二,类型不安全,你在 printf("%s", 42) 时编译器可能只会给警告而不会拦截。
C++ 提供了更安全的备选方案:std::initializer_list<T>。它可以用来接收同一类型的可变数量参数:
cpp复制#include <initializer_list>
int sum(std::initializer_list<int> vals) {
int total = 0;
for (int v : vals) total += v;
return total;
}
int result = sum({1, 2, 3, 4, 5});
initializer_list 的好处是元素类型一致、有 size()、可以遍历,底层是栈上的连续数组。对于要求不同类型混合的可变参数,C++还有可变参数模板(variadic template),那个功能更强,但语法也更复杂,一般场景用不上。我的建议很简单:只要能不写 ...,尽量别写;如果一定要写,务必在文档里明确参数的含义和个数,并在函数内对参数数量做防御性校验。
5.4 实参求值顺序的变化
参数求值顺序的细节,可能是这里最“反直觉”的内容。在C和C++早期标准里,函数实参的求值顺序是未指定的,编译器可以自由选择从左到右还是从右到左。这就有可能导致下面这个经典问题:
cpp复制int i = 0;
func(++i, ++i);
第二个 ++i 到底给第一个 ++i 传什么值,标准并未约束,不同编译器可能给出不同结果,跨平台时bug极其隐蔽。好在 C++17 之后标准规定:函数实参的求值顺序是从左到右的(每个实参内部仍然是未顺序的)。如果你还在维护老代码,遇到这种依赖求值顺序的写法,建议直接拆开,逐字逐句地先求值再传参,不要挑战编译器的底线。
6. 我写代码时判断参数设计的几条经验
6.1 一张选型表+三条守则
综合我多年的项目经验,函数参数设计其实是有规律可循的。我总结了一个相对固定的判断流程:
- 如果参数是基本类型且只是读取:值传递,不用多想。
- 如果参数是自定义对象且只是读取:const 引用,避免无谓拷贝。
- 如果函数要修改外部对象:指针(可能为空时)或非const引用(一定非空时)。
- 如果参数是字符串:std::string_view(C++17)或
const std::string&。 - 如果参数是一个“功能”或者“策略”:函数指针或
std::function。
三条守则:
- 函数形参尽量不超过4个。超过4个时,把逻辑相关的参数封装成结构体或类。不是排版问题,而是调用方要记太多东西,出错概率指数上升。
- 形参命名要和实参语义一致。我见过有人形参写
int a,实参是userId,函数体里到处是一串毫无意义的a,这种代码半年后连作者自己都要靠猜。形参名其实就是接口文档的第一层。 - 不要用“可变参数”逃避类型设计。如果参数类型不确定,先想想是不是应该抽象出一个基类或者变体类型,而不是把所有东西塞给
...。
6.2 实际调试中观察实参和形参的三个技巧
调试器是理解形参实参关系最好的老师,但很多人打开调试器不知道该看什么。我分享三个实用技巧。
技巧一:地址对比法。 在函数调用前后分别打印实参的地址和函数内形参的地址。如果两个地址相同,说明是引用或地址传递;如果不同,说明是值传递。这样能在几秒钟内判断出你的参数机制是否符合预期。在VS Code里配合C/C++扩展,光标悬停在函数名上就能看到实参,Watch窗口里也能直接看地址。
技巧二:在函数入口先取实参地址,再在函数尾打印一次。 如果地址发生了变化,说明代码里对形参做了重新赋值,这种时候需要检查是否应该在局部操作,而不是影响外部。
技巧三:使用const临时验证。 如果怀疑某处代码意外修改了外部变量,临时把对应形参类型改为 const 限定,编译时编译器会精准告诉你哪个语句试图修改它。这是最快的定位方式,比断点肉眼扫代码高效得多。
6.3 最后的一点心得
把这些内容整理成文的时候,我重新翻了翻自己几年前写的代码,果然发现了不少“形参当局部变量用”的坏习惯。比如以前喜欢在函数内部复用形参名做循环计数,后来发现这样不仅影响可读性,还容易在维护时误以为改的是外部变量。
现在我的习惯是:函数形参只允许被“读取”和“按设计”使用,绝不在函数体内随意重新赋值。如果确实需要一个可变的局部值,就另起一个清晰命名的变量,把形参值拷进去再操作。这个习惯看似保守,但在排查复杂bug时却能省下大量时间。
形参、实参这块内容,说白了就是“值、地址、别名”三种关系在大脑里的建模。能把这三者的差异刻进本能反应里,C/C++函数相关的坑就少了一大半。剩下的就是多写、多调试、多犯错,然后把这些经验沉淀到日常编码习惯里。
