- 你真的會寫代碼嗎
- (意)馬爾科·法埃拉
- 13793字
- 2021-07-28 17:52:11
第 1 章 軟件質量和待解決問題
本章內容
● 從不同的視角和不同的目標來評估軟件
● 區分內部軟件質量和外部軟件質量
● 區分功能性軟件質量和非功能性軟件質量
● 評估不同方面的軟件質量間的關系和取舍
本書的核心思想是通過對不同方面的代碼質量(又稱非功能需求)進行比較,使你了解經驗豐富的開發者的思維模式。這些質量大多數(例如性能或可讀性)是通用的,適用于任何軟件。為了強調這一事實,每一章都會重復使用相同的示例:一個用來表示水容器系統的簡單的類。
本章將介紹本書涉及的軟件質量以及水容器示例的規范,然后進行初步的代碼實現。
1.1 軟件質量
在本書中,你應該將“質量”一詞理解為軟件或有或無的特征,而不是其整體價值。這就是為什么我會談論多個方面的質量。不過并非所有特征都稱得上是質量。例如,編寫軟件所使用的開發語言無疑是該軟件的特征,但不是質量。質量是可以按某種尺度進行分級的特征,至少在原則上如此。
和所有產品一樣,人們最感興趣的軟件質量是能衡量系統對自身需求的滿足程度的那些。不幸的是,僅僅描述(更不用說完全滿足)軟件的需求也并非易事。事實上,整個需求分析領域都致力于此。為什么呢?難道系統可靠、穩定地提供用戶所需的服務還不夠嗎?
首先,用戶往往不知道自己需要什么服務,他們需要時間和幫助才能明白。其次,系統要做的根本不只是滿足這些需求。它們提供的服務有快有慢;有的準確、有的不準確;有的需要用戶經過長時間的訓練,也有的讓用戶一看就懂(良好設計的UI);等等。另外,隨著時間的推移,你需要修改、修復或改進系統,這帶來了更多質量上的變數:了解系統的內部工作原理有多容易?修改和擴展它而不破壞其他部分有多容易?這樣的例子不勝枚舉。
要在這眾多標準中找到一些規律,專家們建議根據兩類特征進行組織:內部的與外部的,功能性的與非功能性的。
1.1.1 內部質量與外部質量
最終用戶在與系統交互時可以感知到外部質量,但內部質量只能通過查看源代碼來評估。不過兩者之間并不是涇渭分明,最終用戶也可以間接感知到一些內部質量。反之亦然,所有的外部質量從根本上來說都依賴于源代碼。
軟件質量標準
標準化機構ISO和IEC已在1991年的9126標準中定義了軟件質量,該標準在2011年被25010標準代替。
例如,可維護性(修改、修復或擴展軟件的難易程度)是內部質量,但是如果一個缺陷被發現后,程序員需要花費很長時間才能修復,則最終用戶就會感知到它。相反,對錯誤輸入的穩健性通常被認為是外部質量,但是當軟件(也許是一個類庫)沒有直接暴露給最終用戶,而是僅僅與系統的其他模塊交互時,這種穩健性就會成為內部質量。
1.1.2 功能性質量與非功能性質量
第二個分類方式是根據軟件能做什么(功能性質量)和軟件做得如何(非功能性質量)來分類(見圖1-1)。“內部-外部”二分法也適用于這種分類方式:如果軟件執行了某些操作,那么其影響(無論以何種方式)對最終用戶是否可見?因此,所有功能性質量都是外部的。另外,非功能性質量可以是內部的,也可以是外部的,這取決于它們是與代碼本身更相關,還是與外在表現更相關。接下來的幾節會包含這兩種類型的示例。同時請看圖1-2,它將本章介紹的所有方面的質量都放在了一個二維象限中。橫軸表示內部和外部的區別,縱軸表示功能性和非功能性的區別。
圖1-1 功能性需求和非功能性需求從不同方面影響軟件的側重點,你需要進行取舍
圖1-2 將軟件質量按照兩個二分法進行分類:內部與外部(橫軸),功能性與非功能性(縱軸)。書中特別強調的質量在圖中用粗邊框來表示
下一節會介紹最終用戶可以直接評估的主要軟件質量。
1.2 主要的外部軟件質量
軟件的外部質量屬于程序的可觀察行為,因此自然成了軟件開發過程中的核心關注點。除了將這些質量歸于軟件的屬性之外,我還將結合一個普通的老式烤面包機來討論這些質量,試圖以最通用和直觀的方式來描述它們。接下來的幾節介紹了一些最重要的軟件外部質量。
1.2.1 正確性
遵守既定的目標,亦稱需求或規格。
對于烤面包機來說,正確性意味著它必須可以烘烤切片面包,直到面包變得金黃、酥脆為止。對于軟件來說,正確性意味著它必須提供向客戶承諾的功能。這就是功能性質量的定義。
正確性沒有秘訣,但是大家首先會采用各種最佳實踐和軟件開發流程,來提高編寫正確軟件的可能性,以及事后發現缺陷的可能性。本書將聚焦每個開發者在工作中都可以采用的小技巧,與其公司采用的具體開發流程無關。
首先,如果開發人員對目標需求的理解不清楚,那么就不可能有正確性。第5章探討了一個有效的方法:以契約的方式來思考需求,并采取保障措施來執行這些契約。缺陷是不可避免的,捕獲它們的主要方法是模擬軟件交互,即測試。第6章討論了設計測試用例和評估其有效性的系統性方法。最后,采用代碼可讀性的最佳實踐對正確性也是有益的,可以幫助代碼作者及其同事在測試暴露錯誤之前和之后發現問題,從而提高正確性。第7章會介紹一些最佳實踐。
1.2.2 穩健性
對錯誤的輸入或無效(無法預料)的外部條件(例如某些資源的缺失)的容錯能力。
正確性和穩健性有時被一起稱為可靠性。穩健的烤面包機不會因為將百吉餅、叉子放進去,或什么都不放而著火。它也會具有防止過熱等保護措施。1
1烤面包機的穩健性可不是開玩笑的。據估計,全世界每年約有700人死于與烤面包機相關的安全事故。
穩健的軟件會做很多事情,比如檢查輸入是否有效。如果輸入無效,那么它將發出錯誤信號并做出響應。如果錯誤是致命的,那么穩健的程序會在中斷前盡可能多地挽救用戶數據或已執行完的計算。第5章將通過加強方法契約和類不變式的嚴格規范和運行時監控來提高穩健性。
1.2.3 易用性
對學習如何使用軟件并達到其目的所需的工作量的衡量標準;使用的方便程度。
現代的彈出式烤面包機非常易于使用,不需要用推桿將面包推入并開始烘烤,也不需要用旋鈕調節烘烤量。軟件的易用性和它的用戶界面(UI)設計息息相關,并通過人機交互和用戶體驗(UX)設計等學科來解決。本書不會談及易用性,因為本書關注的是不直接暴露給最終用戶的軟件系統。
1.2.4 效率
適當的資源消耗。
烤面包機的效率指的是它完成烤面包任務需要花費多長時間和電力。對軟件而言,時間和空間(內存)是所有程序都需要消耗的兩個資源。第3章和第4章分別討論了時間效率和空間效率。許多程序還需要網絡帶寬、數據庫連接和眾多其他資源。不同的資源間通常需要進行權衡取舍。功率更強的烤面包機可能更快,但需要消耗更多(峰值)電力。類似地,一些程序可能更快,但需要消耗更多內存(稍后會詳述)。
盡管我將效率列為外部質量,但其真正的本質還是模棱兩可的。例如,最終用戶能明顯覺察到執行速度,尤其是在執行速度較慢的情況下。但是,其他資源的消耗,比如網絡帶寬,對用戶并不可見,只能通過專用工具或分析源代碼來評估。這也是我將效率放在圖1-2中稍微靠中間位置的原因。
大多數情況下,效率屬于非功能性質量,因為用戶通常不關心服務的響應時間是1 ms還是2 ms,也不關心網絡傳輸流量是1 KB還是2 KB。但在下面兩種場景中,它會成為功能性質量。
● 在性能敏感的應用中:在這種情況下,保證性能是需求規范的一部分。設想一個與物理傳感器和執行器進行交互的嵌入式設備,其軟件的響應時間必須遵守嚴格的超時時間。否則,輕則導致功能的不一致,重則在工業、醫療或汽車應用中威脅生命安全。
● 當效率差到影響正常操作時:即使對于面向消費者的、沒有那么關鍵的程序,用戶對響應延遲和內存占用的容忍度也是有限的。如果超過了這個限度,效率不足就會上升為一個功能性缺陷。
1.3 主要的內部軟件質量
查看程序的源代碼比運行它能更好地評估其內部質量。接下來的幾節介紹了一些最重要的內部質量。
1.3.1 可讀性
對其他開發者來說清晰易懂。
談論烤面包機的可讀性似乎有些奇怪,不過要意識到,對于所有的內部質量,我們討論的其實都是結構和設計。事實上,軟件質量的相關國際標準將這個特征稱為可分析性。所以,可讀性良好的烤面包機在被打開檢查時,是很容易分析的:它有清晰的內部布局,加熱原件和電子設備進行了很好的分離,電源電路和定時器很易于識別,等等。
可讀性良好的程序很容易被其他程序員理解,或者其作者過了一段時間再回頭看時還能理解。可讀性是極其重要的,而且其價值經常被低估。第7章將介紹這個主題。
1.3.2 可復用性
復用代碼來解決類似問題的難易程度,以及所需的改動量,又稱為適應性。
如果制造烤面包機的公司能夠將其設計和零件用于制造其他電器,那么你可以認為這款烤面包機是可復用的。例如,它的電源線很可能是標準的,因此可以和類似的小型電器兼容;也許它的定時器可以被用在微波爐中;等等。
在歷史上,代碼復用是面向對象(object-oriented,OO)編程范式的一大亮點。經驗證明,使用大量可復用的軟件組件來構建復雜系統的愿景被夸大了。相反,現代編程趨勢更喜歡專為可復用性而設計的庫和框架。在這些庫和框架之上,是一層不那么薄的、不考慮可復用性的應用相關代碼。第9章會介紹可復用性。
1.3.3 可測試性
為程序編寫測試的能力,以及編寫測試是否容易。它能夠觸發所有相關的程序行為,并觀察其結果。
在討論烤面包機的可測試性之前,讓我們嘗試弄清楚對烤面包機的測試大概是什么樣子的。2一個合理的測試程序會將溫度計插入插槽,并開始烘烤。你可以通過觀察經過一段時間后溫度是否十分接近預設值來判斷成功與否。可測試的烤面包機使此過程易于重復執行和自動執行,盡可能不需要人工干預。例如,通過按下按鈕啟動的烤面包機比需要拉動操縱桿的烤面包機更容易測試,因為對于機器來說,按下按鈕比拉動操縱桿要更容易。
2根據一些報道,“如何測試烤面包機”是軟件工程的工作面試中反復出現的問題。
可測試的代碼提供一個API,允許調用者驗證所有期望的行為。例如,與有返回值的方法相比,void方法(又稱為過程)的可測試性更低。第6章會介紹測試技術和可測試性。
1.3.4 可維護性
易于發現和修復bug,以及改進軟件。
可維護的烤面包機易于拆卸和維修。它的原理圖可以輕易獲得,并且組件是可以更換的。類似地,可維護的軟件是可讀且模塊化的,不同模塊具有明確定義的職責,并以明確定義的方式進行交互。第6章和第7章討論的可測試性和可讀性是保證可維護性的主要因素。
FURPS模型
具有濃厚技術傳統的大公司為它們的軟件開發過程制定了自己的質量模型。例如,惠普公司開發了著名的FURPS模型,將軟件特征分成了五類:功能性(functionality)、易用性(usability)、可靠性(reliability)、性能(performance)和可支持性(supportability)。
1.4 軟件質量之間的關系
某些方面的軟件質量代表了截然不同的目標,而另一些則相輔相成。結果就是對所有工程專業而言都不陌生的取舍行為。數學家給這類問題起了個名字:多準則優化(multi-criteria optimization),即針對多個相互競爭的質量標準找到最佳解決方案。與抽象的數學問題不同,軟件質量可能無法量化(試想一下可讀性)。幸運的是,你并不需要找到真正的最佳解決方案,只需一個足以滿足目標的解決方案即可。
表1-1總結了本書所考量的四種質量之間的關系。時間效率和空間效率都可能會妨礙可讀性,追求最佳的性能會犧牲抽象能力并需要編寫較底層的代碼。在Java中,這可能意味著需要使用基本類型而不是對象,使用普通數組而不是集合(collection),或者在極端情況下使用較底層的語言(例如C)編寫對性能要求苛刻的部分并使用Java本地接口(Java Native Interface)將它們與主程序相連接。
表1-1 代碼質量之間的典型關系:“↓”代表“不利于”,“-”代表“無影響”。本表受到《代碼大全》中圖20-1的啟發(見1.10節)
追求盡可能少地使用內存也會導致使用基本類型以及一些難以理解的代碼,比如通過使用單個值表示不同事物來節省空間。(你將在4.4節中看到一個例子。)這些技術都會犧牲可讀性,從而犧牲可維護性。相反,可讀性高的代碼會使用更多的臨時變量和支持方法,從而避免了為提高性能而編寫底層代碼。
時間效率和空間效率也相互沖突。例如,提高性能的常用策略是將一些額外的信息存儲在內存中,而不是每次需要時都對其進行計算。一個典型的例子是單向鏈表和雙向鏈表之間的區別。即使原則上可以通過遍歷整個鏈表來計算每個節點的“上一個節點”,但是存儲和維護雙向鏈接可以讓刪除任意節點保持常數時間復雜度。4.4節中的例子就是以增加運行時間來換取更高的空間效率。
要追求穩健性最大化,就需要添加代碼來檢查異常情況并以適當的方式進行處理。這種檢查會產生性能開銷,不過通常非常有限。空間效率則不會受到任何影響。同樣,原則上,追求穩健性也不應該降低可讀性。
軟件指標
軟件質量與軟件指標(metrics)息息相關,后者是軟件的可量化屬性。學術界已經提出了數百種指標度量標準,其中最常見的兩個是代碼行數(LOC)和圈復雜度(對嵌套和分支總量的度量)。這些指標提供了評估和監控項目的客觀方法,旨在支持與項目開發相關的決策。例如,圈復雜度高的方法可能需要更多的測試工作。
現代的IDE可以原生地或通過插件自動計算常見的軟件指標。這些指標的相對優勢、它們與本章所述軟件質量的關系,以及它們的有效用法是軟件工程中爭議很大的話題。第6章會用到代碼覆蓋率指標。
有一股力量與這些軟件質量都無法共存,那就是開發時間。業務原因推動人們快速地編寫軟件,但最大限度地提高軟件質量則需要花費大量的精力和時間。即使管理層很能理解“精心設計的軟件能給未來帶來收益”,評估究竟需要多少時間才能獲得高質量的結果也依然很棘手。各種各樣的開發流程為該問題提出了許多解決方案,其中一些主張使用上面提到的軟件指標。
本書不涉及軟件開發過程的辯論(有時稱其為“戰爭”更合適),而是專注于那些對“有固定API的單個類”組成的小型軟件單元仍然有意義的軟件質量,包括時間效率和空間效率,以及可靠性、可讀性和通用性。本書不會涉及易用性或安全性等其他方面的軟件質量。
1.5 特殊的質量
除了前面各節描述的質量屬性外,我們還將探討類的兩個屬性:線程安全和簡潔性。
1.5.1 線程安全
類在多線程環境中正常工作的能力。
線程安全并不是通常意義上的軟件質量,因為它僅適用于多線程程序這個特定的上下文。盡管如此,這樣的上下文已經變得無處不在,而且線程同步問題非常棘手,以至于了解基本的并發原語是任何程序員都應該掌握的一項寶貴的技能。
線程安全很容易被歸類為內部質量,但這是一個錯誤。確實,用戶并不知道程序是順序執行的還是多線程的。在多線程編程領域,線程安全是正確性的基本前提,所以它顯然是個質量因素。順便說一句,線程安全問題在表象上的隨機性以及不易重現性,往往會導致一些極難發現的錯誤。這就是圖1-2將線程安全與正確性和穩健性放在同一區域中的原因。第8章致力于確保線程安全,同時避免常見的并發陷阱。
1.5.2 簡潔性
為給定任務編寫盡可能短的程序。
通常意義上來說,簡潔性根本不是代碼質量。相反,它容易導致糟糕、晦澀的代碼。附錄A中有一個趣味練習,它挑戰了語言的極限,也挑戰了你的Java(或你選擇的任何編程語言)知識。
盡管如此,你仍然可以找到以簡潔為目標的實用場景。手機和信用卡中的智能卡等低端嵌入式系統可能由于配備的內存太少,以至于程序不僅必須在運行時占用很少的內存,而且在持久化的存儲器中存儲時只能占用很小的空間。確實,如今大多數智能卡只有4 KB的RAM和512 KB的持久化存儲空間。在這種情況下,控制字節碼指令的數量就成為一個重要問題,而減少源代碼可以緩解這個問題。
1.6 演進示例:水容器系統
本節描述你將在本書其余部分中反復解決的編程問題,每次都針對不同的軟件質量目標。你將先學習所需的API,然后了解一個簡單的用例和初步實現。
假設你需要為一個新的社交網絡實現核心基礎框架。人們可以注冊,當然也可以彼此聯系。連接是對稱的,也就是說,如果我與你建立了連接,那么你將自動與我建立連接,就像Facebook那樣。并且,該網絡的一項特殊功能是用戶可以向所有與其連接(不論直接或間接)的用戶發送消息。本書將介紹此場景的基本功能,并將其置于更簡單的背景中,在這里我們不必關心消息的內容或人員的屬性。
你在這里要處理的不是人員,而是一組水容器。假定它們完全相同,并且容量是無限的。在任何時間,一個容器可容納一定量的水,任何兩個容器都可以通過管道永久連接。你可以將水倒入容器,或從容器中取水(代替發送消息)。無論何時連接兩個或多個容器,它們都將成為連通容器。一旦連通,它們會將其中的水均分。
1.6.1 API
本節描述水容器所需的API。至少需要構建一個Container類,并為其賦予一個不帶任何參數的公有構造函數。該構造函數創建一個空容器。這個類還擁有以下三個方法。
● public double getAmount():返回此容器中的當前水量。
● public void connectTo(Container other):將此容器永久連接到另一個容器(other)。
● public void addWater(double amount):將一定量的水(amount)倒入此容器中。此方法在所有直接或間接連接到該容器的容器間自動均分其中的水。
你也可以在使用此方法時傳入負數,從該容器中取出水。在這種情況下,一組相連的容器應有足夠的水以滿足要求(你不希望在容器中留存的水量變為負數)。
接下來幾章中介紹的大部分實現完全符合這個API,除了幾個明確標明的例外情況。在這些例外情況中,我調整了API來幫助優化某個特定方面的軟件質量。
兩個容器之間的連接是對稱的:水可以來回流動。一組通過對稱鏈接來連接的容器形成了計算機科學中所謂的無向圖。請參考下面的資料以了解有關無向圖的基本概念。
無向圖
在計算機科學中,由成對連接的項組成的網絡稱為圖(如圖1-3所示)。圖中的項也稱為節點,其連接稱為邊。如果連接是對稱的,則該圖稱為無向圖,因為連接沒有特定的方向。直接或間接連接的一組節點稱為連通分量(connected component)。在本書中,最大的連通分量簡稱為組。
圖1-3 計算機科學中圖的要素
要在水容器方案中實現恰當的addWater方法,需要知道已連通的分量,因為必須在所有已連接的容器之間平均分配(或移除)水。實際上,該場景背后的主要算法問題是在創建節點(new Container)和插入邊(connectTo方法)時維護連通分量的信息,這是圖的動態連通性問題。
此類問題是許多涉及網絡的應用程序的核心:在社交網絡中,連通分量代表一組因有朋友關系而聯系在一起的人;在圖像處理中,相同顏色像素的相連(在相鄰的意義上)區域有助于識別場景中的對象;在計算機網絡中,發現和維護連通分量是路由的一個基本步驟。第9章將探討此類問題的一個具體應用。
1.6.2 用例
本節介紹一個簡單的用例,它體現了上一節描述的API。你將創建四個容器,向其中兩個容器中加一些水,然后逐步將它們連接起來,直到它們形成一個組(見圖1-4)。在這個初步的例子中,會先放入水,然后再將容器連接起來。一般來說,可以自由交錯地執行這兩個操作。而且,可以在任何時候創建新的容器。
圖1-4 用例的四個步驟:從四個獨立的空容器到一個相互連接的容器組
我將用例(在線代碼庫中的UseCase類)分為四部分,這樣就可以很容易地在其他章節中參考具體的點,并研究不同的實現如何滿足相同的需求。這四個步驟如圖1-4所示。在第一部分中,只需創建四個容器,如下面的代碼片段所示。最初,它們是空的、孤立的(沒有連接)。
Container a = new Container(); Container b = new Container(); Container c = new Container(); Container d = new Container();
接下來,向第一個和最后一個容器中加入水,并將前兩個容器用管道連接起來。最后,把每個容器中的水量打印到屏幕上,來檢查是否一切都是按需求規范進行的。
a.addWater(12); d.addWater(8); a.connectTo(b); System.out.println(a.getAmount()+" "+b.getAmount()+" "+ c.getAmount()+" "+d.getAmount());
在上面代碼片段的結尾,容器a和容器b是連在一起的,所以它們共享你放進a的水,而容器c和容器d是隔離的。下面是println的期望輸出。
6.0 6.0 0.0 8.0
讓我們繼續,將c連接到b,檢查添加一個新的連接是否會自動將水在所有連接的容器中重新分配。
b.connectTo(c); System.out.println(a.getAmount()+" "+b.getAmount()+" "+ c.getAmount()+" "+d.getAmount());
這時,c與b相連,并間接地與a相連。此時a、b和c都是相互連通的容器,所有容器中的水的總量在它們之間平均分配。容器d不受影響,導致了如下輸出。
4.0 4.0 4.0 8.0
要特別注意用例中的當前點,因為在接下來的章節中,我們將用它作為一個標準的場景來展示不同的實現如何在內存中表示相同的情況。
最后,將d連接到b,使所有的容器形成一個連接組。
b.connectTo(d); System.out.println(a.getAmount()+" "+b.getAmount()+" "+ c.getAmount()+" "+d.getAmount());
因此,在最后的輸出中,所有容器的水量是相等的。
5.0 5.0 5.0 5.0
1.7 數據的模型和表示
現在已經明確知道了水容器類的需求,可以開始設計一個實際的實現了。需求規范中已經定義了公有API,所以下一步就是確定每個Container對象需要哪些字段,可能還有類本身(又名靜態字段)需要的字段。后面章節中的例子表明,根據所追求的質量目標,可以選擇大量不同的字段,數量之多令人驚訝。本節將介紹一些通用的觀察結果,無論具體的質量目標是什么,這些觀察結果都是適用的。
首先,對象必須包含足夠的信息,以提供需求規范所要求的服務。一旦滿足了這個基本要求,就還有兩類決定要做。
(1) 是否要存儲任何額外的信息,即使不是嚴格意義上的必要信息?
(2) 如何對所有要存儲的信息進行編碼?哪些數據類型或結構是最合適的?又由哪個(些)對象來負責?
關于問題(1),想存儲一些不必要的信息可能出于兩個原因。首先是為了提高性能。在這種情況下,也可以從其他字段中計算這些信息,但更希望這些信息是已準備好的,因為計算信息比維護它更昂貴。想想看,一個鏈表會把它的長度存儲在一個字段中,即使這個信息可以通過遍歷鏈表并計算節點的數量來即時計算。其次,有時會存儲額外的信息,為將來的擴展留下余地。1.7.2節中有一個這樣的例子。
一旦確定了要存儲什么信息,就該通過給類和對象指定適當的字段類型來回答問題(2)了。即使是在像水容器這樣相對簡單的場景中,這一步可能也并非小事。正如整本書試圖證明的那樣,可能存在著幾種相互競爭的解決方案,這些解決方案在不同的上下文中,以及考慮不同的質量目標時都是有效的。
在我們的場景中,一個容器當前狀態信息的描述由兩個方面組成:容器中的水量,以及它和其他容器的連接。接下來的兩節將分別討論這兩個方面。
1.7.1 存儲水量
首先,getAmount方法存在的前提是需要容器“知道”它們中的水量。我所說的“知道”,并不是說一定要將這些信息儲存在容器中。現在談這個還為時過早。我的意思是容器應該有某種方式來計算并返回這個值。此外,API規定了水量必須用double(雙精度)來表示。一個很自然的實現是在每個容器中真正包含一個double類型的水量字段。仔細觀察一下就會發現,一組相連容器的每一個容器中的水量是一樣的。因此,最好將這些容器中的水量只存儲一次,可以存儲在一個獨立的表示一組容器的對象中。這樣一來,當調用addWater時,只需要更新一個對象就可以了,即使當前容器與許多其他容器相連。
最后,除了使用一個獨立的對象,還可以將容器組的水量存儲在其中一個特殊的容器(作為其容器組的代表)中。總結一下,目前為止至少有三種可行的方法。
(1) 每個容器都持有一個最新的“水量”字段。
(2) 一個獨立的“容器組”對象持有這個“水量”字段。
(3) 每個組中只有一個容器(代表)持有最新的“水量”值,該值適用于該組中的所有容器。
在下面的章節中,不同的實現將分別使用這三種方式(以及一些額外的方式),我們將詳細討論每種方式的優劣。
1.7.2 存儲連接
向容器中加水時,水必須被平均分配到所有與該容器(直接或間接)相連的容器中。因此,每個容器必須能夠識別所有與它相連的容器。一個重要的決定是如何區分直接連接和間接連接。a和b之間的直接連接只能通過調用a.connectTo(b)或b.connectTo(a)來建立,而間接連接則是直接連接的結果3。
3在數學術語中,間接連接對應于直接連接的傳遞閉包。
選擇要存儲的信息
我們的需求規范要求的操作沒有區分直接連接和間接連接,所以可以只存儲更通用的:間接連接。但是,假設在未來的某個時候,希望添加一個disconnectFrom的操作,其意圖是撤銷之前的connectTo操作。如果沒有把直接連接和間接連接加以區分,就不可能正確實現disconnectFrom方法。
事實上,考慮一下圖1-5所示的兩種情況,直接連接用容器間的連線來表示。如果只在內存中存儲間接連接,那么這兩種情況是無法區分的:在這兩種情況下,所有的容器都是相互連接的。因此,在這兩種情況下,如果執行一系列順序相同的操作,那么它們必然會有同樣的反應。此外,考慮一下如果客戶端執行以下操作,則會發生什么情況。
a.disconnectFrom(b); a.addWater(1);
如果在第一種情況下(見圖1-5左圖)執行這兩行代碼,三個容器仍然是相連的,所以增加的水量必然會被平均分配給所有的容器。相反,在第二種情況下(見圖1-5右圖),斷開a與b的連接,會使容器a被隔離,所以增加的水必然只會加到a中。由此可見,只存儲間接連接并不能兼容未來的disconnectFrom操作。
圖1-5 兩種“三個容器”的場景。容器間的連線表示直接連接
總結一下,如果認為未來可能會增加disconnectFrom的操作,那么可能就需要將直接連接與間接連接明確地分開存儲。但是,如果不知道關于該軟件未來演進方向的具體信息,就應該警惕這種誘惑。眾所周知,程序員容易過度泛化,他們往往更多的是權衡假設性的利益,而不是隨之而來的某些代價。考慮到一個新功能的成本并不僅限于開發的時間,因為每個不必要的類成員都需要像其他必要的類成員一樣進行測試、編寫文檔和維護。
另外,對于可能想增加的額外信息的數量則沒有限制。如果以后想刪除所有超過一小時的連接怎么辦?應該存儲每個連接的建立時間!如果想知道有多少個線程創建了連接,該怎么辦?應該存儲所有曾經創建過連接的線程的set 4,等等。在下面的章節中,我一般會堅持只存儲目前需要的信息5,有幾個明確標注的例外。
選擇表達方式
最后,假設只滿足于存儲間接連接,下一步就是為它們挑選一個實際的表示方式。在這一點上,初步有兩個選擇:一是顯式使用一個新的類(比如叫Pipe),來表示兩個容器之間的連接;二是直接在容器對象內部存儲相應的信息(隱式表示)。
第一種選擇更符合正統的OO設計。在現實世界中,容器是由管道連接起來的,而管道是真實的物體,與容器有明顯的區別。因此,按理說,它們應該分開建模。不過,本章的規范中并沒有提到任何Pipe對象,所以它們可以仍舊隱藏在容器中,不被客戶端所感知。此外,更重要的是,這些管道對象包含很少的行為。每個管道對象將持有兩個相連容器的引用,沒有其他屬性和重要的方法。
在權衡了這些原因后,似乎引入這個額外的類的好處并不大,所以還不如選擇實用的、隱式的方案,完全避免引入這個額外的類。容器無須使用專門的“管道”對象就可以訪問它們的同伴。但是,到底要如何組織相連容器的引用呢?語言內核及其API提供了多種解決方案:普通數組、列表、set。這里就不分析了,因為其中很多都會在下面的章節(尤其是第4章和第5章)針對不同的代碼質量進行優化時自然而然地出現。
4為了與collection(本書中譯為“集合”)區分,set不做翻譯。特殊名詞中的set除外,如整數集(set of integers)和多重集(multi-set)。——編者注
5極限編程運動已為此原則取了一個“你不需要它”(You aren't gonna need it,YAGNI)的口號。
1.8 你好,容器(Novice)
從本節開始,我們將考慮一個Container的實現,這個實現可以由一個接觸過C語言等結構化語言后剛剛接觸Java的、沒什么編程經驗的程序員來編寫。這個類是整本書中你會遇到的眾多版本中的第一個。我給每個版本起了一個名字,以幫助瀏覽和比較它們。這個版本的名字是Novice,它在代碼庫中的全稱是eis.chapter1.novice.Container。
1.8.1 字段和構造函數
即使是經驗豐富的專業人士,在某個時刻也曾是初學者,在新語言的語法中摸爬滾打,對隱藏在角落里的眾多API并不了解。起初,可以選擇數組這種數據結構,但解決語法錯誤的要求太高,以至于不能考慮什么編碼風格的問題。經過一番試錯后,初學編程的人拼湊出了一個類,可以編譯通過,而且似乎還能滿足需求。也許開始時候的代碼有點兒像代碼清單1-1所示的那樣。
代碼清單1-1 Novice:字段和構造函數
public class Container { Container[] g; ? 一組相連的容器 int n; ? 容器組的實際大小 double x; ? 該容器中的水量 public Container() { g = new Container[1000]; ? 注意:這是一個魔法數 g[0] = this; ? 將該容器放入容器組中 n = 1; x = 0; }
這幾行代碼包含大量的輕微和不太輕微的缺陷。讓我們把重點放在那些容易修復的表面缺陷上,因為其他的缺陷在隨后章的版本中會逐漸浮現出來。
這三個實例字段的用途如下。
● g是一個數組,用于保存連接到這個容器的所有容器,包括當前容器(在構造函數中可以看出)。
● n為g中的容器數量。
● x是該容器中的水量。
唯一明顯讓這段代碼顯得業余的地方是選擇的變量名:非常短,而且完全沒有表達出應有的信息。即使一個專家被犯罪分子要挾用60 s的時間“黑”進一個超級安全的水容器系統,他也不會給一個組起名為g的。玩笑歸玩笑,有意義的命名是代碼可讀性的首要原則,第7章會討論可讀性。
然后就是可見性問題。字段應該是私有的(private),而不是默認的(default)。回想一下,默認可見性比私有性更開放;它允許同一包中的其他類訪問。信息隱藏(又名封裝)是一個基本的OO原則,它使類可以不用關心其他類的內部實現,并通過一個定義良好的公有接口(一種分離關注點的形式)與它們交互。這進而使得類可以修改其內部實現,而不影響已有的客戶端。
關注點分離的原則也為本書提供了基礎。以下各章介紹的許多實現都符合同樣的公有API,因此,客戶端原則上可以互換使用各個版本的實現。使用這種方法,API的每一個實現細節對外部都是不可見的,這要歸功于可見性標識符。從更深的層面來看,單獨優化不同方面的軟件質量本身就是一種極端的關注點分離。它過于極端了,事實上只是一種說教的工具,而不應該是在實踐中追求的方法。
繼續往下看,如代碼清單1-1中的第六行代碼所示,數組的大小由一個所謂的魔法數(magic number)定義,即一個沒有被賦予任何名稱的常數。最佳實踐要求你把所有的常量分配給某個final變量,一來變量的名字可以表示這個常量的含義,二來把這個常量的賦值集中在單個點上,如果多次使用這個常量,那么這一點特別有用。
這里選擇使用普通數組并不是很合適,因為它對連接的容器的最大數量有一個預先確定的邊界:如果邊界太小,那么程序必然會失敗;太大的邊界又會浪費空間。此外,使用數組迫使我們不得不手動跟蹤組中實際的容器數量(此處為字段n)。在Java API中還有更好的選擇,將在第2章中討論。盡管如此,普通數組也將在第5章中派上用場,那里的主要目標是節省空間。
1.8.2 getAmount和addWater方法
接下來看看前兩個方法的源代碼,如代碼清單1-2所示。
代碼清單1-2 Novice:getAmount和addWater方法
public double getAmount() { return x; } public void addWater(double x){ double y = x / n; for (int i=0; i<n; i++) g[i].x = g[i].x + y; }
getAmount只是一個簡單的getter,addWater則顯示了變量x和y的常見命名問題,而i作為數組索引的傳統名稱是可以接受的。如果代碼清單的最后一行使用+=運算符,就不會重復g[i].x兩次,也就不必來回查看,以確保語句實際上是在遞增同一個變量。
注意,addWater方法沒有檢查它的參數是否為負值。在這種情況下,表示并沒有考慮容器組是否有足夠的水量。像這樣的穩健性問題,將在第6章中專門討論。
1.8.3 connectTo方法
最后,我們的新手程序員實現了connectTo方法,它的任務是用一個新的連接合并兩組容器。在這個操作之后,兩組中的所有容器都會持有相同的水量,因為它們都成了連通器。首先,該方法將計算出兩組中的總水量和兩組中容器的總數。合并之后,每個容器的水量,就是簡單地用前者除以后者。
還需要更新兩個組中所有容器的數組。一種樸素的方法是將第二組中的所有容器附加到屬于第一組的所有數組,反之亦然。代碼清單1-3就是這樣做的,使用了兩個嵌套循環。最后,該方法更新了所有受影響的容器的大小字段n和水量字段x。
代碼清單1-3 Novice:connectTo方法
public void connectTo(Container c) { double z = (x*n + c.x*c.n) / (n + c.n); ? 合并后,每個容器的水量 for (int i=0; i<n; i++) ? 遍歷第一組中的每個容器 for (int j=0; j<c.n; j++) { ? 遍歷第二組中的每個容器 g[i].g[n+j] = c.g[j]; ? 將c.g[j]添加到g[i]組中 c.g[j].g[c.n+i] = g[i]; ? 將g[i]添加到c.g[j]組中 } n += c.n; for (int i=0; i<n; i++) { ? 更新大小和水量 g[i].n = n; g[i].x = z; } }
如你所見,connectTo方法是命名問題最嚴重的地方。所有這些單字母的名字很難讓人理解。為了進行明顯的比較,你可能會想先跳過,去看一下第7章中的可讀性優化的版本。
如果用增強型for循環(C#中的foreach語句)替換掉三個for循環,可讀性也會有所改善,但基于固定大小數組的表示方式使其有點兒麻煩。確實如此,想象一下,用下面的語句替換代碼清單1-3中的最后一個循環。
for (Container c: g){ c.n = n; c.x = z; }
這個新的循環當然可讀性更強,但是一旦c變量超出了實際存儲容器引用的數組單元格(cell)6,就會出現NullPointerException。補救方法很簡單,只要檢測到一個null引用,就立即退出循環。
6本書中數組的cell統一翻譯為“單元格”,從而在某些上下文中和“元素”(element)等進行區分。——譯者注
for (Container c: g){ if (c==null) break; c.n = n; c.x = z; }
盡管完全不可讀,但代碼清單1-3中的connectTo方法在邏輯上是正確的,只是有一些限制。事實上,思考一下this和c在方法調用之前就已經相連的情況。更具體地說,假設下面的用例,涉及兩個全新的容器。
a.connectTo(b); a.connectTo(b);
你能看出會發生什么嗎?方法能容忍調用者的這種輕微失誤嗎?請在繼續閱讀之前仔細思考一下。我會等著你。
答案是,連接兩個已經連接的容器會破壞它們的狀態。容器a的容器組數組中最后會有兩個指向自己的引用和兩個指向b的引用,并且大小字段n是4而不是2。類似的事情也會發生在b上。更糟糕的是,即使this(當前容器)和c只是間接連接,也會出現這種缺陷,這不能被認為是調用者的使用不當。我說的情況如下所示(再強調一次,a、b和c是三個全新的容器)。
a.connectTo(b); b.connectTo(c); c.connectTo(a);
在最后一行代碼之前,容器a和c已經連接起來了,盡管是間接的(見圖1-5右圖)。最后一行代碼增加了它們之間的直接連接,根據需求規范,這是有效的。這導致了圖1-5左圖所示的情況。但是代碼清單1-3中的connectTo方法卻給所有容器組數組添加了所有三個容器的第二個副本,同時錯誤地將所有組的大小設置為6而不是3。
此實現的另一個明顯缺陷是,如果合并后的組中包含超過1000個成員(那個魔法數),則代碼清單1-3這兩行的其中之一:
g[i].g[n+j] = c.g[j]; c.g[j].g[c.n+i] = g[i];
會拋出一個ArrayIndexOutOfBoundsException異常,并導致程序崩潰。
下一章將介紹一個參考的實現,它解決了這里指出的大部分表面問題,同時在不同方面的代碼質量之間取得了平衡。
1.9 小結
● 可以將軟件質量分為內部軟件質量和外部軟件質量,也可以分為功能性軟件質量和非功能性軟件質量。
● 有些方面的軟件質量是相互對立的,有些則是相輔相成的。
● 本書以一個水容器系統作為統一的示例來探討軟件質量。
1.10 擴展閱讀
本書試圖將各種不同的主題濃縮進二三百頁里,而這些主題很少被放在一起來講解。因此,每個主題只能淺嘗輒止。這就是為什么每一章的結尾都會提供一個簡短的資源列表,你可以參考本節的內容,以了解更多關于本章內容的信息。
Steve McConnell的《代碼大全》
一本關于編碼風格和優秀軟件方方面面的寶貴圖書,也討論了各種代碼質量及其關系。
Diomidis Spinellis的《代碼質量》
它會帶你體驗一次質量屬性的旅程。與我們在本書中看到的不一樣,它有一個幾乎相反的指導原則:沒有使用單個演進示例,而是使用了取自各種流行開源項目的大量代碼片段。
Stephen H. Kan的《軟件質量工程的度量與模型》
Kan提供了一個系統、深入的軟件指標的處理方法,包括使用統計學上的合理方法來評估,并利用它們來監控和管理軟件開發流程。
Christopher W. H. Davis的《敏捷度量實戰:如何度量并改進團隊績效》
該書第8章討論了軟件質量和可以使用的評估指標。
- JavaScript修煉之道
- Building a Game with Unity and Blender
- 劍指Offer(專項突破版):數據結構與算法名企面試題精講
- 動手玩轉Scratch3.0編程:人工智能科創教育指南
- Spring Boot+Spring Cloud+Vue+Element項目實戰:手把手教你開發權限管理系統
- PHP 編程從入門到實踐
- 秒懂設計模式
- Python貝葉斯分析(第2版)
- D3.js 4.x Data Visualization(Third Edition)
- Spring核心技術和案例實戰
- Create React App 2 Quick Start Guide
- ASP.NET程序開發范例寶典
- Java語言程序設計教程
- 超好玩的Scratch 3.5少兒編程
- Python面試通關寶典