Lec 2 C 语言基础
本讲主线:C 程序由一组外部对象(external objects,即变量与函数)组成。语言提供了一套机制,让我们能控制每个对象的三个属性——作用域、生命周期、链接——从而决定数据如何在函数之间、文件之间共享与隐藏。最后再补上一个独立于编译的文本处理阶段:预处理器。
0. 统一框架:作用域、生命周期、链接
理解本讲的关键,是把变量/函数的三个正交属性分开看:
| 属性 | 含义 | 决定了什么 |
|---|---|---|
| 作用域(scope) | 名字在源码中何处可见 | 谁能引用它 |
| 生命周期(lifetime / storage duration) | 对象在内存中存活多久 | 值是否跨调用保留 |
| 链接(linkage) | 不同文件中的同名引用是否指向同一对象 | 能否跨文件共享 |
本讲涉及的四类对象,全部可以挂到这张表上——后面的所有内容,本质都是在调这三个旋钮:
| 种类 | 作用域 | 生命周期 | 链接 |
|---|---|---|---|
| 自动变量(块内声明) | 块 / 函数内 | 自动(进入时创建,退出时销毁) | 无 |
| 外部变量 | 声明点 → 文件末尾(可经 extern 跨文件) | 静态(程序全程存活) | 外部(external) |
外部 static(变量/函数) | 当前文件内 | 静态 | 内部(internal) |
内部 static(函数内局部变量) | 块 / 函数内 | 静态(程序全程存活) | 无 |
抓住一句话:外部变量管"共享",
static管"隐藏 / 持久"。下面分别展开。
1. 外部变量:跨函数共享数据
1.1 基本概念
"外部"(external)是相对于"内部"(internal,即函数内部的参数和局部变量)而言的。外部变量的特性,正好对应框架里的三个旋钮:
- 作用域:定义在所有函数之外,从声明点起到文件末尾全局可访问,可作为函数间通信的替代手段(代替参数和返回值)。
- 生命周期:永久——从程序启动到结束,值在函数调用之间持续保留。
- 链接:默认外部链接(external linkage),不同源文件中对同一名字的引用指向同一个对象(类似 Fortran 的
COMMON块)。
外部变量方便共享,但要谨慎使用——过多的外部变量会让函数之间产生过度的数据耦合,破坏程序结构。共享是它的优点,也是它的陷阱。
1.2 实例:逆波兰计算器
K&R 用一个逆波兰表示法(Reverse Polish Notation)计算器,来演示"何时该用外部变量"的设计决策。
逆波兰表示法:运算符跟在操作数后面,不需要括号。
中缀: (1 - 2) * (4 + 5)
逆波兰: 1 2 - 4 5 + *实现思路:用栈(stack)驱动——操作数入栈,遇到运算符就弹出操作数、计算、把结果压回去。
主循环伪代码:
while (还有输入)
if (数字) → push
if (运算符) → pop 操作数, 运算, push 结果
if (换行) → pop 并打印栈顶
else → 报错设计决策一:栈放在哪里?
| 方案 | 做法 | 评价 |
|---|---|---|
放在 main 里 | 把栈和栈指针作为参数传给 push / pop | main 不应该知道栈的内部细节 |
| 用外部变量 | 栈和栈指针定义为外部变量,push / pop 直接访问 | 书中采用:main 只调用 push / pop,完全不接触栈的表示 |
程序整体结构如下。注意声明顺序本身就实现了隐藏:
#includes
#defines
函数声明
main() { ... }
/* 栈的外部变量——push/pop 可见,main 不可见 */
int sp = 0;
double val[MAXVAL];
void push(double f) { ... }
double pop(void) { ... }
int getop(char s[]) { ... }
/* getop 的辅助函数 */
int getch(void) { ... }
void ungetch(int) { ... }
sp和val定义在main之后、push/pop之前。由于外部变量的作用域从声明点开始,main看不到它们,而push/pop可以直接使用——通过声明顺序天然实现了隐藏。(这正是 1.2.1 节作用域规则的直接应用。)
设计决策二:- 和 / 的求值顺序陷阱
C 标准不保证函数参数的求值顺序,因此下面这行是错的:
push(pop() - pop()); // ❌ 两个 pop() 的求值顺序未定义对于不满足交换律的 - 和 /,必须用临时变量固定顺序:
op2 = pop(); // 先弹出右操作数
push(pop() - op2); // 再弹出左操作数,确保顺序正确+ 和 * 满足交换律,所以顺序无所谓。
设计决策三:getch / ungetch 回退字符
读数字时,必须多读一个字符才能知道数字在哪里结束(遇到第一个非数字字符),多读的那个字符需要"放回去":
#define BUFSIZE 100
char buf[BUFSIZE]; // 回退缓冲区
int bufp = 0; // 缓冲区当前位置
int getch(void) { // 有缓冲就从缓冲取,否则 getchar()
return (bufp > 0) ? buf[--bufp] : getchar();
}
void ungetch(int c) { // 把字符推回缓冲区
if (bufp >= BUFSIZE)
printf("ungetch: too many characters\n");
else
buf[bufp++] = c;
}buf 和 bufp 之所以是外部变量:它们必须在 getch / ungetch 之间共享,且跨调用保持值——这正是外部变量"全局可访问 + 永久生命周期"两个旋钮同时起作用的场景。
💡 记住这个例子:第 3 节会用内部
static把它改写得更干净(练习 4-11)。
2. 作用域规则:控制"何处可见"
2.1 自动变量 vs 外部变量
| 自动变量(函数内声明) | 外部变量 / 函数 | |
|---|---|---|
| 作用域 | 仅限声明它的函数 | 从声明点开始,到当前源文件末尾 |
| 同名冲突 | 不同函数中的同名变量互不相关 | 同一文件内是同一个对象 |
这就是为什么上面计算器里 main 看不到 sp 和 val——它们定义在 main 之后。
2.2 声明 vs 定义(关键区分)
| 定义(definition) | 声明(declaration) | |
|---|---|---|
| 作用 | 宣布类型 + 分配存储空间 | 只宣布类型,不分配空间 |
| 数量限制 | 整个程序中只能有一个 | 可以有多个 |
| 示例 | int sp; double val[MAXVAL]; | extern int sp; extern double val[]; |
| 初始化 | 可以初始化 | 不能初始化 |
| 数组大小 | 必须指定 | 可以省略 |
一句话:定义"造"对象,声明只是"指"对象。
2.3 多文件组织:用 extern 跨文件
当程序拆分为多个源文件时,靠 extern 声明来跨文件访问同一个对象(即激活"外部链接"这个旋钮):
// file1.c —— 使用 sp 和 val
extern int sp; // 声明:告诉编译器这些变量存在于别处
extern double val[];
void push(double f) { ... }
double pop(void) { ... }
// file2.c —— 定义 sp 和 val
int sp = 0; // 定义:分配空间 + 初始化
double val[MAXVAL];
extern声明放在文件顶部、所有函数之外,就对整个文件中的所有函数生效。
3. static:控制"隐藏"与"持久"
static 的本质是调整作用域和生命周期这两个旋钮。根据声明位置不同,调的旋钮也不同——这是理解它最容易混淆的地方,务必分清两种用法。
3.1 外部 static:文件级隐藏(调"链接"旋钮)
用于文件作用域的变量或函数,把链接从"外部"降为"内部",可见性限制在当前源文件内:
// getch.c
static char buf[BUFSIZE]; // 仅本文件可见
static int bufp = 0; // 仅本文件可见
int getch(void) { ... } // 全局可见
void ungetch(int c) { ... } // 全局可见buf 和 bufp 必须是外部变量(因为 getch 和 ungetch 要共享),但加上 static 后,其他文件无法访问,名字也不会与其他文件中的同名变量冲突。
函数同样可以声明为 static,使其在文件外不可见:
static int helper(int x) { ... } // 仅本文件内可调用这是 C 在没有
class/namespace的条件下实现模块封装的主要手段,可类比其他语言中的private。
3.2 内部 static:函数内持久存储(调"生命周期"旋钮)
用于函数内部的局部变量,作用域不变,但生命周期从"自动"变为"静态":
| 特性 | 普通局部变量(automatic) | static 局部变量 |
|---|---|---|
| 作用域 | 函数内 | 函数内(相同) |
| 生命周期 | 调用时创建,返回时销毁 | 程序整个运行期间持续存在 |
| 初始化 | 每次调用都重新初始化 | 仅在程序启动时初始化一次 |
void counter(void) {
static int count = 0; // 只初始化一次
printf("%d\n", ++count);
}
// 连续调用输出: 1, 2, 3, ...内部
static为单个函数提供了私有的、持久的存储空间——它有外部变量的"持久性",但没有外部变量的"全局可见性"。
3.3 练习 4-11:用内部 static 干掉 ungetch
回到 1.2 节的 getop。它读数字时不知道何时结束,必须多读一个字符判断,再用 ungetch 放回。
为什么想去掉 ungetch? 因为 ungetch 依赖一套外部状态(buf[] 和 bufp),引入了额外的全局变量和函数依赖。如果 getop 能自己记住那个多读的字符,这套机制就不需要了——而"私有 + 跨调用持久"正是内部 static 的拿手好戏。
// 原始方案:依赖外部缓冲区 buf[] 和 ungetch/getch 这对函数
int getop(char s[]) {
int c;
// ... 跳过空格,读数字 ...
while (isdigit(s[++i] = c = getch()))
;
// 循环结束时,c 是第一个"不是数字"的字符(如 '+')
if (c != EOF)
ungetch(c); // 放回去,下次 getch() 会再读到它
return NUMBER;
}
// 改进方案:内部 static 变量,自己记住多读的字符
int getop(char s[]) {
static int lastc = 0; // 私有、持久,记住上次多读的字符
int c;
// 第一步:看看上次有没有"存货"
if (lastc != 0) {
c = lastc; // 用上次存下来的
lastc = 0; // 取走了,清空
} else {
c = getch(); // 没存货,正常读
}
// ... 跳过空格、判断是否是数字 ...
while (isdigit(s[++i] = c = getch()))
;
if (c != EOF)
lastc = c; // 不放回了,自己存着(← 原来是 ungetch(c))
return NUMBER;
}4. C 预处理器:编译前的文本处理阶段
定位:预处理器是编译过程中单独进行的第一个步骤,纯粹的文本替换,与前三节的"语言机制"是不同层面的东西。最常用的两条指令是
#include和#define。
4.1 宏定义与 #undef
格式为 #define 名字 替换文本。若替换文本分成若干行,只需在延续行末尾加反斜杠 \:
#define max(A, B) ( (A) > (B) ? (A) : (B) )#undef 取消对宏名字的定义,通常是为了保证某个调用调用的是真正的函数而不是宏:
#undef getchar
int getchar(void) { ... };4.2 字符串化(#)
引号内的文本不会被宏参数替换。但若参数名前加上 #,该参数会被展开成一个带引号的字符串,其中嵌入实参。配合相邻字符串自动拼接,可写出调试打印宏:
#define dprint(expr) printf(#expr " = %g\n", expr)
dprint(x/y)
// 展开为: printf("x/y" " = %g\n", x/y);
// 拼接后: printf("x/y = %g\n", x/y);实参中的每个
"会被替换为\"、每个\替换为\\,以保证结果是合法的字符串常量。
4.3 记号粘贴(##)
## 用于在宏展开时把两个实参拼接成一个记号。规则:与 ## 相邻的参数先被替换为实参,然后 ## 及其周围空白被删除,拼接结果再被重新扫描一次:
#define paste(front, back) front ## back
paste(name, 1) // 生成记号 name1嵌套使用
##的规则较为晦涩,细节见 K&R 附录 A。
4.4 条件编译
用在预处理阶段求值的条件语句来选择性地包含代码:
#if:求值一个常量整型表达式(不能含sizeof、强制类型转换或enum常量)。非零则包含其后内容,直到#endif/#elif/#else。#elif:相当于 else-if。defined(name):名字已定义返回 1,否则返回 0。#ifdef/#ifndef:专门测试名字是否已定义的简写形式。
应用一:头文件保护(include guard)——确保 hdr.h 只被包含一次:
#if !defined(HDR)
#define HDR
/* hdr.h 内容写在这里 */
#endif首次包含时定义 HDR,后续包含会发现它已定义而直接跳到 #endif。坚持这种写法,每个头文件就能自由 #include 它所依赖的其他头文件,无需使用者去处理依赖关系。等价的简写:
#ifndef HDR
#define HDR
/* hdr.h 内容写在这里 */
#endif应用二:按平台选择头文件
#if SYSTEM == SYSV
#define HDR "sysv.h"
#elif SYSTEM == BSD
#define HDR "bsd.h"
#elif SYSTEM == MSDOS
#define HDR "msdos.h"
#else
#define HDR "default.h"
#endif
#include HDR4.5 练习 4-14:swap 宏
利用块结构交换两个 t 类型的参数:
#define swap(t, x, y) do { t _z = x; x = y; y = _z; } while (0)do { ... } while (0) 的写法,保证宏在任何语句上下文(尤其是 if-else)中都能像单条语句一样安全使用。
如果直接用裸 { ... } 而省略 do-while,分号会引发问题:
if (a > b)
swap(int, a, b); // 展开后 {...}; ← 分号导致 if 提前结束
else
foo(); // 编译报错:else 找不到匹配的 if