C 指针有害健康

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

黑暗的内存

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

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

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

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

C 内存的动态分配与回收

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

#include <stdlib.h>

void *p = malloc(7);
free(p);

一点都不难!跟你去学校图书馆借了 7 本书,而后又还回去没什么两样。有借有还,再借不难,过时不还,就要罚款。有谁由于去图书馆借几本书就被吓哭了的?bash

咱们也能够向堆空间借点地方存储某种类型的数据:多线程

int *n = malloc(4);
*n = 7;
free(n);

若是你不知道 int 类型的数据须要多大的空间才能装下,那就用 sizeof,让 C 编译器去帮助你计算,即:编程语言

int *n = malloc(sizeof(int));
*n = 7;
free(n);

策略与机制分离

在 C 语言中有关内存管理的机制已经简单到了几乎没法再简单的程度了,那么为什么那么多人都在嘲笑讥讽挖苦痛骂诅咒 C 的内存管理呢?ide

若是你略微懂得一些来自 Unix 的哲学,可能据说过这么一句话:策略与机制分离。若是没据说过这句话,建议阅读 Eric Raymond 写的《Unix 编程艺术》第一章中的 Unix 哲学部分。

mallocfree 是 C 提供的内存管理机制,至于你怎么去使用这个机制,那与 C 没有直接关系。例如,你能够手动使用 mallocfree 来管理内存——最简单的策略,你也能够实现一种略微复杂一点的基于引用计数的内存管理策略,还能够基于 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;
}
在 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,只不过不多有人据说过它们,见:http://www.hboehm.info/gc/#users

若是 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 <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 返回值进行检查的必要,例如本文评论中 @依云 所说的那些状况,能够考虑这样作:

#include <stdio.h>
#include <stdlib.h>

#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 <stdio.h>
#include <stdlib.h>

#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 < 10; i++) {
                SAFE_FREE(p);
        }
        return 0;
}

对于所引用的内存被释放了的指针,即便赋之以 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 吐出来钱在数目上对不对。你过马路时,虽然有红绿灯,并且你也都是经过驾照考试的,但你依然会下意识的环顾左右,看有没有正在过往的车辆。若是你坚持 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,延迟到你认为能够高枕无忧然而错误却像癌症般的出现的时候。

相关文章
相关标签/搜索