「MoreThanJava」Day 6:面向物件進階——多型
阿新 • • 發佈:2020-08-12
![](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)
非常感謝各位人才能 **看到這裡**,如果覺得本篇文章寫得不錯,覺得 **「我沒有三顆心臟」有點東西** 的話,**求點贊,求關注,求分享,求留言!**
創作不易,各位的支援和認可,就是我創作的最大動力,我們下篇文