這是一系列以「建構基礎概念」為目標,希望能幫助 Swift 開發者更加瞭解 protocols 和 generics 的文章。文章索引、相關資源以及較詳細的介紹,請見系列簡介。希望讀者在看到文章裡提出問題的時候(請注意 [Q] 標示),能夠一起試著解釋看看,好深化你個人的理解。
上次在 Part 1 裡,我們試著分辨 protocol 和其他的 type 有什麼不一樣。當 protocol 作為 type 使用時,它被稱為 existential type。本篇會假設讀者已經熟悉 Part 1 的內容。
這次的篇幅較長,但是與其拆成數篇,我想把閱讀方式交由讀者自己決定。如果這些對你來說是比較不熟悉的概念,我建議分次分段閱讀,這樣吸收的效果會更好。
Essential Question
如果有人問你這個問題,你會怎麼解釋?[Q]
核心問題:
Protocol as type,也就是 existential type,和 type erasure 之間有什麼關係?
延伸問題:
Type erasure 有什麼替代方案?它們和 type erasure 有什麼性質上的不同?
前言:為什麼要談 Type Erasure
說不定你和我一樣,接觸到 type erasure 一詞的契機,是 compiler 那個經典的錯誤訊息。
Protocol 'MyProtocol' can only be used as a generic constraint because it has Self or associated type requirements
如果用 Part 1 的口語說法,就是 compiler 不想幫我們做盒子,所以這個 protocol 只能當藍圖來用。這個 error 應該會在 Swift 5.7 走入歷史1。順便一提,當 protocol 使用了 Self 或 associated type 時,通常俗稱為 PAT,但這次我會刻意不使用這個稱呼。
對我來說,有很長一段時間,type erasure 和 existential type 是兩個分開的概念。當我慢慢瞭解他們的關係時,發現這對我的理解有很大的幫助,而且多了一種方式來看待 protocol as type。
雖然再來會花不少篇幅討論 type erasure,但重點不會放在要怎麼實作它。我們真正目的,仍是要更深一層理解 protocols & protocol existentials。畢竟,光是知道 existentials 是什麼還不夠。你知道它有什麼重要的特性,又該如何正確的使用嗎?
討論大致會分為三個階段。
- 第一階段,分析現成的 type-erased wrappers,來組織 type erasure 的概念。
- 第二階段,自己寫寫看,來更熟悉它的特性。
- 第三階段,比對 existential type 和 type erasure。
英文小教室:型別橡皮擦
在正式開始之前,先說明一件不是很重要,但也許會在你找資料時有幫助的事情,就是英文 type erasure 一詞的幾種變型。
- Erase:這個字大家應該都認識。在這個情境,我會偏好說是「消匿」,因為 type erasure 雖然有消除型別、讓它消失的意思,但更常像是把型別藏起來。
- Type erasure:名詞,把 type 消匿的這個概念本身。
- Type-erased:形容詞(過去分詞),型別「被」消匿的。因為是形容接在後面的名詞,所以兩字間加了連字號。用來做 type erasure 的容器、包裝通常稱為 type-erased wrapper。
- Type-erasing:形容詞(動名詞),「去」消匿型別的。剛才提到的的 type-erased wrapper 也可能被稱為 type-erasing wrapper。至於這東西到底應該想成是被動的被消匿型別,還是主動去消匿型別?也許就隨你喜歡了。我私下都叫它 type eraser,但這個說法似乎不太流行呢,所以之後會簡稱為 wrapper。
一、從現成 Wrappers 認識 Type Erasure
說到底,為什麼要有 type-erased wrapper 這種東西?它們能做到什麼事情,又是在什麼樣的情境下適用?[Q]
Swift standard library 以及 Apple 提供的 libraries & frameworks 裡,有一些取名為 Any*
,我稱作是「現成」的 type-erased wrappers。它們的文件平易近人,而且透露了很多的「why」。下面我們來取幾個摘要敘述,來試著拼湊出 type erasure 的樣貌。
請注意:雖然我簡化了敘述,但仍然不是每一句話都很重要。建議你可以用上面的那個問題做為引導來抓重點。
AnyCollection 和它的朋友們
Collection 是 Swift standard library 裡極為重要,也相當複雜的 protocol。它有一系列的 Supporting Types,其中就有多達七個的 Any*
type-erased wrappers。我們來看看其中的 AnyCollection。
AnyCollection
是個 generic struct,可以把遵循 Collection
的 instance 收藏起來。AnyCollection
有一個 generic type parameter 叫做 Element
,對應 Collection
的 Element
associated type。文件說,它是一個 type-erased wrapper,當你叫它做什麼事的時候,它就把工作交給底下、細節被藏起來的 collection。
let myArray: Array = [1, 2, 3]
let mySet: Set = Set([4, 5, 6])
var myCollection: AnyCollection<Int> // Element 是 Int
// 同一個 var,可以放 Array 的值...
myCollection = AnyCollection(myArray)
// ...也可以改放 Set 的值
myCollection = AnyCollection(mySet)
// 不管裡面是 Array 還是 Set,都可以用 for loop
for item in myCollection {
print(item)
}
// 它也可以用來把不同種類的 collections 收進同一個 array
let manyCollections = [AnyCollection(myArray), AnyCollection(mySet)]
上面這個例子裡,Array
不只是個 Collection
,還是更強大的 RandomAccessCollection
。Set
除了是 Collection
,同時也是 SetAlgebra
。所以 Collection
好比是它們兩個的「最大公因數」。用 AnyCollection
包起來以後,各自的特殊功能就被隱藏了。
值得一提的是,如果你有試過自己做 Collection
,就會知道它十分棘手。它居然有五個 associated types!可是,AnyCollection
只有把五個中的一個變成 generic type parameter,也就是最關鍵的 Element
。其它四個都是特定的實體型別。看起來 wrapper 對 protocol 的 associated types 並沒有統一的處理方式。
AnyHashable
AnyHashable 對應 Hashable protocol,也是 Collections supporting types 中的一員。Hashable
在 Dictionary
和 Set
的使用上都是必要的。
就像 AnyCollection
可以收納 Collection
的東西,AnyHashable
可以收納 Hashable
。不過,從文件可以看到,它有些奇特的性質。
Hashable
繼承了Equatable
,所以AnyHashable
也要能夠比對是否相等。原先是不同型別的值,在藏進AnyHashable
以後的比對結果不見得會如我們預期。
let myInt: Int = 2
let myDouble: Double = 2.0
AnyHashable(myInt) == AnyHashable(myDouble) // true!
- Compiler 給了它特別的待遇,會幫我們做自動 wrapping2:
let wrapped: AnyHashable = myInt // 沒問題,自動幫你 init
// 等同於 let wrapped: AnyHashable = AnyHashable(myInt)
- 它包裝得很「鬆」,你可以直接操作裡面的東西。
wrapped.base // "Any" type
wrapped.base is Int // true
wrapped.base is Double // false
我覺得 AnyHashable
很有意思的地方,在於它的核心功能不完全合乎直覺。設計者決定了它的 hashing 和 ==
要如何實現,還在文件裡特別說明它的行為。這突顯出一個 type-erased wrapper,有時不只是為它的 protocol 做機械式的包裝。
AnyView
寫 SwiftUI 多少會遇到 AnyView 這個 View
protocol 的 wrapper。它只是個普通的 struct,非 generic type。它的 Body
associated type 是 Never
,就和 Text
,Image
等許多內建的 View
一樣。
一個可能會用到 AnyView
的情境,是視情況產生不同的 view。
var shapeView: AnyView {
if isSerious {
return AnyView(squareText) // a Text
} else {
return AnyView(circleImage) // an Image
}
}
問題是,使用 AnyView
有效能上的疑慮。文件裡警告說,當 AnyView
裡面包裝的東西只要改變型別,view hierarchy 就會摧毀重建。所以 AnyView
看似好用,但其實盡量避免比較好。也可以說,AnyView
給了我們一個 type-erased wrapper 雖然方便,但不理想的例子。
網路上有不少文章教你如何避免 AnyView
。常見的替代方案有 Group
,view builder 和 generics。這幾個方案表面上長得不一樣,但骨子裡卻很接近,因為 Group
是在 initializer 裡使用 view builder,而 view builder 則是自動生成 generic view 來同時接納不同種類的 views。
AnyPublisher
AnyPublisher 對應了 Publisher protocol,是在 Combine 裡發佈數值出來的一方。
和前幾個例子比起來,AnyPublisher
相當實用,幾乎可以說有用 Combine 就會用到它。老實說我自己根本沒用過 AnyCollection
或 AnyHashable
,AnyView
則是通常不用比較好。但 AnyPublisher
不同。在 Publisher
protocol 裡面,就提供了一個 eraseToAnyPublisher() method,任何 Publisher
都可以輕易被消匿成 AnyPublisher
。
let publisher = Just("Aimer")
// Just<String>
.compactMap(makeSearchURL)
// Optional<URL>.Publisher
.flatMap(URLSession.shared.dataTaskPublisher)
// Publishers.FlatMap<URLSession.DataTaskPublisher, Publishers.SetFailureType<Optional<URL>.Publisher, URLSession.DataTaskPublisher.Failure>>
// ...還可以繼續做轉換,generic type 會長到突破天際 🤯
.eraseToAnyPublisher()
// 最後通通用 AnyPublisher 藏起來
上面的例子裡,看得出來有時一個東西的 type 會吐露出很多實作細節的資訊,或是非常複雜冗長。用 type-erased wrapper 藏起來,可以簡化使用,另外也還有別的優點。
在 AnyPublisher
和 eraseToAnyPublisher()
的文件裡,說明了這種 type erasure 的好處:維護 API 的抽象區隔,譬如讓不同 module 之間看不到對方的實作細節。也許我們可以說這樣的 wrapper 是一個 proxy:
- 這讓實作上更有彈性,因為如果 API 對外只有
AnyPublisher
這個介面,那麼更改內部的實作時,也不會影響到外部使用。 - 這也是一種保護機制,因為如果使用者看不到實際上是哪種 publisher、是怎麼 publish 數值的,就很難介入 publish 的過程,造成意料外的影響。
AnyCancellable
同樣在 Combine 裡的 AnyCancellable 對應了 Cancellable protocol,是接收數值的一方。AnyCancellable
和 Cancellable
這對組合,許多方面都可以說是有點特別:
Cancellable
是個很單純的 protocol。它沒有 associated type,也沒有用到 Self。Cancellable
有一個 method 叫做store(in:)
就是把自己轉為AnyCancellable
,並且同時存進一個Set
(AnyCancellable
自己額外遵循了Hashable
)。Publisher
的 methods 會直接回傳AnyCancellable
,而不是其他特定的Cancellable
types。我們其實不太有機會接觸到未被消匿成AnyCancellable
的Cancellable
types。AnyCancellable
是一個 class。它在 deinitialize 的時候會自動執行cancel()
。
超濃縮的形容一下 Combine 的機制:當 Publisher
和 Subscriber
搭上線時,會產生一個 Subscription
,可以讓 Subscriber
用來 request 下一個數值。Subscription
使用者必需把它存留著,數值才會持續流動。Subscription
繼承了 Cancellable
,可以用來手動 cancel 結束數據流,但使用者不應該用它來 request 數值,那是 Subscriber
的工作。所以把 Subscription
藏進 AnyCancellable
後再讓使用者持有,就能隱藏 request 但允許 cancel,兩全其美。
也就是說,AnyCancellable
這個 type-erased wrapper 的功能是當 Subscription
的包裝盒,隱藏實際實作、只曝露一部份的介面,幫忙管理 subscription lifecycle 並且便於儲存。
看完這幾個 Any*
type-erased wrappers 的代表,請你先試著整合一下。它們有什麼共通的用途、功能?有什麼特性?有什麼優缺點,適合用在什麼情境?下一段提供的是我自己的整理。[Q]
第一階段統整:關於現成 Wrappers
這些 wrapper types 的基本功能非常相似。如果我們有個 protocol P
,那對應它的 AnyP
wrapper 可以把遵從 P
的型別的值給收藏在內。透過 AnyP
instance 我們還是可以使用P
定義的功能,但可能看不到原來那個值,及它原有的型別。
因為 wrapper 本身是 struct 或 class 這種實體型別,它能幫我們繞過 compiler 的 static typing 限制。譬如,我們可以把原來不同型別的值放進一個 Array,或是從同一個 function 回傳。有一點 workaround 的味道。
另一方面,消匿 type 資訊這件事,也有更為正面的意義。這些 wrappers 可以隱藏實作細節,達到 decoupling 的效果,並限制 API 抽象層的外面能做的事情。從這個角度來看,type erasure 像是一種 design pattern,配合 protocol 來幫助我們設計更好維護的元件/模組。
消匿一個值它原有的型別資訊,有時會伴隨一些挑戰。譬如比較複雜的 protocol 可能有 associated types,那 wrapper 可能針對這點有不同的實作方式。或是像 AnyHashable
在比對 ==
時會有特別的邏輯。另外像AnyView
則是妨礙了 SwiftUI 的效能優化。
不知道你的結論,和我上面所說的有沒有什麼差別?
做為認識 type erasure 的第一步,我們整理了一些現成 type-erased wrappers 的用途和特性。但是,很多眉角是光看成品難以發現的。所以接下來,我們也來試著實作一個 wrapper,看看還能得到什麼樣的啟發。
二、動手做做看:AnyShape
第一階段我們看了現成的 type-erased wrappers。在第二階段,我們也來自己寫個最簡單的 wrapper。
這次我還是請來了老朋友,Shape
protocol:
protocol Shape {
func draw() -> String
}
struct Triangle: Shape {
let size: Int
func draw() -> String { /* ... */ }
}
struct Square: Shape {
let length: Int
func draw() -> String { /* ... */ }
}
我們來寫一個 wrapper,叫做 AnyShape
,可以把 Triangle
和 Square
的 instance 都藏起來,只曝露出 Shape
的功能。
什麼,你說那很蠢,不需要自己寫?哎呀,配合一下嘛。
struct AnyShape {
func draw() -> String {
// 任何 Shape 都要會 draw
}
/* ... */
}
實作 type erasure 有幾種方法,其中最直覺的大概就是用 closure,以我們的需要來說也很足夠了。我們可以在 AnyShape
裡藏一個實際會做 draw()
這件事的 closure。
struct AnyShape {
// 這個 closure 是等一下收藏 instance 的關鍵
private let drawAction: () -> String
func draw() -> String {
// draw function 本身沒做什麼,只是介接 closure 而已
drawAction()
}
/* ... */
}
到這裡,基礎功能已經完備了,只剩下它要如何生成的問題。那再來,我們只需要寫個 init
,讓我們可以拿一個 Triangle
的 instance,或一個 Square
的 instance 來產生 AnyShape
。
struct AnyShape {
private let drawAction: () -> String
func draw() -> String {
drawAction()
}
init(_ triangle: Triangle) {
drawAction = { // 在這裡把 triangle instance 藏起來
triangle.draw()
}
}
init(_ square: Square) {
drawAction = { // 在這裡把 square instance 藏起來
square.draw()
}
}
}
什麼,你說寫兩個 init
很蠢?哎呀,等一下再改進。畢竟目標已經達成了,像這樣。
// 拿兩個不同型別的 instances
let triangle = Triangle(size: 3)
let square = Square(length: 4)
let shapes: [AnyShape] = [ // 這是單一型別 (AnyShape) 的 Array
AnyShape(triangle), // 消匿 triangle 是 Triangle 的資訊
AnyShape(square) // 消匿 square 是 Square 的資訊
]
for shape in shapes {
// 不知道各個 AnyShape 藏了哪種 Shape,但是都可以 draw
shape.draw()
}
所以寫個基本的 type-erased wrapper 並不難。在繼續之前,不知道你對上面的兩個疑點,心裡是不是已經有了答案。[Q]
- 為什麼寫這個
AnyShape
是多此一舉? - 重覆寫很像的
init
要怎麼改善?
另外還有個伏筆:我沒說 AnyShape
遵遁 Shape
。
struct AnyShape { // 我「忘了寫」struct AnyShape: Shape
/* ... */
}
這有影響我們使用 AnyShape
嗎?
改進 Initializer
我們生成 AnyShape
的方法,是每種 Shape
都寫一個專屬的 init
。這當然是有點糟糕。如果看現成的 wrappers 是怎麼做的,就會發現它們的 init
是 generic function。我們也來試試。
struct AnyShape {
private let drawAction: () -> String
func draw() -> String {
drawAction()
}
/* 參考用,有了 generic 版就可以刪了
init(_ square: Square) {
drawAction = {
square.draw()
}
}
*/
// 把上面的 Square init 給通用化
// 用一個 type parameter T 來取代 Square,限制 T 要遵循 Shape
// 再把 square 改名為 shape 就完成了
init<T: Shape>(_ shape: T) {
drawAction = {
shape.draw()
}
}
}
完成!不止原本的 Triangle
和 Square
,再來任何 Shape
都可以用了。Swift 5.7 以後,我們還可以用 some
關鍵字再簡化一點寫法3,但這下次再說吧。
使用 generics 來實作 type erasure 這件事,總是讓我覺得有點奇妙。如果要說為什麼,也許是因為它們有點像,又不太一樣。譬如,在 init
裡使用的 T
instance,和另一處的 AnyShape
instance 有什麼相似與不同呢?[Q]
struct AnyShape {
/* ... */
init<T: Shape>(_ shape: T) {
drawAction = {
// 這個 shape 是 generic type T 的 instance
shape.draw()
}
}
}
let shape = AnyShape(triangle)
// 這個 shape 是使用 type-erased wrapper 的 instance
shape.draw()
更複雜的 Shape
Shape
protocol 太單純了,我想幫它再加一個 method,看看會造成什麼影響。
我想規定同種 Shape 要能互相比較是否為相似圖形。這個想法和 Equatable
差不多,只是說兩個形狀如果在縮放、旋轉、反轉以後可以變成一樣的,那它們就相似。
下面我改了 Shape
以後,也幫 Triangle
和 Square
實作了新的 isSimilar(to:)
method,但是還沒有去動到 AnyShape
。AnyShape
會需要做什麼調整呢?[Q]
protocol Shape {
func draw() -> String
// 新的相似比對
// 可以和 Self type,也就是和自己相同 type 的東西做比較
func isSimilar(to other: Self) -> Bool
}
struct Triangle: Shape {
/* ... */
func isSimilar(to other: Triangle) -> Bool {
// ...實作相似三角形的邏輯
}
}
struct Square: Shape {
/* ... */
func isSimilar(to other: Square) -> Bool {
true // 每個正方形都天生相似
}
}
// 完整未改。該怎麼應對 Shape 的新 method 呢?
struct AnyShape {
private let drawAction: () -> String
func draw() -> String {
drawAction()
}
init<T: Shape>(_ shape: T) {
drawAction = {
shape.draw()
}
}
}
isSimilar(to:)
讓 Shape
成了一個有使用到 Self 的 protocol。和 associated type 類似,這應該會讓這個 protocol 變得難搞一點。
可是,奇怪?雖然我沒有修改 AnyShape
,但 compiler 也沒有抱怨耶。而且使用上似乎也沒有問題。
// 雖然 AnyShape 沒變,它還是可以包 Triangle & Square instances
let shapes: [AnyShape] = [
AnyShape(triangle),
AnyShape(square)
]
for shape in shapes {
shape.draw() // 也可以 draw
}
不過 AnyShape
並沒有比對相似形狀的功能。但是沒關係,因為… 我沒有宣告 AnyShape
遵循 Shape
!
什麼,你說這根本詐欺?好吧,我自首,但希望能從輕發落。AnyShape
這個名字強烈暗示它是 Shape
,實際上卻不是,有點騙人。但轉念想想,實際上,它現在還是能藏起一個 Shape
instance4,而且也提供了 Shape
部份的功能。
如果我沒有需要比對兩個 AnyShape
是否相似,也沒有要把 AnyShape
instance 用在要求 Shape
的地方(譬如,拿一個 AnyShape
再去 init 一個 AnyShape
),那它還是可以當個稱職的 wrapper。
到這裡有兩個未結疑案。一個是上面這個 AnyShape
不是 Shape
的問題,就這樣放著合理嗎?另一個是如果不想這樣放著,我們可以怎麼實作 isSimilar(to:)
?[Q]
第二階段統整:關於手作 Wrapper
簡單回顧一下。我們為 Shape
protocol 手作了一個 type-erased wrapper,叫做 AnyShape
。一開始 Shape
是個很簡單,只有 draw()
一個 method 的 protocol。
- 首先用了 closure 來實作
draw()
的功能。 - 再來,用 generics 來改進 init,讓它接受任意
Shape
的 instance。 - 最後把
Shape
弄複雜,讓它多了一個用到 Self 的 method,但其實沒有再修改AnyShape
,也還是可以用。
途中我們有遇到幾個疑點。
- 在
Shape
變複雜之前,為什麼寫這個AnyShape
是多此一舉?這個問題先不在這裡回答,但你可能心裡早就有底了。 - Generic type
<T: Shape>
的 instance 和 type-erasedAnyShape
的 instance 有何異同? - 一個 type-erased wrapper 應該要遵循它所對應的 protocol 嗎?
AnyShape
要怎麼遵循有 Self requirement 的Shape
?
做為第二階段的收尾,我們來談談後兩個疑點。
Generics vs. Type Erasure
做 generic init
的時候,我提到使用 generics 和 type erasure 用起來有點像,又不太一樣。
記得那個時候我們在兩個不同的地方用了叫做 shape
的 instance,但是一個是 T
type,一個是 AnyShape
type。它們有什麼差別?
struct AnyShape {
/* ... */
init<T: Shape>(_ shape: T) {
// 這裡的 shape 是 generic type T instance
/* ... */
}
}
let shape = AnyShape(triangle)
// 這裡的 shape 是 type-erased wrapper instance
這兩個 shape
相像的地方,在於它們都可能代表了不同類型的 Shape
。也就是說,兩個 shape
背後的型別都像是個變數,可以是 Triangle
,可以是 Square
,也可以是別種 Shape
。兩個都可以執行 Shape
的功能,像是 draw()
。
不過,它們切換 & 導向不同 Shape
類型的「時機」有明確的差異。一個是在早 compile 的時候就做好,另一個則是延緩到 runtime。這可能聽起來很抽象,所以我做了個(很不精確的)示意圖,等一下在討論時,可以對照著看。
Generic 和 type erasure 從 compile time 到 runtime 的粗略流程
先來看第一種情況,shape
是 generic type T
的 instance。T
是一個 type 的變數,在每一個呼叫 init
的地方,T
就會「代入」某種確定的 type。我們可以說這是「type 層面的抽象化」。
譬如如果呼叫 function 時,代入的是一個 Triangle
instance,那在這裡 T
就是 Triangle
,而 shape
就是個如假包換的 Triangle
instance。如果另一個地方 T
是 Square
,那 shape
就是 Square
instance,以此類推。
再反觀第二種情況,shape
是 AnyShape
的 instance。這次 shape
是一個 AnyShape
instance,它會在 runtime 的時候,再來導向它底下的某種實體 Shape
執行功能。我們可以說這是「value 層面的抽象化」。
我覺得這樣比對兩個機制蠻有趣的。不過對很多人來說,還是會比較關心實用性吧。這裡我們可以自問,從 compile time vs. runtime 這個角度來看,type erasure 相較於 generics 可以如何權衡優劣?[Q]
回到上面的示意圖,可以推論的是 generics 讓 compiler 要做比較多的工作,也可能產出比較大的 binary。這些缺點換來的好處,是 compiler 有比較多的資訊來檢查程式的正確性,和優化 runtime 效能。
Type erasure 這邊則是因為把不同 types 推遲到 runtime 做處理,所以某方面來說它可以更有彈性,但代價是損失一些讓 compiler 最佳化的空間,還有在 runtime 時耗費比較多資源。
用點術語來說的話,type erasure 是一種 runtime polymorphism,相對於 generics 的 compile-time polymorphism。
Wrapper 與它的 Protocol
在寫 AnyShape
時,我很賴皮的沒有宣告它有遵循 Shape
。原本是舉手之勞,但在 Shape
裡面加了相似比較以後,再要遵循就有點困難了。不過,就算 AnyShape
只實作了半個 Shape
該有的功能,它還是可以用。
實作 draw()
的方法蠻無腦的,用幾個固定的步驟就可以做出來了。可是 isSimilar(to:)
呢?
protocol Shape {
func draw() -> String
func isSimilar(to other: Self) -> Bool
}
struct AnyShape { // 打死不說 struct AnyShape: Shape
/* ... */
func draw() -> String { /* ... */ }
// 真的要 conform to Shape 的話就得把這個做出來
func isSimilar(to other: AnyShape) -> Bool {
// 怎麼比對 self 和 other,兩個 AnyShape?
}
}
要比對兩個 AnyShape 時,消匿 type 的問題就跑出來了。我們看不到 self
和 other
裡面到底包了哪種 Shape
,當然,也不知道是不是同一種,即使原本 protocol 裡面要求的是要同一種(即 Self
)。Type erasure 消匿各個 instance 的 type 的同時,原本不同 instances 之間可能有的 type 的關係也消失了。
偷懶的話,乾脆一律 return false 好了。或者,我們在 AnyShape
裡面再加幾個 properties 好做比對。往另一個方向想,也許我們可以試著比對 draw
出來的圖形。回想起 AnyHashable
,也是在 ==
的行為上面做了些特別的決定5。AnyShape
應該怎麼比對相似?這似乎沒有一個標準答案。
但其實「沒有標準答案」這件事,本身就很有意思。isSimilar(to:)
因為用到了 Self,在 wrapper 裡不能像 draw()
那樣套用簡單的步驟來實作,而是我們必需多費一些工、做一些取捨。
這些取捨會直接影響到 wrapper 到底能不能、要不要遵循它的 protocol。譬如,如果我們決定不處理 isSimilar(to:)
,像我原本做的那樣,那 AnyShape
就因此不能遵循 Shape
。
類似的困擾,也適用於有 associated type 的 protocol。記得我們一開始看現成的 wrappers 時,就看到它們對 associated type 各有各的處理方式。
我想,到這裡我們已經對 type erasure 有了不少認識。該是時候回頭談談 protocol as type 了。
三、Protocol as Type 與 Type Erasure
先來簡單回顧一下。一開始,我們以現成的 type-erased wrappers 為例,來認識 type erasure。然後,我們動手做了一個 AnyShape
wrapper,也討論到一些 type erasure 實作上的挑戰。現在,我們終於要回到一開始的問題。Type erasure 和 protocol as type / existential type 之間有什麼樣的關係呢?那就是——
Existential type 也是一種 type erasure。
這樣的話,那也許我們從第一階段的現成 wrappers 學到的事情,以及第二階段自己做一個 wrapper 發現的事情,都可以反過來對應在 existential type 上面。這也就是第三階段的主要目標,不過也請讀者不妨先自己整理看看,這樣的對比是否成立呢?[Q]
AnyShape 與 any Shape
在 Part 1 裡面,我們說拿 protocol 當成 type 使用時,compiler 會自動幫我們生成一種像是盒子一樣的 type,又稱 existential type,而且還會自動幫我們把東西裝進去。雖然我們不會直接看到它,但 existential type 是個實體的 type,我們可以對它呼叫 protocol 裡的方法。
換句話說,any Shape
豈不就是自動生成的 AnyShape
?它們在字面上這麼相似,顯然不會是巧合6。
protocol Shape {
func draw() -> String
}
struct AnyShape { /* ... */ }
let triangle = Triangle(size: 3)
// 自動的 existential container & 自動「打包」
var shape1: any Shape = triangle
// 手寫的 type-erased wrapper & 手動「打包」
var shape2: AnyShape = AnyShape(triangle)
可以說,existential type 是超方便的自動生成 type-erased wrapper。或者,Any*
type-erased wrapper 是充滿人情味的手作 existential container。
回想起剛接觸 type erasure 的時候,我的認識是這樣的:
「如果一個 protocol 因為有 Self or associated type requirement 不能當成 type 使用,我可以用 type erasure 來解決這個問題。」
但現在看來,這個說法其實更貼切:
「如果一個 protocol 有 Self or associated type requirement,那 compiler 就不願意自動幫我做 type-erased wrapper,但我可以自己手動做一個。」
到頭來,Type Erasure 是什麼?
說起來,我們談了這麼久的 type erasure,卻從未明確定義它是什麼。如果有人問你「那 type erasure 到底是什麼」,你會怎麼回答他?[Q]
打岔閒聊一下。我在為這篇文章做研究時,發現 type erasure 這個概念/模式/技巧,原來在不同的情境下,可能指的是非常不同的東西。C++ 圈子裡說的 type erasure 會被形容為一個實現像在 Python 下的 duck typing 的技巧。Java 和 TypeScript 裡則是某種 compiler 或 transpiler 的技術。一般在 Swift 世界裡所稱的 type erasure,應該是貼近 C++ 的世界。
回到正題。與其正面回答什麼是 type erasure,我想藉由比較的方式,來同時描述它是什麼 & 不是什麼。比較的對象是其他 polymorphism 的形式:protocol,generics,和 class inheritance。
Protocol: 與純做為藍圖的 protocol 不同,type-erased wrapper 是可以生出 instance 來的實體 type。一個 wrapper 通常會對應一個 protocol,但嚴格說起來,wrapper 對外的介面不一定要用 protocol 來定義。
Generics: Generics 處理不同 types 是透過 type 變數,可以想成是在 compile time 發生。Type erasure 則是在 runtime 時,把不同 type 的 instance 用單一個 wrapper type 的 instance 給包藏起來,只透過 wrapper 曝露共通的介面。
Class inheritance: Inheritance 和 type erasure 類似,可以在 runtime 把不同 types(也就是不同的 subclass)動態的藏在一個共通介面的實體 type(也就是共同的 superclass)後面。不過,使用 class inheritance 需要先決定 superclass 怎麼做,然後再讓 subclass 去繼承它。Type erasure 則是允許顛倒順序,先有一些相像的 types(甚至可以不是自己寫的),之後再創造一個 wrapper 來代表它們。另外,type erasure 也可以應用於 struct 和 enum 等等,不像 class inheritance 顧名思義是只能使用 class。
Type erasure 和這些不同的方法,有時各司其職,有時可以互相取代,有時相輔相成。像是 generics 和 class inheritance 其實都可能在實作 type erasure 時用到。
回頭看 Existential Type 的基本特性
如果 protocol as type / existential type 跟 Any*
type-erased wrapper 這麼像,那麼我們對後者的認識,應該有很多能應用到 existentials 上面。
統整起來,我們可以說 existential type 它:
- 是用一個實體 type 做 proxy7,讓我們可以把多種不同 types 放進同一個 variable 或 collection,或從同一個 function 回傳
- 會隱蔽 type 資訊,包括一個 instance 原本是什麼 type,它原本的 associated types 是什麼,或如兩個 instances 原本是不是同一種 type
- 是一種 runtime polymorphism,在 value 層面做 types 的抽象化
- 會需要付出一點效能代價,包括 wrapper instance 本身,它的轉接機制,和減少最佳化的機會
- 適當使用,可以降低介面耦合度,幫我們隱藏實作細節
當你拿 protocol 來當成 type 使用時,你是否有主動利用、或至少意識到它的這些特性呢?[Q]
如果讀者你在這篇文章裡,只有自己回答過一次問題,或是讀完只帶走一個觀念,我希望會是上面這個。
我想至少在降低耦合這方面,很多人原本就很熟悉了。因為許多使用 protocol 的經典情境,像是 delegate pattern 或 dependency injection,要的就是這個特性。至於隱蔽 type 資訊和 runtime 代價等方面,就比較有可能其實不是我們所要的,只是因為自動的 existential type 太過方便,就沒想這麼多的接受了。
Existential Type 的限制
另一方面,實作一個 type-erased wrapper 時會遇到的困難,也幫我們解釋了 existentials 的一些限制。
當然,我指的就是 protocol 用到 Self 或 associated type 的情況。這時,實作一個 wrapper 就必需多費一點心思。Associated type 如何決定?用到 Self 的 methods 該有什麼樣的行為?這些都很難用簡單的規則來決定。
把這些困難從 type-erased wrapper 對應到 existential type 上面,可以幫助我們思考兩個問題:
- 如果一個 protocol 用到 Self 或 associated type,那 compiler 要怎麼自動生成 existential type?
要不,就是忽視難做的部份。要不,就是乾脆都不要做。一直到 Swift 5.6,compiler 的做法是後者:不幫你做,告訴我們這個 protocol 不能當作 type 使用。但是,在 Swift 5.7 以後,compiler 會進步成會幫我們做 existential type,只是可能部分無法使用1。就好像我們在實作 AnyShape
時忽略了 isSimilar(to:)
。
- Existential type 要遵循它對應的 protocol 嗎?
如果像上面說的,compiler 做 existential type 時可能有一部分無法使用,那就無法符合遵循 protocol 的條件了。事實上,目前就算是很單純的 protocol,existential type 也沒有遵循它的 protocol(除少數例外8)。這就解釋了為什麼曾經有過「Protocol ‘P’ as a type cannot conform to the protocol itself」這種令人困惑的 compiler error。
Existential Type 的替代方案
那如果我們遇到 existential type 的限制,或者想要避免它的某些特性,有什麼樣的替代的方法呢?我想可以粗略分成幾類。
Existentials 相關的解答空間
首先自然是本篇文章的主題,自己寫一個 type-erased wrapper。這是最接近 existential type 的選項,避開限制、繼續使用 protocol + runtime polymorphism,但性質基本上是相同的。
再來是 generics。因為是 compile-time polymorphism,使用模式和特性都有所不同。但 existential type 和 generics 有點像是 protocol 的雙重人格,其實有不少互相對應之處。
許多情境下,不使用 protocol 也是合理的選項。例如 inheritance 仍然有它的長處,enum 可以使用 associated value 來容納多種不同 types(但僅限於有定義到的 case),使用 closure 有時會比 protocol 更有彈性… 等等。
Swift 是個不斷演進中的語言。在 Swift 5 到 6 的這段期間,有數個減低 existential type 的限制、讓它更強大的改進。但對於在思考 Swift 的未來的語言設計/開發者來說,仍舊有個揮之不去的隱憂,就是 existential type 用起來太方便,導致人們不瞭解它的特性、拿它做不適合的用途,忽視了其他設計可能。希望這次從 type erasure 的角度來探討 existential type,可以幫助我們避免掉進這個陷阱。
回顧
在 Swift 裡面,existential type 可以視為自動生成的 type-erased wrapper。它很適合用做 decoupling,不過作為 runtime polymorphism 它也有些執行時的代價。當對應的 protocol 比較複雜時,existential type 的使用也會受到一些限制。瞭解它的特性、限制和替代方案,我們就可以在設計程式時自問:這裡我需要/想要這樣的 runtime 動態彈性嗎?
在 Part 1 裡,我寫到:「Existential type 雖然方便,但它其實是個還蠻複雜的解法,也不是那麼完美的」。這次算是繞了一大圈,來做一個較為完整的解釋。
如果我們想深入理解一個觀念,有幾個常理且有效的學習原則。在還不熟悉時,可以先研讀範例(worked examples),其後是動手實作(learning by doing)。在過程中試著自行解釋、回答問題,可以進一步加強觀念連結(self-explanation)。Existential type 是被隱藏在 compiler 後面的技術,不過可以透過看得到的 type-erased wrappers 來研究範例,和試著實作。
最後邀請你一起來回顧。[Q]
關於這次的內容,在技術 & 非技術層面,有哪裡是你覺得難以理解、與原本認知有異,或是不同意說法的嗎?
回頭來看一開始的「核心問題」。現在再次請你和別人解釋,答案會和之前有什麼不同呢?
核心問題:
Protocol as type,也就是 existential type,和 type erasure 之間有什麼關係?
延伸問題:(跟開頭的版本稍有不同)
Type erasure / existential type 有什麼替代方案?它們和 type erasure / existential type 有什麼性質上的不同?
- SE-0309: Unlock existentials for all protocols ↩ ↩
- StackOverflow: How are Int and String accepted as AnyHashable? ↩
- SE-0341: Opaque Parameter Declarations ↩
-
其實應該是「一個遵循
Shape
的 type 的 instance」而不是「一個Shape
instance」,因為 protocol 沒有 instance,但是每次用精準的說法好累 ↩ -
Apple’s documentation for
static func == (lhs: AnyHashable, rhs: AnyHashable) -> Bool
↩ - 並不是說 any 這個字有多麼特別,只是設計者顯然有意為之。許多現成 wrapper 也是後來才更名為 Any 開頭的。 ↩
- 至少在 Swift 裡面是這樣,不過單純從 type theory 來說的話,這大概算是 implementation detail。 ↩
- 例外有:Error and @objc protocols with no static requirements。 ↩