系統程序員成長計劃-寫得又快又好的祕訣


文章出處:http://www.limodev.cn/blog
做者聯繫方式:李先靜 <xianjimli at hotmail dot com>程序員

 

「快」是指開發效率高,「好」是指軟件質量高。呵呵,寫得又快又好的人就是高手了。記得這是林銳博士下的定義,讀他那篇著名的《C/C++高質量編程》時,我仍是個初學者,印象特別深。我如今仍然贊同他的觀點,不過這裏標題改成成爲高手的祕訣,感受就有點像標題黨了,因此仍是用比較通俗的說法吧。廢話少說,請讀者回顧一下這段時間的編程經驗,回答下面兩個問題:面試

1.快與好是什麼關係?寫得快就不能寫得好?寫得好就不能寫得快?仍是寫得好才能寫得快?是否是繞暈了?不過這確實是值得思考的問題。算法

2.咱們的時間花在哪裏了?記得剛來深圳時到華爲面試,面試的人是個人學長。他問我,你一天能寫多少行代碼?我想了想說,100行吧。他用看外行的眼光看着我說,能寫100行嗎?我知道說錯話了,趕快補充說,嗯,從整個項目來看可能沒有吧。他才點了點頭。一天只寫100行代碼?初學者可能以爲難以想象,以同時應付10個網友聊天的速度,寫100行代碼不用三分鐘。不過,通過這段時間的練習後,咱們想你們已經明白,敲代碼不是花時間最多的地方,那時間又花到哪裏去了呢?編程

1.好與快的關係小程序

幾年前和一個朋友聊天時,他抱怨他的上司說,要我寫得好又要寫快,那怎麼可能呢?我當時一愣,反問到,寫很差怎麼可能寫得快?他也一愣。數組

傳統觀點認爲在功能、成本(人*時間)和質量這個鐵三角中,提升質量就意味投入更多成本或者減小一些功能。在功能不變的狀況下,不可能在提升質量的同時下降開成成本(對我的來說就是縮短開發時間)。個人朋友持的正是這種傳統觀點。安全

而根據個人經驗來看,結論偏偏相反。每次我想以犧牲質量來加快速度的時候,結果反而花了更多時間,甚至可能到最後搞不定而放棄了。有了屢次這樣的經驗以後,我決定把每一步都作好,從開發的前期來看,我花的時間比別人多一點,但從整個任務來看,我反而能以別人幾倍的速度完成任務。時間長了,我造成了這樣的觀念:只有寫得好纔可能寫得快。服務器

兩種觀點截然相反,因此咱們都愣了。雖然我相信個人經驗沒有錯,但傳統的鐵三角定律是大師們總結出來的,也不可能出錯。那是怎麼回事呢?我開始處處查資料,可是沒有一我的支持個人觀點。我又不想這樣放棄,後來我用了一個簡單的辦法進行推理,結果證實兩個觀點都有各自的適用範圍。網絡

這個推理過程很簡單,把兩種觀點推向極端:數據結構

先看看以犧牲質量來追求進度的例子。我之前參加過兩個大項目,其一個項目的BUG總數達到17000多個,耗時近三年後項目被取消。另外一個項目的BUG總數也超過10000個,三年以後帶着不少BUG發佈了,結果可想而知,產品很快從市場上消失了。這兩個項目在開始階段都制定了極其好笑的項目計劃,爲了趕在這個根本不可能的最後期限前,都採用了犧牲質量的方式來提升開發速度,前期進展都很「順利」,基本功能點很快就完成了,可是項目立刻陷入了無止境的debug之中,開發人員的士氣一下跌到谷底,管理層開始暴跳如雷。

若是這兩個項目有超過170000個BUG,即便項目不取消,再作時間十年也作不完。因而可知:質量低到必定限度時,下降質量會延長項目時間,若是質量降到最低,那項目永遠也不可能完成。這和個人觀點一致:寫很差就寫不快。

再看看追求完美質量的例子。之前參與一個手機模擬器的開發,咱們很快達到88%的真實度,半年以後達到95%的真實度,客戶要98%的真實度。可是怎麼努力也達不到這個標準,花了極大的代價才達到96%多一點,到後來項目被取消了。

若是要達到99%的真實度,即便項目不取消,再作時間十年也作不完。因而可知:質量高到必定程度,提升質量會延長項目時間,若是質量要高到最高,那任務遠也不可能完成。這和傳統觀點一致,提升質量就要延長開發時間。

從兩個極端往中間走,咱們能夠找到一箇中間質量點。低於這個質量點,想以犧牲質量來趕進度,那隻會拔苗助長,質量越低耗時越長。高於這個質量點,想提升質量就得增長成本,質量越高開發時間越長。這樣兩種觀點就統一塊兒來了。

若是在大多數項目中,這個中間質量點是能夠做爲高質量接受的,那咱們就找到了又快又好的最佳方法。這個質量點究竟是多少?呵,我能夠告訴你,那是87.5。可是誰知道怎麼去度量呢?沒有人知道,只能憑感受和經驗了。

2.咱們的時間花在哪裏

通過這段時間的練習,大多數人都體會到敲代碼不是耗費時間最多的地方,一個高效率的程序員,並非打字比別人快,而他節省了別人浪費了的時間。我常說達到別人五倍的效率並不難,由於在軟件開發中,大部分人的大部分時間都浪費掉了,你只要把別人浪費的時間省下來,你的效率就提升上去了。像在優化軟件性能時採用的方法同樣,要優化程序員的性能,咱們要找出性能的瓶頸。也就是弄清楚咱們的時間花在哪些地方,而後想辦法省掉某些浪費了的時間。根據個人經驗,耗費時間最多的地方有:

o 分析

需求分析一般是SPEC工程師(或者所謂的系統分析員)的任務,程序員也會參與到這個過程當中,但程序員的任務主要是理解需求,而後分析如何實現它們,這個分析工做也就是軟件設計。不管你是在計算機上用設計工具畫出正規的軟件架構圖,還在紙上用天然語言描述出算法的邏輯,甚至在腦海中一閃而過的想法都是設計。設計其實就是打草稿,把你的想法進行推敲,最後獲得可行的方案。設計文檔只是設計的結果,是設計的表現形式,沒有寫設計文檔,並不表明沒有作設計(可是寫設計文檔能夠加深你的思考)。

設計自己是一個思考過程,須要耗費大量時間,對於新手來講更是如此。前面幾節中的需求並不難,理解它們只須要不多的時間,但要花很多時間去思考其實現的方法。這個時間因人而異,有的讀者到最後也沒有想出辦法,這沒有關係,沒有人天生就會的,不會的緣由只是由於你暫時還不知道經常使用的設計方法,甚至連基本數據結構和算法都不熟悉。

在後面的章節中,咱們會一步步的深刻學習各類經常使用設計方法,反覆練習基本數據和算法,熟能生巧,軟件設計也同樣,在你什麼都不懂的時候,不可能作出好的設計。你要學習各類經典的設計方法,開始可能生搬硬套,多寫多練多思考,到後來就爲所欲爲了,設計的時間就會大大縮短。

o測試

要寫得好天然離不開測試,初學者都有這個概念。他們忠實的使用了教科書上講的方法,用scanf輸入數據,作些操做以後,用printf打印來,這是一個完美的輸入-處理-輸出的過程。測試也就是要保證正確的輸入能產生正確的輸出,這種方法的原理是沒有錯的,但它們確實耗費了咱們大量時間。

若是測試只須要作一次,這種方法仍是可取的,問題是每次修改以後都要重複這個過程,耗費的時間就更多了。這種工做單調乏味,並且很難堅持作下去,單元測試作得不全面,就有更多BUG等着就調試了。時間久了,或者換人維護了,誰也搞不清楚什麼樣輸入產生什麼樣的輸出,結果多是連測試也省了,那就等着把大量的時間浪費在調試上吧。總而言之,這種測試方法很差,咱們須要更有效的測試方法才行。

o調試

測試時發現程序有BUG,天然要用調試器了,對一些人來講,調試是一件充滿挑戰和樂趣的事。而對大部分人來講,特別是對我這種作過兩年專職調試的人來講,調試是件無趣無聊無用的工做。熟練使用調試器是必要的,在分析現有軟件時,調試器是很是有用的工具。但在開發新軟件時,調試器在浪費咱們的時間。

調試器是最後一招,只有無可奈何時才使用。一位敏捷方法的高手說他已經記不得上次使用調試器是何時了,我想這就是爲何敏捷方法可以提升開發速度的緣由吧。由於沒有什麼比一次性寫好,不用調試器更快的方法了。

知道了浪費時間的地方,接下來幾節中,咱們將介紹避免浪費時間的方法。學完這些方法以後,我但願讀者也能達到普通工程師五倍的效率,呵,讀完本系列文章後之,但願你會達到更高。

代碼閱讀法

軟件工程實踐已經證實Code Review是提升代碼質量最有效的手段之一,極限編程(XP)更是把Code Review推向極致,造成著名的結對編程工做方式,兩個程序員在一臺電腦前面工做,一我的編寫程序,另外一個Review輸入每一行代碼,寫程序人的專一於目前細節上的工做,Review的人同時要從高層次考慮如何改進代碼質量,兩我的的角色會常常互換。

惋惜我即沒有結對編程的經驗,也沒有在CMM3(及以上)團隊中工做過。不過如今我要介紹比結對編程更敏捷更輕量級,可是一樣有效的Review方法。這種方法不須要其餘程序員配合,有你本身就夠了。爲了把這種方法與傳統的Code Review區分開來,我把它稱爲代碼閱讀法吧。

不少初學者包括一些有經驗的程序員,在敲完代碼的最後一個字符後,立刻開始編譯和運行,迫不急待的想看到本身的工做成果。快速反饋有助於知足本身的成就感,可是同時也會帶來一些問題:

讓編譯器幫你檢查語法錯誤能夠省些時間,但程序員每每太專一這些錯誤了,覺得改完這些錯誤就萬事大吉了。其實否則,不少錯誤編譯器是發現不了的,像內存錯誤和線程死鎖等等,這些錯誤可能逃過簡單的測試而遺留在代碼中,直到集成測試或者軟件發佈以後才暴露出來,那時就要花更大代價去修改它們了。

修改完編譯錯誤以後就是運行程序了,運行起來有錯誤,就輪到調試器上場了。花了很多時間去調試,發現無非是些低級錯誤,或許你會自責本身粗枝大葉,可是下次可能仍是犯一樣的錯誤。更嚴重的是這種debug & fix的方法,每每是頭痛醫頭腳痛醫腳,致使低質量的軟件。

讓編譯器幫你檢查語法錯誤,讓調試器幫你查BUG,這是天經地義的事,但這確實是又慢又爛的方法。就像你要到離家東邊1000米的地方開會,結果你往西邊走,又是坐車又是搭飛機,花了一週時間,也繞着地球轉了一週,終於到了會議室,你還大發感慨說,現代的交通工具真是發達啊。其實你往東走,走路也只要十多分鐘就到了。無論你的調試技巧有多高,都不如一次性寫好更高效。

我之前也同樣,想趕時間結果花了更多時間,在通過不少痛苦的經歷以後,我開始學會放鬆本身,讓本身慢下來。寫完程序以後,我會花些時間去閱讀它,一遍兩遍甚至多遍以後,纔開始編譯它,只要有時間,在經過測試以後,我還會閱讀它們,每讀一遍都有不一樣的收穫,有時候會發現一些錯誤,有時候會作些改進,有時候也有新的想法。

下面是我在閱讀本身代碼時的一些方法:

o檢查常見錯誤。

第一遍閱讀時主要關注語法錯誤、代碼排版和命名規則等等問題,只要看不順眼就修改它們。讀完以後,你的代碼不多有低級錯誤,看起來也比較乾淨清爽。第二遍重點關注常見編程錯誤,好比內存泄露和可能的越界訪問,變量沒有初始化,函數忘記返回值等等,在後面的章節中,我會介紹這些常見錯誤,避免這些錯誤能夠爲你省大量的時間。若是有時間,在測試完成以後,還能夠考慮是否有更好的實現方法,甚至嘗試從新去實現它們。說了讀者可能不相信,在學習編程的前幾年,我常常重寫整個模塊,只我以爲能作得更好,能驗證個人一些想法,或提升個人編程能力,即便連續幾天加班到晚上十一點,我也要重寫它們。

o模擬計算機執行。

常見錯誤是比較死的東西,按照檢查列表一條一條的作就好了。有些邏輯一般不是這麼直觀的,這時能夠本身模擬計算機去執行,假想你本身是計算機,讀入這些代碼時你會怎麼處理。這種方法能有效的完善咱們的思路,考慮不一樣的輸入數據,各類邊界值,這能幫助咱們想到一些沒有處理的狀況,讓程序的邏輯更嚴謹。

o假想講給朋友聽。

聽說在Code Review時發現錯誤的,每每不是Review的人而是程序員本身。我也有不少這樣的經歷,在講給別人聽的時候,別人尚未聽明白,本身已經發現裏面存在的錯誤了。上大學時,我經常把寫的或者學到的東西講給隔壁寢室的一個同窗聽,他說他從我這裏學到不少知識,其實我從講的過程當中,常常發現一些問題,對提升本身的能力大有幫助。惋惜並非隨時都能找到好的聽衆,幸虧咱們有另一個替代辦法,記得剛開始寫程序時看過一本書(忘記名字了),做者說他在寫程序時,經常把思路講給他的布娃娃聽。我沒有布娃娃當聽衆,講給鼠標聽老是有點怪怪的,因此就假想旁邊有個朋友,我把本身的思路講給他聽,同時也假想他來質疑我。這種方法很效,可以讓本身的思路更清晰,聽說一些大師也常用這種方法。

這種代碼閱讀法會花你一些時間,可是能夠省下更多調試時間,並且可以提升代碼質量,能夠說是名符其實的「又快又好的」 祕訣之一。至於讀幾遍合適,要根據狀況而定,我的以爲讀兩到三遍是最佳的投資。

 

 

避免常見錯誤

在C語言中,內存錯誤是最爲人詬病的。這些錯誤讓項目延期或者被取消,引起無數的安全問題,甚至出現人命關天的災難。拋開這些大道理不談,它們確實浪費了咱們大量時間,這些錯誤引起的是隨機現象,即便有一些先進工具的幫助,爲了找到重現的路徑,花上幾天時間也不足爲怪。若是可以在編寫代碼的時候避免這些錯誤,開發效率至少提升一倍以上,質量能夠提升幾倍了。這裏列舉一些常見的內存錯誤,供新手參考。

o 內存泄露

你們都知道,在堆上分配的內存,若是再也不使用了,應該把它釋放掉,以便後面其它地方能夠重用。在C/C++中,內存管理器不會幫你自動回收再也不使用的內存。若是你忘了釋放再也不使用的內存,這些內存就不能被重用了,這就形成了所謂的內存泄露。

把內存泄露列爲首位,倒並非由於它有多麼嚴重的後果,而由於它是最爲常見的一類錯誤。一兩處內存泄露一般不至於讓程序崩潰,也不會出現邏輯上的錯誤,加上進程退出時,系統會自動釋放該進程全部相關的內存(共享內存除外),因此內存泄露的後果相對來講仍是比較溫和的。可是,量變會致使質變,一旦內存泄露過多以至於耗盡內存,後續內存分配將會失敗,程序可能所以而崩潰。

如今PC機的內存夠大了,加上進程有獨立的內存空間,對於一些小程序來講,內存泄露已經不是太大的威脅。但對於大型軟件,特別是長時間運行的軟件,或者嵌入式系統來講,內存泄露仍然是致命的因素之一。

無論在什麼狀況下,採起謹慎的態度,杜絕內存泄露的出現,都是可取的。相反,認爲內存有的是,對內存泄露聽任自流都不是負責的。儘管一些工具能夠幫助咱們檢查內存泄露問題,我認爲仍是應該在編程時就仔細一點,及早排除這類錯誤,工具只是用做驗證的手段。

o 內存越界訪問

內存越界訪問有兩種:一種是讀越界,即讀了不屬於本身的數據,若是所讀的內存地址是無效的,程度馬上就崩潰了。若是所讀內存地址是有效的,在讀的時候不會出問題,但因爲讀到的數據是隨機的,它會產生不可預料的後果。另一種是寫越界,又叫緩衝區溢出,所寫入的數據對別人來講是隨機的,它也會產生不可預料的後果。

內存越界訪問形成的後果很是嚴重,是程序穩定性的致命威脅之一。更麻煩的是,它形成的後果是隨機的,表現出來的症狀和時機也是隨機的,讓BUG的現象和本質看似沒有什麼聯繫,這給BUG的定位帶來極大的困難。

一些工具能夠夠幫助檢查內存越界訪問的問題,但也不能太依賴於工具。內存越界訪問一般是動態出現的,即依賴於測試數據,在極端的狀況下才會出現,除非精心設計測試數據,工具也無能爲力。工具自己也有一些限制,甚至在一些大型項目中,工具變得徹底不可用。比較保險的方法仍是在編程是就當心,特別是對於外部傳入的參數要仔細檢查。

咱們來看一個例子:

#include <stdlib.h>
#include <string.h>

int main(int argc, char* argv[])
{
    char str[10];
    int array[10] = {0,1,2,3,4,5,6,7,8,9};

    int data = array[10];
    array[10] = data;

    if(argc == 2)
    {
        strcpy(str, argv[1]);
    }

    return 0;
}

這個例子中有兩個錯誤是新手常犯的:

其一:int array[10] 定義了10個元素大小的數組,因爲C語言中數組的索引是從0開始的,因此只能訪問array[0]到array[9],訪問array[10]就形成了越界錯誤。

其二:strcpy(str, argv[1]);這裏是否存在越界錯誤依賴於外部輸入的數據,這樣的寫法在正常下可能沒有問題,但受到一點惡意攻擊就完蛋了。除非你肯定輸入數據是在你控制內的,不然不要用strcpy、strcat和sprintf之類的函數,而要用strncpy、strncat和snprintf代替。

o 野指針。

野指針是指那些你已經釋放掉的內存指針。當你調用free(p)時,你真正清楚這個動做背後的內容嗎?你會說p指向的內存被釋放了。沒錯,p自己有變化嗎?答案是p自己沒有變化。它指向的內存仍然是有效的,你繼續讀寫p指向的內存,沒有人能攔得住你。

釋放掉的內存會被內存管理器從新分配,此時,野指針指向的內存已經被賦予新的意義。對野指針指向內存的訪問,不管是有意仍是無心的,都爲此會付出巨大代價,由於它形成的後果,如同越界訪問同樣是不可預料的。

釋放內存後當即把對應指針置爲空值,這是避免野指針經常使用的方法。這個方法簡單有效,只是要注意,固然指針是從函數外層傳入的時,在函數內把指針置爲空值,對外層的指針沒有影響。好比,你在析構函數裏把this指針置爲空值,沒有任何效果,這時應該在函數外層把指針置爲空值。

o 訪問空指針。

空指針在C/C++中佔有特殊的地址,一般用來判斷一個指針的有效性。空指針通常定義爲0。現代操做系統都會保留從0開始的一塊內存,至於這塊內存有多大,視不一樣的操做系統而定。一旦程序試圖訪問這塊內存,系統就會觸發一個異常/信號。

操做系統爲何要保留一塊內存,而不是僅僅保留一個字節的內存呢?緣由是:通常內存管理都是按頁進行管理的,沒法單純保留一個字節,至少要保留一個頁面。保留一塊內存也有額外的好處,能夠檢查諸如p=NULL; p[1]之類的內存錯誤。

在一些嵌入式系統(如arm7)中,從0開始的一塊內存是用來安裝中斷向量的,沒有MMU的保護,直接訪問這塊內存好像不會引起異常。不過這塊內存是代碼段的,不是程序中有效的變量地址,因此用空指針來判斷指針的有效性仍然可行。

o 引用未初始化的變量。

未初始化變量的內容是隨機的(有的編譯器會在調試版本中把它們初始化爲固定值,如0xcc),使用這些數據會形成不可預料的後果,調試這樣的BUG也是很是困難的。

對於態度嚴謹的程度員來講,防止這類BUG很是容易。在聲明變量時就對它進行初始化,是一個好的編程習慣。另外也要重視編譯器的警告信息,發現有引用未初始化的變量,當即修改過來。

在下面這個例子中,全局變量g_count是肯定的,由於它在bss段中,自動初始化爲0了。臨時變量a是沒有初始化的,堆內存str是沒有初始化的。但這個例子有點特殊,由於程序剛運行起來,不少東西是肯定的,若是你想把它們看成隨機數的種子是不行的,由於它們還不夠隨機。

#include <stdlib.h>
#include <string.h>

int g_count;

int main(int argc, char* argv[])
{
    int a;
    char* str = (char*)malloc(100);

    return 0;
}

o 不清楚指針運算。

對於一些新手來講,指針經常讓他們犯糊塗。

好比int *p = …; p+1等於(size_t)p + 1嗎

老手天然清楚,新手可能就搞不清了。事實上, p+n 等於 (size_t)p + n * sizeof(*p)

指針是C/C++中最有力的武器,功能很是強大,不管是變量指針仍是函數指針,都應該很是熟練的掌握。只要有不肯定的地方,立刻寫個小程序驗證一下。對每個細節瞭然於胸,在編程時會省下很多時間。

o 結構的成員順序變化引起的錯誤。

在初始化一個結構時,老手可能不多像新手那樣老老實實的,一個成員一個成員的爲結構初始化,而是採用快捷方式,如:

Struct s
{
    int   l;
    char* p;
};

int main(int argc, char* argv[])
{
    struct s s1 = {4, "abcd"};

    return 0;
}

以上這種方式是很是危險的,緣由在於你對結構的內存佈局做了假設。若是這個結構是第三方提供的,他極可能調整結構中成員的相對位置。而這樣的調整每每不會在文檔中說明,你天然不多去關注。若是調整的兩個成員具備相同數據類型,編譯時不會有任何警告,而程序的邏輯可能相距十萬八千里了。

正確的初始化方法應該是(固然,一個成員一個成員的初始化也行):

struct s
{
    int   l;
    char* p;
};

int main(int argc, char* argv[])
{
    struct s s1 = {.l=4, .p = "abcd"};

    return 0;
}

(有的編譯器可能不支持新標準)

o 結構的大小變化引起的錯誤。

咱們看看下面這個例子:

struct base
{
    int n;

};

struct s
{
    struct base b;
    int m;
};

在OOP中,咱們能夠認爲第二個結構繼承了第一結構,這有什麼問題嗎?固然沒有,這是C語言中實現繼承的基本手法。

如今假設第一個結構是第三方提供的,第二個結構是你本身的。第三方提供的庫是以DLL方式分發的,DLL最大好處在於能夠獨立替換。但隨着軟件的進化,問題可能就來了。

當第三方在第一個結構中增長了一個新的成員int k;,編譯好後把DLL給你,你直接把它給了客戶了,讓他們替換掉老版本。程序加載時不會有任何問題,在運行邏輯可能徹底改變!緣由是兩個結構的內存佈局重疊了。

解決這類錯誤的惟一辦法就是從新編譯所有代碼。由此看來,動態庫並不見得能夠動態替換,若是你想了解更多相關內容,建議你閱讀《COM本質論》。

o 分配/釋放不配對。

你們都知道malloc要和free配對使用,new要和delete/delete[]配對使用,重載了類new操做,應該同時重載類的delete/delete[]操做。這些都是書上反覆強調過的,除非當時暈了頭,通常不會犯這樣的低級錯誤。

而有時候咱們卻被矇在鼓裏,兩個代碼看起來都是調用的free函數,實際上卻調用了不一樣的實現。好比在Win32下,調試版與發佈版,單線程與多線程是不一樣的運行時庫,不一樣的運行時庫使用的是不一樣的內存管理器。一不當心連接錯了庫,那你就麻煩了。程序可能動則崩潰,緣由在於在一個內存管理器中分配的內存,在另一個內存管理器中釋放時就會出現問題。

o 返回指向臨時變量的指針

你們都知道,棧裏面的變量都是臨時的。當前函數執行完成時,相關的臨時變量和參數都被清除了。不能把指向這些臨時變量的指針返回給調用者,這樣的指針指向的數據是隨機的,會給程序形成不可預料的後果。

下面是個錯誤的例子:

char* get_str(void)
{
    char str[] = {"abcd"};

    return str;
}

int main(int argc, char* argv[])
{

    char* p = get_str();

    printf("%s/n", p);

    return 0;
}

下面這個例子沒有問題,你們知道爲何嗎?

char* get_str(void)
{
    char* str = {"abcd"};

    return str;
}

int main(int argc, char* argv[])
{

    char* p = get_str();

    printf("%s/n", p);

    return 0;
}

o 試圖修改常量

在函數參數前加上const修飾符,只是給編譯器作類型檢查用的,編譯器禁止修改這樣的變量。但這並非強制的,你徹底能夠用強制類型轉換繞過去,通常也不會出什麼錯。

而全局常量和字符串,用強制類型轉換繞過去,運行時仍然會出錯。緣由在於它們是放在.rodata裏面的,而.rodata內存頁面是不能修改的。試圖對它們修改,會引起內存錯誤。

下面這個程序在運行時會出錯:

int main(int argc, char* argv[])
{
    char* p = "abcd";
    *p = '1';

    return 0;
}

o 誤解傳值與傳引用

在C/C++中,參數默認傳遞方式是傳值的,即在參數入棧時被拷貝一份。在函數裏修改這些參數,不會影響外面的調用者。如:

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

void get_str(char* p)
{

    p = malloc(sizeof("abcd"));

    strcpy(p, "abcd");

    return;
}

int main(int argc, char* argv[])
{
    char* p = NULL;

    get_str(p);

    printf("p=%p/n", p);

    return 0;
}

在main函數裏,p的值仍然是空值。固然在函數裏修改指針指向的內容是能夠的。

o 重名符號。

不管是函數名仍是變量名,若是在不一樣的做用範圍內重名,天然沒有問題。但若是兩個符號的做用域有交集,如全局變量和局部變量,全局變量與全局變量之間,重名的現象必定要堅定避免。gcc有一些隱式規則來決定處理同名變量的方式,編譯時可能沒有任何警告和錯誤,但結果一般並不是你所指望的。

下面例子編譯時就沒有警告:

t.c

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

int count = 0;

int get_count(void)

{
    return count;
}

main.c

#include <stdio.h>

extern int get_count(void);

int count;

int main(int argc, char* argv[])
{
    count = 10;

    printf("get_count=%d/n", get_count());

    return 0;

}

若是把main.c中的int count;修改成int count = 0;,gcc就會編輯出錯,說multiple definition of `count’。它的隱式規則比較奇妙吧,因此仍是不要依賴它爲好。

o 棧溢出。

咱們在前面關於堆棧的一節講過,在PC上,普通線程的棧空間也有十幾M,一般夠用了,定義大一點的臨時變量不會有什麼問題。

而在一些嵌入式中,線程的棧空間可能只5K大小,甚至小到只有256個字節。在這樣的平臺中,棧溢出是最經常使用的錯誤之一。在編程時應該清楚本身平臺的限制,避免棧溢出的可能。

o 誤用sizeof。

儘管C/C++一般是按值傳遞參數,而數組則是例外,在傳遞數組參數時,數組退化爲指針(即按引用傳遞),用sizeof是沒法取得數組的大小的。

從下面這個例子能夠看出:

void test(char str[20])
{
    printf("%s:size=%d/n", __func__, sizeof(str));
}  

int main(int argc, char* argv[])
{
    char str[20]  = {0};

    test(str);

    printf("%s:size=%d/n", __func__, sizeof(str));

    return 0;
}

[root@localhost mm]# ./t.exe
test:size=4
main:size=20

o 字節對齊。

字節對齊主要目的是提升內存訪問的效率。但在有的平臺(如arm7)上,就不光是效率問題了,若是不對齊,獲得的數據是錯誤的。

所幸的是,大多數狀況下,編譯會保證全局變量和臨時變量按正確的方式對齊。內存管理器會保證動態內存按正確的方式對齊。要注意的是,在不一樣類型的變量之間轉換時要當心,如把char*強制轉換爲int*時,要格外當心。

另外,字節對齊也會形成結構大小的變化,在程序內部用sizeof來取得結構的大小,這就足夠了。若數據要在不一樣的機器間傳遞時,在通訊協議中要規定對齊的方式,避免對齊方式不一致引起的問題。

o 字節順序。

字節順序從來是設計跨平臺軟件時頭疼的問題。字節順序是關於數據在物理內存中的佈局的問題,最多見的字節順序有兩種:大端模式與小端模式。

大端模式是高位字節數據存放在低地址處,低位字節數據存放在高地址處。

小端模式指低位字節數據存放在內存低地址處,高位字節數據存放在內存高地址處;

在普通軟件中,字節順序問題並不引人注目。而在開發與網絡通訊和數據交換有關的軟件時,字節順序問題就要特殊注意了。

o 多線程共享變量沒有用valotile修飾。

關鍵字valotile的做用是告訴編譯器,不要把變量優化到寄存器裏。在開發多線程併發的軟件時,若是這些線程共享一些全局變量,這些全局變量最好用valotile修飾。這樣能夠避免由於編譯器優化而引發的錯誤,這樣的錯誤很是難查。

o 忘記函數的返回值

函數須要返回值,若是你忘記return語句,它仍然會返回一個值,由於在i386上,EAX用來保存返回值,若是沒有明確返回,EAX最後的內容被返回,因此EAX的內容是隨機的。

 

自動測試

手工測試比沒有測試強一點,可是它存在的問題讓它很難在實踐中應用:手工輸入數據的過程單調乏味,很難長期堅持。每次都要從新輸入數據,浪費大量時間。測試用例不能累積,測試每每不完整。用人腦判斷輸出的正誤,浪費人力也存在偏差。要寫得好測試天然不能省,要寫得快就須要更好的測試方法。

更好的測試方法固然是自動測試了。幸運的是,剛進入這個行業我就接觸了自動的測試 (呵,讀本文的初學者就更幸運了),個人第一份正式工做是在測試組寫測試程序。當時測試組也算是人才輩出了,竟然有幾個北大畢業的,不過她們都不懂Linux,因此我被指派去爲移植到Linux上的模塊寫測試程序。這些模塊都有測試程序,但這些測試程序的功能太弱了,個人上司要求開發人員改進,但那些開發人員太自覺得是了,根本不理咱們,因此咱們只好本身重寫這些測試程序。模塊不少,大概有50多個模塊,熟悉這些模塊也須要很多時間,按每兩個工做日寫一個測試程序,上司給我5個月時間。

記得第一個模塊是RDFParser,RDF(資源描述框架)是XML的一種應用,RDFParser其實是一個XML解析器,幷包裝成RDF要求的接口。因爲我對C/C++還不太熟悉,對RDF更不熟悉了,花了兩週時間才寫出這個測試程序。運行起來有些不正常,我確信不是測試程序的問題,就去請開發人員幫忙來看一下。負責RDFParser的那個程序員是人大畢業,我沒有見過第二個比他更自覺得是的程序員了,他剛在我座位上坐下就很大聲說,大家QA的人太蠢了!

當時一聽就愣了,不過我是新來的,見上司都沒反應,天然就忍了。我列舉了一些證據是模塊裏面的問題,他聽也不聽,只是不斷重複的說,不多是我程序的問題,大家QA的人太蠢了,老是浪費個人時間。過了一下子,他終於閉上了嘴巴,又等了一下子才說,等會兒從新發個版本給你吧。後來又請他過來四五次,結果每次都是他的問題。

以後我再沒有聽到他說過大家QA的人太蠢了的話。爲了不讓他抓到把柄來嘲笑測試組,我決定請他來查問題以前作更詳細的測試。當時我寫的測試程序和如今初學者寫的測試程序沒有兩樣,都是從教科書上學來的,先經過scanf從終端輸入數據,調用被測函數,再把結果printf出來,這花了我太多時間。想到後面還有50多個模塊的測試程序要寫,這樣下去不行,必定得想個辦法。

後來我把輸入的數據和指望的結果都寫到一個INI文件中,測試程序從這個文件中讀入數據,運行測試,再和預期結果比較,整個過程都自動化了。寫了一個INI文件的解析器花了我一週時間,又重寫了那個測試程序,整整花了我一個月時間完成RDFParser的測試程序。進度天然大落後了,還好上司知道後並無責備我,讓我慢慢作就行了。

寫第二個測試程序時把INI解析的代碼拷貝過去,再加一些調用模塊的代碼就寫好了,第三個也是如此。寫了幾個以後,我發現了INI解析有個BUG,結果每一個測試程序我都要去修改,想到維護起來太麻煩了,就把INI解析器的接口規範化了,編譯成一個獨立共享庫。又寫了幾個測試程序,我寫煩了,緣由是測試程序無非就是讀入數據,調用被測函數,再檢查結果,這個過程太無聊了。想到後面還要把這個過程重複幾十遍,鬱悶了幾天以後,忽然靈機一動,我決定寫了一個代碼產生器來產生這些代碼。開始的代碼產生器用C寫的,用一個簡單的規則來描述被測函數,經過這些規則來產生測試程序。我把這些東西和INI解析器放在一個獨立的庫中,把它叫做TesterFrameWork,通過幾個測試程序的驗證和完善,後來利用這個TesterFrameWork,只要一兩個小時就能完成一個測試程序了。有次請開發人員那邊一個高手幫我查一個問題,他看一下子個人TesterFrameWork以後,盯着我說,你太聰明瞭。我笑了笑說,剛剛開始寫C/C++程序。

一年以後我知道了有個CPPUnit以後,爲了趕時髦我把TesterFrameWork更名爲CxxUnit,非典的時候放假無聊就把它重寫了一遍放在cosoft上了(以後沒有管過它,或許還在吧)。

一個大系統很難自動測試,而一個獨立的模塊則是最佳的自動測試單元。自動測試和單元測試幾乎成了等價的概念,不少人都覺得自動測試就是利用CPPUnit這樣的單元測試框架寫個測試程序而已,這徹底是錯誤的,就像有人覺得有個設計文檔的模板,照着填空就能填出好設計同樣。

我本身實現過單元測試框架,不是像有些人出於模仿去實現,而徹底出於實際的須要,後來我也研究其它測試框架,應該說我對測試程序框架的認識比通常程序員要深入。我認爲測試程序框架能夠減化一些測試程序的工做,但它與自動測試沒有密切關係,用不用測試程序框架徹底是我的喜愛。用測試程序框架未必能寫出好的測試程序,就像用C++未必能寫出好的面向對象的程序同樣。

雖然我順利的完成了那個寫測試程序的任務,但我一直被一個問題困擾:如何寫測試用例,如何去檢測結果?這是測試程序框架幫不上忙的。寫測試用例還好說,經過邊界值法,等價類法和路徑覆蓋法找到最經常使用的測試用例。檢測結果呢?有人說很簡單啊,判斷返回值就行了。那我問一下dlist_insert返回OK,就真的OK了嗎?若是一個函數根本沒有返回值,那你怎麼判斷呢?

測試程序框架是敏捷論者提倡的,在我看來它根本不夠敏捷:你要去學習它,瞭解它的運行機制,要包含它的頭文件,連接它的庫,有比不用它更敏捷麼?重要的是它根本幫不上什麼有用的忙。前面的問題折磨了我一段時間,因而得出一個可能有點偏激的結論:測試程序框架都是愚蠢的,你真正須要的,它根本幫不了你(我知道這樣說會得罪一些用測試程序框架的朋友,若是你想找我討論的話,請看完本節的附帶示例代碼再說)。

就在那個時候,我看到了孟巖老師翻譯的《契約式設計(Design by Contract)》,讀完以後豁然開朗。或許我尚未明白契約式設計的本質,但我確實知道了寫自動測試程序的方法,下面我介紹一下:

o 在設計時,每一個函數只完成單一的功能。單一功能的函數容易理解,也容易預測其行爲。對測試來講,給定一些輸入數據,就知道它的輸出和影響,這樣函數是最容易測試的。

o 在設計時,把函數分爲查詢和命令兩類。查詢函數只查詢對象的狀態,而不改變對象的狀態。命令函數則只修改對象的狀態,只返回其操做是否成功的標誌,而不返回對象的狀態。好比,dlist_length查詢雙向鏈表的長度,它不修改雙向鏈表的任何狀態。dlist_delete修改對象的狀態(刪除結點),並返回其操做是否成功,而不返回當前長度或者刪除的結點之類的狀態。

o 在設計時,把查詢分爲基本查詢和複合查詢兩類。基本查詢函數只查詢單一的狀態,而複合查詢能夠同時查詢多個狀態。好比,window_get_width返回窗口的寬度,這是基本查詢函數,widget_get_rect返回窗口的左上角座標,寬度和高度,這是複合查詢函數。

o在實現時,檢驗輸入數據,確認使用者正確的調用了函數。契約式設計規定了調用者和實現者雙方的責任,調用者須要使用正確的參數,才能保證有正確的結果。政治家告訴咱們,信任但要檢查,因此做爲實現者就須要檢查輸入參數是否違背了契約。那怎麼檢查呢?有人說,若是檢查到無效參數就返回一個錯誤碼。這固然能夠,只是不太好,由於大多數人都沒有檢查返回值的習慣,若是每一個地方都檢查函數的返回值,也是件很繁瑣的事,代碼看起來也比較亂。一般咱們只檢查一些關鍵的地方,對於無效參數這樣的錯誤,可能就無聲無息的隱藏起來了,這樣很差,由於隱藏得越深,發現的時間越晚,修改的代價越大。

在C++和Java裏,若是參數不正確,一般是throw一個無效參數之類的異常,C語言裏面沒有異常這個概念,咱們須要其它辦法才行。有人推薦用assert來檢查,這是一個好辦法,assert只在調試版本中有效(沒有定義NDEBUG),這樣任何無效調用都在調試版本中暴露出來了。若是配合前面返回錯誤碼的方法,在發佈版本中也可能避免程序粗暴的死掉。使用方法以下:

assert(thiz != NULL);
    if(thiz == NULL)
    {
        return DLIST_RET_INVALID_PARAMS;
    }

我一直使用這種方法,可是有個問題:沒法用自動測試驗證assert是否正常的觸發了,當用錯誤的參數測試時,我指望assert被觸發,但若是assert被觸發了,自動程序測試就死掉了,自動測試程序死掉了,就沒法繼續驗證下一個assert。這是一個悖論!

後來我從glib裏面學了一招,它檢查時不用assert,只是打印出一個警告,代碼也簡明瞭,按它的方式,咱們這樣檢查:

return_val_if_fail(cursor != NULL, DLIST_RET_INVALID_PARAMS);

咱們須要定義兩個宏,一個用於無返回值的函數,一個用於有返回值的函數:

#define return_if_fail(p) if(!(p)) /
    {printf("%s:%d Warning: "#p" failed./n", /
        __func__, __LINE__); return;}
#define return_val_if_fail(p, ret) if(!(p)) /
    {printf("%s:%d Warning: "#p" failed./n",/
__func__, __LINE__); return (ret);}

這樣一來,遇到無效參數時,能夠看到一個警告信息,同時又不會影響自動測試。

o在測試時,用查詢來驗證命令。命令通常都有返回值,但只檢查返回值是不夠的。好比dlist_delete返回OK,它真的OK了嗎?咱們信任它,但仍是要檢查。怎麼檢查?很簡單,用查詢函數來檢查對象的狀態是否是預期的。

對於dlist_delete,咱們預期:

1.輸入無效參數,指望返回DLIST_RET_INVALID_PARAMS。
2.輸入正確參數,指望:
	函數返回DLIST_RET_OK
	雙向鏈表的長度減一。
	刪除的位置的下一個元素被移到刪除的位置。

在測試程序中檢查時,由於任何不符合指望的結果都是BUG,因此咱們用assert檢查。這樣有問題立刻暴露出來了,定位錯誤比較容易,一般都不須要調試器。咱們這樣來檢查:

assert(dlist_length(dlist) == (n-i));
    assert(dlist_delete(dlist, 0) == DLIST_RET_OK);
    assert(dlist_length(dlist) == (n-i-1));
    if((i + 1) < n)
    {
         assert(dlist_get_by_index(dlist, 0, (void**)&data) == DLIST_RET_OK);
         assert((int)data == (i+1));
    }

(完整的例子請看本節的示例代碼)

o在測試時,用基本查詢去驗證複合查詢。基本查詢和複合查詢返回的應該一致。好比:

Rect rect = {0};
    widget_get_rect(widget, &rect);
    assert(widget_get_width(widget) == rect.width);
    assert(widget_get_height(widget)== rect.height);

o在測試時,預期結果依賴其執行上下文,咱們要按邏輯組織測試用例。前面調用的函數可能改變了對象的狀態,爲了簡化測試,在每組測試用例開始時,都重置對象到初始狀態。

o 在測試時,第一次只寫基本的測試用例,之後逐漸累積,每次發現新的BUG就把相應的測試用例加進去。每次修改了代碼就運行一遍自動測試,保證修改沒有引發其它反作用。

按着上面的原則,應付正常模塊的測試沒有問題了,可是下面的狀況仍然比較棘手:

o 帶有GUI的應用程序。有GUI的程序會給自動的輸入數據和檢查結果帶來困難,有些工具能夠部分解決這個問題,特別是針對Win32下的GUI,我不多在Windows下寫程序,因此對這方面瞭解很少。不過最好的辦法仍是用MVC模型等分離界面和實現,由於界面一般相對比較簡單,能夠手工測試,而實現的邏輯比較複雜,這部分能夠自動測試。後面咱們會專門講解分離界面和實現的方法。

o 有隨機數據輸入。若是有些輸入數據是內部隨機產生的,那你根本沒法預測它的輸出結果和影響。好比遊戲隨機的步驟和無線網絡信號的變化。對於咱們能夠控制的隨機數據,能夠提供額外的函數去獲取這些數據。對於沒法控制的隨機輸入數據,能夠把它們隔離開,在自動測試中,使用固定的數據。

o 多線程運行的程序。多線程的程序也很難自動測試,好比向鏈表中插入一個元素,當你檢查的時候,根本沒法知道鏈表的長度是否增長,也沒法知道剛纔插入的位置是不是你插入的元素,由於這個時候,可能有另一個線程已經把它刪除了,或者又加入了新的數據。不過在單線程的自動測試經過以後,多線程的問題會大大減小,剩下的問題咱們能夠經過其它方式加以免。

寫自動測試程序會花你一些時間,但這個投資能帶來最大的回報:減小後面調試時的浪費,提升代碼的質量,更重要的是你能夠安穩的睡個覺了。

 

Save your work

「Ernst和Young所在的小組決定使用正規的開發理論—他們經常使用削減法,分階段進行開發並具備中途交付能力。他們的步驟包括細緻的分析和設計—正如本章描寫的基本原則同樣。而其餘競爭者徑直開始了編碼,在開始幾個小時裏,Ernst和Young小組落後了。但到中午時Ernst和Young小組倒是遙遙領先了,而到了這一天的最後,他們卻失敗了。致使失敗的緣由不是由於他們的正規方法,而是他們偶然錯誤的把工做文件覆蓋了,最終他們比午飯時所作的估計少交付了一些功能,他們是被沒有使用有效的源程序版本控制這個典型的錯誤給戰勝了。」

–摘自《快速軟件開發》

 

前段時間看探索頻道的《荒野求生祕技(Man & wild)》,我很喜歡這個節目也喜歡那個英國佬,甚至連重播都不會放過。他展現在沙漠、叢林、冰河和雪山等各類環境的求生祕技,他吃蜘蛛、白蟻、蠍子和蜥蜴,邊吃邊說這東西很噁心,可是裏面含有很是的維生素,蛋白質和糖份,可以Save your life,因此要吃下去。

在Man & Code的世界裏,環境好多了,不用面臨危險,尋找水源和食物根本不須要什麼祕訣。這裏咱們不須要求生祕技去Save your life,但咱們須要一些習慣去Save your work。我說過做爲一名高效的程序員,不是由於他打字比別人快,而是由於他省下了別人浪費的時間,有什麼比成果被毀,從頭再來更浪費時間呢?下面我介紹一些習慣,它們簡單有效,根本算不上什麼祕技,但它們可以Save your work,讓你的工做穩步前進。

o 隨時存盤

每次停電時,我都會聽到有人驚呼,完了,個人代碼沒有保存!補回半小時或一個小時的工做不難,在一個好的工做環境裏,這種狀況一年也就會遇到幾回,浪費的時間徹底能夠忽略不計。可是那種感受很難受,會影響你的工做情緒,平白無故的讓你重作你的工做,和由於要改進去重作徹底是兩回事。在我之前工做過的一個公司,有段時間常常跳閘,每週都要停好幾回,怎麼也找不到緣由,後來請人來查,聽說是線路太長,靜電引發的跳閘。通過那段時間的折磨,我養成了一個習慣:寫代碼的時候,平均30秒鐘存盤一次。如今遇到停電,別人驚呼的時候,我開始閉目養神了。

o 使用版本控制系統

和一些老程序員聊天時(呵,其實我也老了),他們常常問起咱們項目有沒有使用版本控制系統,我說固然有了,大二的時候就我用Sourcesafe來管理用powerbuilder寫的代碼了,後來的工做中一直在使用不一樣的版本控制系統。接着他們開始講述他們慘痛的經歷…這些經歷小則讓他們項目延期,大則致使整個項目失敗。

版本控制系統有不少功能,但對我我的來講,它最重要的功能是備份代碼。每完成一個小功能,我都會把它提交(checkin)進去,若是我不當心刪除了本地文件,或者某個作嘗試的修改失敗了,我能夠恢復代碼到前一個版本。不一樣團隊有不一樣的規則,有的團隊是不容許這樣checkin的,他們只容許checkin通過嚴格測試的代碼。若是是那樣,你能夠在本地創建本身的版本控制系統,初學者在學習時也能夠這樣作。如今有不少免費的版本控制系統可用,像CVS、SVN和Git等等,我我的習慣用CVS,SVN是CVS的改進版,未來確定會替代SVN的,因此推薦你們使用它。

o 按期備份

溫伯格在《Quality Software Management: System Thinking》講了一個有趣的故事,他之前去研究一些失敗的案例,發現這些項目的失敗都是由於欠佳的運氣引發的:好比遭受到洪水、地震、火災和流行感冒等災害,項目主管們把本身描述成外部問題的受害者。他又對另一些成功的項目進行研究,發現其中有些項目一樣經歷這些天然災害,可是他們成功的完成了任務。區別只是在於成功項目的主管,採用積極預防措施,按期備份代碼,把它們放到不一樣的地點。

之前在學校的時候,我有兩臺電腦,一臺賽揚和一臺486。我常常在上面重裝系統,一下子裝Linux,一下子裝NT,一下子又裝Netware。雖然我常常把代碼備份到不一樣的分區上,結果還不當心把全部分區全乾掉了,讓我痛心不已。那只是寫的一些小程序,重寫一遍問題也不大,可是對於專業程序員或一個軟件團隊來講,重寫整個代碼就不能接受了,因此須要更可靠的備份機制。

使用源代碼管理系統還不能保證代碼的安全,好比服務器硬盤損壞和辦公室發生火災等都是可能發生。團隊裏必定要有人負責按期備份源代碼管理系統系統上的資料,做爲初學者也應該有這種意識。另外,我發現有些朋友把重要的資料放在郵箱裏,如今的郵箱容量很大,由於提供商會按期備份,很是安全,這卻是一個不錯的主意。

o 狀態很差就作點別的

女同胞有按期狀態不佳的時候,男同胞也不是天天狀態都很好。感冒了、丟東西了、或者家人爭吵了,都會影響你的狀態。狀態很差的時候作事,每每是進一步退兩步,甚至犯下嚴重的錯誤。有次我得了重感冒,竟然在服務器的根目錄下運行rm * -rf(刪除所有文件),因爲刪除的時間太長,才讓我發現刪錯地方了,嚇得我出了一身冷汗,還好那臺服務器不是運行着源代碼管理系統,但仍是浪費了我兩天時間去重建服務器上的環境。

狀態很差的時候編程也會犯一些低級錯誤,讓你花費更多時間去調試。總要言之,狀態很差的去作重要的事有害無益,這時你不防去作點別作的,好比看看其它模塊的代碼之類的,甚至徹底放鬆去休息都比犯下嚴重的錯誤強。

相關文章
相關標籤/搜索