樸實的C++設計

(這篇文章寫於 2008 年底,「去年」指的是 2007 年。)

去年8月入職,培訓了4個月,12月進入現在這個部門,到現在工作正好一年了。工作內容是軟件開發,具體地說,用C++開發一個網絡應用(TCP not Web),這是我們的外匯交易系統的一個部件。這半年來,和一兩位同事合作把原有的一個C++程序重寫了一遍,並增加了很多新功能,重寫後的代碼不長,不到15000行,代碼質量與性能大大提高。實際上,重寫只花了三個月,9月我們交付了第一個版本,實現了原來的主要功能,吞吐量提高4倍。後面這三個月我們在增加新功能,並準備交付第二個版本。這個項目讓我對C++的使用有了新的體會,那就是「實用當頭,樸實為貴,好用才是王道」。

C++是一門(最)複雜的編程語言,語言雖複雜,不代表一定要用複雜的方式來使用它。對於一個金融交易系統,正確性是首要的,價格/數量/交割日期弄錯了就會賠錢。在編寫代碼時,我們特別注意把代碼寫得儘量簡單直白,讓人一看就懂。為了控制代碼的複雜度,我們採用了基於對象的風格,也就是具體類加全局函數,把C++程序寫得如C語言一般清晰,同時使用一些C++特性和庫來減少代碼。

項目中基本沒有用到面向對象,或者說沒有用到繼承和多態的那種面向對象,不一定非得有基類和派生類的設計才是好設計。引入基類和派生類,或許能帶來靈活性,但是代碼就不如原來透徹了。在不需要這種靈活性的場合,幹嘛要付出這樣的代價呢?我寧願花一天時間把幾千行 C 代碼弄懂,也不願在幾十個類組成的繼承體系裡繞來繞去浪費腦力。定義並使用清晰一致的接口很重要,但「接口」不一定非得是抽象基類,一個類的成員函數就是它的接口。如果看頭文件就能明白這個類在幹什麼、該怎麼用固然很好,如果不明白,打開實現文件,東西都在那兒擺著呢,一望而知。沒必要非得用個抽象的接口類把使用者和實現隔開,再把實現隱藏起來,這除了讓查找並理解代碼變麻煩之外沒有任何好處。一個進程內部的解耦意義不大,相反,函數調用是最直接有效的通信方式。或許採用接口類/實現類的一個可能的好處是依賴注入,便於單元測試。經過權衡比較,我們發現針對各個類寫測試的意義不大。另外,如果用白盒測試,那麼功能代碼和測試代碼就得同步更新,會增加不少工作量,礙手礙腳。

程序裡邊有一處用到了繼承,因為它能簡化設計。這是一個strategy,涉及一個基類和3、4個派生類,所有的類都沒有數據成員,只有虛函數。這幾個類的代碼加起來不到200行。這個設計不是一開始就有的,而是在項目進行了一大半的時候,我們發現代碼裡有若干處針對請求類型的switch/case,於是我們提煉出了一個strategy,把好幾處switch/case替換為了strategy對象的虛函數調用,從而簡化了代碼。這裡我們純粹把OO當做函數指針表來用的。

程序裡還有幾處用了模板,甚至是type traits,這都是為了簡化代碼,少敲鍵盤。這些代碼都藏在一個角落裡,對外只暴露出一個全局函數的接口,使用者不會被其困擾。

項目裡,我們惟一仰賴的C++特性是確定性析構,即一個對象在離開其作用域之後會保證調用析構函數。我們利用這點大大簡化了代碼,並確保資源和內存的回收。在我看來,確定性析構是C++區別其他主流開發語言(Java/C#/C/動態腳本語言)的最主要特性。

為了確保正確性,我們另外用Java寫了一個測試夾具(test harness)來測試我們這個C++程序。這個測試夾具模擬了所有與我們這個C++程序打交道的其他程序,能夠測試各種正常或異常的情況。基本上任何代碼改動和bug修復都在這個夾具中有體現。如果要新加一個功能,會有對應的測試用例來驗證其行為。如果發現了一個bug,先往夾具裡加一個或幾個能復現bug的測試用例,然後修復代碼,讓測試通過。我們積累了幾百個測試用例,這些用例表示了我們對程序行為的預期,是一份可以運行的文檔。每次代碼改動提交之前,我們都會執行一遍測試,以防低級錯誤發生。

我們讓每個類有明確的職責範圍,一個類代表一個概念,不能像個雜貨鋪一樣什麼都裝。在增加或修改功能的時候,仔細考慮在哪兒下手才最合理。必要時可以動大手腳,而不是每次都選擇最簡單的修補方式,那樣只會使代碼越來越臭,積重難返,重蹈上一個版本的覆轍。有時我們會提煉出一個新的類,把原來分散在多個類裡的代碼集中到一起,從而優化結構。我們有測試夾具保障,並不擔心修改會破壞什麼。

設計不是一開始就形成的,而是隨著項目進展逐步演化出來。我們的設計是基於類的,而不是基於類的繼承體系。我們是在寫應用,不是在寫框架,在C++裡用那麼多繼承對我們沒好處。一開始我們只有三四個類,實現了基本的報價功能,然後增加了一個類,實現了下單功能。這時我們把報價和下單的共同數據結構提煉成一個新的類,作為原來兩個類的成員(而不是基類!),並把解析客戶輸入的代碼移到這個類裡。我們的原則是,可以有特別簡單的類,但不宜有特別複雜的類,更不能有大怪獸。一個類太大,我們就看看能不能把它拆成兩個,把責任分開。兩個類有共同的代碼邏輯,我們會考慮提煉出一個工具類來用,輸入數據的驗證就是這麼提煉出來的一個類。勿以善小而不為,所以始終能讓代碼保持清晰易懂。

讓代碼保持清晰,給我們帶來了顯而易見的好處。錯誤更容易暴露,在發佈前每多修復一個錯誤,發佈後就少一次半夜被從被窩裡叫醒查錯的機會:)

不要因為某個技術流行而去用它,除非它確實能降低程序的複雜性。畢竟,軟件開發的首要技術使命是控制複雜度,防止腦袋爆掉。對於繼承要特別小心,這條賊船上去就下不來,除非你是繼承boost::noncopyable 講解面向對象的書裡,總會舉一些用繼承的精巧的例子,比如矩形、正方形、圓形繼承自形狀,飛機和麻雀繼承自「能飛的」,這不意味著繼承處處適用。我認為在C++這樣需要自己管理內存和對象生命期的語言裡,大規模使用面向對象、繼承、多態多是自討苦吃。還不如用C語言的思路來設計,在局部用一用繼承來代替函數指針表。而GoF的《設計模式》與其說是常見問題的解決方案,不如說是繞過(work around)C++語言限制的技巧。當然,也是一些人掛在嘴邊用來忽悠別人或麻痺自己的靈丹妙藥。