从指针开始讲起的 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:一块内存,里面放着整数 42p:另一块内存,里面放着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";
它在内存里不是只有 h 和 i,而是:
'h' 'i' '\0'
strlen 的做法非常粗暴:从字符串开头一路往后找,直到遇到 \0 为止。所以它不是凭空知道了数组长度,而是字符串这种数据格式自己在末尾放了一个路标。
这也是 C 的风格:语言本身给你的东西很少,很多规则都靠约定。约定很强大,也很危险。你遵守约定,它就很快;你破坏约定,它也不会温柔地拦住你。
循环到底是什么
Brainfuck 里最值得看的不是 + 和 >,而是 [ 和 ]。
这两个符号看起来很诡异,其实它们表达的是最朴素的循环:
[ 如果当前格子为 0,就跳出循环
] 如果当前格子不为 0,就回到循环开头
也就是说:
+++[>+<-]
可以理解成:
- 当前格子先加到 4
- 只要当前格子不是 0,就进入循环
- 指针右移,把右边格子加一
- 指针左移,把左边格子减一
- 左边格子还不是 0,就继续重复
如果一开始是:
[4][0]
^
执行完以后会变成:
[0][4]
^
这个程序干了什么?它把左边格子的数字搬到了右边。
当然,它不是「复制」。复制的话,左边还应该留下 4。这里是搬运,因为每次右边加一,左边就减一。这个细节很重要,因为它说明了计算并不是什么神秘的东西。很多复杂操作本质上都是一堆非常无聊的小操作堆出来的。
如果用 C 写,大概就是:
while (*p != 0) {
p++;
(*p)++;
p--;
(*p)--;
}
这段代码不是为了让你现在就学会写 C,而是为了让你看到:所谓循环,其实就是「检查某个值,根据结果决定要不要跳回去」。
所以,while、for、if 这些东西,看起来比 Brainfuck 的 [ ] 高级很多,但底层都绕不开同一个问题:根据一个值,决定下一条指令去哪。
这就是前面说的三件事之一:跳转。
if 也不是魔法
很多人学编程时会把 if 当成理所当然的东西:
if (x > 0) {
// do something
}
但是计算机不会理解「如果」这种人类语言。它只会做比较,然后跳转。
上面的代码大概可以被理解成:
比较 x 是否大于 0
如果不是,就跳过大括号里的代码
如果是,就继续执行大括号里的代码
所以 if 的本质也是跳转。while 只是跳回去的 if。for 也只是把初始化、条件判断、每轮结束后的操作包装在一起。
比如:
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 的难点不在语法。while、if、int *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 语言很少帮你记录额外信息,长度、所有权、生命周期这些东西经常要靠人来维护
如果这些东西先建立起来,后面再看 malloc、sizeof、数组传参、结构体、函数指针,就不会觉得它们是互不相关的怪东西。它们其实都围绕同一个问题展开:
一块内存在哪里,里面是什么,谁负责使用它,谁负责结束它。
下一节再正式进入 C 语言本身。我们会从最普通的变量开始,但脑子里要一直记住:变量只是内存的名字,而 C 语言的许多语法,都是围绕内存这件事展开的。