从指针开始讲起的 C 语言教程 1
上一篇从 Brainfuck 开始,绕了一大圈内存、地址、数组和循环。现在终于可以开始学习正常人会写的 C 语言了。
不过,我们还是不从 Hello, world! 开始。
printf 不是 C 语言本身,而是标准库提供的函数。要解释它,还得先解释头文件、字符串、函数声明、链接和可变参数。拿它当第一行代码当然很有仪式感,但它实际上偷偷欠下了一大堆债。
所以这一节先看一个更无聊的东西:变量。
变量不是一个盒子
许多教程会说,变量就像一个盒子,盒子上写着名字,里面装着值。
这个比喻不能说错。刚开始学的时候,它也确实很好用。但是学 C 时,我们最好尽早把这个盒子拆开。
int x = 42;
这行代码至少告诉了编译器四件事:
x是这个东西的名字int是解释这块内存的方式- 需要为它留出足以容纳一个
int的空间 - 一开始把 42 写进这块空间
程序运行时并不会随身携带一个写着 x 的小标签。编译完成以后,x 可能位于某个寄存器,也可能位于栈上的某个位置,甚至可能因为编译器发现它毫无用处而彻底消失。
变量是源码里的概念。内存和寄存器才是机器运行时真正操作的东西。
但这并不意味着变量名只是无关紧要的装饰。它帮助编译器和人回答几个关键问题:这串比特应该被当成什么?在哪里可以访问它?它应该存在多久?
这几个问题分别牵扯到类型、作用域和生命周期。
声明、定义和初始化不是一回事
这三个词经常混在一起用,日常聊天时问题不大,但学习 C 时最好分清楚。
声明
声明是在告诉编译器:
有这么一个名字,它具有某种类型。
例如:
extern int score;
这句话告诉当前文件,某处存在一个叫 score 的 int。它没有要求编译器现在就为 score 分配空间,只是先提供了足够的信息,让后面的代码能够使用这个名字。
函数也可以只有声明:
int add(int a, int b);
这告诉编译器,add 是一个函数,接收两个 int,返回一个 int。至于函数具体怎么做加法,可以稍后再说,甚至可以写在另一个 .c 文件里。
定义
定义则是在说:
这个东西本体就在这里。
int score;
这是 score 的定义。程序需要真的为它准备存储位置。
函数带上函数体以后,也是定义:
int add(int a, int b) {
return a + b;
}
一个名字可以被声明多次,但通常只能有一个定义。你可以在许多文件里写 extern int score;,告诉它们 score 存在;但如果到处都放一个真正的 score,链接器就会发现它不知道该用哪一个。
初始化
初始化是在一个对象刚开始存在时,给它一个初始值:
int score = 100;
这行代码既定义了 score,也初始化了它。
初始化和赋值看起来很像,但不是同一个动作:
int x = 42; // 初始化
x = 100; // 赋值
初始化发生在 x 的生命刚开始时。赋值则是 x 已经存在以后,把一个新值写进去。
对于 int 来说,这个区别暂时似乎无所谓。但到了 const、结构体和 C++ 里,它会变得越来越重要。即使现在,我们也已经能看到一个实际区别:
void f(void) {
int x;
int y = 0;
}
这里的局部变量 x 没有被初始化。不要想当然地认为它是 0。它占据的那几个字节里原来有什么,现在就可能是什么。直接读取这种未确定的值会让程序落入未定义行为。
而 y 明确从 0 开始。
C 不会因为你忘了初始化,就每次都体贴地帮你清空内存。清空内存需要工作,而 C 的习惯是:如果你没有要求它做,它就尽量不替你做。
大括号不只是在整理代码
看看这个例子:
int main(void) {
int x = 1;
if (x > 0) {
int y = 2;
x = x + y;
}
return x;
}
x 可以从它的声明处一直使用到 main 外层大括号结束。y 则只能在 if 的那对大括号里使用。
出了大括号以后再写:
return y;
编译器会报错,因为这里已经不在 y 的作用域内了。
作用域回答的是:在源码的什么地方,这个名字可见?
注意,是名字可见,不是那几个字节可见。作用域是编译器看源码时遵守的规则,不是一堵真的修建在内存里的墙。
你甚至可以在内层作用域里重新声明一个同名变量:
int x = 1;
{
int x = 2;
// 这里的 x 是里面这个 x
}
// 这里的 x 又是外面那个 x
这两个 x 是两个不同的对象,只是恰好重名。内层的名字把外层的名字遮住了。
可以这样想:两户人家都把孩子叫小明并不会让两个孩子变成同一个人。名字需要放在一个范围里才有意义。
作用域和生命周期也不是一回事
作用域回答“这个名字在哪里能写”,生命周期回答“这个对象在什么时候还存在”。
对于普通局部变量,两者常常看起来差不多:
void f(void) {
int x = 42;
}
调用 f 时,x 开始存在;f 返回时,x 的生命周期结束。此后那块内存可能很快被下一次函数调用拿去放别的东西。
但下面这个变量不同:
void count(void) {
static int n = 0;
n++;
}
n 的名字仍然只能在 count 里面使用,但它的生命周期贯穿整个程序。函数返回以后,它的值不会消失。下一次调用 count 时,拿到的还是之前那个 n。
所以:
- 作用域限制名字
- 生命周期限制对象
把这两个概念混在一起,是许多指针错误的起点。
C 只有传值
现在来看函数。
void add_one(int x) {
x = x + 1;
}
int main(void) {
int n = 10;
add_one(n);
return n;
}
最后 n 仍然是 10。
调用 add_one(n) 时,函数得到的是 n 当前值的一份副本。我们可以把它粗略画成:
main 里的 n: 10
add_one 里的 x: 10
修改 x 只是修改副本,不会穿过时空去修改 n。
C 的函数参数全部是传值。所谓“传指针”也没有打破这条规则,因为传进去的仍然只是一个地址值的副本。
void add_one(int *p) {
*p = *p + 1;
}
int main(void) {
int n = 10;
add_one(&n);
return n;
}
这一次 n 变成了 11。
发生的事情是:
n 的地址: 1000
p 里面的值: 1000
p 自己依然是一份副本。你在 add_one 里让 p 指向别处,不会改变调用者手中的任何指针。但是 p 和调用者都知道地址 1000,于是 *p 可以修改地址 1000 里的 n。
这很像把你家的地址抄在纸上交给别人。对方涂改这张纸,不会修改你手里的原件;但他可以按照纸上的地址找到你家,然后把你家的门刷成绿色。
因此,C 中常被叫作“传引用”的写法,本质上通常是:把地址按值传进去,再通过地址修改原对象。
数组参数为什么特别容易骗人
上一篇说过,数组传进函数以后,函数通常只拿到首元素地址。现在可以写得更准确一点。
下面三个函数参数在这里表达的是同一种东西:
void clear(int a[10]);
void clear(int a[]);
void clear(int *a);
作为函数参数时,前两种写法都会被调整为 int *a。10 并没有跟着数组一起传进函数。
于是这段代码很危险:
void clear(int a[]) {
for (int i = 0; i < 10; i++) {
a[i] = 0;
}
}
函数只是盲目相信调用者传进来的数组至少有 10 个元素。如果实际只有 3 个,它照样会继续往后写,把别的内存一起清掉。
更正常的写法是把长度也传进来:
void clear(int *a, size_t length) {
for (size_t i = 0; i < length; i++) {
a[i] = 0;
}
}
地址回答“从哪里开始”,长度回答“什么时候停”。这两个值共同描述了一个数组视图。只传其中一个,信息就是残缺的。
顺便看一个经典陷阱:
void print_size(int a[]) {
printf("%zu\n", sizeof(a));
}
这里的 sizeof(a) 得到的是指针的大小,而不是原数组的大小。因为进入参数列表以后,a 已经是指针了。
而在数组真正定义的作用域里:
int a[10];
size_t length = sizeof(a) / sizeof(a[0]);
sizeof(a) 才是整个数组占用的字节数。这个公式能算出 10,是因为编译器在这里仍然知道 a 是一个完整数组。
NULL 不是什么都没有
指针应该指向一个有效对象。但有时候,我们确实需要表达“现在没有指向任何东西”。C 通常用空指针来表达这个状态:
int *p = NULL;
NULL 不是一个位于虚空中的对象,也不是一块可以访问的特殊内存。它是一个空指针常量,用来生成“不指向任何有效对象”的指针值。
所以你可以检查它:
if (p != NULL) {
*p = 42;
}
但不能解引用它:
*p = 42;
如果 p 是空指针,这仍然是未定义行为。常见的结果是程序崩溃,但 C 标准并没有承诺它一定会用一种清楚、稳定、方便调试的方式崩溃。
空指针的价值在于,它明确表示“没有”。这比让一个未初始化的指针装着来历不明的地址好得多:
int *bad;
int *better = NULL;
bad 可能碰巧指向任何地方,而 better 至少能被可靠地检查。
当然,写了 NULL 不代表程序就自动安全了。你仍然需要在解引用前判断,也需要约定到底由谁负责让这个指针指向一个有效对象。
比空指针更阴险的是悬空指针
看起来不为 NULL 的指针,也不一定有效。
int *wrong(void) {
int x = 42;
return &x;
}
这个函数返回了局部变量 x 的地址。但函数返回的同时,x 的生命周期结束了。
地址这个数字可能仍然是原来的数字,甚至那块内存里暂时还保留着 42。但你已经没有权利再把它当成那个 x 来访问。
int *p = wrong();
printf("%d\n", *p); // 未定义行为
这种指向已经结束生命周期的对象的指针,叫作悬空指针。
它比 NULL 更危险,因为它看起来很正常。它有一个非零地址,调试器里甚至可能还能看到期待的值。有时程序会正确运行十次,到第十一次才突然坏掉;有时开启编译优化以后才坏;有时加一行日志,问题反而消失了。
这不是内存闹鬼,而是程序已经不再受到语言规则的保护。那块位置可以被重新利用,编译器也可以假设你不会访问已经不存在的对象。
上一篇留下的问题是:一块内存在哪里,里面是什么,谁负责使用它,谁负责结束它。
悬空指针正是最后一个问题没有回答好:对象已经结束了,地址却还在流传。
把这一节压缩成一张图
源码中的名字
|
| 类型告诉编译器如何解释
v
一个对象 --------------------> 一块存储
| ^
| & 取得地址 | * 按地址访问
v |
一个指针 --------------------------+
作用域:名字在哪里可见
生命周期:对象什么时候存在
声明让编译器先认识一个名字,定义让对象真正出现,初始化给刚出现的对象一个起始值。
函数参数永远按值传递。传入普通值,函数修改的是副本;传入地址值,函数仍然修改的是地址的副本,但可以顺着这个地址找到调用者的对象。
指针本身只是地址。它不会自动携带数组长度,不会告诉你对象是否还活着,也不会保证自己一定能被解引用。NULL 明确表示没有对象,而悬空指针则拿着一个已经失效的地址假装一切正常。
这正是 C 的核心气质:它给你的信息,通常就是机器真正需要的那一点信息。其余部分并没有消失,只是变成了程序员需要维护的约定。
下一节就该继续追问了:局部变量到底放在哪里?函数返回以后,为什么它会失效?如果我们希望一块内存活得比一次函数调用更久,又该怎么办?
这会把我们带到栈、堆和 malloc。