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