軟體設計

OOP 多型完整整理:從覆寫到協定一次搞懂

想一次搞懂 OOP 多型、從覆寫到協定,卻越看越混亂嗎?

閱讀 3 分鐘
OOP 多型完整整理:從覆寫到協定一次搞懂 封面圖

想一次搞懂 OOP 多型、從覆寫到協定,卻越看越混亂嗎?

多載、覆寫和協定看起來很像,很容易搞混。

先說結論:多型就是「相同名稱的呼叫,會依目標而有不同的行為」。覆寫透過繼承實作,而協定則透過規範實作。

這篇文章會一步步整理這三個概念如何彼此關聯。

多型到底是什麼?

多型(Polymorphism)在希臘文中代表「多種(poly)形式(morph)」。

簡單來說,即使傳送相同訊息,不同物件也會做出不同回應。

例如,同樣叫狗和貓「發出聲音」,狗會汪汪叫,貓會喵喵叫。

呼叫端只要呼叫소리내()即可。實際要產生什麼內容,由各個物件自行決定。

多型的核心就是「呼叫端不需要知道」。

因為不管收到什麼,都能用相同的程式碼處理。

這項特性讓程式碼更有彈性。即使加入新動物,也不需要修改呼叫端的程式碼。


多載和覆寫有什麼不同?

名稱相似,是最容易混淆的地方。

多載是指建立多個同名函式,只改變參數。

覆寫是指子類別重新定義並覆蓋父類別建立的函式。

光用文字不容易理解,直接看程式碼吧。

class Animal {
    func sound() { print("...") }
}

class Dog: Animal {
    override func sound() { print("汪汪") }  // 覆寫
}

let a: Animal = Dog()
a.sound()
// 輸出:汪汪

有趣的是,雖然a的型別是Animal,卻會發出Dog的聲音。

這就是在執行階段查看實際物件後決定的多型。

單一父類別下,子類別各自以不同方式重新定義
單一父類別下,子類別各自以不同方式重新定義

多載則有些不同。要呼叫哪個函式會在編譯時決定。

func add(_ a: Int, _ b: Int) -> Int { a + b }
func add(_ a: String, _ b: String) -> String { a + b }

print(add(1, 2))       // 輸出: 3
print(add("甲", "乙"))  // 輸出:甲乙

因此,多載也稱為靜態多型,覆寫則稱為動態多型。


為什麼需要協定?

只靠繼承也能實現多型,那為什麼還需要協定?

繼承代表「is-a」關係,因此父類別只能有一個。貓很難同時是動物又屬於另一個階層。

協定採用「只要遵守這份規範,任何型別都可以」的方式,因此自由得多。

protocol Soundable {
    func sound()
}

struct Cat: Soundable {
    func sound() { print("喵喵") }
}

struct Car: Soundable {
    func sound() { print("叭叭") }
}

let things: [Soundable] = [Cat(), Car()]
things.forEach { $0.sound() }
// 輸出:喵喵
// 輸出:叭叭

貓和汽車沒有任何繼承關係,卻能放進同一個陣列並以相同方式處理。

不靠繼承關係也能取得多型,這就是協定的力量。

這也是 Swift 這類語言優先採用協定而非繼承的原因。

親自輸入程式碼後,我掌握了 protocol 和 override 的感覺
親自輸入程式碼後,我掌握了 protocol 和 override 的感覺

什麼時候用繼承,什麼時候用協定?

我把實務上的判斷標準整理成表格。

情況 判斷
想繼承共用程式碼 繼承(覆寫)
沒有關係,但想保證相同的行為 協定
想同時賦予多種特性 協定(可同時採用多個)
想讓值型別(struct)具備多型 協定

只要記住幾個簡單原則即可。

  • 想大量重複使用父類別實作時,用繼承
  • 想定義「能做什麼」的能力時,用協定
  • 如果在意可擴充性,優先考慮協定

面試時會這樣問

Q. 請說明多載和覆寫的差異。

多載是在相同名稱下定義不同參數,於編譯時決定。覆寫則是子類別重新定義繼承而來的方法,於執行時查看實際物件後決定。因此前者稱為靜態多型,後者稱為動態多型。

Q. 使用協定取代繼承有什麼好處?

繼承只能有一個父類別,也不能用於值型別;協定則能同時採用多個,也能套用在 struct 上。即使是不相關的型別,也能用相同規範整合處理,有利於擴充。


多型一開始可能很抽象,但只要抓住「呼叫端不需要知道」這句話,其他內容就會迎刃而解。

把今天學到的覆寫和協定各用程式碼試一次,就會真正熟悉。加油!

延伸閱讀