Skip to content

用指针改调用者的变量:C 没有 reference,靠传指针

引言:第 8 章留下的那个「改不了」

第 8 章讲函数时我们埋了一个遗憾:bump(n) 把参数加 1、可调用完 n 还是原值——因为 C 函数的参数是值传递的副本,函数改的是自己的那份副本,碰不到调用者的原变量。当时我们说「要想真改,得靠指针」,这一章就来兑现这句话。C 没有 C++ 那种 int& 引用(reference),它模拟「让函数修改调用者的变量」只有一个办法:把变量的地址(指针)传进去,函数顺着地址去改。这一招是 C 工程里最常用的指针用法——scanf 为什么要你写 &n(第 12 章)、swap 怎么交换两个数、strcpy 怎么改你的缓冲区(第 11 章),底层全靠它。

值传递为什么改不了

先用一个失败的例子把痛点摆清楚:

c
#include <stdio.h>

void inc(int n) { /* 值传递:n 是 x 的副本 */
    n = n + 1;     /* 改的是副本,原变量纹丝不动 */
}

int main(void) {
    int x = 10;
    inc(x);
    printf("x = %d  (inc 后没变:改的是副本)\n", x);
    return 0;
}
text
$ gcc -std=c11 -Wall value_fail.c -o vf && ./vf
x = 10  (inc 后没变:改的是副本)

inc(x) 调用时,编译器把 x 的值(10)复制一份,赋给参数 n——n 是一个全新、独立的副本。函数里 n = n + 1 改的是这个副本(变成 11),函数一返回、副本销毁,x 本身从头到尾没被动过,所以打印还是 10。这就是值传递的本质:函数拿到的是值、不是变量本身。第 8 章那个 bump 也是同样道理。

传指针:让函数顺着地址改原变量

要让函数真能改 x,就别传 x 的值、改传 x地址——也就是 &x。函数用一个指针参数接住它,然后通过 *p 改那个地址上的对象:

c
#include <stdio.h>

void inc_ptr(int* p) { /* 传 x 的地址:p 指向 x */
    *p = *p + 1;        /* 通过 *p 改的是 x 本身 */
}

int main(void) {
    int x = 10;
    inc_ptr(&x);
    printf("x = %d  (inc_ptr 后变 11:改的是 x 本身)\n", x);
    return 0;
}
text
$ gcc -std=c11 -Wall ptr_inc.c -o pi && ./pi
x = 11  (inc_ptr 后变 11:改的是 x 本身)

这次 x 真的变成 11 了。区别就在参数是 int* p 而不是 int n:调用 inc_ptr(&x) 传的是 x 的地址,p 指向 x;函数里 *px 的别名(第 1 章见过),*p = *p + 1 等于 x = x + 1,改的就是 x 本体。把这两个程序并排看:值传递改副本(x 还是 10)、传指针改本体(x11),这就是 C 模拟 call-by-reference 的全部秘密。

经典:swap 两个变量

「用指针改调用者变量」最经典的例子是交换两个变量的值。新手第一反应往往写成 void swap(int a, int b)——可上一节刚说了,那只交换副本、调用者的 x y 不变。正确的写法必须传指针:

c
#include <stdio.h>

void swap(int* a, int* b) {
    int tmp = *a; /* 经典交换:靠三个赋值 + 一个临时变量 */
    *a = *b;
    *b = tmp;
}

int main(void) {
    int x = 1, y = 2;
    printf("交换前: x=%d y=%d\n", x, y);
    swap(&x, &y);
    printf("交换后: x=%d y=%d\n", x, y);
    return 0;
}
text
$ gcc -std=c11 -Wall swap.c -o sw && ./sw
交换前: x=1 y=2
交换后: x=2 y=1

swap(&x, &y) 传两个地址进去,a 指向 xb 指向 ytmp = *a(存下 x 的值)、*a = *b(把 y 的值写进 x)、*b = tmp(把暂存的旧 x 写进 y),三个赋值完成交换,结果 x=2 y=1。如果你把签名改成 swap(int a, int b)(不传指针),函数体一模一样也白搭——交换的是 a b 两个副本,x y 不变。这个对照是理解「为什么 C 到处都是指针参数」的最好入口。

传数组给函数:退化让函数能改原数组

把「传指针改变量」推进一步——传数组。第 10 章说过,数组名传给函数时会退化成指向首元素的指针,所以函数拿到的不是数组副本、而是首元素地址。这带来一个直接后果:函数能改调用者的原数组(因为它操作的是同一块内存),代价是函数拿不到数组长度、得另传一个长度参数:

c
#include <stdio.h>

/* 传数组(退化为指针)+ 长度:函数能把每个元素乘 2,改的是调用者的原数组 */
void double_all(int* a, int n) {
    for (int i = 0; i < n; i++) {
        a[i] *= 2;
    }
}

int main(void) {
    int a[5] = {1, 2, 3, 4, 5};
    double_all(a, 5);
    printf("翻倍后:");
    for (int i = 0; i < 5; i++) {
        printf(" %d", a[i]);
    }
    printf("\n");
    return 0;
}
text
$ gcc -std=c11 -Wall array_modify.c -o am && ./am
翻倍后: 2 4 6 8 10

double_all(a, 5) 把数组 a(退化成 int*)和长度 5 都传进去,函数里 a[i] *= 2 改的就是 main 里那个原数组的元素——因为是同一块内存。所以回来打印 a,已经是 2 4 6 8 10。这跟基本类型不一样:基本类型传值是副本(改不了),数组传的是地址(能改)。注意参数 int* a 配长度 int n 这对组合(第 10 章的纪律),函数内部绝不能用 sizeof(a)/sizeof(a[0]) 求长度——退化后那是指针大小(第 10 章真跑过 40→8)。

用指针模拟「多返回值」

C 的函数只能 return 一个值。可现实里经常要算出两个结果(除法的商和余数、把一个数拆成高位低位、坐标变换后的 x 和 y),怎么办?套路是:一个用 return、另一个(或几个)用指针参数「带出来」scanf 就是这么干的——它「返回成功匹配的项数」用 return(第 12 章)、而把你输入的值通过 &n 这种指针参数写回给你。看一个 divmod(带余除法)例子:

c
#include <stdio.h>

/* C 不能返回多个值:用一个返回值 + 指针参数把另一个值「带出来」 */
int divmod(int a, int b, int* rem) {
    *rem = a % b; /* 余数通过指针写回调用者的 rem */
    return a / b;  /* 商作为返回值 */
}

int main(void) {
    int rem;
    int quo = divmod(17, 5, &rem);
    printf("17 / 5 = %d%d\n", quo, rem);
    return 0;
}
text
$ gcc -std=c11 -Wall multi_return.c -o mr && ./mr
17 / 5 = 3 余 2

divmod(17, 5, &rem) 把商 3return 返回、把余数 2 通过指针参数写进 main 里的 rem。调用者得事先声明一个 int rem;(先不初始化,函数会通过指针给它赋值),把它的地址 &rem 传进去。这是 C 工程里极常见的「输出参数」模式——函数签名里那些指针参数,要么是「我要改你这个变量」(像 swap)、要么是「我要把结果写回给你」(像这里的 rem)。读 C 接口时,看到 int*/struct Foo* 参数,要养成习惯判断它是「输入(函数读它)」还是「输出(函数写它)」——下一章的 const 就是用来明确标记「我只读不改」的。

小结

C 函数参数默认是值传递的副本(第 8 章 bump、本章 inc 真跑 x 还是 10),所以函数改不了调用者的变量;C 又没有 reference,模拟「call-by-reference」只能靠传指针——把变量地址 &x 传进去,函数用 int* p 接住、通过 *p 改的就是调用者的本体(真跑 inc_ptr(&x)x 从 10 变 11)。这套用法三个典型:swap(int*,int*) 交换两个变量(真跑 1 2 → 2 1,写成 swap(int,int) 只换副本是新手坑);传数组时退化让函数能改原数组(double_all(a,5) 真跑把 1 2 3 4 5 翻倍成 2 4 6 8 10,但要配长度参数,呼应第 10 章退化);C 只能 return 一个值,「多返回值」用一个返回值 + 指针参数带出(divmod(17,5,&rem) 真跑 3 余 2,这正是 scanf 的工作方式)。读 C 接口时,要区分指针参数是「输入」(函数读)还是「输出」(函数写)——下一章我们用 const int* 把「我只读不改」这个承诺写进签名,既保护调用者的数据不被误改、又让读代码的人一眼看明白意图。

参考资源

  • ISO/IEC 9899:2011 §6.5.2.2(函数调用:实参按值传递)、§6.5.3.2(* 解引用写对象)、§6.3.2.1p3(数组实参退化为指针)、§6.7.6.3(函数参数里的数组声明等价于指针)
  • K. N. King《C Programming: A Modern Approach》第 9 章(函数与值传递的局限)、第 11 章(指针参数、用指针修改调用者变量)、第 12 章(数组作为参数)
  • Robert C. Seacord《Effective C》第 6 章·Passing Pointers(指针作输入/输出参数的约定)
  • 第 8 章:函数(值传递、参数是副本)、第 10 章:数组(退化、传参带长度)、第 12 章:基础 IO(scanf& 把值写回)、阶段2·第1章:指针是什么
  • 阶段2·第4章:const 限定(const int* 输入参数)、第 6 章:动态内存(malloc 返回指针、函数内分配后传出)