形参和实参
这两个词经常混用,但值得区分清楚:
- 形参(parameter)是函数定义中的变量:
int square(int n)中的int n。 - 实参(argument)是调用时提供的值:
square(4)中的4。
调用函数时,会把形参创建为全新的局部变量,并把实参复制进去。这个复制就是本页的全部主题。
C 语言永远是值传递
这是一条规则,C 语言没有例外:函数接收的是实参的副本。给形参赋值只会改变副本。
value 仍然是 42。函数拿到的是一个叫 x 的副本,把副本设成了零,函数返回时副本就消失了。把形参改名为 value 也不会有任何变化:名字并不会把它们联系起来,它们是两个不同的变量。
每种类型都是如此:int、double、char、指针,甚至整个结构体(逐个字段复制)。如果你学过 Python、Java 或 JavaScript,要注意这些语言按值传递的是对象的引用,所以在函数内部修改对象,外部是能看到的。C 语言没有这种机制,它复制的是对象本身。
值传递是一种特性,而不是限制。函数不会意外破坏调用者的数据,这让代码更容易推理。
经典的失败案例:swap
教科书上的演示是一个试图交换两个值的函数:
逻辑是正确的,它确实交换了 a 和 b。但 a 和 b 是副本,所以函数一返回,所做的工作就全白费了。
用指针模拟引用传递
要让函数修改调用者的变量,就把变量的地址而不是值传给它。这个地址本身也是按值传递的(指针被复制了),但副本仍然指向原来的对象,所以通过它写入就能修改调用者的内存。
整个思路由三处语法支撑:
- 形参列表中的
int *a把a声明为“指向int的指针”。 - 调用处的
&x取得x的地址。 - 函数内部的
*a表示“a所指向的那个int”,可以读取,也可以赋值。
这就是 C 程序员所说的“引用传递”,但值得说得精确一点:它仍然是值传递,被复制的是指针。给 a 本身赋值(a = NULL;)只会改变副本;给 *a 赋值才会改变调用者的变量。指针一页完整讲解了这个模型。
代价是,调用处不再能直接看出一个变量是否可能被修改,这正是为什么必须写 &。swap(x, y) 无法通过编译,你必须写 swap(&x, &y),这个 & 就是一个可见的标记,表明这次调用可能会修改 x。
返回多个结果
return 只能产生一个值,所以额外的输出要通过指针参数传出。常见的形式是用返回值表示状态码,真正的结果通过指针写入:
注意调用失败时,q 和 r 保持原来的值:函数在写入任何东西之前就返回了。这是调用者必须遵守的约定,也是为什么要先检查状态,再使用输出。
另一种做法是返回一个同时包含两个字段的结构体,这样就完全不需要指针;当这些值本来就属于一个整体时,这通常是更好的设计。
数组不一样
数组不遵循复制规则,这几乎会绊倒每一个人。当你传递数组时,它会退化为指向首元素的指针,所以函数拿到的是地址,而不是数据的副本。
调用者的数组确实被修改了,而且哪里都没有 &,因为在这里 data 本身就是一个地址。由此有三个结论:
在形参列表中,int a[] 和 int *a 的含义完全相同。 就连 int a[100] 也一样,大小会被忽略。写成 int a[] 能表明意图,但不要把它理解为“数组被复制了”。
函数内部的 sizeof 是错的。 这就是那个 bug:
在 main 中,sizeof(data) 是 20 字节,算出的长度是 5。在函数内部,sizeof(a) 是指针的大小,在 64 位系统上是 8,所以“长度”是 2。数组的大小在那里根本无从得知。一定要把长度作为单独的参数传入。 现代编译器在使用 -Wall 时会对此发出警告。
不打算写入时就用 const。 const int a[] 表明函数只读取数据,编译器会强制保证这一点,读代码的人也可以放心:
int sum(const int a[], int n); /* promises not to modify the array */
字符串也是数组,所以同样适用:void greet(const char *name) 是只读取字符串而不修改它的函数的标准签名。
开销怎么样?
按值传递一个大结构体,每次调用都会复制它的每一个字节。对于只有几个字段的结构体,这没问题,而且常常比指针带来的间接访问更快。对于大结构体,就传指针,如果函数只读取,再加上 const:
struct Config { char name[64]; int flags[32]; double weights[128]; };
void applyConfig(const struct Config *cfg); /* no 512-byte copy per call */
const struct Config *cfg 表示“一个指向 Config 的指针,我不会修改它”,这样既有指针的效率,又有副本的安全性。
常见错误
- 以为普通形参能改变调用者的变量。 做不到,要传指针。
- 调用处忘了写
&。 当swap需要指针时写swap(x, y)是类型错误,编译器能发现,但前提是作用域内有函数原型。 - 函数内部忘了写
*。 对两个指针形参写a = b;交换的是局部副本,而不是值。你想要的是*a = *b;。 - 对数组形参使用
sizeof。 永远是错的。传入长度。 - 返回指向局部变量的指针。 函数返回时局部变量就消亡了,调用者拿到的是悬空指针,这是未定义行为。改为按值返回、写入调用者提供的缓冲区,或者用
malloc分配。 - 不检查指针参数是否为
NULL。 一个对传入的任何东西都直接解引用的函数,遇到空指针参数就会崩溃。
常见问题
C 语言有引用传递吗?
没有。C 语言只有一种方式:值传递。函数接收到的永远是每个实参的副本,所以给形参赋值永远不会影响调用者的变量。看起来像引用传递的做法,其实是按值传递一个指针:指针被复制了,但它仍然指向调用者的对象,所以函数可以通过指针修改它。
C 语言中怎么把数组传给函数?
把形参写成 int a[] 或 int *a(两者含义相同),并把长度作为单独的参数传入:int sum(const int a[], int n)。数组不会被复制;函数收到的是指向首元素的指针,所以对 a[i] 的修改,调用者都能看到。
为什么在 C 语言中对数组形参用 sizeof 不行?
因为这个形参实际上是指针。函数内部的 sizeof(a) 得到的是指针的大小(通常是 8 字节),而不是数组的大小,所以 sizeof(a)/sizeof(a[0]) 得出的是 2 之类的数,而不是元素个数。一定要显式传入长度。
C 语言函数怎么返回多个值?
为额外的结果传入指针,并通过指针写入:int divide(int a, int b, int *quotient, int *remainder) 返回一个表示成功与否的标志,并填好两个输出。另一种做法是返回一个把这些值打包在一起的 结构体。