1. 程式人生 > 實用技巧 >面向物件之繼承與多型

面向物件之繼承與多型

面向物件程式設計語言有三大特性:封裝、繼承和多型性。繼承是面嚮物件語言的重要特徵之一,沒有繼承的語言只能被稱作“使用物件的語言”。繼承是非常簡單而強大的設計思想,它提供了我們程式碼重用和程式組織的有力工具。

類是規則,用來製造物件的規則。我們不斷地定義類,用定義的類製造一些物件。類定義了物件的屬性和行為,就像圖紙決定了房子要蓋成什麼樣子。

一張圖紙可以蓋很多房子,它們都是相同的房子,但是坐落在不同的地方,會有不同的人住在裡面。假如現在我們想蓋一座新房子,和以前蓋的房子很相似,但是稍微有點不同。任何一個建築師都會拿以前蓋的房子的圖紙來,稍加修改,成為一張新圖紙,然後蓋這座新房子。所以一旦我們有了一張設計良好的圖紙,我們就可以基於這張圖紙設計出很多相似但不完全相同的房子的圖紙來。

基於已有的設計創造新的設計,就是面向物件程式設計中的繼承。在繼承中,新的類不是憑空產生的,而是基於一個已經存在的類而定義出來的。通過繼承,新的類自動獲得了基礎類中所有的成員,包括成員變數和方法,包括各種訪問屬性的成員,無論是public還是private。當然,在這之後,程式設計師還可以加入自己的新的成員,包括變數和方法。顯然,通過繼承來定義新的類,遠比從頭開始寫一個新的類要簡單快捷和方便。繼承是支援程式碼重用的重要手段之一。

類這個詞有分類的意思,具有相似特性的東西可以歸為一類。比如所有的鳥都有一些共同的特性:有翅膀、下蛋等等。鳥的一個子類,比如雞,具有鳥的所有的特性,同時又有它自己的特性,比如飛不太高等等;而另外一種鳥類,比如鴕鳥,同樣也具有鳥類的全部特性,但是又有它自己的明顯不同於雞的特性。

如果我們用程式設計的語言來描述這個雞和鴕鳥的關係問題,首先有一個類叫做“鳥”,它具有一些成員變數和方法,從而闡述了鳥所應該具有的特徵和行為。然後一個“雞”類可以從這個“鳥”類派生出來,它同樣也具有“鳥”類所有的成員變數和方法,然後再加上自己特有的成員變數和方法。無論是從“鳥”那裡繼承來的變數和方法,還是它自己加上的,都是它的變數和方法。

繼承

繼承表達了一種is-a關係,就是說,子類的物件可以被看作是父類的物件。比如雞是從鳥派生出來的,因此任何一隻都可以被稱作是一隻鳥。但是反過來不行,有些鳥是雞,但並不是所有的鳥都是雞。如果你設計的繼承關係,導致當你試圖把一個子類的物件看作是父類的物件時顯然很不合邏輯,比如你讓雞類從水果類得到繼承,然後你試圖說:這隻本雞是一種水果,所以這本雞煲就像水果色拉。這顯然不合邏輯,如果出現這樣的問題,那就說明你的類的關係的設計是不正確的。Java的繼承只允許單繼承,即一個類只能有一個父類。

對理解繼承來說,最重要的事情是,知道哪些東西被繼承了,或者說,子類從父類那裡得到了什麼。答案是:所有的東西,所有的父類的成員,包括變數和方法,都成為了子類的成員,除了構造方法。構造方法是父類所獨有的,因為它們的名字就是類的名字,所以父類的構造方法在子類中不存在。除此之外,子類繼承得到了父類所有的成員。
但是因為訪問修飾符的原因,不一定能直接使用。

public的成員直接成為子類的public的成員,protected的成員也直接成為子類的protected的成員。Java的protected的意思是包內和子類可訪問,所以它比預設的訪問屬性要寬一些。而對於父類的預設的未定義訪問屬性的成員來說,他們是在父類所在的包內可見,如果子類不屬於父類的包,那麼在子類裡面,這些預設屬性的成員和private的成員是一樣的:不可見。父類的private的成員在子類裡仍然是存在的,只是子類中不能直接訪問。我們不可以在子類中重新定義繼承得到的成員的訪問屬性。如果我們試圖重新定義一個在父類中已經存在的成員變數,那麼我們是在定義一個與父類的成員變數完全無關的變數,在子類中我們可以訪問這個定義在子類中的變數,在父類的方法中訪問父類的那個。儘管它們同名但是互不影響。

在構造一個子類的物件時,父類的構造方法也是會被呼叫的,而且父類的構造方法在子類的構造方法之前被呼叫。在程式執行過程中,子類物件的一部分空間存放的是父類物件。因為子類從父類得到繼承,在子類物件初始化過程中可能會使用到父類的成員。所以父類的空間正是要先被初始化的,然後子類的空間才得到初始化。在這個過程中,如果父類的構造方法需要引數,如何傳遞引數就很重要了。

如果子類繼承到了父類中private的東西,子類無法直接訪問。
如果子類有和父類完全一樣的成員變數,子類中會用自己的那一份。但一般子類不會再定義和父類中同名的變數。

多型變數和向上轉型

當把一個物件賦值給一個變數時,物件的型別必須與變數的型別相匹配,如:

Car myCar = new Car(); 

是一個有效的賦值,因為Car型別的物件被賦值給宣告為儲存Car型別物件的變數。但是由於引入 了繼承,這裡的型別規則就得敘述得更完整些:

一個變數可以儲存其所宣告的型別或該型別的任何子型別。也就是說子類的物件可以被當作父類的物件來使用。

物件變數可以儲存其宣告的型別的物件,或該型別的任何子型別的物件。

Java中儲存物件型別的變數是多型變數。“多型”這個術語(字面意思是許多形態)是指一個變數可以儲存不同型別(即其宣告的型別或任何子型別)的物件。
當把子類的物件賦值給父類的變數的時候就發生了“向上轉型”。

Java和C++的一個區別就是:物件之間無法賦值。

即使是這裡item中確實存放的是一個CD型別的物件,但把它賦值給一個CD型別的變數,編譯器依然是會報錯的,因為編譯器不懂它裡面是CD型別,編譯器只知道item的引用變數型別是Item不是CD,所以它認為錯了。
所以這裡需要強制型別轉換。

向上轉型:

  • 拿一個子類的物件當作父類的物件來使用
  • 向上轉型是預設的,不需要運算子
  • 向上轉型是安全的

多型

如果子類的方法覆蓋了父類的方法,我們也說父類的那個方法在子類有了新的版本或者新的實現。覆蓋的新版本具有與老版本相同的方法簽名:相同的方法名稱和引數表。因此,對於外界來說,子類並沒有增加新的方法,仍然是在父類中定義過的那個方法。不同的是,這是一個新版本,所以通過子類的物件呼叫這個方法,執行的是子類自己的方法。

覆蓋關係並不說明父類中的方法已經不存在了,而是當通過一個子類的物件呼叫這個方法時,子類中的方法取代了父類的方法,父類的這個方法被“覆蓋”起來而看不見了。而當通過父類的物件呼叫這個方法時,實際上執行的仍然是父類中的這個方法。注意我們這裡說的是物件而不是變數,因為一個型別為父類的變數有可能實際指向的是一個子類的物件。

當呼叫一個方法時,究竟應該呼叫哪個方法,這件事情叫做繫結。繫結表明了呼叫一個方法的時候,我們使用的是哪個方法。繫結有兩種:一種是早繫結,又稱靜態繫結,這種繫結在編譯的時候就確定了;另一種是晚繫結,即動態繫結。動態繫結在執行的時候根據變數當時實際所指的物件的型別動態決定呼叫的方法。Java預設使用動態繫結。

函式呼叫的繫結:

  • 當通過物件變數呼叫函式的時候,呼叫哪個函式這件事情叫做繫結
  • 靜態繫結:根據變數的宣告型別來決定
  • 動態繫結:根據變數的動態型別來決定
  • 在成員函式中呼叫其它成員函式也是通過this這個物件變數來呼叫的

常見問題:

  1. 父類的私有成員能被子類繼承嗎?
    官方文件的解釋:“A subclass does not inherit the private members of its parent class. However, if the superclass has public or protected methods for accessing its private fields, these can also be used by the subclass.”。原文地址:Inheritance

從繼承的概念來說,private和final不被繼承。Java官方文件上是這麼說的。從記憶體的角度來說,父類的一切都被繼承(從父類構造方法被呼叫就知道了,因為new一個物件,就會呼叫構造方法,子類被new的時候就會呼叫父類的構造方法,所以從記憶體的角度來說,子類擁有一個完整的父類)。子類物件所引用的記憶體有父類變數的一份拷貝。
  如圖所示,父類為Person類,子類為Student類。首先明確子類不能繼承父類的構造方法。這就是為什麼子類的預設的構造方法會自動呼叫父類的預設的構造方法。
  在子類的構造方法中通過super()方法呼叫父類的構造方法。也就是,在構造子類的同時,為子類構造出跟父類相同的域。如此就在子類的物件中,也擁有了父類宣告的域了。

父類的私有變數能被子類繼承,但在子類中無法直接訪問父類的私有變數。只有藉助公共的方法來訪問父類的私有變數。

//顯式說明使用父類地getSalary()
public double getSalary() {
double baseSalary = super.getSalary()* return baseSalary + bonus;
}
//而不是使用子類中同名的getSalary方法,這樣會導致無限次地呼叫自己,直到記憶體溢位
public double getSalary() {
double baseSalary = getSalary();// still won't work return baseSalary + bonus;
}

注意:
有些人認為 super 與 this 引用是類似的概念, 實際上,這樣比較並不太恰當。這是 因為 super 不是一個物件的引用,不能將 super 賦給另一個物件變數,它只是一個指示編 譯器呼叫超類方法的特殊關鍵字。
關於成員變數的繼承,父類的任何成員變數都是會被子類繼承下去的,這些繼承下來的私有成員雖對子類來說不可見,但子類仍然可以用父類的函式操作他們.
這樣的設計的意義就是我們可以用這個方法將我們的成員保護得更好,讓子類的設計者也只能通過父類指定的方法修改父類的私有成員,這樣將能把類保護得更好,這對一個完整的繼承體系是尤為可貴的.

  1. Java例項化的時候為什麼一定要呼叫父類的構造方法,為什麼父類的構造方法無法被繼承?

    構造方法的定義 是與類的名稱相同;如果子類能夠繼承父類的構造方法;那麼在子類的構造方法中就有不同於子類名稱的構造法;
    這與構造方法的定義不符;所以子類是不能繼承父類的構造方法的;
    究其原因,想必是 Java 語言設計者,要求子類有責任保證它所繼承的父類儘快進入到一個穩定、完整的狀態中。試想,如果沒有這個約束,那麼子類的某個繼承自父類的方法可能會使用到父類中的一些變數,而這些變數並沒有進行初始化,從而產生一些難以預料的後果,因此構造子類的物件前,必須構造父類的物件,並將之隱含於子類物件之中,使用關鍵字super引用父類物件。
    也因此,當一個類的構造方法是 private 時,它是不可被 extends 的,因為子類構造方法難以呼叫到這個父類的構造方法。

  2. 為什麼不能用子類的構造器去初始化繼承到的父類的成員呢?
    首先例項化就是給物件分配記憶體,構造方法就是分配記憶體的實現,那麼,子類如何才能更方便的分配記憶體呢?很顯然,就是呼叫父類構造方法來分配父類部分的記憶體,然後再呼叫自己的構造方法來分配子類擴充套件的記憶體。否則,如果子類完全從頭開始自己分配記憶體,那麼繼承父類又有什麼優點呢?因為子類的父類部分是完全和父類一樣的,你覺得有必要再自己從頭開始分配記憶體嗎?既然父類的記憶體分配已經有現成的方法,為什麼不直接呼叫來分配父類部分的記憶體呢?