Go 語言為什麼需要泛型?

←手機掃碼閱讀     admin @ 2019-08-01 , reply:0

Go:為何帶來泛型

介紹

[這是在Gophercon 2019上發表的演講版本。視頻鏈接可供使用。]

這篇文章是關於向Go添加泛型的意義,以及為什麼我認為我們應該這樣做。我還將介紹為Go添加泛型的設計可能的改變。

Go於2009年11月10日發布。不到24小時后,我們看到了關於泛型第一條評論。(該評論還提到了我們在2010年初以panic和recover的形式添加到語言中的情況。)

在Go調查的三年中,缺乏泛型一直被列為該語言需要修復的三大問題之一。

為什麼是泛型?

但是添加泛型是什麼意思,為什麼我們想要呢?

用 Jazayeri等人的話來說:泛型編程能夠以通用類型的形式表示函數和數據結構。

那意味著什麼呢?

舉一個簡單的例子,我們假設我們想要反轉切片中的元素。這不是很多程序需要做的事情,但並不是那麼不尋常。

讓我們說它是一個int slice。

func ReverseInts(s []int) {
    first := 0
    last := len(s)
    for first < last {
        s[first], s[last] = s[last], s[first]
        first++
        last--
    }
}

非常簡單,但即使是一個簡單的功能,你也想寫一些測試用例。事實上,當我這樣做時,我發現了一個錯誤。我相信很多讀者已經發現了它。

func ReverseInts(s []int) {
    first := 0
    last := len(s) - 1
    for first < last {
        s[first], s[last] = s[last], s[first]
        first++
        last--
    }
}

我們在最後設置變數時需要減去1。

現在讓我們反轉一段字元串。

func ReverseStrings(s []string) {
    first := 0
    last := len(s) - 1
    for first < last {
        s[first], s[last] = s[last], s[first]
        first++
        last--
    }
}

如果比較ReverseInts以及ReverseStrings,您將看到兩個函數完全相同,除了參數的類型。我認為沒有任何讀者對此感到驚訝。

有些人對Go感到驚訝的是,沒有辦法編寫Reverse適用於任何類型切片的簡單函數。

大多數其他語言都可以讓你編寫這種功能。

在像Python或JavaScript這樣的動態類型語言中,您可以簡單地編寫函數,而無需指定元素類型。這在Go中不起作用,因為Go是靜態類型的,並且要求您記下切片的確切類型和切片元素的類型。

大多數其他靜態類型語言,如C++或Java或Rust或Swift,支持泛型來解決這類問題。

今天的Go通用編程

那麼人們如何在Go中編寫這種代碼呢?

在Go中,您可以使用介面類型編寫適用於不同切片類型的單個函數,並在要傳遞的切片類型上定義方法。這就是標準庫的sort.Sort功能的工作方式。

換句話說,Go中的介面類型是通用編程的一種形式。他們讓我們捕捉不同類型的共同方面,並將它們表達為方法。然後我們可以編寫使用這些介面類型的函數,這些函數將適用於實現這些方法的任何類型。

但這種做法不符合我們的要求。使用介面,您必須自己編寫方法。使用幾種方法定義命名類型來反轉切片是很尷尬的。你編寫的方法對於每種切片類型都是完全相同的,所以從某種意義上說,我們只是移動並壓縮了重複的代碼,我們還沒有消除它。雖然介面是泛型的一種形式,但它們並沒有向我們提供我們想要的所有泛型。

使用泛型介面的另一種方法是,可以解決自己編寫方法的需要,就是讓語言為某些類型定義方法。這不是語言支持的東西,但是,例如,語言可以定義每個切片類型都有一個返回元素的Index方法。但是為了在實踐中使用該方法,它必須返回一個空介面類型,然後我們失去了靜態類型的所有好處。更巧妙的是,沒有辦法定義一個泛型函數,該函數採用具有相同元素類型的兩個不同切片,或者採用一個元素類型的映射並返回相同元素類型的切片。Go是一種靜態類型語言,因為這樣可以更容易地編寫大型程序; 我們不想失去靜態類型的好處,以獲得泛型的好處。

另一種方法是Reverse使用反射包編寫一個泛型函數,但這樣寫起來很笨拙而且運行速度慢,很少有人這樣做。該方法還需要顯式類型斷言,並且沒有靜態類型檢查。

或者,您可以編寫一個代碼生成器,它接受一個類型並Reverse為該類型的切片生成一個函數。有幾個代碼生成器就是這樣做的。但是這為需要的每個包添加了另一個步驟Reverse,它使構建變得複雜,因為必須編譯所有不同的副本,並且修復主源中的錯誤需要重新生成所有實例,其中一些實例可能完全在不同的項目中。

所有這些方法都很尷尬,我認為大多數必須在Go中反轉切片的人只是為他們需要的特定切片類型編寫函數。然後他們需要為函數編寫測試用例,以確保它們沒有像我最初製作的那樣犯一個簡單的錯誤。而且他們需要定期運行這些測試。

但是我們這樣做,這意味著除了元素類型之外,對於看起來完全相同的函數來說,還有很多額外的工作。並不是說它無法完成。顯然可以做到,Go程序員正在這樣做。只是應該有更好的方式。

對於像Go這樣的靜態類型語言,更好的方法是泛型。我之前寫的是泛型編程能夠以通用形式表示函數和數據結構,並將類型考慮在內。這正是我們想要的。

為Go可以帶來什麼樣的泛型

我們想要從Go中的泛型中獲得的第一個也是最重要的事情是能夠編寫函數,Reverse而不必關心切片的元素類型。我們想要分解出那種元素類型。然後我們可以編寫一次函數,編寫測試一次,將它們放在一個go-gettable包中,並隨時調用它們。

更好的是,由於這是一個開源世界,其他人可以寫Reverse一次,我們可以使用他們的實現。

在這一點上,我應該說「泛型」可能意味著很多不同的東西。在本文中,我所說的「泛型」是我剛才所描述的。特別是,我不是指C ++語言中的模板,它支持的內容比我在這裡寫的要多得多。

我Reverse詳細介紹了,但是我們可以編寫許多其他功能,例如:

  • 查找切片中的最小/最大元素

  • 求出切片的平均值/標準差

  • 計算聯合/交叉的maps

  • 在node/edge中查找最短路徑

  • 將轉換函數應用於slice/map,返回新的slice/map

這些示例以大多數其他語言提供。實際上,我通過瀏覽C++標準模板庫來編寫這個列表。

還有一些特定於Go的示例,它強烈支持併發性。

  • 從具有超時的通道讀取

  • 將兩個通道組合成一個通道

  • 并行調用函數列表,返回一片結果

  • 調用函數列表,使用Context,返回第一個函數的結果來完成,取消和清理額外的goroutines

我已經看到所有這些函數用不同的類型寫了很多次。在Go中編寫它們並不難。但是能夠重用適用於任何值類型的高效且便於調試的實現會很好。

需要說明的是,這些僅僅是一些例子。還有許多通用功能可以使用泛型更容易和安全地編寫。

另外,正如我之前所寫,它不僅僅是功能。它也是數據結構。

Go有兩種內置於該語言中的通用通用數據結構:切片和maps。切片和maps可以保存任何數據類型的值,使用靜態類型檢查存儲和檢索的值。值存儲為自身,而不是介面類型。也就是說,當我有a時[]int,切片直接保存int,而不是int轉換為介面類型。

切片和maps是最有用的通用數據結構,但它們不是唯一的。以下是其他一些例子。

  • Sets

  • 自平衡樹,有序插入和按行排序遍歷

  • Multimaps,具有密鑰的多個實例

  • 併發哈希映射,支持并行插入和查找,沒有單個鎖

如果我們可以編寫泛型類型,我們可以定義像這樣的新數據結構,它們具有與切片和映射相同的類型檢查優勢:編譯器可以靜態地類型檢查它們所持有的值的類型,並且值可以存儲為自身,而不是存儲為介面類型。

還應該可以採用前面提到的演算法並將它們應用於通用數據結構。

這些示例應該就像Reverse:通用函數和數據結構一次編寫,在包中,並在需要時重用。它們應該像切片和maps一樣工作,因為它們不應該存儲空介面類型的值,但是應該存儲特定的類型,並且應該在編譯時檢查這些類型。

這就是Go可以從泛型中獲得的東西。泛型可以為我們提供強大的構建塊,讓我們可以更輕鬆地共享代碼和構建程序。

我希望我已經解釋了為什麼值得研究。

好處和成本

但泛型並非來自Big Rock Candy Mountain,那裡每天陽光照射在檸檬水泉上。每一種語言變化都有成本。毫無疑問,向Go添加泛型將使語言更複雜。與語言的任何變化一樣,我們需要談論最大化利益並最大限度地降低成本。

在Go中,我們的目標是通過可以自由組合的獨立,正交語言功能來降低複雜性。我們通過簡化各個功能來降低複雜性,並通過允許其自由組合來最大化功能的好處。我們希望對泛型做同樣的事情。

為了使這個更具體,我將列出一些我們應該遵循的準則。

*盡量減少新概念

我們應該儘可能少地為語言添加新概念。這意味著最少的添加新語法和最少的新關鍵字和其他名稱。

*複雜性落在通用代碼的編寫者身上,而不是用戶身上

編程通用包的程序員應儘可能地降低複雜性。我們不希望包的用戶不必擔心泛型。這意味著應該可以以自然的方式調用泛型函數,同時使用通用包時的任何錯誤都應該以易於理解和修復的方式報告。將調用調用為通用代碼也應該很容易。

*作家和用戶可以獨立工作

同樣,我們應該很容易將通用代碼的編寫者及其用戶的關注點分開,以便他們可以獨立開發代碼。他們不應該擔心對方在做什麼,不僅僅是不同包中正常功能的編寫者和調用者都要擔心。這聽起來很明顯,但對於其他所有編程語言中的泛型都不是這樣。

*構建時間短,執行時間短

當然,儘可能地,我們希望保持Go給我們今天的短建造時間和快速執行時間。泛型傾向於在快速構建和快速執行之間進行權衡。我們儘可能地想要兩者。

*保持Go的清晰度和簡潔性

最重要的是,Go今天是一種簡單的語言。Go程序通常清晰易懂。我們探索這個空間的漫長過程的一個主要部分是試圖了解如何在保持清晰度和簡潔性的同時添加泛型。我們需要找到適合現有語言的機制,而不是把它變成完全不同的東西。

這些指南應適用於Go中的任何泛型實現。這是我今天想要留給你的最重要的信息: 泛型可以為語言帶來顯著的好處,但是如果Go仍然感覺像Go那麼它們是值得做的。

草案設計

幸運的是,我認為可以做到。為了完成這篇文章,我將討論為什麼我們想要泛型,以及對它們的要求是什麼,簡要討論我們認為如何將它們添加到語言中的設計。

在今年的Gophercon Robert Griesemer和我發布了一個設計草案,為Go添加泛型。有關詳細信息,請參閱草案。我將在這裡討論一些要點。

這是此設計中的通用反向函數。

func Reverse (type Element) (s []Element) {
    first := 0
    last := len(s) - 1
    for first < last {
        s[first], s[last] = s[last], s[first]
        first++
        last--
    }
}

您會注意到函數的主體完全相同。只有簽名發生了變化。

已經考慮了切片的元素類型。它現在被命名Element並成為我們所謂的 類型參數。它不是成為slice參數類型的一部分,而是一個單獨的附加類型參數。

要使用類型參數調用函數,在一般情況下,您傳遞一個類型參數,這與任何其他參數類似,只不過它是一個類型。

func ReverseAndPrint(s []int) {
    Reverse(int)(s)
    fmt.Println(s)
}

這是在這個例子中就是你看到的Reverse後面的(int)。

幸運的是,在大多數情況下,包括這個,編譯器可以從常規參數的類型中推斷出類型參數,並且根本不需要提及類型參數。

調用泛型函數就像調用任何其他函數一樣。

func ReverseAndPrint(s []int) {
    Reverse(s)
    fmt.Println(s)
}

換句話說,雖然通用Reverse功能略高於更加複雜ReverseInts和ReverseStrings,這種複雜落在函數上,而不是編寫和調用。

合約

由於Go是一種靜態類型語言,我們必須討論類型參數的類型。這個元類型告訴編譯器在調用泛型函數時允許哪種類型的參數,以及泛型函數可以對類型參數的值執行哪些操作。

該Reverse函數可以使用任何類型的切片。它對類型值的唯一作用Element是賦值,它適用於Go中的任何類型。對於這種通用函數,這是一種非常常見的情況,我們不需要對類型參數說些什麼特別的。

讓我們快速瀏覽一下不同的功能。

func IndexByte (type T Sequence) (s T, b byte) int {
    for i := 0; i < len(s); i++ {
        if s[i] == b {
            return i
        }
    }
    return -1
}

目前,標準庫中的bytes包和strings包都有一個IndexByte函數。此函數返回b序列中的索引s,其中s a string或a []byte。我們可以使用這個單一的泛型函數來替換位元組和字元串包中的兩個函數。在實踐中,我們可能不會這樣做,但這是一個有用的簡單示例。

在這裡,我們需要知道類型參數的T作用類似於a string 或a []byte。我們可以調用len它,我們可以索引它,我們可以將索引操作的結果與位元組值進行比較。

要進行編譯,type參數T本身需要一個類型。它是一個元類型,但是因為我們有時需要描述多個相關類型,並且因為它描述了泛型函數的實現與其調用者之間的關係,所以我們實際上調用T了契約的類型。合約在這裡命名Sequence。它出現在類型參數列表之後。

這是為此示例定義Sequence契約的方式。

contract Sequence(T) {
    T string, []byte
}

這很簡單,因為這是一個簡單的例子:type參數 T可以是string或[]byte。這contract可能是一個新的關鍵字,或在包範圍內識別的特殊標識符; 請參閱設計草案了解詳情。

任何記得我們在Gophercon 2018上展示過的設計的人 都會發現這種簽訂合約的方式要簡單得多。我們得到了很多關於合約過於複雜的早期設計的反饋,我們已經嘗試將其考慮在內。新合同的編寫,閱讀和理解都要簡單得多。

它們允許您指定類型參數的基礎類型,和/或列出類型參數的方法。它們還可以讓您描述不同類型參數之間的關係。

與方法簽訂合約

這是另一個簡單的例子,它使用String方法返回[]string所有元素的字元串表示形式s。

func ToStrings (type E Stringer) (s []E) []string {
    r := make([]string, len(s))
    for i, v := range s {
        r[i] = v.String()
    }
    return r
}

它非常簡單:遍歷切片,String 在每個元素上調用方法,然後返回結果字元串的切片。

此函數要求元素類型實現該String 方法。字元串合約確保了這一點。

contract Stringer(T) {
    T String() string
}

合約只是說T必須實施該String 方法。

您可能會注意到此合約看起來像fmt.Stringer 介面,因此值得指出ToStrings函數的參數 不是一個分支fmt.Stringer。它是元素類型實現的一些元素類型的片段 fmt.Stringer。元素類型切片的內存表示和fmt.Stringer 切片通常是不同的,Go不支持它們之間的直接轉換。所以即使fmt.Stringer存在,這是值得寫的。

有多種類型的合約

以下是具有多個類型參數的合約示例。

type Graph (type Node, Edge G) struct { ... }

contract G(Node, Edge) {
    Node Edges() []Edge
    Edge Nodes() (from Node, to Node)
}

func New (type Node, Edge G) (nodes []Node) *Graph(Node, Edge) {
    ...
}

func (g *Graph(Node, Edge)) ShortestPath(from, to Node) []Edge {
    ...
}

這裡我們描述一個由Node和Edge構建的Graph。我們不需要Graph的特定數據結構。相反,我們說Node類型必須有一個Edges 方法,返回連接到的Edge的列表Node。而且Edge類型必須有一個Nodes返回兩個方法 Nodes,該Edge所連接。

我已經跳過了實現,但是這顯示了一個New返回a 的 函數Graph的簽名,以及一個ShortestPath方法的簽名 Graph。

這裡的重要內容是合同不僅僅是一種類型。它可以描述兩種或更多種類型之間的關係。

有序類型

關於Go的一個令人驚訝的常見抱怨是它沒有 Min功能。或者,就此而言,一個Max功能。這是因為一個有用的Min函數應該適用於任何有序類型,這意味著它必須是通用的。

雖然Min編寫自己非常簡單,但任何有用的泛型實現都應該讓我們將它添加到標準庫中。這就是我們的設計。

func Min (type T Ordered) (a, b T) T {
    if a < b {
        return a
    }
    return b
}

該Ordered合約上說T具有類型是有序類型,這意味著它支持像小於,大於,等運算。

contract Ordered(T) {
    T int, int8, int16, int32, int64,
        uint, uint8, uint16, uint32, uint64, uintptr,
        float32, float64,
        string
}

該Ordered合約僅僅是所有由語言定義的命令類型的列表。此合約接受任何列出的類型,或其基礎類型為其中一種類型的任何命名類型。基本上,您可以使用less運算符的任何類型。

事實證明,簡單地枚舉支持小於運算符的類型比發明一個適用於所有運算符的新表示法要容易得多。畢竟,在Go中,只有內置類型支持運算符。

這種方法可以用於任何運算符,或者更一般地,用於編寫旨在與內置類型一起使用的任何通用函數的契約。它允許泛型函數的編寫者清楚地指定函數應該與之一起使用的類型集。它允許通用函數的調用者清楚地看到該函數是否適用於所使用的類型。

實際上,這份合約可能會進入標準庫。所以Min函數(可能也會在某個標準庫中)看起來就像這樣。這裡我們只是提到包合同中定義的Ordered合約。

func Min (type T contracts.Ordered) (a, b T) T {
    if a < b {
        return a
    }
    return b
}

通用數據結構

最後,讓我們看一個簡單的通用數據結構,一個二叉樹。在此示例中,樹具有比較功能,因此對元素類型沒有要求。

type Tree (type E) struct {
    root    *node(E)
    compare func(E, E) int
}

type node (type E) struct {
    val         E
    left, right *node(E)
}

以下是如何創建新的二叉樹。比較函數傳遞給New函數。

func New (type E) (cmp func(E, E) int) *Tree(E) {
    return &Tree(E){compare: cmp}
}

未導出的方法返回一個指向持有v的槽的指針,或指向樹應該去的位置。

func (t *Tree(E)) find(v E) **node(E) {
    pn := &t.root
    for *pn != nil {
        switch cmp := t.compare(v, (*pn).val); {
        case cmp < 0:
            pn = &(*pn).left
        case cmp > 0:
            pn = &(*pn).right
        default:
            return pn
        }
    }
    return pn
}

這裡的細節並不重要,特別是因為我沒有測試過這段代碼。我只想展示編寫簡單通用數據結構的樣子。

這是用於測試樹是否包含值的代碼。

func (t *Tree(E)) Contains(v E) bool {
    return *t.find(e) != nil
}

這是插入新值的代碼。

func (t *Tree(E)) Insert(v E) bool {
    pn := t.find(v)
    if *pn != nil {
        return false
    }
    *pn = &node(E){val: v}
    return true
}

注意類型參數E的類型node。這就是編寫通用數據結構的樣子。正如您所看到的,它看起來像編寫普通的Go代碼,除了在這裡和那裡散布一些類型的參數。

使用樹很簡單。

var intTree = tree.New(func(a, b int) int { return a - b })

func InsertAndCheck(v int) {
    intTree.Insert(v)
    if !intTree.Contains(v) {
        log.Fatalf("%d not found after insertion", v)
    }
}

這是應該的。編寫通用數據結構要困難一些,因為您經常需要為支持類型顯式寫出類型參數,但儘可能使用一個與使用普通的非通用數據結構沒有什麼不同。

下一步

我們正在開展實際實施,以便我們嘗試這種設計。能夠在實踐中嘗試設計非常重要,以確保我們可以編寫我們想要編寫的程序類型。它沒有像我們希望的那樣快,但我們將在這些實現可用時發送更多詳細信息。

Robert Griesemer編寫了一個初步的CL,修改了go/types包。這允許測試使用泛型和合同的代碼是否可以鍵入檢查。它現在還不完整,但它主要適用於單個包,我們將繼續努力。

我們希望人們對這個和未來的實現做的是嘗試編寫和使用通用代碼,看看會發生什麼。我們希望確保人們可以編寫他們需要的代碼,並且他們可以按預期使用它。當然,並不是一切都會起作用,當我們探索這個空間時,我們可能不得不改變一切。而且,要清楚,我們對語義的反饋比對語法的細節更感興趣。

我要感謝所有對早期設計發表評論的人,以及所有討論過Go中泛型的人。我們已經閱讀了所有評論,我們非常感謝人們為此付出的努力。如果沒有那項工作,我們就不會有今天的設計、開發工作。

我們的目標是實現一種設計,使我能夠編寫我今天討論的通用代碼,而不會使語言過於複雜或不再使用Go。我們希望這個設計是朝著這個目標邁出的一步,我們希望在我們學習的過程中繼續調整它,從我們的經驗和你的經驗,什麼有效,什麼無效。如果我們確實達到了這個目標,那麼我們就可以為Go的未來版本提供一些建議。

作者:Ian Lance Taylor

原文:https://blog.golang.org/why-generics

譯文:https://github.com/llgoer/go-generics





[admin ]

來源:OsChina
連結:https://www.oschina.net/news/108697/why-golang-generic
Go 語言為什麼需要泛型?已經有12次圍觀

http://coctec.com/news/soft/show-post-211382.html