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


前言(若无基础,请跳过)

C 语言是一门十分简洁的语言,而且很容易学习。但是,它并不是一门纯粹意义上的 “高级语言”。在 C 语言中,你所写的大部分代码,都可以很容易地被人脑翻译成汇编语言。可以说,C语言只是汇编语言的一层「薄薄地抽象」。

C 语言只有 32 个关键字,却被认为比 Python 等高级语言更难学,这是因为 simplicity 和 complexity 的概念是不同的。

用 C 语言,就基本相当于直接操纵计算机本身。虽然这并不容易,但是它非常 简洁。毕竟,任何计算过程中你唯一能干的事情只有读写内存,运算,跳转这三件事,C 语言只不过是把这三件事给浅浅地包装了一下。

而用 Python 时,常常是跟一堆黑盒子打交道,它们把所有的底层细节封装了起来,你只需要搞懂怎么按这个黑盒子上的按钮就可以了。它很 复杂,但是使用它很简单。

这种区别并不十分 impressive。它有点像高中物理中「公式」和「模型」的区别。你可以背一堆公式,然后遇到题就往里面套;也可以理解牛顿定律到底在说什么,然后自己把公式推出来。前者上手快,后者一开始慢,但是后者会让你在面对陌生问题时更不容易变成傻子。

因为我小时候学习的第一门语言是 Python,之后也学习了 Java, Haskell 和 C# 语言,因此,我在两三年前刚开始学习 C 语言的时候,对很多东西十分陌生。

为什么在 C 语言中,往函数中传数组时,我们之后就没法获取数组的长度了?而对同样是数组类型,对字符串,我们就可以用 strlen 获取到它的长度?malloc 到底有什么用?既然在内存中都是数字,那 C 语言的类型又是做什么的?typedef 声明指针的方式为何如此诡异?C 就不能面向对象吗?为什么 #include <stdio.h> 后能直接用里面的函数,但是 #include "SDL.h" 之后编译报错,难道这东西和 JS 的 import 不一样吗?malloc 是干什么的?sizeof “函数” 是干什么的?有人说数组就是指针?inline 内联速度更快?传值和传引用分别是什么意思?

这些东西当时困扰了我很久,其中许多的内容有经验的人可能会觉得十分好笑,但是对于一个初学者,这些问题是实实在在会令人迷惑的。一旦我们自己知道某样东西,我们就会发现很难想象不知道它的时候会是什么样子。

学过 C 之后,你就能 降维打击 一般地窥见其他任何非函数式编程语言的设计意图。例如 Java 中的装箱,C++ 的 RAII 都是为了什么而服务的。因为你在用底层的计算机的语言去思考。这就像有的学渣会去死记硬背化学方程式,而你掌握了底层的电子转移之类的原理。

因此,我打算用另一种方式。第一课先不讲 hello world ,不讲标准库中的乱七八糟。直接从计算机的原理讲起。

从 Brainfuck 开始

图灵完备性

基于冯诺伊曼架构的,也就是我们现在日常使用的计算机,运行的本源逻辑,都与名为「图灵机」的假想机器相同。

设想有一条极长的纸带。

纸带被分成一个个小格子,每个格子里都可以写一个符号。机器上有一个读写头,它一次只能看见当前所在的那个格子。它能做的事情也很有限:

  • 读取当前格子里的值
  • 改写当前格子里的值
  • 向左或者向右移动
  • 根据当前状态决定下一步干什么

你可能会觉得这东西太弱智了。一个格子,一个读写头,左移右移,改一个值,这能干什么?

答案是:只要纸带足够长,规则设计得足够好,它可以表达任何可以被计算的过程。

这就是所谓的图灵完备性。它并不神秘,它说的不是「这个东西很高级」,而是「这个东西已经拥有了计算所需要的最小能力」。能记东西,能改东西,能根据条件跳转,能重复执行,这就够了。

现代计算机当然不是拿纸带在桌子上爬。但是如果我们把纸带换成内存,把读写头换成地址,把格子里的符号换成字节,事情就开始熟悉起来了。

Brainfuck 是一台小得离谱的计算机

Brainfuck 是一门故意设计得很反人类的编程语言。它只有 8 个指令:

>  指针右移
<  指针左移
+  当前格子的值加一
-  当前格子的值减一
.  输出当前格子的值
,  读入一个字符到当前格子
[  如果当前格子为 0,跳到对应的 ] 后面
]  如果当前格子不为 0,跳回对应的 [ 后面

这门语言乍一看像精神污染。比如最简单的加法,都要写成移动指针、加减格子、循环搬运数字。但是它很适合拿来做 C 语言教程的开头,因为它把计算机最本质的东西暴露得非常干净。

Brainfuck 的世界里有一条数组:

[0][0][0][0][0][0][0][0]...
 ^

这个 ^ 就是当前指针。执行 >,它就往右走一格:

[0][0][0][0][0][0][0][0]...
    ^

执行 +,当前格子的值就加一:

[0][1][0][0][0][0][0][0]...
    ^

这已经很接近 C 的精神了。

C 里面的指针,本质上也就是这样一个东西:它记录着「我现在指向内存中的哪一个位置」。所谓 p++,很多时候就可以粗略理解成 Brainfuck 里的 >。所谓 *p = *p + 1,就可以粗略理解成 Brainfuck 里的 +

当然,真实的 C 比这个复杂。C 的指针不是每次只移动一个字节,而是会根据指向类型的大小移动。例如:

int *p;
p++;

这里的 p++ 不是地址加 1,而是移动到下一个 int 的位置。如果一个 int 占 4 个字节,那么地址实际上会加 4。

这就是类型的第一个作用:告诉编译器如何解释同一片内存。

内存里没有变量,只有数字

初学者很容易以为计算机里真的有一个叫 x 的东西。

int x = 42;

但在机器看来,x 这个名字只存在于编译器和人的脑子里。程序真正运行起来以后,它面对的是一块内存中的若干个字节。int x = 42 的意思大概是:

找一块足够放 int 的地方,把 42 按照 int 的格式写进去,以后在源码里看到 x,就把它翻译成那块地方。

所以变量名不是数据本身。变量名只是给某个内存位置起的名字。

指针则更直接一点。它不是给内存位置起名字,而是把「某个内存位置的地址」本身当成一个值存起来。

int x = 42;
int *p = &x;

这里有两个东西:

  • x:一块内存,里面放着整数 42
  • p:另一块内存,里面放着 x 的地址

&x 的意思是「取出 x 的地址」。*p 的意思是「去 p 里面存着的那个地址,访问那里的东西」。

如果把它画成 Brainfuck 那种纸带,大概是:

地址:  1000      1004
内容:  42        1000
名字:  x         p

于是:

*p = 100;

不是在修改 p 本身,而是在修改 p 所指向的地方。因为 p 里存着 1000,所以 *p = 100 的实际效果是把地址 1000 那里的内容改成 100。也就是说,x 变成了 100。

这就是很多 C 初学者第一次被指针创飞的地方:变量本身可以存值,变量也可以存地址;你可以改这个地址变量本身,也可以顺着这个地址去改它指向的东西。这两件事完全不同。

数组为什么像指针

现在回头看一个非常著名的说法:

数组就是指针。

这句话不严谨,而且会害人。但是它之所以流传很广,是因为在很多表达式里,数组名确实会退化成指向首元素的指针。

例如:

int a[3] = {10, 20, 30};

内存里大概是连续的三个 int

地址:  1000  1004  1008
内容:  10    20    30
名字:  a[0]  a[1]  a[2]

当你写:

a[1]

编译器大致可以把它理解成:

*(a + 1)

也就是:从数组开头的地址出发,移动一个 int 的距离,然后取出那里的值。

所以 a[i] 不是某种魔法。它就是「地址计算 + 解引用」的语法糖。

这也解释了为什么把数组传进函数以后,函数里通常不知道数组长度:

void f(int a[]) {
	// 这里的 a 实际上更像 int *a
}

函数拿到的只是一个地址。地址只告诉你「从哪里开始」,不告诉你「到哪里结束」。这就像别人告诉你某本书从第 37 页开始有一篇文章,但没告诉你这篇文章有几页。你当然能从第 37 页开始读,但你不知道什么时候该停。

字符串为什么又可以用 strlen 呢?

因为 C 字符串自己约定了一个结束标记:\0

char s[] = "hi";

它在内存里不是只有 hi,而是:

'h' 'i' '\0'

strlen 的做法非常粗暴:从字符串开头一路往后找,直到遇到 \0 为止。所以它不是凭空知道了数组长度,而是字符串这种数据格式自己在末尾放了一个路标。

这也是 C 的风格:语言本身给你的东西很少,很多规则都靠约定。约定很强大,也很危险。你遵守约定,它就很快;你破坏约定,它也不会温柔地拦住你。

循环到底是什么

Brainfuck 里最值得看的不是 +>,而是 []

这两个符号看起来很诡异,其实它们表达的是最朴素的循环:

[  如果当前格子为 0,就跳出循环
]  如果当前格子不为 0,就回到循环开头

也就是说:

+++[>+<-]

可以理解成:

  1. 当前格子先加到 4
  2. 只要当前格子不是 0,就进入循环
  3. 指针右移,把右边格子加一
  4. 指针左移,把左边格子减一
  5. 左边格子还不是 0,就继续重复

如果一开始是:

[4][0]
 ^

执行完以后会变成:

[0][4]
 ^

这个程序干了什么?它把左边格子的数字搬到了右边。

当然,它不是「复制」。复制的话,左边还应该留下 4。这里是搬运,因为每次右边加一,左边就减一。这个细节很重要,因为它说明了计算并不是什么神秘的东西。很多复杂操作本质上都是一堆非常无聊的小操作堆出来的。

如果用 C 写,大概就是:

while (*p != 0) {
	p++;
	(*p)++;
	p--;
	(*p)--;
}

这段代码不是为了让你现在就学会写 C,而是为了让你看到:所谓循环,其实就是「检查某个值,根据结果决定要不要跳回去」。

所以,whileforif 这些东西,看起来比 Brainfuck 的 [ ] 高级很多,但底层都绕不开同一个问题:根据一个值,决定下一条指令去哪。

这就是前面说的三件事之一:跳转。

if 也不是魔法

很多人学编程时会把 if 当成理所当然的东西:

if (x > 0) {
	// do something
}

但是计算机不会理解「如果」这种人类语言。它只会做比较,然后跳转。

上面的代码大概可以被理解成:

比较 x 是否大于 0
如果不是,就跳过大括号里的代码
如果是,就继续执行大括号里的代码

所以 if 的本质也是跳转。while 只是跳回去的 iffor 也只是把初始化、条件判断、每轮结束后的操作包装在一起。

比如:

for (int i = 0; i < 10; i++) {
	// do something
}

大概等价于:

int i = 0;
while (i < 10) {
	// do something
	i++;
}

再往下看,while 又不过是:

检查条件
条件不成立就跳到循环后面
执行循环体
跳回去重新检查条件

所以,程序控制流的本质并没有很多种。无非是顺着往下走,或者跳到别的地方去。结构化语法只是让这些跳转看起来不那么像意大利面。

指针、数组和循环合在一起

现在我们可以写一个非常 C 风格的例子:

int a[5] = {1, 2, 3, 4, 5};
int *p = a;

while (p < a + 5) {
	*p = *p + 1;
	p++;
}

这段代码的意思是:从数组开头开始,每个元素加一,直到走到数组末尾。

如果你已经理解了前面 Brainfuck 的模型,这段代码其实不难:

  • p = a:让指针指向数组第一个元素
  • *p = *p + 1:修改当前指针指向的格子
  • p++:移动到下一个格子
  • p < a + 5:判断有没有走到终点

这基本就是 Brainfuck,只不过 C 让每个格子可以有类型,让指针移动时知道一步该走几个字节,让条件判断可以写成正常人能读的样子。

但注意,这段代码也暴露了 C 的危险性。

如果你把条件写成:

while (p <= a + 5) {
	*p = *p + 1;
	p++;
}

那就炸了。

因为 a + 5 指向的是数组最后一个元素后面的位置。它可以作为边界来比较,但不能被解引用。也就是说,p < a + 5 是对的,p <= a + 5 就会让循环最后访问到不属于这个数组的内存。

C 不会帮你检查这个错误。它不会像 Python 一样告诉你 index out of range。你访问了不该访问的内存,后果可能是程序崩溃,可能是数据被悄悄改坏,也可能是看起来什么都没发生。

这就是未定义行为。未定义行为的意思不是「编译器会随机做一件坏事」,而是「C 标准不再保证这段程序有什么意义」。从语言层面看,你已经掉出规则之外了。

所以 C 的难点不在语法。whileifint *p 这些东西很快就能背下来。真正难的是你必须始终知道自己正在操作哪一块内存,边界在哪里,当前值到底是数据还是地址。

sizeof 在这个模型里是什么

顺便提前讲一下 sizeof。很多初学者以为它是函数,因为它长得像:

sizeof(int)

但它不是普通函数。更准确地说,它是一个编译期运算符。它问的是:

这个类型或者这个对象,在内存里要占多少字节?

比如:

sizeof(char)
sizeof(int)
sizeof(int *)

sizeof(char) 按标准永远是 1。注意,这里的 1 不是一个 bit,而是一个 byte。sizeof(int) 通常是 4,但标准并没有要求所有机器上都必须是 4。sizeof(int *) 是一个指针的大小,在 64 位机器上通常是 8。

这也解释了为什么指针类型很微妙。

int *char * 作为地址本身,大小可能一样,因为它们都只是存一个地址。但是它们指向的东西不同,所以 p++ 时移动的距离不同:

int *ip;
char *cp;

ip++; // 通常移动 4 个字节
cp++; // 移动 1 个字节

这说明类型并不一定改变内存里的数字本身。很多时候,类型改变的是编译器解释这些数字的方式。

同样一串字节,你可以把它当整数看,可以把它当字符看,也可以把它当地址看。C 给了你这种能力,也把因此产生的危险一起交给了你。

第一课真正要记住的东西

这一节并不是为了让你学会 Brainfuck。正常人也不会用 Brainfuck 写项目。

它真正想说明的是:

  • 程序运行时面对的是内存,而不是变量名
  • 指针是地址,解引用是顺着地址去访问内容
  • 数组访问本质上是地址计算
  • 类型告诉编译器如何解释和移动地址
  • C 语言很少帮你记录额外信息,长度、所有权、生命周期这些东西经常要靠人来维护

如果这些东西先建立起来,后面再看 mallocsizeof、数组传参、结构体、函数指针,就不会觉得它们是互不相关的怪东西。它们其实都围绕同一个问题展开:

一块内存在哪里,里面是什么,谁负责使用它,谁负责结束它。

下一节再正式进入 C 语言本身。我们会从最普通的变量开始,但脑子里要一直记住:变量只是内存的名字,而 C 语言的许多语法,都是围绕内存这件事展开的。