iOS App 程式開發

詳解 Swift 各種 Type Polymorphism 找出最適合的實作方式!

Swift 不同 Type Polymorphism (多型) 的實現方式各有差異,這些差異在語法中經常被刻意隱瞞。這雖然使程式碼更簡潔易讀,但也造成開發者容易碰到一些不明就裡的設計問題。此文將簡介各個 type polymorphism 的原理與異同之處,為你找出最適合的實作方式。
詳解 Swift 各種 Type Polymorphism 找出最適合的實作方式!
詳解 Swift 各種 Type Polymorphism 找出最適合的實作方式!
In: iOS App 程式開發, Swift 程式語言, Xcode

Polymorphism (多型)是程式設計的基本概念之一,指同一個介面的背後可以有不同的實作。比如說在 UIKit 裡面的 UIImage,它的底層實作可能是 Core Image,也可能是 Core Graphics,但我們在 call site 通常不需要在意這些。另一個例子是 Swift 的 String,它的底層可能是 Swift 原生的也可能是從 NSString 橋接過來的,但它們表面上的介面都是一樣的。如此一來,開發者就不用針對每種實作去寫不同的程式碼,而只要操作一個統一的介面就好了。

Polymorphism 主要可以分成 ad hoc polymorphism、parametric polymorphism 與 subtyping 三個領域。Ad hoc polymorphism 指的就是跟 function 有關的 function overloading 與 operator overloading,但因為我們今天談的是 type polymorphism,所以先讓我們跳過它。

Parametric polymorphism 指的就是 generic programming,將原本應該在 design time 就決定的 type,延後到 compile time 再去解析。它可以展現在 function 上面而成為 generic function,也可以展現在 type 上面而成為 generic type。

Subtyping 則比較複雜一點。它的基本概念是去設計一個 supertype,來當作統一的介面,然後再透過某些方式去把 concrete type 跟 supertype 關聯起來,變成它的 subtype。Subtyping 可以透過 inheritance 與 composition 這兩種方式來達成。

接下來,就讓我們從最傳統的 inheritance 來探討它們的運作原理吧!

Inheritance

Inheritance 是 OOP 最根本的特色之一。在 Apple 平台上,Objective-C 從一開始就支援透過 subclassing 來達成 inheritance,而 Swift 也不例外。它的基本概念很簡單,就是當我們為某個 class 增加 superclass 的時候:

  1. 它會把 superclass 的成員清單 (vtable) 複製下來。
  2. 如果它跟 superclass 有成員重複的時候,它可以覆寫掉 superclass 的成員(overriding)。

在 Objective-C 裡,subclass 並不會複製 superclass 的成員清單,而是依靠 runtime 直接到 superclass 那邊去拿它的成員清單來看,不過這並不影響我們在概念上把 subclassing 用 value semantics 來解釋。

假設我們有 CircleSquare 兩個不同的 class:

class Circle {
    var diameter: Double = 1.0
}

class Square {
    var sideLength: Double = 1.0
}

然後有一個 class Shape

class Shape {
    var fillColor: Color = .red
}

如果把 CircleSquare 都加上 Shape 為 superclass 的話:

class Circle: Shape {
    var diameter: Double = 1.0
}

class Square: Shape {
    var sideLength: Double = 1.0
}

那麼它們就會把 ShapefillColor 也繼承下來:

Circle().fillColor // .red
Square().fillColor // .red

這跟多型有甚麼關係呢?很簡單,因為我們可以把 Circle()Square() 都當成 Shape 來操作:

let shapeA: Shape = Circle()
shapeA.fillColor

let shapeB = Square() as Shape
shapeB.fillColor

這兩種寫法在 Swift 中所產生的效果是一模一樣的。這樣子把 subclass 當成 superclass 來操作,就是所謂的 upcasting。Upcasting 並不會改變實體本身的成員清單,只會改變標示實體的型別資訊而已,所以當我們存取它的成員的時候,成員仍然是屬於 subclass 的。而因為 superclass 的所有成員在 subclass 裡必定都找得到,所以 upcasting 永遠是安全的。

同時,我們還可以把不同 concrete class 的實體放到同一個 Array 裡面:

let shapes: [Shape] = [shapeA, shapeB]

為甚麼這很重要呢?因為同一個陣列裡面,所有的變數大小一定要一致,否則無法進行有效率的記憶體空間管理。Class 變數因為本身儲存的內容就只是指向實體的 reference,所以即使 subclass 多了 stored property,增加實體的大小,也不會影響到只放 reference 的變數本身。

這樣子儲存不同 concrete type 實體的 Array,就是所謂的 heterogeneous collection

Inheritance 的運作概念雖然簡單,但它也容易造成很複雜的繼承階層。另外,Swift 所推出的 value type 也不支援 inheritance,所以勢必得有另一種方式來達成 value type 的 polymorphism,那就是⋯⋯

Composition

Composition 其實是一個比 inheritance 更直覺的概念。簡單來說,當我們想要給某個型別一個 supertype 來做統一介面的時候,我們不為它加一個 superclass,而是把它的實體裝在一個 container 裡面,透過 container 來間接操作它。而專門用來做 supertype 的 container,在 Swift 社群裡被稱作 existential container

Container 的介面會對應到被包裝的 concrete type 的成員,而確保 concrete type 具備這些成員的,就是 protocol。舉例來說,如果我們有 type HTTPCallLoadFileOperation

import Foundation

struct HTTPCall {

    var request: URLRequest

    var session: URLSession = .shared

    func execute(handler: @escaping (Result<Data, Error>) -> Void) {
        // 實作...
    }
}

struct LoadFileOperation {

    var fileURL: URL

    var manager: FileManager = .default

    func execute(handler: @escaping (Result<Data, Error>) -> Void) {
        // 實作...
    }
}

而如果我們想為它們設計一個 supertype 的話,首先就得透過 protocol 去定義統一的介面:

protocol Executable {
    func execute(handler: @escaping (Result<Data, Error>) -> Void)
}

extension HTTPCall: Executable { }

extension LoadFileOperation: Executable { }

如此一來,compiler 就會去檢查 HTTPCallLoadFileOperation 是否符合這個 protocol 的規範,我們也就可以確定它們都一定具備 execute(handler:) 這個 method 了。接下來,我們就可以把它們放進 existential container 裡面了。

怎麼放呢?很簡單,只要把它們的實體 typecast 成 protocol,compiler 就會自動產生一個 existential container 去把它們包起來了:

let call1: Executable = HTTPCall(request: request)

let call2 = LoadFileOperation(fileURL: url) as Executable

是的,我們剛剛寫的 protocol 除了確保 subtype 具備哪些成員之外,它本身也可以被當成 existential container 來用。只要一個變數的 type 是 protocol,那這個變數持有的,其實是一個相對應的 existential container。

Compiler 除了可以從一個 protocol 產生 existential container 之外,也可以從多個 protocol(可包含一個 class)所組成的 protocol composition type 來產生 existential:

protocol A { /* ... */ }
protocol B { /* ... */ }
protocol C { /* ... */ }

typealias ABC = A & B & C

// abc 的值就是一個從 A、B 與 C 的綜合介面所產生的 existential container。
var abc: ABC

Protocol existential container 除了給不同的 concrete type 一個統一介面之外,更解決了不同 type 的實體大小不同,所以不能放進同一個 collection 的問題。每個由 compiler 產生的 protocol existential 的大小都是一樣的,如果放得進 concrete type 實體的話就 inline 直接放,放不下的話就把實體丟到 heap 去再把 reference 存起來。所以,用 existential container 也是可以達成 heterogeneous collection 的。

Protocols with Associated Types (PATs)

Protocol existential 的語法在 Swift 裡極為簡單,因為 Swift 團隊特別把它簡化成跟 class upcasting 一樣用 as 來做,試圖將 existential container 的概念隱藏起來。然而,這樣的嘗試只成功了一半,因為當一個 protocol 具備 associated type 的要求時,Swift compiler 就沒辦法自動去產生 existential container。這樣的 protocol,社群裡稱之為 PAT (protocol with associated types)

假設我們想把 Executable 改為 PAT:

protocol Executable {
    associatedtype Response
    func execute(handler: @escaping (Response) -> Void)
}

extension HTTPCall: Executable {
    // Compiler 可以自動解析出 Response 的 concrete type,所以以下這行可省略。
    typealias Response = Result<Data, Error>
}

extension LoadFileOperation: Executable {
    // 同樣可省略。
    typealias Response = Result<Data, Error>
}

這時,如果我們一樣試圖把 HTTPCallLoadFileOperation cast 成 Executable 的話,就會得到這個警告:

Protocol ‘Executable’ can only be used as a generic constraint because it has Self or associated type requirements

這段話其實就是「Compiler 沒辦法自動產生 existential container」的意思。面對 PAT,我們就必須得手動去創造它的 existential container 了。而這個技巧,通常被稱為 type erasure。

Type Erasure:用 Class Inheritance 解決

Type Erasure 就是在 runtime 前,把實體的 concrete type 資訊給抹除掉的意思。Type Erasure 可以有很多種方式,其中一種是透過 class inheritance,來抹除介面部分的 concrete type 資訊:

// 用來作為統一介面的 abstract class。
// 將 PAT 的 associated type(Response)變成 class 的 generic parameter。
class AnyExecutable<Response>: Executable {

    func execute(handler: @escaping (Response) -> Void) {
        fatalError("未實作。")
    }
}

// 實際儲存實體與 concrete type 資訊的 subclass。
class AnyExecutableContainer<ConcreteType: Executable>: AnyExecutable<ConcreteType.Response> {

    private var instance: ConcreteType

    init(_ base: ConcreteType) {
        self.instance = base
    }

    // 將對 execute(handler:) 的呼叫引導給 instance。
    override func execute(handler: @escaping (ConcreteType.Response) -> Void) {
        instance.execute(handler: handler)
    }
}

// 創造 AnyExecutableContainer 實體後,可以透過 class upcast 把它們當成 AnyExecutable 來使用。
let call1: AnyExecutable = AnyExecutableContainer(HTTPCall(request: request))
let call2 = AnyExecutableContainer(LoadFileOperation(fileURL: url)) as AnyExecutable

// 只要這些 AnyExecutable 的 Response 是同樣的 type,它們就可以被放到同一個 collection 裡面。
let calls = [call1, call2]

這個方式是 Swift Standard Library 裡,用來實作 AnyIterable 等 existential container 的底層方式,可以在 ExistentialCollection.swift.gyb 這個檔案裡找到相關的原始碼。

這裡由於 container 都是 class,所以一樣可以達成 heterogeneous collection。

Type Erasure:手作 Witness Table

另外,因為 Swift 的 function 是 first class function,所以我們也可以去模擬 protocol witness table,把型別的各種成員都寫成是一個 structure 裡的 property,設計一個這樣的 existential container:

// 一樣把 associated type 變成是 generic parameter。
struct AnyExecutable<Response>: Executable {

    // Concrete type 的實體。
    private(set) var instance: Any

    // Concrete type 的成員 getter。
    private let instance_execute: (Any) -> (@escaping (Response) -> Void) -> Void

    // Concrete type 是甚麼,只有在 initializer 裡才知道,也就是這裡的 generic type T。
    init<T: Executable>(_ instance: T) where T.Response == Response {

        self.instance = instance

        self.instance_execute = { instance in

            // 因為只有在這個 initializer 裡面才能設定 self.instance 的型別,所以可以確定之後傳入的 instance 一定是 T,可以強制 cast。
            (instance as! T).execute(handler:)
        }
    }

    func execute(handler: @escaping (Response) -> Void) {

        // 取得 self.instance 的 execute(handler:) method。
        let method = instance_execute(instance)

        // 執行 method。
        method(handler)
    }
}

let call1 = AnyExecutable(HTTPCall(request: request))
let call2 = AnyExecutable(LoadFileOperation(fileURL: url))
let calls = [call1, call2]

這個方式則是依靠 Any 來把 concrete type 抹除掉。其實 Any 本身就是一個可以包容任何 concrete type 的 existential container,所以像 [Any] 這樣的 heterogeneous collection 才是可能的。同樣地,仿 witness table 版的 AnyExecutable 也因為用了 Any,所以可以放進 heterogeneous collection。

這兩種 type erasure 手段都用到了 generic parameter 來定義 PAT 裡的 associated type,這是因為 associated type 本身就已經進到了 ⋯⋯

Generic Programming

一開始的時候,我們就提到 generic programming 是把 design time 就決定的 type 移到 compile time,再去解析。甚麼意思呢?這就要講到 type constructor 的概念了。

如果講到 constructor,你會想到甚麼呢?多半是一個 type 的 initializer 吧:

struct Person {

    var name: String

    init(name: String) {
        self.name = name
    }
}

要創造屬於這個 type 的值,就必須用它的 intializer:

let jane = Person(name: "Jane")

print(jane.name) // Jane

所以,initializer 可以說是一種 value constructor。我們傳值並呼叫 Person.init(name:),它就會創造一個 Person 實體給我們。

Type constructor 的概念其實也一樣:我們傳一個 type 給它,它創造一個 type 給我們。它在 Swift 裡的形式,就是具備 generic parameter 的 type:

struct Container<Wrapped> { /* ... */ }

要創造一個 concrete type 來用,我們可以用 type alias:

typealias IntContainer = Container<Int>

或者,我們也可以省略用 type alias 給它命名的動作來用:

var container: Container<String>

是不是與 initializer 很像呢?不過,type constructor 特別的是它可以在 compile time 就被「執行」。比如說 Container<Wrapped> 裡面的實作,在 design time 時用的是 Wrapped 這個參數;但在 compile time, compiler 就會在碰到 Container<String> 這樣的 type 時,去把 Container 裡的 Wrapped 全部替換成 String,產生一個新的 type 來用。

Generic programming 不只有 type constructor,也可以用來構築 function:

func add<Number>(x: Number, y: Number) -> Number { /* ... */ }

但是單純的 generic parameter 本身是沒有介面的。還好,我們可以給它加上 protocol 或 class 的約束條件,以此來開啟它的介面:

protocol Addable {
    static func + (lhs: Self, rhs: Self) -> Self
}

func add<Number: Addable>(x: Number, y: Number) -> Number {
    return x + y
}

Static Typing

而正是加上約束條件的能力,使 type 為 generic parameter 的實體完全就像一般的實體一樣,可以進行各式各樣的操作。這使得它跟 subtyping 的技巧非常地相似,比如說以下這兩個 function,不只效果幾乎一樣,實作也長得完全相同:

protocol Runnable {
    func run()
}

// Subtyping
func start(runner: Runnable) {
    runner.run()
}

// Generic programming
func start<T: Runnable>(runner: T) {
    runner.run()
}

然而,這兩者其實是完全不同的東西。第一,Subtyping 的 concrete type 要到 runtime 才會被檢查,是 dynamic typing;而 generic 是在 compile time 就被解析,是 static typing,可以限制不同參數、屬性與變數的 type 之間的關係。

protocol Shooter {
    func shoot()
}

// Subtyping
func duel(shooterA: Shooter, shooterB: Shooter) { /* ... */ }

// Generic programming
func duel<T: Shooter>(shooterA: T, shooterB: T) { /* ... */ }

在此例中,subtyping 版本的 duel(shooterA:shooterB:) 裡的 shooterAshooterB可以是不同的 concrete type,只要都有遵守 Shooter 就好。然而在 generic 版本的 duel(shooterA:shooterB:) 中,shooterAshooterB 就必須是完全一樣的 concrete type 才能編譯。

除此之外,subtyping 裡的統一介面由一個 supertype 所定義,而同一個 supertype 底下可以有不同的 subtype,也可以被放進同一個變數或同一個 collection 裡面。相比之下,generic parameter 只是長得像 type,本身並不是一個 type。在 compile time 時,同一個 generic parameter 會被解析成完全一樣的 concrete type,所以它只支援 homogeneous collection,也就是全部元素的 concrete type 都一樣的 collection。

Opaque Return Type

Generic parameter 顧名思義就是傳入 generic type 或 generic function 的一種參數,是從外部透過介面去指定 concrete type 的。因此,generic parameter 會被限制於 generic type/function 內部的 scope。這大大限制了 generic programming 的範圍,不像 subtyping 的各種 supertype(class cluster、existential 等)到哪裡都可以用。

Swift 5.1 新增的 opaque type 功能,就是為了要使 generic programming 能被更廣泛的運用。它的概念跟 generic parameter 剛好相反,是由 generic function 內部的 scope 去決定 concrete type,而外部的 opaque type 則是要到 compile time 才會被解析出來。因此,它也被稱作「反轉的 generic」。

// 一般的 generic。
func doSomething<T>(with value: T) { /* ... */ }

// 由外部決定 T 的 concrete type。
// 這裡,T 是 Int。
doSomething(with: 42)



// 反轉的 generic。
// 跟一般 generic 不同,必須要有約束條件。
protocol Number { }

// 使 Int 遵守 Number 才能用。
extension Int: Number { }

// some Number 即為 opaque reture type,由實作決定 concrete type。
// 這裡它的 type 是 Int。
func getSomething() -> some Number {
    42
}

// number 的 concrete type 由 getSomething 的實作來決定。
let number = getSomething()

不過,因為它屬於 generic programming,所以一樣不能放到 heterogeneous collection 裡面:

// 只要是不同 function 回傳的 opaque type 都會被當成不一樣。
// 即使是兩個 concrete type 一樣的 opaque type...
func getSomething1() -> some Number {
    42
}

func getSomething2() -> some Number {
    42
}

// Compiler 還是會把它們當作不同的 type。
let numbers = [getSomething1(), getSomething2()] // Compile error

// 也不能共用變數。
var number = getSomething1()
number = getSomething2() // Compile error

這是因為它們的 type,是跟當初產生它的 function 綁在一起的。

總結

以上,我們檢視了 subtyping 與 generic programming 兩種迥異而互補的 type polymorphism 形式。在 WWDC 2015 的 408 號講座 Protocol-Oriented Programming in Swift 裡,當中一張簡報是在講一般 protocol 與有 Self 需求的 protocol 的差別,但我認為它其實就總結了 subtyping 與 generic programming 的核心差異:

polymorphism-1

圖片來源:Protocol-Oriented Programming in Swift – WWDC 2015 – Videos – Apple Developer

希望你了解各個 type polymorphism 的做法差異之後,能找到最適合拿來解決問題的方式!

參考資料

作者
Hsu Li-Heng
iOS 開發者、寫作者、filmmaker。現正負責開發 Storyboards by narrativesaw 此一故事板文件 app 中。深深認同 Swift 對於程式碼易讀性的重視。個人網站:lihenghsu.com。電郵:[email protected]
評論
更多來自 AppCoda 中文版
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。