Swift 與 Objective-C

[Swift 中級 #4] 徹底掌握 Swift 關聯型別(associatedtype)

關聯型別(associatedtype)是協定中預留的型別空白。本文整理著名 generic constraint 錯誤的原因、SE-0309 與 SE-0346 如何解除限制,以及 primary associated types 的使用方式。

閱讀 7 分鐘
[Swift 中級 #4] 徹底掌握 Swift 關聯型別(associatedtype) 封面圖

使用 Swift 協定時,總有一天會遇到一道高牆:嘗試將 Equatable 當作變數型別,或將 Collection 放進屬性時,大量出現的關聯型別錯誤。其中最具代表性的,就是曾經惡名昭彰的「Protocol can only be used as a generic constraint because it has Self or associated type requirements」。這道牆的真面目就是關聯型別(associatedtype)。

這是中級系列的第 4 篇。我們會整理關聯型別是什麼、為什麼會出現那個錯誤,以及一路發展到 primary associated types 的語言演進。讀過泛型篇與 some·any 篇,就等於已經準備好所有材料了。

什麼是關聯型別 — 協定中預留的型別空白

在泛型篇中,我們將 <T>整理為「之後再填入型別的空白」。關聯型別就是把這個空白開在協定裡的版本。

假設要建立一個容器協定。我們想抽象化堆疊和佇列共同具備的「放入與取出元素」能力,但問題在於元素型別。IntStack 的元素是 Int,StringQueue 的元素是 String,因此在協定層級無法確定元素型別,只能宣告成空白。

protocol Container {
    associatedtype Item
    mutating func append(_ item: Item)
    var count: Int { get }
    subscript(i: Int) -> Item { get }
}

採用協定的型別會填入空白。IntStack 將 Item 填成 Int,StringQueue 則填成 String。多數情況下,甚至不必明確寫出 typealias Item = Int,因為編譯器會根據 append 的參數型別進行推斷。

其實這不是新概念。標準函式庫的骨架全都以關聯型別建立:Collection 的 Element 與 Index、IteratorProtocol 的 Element,以及泛型篇 where 子句中遇到的 C.Element,都是指向這個空白的語法。就連 Equatable 也有關聯型別的近親 Self 要求(static func == (lhs: Self, rhs: Self) -> Bool)。總之,關聯型別是協定世界的一等公民,無法繞過。

用一句話總結它與泛型的 <T>之差:泛型參數是由使用端填入的空白,關聯型別則是由採用端填入的空白。Stack<Int>由使用者選擇 Int,但 Container 的 Item 是由 IntStack 這個型別自行決定。

錯誤的原因 — 無法決定容器規格

現在可以拆解那個惡名昭彰的錯誤了。為什麼含有關聯型別的協定,不能放在型別位置使用?

在 some·any 篇中,我們將協定型別變數稱為存在型別,也就是容器。寫成 var c: Container,代表要建立一個「裝著某個遵循 Container 之物的容器」。但從容器取出元素時,問題就出現了:c[0]的型別是什麼?它是 Item,但 Item 的實際型別取決於容器裡裝的是什麼。IntStack 是 Int,StringQueue 是 String。編譯器只看容器本身,無法回答這個問題。

在靜態型別語言中,無法確定型別的運算式不被允許,因此舊版 Swift 直接在入口擋下來。這個錯誤的實際意思是:「這個協定只能當作限制使用。」若使用泛型 <C: Container>,呼叫時 C 會被確定,C.Item 也會一併確定,因此完全沒有問題。錯誤訊息雖然不友善,但「使用泛型,而不是容器」其實是正確處方。

無法檢查未知型別容器的機器人,以及泛型限制印章的插圖
因為無法得知從容器取出的 Item 型別,所以才會封住入口。

語言演進 — 一道道解除門閂的歷史

這項不便長期惡名遠播,而 Swift 透過幾項提案逐步解除門閂。

**Swift 5.7 的 Swift Evolution 提案 SE-0309。**含有關聯型別的協定也能使用 any 了。var c: any Container可以編譯。不過,從容器取出的 Item 會被視為「不知道是什麼型別」,因此能做的事情仍有限。門開了,但容器內能做的事還不多。

**同一時期,SE-0346 引入了 primary associated types。**真正的遊戲規則改變者就是它:可以在協定宣告中以角括號公開主要關聯型別。

protocol Container<Item> {
    associatedtype Item
    // ...
}

var numbers: any Container<Int>   // Item是 Int容器
func process(_ c: some Container<Int>)  // 即使在泛型中也很精簡

any Container<Int>是「Item 已確定為 Int 的容器」。編譯器知道取出的元素是 Int,容器的實用性因此大幅提升。標準函式庫也以這套語法重新整理,從這時起便能寫成 any Collection<String>some Sequence<Int>

理解這項演進的方向很有幫助。「含關聯型別的協定不能當作型別使用」這條絕對規則,變成了「可以使用,但能力會隨著未確定的關聯型別而降低」的精確規則。從禁止到明確標示成本,這是 some·any 篇所見理念的延伸。

實務模式 — 如何與關聯型別共事

讓我們把理論帶進實際工作場景。

**設計時:關聯型別適合用在「每個採用者都會各自決定一個不同型別」的地方。**Repository 協定就是好例子。加入 associatedtype Entity後,UserRepository 會填入 User,OrderRepository 則填入 Order。若型別與採用者無關,而是由使用端選擇,應使用泛型函式或型別,而不是協定的關聯型別。

**使用時:基本上採用泛型限制;需要儲存或混合時,則使用填入 primary associated type 的 any。**第一優先是像 func sync<R: Repository>(_ repo: R) where R.Entity == User這樣作為限制,第二優先才是像 var repos: [any Repository<User>]這樣儲存。

**遇到阻礙時:最後手段是型別抹除的 AnyX 模式。**如果即使使用 primary associated type 仍有複雜限制無法解決,就只能像標準函式庫的 AnySequence 或 Combine 的 AnyPublisher 一樣,用具體型別包裝並隱藏關聯型別,採用手動型別抹除(type erasure)。不過 SE-0346 之後,直接這麼做的情況已大幅減少。重新撰寫 AnyX 包裝器前,現在應先確認能否用 primary associated type 解決。

再談一點 Self 要求。像 ==這類運算只有「相同型別之間」才有意義,因此以 Self 宣告。也因此,兩個 any Equatable容器無法直接比較,因為容器內的型別可能不同。這種情況的標準做法是放棄容器,改用泛型解決。

以 SE-0309 與 SE-0346 為階段、門閂依序解除的關卡時間軸插圖
SE-0309 與 SE-0346 依序解除了門閂

總結

  • 關聯型別是協定中的型別空白,由採用型別填入(多數情況會自動推斷)。標準函式庫的骨架也以此建立,例如 Collection 的 Element。
  • 舊錯誤的真相:因為無法確定從容器(存在型別)取出的值之關聯型別,所以入口才被封住。使用泛型限制,原本就不會有問題。
  • SE-0309 開放了 any 的使用,而 SE-0346 的 primary associated types(any Container<Int>)提升了容器的實用性。
  • 實務順序:第一優先是泛型限制,第二優先是填入 primary associated type 的 any,手動型別抹除則是最後手段。

下一篇是比較能實際動手的主題。我們會以實務角度整理 map、filter、reduce,以及 compactMap 和 flatMap 的差異,並延伸到 lazy 序列的效能。


參考資料

延伸閱讀