第 10 章 · 指针与函数

「C 是值传递还是引用传递?」这道题的标准答案只有一个词,但需要一章来理解它:值传递,一切皆值传递,没有例外。函数收到的永远是实参的复印件,地址也不过是「复印件了一个门牌号」。理解了这一点,swap 为什么要传地址、scanf 为什么带 &、出参模式怎么写、以及「返回局部变量地址」为什么是死刑,全部是同一条原理的推论。原文小节编号错成 9.x,一并修正。

10.1 值传递:复印件的世界

void bad_swap(int a, int b) {      // 收到 x、y 的复印件
    int t = a; a = b; b = t;       // 折腾复印件,原件无恙
}

void swap(int *a, int *b) {        // 收到的是「门牌号」的复印件
    int t = *a; *a = *b; *b = t;   // 顺着门牌号改原件
}

int x = 1, y = 2;
swap(&x, &y);                      // 传门牌号

bad_swap 里折腾的是复印件,函数返回即销毁;swap 收到的是地址的复印件,虽然地址本身也是复印件,顺着这个地址能摸到原件。这就是 C 的全部传递模型,Java 里「基本类型按值、对象按引用」的讨论,翻译过来就是「有的复印件是值本身,有的复印件是地址」。scanf("%d", &x) 那个让你初学时困惑的 & ,现在看顺理成章:它要往里写,当然要门牌号。

10.2 传指针的两笔收益

收益一:出参(改调用者的东西)。第 7 章讲过心法「要改谁传谁的地址」,这里补它的多返回值应用。C 函数只能有一个返回值,多个结果靠出参:

int divmod(int a, int b, int *rem) {   // 商走返回值,余数走出参
    *rem = a % b;
    return a / b;
}
int rem;
int q = divmod(17, 5, &rem);           // q = 3, rem = 2

glibc 的例子俯拾皆是:strtod 的第二个参数带出「解析到哪了」,pthread_create 的第一个参数带出新线程句柄。出参的命名习惯(rem、endptr、out_前缀)和「入参在前出参在后」的排序,是可读性的隐形基建。

收益二:省拷贝。结构体按值传递是整块复印(几十上百字节照搬),传指针只复印 8 字节门牌:

typedef struct { char name[32]; int scores[100]; } Student;

void grade(Student *s);        // 8 字节,顺路可写
void view(const Student *s);   // 8 字节,只读承诺(第 8 章)

只读场景务必加 const:view 版既能接变量也能接只读数据,还向读者声明「我不动你的数据」。这两个收益经常同时兑现:传指针又快又能改,何乐不为,唯一要警惕的是第 12 章(别名)里「两个指针指向同一结构」引发的意外。

10.3 返回指针:三白三黑

返回局部变量的地址是死刑(第 2 章从汇编层看过全程):

char *bad(void) {
    char s[64] = "hello";
    return s;              // s 的栈帧随返回作废,这个地址是凶宅
}

白名单三种,可以背:

// 白一:静态存储期的对象(static、全局、字符串字面量)
char *ok1(void) { return "hello"; }           // 字面量在只读段,永生
const char *ok1b(void) { return "hi"; }       // 更严谨的签名(反正不许改)

// 白二:堆上分配,所有权随返回值移交给调用者
char *ok2(void) {
    char *s = malloc(64);
    if (!s) return NULL;
    strcpy(s, "hello");
    return s;                                  // 调用者负责 free,文档里写明!
}

// 白三:调用者传进来的缓冲区,原样返回(方便链式调用)
char *ok3(char *buf, size_t n) { snprintf(buf, n, "hello"); return buf; }

白二是标准库最爱的模式(strdup、strtok 的返回),它的代价是「所有权移交」必须靠文档和自觉:返回堆指针的函数,注释里不写清楚谁释放,三个月后一定有人泄漏。黑名单三种:局部数组地址、局部结构体地址、以及指向局部 static 之外、还指望「下次调用还在」的任何栈内对象。编译器的 -Wreturn-local-addr 会拦第一种,拦不住的是「返回参数指针的减法结果」这类自作聪明。

常见错误

  • 把「传指针」当成「传引用」用出安全感。指针本身还是值传递:函数里 p = malloc(...) 改的是指针复印件(第 7 章的 bad_alloc),要改指针本身还得二级指针。规则没有第二层例外。
  • 返回值忘了说明所有权。ok2 型函数必须在注释/文档里写「调用者 free」;更安全的工程做法是命名带出来(alloc/dup 后缀)或返回结构体打包数据和长度。
  • 出参没初始化就提前 return。函数在错误路径上没写 *out,调用者拿去用的就是自己的旧值;每个出参在任何路径上都要有明确结果,或明确「失败时不动出参」的约定。

练习

  1. 动手:验证 10.1:写 bad_swap 和 swap,打印调用前后的地址(&x、&y 与函数内 a、b),说明「值传递 + 地址复印件」的全过程。
  2. 实现:写 int parse_int(const char *s, int *out)(字符串转整数,失败返回 0 且不动 out,成功返回 1 并写 out),处理前导空白、正负号、非数字截断,不许用 atoi;写 6 组测试覆盖边界。
  3. 手算:以下四个函数哪些返回的指针可以安全使用,各自指向什么、活多久:①返回 static 数组地址;②返回参数指针;③返回 malloc 结果;④返回函数内数组的中间元素地址。
  4. 思考:C 的出参 vs Python 的「return a, b」(元组打包) vs Go 的多返回值,三种「多结果」设计各自的错误处理姿势(检查返回码 vs 解包 vs if err),你觉得哪种最能防止「忘了检查错误」?为什么 C 标准库坚持返回码 + 出参?