祖师爷KR的C语言第3期:函数与数据结构
《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;
}
这里 a、b 是形参,3、10 是实参。
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)复制给 x,x 变 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.要点
-
先想函数接口:输入(参数)是什么?输出(返回值)是什么?
-
先写 原型,放在顶部:
int power(int base, int n); -
main里当“调度器”,负责读输入、调用函数、输出结果。 -
记住: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 []); 这一行删掉或注释掉。
-
编译器在执行到
sum = atof(num_str)时,发现前面没有见过atof。 -
编译器触发默认规则:“我猜
atof肯定返回一个int”。 -
实际上
atof辛辛苦苦算出了123.45,并把它作为一个64位的double压入寄存器/栈中返回。 -
main函数却按照32位int的格式去读取这个返回值,然后再把它转换成double赋值给sum。 -
结果:
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 里的 val 和 power)都是内部变量(局部变量)。它们在函数被调用时诞生,在函数结束时消亡。其他函数是看不见、也摸不着这些变量的。
但是,有时候多个独立运行的函数需要共同访问同一份数据。如果每次都通过函数参数把这些数据传来传去,代码会变得极其臃肿。
外部变量定义在所有函数之外。它们在程序开始运行时就存在,一直活到程序结束,任何函数都可以直接访问和修改它们。
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 变量就是外部变量。push 和 pop 函数没有通过参数传递它们,而是直接“默契”地修改了同一份内存。
3. extern 关键字与“定义” vs “声明”
随着程序变大,你可能会把代码拆分到不同的 .c 文件中。比如:
-
main.c负责用户输入。 -
stack.c负责存放push、pop以及栈变量val和sp。
如果在 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 展示了外部变量的强大,但在现代软件工程中,滥用外部(全局)变量是编程大忌:
-
状态混乱 (Spaghetti Code):如果任何函数都能修改全局变量,当程序出现 Bug 时,你很难追踪到底是谁在什么时候改错了数据。
-
并发问题:在现代的多线程编程中,多个线程同时读写同一个全局变量会导致灾难性的数据竞争(Data Race)。
现代的做法:尽量隐藏状态。在现代 C 语言项目中,通常会把 val 和 sp 声明为 static(静态外部变量,后续4.6节会讲),只让 stack.c 内部可见,外部只能通过调用 push 和 pop 接口来操作,从而实现封装。
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.c 和 stack.c 链接在一起,main.c 里的 extern 声明就像一个寻址雷达,最终会死死锁定 stack.c 里定义的 sp 和 val。
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:栈的实现(包含push和pop,以及val和sp变量) -
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.c 和 stack.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 文件里的(只声明不分配内存):
-
宏定义:如
#define MAXVAL 100 -
函数声明(原型):如
void push(double); -
外部变量声明:如
extern int sp; -
结构体类型定义(后续章节会讲):如
struct Point { int x; int y; };
绝对不能放在 .h 文件里的(会分配内存的代码):
-
变量的定义:如
int sp = 0; -
函数的完整实现代码:如
void push(double f) { ... }
为什么不能放定义? 因为 #include 的本质是“傻瓜式文本复制替换”。如果你的 calc.h 里写了 int sp = 0;,当 main.c 和 stack.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.c 和 file2.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];
}
核心效果:一旦加上了 static,sp 和 val 就只能在 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++) {
/* ... 密集计算 ... */
}
}
核心铁律与限制:
-
不能取地址:既然变量在 CPU 寄存器里,它就没有内存地址。所以你绝对不能对寄存器变量使用
&运算符(如&i是非法的,会导致编译报错)。 -
只有局部变量和形参可以声明为
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 书里直接拿“快速排序”来讲递归,步子确实迈得太大了。咱们把快排、指针、那些复杂的代码全扔到一边!
今天我们就用最直白的大白话,加上一个极其简单的例子,把递归彻底扒开来看。
你一定知道“梦中梦”的概念:
-
你在第一层梦里遇到了一扇打不开的门,于是你暂停第一层的行动,进入第二层梦去找钥匙。
-
在第二层梦里,你又遇到了麻烦,于是你又暂停第二层,进入第三层梦。
-
在第三层梦里,你终于找到了钥匙!此时第三层梦结束,你醒来退回到第二层。
-
第二层拿到了钥匙,解决了麻烦,第二层结束,你醒来退回到第一层。
-
第一层终于可以开门了。
在 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)的巅峰之作,它的逻辑天生就适合用递归来表达。
快排的核心思想:
-
在数组里随便挑一个数作为“基准 (pivot)”。
-
把所有比基准小的数扔到它左边,比基准大的数扔到它右边。
-
递归魔法:对左边的子数组和右边的子数组,重复上述过程。
下面是 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]。
-
i指向7,比4大,不管它。 -
i指向1,比4小!此时++last,把1扔过来,变成[4, 1, 7, 6, 3]。 -
i指向6,比4大,不管。 -
i指向3,比4小!此时++last,把3扔过来,变成[4, 1, 3, 6, 7]。 循环结束!现在last指向3。 最后执行一句swap(v, left, last),把4和3换个位置: 变成[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 大于 j,i++ 会被执行两次!这在普通函数调用里是绝对不可能发生的。这就是文本替换带来的“副作用”。
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 语言操控内存的面纱。
下周我会更新第五章。
更多推荐
所有评论(0)