樸實的C++設計

來源:互聯網
上載者:User

2012-03-06 wcdj

系統重構需要考慮哪些注意事項,偶讀陳碩大牛的一篇文章,頗有借鑒意義,記錄如下。

                                                                                                                                                          

原文作者: 陳碩

Stems form: http://www.iteye.com/topic/736269

(這篇文章寫於 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++語言限制的技巧。當然,也是一些人掛在嘴邊用來忽悠別人或麻痹自己的靈丹妙藥。

                                                                                                                                                                                                                                                                   

PS: 關於一些不錯的comments

hyf:

我也贊同實用主義,喜歡扁平化的類體系。我覺得使用物件導向其實是把平面複雜度轉換成立體複雜度,從流程理解變成互動模型理解。複雜性並被沒有消除,只是轉化成另一種形式。

物件導向具有類比現實的優勢,所以人普遍覺得它更容易理解和把握。優勢不多說,我只彈一些缺點。

1、它對系統的詮釋遠沒有結構化分析來得嚴格和精確。不視角得到不同的結果,這種視角差異可能會導致別人看你的設計彆扭,缺乏認同感,甚至很難接受。或者,有時過一段時間,自己也會看自己的設計不順眼。做出一個好的物件導向設計不容易。

2、它設計的失誤很難糾正。物件導向的抽象粒度比過程式大,就註定它犧牲了靈活性。到後來發現抽象有點問題的時候,整個系統都依賴於這種設計了,這個時候已經很難糾正了。要麼弄些邋遢的補丁,要麼用一些複雜設計來補救,這裡可能費心思用某某設計模式,但原本其實是不必要存在的。

3、可能會設計過度。成熟的設計師或者沒這方面的擔憂,但還是有不少人對設計追求過度了。這不是物件導向特有的現象,卻在它這表現最為突出。因為它很鼓勵抽象,“完美設計”太吸引人。
做出些比如:超出了問題域的設計,只增加複雜度不帶來價值的設計,過分“遠見”代價大的設計,不能抽象的也抽象,互動模型過度複雜的抽象等等。

所以我認為物件導向對於哪些類比為主的任務,或者已經有明確實體概念的領域較為擅長,發揮效用最大。而當沒有現實概念對應,依靠作者本人理解,又或者對系統演化不太明確的情形下,就要十分小心了。推翻設計代價是很大的。

陳碩:

非常深刻的見解!

關於第 2 點,我提供兩個註腳:
1. Linus 在 2007 年炮轟 C++ 時說“——低效的抽象編程模型,可能在兩年之後你會注意到有些抽象效果不怎麼樣,但是所有代碼已經依賴於圍繞它設計的‘漂亮’物件模型了,如果不重寫應用程式,就無法改正。”
http://thread.gmane.org/gmane.comp.version-control.git/57643/focus=57918

2. Google 的 Go 語言在設計時有意禁止了類型繼承:

http://golang.org/doc/go_lang_faq.html#inheritance

這麼做的原因是,如果有一棵類型繼承樹,人們在一開始設計時就得考慮各個 class 在樹上的位置。
隨著時間的推衍,原來正確的決定有可能變成錯誤的。但是更正這個錯誤的代價可能很高。要想把這個
class 在繼承樹上從一個節點挪到另一個節點,可能要觸及所有用到這個 class 的客戶代碼,所有
用到其各層基類的客戶代碼,以及從這個 class 派生出來的classes 的代碼。
簡直牽一髮而動全身,在 C++ 缺乏良好重構工具的語言下,有時候只好保留錯誤,用些 wrapper 或
者 adapter 來掩蓋之。久而久之,設計越來越爛,最後只好推倒重來。
解決辦法之一就是不採用基於繼承的設計,而是寫一些容易使用也容易修改的具體類。

總之,繼承和虛函數是萬惡之源,這條賊船上去就不容易下來。不過還好,在 C++ 裡我們有別的辦法:
http://blog.csdn.net/Solstice/archive/2008/10/13/3066268.aspx

關於此文的更多討論:

[1] 原貼

[2] 透過現象看本質——“樸實的C++設計”一帖中沒說完的話

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.