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,延遲到你認爲能夠高枕無憂然而錯誤卻像癌症般的出現的時候。

相關文章
相關標籤/搜索