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


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 Shape

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

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

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

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

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

為甚麼這很重要呢?因為同一個陣列裡面,所有的變數大小一定要一致,否則無法進行有效率的記憶體空間管理。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

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

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

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

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

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

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:

這時,如果我們一樣試圖把 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 資訊:

這個方式是 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:

這個方式則是依靠 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 吧:

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

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

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

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

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

是不是與 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:

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

Static Typing

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

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

在此例中,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 programming,所以一樣不能放到 heterogeneous collection 裡面:

這是因為它們的 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 的做法差異之後,能找到最適合拿來解決問題的方式!

參考資料


iOS 開發者、寫作者、filmmaker。現正負責開發 Storyboards by narrativesaw 此一故事板文件 app 中。深深認同 Swift 對於程式碼易讀性的重視。個人網站:lihenghsu.com。電郵:[email protected]

blog comments powered by Disqus
訂閲電子報

訂閲電子報

AppCoda致力於發佈優質iOS程式教學,你不必每天上站,輸入你的電子郵件地址訂閱網站的最新教學文章。每當有新文章發佈,我們會使用電子郵件通知你。

已收你的指示。請你檢查你的電郵,我們已寄出一封認證信,點擊信中鏈結才算完成訂閱。

Shares
Share This