笨辦法學C 練習27:創造性和防護性編程

練習27:創造性和防護性編程

原文:Exercise 27: Creative And Defensive Programminghtml

譯者:飛龍git

你已經學到了大多數C語言的基礎,而且準備好開始成爲一個更嚴謹的程序員了。這裏就是從初學者走向專家的地方,不只僅對於C,更對於核心的計算機科學概念。我將會教給你一些核心的數據結構和算法,它們是每一個程序員都要懂的,還有一些我在真實程序中所使用的一些很是有趣的東西。程序員

在我開始以前,我須要教給你一些基本的技巧和觀念,它們能幫助你編寫更好的軟件。練習27到31會教給你高級的概念和特性,而不是談論編程,可是這些以後你將會應用它們來編寫核心庫或有用的數據結構。github

編寫更好的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,由於它會混淆比較。

  • 記錄爲何存在這些先決條件,當一個程序員碰到錯誤時,他能夠弄清楚這些是不是真正必要的。

  • 若是你修改了輸入,確保當函數退出或停止時它們也會正確產生。

  • 老是要檢查所使用的函數的錯誤代碼。例如,人們有時會忘記檢查fopenfread的返回代碼,這會致使他們在錯誤下仍然使用這個資源。這會致使你的程序崩潰或者易受攻擊。

  • 你也須要返回一致的錯誤代碼,以便對你的每一個函數添加相同的機制。一旦你熟悉了這一習慣,你就會明白爲何個人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,提交一個補丁來修復它。

相關文章
相關標籤/搜索