1. 程式人生 > >重新學習java(四) ---- 組合、聚合與繼承的愛恨情仇

重新學習java(四) ---- 組合、聚合與繼承的愛恨情仇

有人學了繼承,認為他是面向物件特點之一,就在所有能用到繼承的地方使用繼承,而不考慮究竟該不該使用,無疑,這是錯誤的。那麼,究竟該如何使用繼承呢?

java中類與類之間的關係

大部分的初學者只知道java中兩個類之間可以是繼承與被繼承的關係,可是事實上,類之間的關係大體上存在五種—繼承(實現)、依賴、關聯、聚合、組合。

接下來,簡單的分析一下這些關係。

繼承(實現)

對於類來說,這種關係叫做繼承,對於介面來說,這種關係叫做實現。繼承上一篇文章已經詳細的講解過了,至於實現,我想大家也都知道是怎麼回事,由於後面要專門講介面,所以這裡就先不說了。繼承是一種“is-a”關係。

依賴

依賴簡單的理解,就是一個類A中的方法使用到了另一個類B。

這種使用關係是具有偶然性的、臨時性的、非常弱的,但是B類的變化會影響到A。

比如說,我用筆寫字,首先需要一個類來代表我自己,然後需要一個類來代表一支筆,最後,‘我’要呼叫‘筆’裡的方法來寫字,用程式碼實現一下:

public class Pen {
    public void write(){
        System.out.println("use pen to write");
    }
}

public class Me {
    public void write(Pen pen){//這裡,pen作為Me類方法的引數
        pen.write();
    }
}

看到這大家都懂了,因為這種程式碼你每天都會寫。現在你知道了,這就是一種類與類之間的關係,叫做依賴。

這種關係是一種很弱的關係,但是pen類的改變,有可能會影響到Me類的結果,比如我把pen類write方法的方法體改了,me中再呼叫就會得到不同的結果。

一般而言,依賴關係在Java中體現為局域變數、方法的形參,或者對靜態方法的呼叫。

關聯

關聯體現的是兩個類、或者類與介面之間語義級別的一種強依賴關係。

這種關係比依賴更強、不存在依賴關係的偶然性、關係也不是臨時性的,一般是長期性的,而且雙方的關係一般是平等的、關聯可以是單向、雙向的。

看下面這段程式碼:

// pen 還是上面的pen
public class You {
    private Pen pen; // 讓pen成為you的類屬性 

    public
You(Pen p){ this.pen = p; } public void write(){ pen.write(); } }

被關聯類B以類屬性的形式出現在關聯類A中,或者關聯類A引用了一個型別為被關聯類B的全域性變數的這種關係,就叫關聯關係。

在Java中,關聯關係一般使用成員變數來實現。

聚合

聚合是關聯關係的一種特例,他體現的是整體與部分、擁有的關係,即has-a的關係

看下面一段程式碼:

public class Family {
    private List<Child> children; //一個家庭裡有許多孩子

    // ...
}

在程式碼層面,聚合和關聯關係是一致的,只能從語義級別來區分。普通的關聯關係中,a類和b類沒有必然的聯絡,而聚合中,需要b類是a類的一部分,是一種”has-a“的關係,即 a has-a b; 比如家庭有孩子,屋子裡有空調。

但是,has 不是 must has,a可以有b,也可以沒有。a是整體,b是部分,整體與部分之間是可分離的,他們可以具有各自的生命週期,部分可以屬於多個整體物件,也可以為多個整體物件共享。

不同於關聯關係的平等地位,聚合關係中兩個類的地位是不平等。

組合

組合也是關聯關係的一種特例,他體現的是一種contains-a的關係,這種關係比聚合更強,也稱為強聚合。

先看一段程式碼:

public class Nose {
    private Eye eye = new Eye();  //一個人有鼻子有眼睛
    private Nose nose = new Nose();

    // .... 
}

組合同樣體現整體與部分間的關係,但此時整體與部分是不可分的,整體的生命週期結束也就意味著部分的生命週期結束。

就像你有鼻子有眼睛,如果你一不小心結束了生命週期,鼻子和眼睛的生命週期也會結束,而且,鼻子和眼睛不能脫離你單獨存在。

只看程式碼,你是無法區分關聯,聚合和組合的,具體是哪一種關係,只能從語義級別來區分。

同樣,組合關係中,兩個類額關係也是不平等的。

組合,聚合和繼承

依賴關係是每一個java程式都離不開的,所以就不單獨討論了,普通的關聯關係也沒有什麼特殊的地方,下面我們重點研究一下組合,聚合和繼承。

聚合與組合

  1. 聚合與組合都是一種關聯關係,只是額外具有整體-部分的意義。

  2. 部件的生命週期不同

    • 聚合關係中,整件不會擁有部件的生命週期,所以整件刪除時,部件不會被刪除。再者,多個整件可以共享同一個部件。

    • 組合關係中,整件擁有部件的生命週期,所以整件刪除時,部件一定會跟著刪除。而且,多個整件不可以同時間共享同一個部件。

    這個區別可以用來區分某個關聯關係到底是組合還是聚合。兩個類生命週期不同步,則是聚合關係,生命週期同步就是組合關係。

  3. 聚合關係是【has-a】關係,組合關係是【contains-a】關係。

    平時我們只討論組合和繼承的時候,認為組合是【has-a 】關係,而事實上,聚合才是真正的【has-a】關係,組合是更深層次的【contains-a】關係。

    由於【contains-a】關係是一種更深的【has-a】關係,所以說組合是【has-a】關係也是正確的。

組合和繼承

這個才是本文的重點。

學過設計模式的都知道,要“少用繼承,多用組合”,這究竟是為什麼呢?

我們先來看一下組合和繼承各自的優缺點:

組合和繼承的優缺點

組合

優點:

- 不破壞封裝,整體類與區域性類之間鬆耦合,彼此相對獨立
- 具有較好的可擴充套件性
- 支援動態組合。在執行時,整體物件可以選擇不同型別的區域性物件
- 整體類可以對區域性類進行包裝,封裝區域性類的介面,提供新的介面

缺點:

- 整體類不能自動獲得和區域性類同樣的介面
- 建立整體類的物件時,需要建立所有區域性類的物件

缺點分析:

1、整體類不能自動獲得和區域性類同樣的介面

如果父類的方法子類中幾乎都要暴露出去,這時可能會覺得使用組合很不方便,使用繼承似乎更簡單方便。但從另一個角度講,實際上也許子類中並不需要暴露這些方法,客戶端組合應用就可以了。所以上邊推薦不要繼承那些不是為了繼承而設計的類,一般為了繼承而設計的類都是抽象類。

2、建立整體類的物件時,需要建立所有區域性類的物件

這個可能沒什麼更好的辦法,但在實際應用中並沒有多出多少程式碼。

繼承

優點:

- 子類能自動繼承父類的介面
- 建立子類的物件時,無須建立父類的物件

缺點:

- 破壞封裝,子類與父類之間緊密耦合,子類依賴於父類的實現,子類缺乏獨立性
- 支援擴充套件,但是往往以增加系統結構的複雜度為代價
- 不支援動態繼承。在執行時,子類無法選擇不同的父類
- 子類不能改變父類的介面

缺點分析:

1、為什麼繼承破壞封裝性?

這裡寫圖片描述

鴨子中不想要“飛”的方法,但因為繼承無法封裝這個無用的“飛”方法 。

2、為什麼繼承緊耦合:

這裡寫圖片描述

當作為父類的BaseTable中感覺Insert這個名字不合適時,如果希望將其修改成Create方法,那使用了子類物件Insert方法將會編譯出錯,可能你會覺得這改起來還算容易,因為有重構工具一下子就好了並且編譯錯誤改起來很容易。但如果BaseTable和子類在不同的程式集中,維護的人員不同,BaseTable程式集升級,那本來能用的程式碼忽然不能用了,這還是很難讓人接受的

3、為什麼繼承擴充套件起來比較複雜

這裡寫圖片描述

當圖書和數碼的算稅方式和數碼產品一樣時,而消費類產品的算稅方式是另一樣時,如果採用繼承方案可能會演變成如下方式:

這裡寫圖片描述

這樣如果產品繼續增加,算稅方式繼續增加,那繼承的層次會非常複雜,而且很難控制,而使用組合就能很好的解決這個問題

4、繼承不能支援動態繼承

這個其實很好理解,因為繼承是編譯期就決定下來的,無法在執行時改變,如3例中,如果使用者需要根據當地的情況選擇計稅方式,使用繼承就解決不了,而使用組合結合反射就能很好的解決。

5、為什麼繼承,子類不能改變父類介面

如2中的圖,子類中覺得Insert方法不合適,希望使用Create方法,因為繼承的原因無法改變

組合與繼承的區別和聯絡

  • 在繼承結構中,父類的內部細節對於子類是可見的。所以我們通常也可以說通過繼承的程式碼複用是一種 白盒式程式碼複用。(如果基類的實現發生改變,那麼派生類的實現也將隨之改變。這樣就導致了子類行為的不可預知性)

  • 組合是通過對現有的物件進行拼裝(組合)產生新的、更復雜的功能。因為在物件之間,各自的內部細節是不可見的,所以我們也說這種方式的程式碼複用是黑盒式程式碼複用 。(因為組合中一般都定義一個型別,所以在編譯期根本不知道具體會呼叫哪個實現類的方法)

  • 繼承在寫程式碼的時候就要指名具體繼承哪個類,所以,在編譯期就確定了關係。(從基類繼承來的實現是無法在執行期動態改變的,因此降低了應用的靈活性。)

  • 組合,在寫程式碼的時候可以採用面向介面程式設計。所以,類的組合關係一般在執行期確定。

  • 組合(has-a)關係可以顯式地獲得被包含類(繼承中稱為父類)的物件,而繼承(is-a)則是隱式地獲得父類的物件,被包含類和父類對應,而組合外部類和子類對應。

  • 組合是在組合類和被包含類之間的一種鬆耦合關係,而繼承則是父類和子類之間的一種緊耦合關係。

  • 當選擇使用組合關係時,在組合類中包含了外部類的物件,組合類可以呼叫外部類必須的方法,而使用繼承關係時,父類的所有方法和變數都被子類無條件繼承,子類不能選擇。

  • 最重要的一點,使用繼承關係時,可以實現型別的回溯,即用父類變數引用子類物件,這樣便可以實現多型,而組合沒有這個特性。

  • 還有一點需要注意,如果你確定複用另外一個類的方法永遠不需要改變時,應該使用組合,因為組合只是簡單地複用被包含類的介面,而繼承除了複用父類的介面外,它甚至還可以覆蓋這些介面,修改父類介面的預設實現,這個特性是組合所不具有的。

  • 從邏輯上看,組合最主要地體現的是一種整體和部分的思想,例如在電腦類是由記憶體類,CPU類,硬碟類等等組成的,而繼承則體現的是一種可以回溯的父子關係,子類也是父類的一個物件。

  • 這兩者的區別主要體現在類的抽象階段,在分析類之間的關係時就應該確定是採用組合還是採用繼承。

  • 引用網友的一句很經典的話應該更能讓大家分清繼承和組合的區別:組合可以被說成“我請了個老頭在我家裡幹活” ,繼承則是“我父親在家裡幫我幹活”。

繼承還是組合?

首先它們都是實現系統功能重用,程式碼複用的最常用的有效的設計技巧,都是在設計模式中的基礎結構。

很多人都知道面向物件中有一個比較重要的原則『多用組合、少用繼承』或者說『組合優於繼承』。從前面的介紹已經優缺點對比中也可以看出,組合確實比繼承更加靈活,也更有助於程式碼維護。

所以,建議在同樣可行的情況下,優先使用組合而不是繼承。因為組合更安全,更簡單,更靈活,更高效。

注意,並不是說繼承就一點用都沒有了,前面說的是【在同樣可行的情況下】。有一些場景還是需要使用繼承的,或者是更適合使用繼承。

繼承要慎用,其使用場合僅限於你確信使用該技術有效的情況。一個判斷方法是,問一問自己是否需要從新類向基類進行向上轉型。如果是必須的,則繼承是必要的。反之則應該好好考慮是否需要繼承。

只有當子類真正是超類的子型別時,才適合用繼承。換句話說,對於兩個類A和B,只有當兩者之間確實存在 is-a 關係的時候,類B才應該繼承類A。

向上轉型將會在下一篇《重新認識Java(五) — 面向物件之多型》中詳細講解。

總結

根據我們前面講的內容我們可以發現繼承的缺點遠遠多於優點,儘管繼承在學習OOP的過程中得到了大量的強調,但並不意味著應該儘可能地到處使用它。相反,使用它時要特別慎重。

只有在清楚知道繼承在所有方法中最有效的前提下,才可考慮它。 繼承最大的優點就是擴充套件簡單,但大多數缺點都很致命,但是因為這個擴充套件簡單的優點太明顯了,很多人並不深入思考,所以造成了太多問題。

最後,總結一下:

1、精心設計專門用於被繼承的類,繼承樹的抽象層應該比較穩定,一般不要多於三層。
2、對於不是專門用於被繼承的類,禁止其被繼承。
3、優先考慮用組合關係來提高程式碼的可重用性。
4、子類是一種特殊的型別,而不只是父類的一個角色
5、子類擴充套件,而不是覆蓋或者使父類的功能失效

沒錯,寫了這麼多,就是想說: 請慎重使用繼承,除非你確定非用繼承不可!

這篇文章寫得比較粗糙,因為寫文章的時候一直在拉肚子。。。以後還會做一些修改,暫時先這樣。如果文中有錯誤或者有更好的解釋,歡迎給我留言。我也只是一個學習的人,而不是一個Java大神,所以不保證文章內容的正確性~