泛型和元編程的模型:Java, Go, Rust, Swift, D等
在程序設(shè)計的時候,我們通常希望使用同樣的數(shù)據(jù)結(jié)構(gòu)或算法,就可以處理許多不同類型的元素,比如通用的List或只需要實現(xiàn)compare函數(shù)的排序算法。對于這個問題,不同的編程語言已經(jīng)提出了各種各樣的解決方案:從只是提供對特定目標(biāo)有用的通用函數(shù)(如C,Go),到功能強(qiáng)大的圖靈完備的通用系統(tǒng)(如Rust,C++)。在本文中,我將帶你領(lǐng)略不同語言中的泛型系統(tǒng)以及它們是如何實現(xiàn)的。我將從C這樣的不具備泛型系統(tǒng)的語言如何解決這個問題開始,然后分別展示其他語言如何在不同的方向上逐漸添加擴(kuò)展,從而發(fā)展出各具特色的泛型系統(tǒng)。
泛型是元編程領(lǐng)域內(nèi)通用問題的簡單案例:編寫可以生成其他程序的程序。我將描述三種不同的完全通用的元編程方法,看看它們是如何在泛型系統(tǒng)空的不同方向進(jìn)行擴(kuò)展:像Python這樣的動態(tài)語言,像Template Haskell這樣的過程宏系統(tǒng),以及像Zig和Terra這樣的階段性編譯。
概述
下圖包含了本文討論的所有語言的泛型系統(tǒng),用以概述本文主要內(nèi)容以及它們是如何結(jié)合在一起的。
基本想法
假設(shè)我們用一種沒有泛型系統(tǒng)的語言進(jìn)行編程,我們想實現(xiàn)一個通用的堆棧數(shù)據(jù)結(jié)構(gòu),它對任何數(shù)據(jù)類型都有效。困難在于我們寫的每一個函數(shù)和類型定義都只對那些大小相同、復(fù)制方式相同、行為相同的數(shù)據(jù)有效。
如何解決這個問題?有兩個基本的想法,一是想辦法讓所有數(shù)據(jù)類型在我們的數(shù)據(jù)結(jié)構(gòu)中有同樣的行為方式,二是對我們的數(shù)據(jù)結(jié)構(gòu)進(jìn)行多份拷貝,并稍作調(diào)整,以特定的方式處理每種數(shù)據(jù)類型。這兩個想法構(gòu)成了兩大類解決泛型問題的基礎(chǔ)方法,即"裝箱 "和 "單態(tài)化"。
裝箱是指我們把所有的東西都放在統(tǒng)一的 "盒子 "里,使它們的行為方式都一樣。通常是通過在堆上分配內(nèi)存,只在數(shù)據(jù)結(jié)構(gòu)中放指針來實現(xiàn)的。我們可以讓不同類型的指針有同樣的行為方式,這樣,同樣的代碼就可以處理所有的數(shù)據(jù)類型了。然而這種做法可能要付出額外的內(nèi)存分配、動態(tài)查找和緩存丟失的代價。在C語言中,這相當(dāng)于讓你的數(shù)據(jù)結(jié)構(gòu)存儲void*指針,也需要將你的數(shù)據(jù)指針轉(zhuǎn)換為void*或從void*進(jìn)行類型轉(zhuǎn)換(如果數(shù)據(jù)還沒有在堆上,則在堆上分配)。
單態(tài)化是針對我們要處理的不同類型的數(shù)據(jù),多次復(fù)制代碼。這樣每份代碼都直接使用對應(yīng)的數(shù)據(jù)結(jié)構(gòu)和函數(shù),而不需要任何動態(tài)查找。這樣運(yùn)行效率足夠快,但代價是代碼大小和編譯時間的膨脹,因為同樣的代碼只要稍加調(diào)整就會被編譯多次。在C語言中,這相當(dāng)于在一個宏中定義你的整個數(shù)據(jù)結(jié)構(gòu),并為在使用該結(jié)構(gòu)的地方調(diào)用該宏。
總的來說,裝箱有利于縮短編譯時間,但會損害運(yùn)行時性能,而單態(tài)化會生成的代碼運(yùn)行期效率高,但需要額外的時間來編譯和優(yōu)化生成的代碼。當(dāng)然它們在如何擴(kuò)展方面這方面也有所不同。裝箱允許在運(yùn)行時有更多的動態(tài)行為,而單態(tài)化則可以更靈活地處理通用代碼的不同實例。另外值得注意的是,在一些大型程序中,單態(tài)化的性能優(yōu)勢可能會被額外生成的代碼所帶來的額外指令導(dǎo)致緩存未命中所抵消。
兩個基礎(chǔ)流派中的每一個流派都有很多方向可以擴(kuò)展,以增加額外的能力或安全性,不同的語言已經(jīng)將兩者帶入了非常有趣的方向。有些語言如Rust和C#甚至提供了這兩種選擇!
裝箱
讓我們以go語言為例:
- type Stack struct {
- values []interface{}
- }
- func (this *Stack) Push(value interface{}) {
- this.values = append(this.values, value)
- }
- func (this *Stack) Pop() interface{} {
- x := this.values[len(this.values)-1]
- this.values = this.values[:len(this.values)-1]
- return x
- }
使用裝箱的語言示例。C(void*)、Go(interface{})、無泛型的Java(Object)、無泛型的Objective-C(id)
基于類型擦除裝箱的泛型
這里有一些基礎(chǔ)裝箱的問題。
- 根據(jù)語言的不同,我們經(jīng)常需要在每次讀寫數(shù)據(jù)結(jié)構(gòu)的時候,進(jìn)行類型轉(zhuǎn)換。
- 很難阻止使用者將不同類型的元素放入數(shù)據(jù)結(jié)構(gòu)中,這可能會導(dǎo)致運(yùn)行時異常。
解決方法是在類型系統(tǒng)中增加泛型功能,同時在運(yùn)行時仍然和以前一樣完全使用基本裝箱方法。這種方法通常被稱為類型擦除,因為類型系統(tǒng)中的類型都被 "擦除 "了,都變成了同一類型(比如Object)。
Java和Objective-C一開始都是使用基礎(chǔ)裝箱,后來又增加了基于類型擦除的泛型功能,為了兼容,甚至使用了和以前完全一樣的集合類型,但可以選擇泛型參數(shù)。請看下面的例子,其來自維基百科上關(guān)于Java泛型的文章。
- List v = new ArrayList();
- v.add("test"); // A String that cannot be cast to an Integer
- Integer i = (Integer)v.get(0); // Run time error
- List<String> v = new ArrayList<String>();
- v.add("test");
- Integer i = v.get(0); // (type error) compilation-time error
具有統(tǒng)一表達(dá)方式的推斷裝箱泛型
OCaml將這個想法更進(jìn)一步,采用統(tǒng)一的表示方式,沒有需要額外裝箱分配的基元類型(就像Java中int需要變成Integer才能進(jìn)入ArrayList一樣),因為所有的對象要么已經(jīng)被裝箱,要么用一個指針大小的整數(shù)表示,所以一切都是一個機(jī)器字。然而當(dāng)垃圾收集器查看存儲在通用結(jié)構(gòu)中的數(shù)據(jù)時,它需要區(qū)分指針和整數(shù),所以用1位(指針不會有這1位)來標(biāo)記整數(shù),只留下31位或63位的范圍。
OCaml還有一個類型推理系統(tǒng),所以你可以寫一個函數(shù),如果你不注釋它,編譯器會推斷出最通用的類型,這可能導(dǎo)致函數(shù)看起來像動態(tài)類型語言。
- let first (head :: tail) = head(* inferred type: 'a list -> 'a *)
推斷類型會推斷出 "從類型為'a'的元素列表到類型為'a'的元素的函數(shù)"。該代碼確認(rèn)了這樣的關(guān)系:返回類型與列表類型相同,但可以是任何類型。
接口
基礎(chǔ)裝箱方法的另一個限制是,裝箱類型是完全不透明的。這對于堆棧這樣的數(shù)據(jù)結(jié)構(gòu)來說是沒有問題的,但是像通用排序函數(shù)這樣的功能需要一些額外的函數(shù),比如特定類型的比較函數(shù)。有很多不同的方式可以在運(yùn)行時實現(xiàn)并在語言中導(dǎo)出該功能,你可以在同一種語言中使用多種方式。然而不同的語言大多數(shù)采用某種特定方式實現(xiàn),然后語言擴(kuò)展則充分利用所選實現(xiàn)的優(yōu)勢。這意味著基于著不同的運(yùn)行時方法,主要有兩個選擇:vtables和字典傳遞。
接口vtables
如果我們想暴露類型特化的函數(shù),同時又要堅持裝箱策略,那么我們只要確保有統(tǒng)一的方法可以從對象中找到給定類型的函數(shù)就可以了。這種方法叫做 "vtables"(由 "虛擬方法表 "縮寫而來),它的實現(xiàn)方式是,在通用結(jié)構(gòu)中的每個對象的偏移量為0的地方,都有一個指向函數(shù)指針表的指針。這些表通過在固定的偏移量處索引某些指針,讓通用代碼以同樣的方式為每個類型查找特定類型的函數(shù)指針。
譯者注,圖示如下:
這就是Go中接口類型的實現(xiàn)方式,以及Rust中dyn trait對象的實現(xiàn)方式。當(dāng)你把一個類型轉(zhuǎn)換為一個接口類型時,它會創(chuàng)建一個包裝器,這個包裝器包含一個指向原始對象的指針和一個指向該接口特定類型函數(shù)的vtable的指針。然而這需要額外的指針和內(nèi)存,這也是為什么Go中的排序需要切片實現(xiàn)Sort.Interface接口,而非切片元素實現(xiàn)Comparable接口。
譯者注:
Go 語言對slice進(jìn)行排序,需要在slice(切片)上實現(xiàn)Sort.Interface接口,如下所示:
- type Interface interface {
- Len() int // Len 為集合內(nèi)元素的總數(shù)
- Less(i, j int) bool //如果index為i的元素小于index為j的元素,則返回true,否則返回false
- Swap(i, j int) // Swap 交換索引為 i 和 j 的元素
- }
使用方式:
- package main
- import (
- "fmt"
- "sort"
- )
- //定義interface{},并實現(xiàn)sort.Interface接口的三個方法
- type IntSlice []int
- func (c IntSlice) Len() int {
- return len(c)
- }
- func (c IntSlice) Swap(i, j int) {
- c[i], c[j] = c[j], c[i]
- }
- func (c IntSlice) Less(i, j int) bool {
- return c[i] < c[j]
- }
- func main() {
- a := IntSlice{1, 3, 5, 7, 2}
- b := []float64{1.1, 2.3, 5.3, 3.4}
- c := []int{1, 3, 5, 4, 2}
- fmt.Println(sort.IsSorted(a)) //false
- if !sort.IsSorted(a) {
- sort.Sort(a)
- }
- if !sort.Float64sAreSorted(b) {
- sort.Float64s(b)
- }
- if !sort.IntsAreSorted(c) {
- sort.Ints(c)
- }
- fmt.Println(a)//[1 2 3 5 7]
- fmt.Println(b)//[1.1 2.3 3.4 5.3]
- fmt.Println(c)// [1 2 3 4 5]
- }
對于Java來說,對數(shù)組排序需要在數(shù)組/集合元素上實現(xiàn)Comparable 接口,代碼如下:
- class Simpson implements Comparable<Simpson> {
- String name;
- Simpson(String name) {
- this.name = name;
- }
- @Override
- public int compareTo(Simpson simpson) {
- return this.name.compareTo(simpson.name);
- }
- }
- public class SimpsonSorting {
- public static void main(String... sortingWithList) {
- List<SimpsonCharacter> simpsons = new ArrayList<>();
- simpsons.add(new SimpsonCharacter("Homer "));
- simpsons.add(new SimpsonCharacter("Marge "));
- simpsons.add(new SimpsonCharacter("Bart "));
- simpsons.add(new SimpsonCharacter("Lisa "));
- Collections.sort(simpsons);
- simpsons.stream().map(s -> s.name).forEach(System.out::print);
- Collections.reverse(simpsons);
- simpsons.stream().forEach(System.out::print);
- }
- }
面向?qū)ο缶幊?/strong>
面向?qū)ο缶幊陶Z言很好地利用了vtables的力量。像Java這樣的面向?qū)ο笳Z言沒有獨(dú)立的包含vtables的接口對象,而是在每個對象的開頭有一個vtable指針。類似Java的語言有繼承和接口系統(tǒng),完全可以用這些對象vtables來實現(xiàn)。
除了提供額外的功能外,在每個對象中嵌入vtables還解決了之前需要構(gòu)造新類型的問題。與Go不同的是,在Java中,排序函數(shù)可以使用該類型上的Comparable接口。
反射
一旦你有了vtables,就可以讓編譯器也生成其他類型信息,如字段名、類型和位置,這些都不困難。這樣就可以用同樣的代碼訪問一個類型中的所有數(shù)據(jù),而這些代碼可以檢查其他任何類型中的數(shù)據(jù)。就可以添加 "反射 "功能,它可以用來實現(xiàn)任意類型的序列化等功能。作為裝箱范式的擴(kuò)展,它有同樣的問題,即它只需要一份代碼,但需要大量動態(tài)查找,這可能會導(dǎo)致序列化性能很低。
具有反射功能的語言以及將其用于序列化的例子包括Java、C#和Go。
動態(tài)類型語言
反射是非常強(qiáng)大的,可以完成很多不同的元編程任務(wù),但有一點它不能做,那就是創(chuàng)建新的類型或編輯現(xiàn)有字段的類型信息。如果我們增加了這樣的能力,并通過反射來實現(xiàn),最終就會得到動態(tài)類型語言。在Python和Ruby這樣的語言中,其超強(qiáng)的反射系統(tǒng)會帶來驚人的元編程能力,并且使用其元編程能力的代碼無處不在。
"但是Tristan,動態(tài)語言不是這樣工作的,他們只是用哈希表來實現(xiàn)一切!"有人可能會這么說。好吧,哈希表只是一個用于實現(xiàn)可編輯的類型信息表的數(shù)據(jù)結(jié)構(gòu)。而且,這只是某些像CPython這樣的解釋器的工作方式。如果你看一眼像V8這樣的高性能JIT是如何實現(xiàn)的,它的做法就類似vtables和反射信息! V8的隱藏類(vtables和反射信息)和對象布局與你在Java虛擬機(jī)中看到的類似,只是對象能夠在運(yùn)行時改為新vtable。
字典傳遞
除了將vtables與對象關(guān)聯(lián)起來,實現(xiàn)動態(tài)接口的另一種方式是將所需的函數(shù)指針表傳遞給需要它們的通用函數(shù)。這種方法在某種程度上類似于在調(diào)用時構(gòu)造Go式的接口對象,只是將函數(shù)指針表作為一個隱藏的參數(shù)傳遞,而不是作為現(xiàn)有的參數(shù)之一打包在一起。
這種方式雖然被Haskell類型類使用,但GHC(GHC是Haskell編譯器)通過內(nèi)聯(lián)和特殊化,也可以做單態(tài)化優(yōu)化。字典傳遞這種方式也被OCaml使用,其以一等模塊的形式提供一個顯式參數(shù)傳遞字典,但也有建議增加隱式參數(shù)的機(jī)制。
Swift Witness Tables
Swift的泛型實現(xiàn)更加有趣,通過使用字典傳遞,同時把類型的大小以及如何移動、復(fù)制和釋放它們放到函數(shù)指針表中,該表可以提供所有所需的信息,以統(tǒng)一的方式處理任何類型,而不需要裝箱。這樣一來,Swift就可以在沒有單態(tài)化的情況下實現(xiàn)泛型,也不需要把所有的類型都使用統(tǒng)一的表達(dá)。雖然仍然存在所有動態(tài)查找成本,然而也節(jié)省了分配內(nèi)存、內(nèi)存和緩存不連貫的成本。Swift編譯器能夠在模塊內(nèi)和跨模塊使用注解為@inlinable的函數(shù)進(jìn)行單態(tài)化處理(monomorphize)和內(nèi)聯(lián)泛型,以避免這些成本,其使用啟發(fā)式算法來估算代碼會膨脹多少。
此功能還解釋了Swift為何以允許在結(jié)構(gòu)體中添加和重新排列字段的方式實現(xiàn)ABI穩(wěn)定性,盡管它們出于性能原因提供@frozen屬性以選擇退出動態(tài)查找。
內(nèi)涵類型分析
還有一種為裝箱類型實現(xiàn)接口的方法是在對象的固定部分添加類型ID,就像vtable指針會訪問的位置,然后為每個接口方法生成函數(shù),在所有實現(xiàn)該接口方法的類型上有一個大的switch語句,并派發(fā)到正確的特定類型方法。
我不知道有什么語言使用這種技術(shù),但是C++編譯器和Java虛擬機(jī)在使用profile-guided優(yōu)化來了解某個通用調(diào)用點主要作用于某些類型的對象時,會做類似的事情。他們會對每個通用類型檢查以代替調(diào)用點,然后對該通用類型進(jìn)行靜態(tài)調(diào)度,通常的動態(tài)調(diào)度作為后備情況。這樣分支預(yù)測器就可以預(yù)測出將采取的通用情況分支,并通過靜態(tài)調(diào)用繼續(xù)調(diào)度指令。
單態(tài)化
另一種泛型的實現(xiàn)方法是單態(tài)化。在這種方式中,需要找到某種方法來為每種類型輸出多個版本的代碼。編譯器在編譯時,代碼會經(jīng)過多個表達(dá)階段,理論上我們可以在其中任何一個階段進(jìn)行復(fù)制。
生成源代碼
單態(tài)化最簡單的方法就是在源代碼層面就進(jìn)行復(fù)制。這樣編譯器甚至不需要支持泛型,C和Go等(編譯器不支持泛型)語言的用戶有時會這樣做。
在C語言中,你可以使用預(yù)處理程序,在宏或頭文件中定義你的數(shù)據(jù)結(jié)構(gòu),并多次包含#defines。在Go中,有像genny這樣的腳本,可以簡化代碼生成的過程。
這樣做的缺點是,復(fù)制源代碼會有很多弊端和邊緣情況需要注意,對基本相同的代碼進(jìn)行多次解析和類型檢查也給編譯器帶來很多額外的工作。其次根據(jù)語言和工具的不同,這種泛型方法寫起來和用起來都會很丑,比如說如果你在C語言宏里面寫一個宏,每一行都要以反斜杠結(jié)尾,而且所有的類型和函數(shù)名都需要手動連接上標(biāo)識符以避免碰撞。
D string mixins
不過代碼生成確實有一些好處,那就是你可以使用全能的編程語言來生成代碼,而且它使用的是用戶已經(jīng)熟悉的方法。
一些以其他方式實現(xiàn)泛型功能的語言也包含了一種干凈的代碼生成方式,以解決其泛型系統(tǒng)沒有涵蓋的更一般的元編程用例。最明顯的例子是D 語言的string mixin,它可以在編譯中間使用D的所有功能將D代碼生成為字符串。
Rust 過程宏
還有一個類似的例子是Rust的過程宏,它將token流作為輸入,輸出token流,同時提供程序?qū)oken流轉(zhuǎn)換為字符串或者從字符串轉(zhuǎn)換為token流。這種方法的優(yōu)點是token流可以保存源代碼位置信息。使用宏就可以直接將用戶寫的代碼以token的形式從輸入粘貼到輸出,如果用戶的代碼在宏輸出中引起編譯器錯誤,編譯器輸出的錯誤信息將正確地指向用戶代碼所在的文件、行和列,但如果宏生成了錯誤,那么錯誤信息將指向宏調(diào)用。例如如果在日志調(diào)用中使用了一個封裝函數(shù)的宏,而在封裝函數(shù)的實現(xiàn)中出錯,編譯器的錯誤將直接指向錯誤所在的你的代碼,而非指向宏。
語法樹宏
有些語言確實更進(jìn)一步,提供了在宏中消費(fèi)和產(chǎn)生抽象語法樹(AST)類型的功能。這方面的例子包括模板Haskell、Nim macros、OCaml PPX和幾乎所有的Lisps。
AST宏的問題是,你不希望用戶學(xué)習(xí)一堆構(gòu)造AST類型的函數(shù)。Lisp系列語言解決了這個問題,其語法和AST有非常直接的對應(yīng)關(guān)系,但構(gòu)造過程仍然會很繁瑣。因此,我提到的所有語言都有某種形式的 "引用 "原語,你在語言中提供一個代碼片段,它就會返回語法樹。這些引用原語也提供方法來拼接語法樹的值,就像字符串拼接一樣。下面是模板Haskell中的一個例子。
- -- using AST construction functions
- genFn :: Name -> Q Exp
- genFn f = do
- x <- newName "x"
- lamE [varP x] (appE (varE f) (varE x))
- -- using quotation with $() for splicing
- genFn' :: Name -> Q Exp
- genFn' f = [| \x -> $(varE f) x |]
在語法樹級別而不是token級別做過程宏的一個缺點是,語法樹類型經(jīng)常會隨著新的語言特性增加而改變,而token類型可以保持兼容。例如OCaml的PPX系統(tǒng)需要特殊的基礎(chǔ)設(shè)施來遷移解析樹到宏所使用的語言版本中去。而Rust的相關(guān)庫則增加了解析和引用實用程序,因此你可以用類似過程宏的風(fēng)格來編寫語法樹宏。Rust甚至有一個實驗性的庫,通過這種方式提供反射功能。
模板
下一種泛型的實現(xiàn)方式,是把生成代碼推進(jìn)到編譯的下一階段。在C++和D中使用的模板使用這種方式,你可以在類型和函數(shù)上指定 "模板參數(shù)",當(dāng)你實例化一個具有特定類型的模板時,該類型會被替換到函數(shù)中,然后對函數(shù)進(jìn)行類型檢查,以確保組合是有效的。
- template <class T> T myMax(T a, T b) {
- return (a>b?a:b);
- }
- template <class T> struct Pair {
- T values[2];
- };
- int main() {
- myMax(5, 6);
- Pair<int> p { {5,6} };
- // This would give us a compile error inside myMax
- // about Pair being an invalid operand to `>`:
- // myMax(p, p);
- }
模板系統(tǒng)的問題是,如果你在你的庫中包含一個模板函數(shù),而用戶用錯誤的類型實例化它,其編譯錯誤難以理解。這與動態(tài)類型語言中的庫在處理用戶傳遞錯誤類型時可能發(fā)生的情況非常相似。D語言有一個有趣的解決方法,也與動態(tài)語言中流行的做法類似:只需使用幫助函數(shù)來檢查類型是否有效,如果失敗的話,錯誤信息會指向幫助函數(shù)! 下面是D語言中的例子。
- // We're going to use the isNumeric function in std.traits
- import std.traits;
- // The `if` is optional (without it you'll get an error inside like C++)
- // The `if` is also included in docs and participates in overloading!
- T myMax(T)(T a, T b) if(isNumeric!T) {
- return (a>b?a:b);
- }
- struct Pair(T) {
- T[2] values;
- }
- void main() {
- myMax(5, 6);
- Pair!int p = {[5,6]};
- // This would give a compile error saying that `(Pair!int, Pair!int)`
- // doesn't match the available instance `myMax(T a, T b) if(isNumeric!T)`:
- // myMax(p, p);
- }
C++20有一個叫做 "概念(concepts) "的功能,除了設(shè)計上更像定義接口和類型約束外,它的作用是一樣的。
編譯期函數(shù)
D的模板有很多擴(kuò)展,允許你使用編譯期函數(shù)評估和靜態(tài)if等功能,可以使模板的行為就像函數(shù)一樣,在編譯時接受一組參數(shù),并返回一個非通用的運(yùn)行時函數(shù)。這使得D模板成為功能齊全的元編程系統(tǒng),據(jù)我了解,現(xiàn)代C++模板也有類似的功能,但實現(xiàn)機(jī)制不夠干凈。
還有一些語言把 "泛型只是編譯期函數(shù) "的概念更進(jìn)一步的運(yùn)行,比如Zig。
- fn Stack(comptime T: type) type {
- return struct {
- items: []T,
- len: usize,
- const Self = @This();
- pub fn push(self: Self, item: T) {
- // ...
- }
- };
- }
Zig在編譯時和運(yùn)行時都使用同一種語言,函數(shù)根據(jù)是否標(biāo)記為comptime的參數(shù)進(jìn)行區(qū)分。還有一種語言,在元級(meta level)使用單獨(dú)的但類似的語言,叫Terra。Terra是Lua的一種方言,它允許你構(gòu)建類似C語言的低級函數(shù),然后使用Lua API以及引用和拼接原語言在元級來操作它們。
- function MakeStack(T)
- local struct Stack {
- items : &T; -- &T is a pointer to T
- len : int;
- }
- terra Stack:push(item : T)
- -- ...
- end
- return Stack
- end
Terra瘋狂的元編程能力讓它可以做很多事情,比如把特定領(lǐng)域語言的編譯器作為簡單的函數(shù)來實現(xiàn),或者用少量的代碼在庫中實現(xiàn)Java和Go的接口和對象系統(tǒng)。然后它可以將生成的運(yùn)行時代碼保存為無依賴的對象文件。
Rust 泛型
下一種類型的單態(tài)化泛型,是在類型檢查之后,把代碼生成的過程再推進(jìn)一步。上文提到用C++可以像動態(tài)類型語言中的獲取泛型庫函數(shù)內(nèi)的錯誤類型,這是因為模板參數(shù)中基本只有一種類型。所以這就意味著我們可以通過在我們的元級中增加類型系統(tǒng)來解決這個問題,并靜態(tài)檢查它們是否支持你使用的操作。這就是泛型在Rust中的工作方式,在語言層面來說也是Swift和Haskell中泛型的工作方式。
在Rust中,你需要在你的類型參數(shù)上聲明 "trait bounds",其中trait就像其他語言中的接口一樣,聲明了類型提供的一系列函數(shù)。Rust編譯器會檢查你的泛型函數(shù)的主體是否能與任trait bounds的類型一起工作,也不允許你使用trait bounds沒有聲明的函數(shù)。這樣Rust中泛型函數(shù)在實例化時,就永遠(yuǎn)不會在庫函數(shù)得到編譯器錯誤。編譯器也只需要對每個泛型函數(shù)進(jìn)行一次類型檢查。
- fn my_max<T: PartialOrd>(a: T, b: T) -> T {
- if a > b { a } else { b }
- }
- struct Pair<T> {
- values: [T; 2],
- }
- fn main() {
- my_max(5,6);
- let p: Pair<i32> = Pair { values: [5,6] };
- // Would give a compile error saying that
- // PartialOrd is not implemented for Pair<i32>:
- // my_max(p,p);
- }
在語言層面上,以裝箱方式實現(xiàn)的泛型所需要的類型系統(tǒng)和這個十分類似,這也是為什么Rust可以使用同一個類型系統(tǒng)來支持這兩種泛型的原因! Rust 2018甚至增加了統(tǒng)一的語法,其中v: &impl SomeTrait參數(shù)會被單態(tài)化,但v: &dyn SomeTrait參數(shù)會使用裝箱。這一方式也讓Swift的編譯器和Haskell的GHC等編譯器即使默認(rèn)使用裝箱來實現(xiàn)泛型,也可以單態(tài)化作為優(yōu)化手段。
機(jī)器碼單態(tài)化
單態(tài)化泛型的下一步是在編譯器后端中進(jìn)一步推進(jìn)。就像我們可以復(fù)制帶有泛型類型占位符的源代碼模板一樣,我們可以生成帶有特定類型占位符的機(jī)器代碼。然后我們就可以像鏈接器的一樣工作,通過memcpy和一些補(bǔ)丁,很快就可以把這些模板標(biāo)記出來! 其缺點是每個單態(tài)化的副本不能被優(yōu)化器特別優(yōu)化,然而因為沒有重復(fù)優(yōu)化,所以編譯速度可以快很多。我們甚至可以把代碼stamper做成一個小小的JIT,被包含在二進(jìn)制文件中,并在運(yùn)行時把單態(tài)化的副本標(biāo)記出來,以避免二進(jìn)制文件的膨脹。
其實我并不知道有哪種語言的泛型是這樣工作的,這只是我在寫作本文時的一個想法,作為這個分類法的自然延伸,這也正是我希望從中得到的東西! 我希望這篇文章能讓你更清楚地了解不同語言中的泛型系統(tǒng),以及如何對他們分類,并促進(jìn)你的思考,也許我們可能會發(fā)現(xiàn)新的酷炫的編程語言的方向。
原文地址:
https://thume.ca/2019/07/14/a-tour-of-metaprogramming-models-for-generics/
本文轉(zhuǎn)載自微信公眾號「高可用架構(gòu)」,可以通過以下二維碼關(guān)注。轉(zhuǎn)載本文請聯(lián)系高可用架構(gòu)公眾號。