<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          Java 小白成長記 · 第 6 篇「為什么說要慎用繼承,優(yōu)先使用組合」

          共 6005字,需瀏覽 13分鐘

           ·

          2021-02-02 00:40

          點擊上方藍(lán)字關(guān)注我們


          0. 前言

          在代碼的編寫過程中,避免冗余代碼的出現(xiàn)是非常重要的,大段大段的重復(fù)代碼必然不能夠稱之為優(yōu)雅。所謂減少冗余代碼,通俗來說就是實現(xiàn)一段代碼多處使用,「在不污染源代碼的前提下使用現(xiàn)存代碼」,也就是代碼「復(fù)用」,避免重復(fù)編寫。然而,對于像 C 語言等面向過程的語言來說,復(fù)用通常指的僅僅只是「復(fù)制代碼」,任何語言都可通過簡單的復(fù)制來達(dá)到代碼復(fù)用的目的,顯然這樣做的效果并不好。

          Java 作為一種面向?qū)ο蟮恼Z言,圍繞「類」來解決冗余代碼的問題。我們可以直接使用別人構(gòu)建的代碼,而非創(chuàng)建新類、重新開始或者無腦的復(fù)制代碼。

          Java 中實現(xiàn)代碼復(fù)用的手段有兩種,標(biāo)題也寫的很清楚:

          • 第一種手段:組合
          • 第二種手段:繼承

          本文會先分別講解什么是繼承,什么是組合,最后再揭開標(biāo)題的謎底 — 「為什么說要慎用繼承,優(yōu)先使用組合」。

          1. 什么是組合

          所謂組合(Composition),就是「在新類中創(chuàng)建現(xiàn)有類的對象」。不管是繼承和組合,都允許在新類中直接復(fù)用舊類的「公有」方法或字段。

          舉個例子,比如說所有的動物都擁有心跳 beat 和呼吸 breath,我們將心跳和呼吸抽象成一個類 Animal,這個類就稱為現(xiàn)有類,現(xiàn)在有一個動物:貓 Cat,那么 Cat 這個類就稱為新類,「將 Animal 類的對象嵌入 Cat 這個類中,Cat 就具有了心跳和呼吸」,這就使用了組合。

          通俗來說 Cat 擁有 Animal,即 「has-a」 的關(guān)系。以后再有其他動物的出現(xiàn),比如狗 Dog,也同樣將 Animal 類嵌入其中使其具有心跳和呼吸即可,不必重復(fù)的寫心跳和呼吸方法的代碼。這便是組合的全部意義。UML 類圖如下:

          代碼示例如下:

          public?class?Animal?{
          ?public?void?beat(){
          ??System.out.println("My?heart?is?beating");
          ?}
          ?public?void?breath(){
          ??System.out.println("I'm?breathing");
          ?}
          }

          Cat 擁有 Animal,不僅擁有了呼吸和心跳功能,并且還可以添加自己的新屬性,使其具有新的方法:

          public?class?Cat?{
          ????//?組合
          ?private?Animal?animal;
          ????//?使用構(gòu)造函數(shù)初始化成員變量
          ?public?Cat(Animal?animal){
          ??this.animal?=?animal;
          ?}
          ????//?通過調(diào)用成員變量的固有方法使新類具有相同的功能
          ?public?void?breath(){
          ??animal.breath();
          ?}
          ????//?通過調(diào)用成員變量的固有方法使新類具有相同的功能
          ?public?void?beat(){
          ??animal.beat();
          ?}
          ????//?為新類增加新的方法
          ?public?void?run(){
          ??System.out.println("I'm?running");??
          ?}
          }

          這樣,Cat 這個新類擁有了三種方法:breath / beat / run:

          //?顯式創(chuàng)建被組合的對象實例?animal
          Animal?animal?=?new?Animal();
          //?以?animal?為基礎(chǔ)組合出新對象實例?cat
          Cat?cat?=?new?Bird(animal);
          //?新對象實例?cat?可以?breath()
          cat.breath();
          //?新對象實例?cat?可以?beat()
          cat.beat();
          //?新對象實例?cat?可以?run()
          cat.run();

          以上便是組合實現(xiàn)復(fù)用的方式,Cat 對象由 Animal 對象組合而成,如上面的示例代碼,在創(chuàng)建 Cat 對象之前先創(chuàng)建 Animal 對象,并利用這個 Animal 對象來創(chuàng)建 Cat 對象。

          實際上,組合表示出來的是一種明確的「整體-部分」的關(guān)系。而對于繼承來說,是將某一個抽象的類,改造成能夠適用于不同特定需求的類。

          2. 什么是繼承

          還從上面的例子的入手,上面我們使用組合復(fù)用了 Animal 類,事實上,也可以使用繼承實現(xiàn) Animal 類的復(fù)用。

          對于 CatAnimal,我們還可以這樣理解,Cat 「是」一種 Animal,即 「is-a」 的關(guān)系。這樣,Cat 稱為「子類(派生類)」,Animal稱為 Cat「父類(超類、基類)」。在組合中,新類 Cat 訪問舊類 Animal 中的屬性需要通過內(nèi)嵌的舊類對象來調(diào)用,而對于繼承來說,「新類(子類)可以直接調(diào)用舊類(父類)的公有屬性」。UML 類圖如下:

          Java 中的繼承關(guān)系使用關(guān)鍵字 extends 來標(biāo)識,示例代碼如下:

          public?class?Cat?extends?Animal{
          ????//?為新類增加新的方法
          ?public?void?run(){
          ??System.out.println("I'm?running");??
          ?}
          }

          Cat 繼承 Animal 后,自動擁有了父類 Animal 中的方法 beatbreath,并可以直接調(diào)用,代碼如下:

          Cat?cat?=?new?Cat();
          //?子類實例?cat?可以?breath()
          cat.breath();
          //?子類實例?cat?可以?beat()
          cat.beat();
          //?子類實例?cat?可以?run()
          cat.run();

          以上便是繼承實現(xiàn)復(fù)用的方式,Cat 繼承自抽象的類 Animal,并將其改造成能夠適用于某種特定需求的類。

          3. 方法覆蓋 / 重寫

          子類繼承父類后,不僅可以直接調(diào)用父類的方法,還可以對父類的方法進(jìn)行重寫,使其擁有自己的特征。仍然以上面的 CatAnimal 為例,假設(shè) Cat 繼承 Animal 后,對 Animal 原生的呼吸方法 breath 很不滿意,但是你不能不呼吸對吧,所以這個時候就可以直接對 breath 方法的方法體進(jìn)行重寫。

          「注意,重寫和重載不同」,在Java 小白成長記第 4 篇中我們說過,重載指的是兩個方法具有相同的名字,但是不同的參數(shù),而「重寫不僅方法名相同,參數(shù)列表和返回類型也相同」。示例代碼如下:

          public?class?Cat?extends?Animal{
          ????......
          ????
          ????//?重寫?breath?方法
          ????@Override
          ????public?void?breath(){
          ??System.out.println("I'm?cat,?"?+?super.breath());
          ?}????
          }

          @Override 注解即表示方法重寫,不過這個也可以不寫,JVM 能夠自動的識別方法覆蓋。

          上面這個方法輸出的將是 I'm cat, I'm breathing,也就是說,在子類中可以使用 super 關(guān)鍵字調(diào)用父類的方法。

          另外,一定要注意的是:「在覆蓋一個方法的時候,子類方法不能低于父類方法的可見性」。特別是, 如果超類方法是 public, 子類方法一定要聲明為 public。常會發(fā)生這類錯誤:在聲明子類方法的時候, 遺漏了 public修飾符。此時,編譯器將會把它解釋為試圖提供更嚴(yán)格的訪問權(quán)限:

          4. 子類的構(gòu)造函數(shù)

          現(xiàn)在,我們?yōu)楦割?Animal 添加一個私有字段 age,每個動物都有年齡嘛,當(dāng)然,對于子類來說,這個私有字段它們是無法訪問的。

          public?class?Animal?{
          ????//?新增一個私有字段
          ????private?int?age;?
          ????
          ????//?父類的構(gòu)造函數(shù)
          ????public?Animal(int?age)?{?
          ????????this.age?=?age;
          ????}
          ?......
          }

          同樣的,我們規(guī)定在構(gòu)造 Cat 的時候,需要為其指定年齡 age 和貓耳的類型 earKind,這就需要使用子類的構(gòu)造函數(shù)了:

          public?class?Cat?extends?Animal{
          ????private?String?earKind;
          ????
          ????public?Cat(int?age,?String?earKind)?{
          ????????super(age);
          ????????this.earKind?=?earKind;
          ????}
          ????
          ????.........
          }

          可以看出,我們通過 super(age) 調(diào)用了父類的構(gòu)造函數(shù)為這個貓指定了年齡,這個同 this 關(guān)鍵字一樣,「使用 super調(diào)用構(gòu)造函數(shù)的語句必須是子類構(gòu)造函數(shù)的第一條語句」。

          ?

          「如果子類的構(gòu)造器沒有顯式地調(diào)用父類的構(gòu)造器, 則將自動地調(diào)用父類默認(rèn)的構(gòu)造函數(shù)(無參構(gòu)造函數(shù))」。如果超類沒有無參構(gòu)造函數(shù), 并且在子類的構(gòu)造器中又沒有顯式地調(diào)用超類的其他構(gòu)造器,則 Java 編譯器將報告錯誤。

          ?

          需要注意的是:「父類的構(gòu)造函數(shù)總是先于子類的構(gòu)造函數(shù)執(zhí)行」。這點應(yīng)該很好理解,你不能說先構(gòu)造一個個貓出來,再給他添加呼吸和心跳對吧,你一定是先有呼吸和心跳,才有這個貓的。

          5. 向上轉(zhuǎn)型和向下轉(zhuǎn)型

          ① 向上轉(zhuǎn)型

          繼承最重要的方面不是為子類提供方法。它是子類與父類的一種關(guān)系。簡而言之,上文我們也說過,這種關(guān)系可以表述為「子類是父類的一種類型」。這種描述并非是解釋繼承的一種花哨方式,這是直接由語言支持的。下面例子展示了編譯器是如何支持這一概念的:

          Animal?cat?=?new?Cat(...);?//?向上轉(zhuǎn)型?Cat->Animal

          也就是說,「程序中出現(xiàn)父類對象的任何地方都可以用子類對象置換」,這便是「向上轉(zhuǎn)型」。通過子類對象 (小范圍) 實例化父類對象(大范圍),這種屬于自動轉(zhuǎn)換。事實上,這是「多態(tài)」的一種體現(xiàn)。后續(xù)文章我們會詳細(xì)講解。

          需要注意的是:「父類引用變量指向子類對象后,只能使用父類已聲明的方法」,但方法如果被重寫會執(zhí)行子類的方法,如果方法未被重寫那么將執(zhí)行父類的方法。

          ② 向下轉(zhuǎn)型

          不僅存在向上轉(zhuǎn)型,還存在向下轉(zhuǎn)型。正像有時候需要將浮點型數(shù)值 float 轉(zhuǎn)換成整型數(shù)值 int 一樣,有時候也可能需要「將某個父類的對象引用轉(zhuǎn)換成子類的對象引用,調(diào)用一些子類特有而父類沒有的方法」。對象向下轉(zhuǎn)型的語法與數(shù)值表達(dá)式的類型轉(zhuǎn)換類似,僅需要用一對圓括號將目標(biāo)類名括起來,并放置在需要轉(zhuǎn)換的對象引用之前就可以了。例如:

          Animal?animal?=?new?Cat(...);?//?向上轉(zhuǎn)型?Cat->Animal
          Cat?cat?=?(Cat)?animal;?//?向下轉(zhuǎn)型?Animal->Cat,animal?的實質(zhì)還是指向?Cat

          6. 受保護訪問 protected

          大家都知道,最好將類中的域標(biāo)記為 private, 而方法標(biāo)記為 public。任何聲明為 private 的內(nèi)容對其他類都是不可見的。前面已經(jīng)看到, 這對于子類來說也完全適用,即子類也不能訪問父類的私有域。

          然而,在有些時候,人們希望父類中的某些方法或字段允許被子類訪問,為此, 需要將這些方法或域聲明為 protected。上篇文章說過,「這個訪問修飾符提供包訪問權(quán)限和子類訪問權(quán)限」。例如,如果將父類 Animal中的 age聲明為 proteced,而不是私有的, Cat中的方法就可以直接地訪問它,「即使子類和父類不在一個包下」。這表明子類得到信任,可以正確地使用這個方法,而不和父類在同一個包下的其他類則不行。

          7. Java 中的單繼承

          在深入學(xué)習(xí) Java 之前,我學(xué)的其實是 C++,而 C++ 是支持多繼承的,也就是說 A 可以同時繼承 B 和 C 甚至更多。然而,「在 Java 中,子類只能繼承一個父類」。也就是「單繼承」

          為啥 Java 和 C++ 都是面向?qū)ο蟮?,C++ 支持多繼承和 Java 卻不支持呢?C++ 語言是 1983 年由貝爾實驗室的 Bjarne Stroustrup 在 C 語言的基礎(chǔ)上推出的,Java 語言是 1995 年由 James Gosling 和同事共同正式推出的。在 C++ 被設(shè)計出來后,太多人掉進(jìn)了多繼承帶來的坑,雖然它也提出了相應(yīng)的解決辦法,「但 Java 語言本著簡單的原則舍棄了 C++ 中的多繼承,這樣也會使程序更具安全性」。

          那么多繼承到底帶來什么坑?其實也不難理解:

          如果一個子類擁有多個父類的話,那么當(dāng)多個父類中有重復(fù)的屬性或者方法時,子類的調(diào)用結(jié)果就會含糊不清,也就是存在「二義性」。因此 Java 使用了單繼承。

          那么問題來了,假設(shè)有一個人魚種類,它既擁有動物 Animal 的特征,又擁有人 Person 的特征,既然不支持多繼承,它如何同時具有這兩個的特征呢?這時候就可以使用「多接口(多實現(xiàn))」,通過實現(xiàn)多個接口拓展類的功能,即使實現(xiàn)的多個接口中有重復(fù)的方法也沒關(guān)系,因為在實現(xiàn)類中必須重寫接口中的方法,所以調(diào)用的時候調(diào)用的是實現(xiàn)類中重寫的方法。接口部分是后話了,本文暫且不做討論。

          8. 為什么說要慎用繼承,優(yōu)先使用組合

          終于來到了文章標(biāo)題,為什么說要「慎用繼承,優(yōu)先使用組合」?

          因為在 Java 中使用繼承就無法避免以下這兩個問題:

          • 1)打破了封裝性,違反了 OOP 原則。迫使開發(fā)者去了解父類的實現(xiàn)細(xì)節(jié),子類和父類耦合
          • 2)父類更新后可能會導(dǎo)致一些不可知的錯誤

          這么說大家可能還無法直觀的感受,這樣,我們舉個例子:自定義一個子類 MyHashSet,它繼承了 Java 的原生 API HashSet,并重寫了父類的兩個方法 addaddAll,它和父類唯一的區(qū)別是加入了一個計數(shù)器,用來統(tǒng)計添加過多少個元素。

          public?class?MyHashSet<E>?extends?HashSet<E>?{
          ????private?int?addCount?=?0;?
          ?
          ????//?獲取?addCount
          ????public?int?getAddCount()?{?
          ????????return?addCount;
          ????}
          ?
          ????//?重寫父類的?add?方法
          ????@Override
          ????public?boolean?add(E?e)?{
          ????????addCount++;
          ????????return?super.add(e);
          ????}
          ?
          ????//?重寫父類的?add?方法
          ????@Override
          ????public?boolean?addAll(Collection?c)?{
          ????????addCount?+=?c.size();
          ????????return?super.addAll(c);
          ????}
          }

          HashSet 是集合章節(jié)的內(nèi)容,后續(xù)會詳細(xì)講解,這里大家只需要知道 add 用來向集合中添加一個元素,addAll 用來向集合中添加多個元素即可。

          按照上面子類重寫的邏輯,每向集合中添加一個元素,addCount 就會相應(yīng)的增加一個。

          MyHashSet?myHashSet?=?new?MyHashSet();
          myHashSet.addAll(Arrays.asList(1,2,3));
          System.out.println(myHashSet.getAddCount());

          上面這段測試代碼我們通過子類重寫的 addAll 方法向集合中添加了 3 個元素,按理來說,addCount 應(yīng)該是 3。然而,運行結(jié)果卻是 6。這看起來確實很匪夷所思。

          我們進(jìn)入父類 HashSet 的源碼看看,就能發(fā)現(xiàn)出錯的原因:

          addAll 方法內(nèi)部調(diào)用的是 add() 方法。也就是說,按照上面子類重寫的邏輯,子類在調(diào)用自己的 addAll() 方法時,首先 addCount 會加 3,然后調(diào)用父類的 addAll() 方法,父類的 addAll() 又會調(diào)用子類的 add() 方法三次,這樣 addCount 又會再加 3。

          出現(xiàn)這種情況的原因,就是「父類中可覆蓋的方法調(diào)用了別的可覆蓋的方法,這時候如果子類覆蓋了其中的一些方法,就可能導(dǎo)致錯誤」。

          結(jié)合上圖理解,HashSet 類里有可覆蓋的方法 addAll 和方法 add,并且 addAll 調(diào)用了 add。子類 MyHashSet 重寫了方法 add,這時候如果子類調(diào)用繼承來的方法 addAll,那么方法 addAll 調(diào)用的就不再是父類的 HashSet.add(),而是子類中的方法 MyHashSet.add()。

          顯然,這樣的問題出現(xiàn)后,開發(fā)人員會一臉懵逼,子類的寫法從表面上看來完全沒有問題,這就迫使開發(fā)認(rèn)域去了解父類的實現(xiàn)細(xì)節(jié),從而打破了面向?qū)ο蟮姆庋b性,因為封裝性是要求隱藏實現(xiàn)細(xì)節(jié)的。更危險的是,錯誤不一定能輕易地被測出來,如果開發(fā)者不了解超類的實現(xiàn)細(xì)節(jié)就進(jìn)行重寫,那么可能就埋下了隱患。

          第二個使用繼承的缺點即父類更新后可能會導(dǎo)致一些不可知的錯誤,這點很好理解:

          • 1)父類更改了方法的簽名,會導(dǎo)致編譯錯誤
          • 2)父類新增了方法,并且正好和子類的某個方法同名但是返回類型不同,會導(dǎo)致編譯錯誤
          • 3)父類新增了方法,并且正好和子類的某個方法的簽名完全相同,這時候編譯器會認(rèn)為子類進(jìn)行了方法重寫,會導(dǎo)致編譯錯誤
          • 4)......

          說到這里,大家大概了解了為什么說要慎重使用繼承了吧,「如果使用繼承和組合都可以處理某種情況,那么優(yōu)先使用組合」,組合完美的解決了上述繼承的缺點。而如果必須要使用繼承,那么應(yīng)該精心設(shè)計父類,防止上述問題的發(fā)生,并提供詳細(xì)的開發(fā)文檔。




          ???下方掃碼關(guān)注公眾號「飛天小牛肉」(專注于分享計算機基礎(chǔ)、Java 基礎(chǔ)和面試指南的相關(guān)原創(chuàng)技術(shù)好文,幫助讀者快速掌握高頻重點知識,有的放矢),與小牛肉一起成長、共同進(jìn)步

          ????

          ???并向大家強烈推薦我維護的?Gitee 倉庫?「CS-Wiki」(Gitee 推薦項目,目前已 0.9k star。面向全棧,致力于構(gòu)建完善的知識體系:數(shù)據(jù)結(jié)構(gòu)、計算機網(wǎng)絡(luò)、操作系統(tǒng)、算法、數(shù)據(jù)庫、設(shè)計模式、Java 技術(shù)棧、機器學(xué)習(xí)、深度學(xué)習(xí)、強化學(xué)習(xí)等),相比公眾號,該倉庫擁有更健全的知識體系,歡迎前來 star,倉庫地址 https://gitee.com/veal98/CS-Wiki。也可直接下方掃碼訪問

          瀏覽 58
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  国产7777777 | 成人啪啪网站 | 在线观看内射视频 | 色婷五月天 | 黄色电影A片电影 |