Swift Protocols and Generics, Part 1: Protocol 和其他 Type 有什麼不一樣?

這是一系列以「建構基礎概念」為目標,希望能幫助 Swift 開發者更加瞭解 protocols 和 generics 的文章。文章索引、相關資源以及較詳細的介紹,請見系列簡介

希望讀者在看到文章裡提出問題的時候(請注意 [Q] 標示),能夠一起試著解釋看看,好深化你個人的理解。

Essential Questions

如果有人問你這個問題,你會怎麼解釋?[Q]

核心問題:

Protocol 作為一個 type,和 enum, struct, class… 等等其他的 type 有什麼不一樣?

延伸問題:

在 Swift 5.6 以後,protocol type 的 variables 前面要加上 any 一字。為什麼要改成這樣?

大家都認識的 Protocol

什麼是 protocol?[Q]

只要是寫 Swift 的開發者,想必沒有不認識 protocol 的吧。

Protocol 是一個藍圖,一個協議,一個規範。借用個官方文件的例子:

protocol Shape {
    func draw() -> String
}

struct Triangle: Shape {
    var size: Int
    func draw() -> String {
        /* ... */
    }
}

Shape 規定了一個符合它這個「藍圖」的 type 要能做到什麼事情。Triangle 則昭告天下,說它符合這個藍圖。所以,Triangle 是一個 Shape

有一件事,說起來有點理所當然,但不知道你有沒有仔細想過。那就是,Triangle 可以生一個 instance(實例)出來,但 Shape 不行。

let myTriangle = Triangle(size: 10) // OK

let myShape = Shape()               // 不行不行!

不論是 struct,enum 還是 class,都可以生出 instance 來,但 protocol 不行。它只是藍圖,它不必說明要怎麼做到它規範的項目。這樣一說,感覺其它那些才能算是真的 types,但連個實體 instance 都不能做出來的 protocol 好像… 不太算?[Q]

聽說 Swift 是個「靜態型別」(Statically Typed)的語言

在型別這方面,Swift 真的是相當嚴格。一個變數只要定義了它是什麼 type,那如果有任何差錯,compiler 是不會跟你客氣的。

更甚者,compiler 會要求任何變數都在 compile 的時候,就已經知道它的 type,不能說「喔我們程式執行的時候再走著瞧吧」。

當然我們對 Swift 熟一點,會知道它其實也是有辦法在 runtime 時做一些 dynamic 的事情。不過當我們想到 Swift 引以為傲的 type safety,這安全性無疑是源自 compiler 會在 compile time 知道 & 檢查所有東西的型別。

Compiler 的困擾

根據到目前為止所說的,可以想像當 compiler 看到類似下面的寫法,可能會覺得相當困擾。

var myShape: Shape = /* ... */

「保羅,這個 myShape 是什麼 type?」

我停下敲鍵盤的手,「就是一個 Shape 啊。」

「呃,可是 Shape 是一個 protocol,它是無法生成 instance 的啊。」Compiler 不以為然的說。

我想了想,「嘛… 其實我是想先放一個 Triangle。」

「那就…!」

「可是接下來可能會視情況換成一個 Circle。」

Compiler 沉默了下來,我也大概知道原因。一個 Triangle 和一個 Circle 的 instance,分別屬於某種「遵從 Shape protocol」的 type,但那並不是「Shape type」,也不是同一種 type。Compiler 不能允許 myShape 的型別變來變去。

「Compiler,就沒有什麼好方法嗎?我想用同一個 variable 來放任何 Shape。我有好幾種 Shape,想保留一點彈性。」

Compiler 嘆了口氣,搔了搔頭。「我知道了,你就繼續把 Shape 當成 type 來用吧,總之我來處理。你想在 myShapeTriangleCircle 還是什麼符合 Shape 的東西都可以。不過放進來以後,你就看不到它原本的 type 了喔!」

雖是這麼說,但 compiler 也不能違反它的原則。它在 compile 時就必需決定 myShape 的 type。但是,單純 Shape protocol 不足以直接拿來作為一個 type 使用。Shape 空有規範,就連之後會放一個 struct 還是 class 都不知道,更別說是要保留多少記憶體等等。

(註:一直到 Swift 5.6,compiler 都不會再有怨言,但在那之後的版本會叫你加上關鍵字 any,下面會討論。我們先假設 compiler 還在比較舊的版本。)

Protocol 做為一個 Type

為了給我們方便,讓我們能把 Shape 當成一個 type 使用,compiler 在幕後可是大費周章。

Compiler 默默做了一個新的 type,但它沒有特別跟我們講。這個 type 像是一個盒子,可以把 Triangle 也好,Circle 也好,任何遵循 Shape 的 type 的 instance 藏起來。

如果我要把 myShape 設為一個 Triangle instance,那 compiler 會另外加上生成盒子、把它裝進去的動作。就好像如果我想把某個 optional Triangle 的 variable 設為一個 Triangle instance,compiler 會幫我包成 optional 一樣。

我們雖然不知道 myShape 的真實身份是 compiler 做的新盒子,但我們還是可以拿 myShape 當成一個「可以執行 Shape 規範的方法」的東西來用。這是因為 compiler 在設計這個盒子的時候,就建好了機制,讓我們在對 myShape 呼叫 Shape 的方法時,正確的指揮被裝進盒子裡的那個 instance 來做事。

同樣的一行 code,從開發者的角度,和從 compiler 的角度看來,相當不一樣。

/*         開發者視角:
     myShape 它就是一個 Shape
               |
               ▼               */

var myShape: Shape

/*             ▲
               |
           Compiler 視角:
     myShape 是一個特製的盒子
     可以裝符合 Shape protocol 
            的任何東西         */

一個名字,兩種身份

每當我們拿一個 protocol 當成 type 使用,compiler 就會默默幫我們做個盒子。所以這些時候,protocol 並非我們所想那個單純的「藍圖」。

可是,有的時候它的確是被做為規範其他 type 的藍圖使用。

也就是說,當我們在程式中用到一個 protocol 的名字時,它都有兩種可能的身分。我們怎麼知道哪個是哪個呢?它現在是藍圖,還是盒子呢?[Q]

如果你目前還覺得很難分辨 protocol 是哪種使用方式,別急,再多想想。原則上,我們只要認出 protocol 被使用的情境是在「type 層面」,還是在「value 層面」,就知道答案了。前者它就還是藍圖,後者才是盒子。

在「type 層面」使用:針對 type,作為某些 type 的規範

/*                這裡
                   ▼                              */
struct Triangle: Shape { /* ... */ }

/*                     這裡  和  這裡
                         ▼       ▼                */
struct ClippedShape<T: Shape>: Shape { /* ... */ }

/*            這裡                     這以後再談
               ▼                          ▼       */
func flip<T: Shape>(_ shape: T) -> some Shape { /* ... */ }

在「value 層面」使用:針對 value,作為某個值的 type,實際上變成盒子

/*            這裡
               ▼                                  */
var myShape: Shape = /* ... */

/*                        這裡
                           ▼                      */
func estimateSize(shape: Shape) -> Double { /* ... */ }

/*                           這裡
                              ▼                   */
func makeRandomShapes() -> [Shape] { /* ... */ }

盒子的學名

Compiler 幫我們做的這個盒子,確切來說它的名稱是「existential type」。也有人叫它「existential container」,或是口語化的就叫它「existential」。

為什麼叫這個怪名字,難道它跟哲學有關?Swift 開發者也必需認識尼采嗎?

受到好奇心驅使,我也花了點時間研究(不是尼采),到了一知半解的程度。這個名字的由來,真的,真的不重要。但也許也有人也同樣好奇,所以就稍微提一下。

Existential 這個怪名字有點像是在說,「我這盒子裡存在(∃)某個符合你的 protocol 的 type」。Existential(∃)和 universal(∀)這兩個數學邏輯概念,被用在程式的 type theory 後,成了某種 type 概念的名字。

順道一提,看來與 existential type 相對的 universal type,在 Swift 裡面符合概念的是 generic type。

至於更準確的定義… 就請有興趣的人自行研究吧。雖然它在 type theory 裡面是一個更廣泛的概念,但至少當我們在討論 Swift 的時候,existential type 一詞指的是一個明確、特定的東西,也就是把 protocol 當作 type 時的這個盒子。

拉近兩種思維

言歸正傳。Existential type 雖然方便,但它其實是個還蠻複雜的解法,也不是那麼完美的。譬如說,有時我們會看到「protocol 不符合 protocol」的奇妙錯誤。多的一層盒子也會稍微影響執行效率。還有當 protocol 有 associated type 的時候,情況又更複雜。

不過,對於許多的進階使用者,以及正在研發、改進 Swift 語言本身的開發者們來說,還有另一個大問題,那就是它方便過頭了。直接拿 protocol 來當 type 這麼的方便,以至於很多人寫了很久的 Swift,都沒有意識到原來 compiler 還會要幫我們做盒子。因為沒有正確的認識,所以也難以發現,有時候它並不是最好的選擇。Swift 還有其他進階功能,像是 generics,更適合用在某些情境。

Swift 是個不斷在進步的語言。而這次的進步,代表的是有東西會不像以前那麼方便了。

預計從 Swift 6 開始,如果要使用 protocol existential type,必需在 protocol 的名字前面加上 any 一字。當然,這僅限在 protocol 被當作盒子,而非作為藍圖使用的時候。

/*           加上
              ▼                                   */
var myShape: any Shape = /* ... */

/*                       加上
                          ▼                       */
func estimateSize(shape: any Shape) -> Double { /* ... */ }

/*                          加上
                             ▼                    */
func makeRandomShapes() -> [any Shape] { /* ... */ }

有點像把一個 type 的名字用 [] 圍起來會變成 Array、後面加上 ? 會變成 Optional 一樣,當 protocol 的名字前面加上 any 時,我們就知道,原來這是個 existential container 啊。

/*        開發者視角:
         原來是盒子啊。
              |
              ▼              */

var myShape: any Shape

/*            ▲
              |
          Compiler 視角:
            是盒子呢。
                             */

回顧

在 Swift 裡面,一個 protocol 雖然不能像 struct 和 class 一樣生出 instance,但我們還是能把它當成 instance 的 type 來使用,來達到我們 polymorphism 的目的。實際上為了做到這件事,compiler 會在幕後幫我們產生 existential type。這雖然方便,但也反而造成一些困惑。預計在 Swift 6 之後,protocol existential type 會被要求前面加上一個 any 關鍵字。

最後邀請你一起來回顧。[Q]

關於這次的內容,在技術 & 非技術層面,有哪裡是你覺得難以理解、與原本認知有異,或是不同意說法的嗎?

回頭來看一開始的「核心問題」。現在再次請你和別人解釋,答案會和之前有什麼不同呢?

核心問題:

Protocol 作為一個 type,和 enum, struct, class… 等等其他的 type 有什麼不一樣?

延伸問題:

在 Swift 5.6 以後,protocol type 的 variables 前面要加上 any 一字。為什麼要改成這樣?

Leave a Reply

Your email address will not be published.