Swift 與 Objective-C

[Swift 中階 #3] some 與 any,以及 existential 的成本

查看 Swift 5.6 之後的程式碼,會看到協定名稱前面加上 some 或 any:some View、any Error、some Collection。以前可以直接把協定名稱放在型別位置,但現在編譯器會警告或報錯,要求加上 any。…

閱讀 6 分鐘
[Swift 中階 #3] some 與 any,以及 existential 的成本 封面圖

先說結論:原本行為不同的兩種情況被混成同一種寫法,而 Swift 現在把這個區別帶到表面上。some Viewany Errorsome Collection。以前可以直接把協定名稱放在型別位置,但現在編譯器會警告或報錯,要求加上 any。到底改變了什麼?

簡單說,就是泛型篇預告過的靜態多型與動態多型之分。這正是中階系列第 3 篇要整理的 some 與 any。

問題的起源 — 協定放在型別位置時有兩副面孔

協定原本是描述限制的語言,用來表達資格,例如採用 Comparable 的型別。但當協定名稱出現在變數或回傳型別的位置時,它的性質就改變了。

let shapes: [Shape] = [Circle(), Square(), Triangle()]

這個陣列混合了不同型別。為了讓它可行,編譯器會把每個值放進盒子裡:一個標示「遵循 Shape 的某種東西」的盒子,這就是 existential type。盒內的具體型別只有在執行階段才能知道,方法呼叫也會變成打開盒子、尋找實際型別實作的間接呼叫。

問題在於這個盒子並不是免費的。不同大小的值必須放進統一的盒子,因此需要獨立的 existential container 規格;大型值會放在堆積上,盒子只保存指標。呼叫會經過稱為 witness table 的函式列表,形成動態分派,讓內嵌與特化等許多編譯器最佳化無法進行。功能上也有限制。由於盒內的實際型別已被抹除,很難回答兩個 Shape 是否為同一型別。

但在舊語法中,這個成本是看不見的。寫成 func draw(shape: Shape) 會建立盒子,寫成 func draw<S: Shape>(shape: S) 則會以泛型運作,不需要盒子。外觀看似相近的兩段程式碼,效能特性卻完全不同,而語法把差異隱藏了。Swift Evolution 提案 SE-0335 導入 any 關鍵字,正是因為這個原因:在會建立盒子的地方,明確標示那是盒子。這是第 1 篇提到的「不隱藏成本」理念延伸到語法修訂的例子。

some — 不用盒子也能隱藏

如果說 any 是能裝進任何東西的盒子,some 就是相反方向的工具。some Shape 表示具體型別已固定為一種,但不公開它的名稱,因此稱為不透明型別(opaque type)。

func makeShape() -> some Shape {
    Circle(radius: 10)  // 一律 Circle 只回傳一種型別
}

呼叫端不知道 Circle 這個名稱,但編譯器知道。因此沒有盒子,也沒有間接呼叫。靜態分派與最佳化都能保留,實際上會像泛型一樣受到處理。代價是彈性:回傳 some Shape 的函式,在每條 return 路徑都必須回傳相同的具體型別。依條件回傳 Circle 或 Square 會造成編譯錯誤。

SwiftUI 的 var body: some View 是這個語法的代表性用途。body 實際回傳的型別可能是 VStack<TupleView<(Text, Image)>> 這種怪物,既無法也不想把它寫進簽章。some View 只隱藏名稱,同時讓編譯器知道具體型別,藉此解決問題,而且不必犧牲效能。Progressive Disclosure 篇中將它視為「初學者不必知道的複雜性」的語法,其真面目就是這個。

也值得了解參數位置的 some。func draw(shape: some Shape)func draw<S: Shape>(shape: S) 的簡寫(SE-0341)。當本體不需要型別參數名稱時,可以用更輕量的方式撰寫泛型。

盒子附帶堆積配置與 witness table 的價目表
盒子附帶堆積配置與 witness table 的價目表

選擇標準 — 預設使用 some,有理由時才用 any

把兩個關鍵字的差異濃縮成表格:some 在編譯時確定一種型別(靜態分派、可最佳化、保留型別關係);any 則允許執行階段接受任何型別(動態分派、盒子成本、型別抹除)。

實務標準很明確:預設使用 some(或泛型),只有確實需要混合多種型別時才使用 any。

any 大致有三種合理使用場景。第一,異質集合。像 [any Shape] 一樣把不同型別放在同一個陣列中,沒有盒子就不可能。第二,型別在執行階段決定的儲存屬性,例如依設定插入不同實作的 var strategy: any PaymentStrategy。策略模式與相依性注入中的協定屬性,大多屬於這一類。第三,回傳型別依條件改變的函式,也就是 some 不允許的情況。

換句話說,如果只是想讓函式參數接收任何遵循這個協定的型別,答案就是 some。每次呼叫的型別都會固定為一種。Swift 團隊的指南也指向相同方向:只有需要在集合中混合或儲存時,才提升為 any。

不必誇大,也不必忽略效能差異。在只處理幾次 UI 事件的程式碼中,any 的成本微不足道。但在每秒執行數萬次的迴圈內,盒子成本與受阻的最佳化會造成可測量的差異。一如往常,標準是測量;預設使用 some,也能減少需要測量的地方。

解讀錯誤訊息 — 「請加上 any」透露了什麼

了解這個區別後,過去只能靠背誦帶過的編譯器訊息也能被解讀了。

「Use of protocol ‘X’ as a type must be written ‘any X’」是在要求你用語法承認:把協定放在型別位置會建立盒子。在機械式加上 any 之前,應先問自己這個位置是否真的需要盒子(是否其實能用 some 或泛型),這才是正確處理這個錯誤的方式。

「Protocol ‘X’ can only be used as a generic constraint」是舊版 Swift 中的知名錯誤:當帶有 associatedtype 或 Self 的協定被放在型別位置時就會出現。它表示即使要放進盒子,也不知道關聯型別,因此無法決定盒子的規格。現在語言透過 SE-0309、primary associated types 等功能,已允許大部分情況,但要完整理解仍需討論關聯型別本身。這就是下一篇的主題。

預設使用 some,只有真的需要混合時才使用 any
預設使用 some,只有真的需要混合時才使用 any

總結

  • 把協定放在型別位置會建立 existential type(盒子),並帶來動態分派、容器成本與型別抹除。any 是貼在這個盒子上的誠實標籤。
  • some 則相反:確定一個具體型別,只隱藏名稱。因為沒有盒子,效能與泛型相同;SwiftUI 的 some View 是代表案例。
  • 預設使用 some(泛型),any 只用在異質集合、執行階段決定的儲存、條件式回傳等多種型別真正混合的地方。
  • 把編譯器要求使用 any 視為「請意識到盒子成本」的訊號,在無條件加上之前,先確認是否能用 some。

下一篇依預告介紹 associatedtype:整理「generic constraint」錯誤的根源、協定包含型別空位的意義,以及 primary associated types。

延伸閱讀