从指针开始讲起的 C 语言教程 1


上一篇从 Brainfuck 开始,绕了一大圈内存、地址、数组和循环。现在终于可以开始学习正常人会写的 C 语言了。

不过,我们还是不从 Hello, world! 开始。

printf 不是 C 语言本身,而是标准库提供的函数。要解释它,还得先解释头文件、字符串、函数声明、链接和可变参数。拿它当第一行代码当然很有仪式感,但它实际上偷偷欠下了一大堆债。

所以这一节先看一个更无聊的东西:变量。

变量不是一个盒子

许多教程会说,变量就像一个盒子,盒子上写着名字,里面装着值。

这个比喻不能说错。刚开始学的时候,它也确实很好用。但是学 C 时,我们最好尽早把这个盒子拆开。

int x = 42;

这行代码至少告诉了编译器四件事:

  • x 是这个东西的名字
  • int 是解释这块内存的方式
  • 需要为它留出足以容纳一个 int 的空间
  • 一开始把 42 写进这块空间

程序运行时并不会随身携带一个写着 x 的小标签。编译完成以后,x 可能位于某个寄存器,也可能位于栈上的某个位置,甚至可能因为编译器发现它毫无用处而彻底消失。

变量是源码里的概念。内存和寄存器才是机器运行时真正操作的东西。

但这并不意味着变量名只是无关紧要的装饰。它帮助编译器和人回答几个关键问题:这串比特应该被当成什么?在哪里可以访问它?它应该存在多久?

这几个问题分别牵扯到类型、作用域和生命周期。

声明、定义和初始化不是一回事

这三个词经常混在一起用,日常聊天时问题不大,但学习 C 时最好分清楚。

声明

声明是在告诉编译器:

有这么一个名字,它具有某种类型。

例如:

extern int score;

这句话告诉当前文件,某处存在一个叫 scoreint。它没有要求编译器现在就为 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 *a10 并没有跟着数组一起传进函数。

于是这段代码很危险:

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