1. 程式人生 > >「MoreThanJava」Day 6:面向物件進階——多型

「MoreThanJava」Day 6:面向物件進階——多型

![](https://cdn.jsdelivr.net/gh/wmyskxz/BlogImage01/「MoreThanJava」Day6:面向物件進階——多型/image-20200811201534863.png) - **「MoreThanJava」** 宣揚的是 **「學習,不止 CODE」**,本系列 Java 基礎教程是自己在結合各方面的知識之後,對 Java 基礎的一個總回顧,旨在 **「幫助新朋友快速高質量的學習」**。 - 當然 **不論新老朋友** 我相信您都可以 **從中獲益**。如果覺得 **「不錯」** 的朋友,歡迎 **「關注 + 留言 + 分享」**,文末有完整的獲取連結,您的支援是我前進的最大的動力! # Part 1. 多型概述 多型,簡而言之就是 **同一個行為** 具有 **多個不同表現形式** 或形態的能力。在面向物件的程式設計中,**多型的能力是通過資料抽象和繼承之後得來的**。 ![](https://cdn.jsdelivr.net/gh/wmyskxz/img/img/%E3%80%8CMoreThanJava%E3%80%8DDay4%EF%BC%9A%E9%9D%A2%E5%90%91%E5%AF%B9%E8%B1%A1%E5%9F%BA%E7%A1%80/ae55c7cc-ea6e-45f6-8014-307eb774bb38.png) 比如,有一杯水,我不知道它是溫的、冰的還是燙的,但是我一摸我就知道了,我摸水杯的這個動作 *(方法)*,對於不同溫度的水 *(執行時不同的物件型別)*,就會得到不同的結果,這就是多型。 **程式碼演示:** ```java // 基類定義 public class Water { public void showTem() { } } // 冰水 public class IceWater extends Water { @Override public void showTem() { System.out.println("我的溫度是: 0度"); } } // 溫水 public class WarmWater extends Water { @Override public void showTem() { System.out.println("我的溫度是: 40度"); } } // 開水 public class HotWater extends Water { @Override public void showTem() { System.out.println("我的溫度是: 100度"); } } // 測試類 public class TestWater { public static void main(String[] args) { Water w = new WarmWater(); w.showTem(); w = new IceWater(); w.showTem(); w = new HotWater(); w.showTem(); } } ``` **結果輸出:** ```text 我的溫度是: 40度 我的溫度是: 0度 我的溫度是: 100度 ``` 這裡的方法 `showTem()` 就相當於你去摸水杯。我們定義的 `Water` 型別的引用變數 `w` 就相當於水杯,你在水杯裡放了什麼溫度的水,那麼我摸出來的感覺就是什麼。就像程式碼中的那樣,放置不同溫度的水,得到的溫度也就不同,但水杯是同一個。 ## 里氏替換原則(LSP) 面向物件的設計原則有一條關於多型的原則,它的描述大概是這樣子的:**子類物件** *(object of subtype/derived class)* 能夠 **替換** 程式 *(program)* 中 **父類物件** *(object of base/parent class)* 出現的 **任何地方**,並且 **保證原來程式的邏輯行為 *(behavior)* 不變及正確性不被破壞**。 這麼說可能有點抽象,簡單說就是 **子類和父類的行為應該保持一致**。 ### 哪些程式碼明顯違背了 LSP? 實際上,裡式替換原則還有另外一個更加能落地、更有指導意義的描述,那就是 **“Design By Contract”**,中文翻譯就是 **“按照協議來設計”**。定義中父類和子類之間的關係,也可以替換成介面和實現類之間的關係。 為了更好地理解這句話,我舉幾個違反裡式替換原則的例子來解釋一下。 #### 1 - 子類違背父類宣告要實現的功能 父類中提供的 `sortOrdersByAmount()` 訂單排序函式,是按照金額從小到大來給訂單排序的,而子類重寫這個 `sortOrdersByAmount()` 訂單排序函式之後,是按照建立日期來給訂單排序的。那子類的設計就違背裡式替換原則。 #### 2 - 子類違背父類對輸入、輸出、異常的約定 在父類中,某個函式約定:執行出錯的時候返回 `null`;獲取資料為空的時候返回空集合(empty collection)。而子類過載函式之後,實現變了,執行出錯返回異常(exception),獲取不到資料返回 `null`。那子類的設計就違背裡式替換原則。 #### 3 - 子類違背父類註釋中所羅列的任何特殊說明 父類中定義的 `withdraw()` 提現函式的註釋是這麼寫的:“使用者的提現金額不得超過賬戶餘額……”,而子類重寫 `withdraw()` 函式之後,針對 VIP 賬號實現了透支提現的功能,也就是提現金額可以大於賬戶餘額,那這個子類的設計也是不符合裡式替換原則的。 > 當然,當前的大環境下,註釋的可信度還是得斟酌斟酌.. *(不可盡信..)* # Part 2. 向上轉型 && 向下轉型 ## 再談向上轉型 在 [上一篇文章](https://www.wmyskxz.com/2020/08/07/morethanjava-day-5-mian-xiang-dui-xiang-jin-jie-ji-cheng-xiang-jie/) 裡面我們已經談到 —— **物件既可以作為它本身的型別使用,也可以作為它基類的型別使用**。而這種把對某個物件的引用視為其基型別的引用的做法被稱為 **向上轉型** *(因為在繼承樹的畫法中,基類位於子類上方)*。 語句 `Water w = new WarmWater();` 就是向上轉型的典型程式碼,這會將子類型別 `WarmWater` 轉成父類的 `Water` 型別。 ### 存在問題 **❶ 向上轉型時,子類單獨定義的方法會丟失。** 例如,我們如果在溫水中定義一個喝水的方法 `drink()`,那麼當 `w` 引用指向 `WarmWater` 類例項的時候是訪問不到 `drink()` 方法的,`w.drink()` 會報錯。 **❷ 子類引用不能指向父類物件。** `HotWater hotWater = (HotWater)new Water();` 這樣是不行的。 ### 向上轉型的好處 - 減少重複程式碼,提高程式碼可讀性; - 提高系統擴充套件性; 舉個例子,比如我現在有許多不同溫度的水,如果不用向上轉型,摸水杯這個動作我需要這樣寫: ```java // Water 類中方法定義 public void showTem(IceWater water) { water.showTem(); } public void showTem(WarmWater water) { water.showTem(); } public void showTem(HotWater water) { water.showTem(); } // 測試類中呼叫 water.showTem(new IceWater()); water.showTem(new WarmWater()); water.showTem(new HotWater()); ``` 每一種不同溫度的水我都需要在 `Water` 中單獨定義一個方法 *(因為都是不同的型別)*,數量一多,就會變得非常冗餘和複雜。 但使用向上轉型,一切就輕鬆多了: ```java // Water 類中方法定義 public void showTem(Water water) { water.showTem(); } // 測試類中呼叫 water.showTem(new IceWater()); water.showTem(new WarmWater()); water.showTem(new HotWater()); ``` 就算新新增一種溫度的水,我也只需要繼承 `Water` 實現 `showTem()` 方法就行了,原有的程式碼幾乎不需要修改。這也體現了軟體設計原則中重要的 **開閉原則 —— 對擴充套件開放,對修改封閉。** ## 向下轉型 與向上轉型相對應的就是向下轉型了 —— 也就是把父類物件轉為子類物件。*(這有大坑...)* 還是用上面的摸水杯的例子來說明,我們先在溫水 `WarmWater` 中加入一個喝水的方法: ```java public class WarmWater extends Water { @Override public void showTem() { System.out.println("我的溫度是: 40度"); } // 新增加的喝水的方法 public void drink() { System.out.println("喝水..."); } } ``` **示例程式碼:** ```java class Tester { public static void main(String[] args) { Water water = new WarmWater();// 子類例項賦給父類引用 - 向上轉型 WarmWater warmWater = (WarmWater) water;// Water向下轉型為WarmWater warmWater.drink(); IceWater iceWater = (IceWater) water;// Water向下轉型為IceWater iceWater.drink();// IDE 提示無法找到 drink() 方法 } } ``` 為什麼第一段程式碼不報錯呢?因為 `water` 本身就是 `WarmWater` 型別的物件,所以它理所當然的可以向下轉型為 `WarmWater` 型別了,也理所當然的不能轉型為 `IceWater`,這就好像你見過 **一條狗突然變成一隻貓** 的情況嗎? **再來看下列程式碼:** ```java class Tester { public static void main(String[] args) { Water water = new Water(); WarmWater warmWater = (WarmWater) water; // 下列程式碼報錯:java.lang.ClassCastException: class Water cannot be cast to class WarmWater warmWater.drink(); } } ``` 上面例子想要說明的是,**`Water` 型別的物件 *(父型別)* 不能向下轉型為任何型別的物件**。這就好像你去考古,你發現了一個新生物,你知道它是一種動物,但你不能直接說它是貓或者狗... ### 向下轉型注意事項 - 向下轉型的前提是父類物件指向的是子類物件;*(也就是對應上面例項程式碼中向下轉型 `WarmWater` 的情況.. `new WarmWater()` 首先得完成向上的轉型..)* - 向下轉型只能轉型為本類物件;*(貓是不能變成狗的.. 對應上方 `WamWater` 型別就不能轉成 `IceWarm` 型別的情況)* ### 向下轉型的意義 有的小夥伴可能看到這裡有點懵了.. 向下轉型需要先向上轉型,這轉來轉去好玩兒是嗎? ![](https://cdn.jsdelivr.net/gh/wmyskxz/BlogImage01/「MoreThanJava」Day6:面向物件進階——多型/6af89bc8gw1f8q76jxt2xj202a025dfn.png) 向上轉型讓我們有了統一處理一類抽象事物的能力,這大大減少了我們的重複程式碼,並增加了我們程式碼的可擴充套件性。可事實上是,儘管我們盡力抽象一類事物,讓他們儘可能地保證行為的統一,但總有例外!*(就像 [上一次](https://www.wmyskxz.com/2020/08/07/morethanjava-day-5-mian-xiang-dui-xiang-jin-jie-ji-cheng-xiang-jie/) 我們討論繼承時提到的鳥類的例子,並不是所有鳥都能飛或者叫!)* 所以當例外來臨時,我們就可以及時判斷並做對應的處理。*(這也比較符合現實的情況)* 最典型的例子就是 JDK 中的某一些集合類,對於集合類來說,並不需要記住儲存所有儲存物件的型別,而是統一抽象成了 `Node` 型別,就拿 `HashMap` 來說吧,儲存一個元素 *(`putVal()` 方法)* 時就要判定當前節點時屬於連結串列還是紅黑樹的部分: ![為防止 "新小夥伴" 看著程式碼頭暈,簡化處理了一下](https://cdn.jsdelivr.net/gh/wmyskxz/BlogImage01/「MoreThanJava」Day6:面向物件進階——多型/image-20200811180639052.png) # Part 3. 多型經典案例分析 我們來看一個經典的例子: ```java // A 類 public class A { public String show(D object) { return "A and D"; } public String show(A object) { return "A and A"; } } // B 類 public class B extends A { public String show(B object) { return "B and B"; } @Override public String show(A object) { return "B and A"; } } // C 類 public class C extends B{ } // D 類 public class D extends B{ } ``` **測試類:** ```java public class Tester { public static void main(String[] args) { A a = new A(); A aRefB = new B(); B b = new B(); C c = new C(); D d = new D(); System.out.println("1-" + a.show(b)); System.out.println("2-" + a.show(c)); System.out.println("3-" + a.show(d)); System.out.println("4-" + aRefB.show(b)); System.out.println("5-" + aRefB.show(c)); System.out.println("6-" + aRefB.show(d)); System.out.println("7-" + b.show(b)); System.out.println("8-" + b.show(c)); System.out.println("9-" + b.show(d)); } } ``` **輸出結果:** ```text 1-A and A 2-A and A 3-A and D 4-B and A 5-B and A 6-A and D 7-B and B 8-B and B 9-A and D ``` 前三個比較容易,因為 B、C 都本質上是 A 類,所以 `1` 和 `2` 都進入了 A 類中籤名為 `show(A)` 的方法。 但是第四個非常奇怪,A 物件型別引用了一個 B 型別的例項,輸出是 `B and A`,而不是想象中的 `B and B`,為什麼呢? 這裡有一個新知識點:**決定呼叫哪個方法的是引用變數型別**。 拿這裡的 `aRefB.show(b)` 來說好了,`aRefB` 雖然是 A 型別的引用,但首先會查詢 B 物件中的方法 *(因為它實際的指向是 B)*,而引用 `b` 正好是一個 B 型別 *(實質上是 is-a A 型別)*,所以符合 B 物件中籤名為 `show(A)` 的方法,就輸出了 `B and A`。如果 B 型別中沒有符合簽名的方法,那麼會從父類中查詢,繼續這個過程直到找到或者報錯。 如果你能理解這個過程,並分析其他的情況,那麼說明你真的掌握了。 再來分析 `b.show(d)` 輸出 `A and D` 的情況,就簡單很多了:B 物件中不存在 `show(D)` 這樣的簽名,所以從父類 A 中查詢,故輸出了 `A and D`。 # 要點回顧 1. 多型概述 / 里氏替換原則 / 向上向下轉型; 2. 典型多型案例分析 / 練習; # 練習 ## 練習 1:工資結算系統 > 某公司有三種類型的員工,分別是部門經理、程式設計師和銷售員。需要設計一個工資結算系統,根據提供的員工資訊來計算月薪。 > > 部門經理的月薪是每月固定 `15000` 元; > 程式設計師的月薪按每月工作時間計算,每小時 `150` 元; > 銷售員的月薪是 `1200` 底薪加上銷售額 `5%` 的提成; **抽象員工類:** ```java public abstract class AbstractEmployee { private String name; public AbstractEmployee(String name) { this.name = name; } // 獲取工資 public abstract double getSalary(); public String getName() { return name; } public void setName(String name) { this.name = name; } } ``` **專案經理類:** ```java public class Manager extends AbstractEmployee { public Manager(String name) { super(name); } @Override public double getSalary() { return 15000; } } ``` **程式設計師類:** ```java public class Programer extends AbstractEmployee { private Integer workHours; public Programer(String name, Integer workHours) { super(name); this.workHours = workHours; } // 僅提供單獨的 set 方法,工作時間理論上來說是一個私人的訊息.. public void setWorkHours(Integer workHours) { this.workHours = workHours; } @Override public double getSalary() { return 150 * workHours; } } ``` **銷售員類:** ```java public class Salesman extends AbstractEmployee { private Integer salesAmount; public Salesman(String name, Integer salesAmount) { super(name); this.salesAmount = salesAmount; } // 也僅提供 set 方法,並不是所有人都能訪問銷售人員的銷售金額 public void setSalesAmount(Integer salesAmount) { this.salesAmount = salesAmount; } @Override public double getSalary() { return 1200 + 0.05 * salesAmount; } } ``` **測試類:** ```java import java.util.List; public class Tester { public static void main(String[] args) { // 專案經理張三、996程式設計師李四、月銷售過萬的明星銷售員王五 List employees = List .of(new Manager("張三"), new Programer("李四", (21 - 9) * 6), new Salesman("王五", 10000)); // 發工資.. for (AbstractEmployee employee : employees) { System.out.println(employee.getName() + "工資為:" + employee.getSalary()); } } } ``` **程式輸出:** ```text 張三工資為:15000.0 李四工資為:10800.0 王五工資為:1700.0 ``` *(ps:有感受到來自於現實主義的正義光輝灑在你的身上嗎?)* # 參考資料 1. 《Java 核心技術 卷 I》 2. 《Java 程式設計思想》 3. Introduction to Computer Science using Java - http://programmedlessons.org/Java9/index.html 4. 重新認識java(五) ---- 面向物件之多型(向上轉型與向下轉型) - https://blog.csdn.net/qq_31655965/article/details/54746235 5. 極客時間 | 設計模式之美 - https://time.geekbang.org/column/article/177110 6. Python 100 天從新手到大師 - https://github.com/jackfrued/Python-100-Days > - 本文已收錄至我的 Github 程式設計師成長系列 **【More Than Java】,學習,不止 Code,歡迎 star:[https://github.com/wmyskxz/MoreThanJava](https://github.com/wmyskxz/MoreThanJava)** > - **個人公眾號** :wmyskxz,**個人獨立域名部落格**:wmyskxz.com,堅持原創輸出,下方掃碼關注,2020,與您共同成長! ![](https://cdn.jsdelivr.net/gh/wmyskxz/img/img/common/qrcode.png) 非常感謝各位人才能 **看到這裡**,如果覺得本篇文章寫得不錯,覺得 **「我沒有三顆心臟」有點東西** 的話,**求點贊,求關注,求分享,求留言!** 創作不易,各位的支援和認可,就是我創作的最大動力,我們下篇文