天天看點

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之三:原則第3章

點選檢視第一章 點選檢視第二章

第3章

原則

我建議學生們把更多的精力放在學習基本思想上,而不是新技術上,因為新技術在他們畢業之前就有可能過時,而基本思想則永遠不會過時。

—David L. Parnas

在本章,我将介紹設計良好的和精心制作的軟體需要遵循哪些最基本的原則。這些基本原則的特别之處在于,它們并不是隻針對某些程式設計案例或者程式設計語言,其中一些原則甚至并不是專用于軟體開發的。例如,我們讨論的KISS原則可以适用于生活的很多方面,一般來說,不僅是軟體開發,把生活中的一切事情變得盡可能簡單并不一定都是壞事。

也就是說,下面這些原則我們不應該學一次就忘掉,建議熟練掌握它們。這些原則非常重要,理想情況下,它們會成為每個開發人員的第二天性。我在後面章節中即将讨論到的很多具體原則都是基于以下這些基本原則的。

3.1 什麼是原則

在本書中,你會發現許多編寫更好的C++代碼和設計良好的軟體的原則,但到底什麼是原則呢?

許多人都有一些指導他們一生的原則。舉個例子,如果你因為某些原因不吃肉,那麼這可能就是原則;如果你想保護你的小孩,那麼你會教他一些原則,指導他可以自己做出正确的決定,比如“不要和陌生人說話!”隻要将這個原則記住,孩子就可以在特定的場合下做出正确的決定。

原則是一種規則、信仰或者指引你的觀念,原則通常與價值觀或價值體系直接聯系。例如,我們不需要被告知同類相殘是錯誤的,因為人們對人類生活有天生的價值觀。更好的一個例子是Agile Manifesto [Beck01] 包含了12條原則,指導項目團隊開展靈活項目。

原則并不是不可改變的法律,更沒有明文規定地刻在石頭上。而且在程式設計的時候有時故意違背其中一些原則是有必要的,隻要你有充分的理由違背原則,就可以去做,但是做的時候一定要小心!因為結果很可能會出乎你的意料。

以下幾項基本原則,将會在本書後面的各個章節分别進行回顧及強化。

3.2 保持簡單和直接原則(KISS)

任何事情都應該盡可能簡單,而不是稍微簡單一點。

—Albert Einstein, theoretical physicist, 1879—1955

KISS是“Keep it simple,stupid”或“Keep it simple and stupid”的縮寫(對于這個縮寫有很多其他的意思,這兩個是最常用的)。在極限程式設計中(extreme programming),簡稱XP,這個原則有一個更有實踐意義的名字“Do the simplest thing that could possibly work”(DTSTTCPW),即簡單到隻要能正常工作就好。KISS原則旨在在軟體開發中,把簡單當作一個主要的目标,避免做一些沒有必要的複雜性的工作。

我認為在軟體開發過程中,軟體開發者經常會忘記KISS原則,程式員偏向以精心設計的方式編寫代碼,這樣導緻的結果是他們往往将問題複雜化。我知道,我們都是技術精湛、積極進取的開發人員,而且我們也了解設計和架構模式、架構、技術、工具以及其他酷炫和奇特的東西,開發很酷的軟體不隻是我們朝九晚五的一個工作而已—它已經成為了我們的使命,我們因我們的工作而變得更有成就感。

但是你必須記住,任何軟體系統都有内在的複雜性,毫無疑問,複雜問題通常需要複雜的代碼。内在的複雜性是不可避免的,由于系統需要滿足需求,是以這種複雜性客觀存在。但是,為這種内在的複雜性添加不必要的複雜性将是緻命的。是以,建議不要僅因為你會用,就把一些花哨技巧或一些很酷的設計模式都用在你所使用的程式設計語言中。另一方面,也不要過分簡單,如果在switch-case判斷中有十個條件是必需的,那它就應該有十個條件。

保持代碼盡可能簡單!當然,如果對靈活性和可擴充性有很高的品質要求,則必須增加軟體的複雜性以滿足這些需求。例如,你可以使用衆所周知的政策模式(請參閱第9章“設計模式”),如果需求需要的話,在代碼中引入靈活的可變點。但要小心,隻添加那些使事情整體變得更簡單的複雜性的東西。

對于程式員來說,關注簡單性可能是最困難的事情之一,并且這是一個終生的學習經驗。

—Adrian Bolboaca (@adibolb), April 3, 2014, on Twitter

3.3 不需要原則(YAGNI)

總是在你真正需要的時候再實作它們,而不是在你隻是預見到你需要它們的時候實作它們。

—Ron Jeffries, You抮e NOT gonna need it! [Jeffries98]

這一原則與之前讨論的KISS原則緊密相連。YAGNI是“You Aren抰 Gonna Need It!”的縮寫,也可以看作“You Ain抰 Gonna Need It!”的縮寫。YAGNI原則向投機取巧和過度設計宣戰,它的主旨是希望你不要寫目前用不上,但将來也許需要的代碼。

幾乎每個開發者在日常工作中都有這樣一種沖動:“以後我們也許會用到這個功能……”錯,你不會用到它!無論在什麼情況下,你都要抵制開發以後可能用到的功能。畢竟,你可能根本不需要這個功能。如果你已經實作了這種無意義的功能,那麼你花在那上面的時間就浪費了,并且你的代碼也變得更加複雜!當然,你也破壞了KISS原則。更嚴重的是,這些為日後的功能做準備的代碼,充滿了bug并可能導緻嚴重的後果!

我的建議是:在你确定真的有必要的時候再寫代碼,那時再重構仍然來得及。

3.4 避免複制原則(DRY)

複制和粘貼是一個設計錯誤。

雖然這個原則是最重要的,但我确信開發人員經常有意或無意地違反這個原則。DRY是“Don抰 repeat yourself! ”的縮寫。我們應該盡可能避免複制,因為複制是一個非常不好的行為。該原則也稱為“Once And Only Once”(OAOO)原則。

複制是非常危險的,其原因顯而易見:當一段代碼被修改的時候,也必須相應地修改這段代碼的副本,不要抱着不修改副本的期望,可以肯定的是,一定要修改副本。任何複制的代碼片段遲早會被忘記,并且,會因為漏改代碼的副本而産生bug。

就這樣,沒什麼别的了嗎?不是的,還有一些需要我們深入讨論的事情。

在Dave Thomas和Andy Hunt的出色的著作《The Pragmatic Programmer》[Hunt99]中陳述了DRY原則的含義,就是我們要保證“在一個系統内部,任何一個知識點都必須有一個單一的、明确的、權威的陳述。”值得注意的是,Dave和Andy并沒有明确地提到代碼,他們談論的是知識點。一個系統的知識所影響的範圍遠比它的代碼更廣泛。例如,DRY原則同樣也适用于文檔、項目、測試計劃和系統的配置資料。可以說,DRY原則影響了每一件事情!你可以想象一下,嚴格遵守這一原則并不像起初看起來那麼容易。

3.5 資訊隐藏原則

資訊隐藏原則是軟體開發中一個衆所周知的基本原則,它首先記錄在開創性論文“On the Criteria to Be Used in Decomposing Systems Into Modules”[Parnas72]中,由David L. Parnas于1972年撰寫。

該原則指出,一段代碼調用了另外一段代碼,那麼,調用者不應該知道被調用者的内部實作。否則,調用者就有可能通過修改被調用者的内部實作而完成某個功能,而不是強制性地要求調用者修改自己的代碼。

David L. Parnas認為資訊隐藏是把系統分解為子產品的基本原則,Parnas同樣認為系統子產品化是為了隐藏困難的設計決策或可能改變的設計決策,應該涉及隐藏困難的設計決策或可能改變的設計決策,軟體單元(例如,類或元件)暴露于其環境的内部構件越少,該單元的實作與其用戶端之間的耦合就越低。是以,軟體單元内部實作的更改将不會被其使用者所察覺。

資訊隐藏有很多優點:

□限制了子產品變更的範圍。

□如果需要修複缺陷,對其他子產品的影響最小。

□顯著提高了子產品的可複用性。

□子產品具有更好的可測試性。

資訊隐藏通常與封裝混淆,但其實它們不一樣,這兩個術語在許多著名的書籍中是同義詞,但我并不這麼認為。資訊隐藏是幫助開發人員找到好的設計子產品的原則,該原則适用于多個抽象層次并能展現其正面效果,特别是在大型系統中。

封裝通常是依賴于程式設計語言的技術,用于限制對子產品内部的通路。例如,在C++中,你可以在private關鍵字後定義一些類成員,以確定類外部無法通路它們,但我們僅用這種防護方式進行通路控制,離自動隐藏資訊還遠着呢,封裝有助于但不能保證資訊隐藏。

以下代碼示例展示了隐藏資訊較差的封裝類:

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之三:原則第3章
帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之三:原則第3章

這不是資訊隐藏,因為類内部的實作部分暴露給了外部環境,盡管該類看起來封裝得很好。注意getState傳回值的類型,用戶端用到的枚舉類State用到了這個類,如下示例所示:

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之三:原則第3章

枚舉類(結構體)[C++11]

在C++11中,枚舉類型也有了創新。為了向下相容早期的C++标準,現在仍存在衆所周知的枚舉類型及其關鍵字enum。從C++11開始,我們還引入了枚舉類和枚舉結構體。

舊的C++枚舉類型有一個壞處是,它們将枚舉成員引入周圍的命名空間,導緻了名稱沖突,如下示例所示:

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之三:原則第3章

此外,舊的C++ enum會隐式轉換為int,當我們不預期或不需要這樣的轉換時會導緻難以察覺的錯誤:

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之三:原則第3章

當使用枚舉類(也稱為“新枚舉”或“強枚舉”)時,這些問題将不再存在,它們的枚舉成員對枚舉來說是局部的,并且它們的值不會隐式轉換為其他類型(比如另一個枚舉或int類型)。

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之三:原則第3章

對于現代C++程式,強烈建議使用枚舉類而非普通的舊的枚舉類型,因為它使代碼更安全,并且因為枚舉類也是類,是以它們可以前向聲明。

如果必須更改AutomaticDoor的内部實作并從類中删除枚舉類State,那麼會發生什麼呢?很容易看出它會對用戶端的代碼産生重大影響,它将導緻使用成員函數AutomaticDoor::getState()的所有地方都要進行更改。

以下是具有良好的資訊隐藏性的封裝的AutomaticDoor類:

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之三:原則第3章
帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之三:原則第3章

現在,修改AutomaticDoor類的内部要容易實作得多。用戶端代碼不再依賴于類的内部實作。現在你可以在不引起該類任何使用者注意的情況下,删除State枚舉并将其替換為另一種實作。

3.6 高内聚原則

軟體開發中的一條通用建議是,任何軟體實體(如子產品、元件、單元、類、函數等)應該具有很高的(或強的)内聚性。一般來講,當子產品實作定義确切的功能時,應該具有高内聚的特性。

為了深入研究該原則,讓我們來看兩個例子,這兩個例子沒有太多的關聯,從圖3-1開始。

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之三:原則第3章

在上面的例子中,子產品随意劃分,業務領域三個不同的功能放在了一個子產品内。功能A、功能B和功能C之間基本沒有什麼共同點,但是這三個功能卻被放在MyModel子產品中。閱讀子產品的代碼就會發現,功能A、功能B和功能C在不同的、完全獨立的資料上運作。

現在,觀察圖3-1中所有的虛線箭頭,箭頭指向的每一個子產品都是一個被依賴者,箭頭尾部的子產品需要箭頭指向的子產品來實作。在這種情況下,系統中的其他子產品想要使用功能A、功能B或功能C時,調用的子產品就會依賴于整個MyModule子產品。這樣設計的缺點是顯而易見的:這會導緻太多的依賴,并且可維護性也會降低。

為了提高内聚性,功能A、功能B和功能C應該彼此分離(見圖3-2)。

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之三:原則第3章

現在,很容易地看出,每個子產品的依賴項比舊的MyModule的依賴項少得多。很明顯,子產品A、子產品B、子產品C之間沒有直接的關系。Module1是唯一依賴子產品A、子產品B和子產品C的子產品。

另外一個低内聚的形式是散彈槍反模式(Shot Gun Anti-Pattern)。我想大家應該都聽說過,霰彈槍是一種能發射大量小鐵沙的武器,這種武器通常有很大的散射性。在軟體開發中,這種隐喻用于描述某個特定領域方面或單個邏輯思想是高度碎片化的,并且分布在許多子產品中,圖3-3描述了這種情況。

在這種低内聚方式下,出現了許多不利的依賴關系,功能A的各個片段必須緊密結合在一起。這就意味着,實作功能A子集的每個子產品必須至少與一個包含功能A子集的子產品互動。這會導緻大量的依賴性交叉。最壞的情況是導緻循環依賴;比如,子產品1和子產品3之間,或子產品6和子產品7之間。這再一次對可維護性和可擴充性産生了負面影響。當然,這種設計的可測試性也是非常差的。

這種設計将導緻所謂的“霰彈槍手術”。對功能A的某種修改會導緻很多子產品進行或多或少的修改,這真的很糟糕,并且應該避免。我們應該把與功能A相關的所有代碼都拿出來,把相同邏輯的代碼放到一個高内聚的子產品内。

一些其他的原則—例如,面向對象設計的單一職責(SRP)原則(詳見第6章),會促進高内聚性。高内聚往往與松耦合相關,反之亦然。

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之三:原則第3章

3.7 松耦合原則

考慮下面的示例代碼:

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之三:原則第3章
帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之三:原則第3章

這段代碼基本上可以正常運作。首先你需要建立Lamp類的執行個體,然後通過引用方式将Lamp的執行個體傳遞給Switch。這個小例子看起來像圖3-4描述的那樣。

這個設計有什麼問題?

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之三:原則第3章

問題就是,我們的Switch類直接包含了一個具體類Lamp的引用。換句話說,這個Switch類知道那是一個具體的Lamp類。

也許你會争辯說:“好吧,這就是開關的目的,開關必須能夠開燈和關燈。”我會說:“是的,如果這是開關應該做的唯一的一件事情,那麼這個設計就足夠了。但是,請你去商店看看,賣開關的人知道燈的存在嗎?”

你對這個設計的可測試性有什麼看法?在單元測試中,SWitch類可以被單獨測試嗎?顯然這是不可能的。當開關不僅需要打開燈、打開風扇、打開電動卷簾時,我們該怎麼辦?

在上面的例子中,燈和開關是緊耦合的。

在軟體開發過程中,應該尋求子產品間的松耦合(也稱為低耦合或弱耦合)。這意味着你應該建構一個系統,在該系統中,每個子產品都應該很少使用或不知道其他獨立子產品的定義。

軟體開發中,松耦合的關鍵是接口。接口聲明類的公共行為,而不涉及該類的具體實作。接口就像合同,而實作接口的類負責履行契約,也就是說,這些實作接口的類必須為接口的方法簽名提供具體的實作。

在C++中,使用抽象類實作接口,如下所示:

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之三:原則第3章

這個Switch類不再包含Lamp類的引用。相反,它持有了我們新定義的Switchable接口類。

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之三:原則第3章

這個Lamp類實作了我們新定義的Switchable接口。

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之三:原則第3章

用UML類圖表示,我們新設計的類圖看起來像下圖3-5那樣。

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之三:原則第3章

這個設計的優點是顯而易見的。Switch已經能完全獨立于由它控制的具體類。而且,Switch可以通過實作Switchable接口的測試替身進行獨立的測試。如果你想控制一個風扇而不是一盞燈呢?也沒有問題,這個設計對擴充是開放的。隻需要建立一個實作了Switchable接口的風扇類或者電氣裝置的其他類就可以了,詳見圖3-6。

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之三:原則第3章

松耦合可以為系統的各個獨立的子產品提供高度的自治性,該原理可以适用于很多不同的層次:可以用在最小的子產品上,當然,還可以用在大型元件的體系結構上。高内聚會促進松耦合,因為具有明确定義責任的子產品,通常會依賴于較少的其他子產品。

3.8 小心優化原則

不成熟的優化是程式設計中所有問題(或者至少是大部分問題)的根源。

—Donald E. Knuth, American computer scientist [Knuth74]

我發現,開發人員隻有一個模糊的想法就進行程式的優化,但他們并不确切知道性能瓶頸究竟在哪裡。他們經常擺弄個别的訓示,或嘗試優化小的、局部的循環結構以擠出最後一點性能。我也是這些開發人員中的一員,其實這很浪費時間。

一般來講,這些更改對性能的變化是微不足道的,通常不會出現預期的性能提升,這隻會浪費寶貴的時間。相反的是,這種所謂的優化後的代碼的可了解性和可維護性通常會受到嚴重影響。特别糟糕的是,有時在這種優化措施中,一些缺陷反而“巧妙”地被引入到了代碼中。我的建議是:隻要沒有明确的性能要求,就避免優化。

代碼的可了解性和可維護性應該是我們的第一個目标,正如我在第4章“調用開銷”一節中所解釋的那樣,現代的編譯器已經非常擅長優化代碼了,每當你想優化某些代碼時,想想YAGNI原則。

隻有在不滿足利益相關方明确要求的情況下才能采取行動。但是,你應該仔細分析影響性能的地方,不要僅憑直覺進行優化。例如,你可以使用Profiler找出軟體的瓶頸所在。使用這樣的工具後,開發人員常常會驚訝于影響性能的點與最初假設的位置相差甚遠。

注意:Profiler是一種動态程式分析工具。除其他常用名額外,它還測量函數調用的頻率和持續時間,它收集的分析資訊還可用于程式優化。

3.9 最少驚訝原則(PLA)

最少驚訝原則(POLA / PLA),也稱為最少驚喜原則(POLS),它在使用者界面設計和人因工程學設計中很知名。該原則指出不應該讓使用者對使用者界面的意外響應而感到驚訝,也不應該對出現或消失的控件、混亂的錯誤消息、公認的按鍵序列的異常響應(記住,Ctrl+C是在Windows作業系統中複制應用程式的标準事務,而不是退出程式)或其他意外行為而感到困惑。

這個原則也可以很好地應用到軟體開發中的API設計中。調用函數不應該讓調用者感覺到異常行為或一些隐藏的副作用,函數應該完全按照函數名稱所暗示的意義執行(請參閱第4章中4.3.3節“函數命名”)。例如,在類的執行個體上調用getter時不應該修改該對象的内部狀态。

3.10 童子軍原則

這個原則是關于你和你的行為的,其内容是:在離開露營地的時候,應讓露營地比你來之前還要幹淨。

童子軍非常有原則,其中一個原則是,一旦他們發現了一些不好的東西,就立即清理環境中的污染物或那些引起混亂的東西。作為一名負責任的軟體工程師,我們應該将這一原則應用于我們的日常工作,每當我們在一段代碼中發現需要改進的或者風格不好的代碼時,我們應該立即修正它,與這段代碼的原創作者是誰無關緊要。

這種行為的好處是我們能不斷防止自己的代碼被破壞。如果我們都那樣做,代碼就不會變糟,軟體熵增加的趨勢也就沒有機會能占據我們系統的主導地位。改善代碼并不一定要大刀闊斧地去做,也可能隻是一次小小的清理。舉例如下:

□重命名那些命名不佳的類、變量、函數或方法(請參閱第4章中的4.1節“良好的命名”和4.3.3節“函數命名”)。

□将大型函數分解為更小函數(請參閱第4章中4.3.2節“讓函數盡可能小”)。

□讓需要注釋的代碼不言自明,以避免注釋(請參閱第4章中4.2.2節“不要為易懂的代碼寫注釋”)。

□清理複雜而令人費解的if-else組合。

□删除一小部分重複的代碼(請參閱本章中有關DRY原則的部分)。

由于這些改進大多數都是代碼重構,是以如第2章所述,由良好的單元測試組成的堅固的安全體系是必不可少的。沒有單元測試,你根本無法确定你是否破壞了某些東西。

除了良好的單元測試,我們仍然需要團隊中的一種特殊的文化:代碼所有權集體化(Collective Code Ownership)。

代碼所有權集體化意味着我們應該真正地融入團隊。每個團隊成員在任何時候都可以對任何代碼進行更改或擴充,不應該有這樣的态度:“這是Peter的代碼,這是Fred的子產品, 我不會碰它們!”其他人可以接管我們寫的代碼,這應該被當作一種很高的衡量标準,團隊中的任何人都不應該害怕,或者必須獲得許可才能整理代碼或添加新的功能。代碼所有權集體化這種文化将使童子軍原則很好地執行。

繼續閱讀