C 指標有害健康

segmentfault發表於2016-03-16

每一盒香菸的包裝上都會寫『吸菸有害健康』。白酒瓶上也寫了『過度飲酒,有害健康』。本文的外包裝上寫的則是『閱讀有害健康』,特別是『甩掉強迫症』那一節,它適合我自己閱讀,但不一定適合你。

黑暗的記憶體

很多人對 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 說的,學會程式設計通常需要十年,為何人人都這麼著急?

相關文章