Swift Protocols and Generics, Part 2: Protocol as Type 和 Type Erasure 有什麼關係?

這是一系列以「建構基礎概念」為目標,希望能幫助 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,對應 CollectionElement 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,還是更強大的 RandomAccessCollectionSet 除了是 Collection,同時也是 SetAlgebra。所以 Collection 好比是它們兩個的「最大公因數」。用 AnyCollection 包起來以後,各自的特殊功能就被隱藏了。

值得一提的是,如果你有試過自己做 Collection,就會知道它十分棘手。它居然有五個 associated types!可是,AnyCollection 只有把五個中的一個變成 generic type parameter,也就是最關鍵的 Element。其它四個都是特定的實體型別。看起來 wrapper 對 protocol 的 associated types 並沒有統一的處理方式。

AnyHashable

AnyHashable 對應 Hashable protocol,也是 Collections supporting types 中的一員。HashableDictionarySet 的使用上都是必要的。

就像 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,就和 TextImage 等許多內建的 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 就會用到它。老實說我自己根本沒用過 AnyCollectionAnyHashableAnyView 則是通常不用比較好。但 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 藏起來,可以簡化使用,另外也還有別的優點。

AnyPublishereraseToAnyPublisher() 的文件裡,說明了這種 type erasure 的好處:維護 API 的抽象區隔,譬如讓不同 module 之間看不到對方的實作細節。也許我們可以說這樣的 wrapper 是一個 proxy:

  • 這讓實作上更有彈性,因為如果 API 對外只有 AnyPublisher 這個介面,那麼更改內部的實作時,也不會影響到外部使用。
  • 這也是一種保護機制,因為如果使用者看不到實際上是哪種 publisher、是怎麼 publish 數值的,就很難介入 publish 的過程,造成意料外的影響。

AnyCancellable

同樣在 Combine 裡的 AnyCancellable 對應了 Cancellable protocol,是接收數值的一方。AnyCancellableCancellable 這對組合,許多方面都可以說是有點特別:

  • Cancellable 是個很單純的 protocol。它沒有 associated type,也沒有用到 Self。
  • Cancellable 有一個 method 叫做 store(in:) 就是把自己轉為 AnyCancellable,並且同時存進一個 SetAnyCancellable 自己額外遵循了 Hashable)。
  • Publisher 的 methods 會直接回傳 AnyCancellable,而不是其他特定的 Cancellable types。我們其實不太有機會接觸到未被消匿成 AnyCancellableCancellable types。
  • AnyCancellable 是一個 class。它在 deinitialize 的時候會自動執行 cancel()

超濃縮的形容一下 Combine 的機制:當 PublisherSubscriber 搭上線時,會產生一個 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,可以把 TriangleSquare 的 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()
        }
    }
}

完成!不止原本的 TriangleSquare,再來任何 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 以後,也幫 TriangleSquare 實作了新的 isSimilar(to:) method,但是還沒有去動到 AnyShapeAnyShape 會需要做什麼調整呢?[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-erased AnyShape 的 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。這可能聽起來很抽象,所以我做了個(很不精確的)示意圖,等一下在討論時,可以對照著看。

Generics vs. AnyShape
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。如果另一個地方 TSquare,那 shape 就是 Square instance,以此類推。

再反觀第二種情況,shapeAnyShape 的 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 的問題就跑出來了。我們看不到 selfother 裡面到底包了哪種 Shape,當然,也不知道是不是同一種,即使原本 protocol 裡面要求的是要同一種(即 Self)。Type erasure 消匿各個 instance 的 type 的同時,原本不同 instances 之間可能有的 type 的關係也消失了。

偷懶的話,乾脆一律 return false 好了。或者,我們在 AnyShape 裡面再加幾個 properties 好做比對。往另一個方向想,也許我們可以試著比對 draw 出來的圖形。回想起 AnyHashable,也是在 == 的行為上面做了些特別的決定5AnyShape 應該怎麼比對相似?這似乎沒有一個標準答案。

但其實「沒有標準答案」這件事,本身就很有意思。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 alternatives solution space
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 有什麼性質上的不同?


  1. SE-0309: Unlock existentials for all protocols 
  2. StackOverflow: How are Int and String accepted as AnyHashable? 
  3. SE-0341: Opaque Parameter Declarations 
  4. 其實應該是「一個遵循 Shape 的 type 的 instance」而不是「一個 Shape instance」,因為 protocol 沒有 instance,但是每次用精準的說法好累 
  5. Apple’s documentation for static func == (lhs: AnyHashable, rhs: AnyHashable) -> Bool 
  6. 並不是說 any 這個字有多麼特別,只是設計者顯然有意為之。許多現成 wrapper 也是後來才更名為 Any 開頭的。 
  7. 至少在 Swift 裡面是這樣,不過單純從 type theory 來說的話,這大概算是 implementation detail。 
  8. 例外有:Error and @objc protocols with no static requirements。 

Leave a Reply

Your email address will not be published.