(這篇文章寫于 2008 年底,“去年”指的是 2007 年。)
去年8月入職,培訓(xùn)了4個月,12月進(jìn)入現(xiàn)在這個部門,到現(xiàn)在工作正好一年了。工作內(nèi)容是軟件開發(fā),具體地說,用C++開發(fā)一個網(wǎng)絡(luò)應(yīng)用(TCP not Web),這是我們的外匯交易系統(tǒng)的一個部件。這半年來,和一兩位同事合作把原有的一個C++程序重寫了一遍,并增加了很多新功能,重寫后的代碼不長,不到15000行,代碼質(zhì)量與性能大大提高。實(shí)際上,重寫只花了三個月,9月我們交付了第一個版本,實(shí)現(xiàn)了原來的主要功能,吞吐量提高4倍。后面這三個月我們在增加新功能,并準(zhǔn)備交付第二個版本。這個項(xiàng)目讓我對C++的使用有了新的體會,那就是“實(shí)用當(dāng)頭,樸實(shí)為貴,好用才是王道”。
C++是一門(最)復(fù)雜的編程語言,語言雖復(fù)雜,不代表一定要用復(fù)雜的方式來使用它。對于一個金融交易系統(tǒng),正確性是首要的,價格/數(shù)量/交割日期弄錯了就會賠錢。在編寫代碼時,我們特別注意把代碼寫得盡量簡單直白,讓人一看就懂。為了控制代碼的復(fù)雜度,我們采用了基于對象的風(fēng)格,也就是具體類加全局函數(shù),把C++程序?qū)懙萌鏑語言一般清晰,同時使用一些C++特性和庫來減少代碼。
項(xiàng)目中基本沒有用到面向?qū)ο,或者說沒有用到繼承和多態(tài)的那種面向?qū)ο,不一定非得有基類和派生類的設(shè)計才是好設(shè)計。引入基類和派生類,或許能帶來靈活性,但是代碼就不如原來透徹了。在不需要這種靈活性的場合,干嘛要付出這樣的代價呢?我寧愿花一天時間把幾千行 C 代碼弄懂,也不愿在幾十個類組成的繼承體系里繞來繞去浪費(fèi)腦力。定義并使用清晰一致的接口很重要,但“接口”不一定非得是抽象基類,一個類的成員函數(shù)就是它的接口。如果看頭文件就能明白這個類在干什么、該怎么用固然很好,如果不明白,打開實(shí)現(xiàn)文件,東西都在那兒擺著呢,一望而知。沒必要非得用個抽象的接口類把使用者和實(shí)現(xiàn)隔開,再把實(shí)現(xiàn)隱藏起來,這除了讓查找并理解代碼變麻煩之外沒有任何好處。一個進(jìn)程內(nèi)部的解耦意義不大,相反,函數(shù)調(diào)用是最直接有效的通信方式;蛟S采用接口類/實(shí)現(xiàn)類的一個可能的好處是依賴注入,便于單元測試。經(jīng)過權(quán)衡比較,我們發(fā)現(xiàn)針對各個類寫測試的意義不大。另外,如果用白盒測試,那么功能代碼和測試代碼就得同步更新,會增加不少工作量,礙手礙腳。
程序里邊有一處用到了繼承,因?yàn)樗芎喕O(shè)計。這是一個strategy,涉及一個基類和3、4個派生類,所有的類都沒有數(shù)據(jù)成員,只有虛函數(shù)。這幾個類的代碼加起來不到200行。這個設(shè)計不是一開始就有的,而是在項(xiàng)目進(jìn)行了一大半的時候,我們發(fā)現(xiàn)代碼里有若干處針對請求類型的switch/case,于是我們提煉出了一個strategy,把好幾處switch/case替換為了strategy對象的虛函數(shù)調(diào)用,從而簡化了代碼。這里我們純粹把OO當(dāng)做函數(shù)指針表來用的。
程序里還有幾處用了模板,甚至是type traits,這都是為了簡化代碼,少敲鍵盤。這些代碼都藏在一個角落里,對外只暴露出一個全局函數(shù)的接口,使用者不會被其困擾。
項(xiàng)目里,我們惟一仰賴的C++特性是確定性析構(gòu),即一個對象在離開其作用域之后會保證調(diào)用析構(gòu)函數(shù)。我們利用這點(diǎn)大大簡化了代碼,并確保資源和內(nèi)存的回收。在我看來,確定性析構(gòu)是C++區(qū)別其他主流開發(fā)語言(Java/C#/C/動態(tài)腳本語言)的最主要特性。
為了確保正確性,我們另外用Java寫了一個測試夾具(test harness)來測試我們這個C++程序。這個測試夾具模擬了所有與我們這個C++程序打交道的其他程序,能夠測試各種正常或異常的情況;旧先魏未a改動和bug修復(fù)都在這個夾具中有體現(xiàn)。如果要新加一個功能,會有對應(yīng)的測試用例來驗(yàn)證其行為。如果發(fā)現(xiàn)了一個bug,先往夾具里加一個或幾個能復(fù)現(xiàn)bug的測試用例,然后修復(fù)代碼,讓測試通過。我們積累了幾百個測試用例,這些用例表示了我們對程序行為的預(yù)期,是一份可以運(yùn)行的文檔。每次代碼改動提交之前,我們都會執(zhí)行一遍測試,以防低級錯誤發(fā)生。
我們讓每個類有明確的職責(zé)范圍,一個類代表一個概念,不能像個雜貨鋪一樣什么都裝。在增加或修改功能的時候,仔細(xì)考慮在哪兒下手才最合理。必要時可以動大手腳,而不是每次都選擇最簡單的修補(bǔ)方式,那樣只會使代碼越來越臭,積重難返,重蹈上一個版本的覆轍。有時我們會提煉出一個新的類,把原來分散在多個類里的代碼集中到一起,從而優(yōu)化結(jié)構(gòu)。我們有測試夾具保障,并不擔(dān)心修改會破壞什么。
設(shè)計不是一開始就形成的,而是隨著項(xiàng)目進(jìn)展逐步演化出來。我們的設(shè)計是基于類的,而不是基于類的繼承體系。我們是在寫應(yīng)用,不是在寫框架,在C++里用那么多繼承對我們沒好處。一開始我們只有三四個類,實(shí)現(xiàn)了基本的報價功能,然后增加了一個類,實(shí)現(xiàn)了下單功能。這時我們把報價和下單的共同數(shù)據(jù)結(jié)構(gòu)提煉成一個新的類,作為原來兩個類的成員(而不是基類。,并把解析客戶輸入的代碼移到這個類里。我們的原則是,可以有特別簡單的類,但不宜有特別復(fù)雜的類,更不能有大怪獸。一個類太大,我們就看看能不能把它拆成兩個,把責(zé)任分開。兩個類有共同的代碼邏輯,我們會考慮提煉出一個工具類來用,輸入數(shù)據(jù)的驗(yàn)證就是這么提煉出來的一個類。勿以善小而不為,所以始終能讓代碼保持清晰易懂。
讓代碼保持清晰,給我們帶來了顯而易見的好處。錯誤更容易暴露,在發(fā)布前每多修復(fù)一個錯誤,發(fā)布后就少一次半夜被從被窩里叫醒查錯的機(jī)會:)
不要因?yàn)槟硞技術(shù)流行而去用它,除非它確實(shí)能降低程序的復(fù)雜性。畢竟,軟件開發(fā)的首要技術(shù)使命是控制復(fù)雜度,防止腦袋爆掉。對于繼承要特別小心,這條賊船上去就下不來,除非你是繼承boost::noncopyable 講解面向?qū)ο蟮臅铮倳e一些用繼承的精巧的例子,比如矩形、正方形、圓形繼承自形狀,飛機(jī)和麻雀繼承自“能飛的”,這不意味著繼承處處適用。我認(rèn)為在C++這樣需要自己管理內(nèi)存和對象生命期的語言里,大規(guī)模使用面向?qū)ο蟆⒗^承、多態(tài)多是自討苦吃。還不如用C語言的思路來設(shè)計,在局部用一用繼承來代替函數(shù)指針表。而GoF的《設(shè)計模式》與其說是常見問題的解決方案,不如說是繞過(work around)C++語言限制的技巧。當(dāng)然,也是一些人掛在嘴邊用來忽悠別人或麻痹自己的靈丹妙藥。