《The C Programming Language》(简称 K&R)的第 4 章是 C 语言进阶的转折点。如果说前三章是在教你如何写“句子”,那么第四章就是在教你如何写“文章”。

4.1 函数的基础

它主要想让你掌握 “函数怎么写、怎么被调用、参数怎么传、返回值怎么用、以及声明(原型)为什么重要”

1. 什么是函数:把一段逻辑“打包成可复用的模块”

如果你要写一个给全校学生发通知的程序,你不会每次发邮件的时候都重复写一遍“打印页眉、打印姓名、打印日期”。你会把这些重复的操作打包成一个函数

函数的本质: 一个具备输入(参数)、执行逻辑(函数体)和输出(返回值)的“黑盒子”。

返回类型 函数名(参数列表) {
    // 函数体:一堆语句
    return 某个值; // 如果返回类型不是 void
}

最小例子:写一个求平方的函数

#include <stdio.h>

int square(int x) {
    return x * x;
}

int main(void) {
    printf("%d\n", square(5));  // 输出 25
    return 0;
}

你可以把它理解为数学里的:square(5) = 25
调用函数就是写 square(5),它会把 5 传进去,计算后返回结果。

2.函数调用:实参传给形参

  • 形参:函数定义里括号中的变量(如 int x

  • 实参:调用时传进去的值(如 square(5)5

例子:多个参数

#include <stdio.h>

int max2(int a, int b) {
    if (a > b) return a;
    return b;
}

int main(void) {
    printf("%d\n", max2(3, 10)); // 10
    return 0;
}

这里 ab 是形参,310 是实参。

3.返回值:函数“产出”的结果

有返回值:用 return 表达式;

int add(int a, int b) {
    return a + b;
}

函数被调用后,返回值可以参与更大的表达式

int x = add(2, 3) * 10;  // x = 50

没有返回值:用 void

void print_line(void) {
    printf("--------\n");
}

调用:

print_line(); // 只是执行,不产生值

4.声明(函数原型 prototype)

在 C 里,如果你在 main 里先调用某个函数,但该函数的定义在后面,那么编译器需要提前知道:

  • 这个函数 返回什么类型

  • 参数有几个、各是什么类型(尤其在现代 C 中非常关键)

所以要写 函数原型

int square(int x);   // 声明/原型:告诉编译器这个函数长什么样

例子:先声明,后定义(K&R 典型写法)

#include <stdio.h>

int square(int x);   // prototype

int main(void) {
    printf("%d\n", square(6));
    return 0;
}

int square(int x) {  // definition
    return x * x;
}

要点:

  • 原型通常放在文件顶部(main 前),或放在头文件 .h 里。

  • 原型最后要有分号 ;

5.参数传递方式:C 默认是“值传递”

K&R 4.1 的一个核心:C 传参是“把实参的值拷贝一份给形参”
所以在函数里改形参,不会影响外面的变量。

例子:在函数里改参数,外面不变

#include <stdio.h>

void change(int x) {
    x = 999;
}

int main(void) {
    int a = 10;
    change(a);
    printf("%d\n", a);   // 仍然输出 10
    return 0;
}

因为 change(a) 只是把 a 的值(10)复制给 xx 变 999,不会回写到 a

那如果我就是想改外部变量?
C 用 指针(后面章节重点),把“地址”传进去才能改到外面。

6.局部变量:函数内部定义的变量只在函数内有效

K&R 强调:函数内部声明的变量通常是 automatic(自动)存储期

  • 进入函数创建

  • 退出函数销毁

  • 其他函数看不见(作用域 scope)

例子:局部变量只在函数内

int f(int x) {
    int t = x + 1;  // t 是局部变量
    return t;
}

int main(void) {
    // 这里不能用 t
}

7.把一段逻辑抽成函数(例如“求幂”)

你会经常看到这种:从 main 里把运算抽出来:

#include <stdio.h>

int power(int base, int n) {
    int p = 1;
    for (int i = 0; i < n; i++) {
        p = p * base;
    }
    return p;
}

int main(void) {
    printf("%d\n", power(2, 10)); // 1024
    printf("%d\n", power(5, 3));  // 125
    return 0;
}

这类例子主要让你体会:

  • 主程序更清爽

  • 功能可复用(power(2,10), power(5,3)

  • 易测试、易维护

8.要点

  1. 先想函数接口:输入(参数)是什么?输出(返回值)是什么?

  2. 先写 原型,放在顶部:

    int power(int base, int n);
  3. main 里当“调度器”,负责读输入、调用函数、输出结果。

  4. 记住:C 传参默认值传递,想改外部变量要用指针(后续)。

4.2 返回非整型值的函数

这一节非常经典,它揭示了早期C语言中一个极易引发Bug的设计缺陷,并引出了函数原型(Function Prototype)声明的重要性。

1. 核心问题:C语言的“隐式 int”陷阱

在早期的C语言(K&R C和C89)中,有一条很霸道的默认规则:如果一个函数在被调用之前没有经过声明,编译器就会自动假设这个函数返回的是 int(整型)类型。

如果你的函数确实返回 int,那一切好说。但如果你的函数返回的是 double(双精度浮点数),问题就大了。编译器会把函数返回的浮点数内存位(通常是 64 bit),强行按照整型(通常是 32 bit)去解读,最终得到一个毫无意义的乱码数字。

第4.2节的核心教条就是:如果函数返回非整型值,必须在调用它之前显式地声明它的返回类型。

2. K&R 的经典实例:atof 函数

为了说明这个问题,K&R 在书里实现了一个经典的函数 atof (ASCII to float)。它的作用是把一个字符串(比如 "123.45")转换成一个 double 类型的浮点数。

第一步:定义 atof 函数

这里我们复刻一下 K&R 书中 atof 的核心实现逻辑:

#include <ctype.h>

/* atof 函数定义:把字符串s转换为双精度浮点数 */
double atof(char s[]) {
    double val, power;
    int i, sign;

    /* 1. 跳过前导的空白符 */
    for (i = 0; isspace(s[i]); i++) 
        ;
        
    /* 2. 记录符号,如果是负号则sign为-1 */
    sign = (s[i] == '-') ? -1 : 1;
    if (s[i] == '+' || s[i] == '-')
        i++;
        
    /* 3. 处理小数点前面的整数部分 */
    for (val = 0.0; isdigit(s[i]); i++)
        val = 10.0 * val + (s[i] - '0');
        
    /* 4. 跳过小数点 */
    if (s[i] == '.')
        i++;
        
    /* 5. 处理小数点后面的小数部分,同时计算10的幂 */
    for (power = 1.0; isdigit(s[i]); i++) {
        val = 10.0 * val + (s[i] - '0');
        power *= 10.0;
    }
    
    /* 6. 返回最终计算结果 */
    return sign * val / power;
}
第二步:正确地调用 atof

现在我们要在 main 函数里使用它。请注意 main 函数内部的那句声明:

#include <stdio.h>

int main() {
    char num_str[] = "123.45";
    double sum = 0.0;
    
    /* 【关键点】:声明 atof 函数。告诉编译器它返回一个 double */
    double atof(char []); 

    /* 调用函数 */
    sum = atof(num_str) + 10.0; 
    
    printf("计算结果是: %f\n", sum); /* 预期输出: 133.450000 */
    
    return 0;
}

3. 如果我们不声明会发生什么?(反面教材)

假设我们把上面 main 函数里的 double atof(char []); 这一行删掉或注释掉。

  1. 编译器在执行到 sum = atof(num_str) 时,发现前面没有见过 atof

  2. 编译器触发默认规则:“我猜 atof 肯定返回一个 int”。

  3. 实际上 atof 辛辛苦苦算出了 123.45,并把它作为一个64位的 double 压入寄存器/栈中返回。

  4. main 函数却按照32位 int 的格式去读取这个返回值,然后再把它转换成 double 赋值给 sum

  5. 结果: sum 变成了一个极其离谱的数字(比如 10428392.000),因为内存的读取方式完全错位了。

4. 现代 C 语言的现状 (Reality Check)

虽然 K&R 第4.2节讲的内容非常基础,但需要指出的是,C语言标准已经发展了

  • C99 标准及以后: 已经彻底废除了“隐式 int”规则。如果你在现代编译器(如新版的 GCC 或 Clang)中调用一个未声明的函数,编译器通常会直接报错(Error),或者给出极其严厉的警告,拒绝编译。

  • 如何声明: 现代编程中,我们极少把函数声明写在 main 函数里面。标准的做法是把 double atof(char s[]); 这样的声明放在一个头文件(如 <stdlib.h>)中,然后在代码顶部通过 #include <stdlib.h> 引入。

正确写法(现代C推荐:函数原型/声明写在前面)

#include <stdio.h>

double power(double base, int n);   // 先声明:告诉编译器返回 double

double power(double base, int n) {
    double p = 1.0;
    for (int i = 0; i < n; i++) p *= base;
    return p;                       // 返回 double
}

int main(void) {
    printf("%.2f\n", power(2.0, 3));  // 8.00
    printf("%.2f\n", power(5.0, 0));  // 1.00
    return 0;
}
  • power 返回的是 double,所以 声明和定义都必须是 double power(...)

  • main 里调用之前就要让编译器知道(通过原型/声明),否则旧式C可能按 “默认返回 int” 处理,结果可能被截断或产生未定义行为。

总结

K&R 4.2 节的核心要点可以浓缩为一句话:在 C 语言中,函数不仅要定义怎么做,还要提前“挂号”(声明)它会返回什么,特别是当它返回的不是整数的时候。

4.3 外部变量

如果说4.2节讲的是函数如何向外输出结果,那么4.3节讲的就是函数之间如何共享数据。在C语言中,“外部变量”其实就是我们现在常说的全局变量 (Global Variables)

这一节非常关键,因为它不仅引入了变量的作用域概念,还通过一个经典的逆波兰计算器 (Reverse Polish Calculator) 实例,展示了如何用外部变量来维护“共享状态”。

以下是4.3节的核心拆解和实例演示:

1. 核心动机:为什么要用外部变量?

默认情况下,我们在函数内部定义的变量(比如上一节 atof 里的 valpower)都是内部变量(局部变量)。它们在函数被调用时诞生,在函数结束时消亡。其他函数是看不见、也摸不着这些变量的。

但是,有时候多个独立运行的函数需要共同访问同一份数据。如果每次都通过函数参数把这些数据传来传去,代码会变得极其臃肿。

外部变量定义在所有函数之外。它们在程序开始运行时就存在,一直活到程序结束,任何函数都可以直接访问和修改它们。

2. K&R 的经典实例:栈 (Stack)

为了说明外部变量的用途,K&R 在书里实现了一个计算器。这个计算器需要用到一个叫做“栈”的数据结构。栈有一个特点:后进先出 (LIFO)。

我们需要两个函数来操作栈:

  • push(f):把一个浮点数压入栈顶。

  • pop():把栈顶的数字弹出来。

这两个函数必须操作同一个数组同一个记录位置的指针。这就是外部变量登场的完美时机。

代码实例:利用外部变量实现共享栈

#include <stdio.h>

#define MAXVAL 100  /* 栈的最大深度 */

/* * 【关键点 1】:定义外部变量 
 * 它们定义在所有函数之外,属于全局可见。
 */
int sp = 0;         /* 下一个空闲的栈顶位置 (Stack Pointer) */
double val[MAXVAL]; /* 存放栈元素的数组 */


/* push 函数:把 f 压入栈中 */
void push(double f) {
    if (sp < MAXVAL) {
        val[sp++] = f;  /* 使用了外部变量 val 和 sp */
    } else {
        printf("错误:栈满了,放不下了!\n");
    }
}

/* pop 函数:弹出并返回栈顶元素 */
double pop(void) {
    if (sp > 0) {
        return val[--sp]; /* 同样使用了外部变量 val 和 sp */
    } else {
        printf("错误:栈是空的!\n");
        return 0.0;
    }
}

/* main 函数测试 */
int main() {
    push(3.14);
    push(2.71);
    
    printf("弹出的第一个数字: %f\n", pop()); /* 预期: 2.710000 */
    printf("弹出的第二个数字: %f\n", pop()); /* 预期: 3.140000 */
    
    return 0;
}

在这个例子中,val 数组和 sp 变量就是外部变量。pushpop 函数没有通过参数传递它们,而是直接“默契”地修改了同一份内存。

3. extern 关键字与“定义” vs “声明”

随着程序变大,你可能会把代码拆分到不同的 .c 文件中。比如:

  • main.c 负责用户输入。

  • stack.c 负责存放 pushpop 以及栈变量 valsp

如果在 main.c 里面也想直接读取栈顶位置 sp,该怎么办?

这就引出了 C 语言中一个极其重要的概念:定义 (Definition)声明 (Declaration) 的区别。

  • 定义:真正分配内存空间(只能有一次)。

  • 声明:告诉编译器“这个变量在别的地方分配了内存,它的类型是什么”(可以有很多次)。

如果要在 main.c 中使用 stack.c 里的外部变量,你必须在 main.c 中使用 extern 关键字进行声明

/* 在 main.c 文件中 */

extern int sp;         /* 【声明】:告诉编译器,sp 是个整型,但在别的文件定义的 */
extern double val[];   /* 【声明】:告诉编译器,val 是个双精度数组 */

int main() {
    /* 现在 main 里面也可以安全地使用 sp 和 val 了 */
    printf("当前栈的大小是: %d\n", sp); 
    return 0;
}

4. 现代 C 语言的现状 (Reality Check)

虽然 K&R 展示了外部变量的强大,但在现代软件工程中,滥用外部(全局)变量是编程大忌

  1. 状态混乱 (Spaghetti Code):如果任何函数都能修改全局变量,当程序出现 Bug 时,你很难追踪到底是谁在什么时候改错了数据。

  2. 并发问题:在现代的多线程编程中,多个线程同时读写同一个全局变量会导致灾难性的数据竞争(Data Race)。

现代的做法:尽量隐藏状态。在现代 C 语言项目中,通常会把 valsp 声明为 static(静态外部变量,后续4.6节会讲),只让 stack.c 内部可见,外部只能通过调用 pushpop 接口来操作,从而实现封装

4.4 作用域规则 

在4.3节中我们学习了外部变量(全局变量),4.4节则是对变量的“可见范围”定下严格的法律。如果说内存是变量的家,那么作用域(Scope)就是这个变量在代码中能够被合法叫出名字的范围

1. 局部变量(内部变量)的作用域

局部变量也就是定义在函数内部(或者大括号 {} 内部)的变量。

  • 规则:它的作用域从它被声明的那一行开始,到它所在的那个函数(或代码块)的右大括号 } 结束。

  • 特点:不同函数里即使使用了同名的局部变量,它们也互不干扰,就像不同班级里可以有同名的学生一样。

void funcA() {
    int i = 10; /* 变量 i 的作用域从这里开始 */
    /* ... 只有 funcA 认识这个 i ... */
} /* 变量 i 的作用域在这里结束,i 死亡 */

void funcB() {
    int i = 20; /* 这是一个全新的 i,跟 funcA 里的毫无关系 */
}

2. 外部变量(全局变量)的作用域

外部变量定义在所有函数之外。

  • 规则:它的作用域从它被定义(或声明)的那个位置开始,一直到这个源文件(.c 文件)的末尾结束。

这就是为什么函数的前后顺序在 C 语言中很重要的原因。我们来看下面这个极易犯错的例子:

#include <stdio.h>

int main() {
    /* 编译器报错:我不认识 sp!*/
    printf("栈顶位置是: %d\n", sp); 
    return 0;
}

/* 外部变量 sp 在这里才被定义 */
int sp = 0; 

void push(double f) {
    sp++; /* 这里可以正常使用,因为 sp 的作用域从上一行开始,到文件末尾 */
}

3. 救场法宝:extern 声明

如果由于代码结构的需要,你必须在外部变量定义之前使用它,或者你想跨文件(在 file1.c 中使用 file2.c 里定义的变量)使用它,你该怎么办?

这时候就必须用 extern 关键字来进行显式的声明extern 的潜台词是:“编译器老哥,这个变量是确实存在的,只是它的真身在后面,或者在别的文件里,你先放行。”

具体实例:跨文件的作用域

在实际的工程开发中,代码通常会拆分成多个文件。K&R 举了这样一个极其经典的场景:

文件 1:main.c

#include <stdio.h>

/* 【声明】告诉编译器:sp 和 val 存在,但在其他地方定义的 */
extern int sp;
extern double val[];

int main() {
    /* 因为有了上面的 extern 声明,这里的作用域合法了 */
    val[0] = 3.14; 
    sp = 1;
    printf("sp = %d, val[0] = %f\n", sp, val[0]);
    return 0;
}

文件 2:stack.c

/* 【定义】这里是真身!真正分配了内存空间 */
int sp = 0;
double val[100];

void push(double f) {
    val[sp++] = f;
}

编译时,编译器会将 main.cstack.c 链接在一起,main.c 里的 extern 声明就像一个寻址雷达,最终会死死锁定 stack.c 里定义的 spval

4. 核心铁律:声明 (Declaration) vs 定义 (Definition)

K&R 在这一节反复强调了一个很容易混淆的概念,也是 C 语言的黄金法则:

  • 定义 (Definition):指明变量的类型,并且分配内存空间(如果愿意,还可以赋初值)。一个外部变量在整个程序的几万行代码中,只能被定义一次。

    • 例如:int sp = 0;double val[100];

  • 声明 (Declaration):说明变量的性质(类型),但不分配内存一个变量可以被声明无数次。

    • 例如:extern int sp;extern double val[]; (注意数组声明不需要写长度,因为不分配内存,只需告诉编译器它是个数组即可)。

总结

4.4 节的核心就是“先声明/定义,后使用”。作用域规则本质上是编译器在扫描你的代码时,脑海中维护的一份“变量白名单”。不在白名单里的名字,就会引发编译错误。所以大家会发现C语言相较于其他的语言更加的严谨,C语言的严谨(甚至可以说是苛刻),正是它能够成为操作系统、嵌入式系统等底层基础设施基石的原因。它要求程序员对每一块内存、每一个变量的来龙去脉都了如指掌。

4.5 :头文件 

既然我们已经知道外部变量和函数需要“先声明,后使用”(4.3和4.4节的内容),那么问题来了:当程序变得很大,拆分成十几个甚至上百个 .c 文件时,难道我们要手工在每个文件开头写一遍 extern int sp;void push(double); 吗?

如果哪天 push 函数的参数类型变了,岂不是要在这几十个文件里挨个查找替换?这不仅繁琐,而且极易出错。

1. 核心思想:把“契约”集中管理

头文件(以 .h 为后缀)的作用就像是一份公共契约公告板

它的核心理念是:把所有的共享信息(宏定义、函数声明、外部变量声明)都抽离出来,放进一个单独的 .h 文件中。所有需要用到这些信息的 .c 文件,只需要通过 #include 把这个头文件包含进来即可。

K&R 在这一节把前面的逆波兰计算器程序进行了“工程化”的拆分,我们来看看它是怎么做的。

2. 具体实例:计算器程序的模块化拆分

假设我们把计算器拆分成下面几个文件:

  • calc.h:头文件(负责所有的声明)

  • main.c:主程序(包含计算逻辑主循环)

  • stack.c:栈的实现(包含 pushpop,以及 valsp 变量)

  • getop.c:获取下一个输入字符的逻辑

第一步:编写头文件 calc.h

我们把所有的“共享声明”都扔进这里:

/* 文件:calc.h */

#define NUMBER '0'  /* 宏定义可以放在这里 */

/* 函数原型声明 (告诉大家有这些函数可用) */
void push(double);
double pop(void);
int getop(char []);
int getch(void);
void ungetch(int);

/* 注意这里!我们没有放变量声明,为什么?后面解释 */
第二步:在 .c 文件中使用头文件

我们来看看 main.cstack.c 是如何包含这个“公告板”的:

主程序:main.c

/* 文件:main.c */
#include <stdio.h>
#include <stdlib.h>
#include "calc.h"  /* 【关键】:把声明全部拉取过来! */

int main() {
    int type;
    double op2;
    char s[100];

    /* 此时可以直接调用 push 和 pop,因为 calc.h 已经声明了它们 */
    while ((type = getop(s)) != EOF) {
        if (type == NUMBER) {
            push(atof(s)); 
        }
        /* ... 其他计算逻辑 ... */
    }
    return 0;
}

栈实现:stack.c

/* 文件:stack.c */
#include <stdio.h>
#include "calc.h"  /* 栈实现自己也包含这个头文件,用来校验自己的函数定义是否和声明一致 */

#define MAXVAL 100

/* 【注意】:真正的外部变量定义,只能乖乖待在某一个 .c 文件里 */
int sp = 0;
double val[MAXVAL];

void push(double f) {
    if (sp < MAXVAL) val[sp++] = f;
    else printf("error: stack full\n");
}

double pop(void) {
    if (sp > 0) return val[--sp];
    else { printf("error: stack empty\n"); return 0.0; }
}

3. 头文件的铁律:能放什么,不能放什么?

这是初学者最容易踩的深坑,K&R 在这一节虽然写得很精炼,但字字珠玑:

应该放在 .h 文件里的(只声明不分配内存):

  1. 宏定义:如 #define MAXVAL 100

  2. 函数声明(原型):如 void push(double);

  3. 外部变量声明:如 extern int sp;

  4. 结构体类型定义(后续章节会讲):如 struct Point { int x; int y; };

 绝对不能放在 .h 文件里的(会分配内存的代码):

  1. 变量的定义:如 int sp = 0;

  2. 函数的完整实现代码:如 void push(double f) { ... }

为什么不能放定义? 因为 #include 的本质是“傻瓜式文本复制替换”。如果你的 calc.h 里写了 int sp = 0;,当 main.cstack.c#include "calc.h" 时,编译期就会在两个文件里各自生成一句 int sp = 0;。 等最后把所有文件打包(链接)在一起时,系统会惊恐地发现:“怎么有两个 sp?到底听谁的?” —— 这就会引发著名的 Multiple Definition(多重定义)错误。

#include <stdio.h>#include "calc.h" 的区别是什么?

 解析: 这是 C 语言寻找头文件的路径规则:

  • 尖括号 < >:告诉预处理器去系统自带的系统目录(例如标准库的存放路径)里寻找这个头文件。通常用于标准库(如 stdio.h, math.h)。

  • 双引号 " ":告诉预处理器首先在当前源文件所在的目录下寻找。如果找不到,再去系统目录找。通常用于你自己写的项目头文件。

小明写了一个头文件 global.h 和两个源文件:

/* global.h */
extern int max_retries; /* 声明 */
int current_status = 1; /* 小明觉得顺手给个初始状态挺好 */

/* file1.c */
#include "global.h"
void func1() { current_status = 2; }

/* file2.c */
#include "global.h"
void func2() { current_status = 3; }

问: 这个程序在编译和链接时会发生什么?为什么?

会发生链接错误:Multiple definition of 'current_status'(多重定义)。 因为 #include "global.h" 是直接文本替换。相当于在 file1.cfile2.c 的开头都写了一句 int current_status = 1;记住铁律: 头文件里只能放带有 extern声明,绝对不能放带有赋值或分配内存的定义current_status 的真身必须只能且仅能存在于某一个特定的 .c 文件中。

4.6:静态变量 

我们之前学到了全局变量(外部变量)的强大。

全局变量虽然方便,但极其危险——因为程序里的任何一个函数都能悄悄修改它。在现代软件工程中,我们提倡“高内聚,低耦合”和“信息隐藏”。C语言实现这种数据隐藏的终极武器就是 static 关键字。

对于初学者来说,static 是最容易搞混的关键字之一,因为它在不同的位置,发挥着两种完全不同的作用。我们来逐一拆解。

1. 外部静态变量(给文件上锁)

static 用在所有函数之外(也就是修饰全局变量时),它的作用是限制作用域

在 4.3 节的计算器例子中,我们将栈的指针 sp 和数组 val 定义成了全局变量:

int sp = 0;
double val[MAXVAL];

这其实很糟糕。如果别人在 main.c 里写了一句 extern int sp;,他就可以直接修改 sp = -999;,直接把你的栈搞崩溃。

解决办法:加上 static

/* 文件:stack.c */

/* 【上锁】:加上 static 后,sp 和 val 变成了这个文件的“私有财产” */
static int sp = 0;
static double val[MAXVAL];

void push(double f) {
    /* push 函数在这个文件内,可以正常访问 sp 和 val */
    val[sp++] = f; 
}

double pop(void) {
    return val[--sp];
}

核心效果:一旦加上了 staticspval只能在 stack.c 这个文件内部被访问。其他任何文件,哪怕用 extern int sp; 强行声明,编译器也会在链接时无情地报错(找不到该变量)。这完美实现了面向对象编程中的“私有属性(Private)”概念。

(注:函数也可以声明为 static,比如 static void my_helper() {},这意味着这个辅助函数只能被当前文件内的其他函数调用,外部不可见。)

2. 第二张面孔:内部静态变量(拥有记忆的局部变量)

static 用在函数内部(修饰局部变量时),它的作用是改变生命周期,赋予变量“记忆力”

普通的局部变量在函数每次被调用时创建,在函数结束时销毁(失去记忆)。但如果加上 static,这个变量就会被存放在全局数据区,它在函数调用结束后不会死亡,而是保留它的值,直到下一次调用。但注意,它的作用域依然被死死限制在这个函数内部

具体实例:计数器函数

#include <stdio.h>

void counter() {
    /* 普通局部变量:每次调用都会重新初始化为 0 */
    int normal_count = 0; 
    
    /* 内部静态变量:只在第一次调用时初始化为 0,之后保留上次的值 */
    static int static_count = 0; 
    
    normal_count++;
    static_count++;
    
    printf("普通: %d, 静态: %d\n", normal_count, static_count);
}

int main() {
    counter(); /* 输出:普通: 1, 静态: 1 */
    counter(); /* 输出:普通: 1, 静态: 2 */
    counter(); /* 输出:普通: 1, 静态: 3 */
    return 0;
}

核心效果static_count 就像是 counter 函数私藏的一个小账本。别的函数既看不见它,也改不了它,但它却能长久存在。

题目 1:同名不冲突 假设你有两个文件:

  • file1.c 中定义了:static int status = 1;

  • file2.c 中定义了:static int status = 2; 把它俩一起编译链接,会像 4.5 节那样报“多重定义(Multiple definition)”的冲突错误吗?为什么?

解析: 完全不会冲突。 这正是外部 static 的绝妙之处。static 将这两个 status 变量的作用域严格限制在了它们各自的源文件中。对于编译器来说,这就是两个互不相干、彼此屏蔽的同名变量。这就好比一班有个叫“张伟”的,二班也有个叫“张伟”的,只要他们不出班级,就不会弄混。

题目 2:记忆陷阱 阅读以下代码,请问连续调用三次 get_next_even(),分别会返回什么值?

int get_next_even() {
    static int num = 0;
    num += 2;
    return num;
}

解析: 分别返回:2,4,6。 因为 num 是内部静态变量,它在程序运行期间一直存活。 第 1 次调用:num 从 0 变成 2,返回 2。 第 2 次调用:num 记得自己上次是 2,加 2 变成 4,返回 4。 第 3 次调用:num 记得自己上次是 4,加 2 变成 6,返回 6。 如果去掉 static,它每次都会返回 2。

总结

static 是 C 语言中管理程序架构的利器:

  • 用在外面(全局):防偷窥(限制作用域到当前文件)。

  • 用在里面(局部):加记忆(延长生命周期到程序结束)。

4.7-4.9

接下来的三节(4.7 到 4.9)篇幅较短,但填补了 C 语言变量管理中极其重要的几个细节。它们分别回答了三个问题:如何让变量跑得更快?变量到底能在哪里存活?变量出生时默认是什么样?

我们将这三节连起来看,并在每一部分附上辅助理解的小题。

4.7 寄存器变量 (register):追求极致的速度

计算机的存储是有层级的,CPU 内部的寄存器(Register)是速度最快、但数量极其稀少的存储空间(比普通的内存快百倍以上)。

register 关键字的作用是向编译器提出强烈建议:“编译器,这个变量我接下来要极其频繁地使用(比如循环控制变量),请务必把它放在 CPU 寄存器里,而不是普通的内存里。”

void fast_loop() {
    register int i; /* 建议将 i 放入寄存器 */
    for (i = 0; i < 1000000; i++) {
        /* ... 密集计算 ... */
    }
}

核心铁律与限制:

  1. 不能取地址:既然变量在 CPU 寄存器里,它就没有内存地址。所以你绝对不能对寄存器变量使用 & 运算符(如 &i 是非法的,会导致编译报错)。

  2. 只有局部变量和形参可以声明为 register,全局变量不行。

现代 C 语言的现状 (Reality Check): 在今天,register 关键字几乎已经名存实亡。现代编译器(如 GCC 或 Clang 的 -O2 优化)非常聪明,它们做寄存器分配的能力远超人类程序员。即使你不写 register,编译器也会自动把频繁使用的变量丢进寄存器;即使你写了,编译器如果觉得不合适,也会直接忽略你的建议。不过,“不能取地址”的语法限制依然存在

题目: 下面哪一行代码会引发编译错误?

void test() {
    register int x = 10;
    int y = x + 5;       /* A */
    int *ptr = &x;       /* B */
    x = 20;              /* C */
}

B 会报错。 试图用 & 获取寄存器变量 x 的地址是严令禁止的,因为寄存器没有内存地址。

4.8 程序块结构 (Block Structure):大括号的绝对领域

在早期的一些语言中,局部变量必须统一写在函数的最开头。而 C 语言引入了“程序块(Block)”的概念:任何一对大括号 { } 都构成一个块,你可以在任何一个块的开头声明变量。

核心规则:作用域屏蔽(Shadowing) 如果在内层大括号里定义了一个和外层同名的变量,那么在内层大括号里,外层的变量会被“隐藏”或“屏蔽”。离开内层大括号后,外层变量又会恢复如初。

#include <stdio.h>

int main() {
    int x = 10; /* 外层 x */
    
    if (x > 0) {
        int x = 50; /* 内层全新的 x,屏蔽了外层的 x */
        printf("内层 x = %d\n", x); /* 打印 50 */
    } /* 内层 x 死亡 */
    
    printf("外层 x = %d\n", x); /* 打印 10,外层 x 毫发无损 */
    return 0;
}

在 K&R C 和 C89 标准中,变量声明必须放在大括号内部的第一行(任何执行语句之前)。但在现代的 C99 标准之后,变量可以随时随地声明了(比如 for(int i=0;...))。

4.9 初始化 (Initialization):变量的“出生证明”

当我们声明一个变量却没有给它赋值时,它的初始值到底是多少?如果不搞清楚这个问题,你的程序就会出现玄学 Bug。

K&R 给出了极其清晰的两条铁律:

铁律 1:外部变量(全局变量)和 静态变量(static

  • 默认值: 自动初始化为 (整型为 0,浮点型为 0.0,指针为 NULL)。

  • 时间点: 在程序开始执行前,由系统统一清零。

铁律 2:自动变量(普通的局部变量)和 寄存器变量

  • 默认值: 垃圾值(Garbage Value)。它们不会被自动清零,内存里原来残留着什么乱七八糟的数据,它的初始值就是什么。

  • 时间点: 每次进入函数/块时才会分配内存。

数组的初始化 C 语言允许用花括号 {} 极其方便地初始化数组:

/* 如果不写数组长度,编译器会自动数有几个元素(这里是 5 个) */
int days[] = { 31, 28, 31, 30, 31 };

/* 如果指定的长度比提供的值多,剩下的元素全部自动补 0 */
int months[12] = { 1, 2, 3 }; /* 后面的 9 个元素全是 0 */

/* 字符数组(字符串)的特殊初始化 */
char msg[] = "Hello"; /* 等价于 {'H','e','l','l','o','\0'},自动补结束符 */

题目: 下面代码在运行时,控制台大概率会打印出什么?

#include <stdio.h>

int global_val; /* 全局变量 */

int main() {
    int local_val; /* 局部变量 */
    static int static_val; /* 静态局部变量 */
    
    printf("global = %d\n", global_val);
    printf("static = %d\n", static_val);
    printf("local = %d\n", local_val);
    
    return 0;
}
  • global = 0 (全局变量自动清零)

  • static = 0 (静态变量自动清零)

  • local = 32767 (或者 -858993460 等任意不可预测的垃圾数字。局部变量如果不手动赋初值,直接使用是非常危险的!)

到此为止,关于变量的声明、作用域、生存期和初始化的所有基本功,我们已经全部打扎实了。

第四章最后剩下的两块大骨头是:

  • 4.10 递归 (Recursion):函数自己调用自己。K&R 在这里用 C 语言手写了一个极其经典的快速排序 (Quicksort) 核心逻辑。

  • 4.11 C预处理器 (The C Preprocessor):比如 #define#include 到底在底层是怎么工作的。

4.10:递归

如果说前面的章节是在教你如何使用 C 语言的“砖块”(变量、作用域、函数),那么这一节就是在教你一种“建筑风格”。

递归的核心定义非常简单:一个函数直接或间接地调用它自己。

但这听起来有点像“左脚踩右脚上天”,它是如何工作的?变量不会打架吗?程序不会死循环吗?

K&R 书里直接拿“快速排序”来讲递归,步子确实迈得太大了。咱们把快排、指针、那些复杂的代码全扔到一边!

今天我们就用最直白的大白话,加上一个极其简单的例子,把递归彻底扒开来看。

你一定知道“梦中梦”的概念:

  1. 你在第一层梦里遇到了一扇打不开的门,于是你暂停第一层的行动,进入第二层梦去找钥匙。

  2. 在第二层梦里,你又遇到了麻烦,于是你又暂停第二层,进入第三层梦。

  3. 在第三层梦里,你终于找到了钥匙!此时第三层梦结束,你醒来退回到第二层

  4. 第二层拿到了钥匙,解决了麻烦,第二层结束,你醒来退回到第一层

  5. 第一层终于可以开门了。

在 C 语言里,这种行为分为两个阶段:

  • “递”(入梦):函数遇到自己,就把自己当前的状态冻结(暂停),然后去执行下一层。

  • “归”(苏醒):最底层执行完毕后,上层被冻结的函数才会依次解冻,继续执行剩下的代码。

#include <stdio.h>

void mystery(int n) {
    if (n > 0) {
        mystery(n - 1);
        printf("%d ", n);
    }
}

我们拿上面那道小测代码来做一次“逐帧慢动作回放”。你只要盯着代码里的这两行看就行了:

现在,我们在 main 函数里大喊一声:mystery(3);,看看计算机到底在干嘛:

第一层梦境启动:传入 n = 3

  • 计算机问:3 > 0 吗?是的。

  • 走到 mystery(3 - 1),也就是 mystery(2)

  • 【关键点来了】: 计算机此刻立刻冻结了第一层梦!后面的 printf 根本还没执行!它必须等 mystery(2) 彻底跑完。

第二层梦境启动:传入 n = 2

  • 计算机问:2 > 0 吗?是的。

  • 走到 mystery(2 - 1),也就是 mystery(1)

  • 立刻冻结第二层梦!等待 mystery(1) 跑完。

第三层梦境启动:传入 n = 1

  • 计算机问:1 > 0 吗?是的。

  • 走到 mystery(1 - 1),也就是 mystery(0)

  • 立刻冻结第三层梦!等待 mystery(0) 跑完。

第四层梦境启动(最底层):传入 n = 0

  • 计算机问:0 > 0 吗?不是!

  • 太好了,if 进不去,什么也不用干。最底层梦境结束!

  • 开始大撤退(苏醒阶段)

开始“归”的过程(依次解冻)

  • 退回第三层梦: 之前在 n = 1 这里冻结的。现在解冻!继续往下走,执行它没干完的活儿:printf("%d ", n);。  屏幕上打印出:1 第三层梦彻底结束。

  • 退回第二层梦: 之前在 n = 2 这里冻结的。现在解冻!执行它没干完的活儿:printf("%d ", n);。  屏幕上打印出:2 第二层梦彻底结束。

  • 退回第一层梦: 之前在 n = 3 这里冻结的。现在解冻!执行它没干完的活儿:printf("%d ", n);。  屏幕上打印出:3 第一层梦彻底结束。全部收工!

所以,最终屏幕上的输出顺序是:1 2 3

为什么没有输出 3 2 1? 因为代码是这样写的:

mystery(n - 1);   // 先让小弟去干活
printf("%d ", n); // 小弟干完了,我再打印我自己

如果我把代码的顺序调换一下:

printf("%d ", n); // 我先打印我自己!
mystery(n - 1);   // 然后我再叫小弟去干活

这个时候,输出的就会是 3 2 1

经典实例

快速排序(由 C.A.R. Hoare 发明)是分治法(Divide and Conquer)的巅峰之作,它的逻辑天生就适合用递归来表达。

快排的核心思想:

  1. 在数组里随便挑一个数作为“基准 (pivot)”。

  2. 把所有比基准小的数扔到它左边,比基准大的数扔到它右边。

  3. 递归魔法:对左边的子数组和右边的子数组,重复上述过程。

下面是 K&R 书中高度精简的 C 语言实现:

/* qsort:对数组 v 的 left 到 right 范围进行排序 */
void qsort(int v[], int left, int right) {
    int i, last;

    /* 【终止条件】:如果分给你的数组只有 1 个数字,或者没数字了 */
    /* 直接下班(结束这一层递归,解冻上一层) */
    if (left >= right) {
        return; 
    }

    /* ---------------- 开始你的老板工作 ---------------- */

    /* 步骤 1:挑个基准。K&R 挑的是中间那个数 (left+right)/2。
       为了方便操作,我们先把它和最左边(left)的数互换,把它“藏”在最左边备用。*/
    swap(v, left, (left + right) / 2);
    
    /* 此时,v[left] 就是我们的基准数。
       设定 last 指向 left,表示目前“小于基准阵营”只有基准它自己。*/
    last = left; 

    /* 步骤 2:核心的站队循环!从基准右边第一个元素开始往后挨个查 */
    for (i = left + 1; i <= right; i++) {
        /* 如果发现一个数比基准数 v[left] 还要小 */
        if (v[i] < v[left]) {
            /* 把“小于阵营”的边界往右扩充一格 (++last)
               然后把这个小数字,扔进这个阵营里(和 i 交换位置) */
            swap(v, ++last, i);
        }
    }

    /* 步骤 3:现在 last 的左边(包括 last)全是比基准小的了。
       把之前藏在最左边的基准数 v[left],换到 last 的位置。
       这下,基准数彻底坐稳了它最终的王座!*/
    swap(v, left, last);

    /* ---------------- 老板工作结束,开始甩锅 ---------------- */

    /* 步骤 4:一号小弟去排基准数左边的部分(从 left 到 last - 1) */
    qsort(v, left, last - 1);  /* 老板在这里【冻结】,等左半边排完 */
    
    /* 步骤 5:二号小弟去排基准数右边的部分(从 last + 1 到 right) */
    qsort(v, last + 1, right); /* 老板在这里再次【冻结】,等右半边排完 */
}

假设基准数是 4 藏在最左边,数组是 [4, 7, 1, 6, 3]

  1. i 指向 7,比 4 大,不管它。

  2. i 指向 1,比 4 小!此时 ++last,把 1 扔过来,变成 [4, 1, 7, 6, 3]

  3. i 指向 6,比 4 大,不管。

  4. i 指向 3,比 4 小!此时 ++last,把 3 扔过来,变成 [4, 1, 3, 6, 7]。 循环结束!现在 last 指向 3。 最后执行一句 swap(v, left, last),把 43 换个位置: 变成 [3, 1, (4), 6, 7]

完美!4 的左边全比它小,右边全比它大。然后就可以派两个小弟去处理 [3, 1][6, 7] 了。

如果不使用递归,要写出具有相同功能的快排代码,需要手动维护一个极其复杂的栈结构,代码量会翻好几倍,而且极容易出错。

递归的利与弊 (Reality Check)

K&R 在这一节的最后非常客观地评价了递归:

  • 优点:代码紧凑,逻辑清晰。在处理树形结构、图遍历或者分治算法时,递归是人类大脑最容易理解的表达方式。

  • 缺点:不节省内存,也不节省时间。每一次函数调用都需要保存现场、分配栈帧,这会带来额外的性能开销。

  • 致命风险(Stack Overflow):如果递归没有写正确的终止条件(Base Case),或者递归层次太深(比如几十万层),程序的栈内存会被耗尽,导致著名的“栈溢出(Stack Overflow)”崩溃。

4.11 C 预处理器 (The C Preprocessor)

这绝对是 C 语言里最具特色、但也最容易引发玄学 Bug 的机制之一。

要搞懂预处理器,你必须先在脑海里建立一个物理概念:预处理器不是编译器。它根本不懂 C 语言的语法! 预处理器就像是一个“极其尽职但毫无感情的文本替换机器人”。在编译器真正开始把你的 C 代码翻译成机器码之前,这个机器人会先把你代码里所有带 # 号开头的行(比如 #include#define)全部用纯文本替换的方式处理一遍。

我们来看看 K&R 在这一节介绍的预处理器的三大核心

1. 文件包含 (#include):暴力的复制粘贴

我们在 4.5 节(头文件)中其实已经见识过它了。

当你在代码里写下 #include "calc.h" 时,预处理器机器人的工作极其简单粗暴:它会把 calc.h 这个文件打开,把里面的所有文本一字不落地复制出来,然后粘贴到你写 #include 的这个位置

  • #include "文件名":机器人先在你当前代码所在的文件夹里找这个文件。

  • #include <文件名>:机器人直接去系统指定的标准库文件夹里找(比如找 stdio.h)。

2. 宏替换 (#define):高级的“查找与替换”

这是最常用的功能。你可以定义一个名字来代表一串任意的字符。

#define MAX_LEN 100
#define FOREVER for (;;) 

int main() {
    int arr[MAX_LEN]; 
    FOREVER {
        /* 无限循环 */
    }
}

机器人的工作: 它会在全文搜索 MAX_LEN,只要不是在字符串里(比如 "MAX_LEN is big" 不会被替换),全部生硬地替换成 100。搜索到 FOREVER,全部替换成 for (;;)。等编译器接手时,它根本不知道曾经有过 MAX_LEN 这个词。

3. 带参数的宏( 极度危险)

预处理器的宏甚至可以带参数,看起来就像函数一样。但它绝对不是函数,它依然只是盲目的文本替换!这是无数 C 语言程序员翻车的地方。

K&R 书中给出了一个经典的求最大值的宏:

#define max(A, B) ((A) > (B) ? (A) : (B))

如果你在代码里写: x = max(p+q, r+s);

机器人会把它原封不动地替换成: x = ((p+q) > (r+s) ? (p+q) : (r+s));

这看起来很完美,而且因为没有函数调用的开销(不用分配栈帧),它跑得极其快。

翻车现场:为什么需要那么多括号?

假设你自己写了一个求平方的宏,觉得没必要加那么多括号:

#define square(x) x * x

一:优先级错乱

你在代码里调用:int result = square(z + 1);

你以为的结果:(z+1) * (z+1)

机器人的实际替换结果int result = z + 1 * z + 1;

根据数学优先级,这变成了 z + z + 1,完全算错了!

修复方法:在宏定义里加上括号保护参数。

#define square(x) (x) * (x)

二:副作用(Side Effects) 即便加上了括号,带参数的宏依然有一个致命缺陷。假设我们用回 K&R 那个完美的 max 宏:

#define max(A, B) ((A) > (B) ? (A) : (B))

你在代码里调用:max(i++, j++) (本意是求最大值,然后把 i 和 j 都加 1)。

机器人的实际替换结果((i++) > (j++) ? (i++) : (j++))

看到了吗?如果 i 大于 ji++ 会被执行两次!这在普通函数调用里是绝对不可能发生的。这就是文本替换带来的“副作用”。

4. 条件包含 (#if, #ifdef, #ifndef)

最后,预处理器还可以做逻辑判断,决定某一段代码要不要留给编译器。

最经典的用法就是防止头文件被重复包含(Include Guards)。 在 4.5 节我们说过,头文件里不能放变量的“定义”。但如果项目很大,A 文件 include 了 B,B 又 include 了 C,A 同时也 include 了 C。C 被重复粘贴了两次,还是会报错。

这时候预处理器出马了,在头文件 calc.h 里面这么写:

#ifndef _CALC_H_    /* 如果还没定义过 _CALC_H_ 这个名字 */
#define _CALC_H_    /* 赶紧定义一下,占个坑 */

/* 这里放所有真实的声明代码 */
void push(double);
double pop(void);

#endif              /* 结束 */

运作逻辑: 第一次包含这个文件时,机器人看到 _CALC_H_ 没定义过,就放行,并且把 _CALC_H_ 定义了。第二次再包含这个文件时,机器人发现已经定义过了,#ifndef 进不去,于是把 #ifndef#endif 之间的所有文本全部删掉,不交给编译器。完美解决重复包含问题!

题目: 阅读以下宏定义和代码:

#include <stdio.h>

#define DOUBLE(x) 2 * x

int main() {
    int a = 5;
    int b = 10 / DOUBLE(a);
    printf("b = %d\n", b);
    return 0;
}

问: 程序最终打印出来的 b 的值是多少?(如果你凭直觉觉得是 1,请再仔细想想机器人的“无脑替换”动作)

打印出来的值是 10。 让我们模仿机器人做文本替换。把 DOUBLE(a) 替换掉: int b = 10 / 2 * a; 变量 a 是 5。根据 C 语言乘除法同级,从左向右计算的规则: 先算 10 / 2 = 5。 再算 5 * 5 = 25。 

正确的宏应该写成#define DOUBLE(x) (2 * (x)),这样替换后变成 10 / (2 * (5)),才能得出预期的结果 1。

到这里,K&R 《C程序设计语言》第四章关于“函数与程序结构”的所有内容(从函数的隐式声明、全局变量、作用域、static 加锁、递归,到预处理宏的黑魔法)我们就全部啃完了!

接下来的第五章 (Pointers and Arrays 指针与数组) 是整本书中最硬核、也最劝退的一章。它将彻底揭开 C 语言操控内存的面纱。

下周我会更新第五章。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐