神刀安全网

C 指针有害健康

每一盒香烟的包装上都会写『吸烟有害健康』。白酒瓶上也写了『过度饮酒,有害健康』。本文的外包装上写的则是『阅读有害健康』,特别是『 甩掉强迫症 』那一节,它适合我自己阅读,但不一定适合你。

黑暗的内存

很多人对 C 语言深恶痛绝,仅仅是因为 C 语言迫使他们在编程中必须手动分配与释放内存,然后通过指针去访问,稍有不慎可能就会导致程序运行运行时出现内存泄漏或内存越界访问。

C 程序的内存泄漏只会发生在程序所用的堆空间内,因为程序只能在堆空间内动态分配内存。 NULL 指针、未初始化的指针以及引用的内存空间被释放了的指针,如果这些指针访问内存,很容易就让程序挂掉。

除了堆空间,程序还有个一般而言比较小的栈空间。这个空间是所有的函数共享的,每个函数在运行时会独占这个空间。栈空间的大小是固定的,它是留给函数的参数与局部变量用的。栈空间有点像宾馆,你下榻后,即使将房间搞的一团糟,也不需要你去收拾它,除非你把房间很严重的损坏了——用 C 的黑话来说,即缓冲区溢出。

虽然导致这些问题出现的原因很简单,但是却成为缺乏编程素养的人难以克服的障碍,被 C 语言吓哭很多次之后,他们叛逃到了 Java、C# 以及各种动态类型语言的阵营,因为这些语言将指针隐藏了起来,并提供内存垃圾回收(GC)功能。他们赢了,他们懒洋洋的躺在沙发上,拿着遥控器指挥内存,信号偶尔中断,内存偶尔紊乱。

C 内存的动态分配与回收

C 语言标准库(stdlib)中为堆空间中的内存分配与回收提供了 mallocfree 函数。例如,在下面的代码中,我们从堆空间中分配了 7 个字节大小的空间,然后又释放了:

#include 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 哲学部分。

mallocfree 是 C 提供的内存管理机制,至于你怎么去使用这个机制,那与 C 没有直接关系。例如,你可以手动使用 mallocfree 来管理内存——最简单的策略,你也可以实现一种略微复杂一点的基于引用计数的内存管理策略,还可以基于 Lisp 之父 John McCarthy 独创的 Mark&Sweep 算法实现一种保守的内存自动回收策略,还可以将引用计数与 Mark&Sweep 这两种策略结合起来实现内存自动回收。总之,这些策略都可以在 C 的内存管理机制上实现。

借助 Boehm GC 库,就可以在 C 程序中实现垃圾内存的自动回收:

#include  #include  #include   int main(void) {     GC_INIT();     for (int i = 0; i

在 C 程序中使用 Boehm GC 库,只需用 GC_MALLOCC_MALLOC_ATOMIC 替换 malloc ,然后去掉所有的 free 语句。 C_MALLOC_ATOMIC 用于分配不会用于存储指针数据的堆空间。

如果你的系统(Linux)中安装了 boehm-gc 库(很微型,刚 100 多 Kb),可以用 gcc 编译这个程序然后运行一次体验一下,编译命令如下:

$ gcc -lgc test-gc.c

GNU 的 Scheme 解释器 Guile 2.0 就是用的 boehm-gc 来实现内存回收的。有很多项目在用 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 引用了 B 和 E,E 引用了 A……然后你可能就凌乱了。如果是基于引用计数来实现内存自动回收,遇到这种对象之间相互引用的情况,虽然那程序不会崩溃,但是会出现内存泄漏,除非借助弱引用来打破这种这种引用循环,本质上这只是变相的 谁最后使用,谁负责释放

函数式编程范式中,内存泄漏问题依然很容易出现,特别是在递归函数中,通常需要借助一种很别扭的思维将递归函数弄成尾递归形式才能解决这种问题。另外,惰性计算也可能会导致内存泄漏。

似乎并没有任何一种编程语言能够真正完美的解决内存泄漏问题——有人说 Rust 能解决,我不是很相信,但是显而易见,程序在设计上越低劣,就越容易导致内存错误。似乎只有通过大量实践,亡羊补牢,塞翁失马,卧薪尝胆,破釜沉舟,久而久之,等你三观正常了,不焦不躁了,明心见性了,内存错误这种癌症就会自动从你的 C 代码中消失了—— 好的设计品味,自然就是内存友好的 。当我们达到这种境界时,可能就不会再介意在 C 中手动管理内存。

让 Valgrind 帮你养成 C 内存管理的好习惯

Linux 环境中有一个专门用于 C 程序内存错误检测工具——valgrind,其他操作系统上应该也有类似的工具。valgrind 能够发现程序中大部分内存错误——程序中使用了未初始化的内存,使用了已释放的内存,内存越界访问、内存覆盖以及内存泄漏等错误。

看下面这个来自『The Valgrind Quick Start Guide』的小例子:

#include   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 返回值进行检查的必要,例如本文评论中 @依云 所说的那些情况,可以考虑这样做:

#include  #include   #define SAFE_MALLOC(n) safe_malloc(n)  void * safe_malloc(size_t n) {         void *p = malloc(n);         if (p) {                 return p;         } else {                 printf("你的内存不够用,我先撤了!n");                 exit(-1);         } } int main(void) {         int *p = SAFE_MALLOC(sizeof(int));         ... ... ...          return 0; }

如果你被我说服了,决定不去检查 malloc 的返回值是否为 NULL ,那么又一个问题随之而来。我们是否需要在程序中检测一个指针是否为 NULLNULL 指针实在是太恐怖了,直接决定了程序的生死。为了安全起见,在用一个指针时检测一下它的值是否为 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 ,但是无法检测未初始化的指针或者被释放了的指针。也许你认为只要坚持将指针初始化为 NULL ,并坚持消除野指针,那么 foo 中的 NULL 检测就是有效的。但是很可惜,野指针不是那么容易消除,下面就会讨论此事。凡是不能彻底消除的问题,就不应该再浪费心机,否则只是将一个问题演变了另一个问题而已。那些被重重遮掩的问题,一旦被触发,你会更看难看清真相。

指针不应该受到不公正待遇。如果你处处纠结程序中用到的整型数或浮点数是否会溢出,或者你走在人家楼下也不是时时仰望上方有没有高空坠物,那么也就不应该对指针是否为 NULL 那么重视,甚至不惜代价为其修建万里长城。在 C 语言中,不需要指针的 NULL 契约,只需要遵守指针法律:你要传给我指针,就必须保证你的指针是有效的,否则我就用程序崩溃来惩罚你。

第三个问题依然与 NULL 有关,那就是一个指针所引用的内存空间被释放后,是否要将这个指针赋值为 NULL ?对于这个问题,大家一致认为应该为之赋以 NULL ,否则这个指针就成为『野指针』——野指针是有害的。一开始我也这么认为,但是久而久之就觉得消除野指针,是一种很无聊的行为。程序中之所以会出现野指针引发的内存错误,往往意味着你的代码出现了拙劣的设计!如果消除野指针,再配合指针是否为 NULL 的检测,这样做固然可以很快的定位出错点,但是换来的经常是一个很脏的补丁式修正,而坏的设计可能会继续得到纵容。

如果你真的害怕野指针,可以像下面这样做:

#include  #include   #define SAFE_FREE(p) safe_free((void **)(&(p)))  void safe_free(void **p) {         if(*p) {                 free(*p);                 *p = NULL;         } else {                 printf("哎呀,我怕死野指针了!n");         } }  int main(void) {         int *p = malloc(sizeof(int));         for(int i = 0; i

对于所引用的内存被释放了的指针,即使赋之以 NULL ,也只能解决那些你原本一眼就能看出来的问题。更糟糕的是,当你对消除野指针非常上心时,每当消除一个野指针,可能会让你觉得你的程序更加健壮了,这种错觉反而消除了你的警惕之心。如果一块内存空间被多个指针引用,当你通过其中一个指针释放这块内存空间之后,并赋该指针以 NULL ,那么其他几个指针该怎么处理?也许你会说,那应该用引用计数技术来解决这样的问题。引用计数的确可以解决一些问题,但是它又带来一个新的问题,对于指针所引用的空间,在引用计数为 0 时,它被释放了,这时另外一个地方依然有代码在试图 unref ,这时该怎么处理?

绝对的不去检测指针是否为 NULL 肯定也不科学。因为有时 NULL 是作为状态来用的。例如在树结构中,可以根据任一结点中的子结点指针是否为 NULL 来判断这个结点是否为叶结点。有些函数通过返回 NULL 告诉调用者:『我可耻的失败了』。我觉得这才是 NULL 真正的用武之地。

王垠在『编程的智慧』一文中告诫大家,尽量不要让函数返回 NULL,他认为如果你的函数要返回『没有』或『出错了』之类的结果,尽量使用 Java 的异常机制。这种观点也许是对的,但是鉴于 C 没有异常机制(在 C 中可以用 setjmp/longjmp 勉强模拟异常),只有 NULL 可用。有些人形而上学强加附会的将这种观点解读为让函数返回 NULL 是有害的,甚至将这种行为视为『低级错误』,甚至认为 C 指针的存在本身就是错误,认为这样做是整个软件行业 40 多年的耻辱,这是小题大作,或者说他只有能力将罪责推给 NULL ,而没有能力限制 NULL 的副作用。如果我们只将 NULL 用于表示『没有』或『出错了』的状态,这非但无害,而且会让代码更加简洁清晰。

如果你期望一个函数能够返回一个有效的指针,那么你就有义务检查它是不是真的返回了有效的指针,否则就没必要检查。这种检查其实与这个函数是否有可能返回 NULL 无关。类似的 NULL 检查,在生活中很常见。即使银行的 ATM 机已经在安全性上做了重重防御,但是你取钱时,也经常会检查一下 ATM 吐出来钱在数目上对不对。你过马路时,虽然有红绿灯,而且司机师傅也都是通过驾照考试的,但你依然会有意识的环顾左右,看有没有正在过往的车辆。

如果你通过一个查询函数在 C 式的泛型容器中查询一个元素(其实是指针),而容器中没有这个元素,那么查询函数返回一个 NULL ,这是一个无效的指针,这时,你可以认为查询函数内部出错了,也可以认为查询函数告诉你容器中没有这个元素。这种情况下,查询函数的返回结果出现了二义性,但是这难道不可以视为是重新检验查询函数正确性的好机会么?你可以自行遍历你所用的容器中是否存在要查询的元素,如果确定有,那么就可以肯定是刚才你用的查询函数有 bug。如果容器中的确没有这个元素,那么查询函数的正确性就得到了检验。如果你坚持 NULL 的意义不明确而导致歧义,然后得出推论『返回 NULL 的函数是有害的』,那么马克思都会比你善于写程序。

当你打算检测一个指针的值是否为 NULL 时,问题又来了……我们是应该

if(p == NULL) {         ... ... ... }

还是应该

if(!p) {         ... ... ... }

?

很多人害怕出错,他们往往会选择第一种判断方式,他们的理由是:在某些 C 的实现(编译器与标准库)中, NULL 的值可能不是 0 。这个理由,也许对于 C99 之前的 C 是成立的,但是至少从 C99 就不再是这样了。C99 标准的 6.3.2.3 节,明确将空指针定义为常量 0 。在现代一些的 C 编译器上,完全可以放心使用更为简洁且直观的第二种判断方式。

用 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 说的,学会编程通常需要十年,为何人人都这么着急?

转载本站任何文章请注明:转载至神刀安全网,谢谢神刀安全网 » C 指针有害健康

分享到:更多 ()

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
分享按钮