1. 程式人生 > >08有關類設計和實現的問題(類的結構關系)

08有關類設計和實現的問題(類的結構關系)

怎麽 包含 let 層次 維護 大量 raw int() 模式

一. 類內部的設計和實現

? 給類定義合理的接口,對於創建高質量程序起到了關鍵作用。然而,類內部的設計和實現也同樣重要。這裏主要論述關於包含、繼承、成員函數和數據成員、類之間的耦合性、構造函數、值對象與引用對象等。

1. 包含(“有一個...”關系)——“has a”

? 包含是一個非常簡單的概念,它表示一個類含有一個基本數據元素或對象。包含是面向對象編程中的主力技術。

1.1 通過包含來實現“有一個 / has a”的關系
1.2 警惕有超過約 7 個數據成員的類
2. 繼承(“是一個...”關系)—— “is a”

? 繼承的概念是說一個類是另一個類的一種特化。繼承的目的在於,通過“定義能為兩個或多個派生類提供共有元素的基類”的方式寫出更精簡的代碼。其中的共有元素可以使子程序接口、內部實現、數據成員或數據類型等。繼承能把這些共有的元素集中在一個基類中,從而有助於避免在多處出現重復的代碼和數據。

? 當決定使用繼承時,你必須要做如下幾項決策。

  • 對於每一個成員函數而言,它應該對派生類可見嗎?它應該由默認的實現嗎?這一默認的實現能被覆蓋嗎?
  • 對於每一個數據成員而言(包括變量、具名常量、枚舉等),它應該對派生類可見嗎?

    2.1 用 public 繼承來實現“是一個...”的關系

? 當程序員決定通過繼承一個現有類的方式創建一個新類時,他是在表明這個新的類是現有類的一個更為特殊的版本。基類既對派生類將會做什麽設定類預期,也對派生類能怎麽運作提出了限制。

? 如果派生類不 準備完全遵守由基類定義的同一個接口契約,繼承就不是正確的實現技術了。請考慮換用包含的方式,或者對繼承體系的上層做修改。

2.2 要麽使用繼承並進行詳細說明,要麽就不要用它

? 繼承給程序增加了復雜度,因此它是一種危險的技術。“要麽使用繼承並進行詳細說明,要麽就不要用它”。

2.3 遵循 Liskov 替換原則

? Barbara Liskov 在一篇面向對象編程的開創性論文中提出,除非派生類真的“是一個”更特殊的基類,否則不應該從基類繼承。即“派生類必須能夠通過基類的接口而被使用”,且使用者無須了解兩個之間的差異。換句話說,對於基類中定義的所有子程序,用在它的任何一個派生類中時的含義都應該是相同的。

? 如果程序遵循 Liskov 替換原則,繼承就能成為降低復雜度的一個強大工具,因為它能讓程序員關註與對象的一般特性而不必擔心細節。若谷程序員必須要不斷地思考不同派生類的實現在語義上的差異,那繼承就只會增加復雜度了。

2.4 確保只繼承需要繼承的部分

? 派生類可以繼承成員函數的接口和/或實現。

  • 抽象且可覆蓋的子程序(如純虛函數)是指派生類只繼承了該子程序的接口,但不繼承其實現。
  • 可覆蓋的子程序(如非純虛函數)是指派生類繼承了該子程序的接口及默認實現,並且可以覆蓋該默認實現。
  • 不可覆蓋的子程序(如 override final 標識的虛函數)是指派生類繼承了該子程序的接口及其默認實現,但不能覆蓋該默認實現。

當你選擇通過繼承的方式來實現一個新的類時,請針對每一個子程序仔細考慮你所希望的繼承方式.僅僅是因為要繼承接口所以才繼承實現,或僅僅是因為要繼承實現所以才繼承接口,這兩種情況都值得註意.如果你只是想使用一個類的實現而不是接口,那麽就應該采用包含方式,而不是繼承。

2.5 不要“覆蓋”一個不可覆蓋的成員函數

? C++ 和 Java 兩種語言都允許程序員“覆蓋”那些不可覆蓋的成員函數。如果一個成員函數在基類中時私有的,其派生類可以創建一個同名的成員函數。對於閱讀 派生類代碼的程序員來說,這個函數是令人困惑的,因為它看上去似乎應該是多態的,但事實上缺非如此,只是同名而已。

2.6 把共用的接口、數據及操作放到繼承樹中盡可能高的位置

? 接口、數據和操作在繼承體系中的位置越高,派生類使用它們的時候就越容易。多高就算太高了呢?根據抽象性來決定以吧。如果你發現一個子程序移到更高的層次後會破壞該層對象的抽象性,就該停手了。

2.7 只有一個實例的類是值得懷疑的

? 只需要一個實例,這可能表名設計中把對象和類混為一談了。考慮一下能否只創建一個新的對象而不是一個新的類。派生類中的差異能否用數據而不是新的類來表達呢?單例模式(Singleton)則是本條指導方針的一個特例。

2.8 只有一個派生類的基類也值得懷疑

? 每當我看到只有一個派生類的基類時,我就懷疑某個程序員又在進行“提前設計”了 —— 也就是試圖去預測未來的需要,而又常常沒有真正了解未來到底需要什麽。為未來要做的工作著手進行準備的最好方法,並不是去創造幾層額外的、“沒準以後那天就能用的上的”基類,而是讓眼下的工作成果盡可能地清晰、簡單、直截了當。也就是說,不要創建任何並非絕對必要的繼承結構。

2.9 派生後覆蓋了某個子程序,但在其中沒做任何操作,這種情況也值得懷疑

? 這通常表明基類的設計中有錯誤。舉例來說,假設你有一個 Cat 類,它有一個 Scratch() 成員函數,可是最終你發現有些貓的爪尖兒沒了,不能抓了。你可能想從 Cat 類派生一個叫 ScratchlessCat 的類,然後覆蓋 Scratch() 方法讓它什麽都不做。但這種做法有一下你個問題:

  • 它修改了 Cat 類接口所表達的語義,因此破壞了 Cat 類所代表的抽象(即接口契約)。

  • 當你從它進一步派生出其他派生類時,采用這一做法會迅速失控。如果你又發現有只貓沒有尾巴了怎麽辦?

  • 采用這種做法一段時間後,代碼會逐漸變得混亂而難以維護,因為基類的接口和行為幾乎無法讓人理解其派生類的行為。

修正這一問題的位置不是在派生類,而是在最初的 Cat 類中。應該創建一個 Claw 類並讓 Cat 類包含它。問題的根源在於做了所有貓都能抓的假設,因此應該從源頭上理解這個問題,而不是到發現問題的地方修補。

2.10 避免讓繼承體系過深

? 面向對象的編程方法提供了大量可以用來管理復雜度的技術。然而每種強大的工具都有其危險之處,甚至有些面向對象技術還有增加 —— 而不是降低 —— 復雜度的趨勢。

? Arthur Riel 建議把繼承層次限制在最多 6 層之內。 Arthur 是基於 “神奇數字 7 +- 2” 這一理論得出這一建議的,但我覺得這樣過於樂觀了。依我的經驗,大多數人在腦中同時應付超過 2 到 3 層繼承時就有麻煩了。

? 人們已經發現,過深的集成層次會顯著導致錯誤率的增長。每個曾經調試過復雜繼承關系的人都應該知道個中原因。過深的繼承層次增加了復雜度,而這恰恰與繼承所應解決的問題相反。請牢牢記住首要的技術使命。請確保你在用繼承來避免代碼重復並使復雜度最小。

2.11 盡量使用多態,避免大量的類型檢查

? 頻繁重復出現的 case 語句有時是在暗示,采用繼承可能是中更好的設計選擇 —— 盡管並不總是如此。下面就是一段迫切需要采用更為面向對象的方法的典型代碼示例:

//多半應該用多態替代的 case 語句
switch (shape.type){
    case Shape_Circle:
        shape.DrawCircle();
        break;
    case Shape_Circle:
        shape.DrawSquare();
        break;
    ...
}

? 在這個例子中,對 shape.DrawCircle() 和 shape.DrawSquare() 的調用應該用一個叫 shape.Draw() 的方法來替代。因為無論形狀是圓還是放都可以調用這個方法來繪制

? 另外,case 語句有時也用來把種類確實不同的對象或行為分開。下面就是一個在面向對象編程中合理采用 case 語句的例子:

//也許不該用多態來替代的 case 語句
switch (ui.command()){
    case Command_OpenFile:
        OpenFile();
        break;
    case Command_Print:
        Print();
        break;
    case Command_Exit:
        ShutDown();
        break;
    ...
}

? 此時也可以創建一個基類並派生一些派生類,再用多態的 DoCommand() 方法來實現每一種命令(就像 Command 模式的做法一樣)。但在項這個例子一樣簡單的場合中,DoCommand() 意義實在不大,因此采用 case 語句才是更容易理解的方案。

2.12 讓所有數據都是 private ( 而非 protected)

? 正如 Joshua Bloch 所言,“繼承會破壞封裝”。當你從一個對象繼承時,你就擁有可能夠訪問該對象中的 protected 數據的特權。如果派生類真的需要訪問基類的屬性,就應該提供 protected 訪問器函數。

2.13 多重繼承

? 繼承是一種強大的工具。就像用電鋸取代手鋸伐木一樣,當小心使用時,它非常有用,但在還沒能了解應該註意的事項的人手中,他也會變得非常危險。

? 如果把繼承比作是電鋸,那麽多重繼承就是 20 世紀 50 年代 的那種既沒有防護罩,也不能自動停機的危險電鋸。有時這種工具的確有用,但在大多數情況下,你最好還是把它放在倉庫裏為妙 —— 至少在這兒它不會造成任何破壞。

? 雖然有些專家建議廣泛使用多重繼承,但以我個人經驗而言,多重繼承的用途主要是定義“混合體”,也就是一些能給對象增加一組屬性的簡單類。之所以稱其為混合體,是因為他們可以把一些屬性“混合”到派生類裏面。“混合體”可以是行如 Displayable (可顯示), Persistant (持久化),serializable (可序列化) 或 Sortable (可排序)這樣的類。它們幾乎總是抽象的,也不打算獨立於其他對象而被單獨實例化。

? 混合體需要使用多重繼承,但只要所有的混合體之間保持完全獨立,他們也不會導致典型的菱形繼承問題。通過把一類屬性夾在一起,還能使設計方案更容易理解。程序員會更容易理解一個用了 Displayable 和 Peristent 混合體的對象 —— 因為這樣只需要實現兩個屬性即可 —— 而較難理解一個需要 11 個更具體的子程序的對象。

? Java 和 VB 語言也都認可混合體的價值,因為它們允許多重繼承,但只能繼承一個類的實現。而 C++ 則同時支持接口和實現的多重繼承。程序員在決定使用多重繼承之前,應該仔細地考慮其他方案,並謹慎地評估它可能對系統的復雜度和可理解性產生的影響。

2.14 為什麽有這麽多關於繼承的規則

? 這一節給出了許多規則,它們能幫你遠離與繼承相關的麻煩。所有這些規則背後的前臺詞都是在說,繼承往往會讓擬合程序員的首要技術使命(即管理復雜度)背道而馳。從控制復雜度的角度來說,你應該對繼承持有非常歧視的態度。下面來總結一下何時可以使用繼承,何時又該使用包含:

  • 如果多個類共享數據而非行為,應該創建這些類可以包含的共用對象。
  • 如果多個類共享行為而非數據,應該讓它們從共同的基類繼承而來,並在基類裏定義共用的子程序。
  • 如果多個類既共享數據也共享行為,應該讓它們從一個共同的基類繼承而來,並在基類裏定義共用的數據和子程序。
  • 當你想由基類控制接口時,使用繼承;當你想自己控制接口時,使用包含。

二. 成員函數和數據成員

###### 1. 讓類中子程序的數量盡可能少

? 一份針對 C++ 程序的研究發現,類裏面的子程序的數量越多,則出錯率也就越高。然而,也發現其他一些競爭因素產生的影響更顯著,包括過深的集成體系、在一個類中調用了大量的子程序,以及類之間的強耦合等。請在保持子程序數量最少和其他這些因素之間評估利弊。

2. 禁止隱式地產生你不需要的成員函數和運算符

? 有時你會發現應該禁止某些成員函數 —— 比如說你想禁止賦值,或不想讓某個對象被構造。你可能會覺得,既然編譯器是自動生成這些運算符的,你也就只能對它們放行。當時在這種情況下,你完全可以通過把構造函數、賦值運算符或其他成員函數或運算符定義為 private,從而禁止調用方代碼訪問它們(把構造函數定義為 private 也是定義單件類時所有的標準技術)。

3. 減少類所調用的不同子程序的數量

? 一份研究發現,類裏面的錯誤數量與類所調用的子程序的總數是統計相關的。統一研究還發現,類所用到的其他類的數量越高,其出錯率也會越高。

4. 對其他類的子程序的間接調用要盡可能少

? 直接的關聯已經夠危險了。而間接的關聯 —— 如 account.ContactPerson().DaytimeContactInfo().PhoneNumber() —— 往往更加危險。研究人員就此總結出了一條 “Demeter 法則”,基本上就是說 A 對象 可以任意調用它自己的所有子程序。如果 A 對象創建了一個 B 對象,它也可以調用 B 對象的任何 (公用)子程序,但是它應該避免再調用由 B 對象所提供的對象中的子程序。在前面 account 這個例子中,就是說 account.ContactPerson() 這一調用是合適的,但 account.ContactPerson().DaytimeContactInfo() 這一調用則不合適。

一般來說,應盡量減小類和類之間相互合作的範圍 —— 即盡量讓下面這幾個數字最小。
  • 所實例化的對象的種類
  • 在被實例化對象上直接調用的不同子程序的數量
  • 調用由其他對象返回的對象的子程序的數量

三. 構造函數

1. 如果可能,應該在所有的構造函數中初始化所有的數據成員

? 在所有的構造函數中初始化所有的數據成員是一個不難做到的防禦式編程時實踐。

2. 用私有(private)構造函數來強制實現單件模式

? 如果你想定義一個類,並需要強制規定它只能有唯一一個對象實例的話,可以把該類所有的構造函數都隱藏起來,然後對外界提供一個 static 的 GetInstance() 子程序來訪問該類的唯一實例。

3. 優先采用深拷貝(deep copues), 除非論證可行,才采用淺拷貝(shallow copies)

? 在設計復雜對象時,你需要做成一項主要決策,即應為對象實現深拷貝(得到深層復本)還是淺拷貝(得到淺層復本)。對象的深層復本是對象成員數據逐項復制的結果;而其淺層復本則往往只是指向或引用同一個實例對象,當然 “深” 和 “淺” 的具體含義可以有些出入。

? 實現淺層復本的動機一般是為了改善性能。盡管把大型的對象復制出多份復本從美學上看十分令人不快,但這樣做很少會導致顯著的性能損失。某幾個對象可能會引起性能問題,但眾所周知,程序員們很不擅長推測真正招致問題的代碼。

? 為了不確定的性能提高而增加復雜度是不妥的,因此,在面臨選擇實現深拷貝還是淺拷貝時,一種合理的方式便是優先實現深拷貝 —— 除非能夠論證淺拷貝更好。

? 深層復本在開發和維護方面都要比淺層復本簡單。實現淺拷貝除了要用到兩種方法都需要的代碼之外,還要增加很多代碼用於引用計數、確保安全的復制對象、安全地比較對象以及安全地刪除對象等。而這些代碼時很容易出錯的,除非你有充分地理由,否則就應該避免他們。

08有關類設計和實現的問題(類的結構關系)