C 指针有害内存健康!? - # cd /
每一盒香烟的包装上都会写『吸烟有害健康』。白酒瓶上也写了『过度饮酒,有害健康』。本文的外包装上写的则是『阅读有害健康』。
劝学
很多人对 C 语言深恶痛绝,仅仅是因为 C 语言迫使他们在编程中必须手动分配与释放内存,在这个过程中,稍有不慎可能就会导致程序运行运行时出现内存泄漏。与 C/C++ 这种长期依靠手动管理内存的语言相比(C++ 这个叛徒现在是提倡智能指针了),Java、C# 以及那些运行于解释器上的动态类型语言均提供了内存自动回收的运行时环境,因此很多人认为如果革了前者的命,热情的拥抱后者,他们就能够快速步入按需分配的共产主义社会。
大致有两类人不喜欢手动管理程序内存,一类是非常擅长此事,另一类是非常不擅长此事。擅长此事者,因为在编程中有关内存分配、释放与调试方面的代码写的太多而倍感乏味,并且项目开发进度也在一定程度上被延缓。不擅长此事者,则是经常被代码中接连不断的内存错误吓哭。于是,他们都很高兴的拥抱了提供内存回收的编程语言了……
结果也是显而易见的,一群苦大仇深经历过斗过低主爬过雪山走过草地八年抗战三年内战的人,与一群曾被 C/C++ 指针与内存管理吓走了的投机主义者,他们在虽然身处同一个屋檐下,但是人材与庸材依然是泾渭分明的——前者深知那些提供内存回收功能的编程语言依然可能会出现内存泄漏问题,他们也能在代码采用一些手段规避这些问题;后者则继续被吓哭,并且再没退路了。
管理程序的内存资源是每个编程者的义务,尽管你可能大多数时间不需要履行这种义务。这有点像服兵役。虽然我们大部分人从未服过兵役,但是不可否认这是每个公民的义务,而且你之所以不用去服兵役,是因为别人替你服役了。当国家要求你必须服兵役时,你就不可推脱。当有一天你被迫履行管理内存资源的义务时,为了不至于被吓哭,事先掌握 C 内存手动管理的一般方法,也不见得是一件多么糟糕的事情,就当成野外生存技能来学习吧。
C 内存的分配与回收
内存泄漏只会发生在程序所用的堆空间内,因为程序只能在堆空间内分配内存。
除了堆空间,程序还有个一半而言比较小的栈空间。这个空间是所有的函数共享的,每个函数在运行时会独占这个空间。栈空间的大小是固定的,它是留给函数的参数与局部变量用的。栈空间有点像宾馆,你下榻后,即使将房间搞的一团糟,也不需要你去收拾它,除非你把房间很严重的损坏了——用 C 的黑话来说,即缓冲区溢出。
C 语言标准库(stdlib)中为堆空间中的内存分配与回收提供了 malloc
与 free
函数。例如,在下面的代码中,我们从堆空间中分配了 7 个字节大小的空间,然后又释放了:
#include <stdlib.h>void *p = malloc(7);free(p);
一点都不难!跟你去学校图书馆借了 7 本书,然后又还回去没什么两样。有借有还,再借不难,过期不还,就要罚款。有谁因为去图书馆借几本书就被吓哭了的?
我们也可以向堆空间借点地方存储某种类型的数据:
int *n = malloc(4);*n = 7;free(n);
如果你不知道 int
类型的数据需要多大的空间才能装下,那就用 sizeof
,让 C 编译器去帮助你计算,即:
int *n = malloc(sizeof(int));*n = 7;free(n);
策略与机制分离
在 C 语言中有关内存管理的机制已经简单到了几乎无法再简单的程度了,那么为何那么多人都在嘲笑讥讽挖苦痛骂诅咒 C 的内存管理呢?
如果你略微懂得一些来自 Unix 的哲学,可能听说过这么一句话:策略与机制分离。如果没听说过这句话,建议阅读 Eric Raymond 写的《Unix 编程艺术》第一章中的 Unix 哲学部分。
malloc
与 free
是 C 提供的内存管理机制,至于你怎么去使用这个机制,那与 C 没有直接关系。例如,你可以手动使用 malloc
与 free
来管理内存——最简单的策略,你也可以实现一种略微复杂一点的基于引用计数的内存管理策略,还可以基于 Lisp 之父 John McCarthy 独创的 Mark&Sweep 算法实现一种保守的内存自动回收策略,还可以将引用计数与 Mark&Sweep 这两种策略结合起来实现内存自动回收。总之,这些策略都可以在 C 的内存管理机制上实现。例如,借助 Boehm GC 库,就可以在 C 程序中实现垃圾内存的自动回收:
#include <assert.h>#include <stdio.h>#include <gc.h>int main(void){ GC_INIT(); for (int i = 0; i < 10000000; ++i) { int **p = GC_MALLOC(sizeof(int *)); int *q = GC_MALLOC_ATOMIC(sizeof(int)); assert(*p == 0); *p = GC_REALLOC(q, 2 * sizeof(int)); if (i % 100000 == 0)printf("Heap size = %zu\n", GC_get_heap_size()); } return 0;}
如果你的系统(Linux)中安装了 boehm-gc 库(很微型,刚 100 多 Kb),可以用 gcc 编译这个程序然后运行一次体验一下,编译命令如下:
$ gcc -lgc test-gc.c
GNU 的 Scheme 解释器 Guile 2.0 就是用的 boehm-gc 来实现内存回收的。
如果 C 语言直接提供了某种内存管理策略,无论是提供引用计数还是 Mark&Sweep 抑或这二者的结合体,那么都是在剥夺其他策略生存的机会。例如,在 Java、C# 以及动态类型语言中,你很难再实现一种新的内存管理策略了——例如手动分配与释放这种策略。
Eric Raymond 说,将策略与机制揉在一起会导致有两个问题,(1) 策略会变得死板,难以适应用户需求的改变;(2) 任何策略的改变都极有可能动摇机制。相反,如果将二者剥离,就可以在探索新策略的时候不会破坏机制,并且还检验了机制的稳定性与有效性。
Unix 的哲学与 C 有何相干?不仅是有何相干,而且是息息相关!因为 C 与 Unix 是鸡生蛋 & 蛋生鸡的关系——Unix 是用 C 语言开发的,而 C 语言在 Unix 的开发过程中逐渐成熟。C 语言只提供机制,不提供策略,也正因为如此才后招致那些贪心的人的鄙薄。
这么多年来,像 C 语言提供的这种 malloc
+ free
的内存管理机制一直都没有什么变化,而计算机科学家们提出的内存管理策略在数量上可能会非常惊人。像 C++ 11 的智能指针与 Java 的 GC 技术,如果从研究的角度来看,可能它们已经属于陈旧的内存回收策略了。因为它们的缺点早就暴露了出来,相应的改进方案肯定不止一种被提了出来,而且其中肯定会有一些策略是基于概率算法的……那些孜孜不倦到处寻找问题的计算机科学家们,怎能错过这种可以打怪升级赚经费的好机会?
总之,C 经向你提供了健全的内存管理机制,它并没有限制你使用它实现一种新的内存管理策略。
手动管理内存的常见陷阱
在编写 C 程序时,手动管理内存只有一个基本原则是:谁需要,谁分配;谁最后使用,谁负责释放。这里的『谁』,指的是函数。也就是说,我们有义务全程跟踪某块被分配的堆空间的生命周期,稍有疏忽可能就会导致内存泄漏或内存被重复释放等问题。
那些在函数内部作为局部变量使用的堆空间比较容易管理,只要在函数结尾部分稍微留心将其释放即可。一个函数写完后,首先检查一下所分配的堆空间是否被正确释放,这个习惯很好养成。这种简单的事其实根本不用劳烦那些复杂的内存回收策略。
C 程序内存管理的复杂之处在于在某个函数中分配的堆空间可能会一路辗转穿过七八个函数,最后又忘记将其释放,或者本来是希望在第 7 个函数中访问这块堆空间的,结果却在第 3 个函数中将其释放了。尽管这样的场景一般不会出现(根据快递公司丢包的概率,这种堆空间传递失误的概率大概有 0.001),但是一旦出现,就够你抓狂一回的了。没什么好方法,惟有提高自身修养。
堆空间数据在多个函数中传递,这种情况往往出现于面向对象编程范式。例如在 C++ 程序中,对象会作为一种穿着隐行衣的数据——this
指针的方式穿过对象的所有方法(类的成员函数),像穿糖葫芦一样。不过,由于 C++ 类专门为对象生命终结专门设立了析构函数,只要这个析构函数没被触发,那么这个对象在穿过它的方法时,一般不会出问题。因为 this
指针是隐藏的,也没人会神经错乱在对象的某个方法中去 delete this
。真正的陷阱往往出现在类的继承上。任何一个训练有素的 C++ 编程者都懂得什么时候动用虚析构函数,否则就会陷入用 delete
去释放引用了派生类对象的基类指针所导致的内存泄漏陷阱之中。
在面向对象编程范式中,还会出现对象之间彼此引用的现象。例如,如果对象 A 引用了对象 B,而对象 B 又引用了对象 A。如果这两个对象的析构函数都试图将各自所引用对象销毁,那么程序就会直接崩溃了。如果只是两个相邻的对象的相互引用,这也不难解决,但是如果 A 引用了 B,B 引用了 C, C 引用了 D, D 引用了 E,E 引用了 A……然后你可能就凌乱了。如果是基于引用计数来实现内存自动回收,遇到这种对象之间相互引用的情况,虽然那程序不会崩溃,但是会出现内存泄漏,除非借助弱引用来打破这种这种引用循环,本质上这只是变相的谁最后使用,谁负责释放。
函数式编程范式中,内存泄漏问题依然很容易出现,特别是在递归函数中,通常需要借助一种很别扭的思维将递归函数弄成尾递归形式才能解决这种问题。另外,惰性计算也可能会导致内存泄漏。
似乎并没有任何一种编程语言能够真正完美的解决内存泄漏问题——有人说 Rust 能解决,我不是很相信,但是显而易见,程序在设计上越低劣,就越容易导致内存错误。似乎只有通过大量实践,亡羊补牢,塞翁失马,卧薪尝胆,破釜沉舟,久而久之,等你三观正常了,不焦不躁了,明心见性了,内存错误这种癌症就会自动从你的 C 代码中消失了——好的设计品味,自然就是内存友好的。当我们达到这种境界时,可能就不会再介意在 C 中手动管理内存。
让 Valgrind 帮你养成 C 内存管理的好习惯
Linux 环境中有一个专门用于 C 程序内存错误检测工具——valgrind,其他操作系统上应该也有类似的工具。valgrind 能够发现程序中大部分内存错误——程序中使用了未初始化的内存,使用了已释放的内存,内存越界访问、内存覆盖以及内存泄漏等错误。
看下面这个来自『The Valgrind Quick Start Guide』的小例子:
#include <stdlib.h>void f(void){ int* x = malloc(10 * sizeof(int)); x[10] = 0;}int main(void){ f(); return 0;}
不难发现,在 f
函数中即存在这内存泄漏,又存在着内存越界访问。假设这份代码保存在 valgrind-demo.c
文件中,然后使用 gcc 编译它:
$ gcc -g -O0 valgrind-demo.c -o valgrind-demo
为了让 valgrind 能够更准确的给出程序内存错误信息,建议打开编译器的调试选项 -g
,并且禁止代码优化,即 -O0
。
然后用 valgrind 检查 valgrind-demo 程序:
$ valgrind --leak-check=yes ./valgrind-demo
结果 valgrind 输出以下信息:
==10000== Memcheck, a memory error detector==10000== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al.==10000== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info==10000== Command: ./valgrind-demo==10000== ==10000== Invalid write of size 4==10000== at 0x400574: f (valgrind-demo.c:6)==10000== by 0x400585: main (valgrind-demo.c:11)==10000== Address 0x51d3068 is 0 bytes after a block of size 40 alloc'd==10000== at 0x4C29FE0: malloc (in /usr/lib64/valgrind/vgpreload_memcheck-amd64-linux.so)==10000== by 0x400567: f (valgrind-demo.c:5)==10000== by 0x400585: main (valgrind-demo.c:11)==10000== ==10000== ==10000== HEAP SUMMARY:==10000== in use at exit: 40 bytes in 1 blocks==10000== total heap usage: 1 allocs, 0 frees, 40 bytes allocated==10000== ==10000== 40 bytes in 1 blocks are definitely lost in loss record 1 of 1==10000== at 0x4C29FE0: malloc (in /usr/lib64/valgrind/vgpreload_memcheck-amd64-linux.so)==10000== by 0x400567: f (valgrind-demo.c:5)==10000== by 0x400585: main (valgrind-demo.c:11)==10000== ==10000== LEAK SUMMARY:==10000== definitely lost: 40 bytes in 1 blocks==10000== indirectly lost: 0 bytes in 0 blocks==10000== possibly lost: 0 bytes in 0 blocks==10000== still reachable: 0 bytes in 0 blocks==10000== suppressed: 0 bytes in 0 blocks==10000== ==10000== For counts of detected and suppressed errors, rerun with: -v==10000== ERROR SUMMARY: 2 errors from 2 contexts (suppressed: 0 from 0)
valgrind 首先检测出在 valgrind-demo 程序中存在一处内存越界访问错误,即:
==10000== Invalid write of size 4==10000== at 0x400574: f (valgrind-demo.c:6)==10000== by 0x400585: main (valgrind-demo.c:11)==10000== Address 0x51d3068 is 0 bytes after a block of size 40 alloc'd==10000== at 0x4C29FE0: malloc (in /usr/lib64/valgrind/vgpreload_memcheck-amd64-linux.so)==10000== by 0x400567: f (valgrind-demo.c:5)==10000== by 0x400585: main (valgrind-demo.c:11)
然后 valgrind 又发现在 valgrind-demo 程序中存在 40 字节的内存泄漏,即:
10000== 40 bytes in 1 blocks are definitely lost in loss record 1 of 1==10000== at 0x4C29FE0: malloc (in /usr/lib64/valgrind/vgpreload_memcheck-amd64-linux.so)==10000== by 0x400567: f (valgrind-demo.c:5)==10000== by 0x400585: main (valgrind-demo.c:11)
由于我们在编译时开启了调试选项,所以 valgrind 也能告诉我们内存错误发生在具体哪一行源代码中。
除了可用于程序内存错误的检测之外,valgrind 也具有函数调用关系跟踪、程序缓冲区检查、多线程竞争检测等功能,但是无论 valgrind 有多么强大,你要做的是逐渐的摆脱它,永远也不要将自己的代码建立在『反正 valgrind 能帮我检查错误』这样的基础上。
甩掉强迫症
选择用 C 语言来写程序,这已经让我们牺牲了很多东西——项目进度、漂亮的桌面程序、炙手可热的网站前端……如果你再为 C 语言的一些『脆弱』之处患上强迫症,这样的人生太过于悲催。用 C 语言,就要对自己好一点。
负责分配内存的 malloc
函数可能会遇到内存分配失败的情况,这时它会返回 NULL
。于是,问题就来了,是否需要在程序中检测 malloc
的返回值是否为 NULL
?我觉得没必要检测,只需记住 malloc
可能会返回 NULL
这一事实即可。如果一个程序连内存空间都无法分配了,那么它还有什么再继续运行的必要?有时,可能会因为系统中进程存在内存泄漏,导致你的程序无法分配内存,这时你使用 malloc
返回的 NULL
指针来访问内存,会出现地址越界错误,这种错误很容易定位,并且由于你知道 malloc
可能会返回 NULL
这一事实,也很容易确定错误的原因,实在不济,还有 valgrind。
如果你被我说服了,决定不去检查 malloc
的返回值是否为 NULL
,那么又一个问题随之而来。我们是否需要在程序中检测一个指针是否为 NULL
?NULL
指针实在是太恐怖了,直接决定了程序的生死。为了安全起见,在用一个指针时检测一下它的值是否为 NULL
似乎非常有必要。特别是向一个函数传递指针,很多编程专家都建议在函数内部首先要检测指针参数是否为 NULL
,并将这种行为取名为『契约式编程』。之所以是契约式的,是因为这种检测已经假设了函数的调用者可能会传入 NULL
……事实上这种契约非常容易被破坏,例如:
void foo(int *p){ if(!p) { printf("You passed a NULL pointer!\n"); exit(-1); } ... ... ... }int main(void){ int *p; foo(p); return 0;}
当我将一个未初始化的指针传给 foo
函数时,foo
函数对参数的检测不会起到任何作用。可能你会辩解,说调用 foo
函数的人,应该先将 p
指针初始化为 NULL
。这种辩解有些自欺欺人。契约应当是双方彼此达成一致意见之后才能签署,而不是你单方面起草一个契约,然后要求他人必须遵守这个契约。本来调用 foo
的人是有义务向这个函数传递一个有效的指针的,如果他传递的是 NULL 或未初始化的指针,foo
理应就地崩溃,它不应该为用户传入无效的指针而买单,何况也根本无法买这个单——你能检测的了 NULL
,但是无法检测未初始化的指针或者被释放了的指针。
指针不应该受到不公正待遇。如果你不时常纠结程序中的整型数或浮点数是否溢出,那么也就不应该对指针是否为 NULL
那么重视,甚至不惜代价为其修建了万里长城。在 C 语言中,不需要指针的 NULL
契约,只需要遵守指针法律:你要传给我指针,就必须保证你的指针是有效的,否则我就用程序崩溃来惩罚你。
第三个问题依然与 NULL
有关,那就是一个指针所引用的内存空间被释放后,是否要将这个指针赋值为 NULL
?既然都已经放弃了检测指针是否为 NULL
,那么自然不需再纠结这个问题了。程序中之所以会出现野指针所引发的内存错误,本质原因不在于释放空间后未对指针赋以 NULL
,而在于拙劣的设计。一个指针所引用的空间已被释放,然而代码中又再次使用了这个指针来访问内存,这往往意味着你的代码已经出现了混乱。如果野指针被赋以 NULL
,再配合指针是否为 NULL
的检测,这样做固然可以很快的定位出错点,但是换来的经常是一个很脏的补丁式修正,坏的设计却得到了纵容。
绝对的不去检测指针是否为 NULL
肯定也不科学。因为有时 NULL
是作为状态来用的。例如在树结构中,可以根据任一结点中的子结点指针是否为 NULL
来判断这个结点是否为叶结点。有些函数通过返回 NULL
告诉调用者:『我可耻的失败了』。我觉得这才是 NULL
真正的用武之地。
王垠在『编程的智慧』一文中告诫大家,尽量不要让函数返回 NULL,他认为如果你的函数要返回『没有』或『出错了』之类的结果,尽量使用 Java 的异常机制。这种观点没有什么太大的错误,但是 C 没有异常机制,只有 NULL 可用。有些人形而上学强加附会的将这种观点解读为让函数返回 NULL
是有害的,甚至将这种行为视为『低级错误』,甚至认为 C 指针的存在本身就是错误,认为这样做是整个软件行业 40 多年的耻辱,这是小题大作,或者说他只有能力将罪责推给 NULL
,而没有能力限制 NULL
的副作用。如果我们只将 NULL
用于表示『没有』或『出错了』的状态,这不仅不是无害,而且会让代码更加简洁清晰。
如果你期望一个函数能够返回一个有效的指针,那么你就有义务检查它是不是真的返回了有效的指针。你从银行取款机里取钱,也会检查一下钱数对不对的。你过马路时,虽然有红绿灯,也会有意识的环顾左右,看有没有正在过往的车辆。对返回指针的函数不放心,就检查一下,让错误尽早的暴露出来。
如果你通过一个查询函数在 C 式的泛型容器中查询一个元素(其实是指针),而容器中没有这个元素,那么查询函数返回一个 NULL
,这是一个无效的指针,这时,你可以认为查询函数内部出错了,也可以认为查询函数告诉你容器中没有这个元素。这种情况下,查询函数的返回结果可能出现了二义性,但是这难道不可以视为是重新检验查询函数正确性的好机会么?你可以自行遍历你所用的容器中是否存在要查询的元素,如果确定有,那么就可以肯定是刚才你用的查询函数有 bug。如果容器中的确没有这个元素,那么查询函数的正确性就得到了验证,同时你应该反思,为什么你的代码要去查询一个根本就不在容器中的元素!如果你坚持 NULL
的意义不明确而导致歧义,然后得出推论『返回 NULL
的函数是有害的』,那么马克思都会比你善于写程序。
用 C 语言,就不要想太多。想的太多,你可能就不会或者不敢编程了。用 C 语言,你又必须想太多,因为不安全的因素到处都有,但是也只有不安全的东西才真正是有威力的工具,刀枪剑戟,车铣刨磨,布鲁弗莱学院传授的挖掘机技术,哪样不能要人命!不要想太多,指的是不要在一些细枝末节之处去考虑安全性,甚至对于野指针这种东西都诚惶诚恐。必须想太多,指的是多从程序的逻辑层面来考虑安全性。出错不可怕,可怕的是你努力用一些小技俩来规避错误,这种行为只会导致错误向后延迟,延迟到基于引用计数的内存回收,延迟到 Java 式的 GC,延迟到你认为可以高枕无忧然而错误却像癌症般的出现的时候。
不好的设计品味
上文谈到,内存错误往往是由不良的设计导致的。我不确定怎样的设计算是好品味的,但是我可以确定一些品味不怎么样的设计。
第一种品味不怎么样的设计是割裂算法与数据结构的关系。这种设计源于对教科书的盲目信仰。几乎任何一本讲数据结构与算法的书都会煞有介事的告诉你 程序 = 数据结构 + 算法
。这个『公式』是 Pascal 之父 Nicklaus Wirth 提出的,但实际上形同废话,类似于 英语 = 单词 + 语法
。可是这种废话却让许多人形而上学了。有些人坚定不移的确信,只要把数据结构设计正确了,正确的算法不言自明,于是他们就从面向对象开始设计程序。还有些人坚定不移的确信,只要将算法设计正确了,正确的数据结构不言自明,于是他们就从算法设计开始。这都是不学习马克思哲学的下场。
马克思哲学是一门注重迭代的哲学,马克思经常说,算法与数据结构是矛盾的,二者统一于程序之中;算法决定了数据结构,数据结构又反过来影响算法。马克思说的肯定不是废话,任何一个有素样的程序员都不会否定迭代设计。任何一个程序在诞生之初都不是完美的,但是负责任的设计者会努力使之进化,趋向完美。如果马克思学编程,他一定会将泛型编程与面向对象编程这两大范式统一起来,左手画圆,右手画方,傲视群雄。达尔文也说过,程序不是设计出来的,而是进化出来的,能以最短路径去解决问题的程序会生存,反之会被淘汰。
第二种品味不怎么样的设计是面向某种编程范式。我在『面向指针编程』一文中捏造了一个很简单的例子,然后将面向对象、泛型编程以及函数式编程中最基本的手段穿插在一起,结果可以得到一个思路清晰、代码简洁的小程序。其实这个例子源自很多年前我自己写的一个双向链表模块,在用 C 构建稍微有点规模的程序或库时,这些手段都是最基本的。事实上,当时我在写这些代码时,并不怎么懂面向对象、泛型编程以及函数式编程这些烧脑的概念,完全是为了解决我面对的一些小问题,自然而然的就用上了这些手段。很多年后我才隐约发现这些手段竟然对应着一种又一种编程范式的雏形。
我应该庆幸,除了指针与宏之外没有任何特性的 C 语言没有给我带来太多难以理解的概念,以至于我可以直接面向问题编程。C 语言的发明者 Dannis Ritche 说,A language that doesn't have everything is actually easier to program in than some that do. 翻译过来,就是『不试图拥有一切的语言实际上要比那些试图拥有一切的语言更易于编程』。
大部分情况下,我们写的代码在逻辑上并不复杂,所以各种编程范式看上去都同样有效——如果你说你能用面向对象解决问题,那么肯定就会有人说他用函数式编程也能解决同样的问题。大部分程序在数据结构与算法方面只用到了线性表与排序算法,程序中大部分代码是与问题领域息息相关的。像树与图这种数据结构及相关的算法,往往已经以库的形式被实现了。就连遗传算法、神经网络算法以及支持向量机这些复杂的算法也有现成的库可调用。当问题足够复杂时,你会发现任何编程范式都没法帮助你解决问题,它们甚至对你如何理解问题都没有任何帮助。在我看来,任何编程范式在本质上都是代码层面的『图形界面(GUI)』,它们并不能真正代表设计上的好品味。如果你的程序是持续进化的,那么它总是有可能进化为最适合它的那些编程范式。
第三种品味不怎么样的设计是不为自己的设计提供有效的文档,主要表现为 (1) 文档写的比代码还烂;(2) 文档不能反映最新的代码;(3) 干脆不写文档,让他人去 read the fucking source code. 如果你能够长期维护你所写的代码,不提供有效的文档也不是什么大事,否则你让别人去阅读你写的 fucking source code,那是对他人的侮辱。因为你是面向机器写代码,他人要想读懂你的代码,就必须通过代码去逆向还原你的思路。一些水平很烂的的程序员,企图像 Linus 那样高调,趾高气扬的让我们去阅读他们写的代码,这种行为无异于让我们从其排泄物中猜测他们中午在哪个馆子吃了什么饭……结果往往是他们的代码很快就死掉了。这个世界上能实现代码即文档,文档即代码的人几乎没有。即使软件世界的泰山北斗 Donald Knuth 老先生也得借助文式编程的方式来注解自己的代码,也就是说他只能达到又能写书又能写代码的境界,即便如此,这个星球上能与之比肩的人寥寥无几。
我又成功的跑题了。其实我想说的是,与这些品味不好的设计相比,用 C 管理一下内存所带来的繁琐与困难根本不值一提。请记住 Peter Norvig 说的,学会编程通常需要十年,为何人人都这么着急?