在不久之前Go 1.18釋出,包含了許多Go開發者翹首盼望的泛型(Generics)功能。然而,在〈Go 1.18 Release Notes〉一開頭就談到,泛型特性尚需要大量的實務程式碼驗證,雖然Go一直致力於向後相容性,不過,未來若發現規範上的錯誤,或者是實作上的臭蟲,基於修正這些錯誤或臭蟲的必要性,開發者基於泛型撰寫的程式,就會有在新版本上的相容性問題,Go不能保證這種可能性為零。

Go 1.18對泛型的警告

Go發布十多年以來,始終不支援泛型,卻在支援泛型的時候,在聲明添上這種警告,開發者到底是用還是不用呢?就結論而言,如果開發者只是想將Go泛型,當成是C++之類的樣版(template)生成來用,建議還是先尋求泛型之外的解決方案。

這建議看似與泛型目的是相對的,畢竟泛型不就是撰寫程式演算流程時,可以先不用明確地撰寫型態,待日後需要時才指定具體類型嗎?對於許多開發者而言,如果發現有兩個相同的流程,僅僅只是過程中使用的型態不同,使用泛型來實作,日後基於真正的型態產生函式實例,不就是天經地義?

最常被拿來作為泛型應用場合的簡單例子是排序,針對[]int與[]float64的排序演算流程會一模一樣,僅僅只在型態名稱各是int、float64,沒有泛型,實作程式庫時,就必須像sort套件那樣,提供Ints、Float64等各個型態的實作,若能代換為空泛的T型態,就可以用一個函式來解決各種型態之排序。

欸……不對!sort套件的Sort就是通用函式,Ints、Float64s等函式,內部也只是轉換為IntSlice、Float64Slice、StringSlice,然後呼叫Sort罷了,Sort函式的參數型態是Interface,規範了Len、Less、Swap三個行為,只要先傳Sort的引數符合Interface的規範,就可以進行排序,也就是說,Go沒有泛型之前,也能利用多型來解決類似情境的需求。

參數與次型態多型

多型大致上是指相同的介面可以有不同的行為,由於這本身是個空泛的定義,也因此會有不同的實現形式。對於多型稍有研究的開發者而言,基本上,會將Go的泛型認知為參數多型(parametric polymorphism),至於interface的實現,會認知為次型態多型(subtype polymorphism)。

其實,不論是參數多型或是次型態多型,兩者本質上相同,只是因應不同的語言典範或特性,特別是在具有物件概念的靜態定型語言中,才會被區分開來。

而在動態定型語言沒有這個問題,例如,不具備物件概念的Haskell中,並不區分參數多型或次型態多型,多型就是以型態參數與型態類別(Typeclass)來實現。

談到Haskell,有些開發者愛用函數式範疇,來討論Go要加入泛型的理由之一,就是不容易實現出Map/Filter之類的通用函式,例如,用泛型可以簡單地就實現出〈Go泛型:Map實現〉的Map函式,不過如果沒有使用泛型,我也能實現出〈Go介面:Map實現〉的Map函式,就函式簽署而言,後者還容易閱讀一些。

一個使用參數多型實現的函式,基本上都可以實現另一個基於次型態多型的版本,對於Go這門語言來說更是如此,因為Go語言的介面,概念上很接近Haskell的型態類別,Go的結構就類似Haskell的Product型態,因而方才兩個Gist的Map函式內容,實現後幾乎是一模一樣,差別在於後者被抽取出Len與Idx行為,規範為Indicable介面。

其實,許多開發者面對泛型有一種心態,在發現兩個函式的流程內容一模一樣,只是某些資料的型態不同時,他們只從型態不同的角度來看,因而才會只想到以型態參數來通用化;然而,從另一角度來看,為什麼不同的型態會存在通用化方案?這是否表示:「不同型態間存在某些共同行為」,這時就會多一個通用化的方案了!

簡單來說,參數多型與次型態多型就是看待事情的兩個角度,也就是為什麼我會說,參數多型與次型態多型本質上就是相同的!

型態約束與推論

雖說參數多型與次型態多型本質上是相同,在Go中沒泛型也做得了事,不過〈Go介面:Map實現〉的寫法,看來好像還是麻煩了一些?畢竟必須實現Len與Idx行為,對於[]int,還要轉型為AnySlice型態,才能呼叫Map函式,而且指定的mapper函式實現,也要進行型態測試。

這就是Go泛型需要型態約束(就算只是any型態)的原因,型態約束提供編譯器型態資訊,以便進行更好的編譯時期型態檢查,而在前文後資訊足夠的情況下,不需要明確指定實際型態來呼叫函式,也不需要轉型或者是型態測試,編譯器可以進行型態推論,這讓函式呼叫時的引數傳遞與傳回值應用,都可以簡潔許多。

不過,這份簡潔是有代價的,其中一個代價是編譯器的實現變得複雜、編譯會變慢,這也違反Go的設計初衷;另外,函式宣告會變得複雜,就我而言,無論是哪個語言的泛型語法,每次看到都會有種眼睛痛的錯覺。

還有一個代價是,開發者很容易單純地將泛型當成是樣版生成,懶得思考為什麼兩個不同型態的演算,會有相同的過程,從而令函式的實現失去更通用的可能性。

〈Go介面:Map實現〉的Map函式實現來說,就只適用於各種型態的slice,然而〈Go介面:Map實現〉更為通用,只要資料實現了Len與Idx行為,就算它不是slice,也能使用其Map函式,Indicable在概念上,接近物件導向模式中的迭代器規範,而AnySlice就像是迭代器了,從這個角度來看,實現Len與Idx行為就不是個麻煩,反倒是清楚地交代了AnySlice是怎麼進行迭代的。

有些開發者吹捧Go泛型時,會說泛型可以將一些函式寫得更通用,然而,單純地將流程中不同的型態參數化,並不會比抽取流程中共同行為來得通用。所以,通用性不應該是使用泛型的主要原因,更好的編譯時期型態檢查、透過型態推論省去明確指定型態的負擔,才是使用泛型的好處。

兩個方向的權衡

相對於Java之類具有繼承的語言,仍須面對正變性、逆變性等問題,Go因為沒有繼承,泛型在使用上已經單純許多,即便如此,也不應該濫用泛型,一個簡單的出發點是,如果只是想當成樣版生成,或者只考量了型態的不同,單純想藉由抽取型態並宣稱讓函式更通用,那就別使用泛型。

通用性畢竟是有代價的,需要更多的思考,也就是開發者必須思考另一個方向,不同型態間究竟是具有哪些相同行為,才能通用同一流程,這就是使用介面來實現次型態多型會麻煩一些的原因;然而,泛型也有其相對的代價,除了方才提及的編譯速度與更複雜的函式宣告外,就目前而言還有一個代價,也就是尚待社群進一步地驗證。

這就使得泛型與介面,也就是參數多型與次型態多型,在Go中更像是兩個方向的權衡,端看開發者選擇光譜的哪個位置罷了,若對〈Go 1.18 Release Notes〉的警告感到不安,先觀望當然也沒問題,有時候不需要通用性的話,那麼針對需求撰寫特定型態的函式,就算流程相同,也是一種選擇!

 相關報導  Go泛型主要設計者出面說明使用泛型的時機

專欄作者


熱門新聞

Advertisement