練習27:創造性和防御性編程
譯者:飛龍
你已經學到了大多數C語言的基礎,并且準備好開始成為一個更嚴謹的程序員了。這里就是從初學者走向專家的地方,不僅僅對于C,更對于核心的計算機科學概念。我將會教給你一些核心的數據結構和算法,它們是每個程序員都要懂的,還有一些我在真實程序中所使用的一些非常有趣的東西。
在我開始之前,我需要教給你一些基本的技巧和觀念,它們能幫助你編寫更好的軟件。練習27到31會教給你高級的概念和特性,而不是談論編程,但是這些之后你將會應用它們來編寫核心庫或有用的數據結構。
編寫更好的C代碼(實際上是所有語言)的第一步是,學習一種新的觀念叫做“防御性編程”。防御性編程假設你可能會制造出很多錯誤,之后嘗試在每一步盡可能預防它們。這個練習中我打算教給你如何以防御性的思維來思考編程。
創造性編程思維
在這個簡單的練習中要告訴你如何做到創造性是不可能的,但是我會告訴你一些涉及到任務風險和開放思維的創造力。恐懼會快速地扼殺創造力,所以我采用,并且許多程序員也采用的這種思維方式使我不會懼怕風險,并且看上去像個傻瓜。
- 我不會犯錯誤。
- 人們所想的并不重要。
- 我腦子里面誕生的想法才是最好的。
我只是暫時接受了這種思維,并且在應用中用了一些小技巧。為了這樣做我會提出一些想法,尋找創造性的解決方案,開一些奇奇怪怪的腦洞,并且不會害怕發明一些古怪的東西。在這種思維方式下,我通常會編寫出第一個版本的糟糕代碼,用于將想法描述出來。
然而,當我完成我的創造性原型時,我會將它扔掉,并且將它變得嚴謹和可考。其它人在這里常犯的一個錯誤就是將創造性思維引入它們的實現階段。這樣會產生一種非常不同的破壞性思維,它是創造性思維的陰暗面:
- 編寫完美的軟件是可行的。
- 我的大腦告訴我了真相,它不會發現任何錯誤,所以我寫了完美的軟件。
- 我的代碼就是我自己,批判它的人也在批判我。
這些都是錯誤的。你經常會碰到一些程序員,它們對自己創造的軟件具有強烈的榮譽感。這很正常,但是這種榮譽感會成為客觀上改進作品的阻力。由于這種榮譽感和它們對作品的依戀,它們會一直相信它們編寫的東西是完美的。只要它們忽視其它人的對這些代碼的觀點,它們就可以保護它們的玻璃心,并且永遠不會改進。
同時具有創造性思維和編寫可靠軟件的技巧是,采用防御性編程的思維。
防御性編程思維
在你做出創造性原型,并且對你的想法感覺良好之后,就應該切換到防御性思維了。防御性思維的程序員大致上會否定你的代碼,并且相信下面這些事情:
- 軟件中存在錯誤。
- 你并不是你的軟件,但你需要為錯誤負責。
- 你永遠不可能消除所有錯誤,只能降低它們的可能性。
這種思維方式讓你誠實地對待你的代碼,并且為改進批判地分析它。注意上面并沒有說你充滿了錯誤,只是說你的代碼充滿錯誤。這是一個需要理解的關鍵,因為它給了你編寫下一個實現的客觀力量。
就像創造性思維,防御性編程思維也有陰暗面。防御性程序員是一個懼怕任何事情的偏執狂,這種恐懼使他們遠離可能的錯誤或避免犯錯誤。當你嘗試做到嚴格一致或正確時這很好,但是它是創造力和專注的殺手。
八個防御性編程策略
一旦你接受了這一思維,你可以重新編寫你的原型,并且遵循下面的八個策略,它們被我用于盡可能把代碼變得可靠。當我編寫代碼的“實際”版本,我會嚴格按照下面的策略,并且嘗試消除盡可能多的錯誤,以一些會破壞我軟件的人的方式思考。
永遠不要信任輸入
永遠不要提供的輸入,并總是校驗它。
避免錯誤
如果錯誤可能發生,不管可能性多低都要避免它。
過早暴露錯誤
過早暴露錯誤,并且評估發生了什么、在哪里發生以及如何修復。
記錄假設
清楚地記錄所有先決條件,后置條件以及不變量。
防止過多的文檔
不要在實現階段就編寫文檔,它們可以在代碼完成時編寫。
使一切自動化
使一切自動化,尤其是測試。
簡單化和清晰化
永遠簡化你的代碼,在沒有犧牲安全性的同時變得最小和最整潔。
質疑權威
不要盲目遵循或拒絕規則。
這些并不是全部,僅僅是一些核心的東西,我認為程序員應該在編程可靠的代碼時專注于它們。要注意我并沒有真正說明如何具體做到這些,我接下來會更細致地講解每一條,并且會布置一些覆蓋它們的練習。
應用這八條策略
這些觀點都是一些流行心理學的陳詞濫調,但是你如何把它們應用到實際編程中呢?我現在打算向你展示這本書中的一些代碼所做的事情,這些代碼用具體的例子展示每一條策略。這八條策略并不止于這些例子,你應該使用它們作為指導,使你的代碼更可靠。
永遠不要信任輸入
讓我們來看一個壞設計和“更好”的設計的例子。我并不想稱之為好設計,因為它可以做得更好。看一看這兩個函數,它們都復制字符串,main
函數用于測試哪個更好。
undef NDEBUG
#include "dbg.h"
#include <stdio.h>
#include <assert.h>
/*
* Naive copy that assumes all inputs are always valid
* taken from K&R C and cleaned up a bit.
*/
void copy(char to[], char from[])
{
int i = 0;
// while loop will not end if from isn't '\0' terminated
while((to[i] = from[i]) != '\0') {
++i;
}
}
/*
* A safer version that checks for many common errors using the
* length of each string to control the loops and termination.
*/
int safercopy(int from_len, char *from, int to_len, char *to)
{
assert(from != NULL && to != NULL && "from and to can't be NULL");
int i = 0;
int max = from_len > to_len - 1 ? to_len - 1 : from_len;
// to_len must have at least 1 byte
if(from_len < 0 || to_len <= 0) return -1;
for(i = 0; i < max; i++) {
to[i] = from[i];
}
to[to_len - 1] = '\0';
return i;
}
int main(int argc, char *argv[])
{
// careful to understand why we can get these sizes
char from[] = "0123456789";
int from_len = sizeof(from);
// notice that it's 7 chars + \0
char to[] = "0123456";
int to_len = sizeof(to);
debug("Copying '%s':%d to '%s':%d", from, from_len, to, to_len);
int rc = safercopy(from_len, from, to_len, to);
check(rc > 0, "Failed to safercopy.");
check(to[to_len - 1] == '\0', "String not terminated.");
debug("Result is: '%s':%d", to, to_len);
// now try to break it
rc = safercopy(from_len * -1, from, to_len, to);
check(rc == -1, "safercopy should fail #1");
check(to[to_len - 1] == '\0', "String not terminated.");
rc = safercopy(from_len, from, 0, to);
check(rc == -1, "safercopy should fail #2");
check(to[to_len - 1] == '\0', "String not terminated.");
return 0;
error:
return 1;
}
copy
函數是典型的C代碼,而且它是大量緩沖區溢出的來源。它有缺陷,因為它總是假設接受到的是合法的C字符串(帶有'\0'
),并且只是用一個while
循環來處理。問題是,確保這些是十分困難的,并且如果沒有處理好,它會使while
循環無限執行。編寫可靠代碼的一個要點就是,不要編寫可能不會終止的循環。
safecopy
函數嘗試通過要求調用者提供兩個字符串的長度來解決問題。它可以執行有關這些字符串的、copy
函數不具備的特定檢查。他可以保證長度正確,to
字符串具有足夠的容量,以及它總是可終止。這個函數不像copy
函數那樣可能會永遠執行下去。
這個就是永遠不信任輸入的實例。如果你假設你的函數要接受一個沒有終止標識的字符串(通常是這樣),你需要設計你的函數,不要依賴字符串本身。如果你想讓參數不為NULL
,你應該對此做檢查。如果大小應該在正常范圍內,也要對它做檢查。你只需要簡單假設調用你代碼的人會把它弄錯,并且使他們更難破壞你的函數。
這個可以擴展到從外部環境獲取輸入的的軟件。程序員著名的臨終遺言是,“沒人會這樣做。”我看到他們說了這句話后,第二天有人就這樣做,黑掉或崩潰它們的應用。如果你說沒有人會這樣做,那就加固代碼來保證他們不會簡單地黑掉你的應用。你會因所做的事情而感到高興。
這種行為會出現收益遞減。下面是一個清單,我會嘗試對我用C寫的每個函數做如下工作:
- 對于每一個參數定義它的先決條件,以及這個條件是否導致失效或返回錯誤值。如果你在編寫一個庫,比起失效要更傾向于錯誤。
- 對于每個先決條件,使用
assert(test && "message");
在最開始添加assert
檢查。這句代碼會執行檢查,失敗時OS通常會打印斷言行,通常它包括信息。當你嘗試弄清assert
為什么在這里時,這會非常有用。 - 對于其它先決條件,返回錯誤代碼或者使用我的
check
宏來執行它并且提供錯誤信息。我在這個例子中沒有使用check
,因為它會混淆比較。 - 記錄為什么存在這些先決條件,當一個程序員碰到錯誤時,他可以弄清楚這些是否是真正必要的。
- 如果你修改了輸入,確保當函數退出或中止時它們也會正確產生。
- 總是要檢查所使用的函數的錯誤代碼。例如,人們有時會忘記檢查
fopen
或fread
的返回代碼,這會導致他們在錯誤下仍然使用這個資源。這會導致你的程序崩潰或者易受攻擊。 - 你也需要返回一致的錯誤代碼,以便對你的每個函數添加相同的機制。一旦你熟悉了這一習慣,你就會明白為什么我的
check
宏這樣工作。
只是這些微小的事情就會改進你的資源處理方式,并且避免一大堆錯誤。
避免錯誤
上一個例子中你可能會聽到別人說,“程序員不會經常錯誤地使用copy
。”盡管大量攻擊都針對這類函數,他們仍舊相信這種錯誤的概率非常低。概率是個很有趣的事情,因為人們不擅長猜測所有事情的概率,這非常難以置信。然而人們對于判斷一個事情是否可能,是很擅長的。他們可能會說copy
中的錯誤不常見,但是無法否認它可能發生。
關鍵的原因是對于一些常見的事情,它首先是可能的。判斷可能性非常簡單,因為我們都知道事情如何發生。但是隨后判斷出概率就不是那么容易了。人們錯誤使用copy
的情況會占到20%、10%,或1%?沒有人知道。為了弄清楚你需要收集證據,統計許多軟件包中的錯誤率,并且可能需要調查真實的程序員如何使用這個函數。
這意味著,如果你打算避免錯誤,你不需要嘗試避免可能發生的事情,而是要首先集中解決概率最大的事情。解決軟件所有可能崩潰的方式并不可行,但是你可以嘗試一下。同時,如果你不以最少的努力解決最可能發生的事件,你就是在不相關的風險上浪費時間。
下面是一個決定避免什么的處理過程:
- 列出所有可能發生的錯誤,無論概率大小,并帶著它們的原因。不要列出外星人可能會監聽內存來偷走密碼這樣的事情。
- 評估每個的概率,使用危險行為的百分比來表示。如果你處理來自互聯網的情況,那么則為可能出現錯誤的請求的百分比。如果是函數調用,那么它是出現錯誤的函數調用百分比。
- 評估每個的工作量,使用避免它所需的代碼量或工作時長來表示。你也可以簡單給它一個“容易”或者“難”的度量。當需要修復的簡單錯誤仍在列表上時,任何這種度量都可以讓你避免做無謂的工作。
- 按照工作量(低到高)和概率(高到低)排序,這就是你的任務列表。
- 之后避免你在列表中列出的任何錯誤,如果你不能消除它的可能性,要降低它的概率。
- 如果存在你不能修復的錯誤,記錄下來并提供給可以修復的人。
這一微小的過程會產生一份不錯的待辦列表。更重要的是,當有其它重要的事情需要解決時,它讓你遠離勞而無功。你也可以更正式或更不正式地處理這一過程。如果你要完成整個安全審計,你最好和團隊一起做,并且有個更詳細的電子表格。如果你只是編寫一個函數,簡單地復查代碼之后劃掉它們就夠了。最重要的是你要停止假設錯誤不會發生,并且著力于消除它們,這樣就不會浪費時間。
過早暴露錯誤
如果你遇到C中的錯誤,你有兩個選擇:
- 返回錯誤代碼。
- 中止進程。
這就是處理方法,你需要執行它來確保錯誤盡快發生,記錄清楚,提供錯誤信息,并且易于程序員來避免它。這就是我提供的check
宏這樣工作的原因。對于每一個錯誤,你都要讓它你打印信息、文件名和行號,并且強制返回錯誤代碼。如果你使用了我的宏,你會以正確的方式做任何事情。
我傾向于返回錯誤代碼而不是終止程序。如果出現了大錯誤我會中止程序,但是實際上我很少碰到大錯誤。一個需要中止程序的很好例子是,我獲取到了一個無效的指針,就像safecopy
中那樣。我沒有讓程序在某個地方產生“段錯誤”,而是立即捕獲并中止。但是,如果傳入NULL
十分普遍,我可能會改變方式而使用check
來檢查,以保證調用者可以繼續運行。
然而在庫中,我盡我最大努力永不中止。使用我的庫的軟件可以決定是否應該中止。如果這個庫使用非常不當,我才會中止程序。
最后,關于“暴露”的一大部分內容是,不要對多于一個錯誤使用相同的信息或錯誤代碼。你通常會在外部資源的錯誤中見到這種情況。比如一個庫捕獲了套接字上的錯誤,之后簡單報告“套接字錯誤”。它應該做的是返回具體的信息,比如套接字上發生了什么錯誤,使它可以被合理地調試和修復。當你設計錯誤報告時,確保對于不同的錯誤你提供了不同的錯誤消息。
記錄假設
如果你遵循并執行了這個建議,你就構建了一份“契約”,關于函數期望這個世界是什么樣子。你已經為每個參數預設了條件,處理潛在的錯誤,并且優雅地產生失敗。下一步是完善這一契約,并且添加“不變量”和“后置條件”。
不變量就是在函數運行時,一些場合下必須恒為真的條件。這對于簡單的函數并不常見,但是當你處理復雜的結構時,它會變得很必要。一個關于不變量的很好的例子是,結構體在使用時都會合理地初始化。另一個是有序的數據結構在處理時總是排好序的。
后置條件就是退出值或者函數運行結果的保證。這可以和不變了混在一起,但是也可以是一些很簡單的事情,比如“函數應總是返回0,或者錯誤時返回-1”。通常這些都有文檔記錄,但是如果你的函數返回一個分配的資源,你應該添加一個后置條件,做檢查來確保它返回了一個不為NULL
的東西。或者,你可以使用NULL
來表示錯誤,這種情況下,你的后置條件就是資源在任何錯誤時都會被釋放。
在C編程中,不變量和后置條件都通常比實際的代碼和斷言更加文檔化。處理它們的最好當時就是盡可能添加assert
調用,之后記錄剩下的部分。如果你這么做了,當其它人碰到錯誤時,他們可以看到你在編寫函數時做了什么假設。
避免過多文檔
程序員編寫代碼時的一個普遍問題,就是他們會記錄一個普遍的bug,而不是簡單地修復它。我最喜歡的方式是,Ruby on Rails系統只是簡單地假設所有月份都有30天。日歷太麻煩了,所以與其修復它,不如在一些地方放置一個小的注釋,說這是故意的,并且幾年內都不會改正。每次一些人試圖抱怨它時,他們都會說,“文檔里面都有!”
如果你能夠實際修復問題,文檔并不重要,并且,如果函數具有嚴重的缺陷,你在修復它之前可以不記錄它。在Ruby on Rails的例子中,不包含日期函數會更好一些,而不是包含一個沒人會用的錯誤的函數。
當你為防御性編程執行清理時,盡可能嘗試修復任何事情。如果你發現你記錄了越來越多的,你不能修復的事情,需要考慮重新設計特性,或簡單地移除它。如果你真的需要保留這一可怕的錯誤的特性,那么我建議你編寫它、記錄它,并且在你受責備之前找一份新的工作。
使一切自動化
你是個程序員,這意味著你的工作是通過自動化消滅其它人的工作。它的終極目標是使用自動化來使你自己也失業。很顯然你不應該完全消除你做的東西,但如果你花了一整天在終端上重復運行手動測試,你的工作就不是編程。你只是在做QA,并且你應該使自己自動化,消除這個你可能并不是真的想干的QA工作。
實現它的最簡單方式就是編寫自動化測試,或者單元測試。這本書里我打算講解如何使它更簡單,并且我會避免多數編寫測試的信條。我只會專注于如何編寫它們,測試什么,以及如何使測試更高效。
下面是程序員沒有但是應該自動化的一些事情:
- 測試和校驗。
- 構建過程。
- 軟件部署。
- 系統管理。
- 錯誤報告。
嘗試花一些時間在自動化上面,你會有更多的時間用來處理一些有趣的事情。或者,如果這對你來說很有趣,也許你應該編寫自動化完成這些事情的軟件。
簡單化和清晰化
“簡單性”的概念對許多人來說比較微妙,尤其是一些聰明人。它們通常將“內涵”與“簡單性”混淆起來。如果他們很好地理解了它,很顯然非常簡單。簡單性的測試是通過將一個東西與比它更簡單的東西比較。但是,你會看到編寫代碼的人會使用最復雜的、匪夷所思的數據結構,因為它們認為做同樣事情的簡單版本非常“惡心”。對復雜性的愛好是程序員的弱點。
你可以首先通過告訴自己,“簡單和清晰并不惡心,無論誰在干什么事情”來戰勝這一弱點。如果其它人編寫了愚蠢的觀察者模式涉及到19個類,12個接口,而你只用了兩個字符串操作就可以實現它,那么你贏了。他們就是錯了,無論他們認為自己的復雜設計有多么高大上。
對于要使用哪個函數的最簡單測試是:
- 確保所有函數都沒有問題。如果它有錯誤,它有多快或多簡單就不重要了。
- 如果你不能修復問題,就選擇另外一個。
- 它們會產生相同結果嘛?如果不是就挑選具有所需結果的函數。
- 如果它們會產生相同結果,挑選包含更少特性,更少分支的那個,或者挑選你認為最簡單的那個。
- 確保你沒有只是挑選最具有表現力的那個。無論怎么樣,簡單和清晰,都會戰勝復雜和惡心。
你會注意到,最后我一般會放棄并告訴你根據你的判斷。簡單性非常諷刺地是一件復雜的事情,所以使用你的品位作為指引是最好的方式。只需要確保在你獲取更多經驗之后,你會調整你對于什么是“好”的看法。
質疑權威
最后一個策略是最重要的,因為它讓你突破防御性編程思維,并且讓你轉換為創造性思維。防御性編程是權威性的,并且比較無情。這一思維方式的任務是讓你遵循規則,因為否則你會錯失一些東西或心煩意亂。
這一權威性的觀點的壞處是扼殺了獨立的創造性思維。規則對于完成事情是必要的,但是做它們的奴隸會扼殺你的創造力。
這條最后的策略的意思是你應該周期性地質疑你遵循的規則,并且假設它們都是錯誤的,就像你之前復查的軟件那樣。在一段防御性編程的時間之后,我通常會這樣做,我會擁有一個不編程的休息并讓這些規則消失。之后我會準備好去做一些創造性的工作,或按需做更多的防御型編程。
順序并不重要
在這一哲學上我想說的最后一件事,就是我并不是告訴你要按照一個嚴格的規則,比如“創造!防御!創造!防御!”去做這件事。最開始你可能想這樣做,但是我實際上會做不等量的這些事情,取決于我想做什么,并且我可能會將二者融合到一起,沒有明確的邊界。
我也不認為其中一種思維會優于另一種,或者它們之間有嚴格的界限。你需要在編程上既有創造力也要嚴格,所以如果想要提升的話,需要同時做到它們。
附加題
- 到現在為止(以及以后)書中的代碼都可能違反這些規則。回退并挑選一個練習,將你學到的應用在它上面,來看看你能不能改進它或發現bug。
- 尋找一個開源項目,對其中一些文件進行類似的代碼復查。如果你發現了bug,提交一個補丁來修復它。