golang拾遺:爲何咱們須要泛型

從golang誕生起是否應該添加泛型支持就是一個熱度不曾消減的議題。泛型的支持者們認爲沒有泛型的語言是不完整的,而泛型的反對者們則認爲接口足以取代泛型,增長泛型只會徒增語言的複雜度。雙方互不相讓,爭執不下,直到官方最終肯定泛型是go2的發展路線中的重中之重。html

今天咱們就來看看爲何咱們須要泛型,沒有泛型時咱們在作什麼,泛型會帶來哪些影響,泛型能拯救咱們嗎?java

本文索引

沒有泛型的世界

泛型最多見也是最簡單的需求就是建立一組操做相同或相似的算法,這些算法應該是和數據類型無關的,無論什麼數據類型只要符合要求就能夠操做。node

看起來很簡單,咱們只須要專一於算法自身的實現,而不用操心其餘細枝末節。然而現實是骨感的,想要實現類型無關算法在沒有泛型的世界裏倒是困難的,須要在許多條件中利弊取捨。python

下面咱們就來看看在沒有泛型的參與下咱們是如何處理數據的。linux

暴力窮舉

這是最簡單也是最容易想到的方法。c++

既然算法部分的代碼是幾乎相同的,那麼就copy幾遍,而後把數據類型的地方作個修改替換,這樣的工做甚至能夠用文本編輯器的代碼片斷+查找替換來快速實現。好比下面的c代碼:git

float a = logf(2.0f);
double b = log(2.0);

typedef struct {
    int *data;
    unsigned int max_size;
} IntQueue;

typedef struct {
    double *data;
    unsigned int max_size;
} DoubleQueue;

IntQueue* NewIntQueue(unsigned int size)
{
    IntQueue* q = (IntQueue*)malloc(sizeof(IntQueue));
    if (q == NULL) {
        return NULL;
    }
    q->max_size = size;
    q->data = (int*)malloc(size * sizeof(int));
    return q;
}

DoubleQueue* NewDoubleQueue(unsigned int size)
{
    DoubleQueue* q = (DoubleQueue*)malloc(sizeof(DoubleQueue));
    if (q == NULL) {
        return NULL;
    }
    q->max_size = size;
    q->data = (double*)malloc(size * sizeof(double));
    return q;
}

問題看上去解決了,除了修改和複查比較麻煩以外。作程序員的誰尚未cv過呢,然而這種方法缺點很明顯:程序員

  • 嚴重違反DRY(don't repeat yourself),數據結構的修改和擴展極其困難
  • 複製粘貼修改中可能會出現低級的人力錯誤,而且耗費精力
  • 最關鍵的一點,咱們不可能針對全部類型去寫出特定的算法,由於這些類型的數量少則5,6種,多則上不封頂。

固然,好處也不是沒有:github

  • 保證了類型安全,任何類型問題都能在編譯期暴露
  • 更靈活,對於某些特定類型咱們還能夠作出很是細緻的優化工做(好比對於bool類型咱們可使用unsigned int這個通常來講4字節大小的類型存放32個bool值,而不是用32個bool變量消耗32字節內存)

然而缺點1和缺點3給予的是致命打擊,所以一般咱們不會用這種方法實現通用算法和數據結構。(然而不幸的是golang中的math/rand就是這麼實現的)golang

依靠通用引用類型

其實方案1還能夠依靠宏來實現,linux內核就是這麼作的,不過宏這個機制不是每一個語言都有的,所以參考價值不是很高。

既然明確指出數據的類型不可行,那咱們還有其餘的辦法。好比立刻要介紹的使用通用類型引用數據。

通用的引用類型,表示它能夠引用其餘不一樣類型的數據而自身的數據類型不會改變,好比c中的void *

void *ptr = NULL;
ptr = (void*)"hello";
int a = 100;
ptr = (void*)&a;

c語言容許非函數指針的數據類型指針轉換爲void *,所以咱們能夠用它來囊括幾乎全部的數據(函數除外)。

因而Queue的代碼就會變成以下的畫風:

typedef struct {
    void *data;
    unsigned int max_size;
} Queue;

Queue* NewQueue(unsigned int size)
{
    Queue* q = (Queue*)malloc(sizeof(Queue));
    if (q == NULL) {
        return NULL;
    }
    q->max_size = size;
    q->data = // 這裏填什麼呢?
}

代碼寫了一半發現寫不下去了?放心,這不是你的問題。在c語言裏咱們不能建立void類型的變量,因此咱們不可能給data預先分配內存。

那麼退一步考慮,若是引入一個java那樣的相似void*Object類型,是否就能解決內存分配呢?答案是否認的,假設Object大小是8字節,若是咱們放一個一般只有一字節大小的bool進去就會有7字節的浪費,若是咱們放一個32字節的自定義類型,那麼很顯然一個Object的空間是遠遠不夠的。在c這樣的語言中咱們想要使用數據就須要知道該數據的類型,想要肯定類型就要先肯定它的內存佈局,而要能肯定內存佈局第一步就是要知道類型須要的內存空間大小。

遺憾的是通用引用類型幫咱們把具體的類型信息所有擦除了。

寫程序最重要的就是發散型的思惟,若是你看到這裏以爲本方案不行了的話你就太天真了。別的不說,java能用Object實現泛用容器,c也能夠。祕訣很簡單,既然咱們不能準確建立類型的實例,那不建立不就好了嘛。隊列原本就是負責存取數據的,建立這種工做外包給其餘代碼就好了:

typedef struct {
    unsigned int max_size;
    unsigned int current;
    void **data;
} Queue;

Queue* NewQueue(unsigned int size)
{
    Queue* q = (Queue*)malloc(sizeof(Queue));
    if (q == NULL) {
        return NULL;
    }
    q->max_size = size;
    q->size = 0;
    q->data = (void **)malloc(size*sizeof(void*));
}

bool QueuePush(Queue* q, void* value)
{
    if (q == NULL || value == NULL || q->current == q->max_size-1) {
        return false;
    }

    q->data[q->current++] = value;
    return true;
}

It works! 可是咱們須要隊列中的類型有特定操做呢?把操做抽象造成函數再傳遞給隊列的方法就好了,能夠參考c的qsort和bsearch:

#include <sdtlib.h>

void qsort(void *base, size_t nmemb, size_t size,
                  int (*compar)(const void *, const void *));

void *bsearch(const void *key, const void *base,
                     size_t nmemb, size_t size,
                     int (*compar)(const void *, const void *));

更廣泛的,你能夠用鏈表去實現隊列:

typedef struct node {
   int val;
   struct node *next;
} node_t;

void enqueue(node_t **head, int val) {
   node_t *new_node = malloc(sizeof(node_t));
   if (!new_node) return;

   new_node->val = val;
   new_node->next = *head;

   *head = new_node;
}

原理一樣是將建立具體的數據的任務外包,只不過鏈表額外增長了一層node的包裝罷了。

那麼這麼作的好處和壞處是什麼呢?

好處是咱們能夠遵照DRY原則了,同時還能專一於隊列自己的實現。

壞處那就有點多了:

  • 首先是類型擦除的同時沒有任何類型檢測的手段,所以類型安全無從保證,好比存進去的能夠是int,取出來的時候你能夠轉換成char*,程序不會給出任何警告,等你準備從這個char*裏取出某個位置上的字符的時候就會引起未定義行爲,從而出現許許多多奇形怪狀的bug
  • 只能存指針類型
  • 如何肯定隊列裏存儲數據的全部權?交給隊列管理會增長隊列實現的複雜性,不交給隊列管理就須要手動追蹤N個對象的生命週期,心智負擔很沉重,而且若是咱們是存入的局部變量的指針,那麼交給隊列管理就必定會致使free出現未定義行爲,從代碼層面咱們是幾乎不能區分一個指針是否是真的指向了堆上的內容的
  • 依舊不能避免書寫類型代碼,首先使用數據時要從void*轉換爲對應類型,其次咱們須要書寫如qsort例子裏那樣的幫助函數。

動態類型語言的特例

在真正進入本節的主題以前,我想先介紹下什麼是動態類型,什麼是靜態類型。

所謂靜態類型,就是在編譯期可以肯定的變量、表達式的數據類型,換而言之,編譯期若是就能肯定某個類型的內存佈局,那麼它就是靜態類型。舉個c語言的例子:

int a = 0;
const char *str = "hello generic";
double values[] = {1., 2., 3.};

上述代碼中intconst char *double[3]都是靜態類型,其中intconst char *(指針類型不受底層類型的影響,你們有着相同的大小)標準中都給出了類型所需的最小內存大小,而數組類型是帶有長度的,或者是在表達式和參數傳遞中退化(decay)爲指針類型,所以編譯器在編譯這些代碼的時候就能知道變量所需的內存大小,進而肯定了其在內存中的佈局。固然靜態類型其中還有許多細節,這裏暫時沒必要深究。

回過來看動態類型就很好理解了,編譯期間沒法肯定某個變量、表達式的具體類型,這種類型就是動態的,例以下面的python代碼:

name = 'apocelipes'
name = 12345

name到底是什麼類型的變量?不知道,由於name實際上能夠賦值任意的數據,咱們只能在運行時的某個點作類型檢測,而後斷言name是xxx類型的,然而過了這個時間點以後name還能夠賦值一個徹底不一樣類型的數據。

好了如今咱們回到正題,可能你已經猜到了,我要說的特例是什麼。沒錯,由於動態類型語言實際上不關心數據的具體類型是什麼,因此即便沒有泛型你也能夠寫出相似泛型的代碼,並且一般它們工做得很好:

class Queue:
    def __init__(self):
        self.data = []
    
    def push(self, value):
        self.data.append()
    
    def pop(self):
        self.data.pop()
    
    def take(self, index):
        return self.data[index]

咱們既能放字符串進Queue也能放整數和浮點數進去。然而這並不能稱之爲泛型,使用泛型除了由於能夠少寫重複的代碼,更重要的一點是能夠確保代碼的類型安全,看以下例子,咱們給Queue添加一個方法:

def transform(self):
    for i in range(len(self.data)):
        self.data[i] = self.data[i].upper()

咱們提供了一個方法,能夠將隊列中的字符串從小寫轉換爲大寫。問題發生了,咱們的隊列不只能夠接受字符串,它還能夠接受數字,這時候若是咱們調用transform方法就會發生運行時異常:AttributeError: 'int' object has no attribute 'upper'。那麼怎麼避免問題呢?添加運行時的類型檢測就能夠了,然而這樣作有兩個沒法繞開的弊端:

  • 寫出了類型相關的代碼,和咱們本意上想要實現類型無關的代碼結構相沖突
  • 限定了算法只能由幾種數據類型使用,但事實上有無限多的類型能夠實現upper方法,然而咱們不能在類型檢查裏一一列舉他們,從而致使了咱們的通用算法變爲了限定算法。

動靜結合

沒有泛型的世界實在是充滿了煎熬,不是在違反DRY原則的邊緣反覆試探,就是冒着類型安全的風險激流勇進。有什麼能脫離苦海的辦法嗎?

做爲一門靜態強類型語言,golang提供了一個不是太完美的答案——interface。

使用interface模擬泛型

interface能夠接受任何知足要求的類型的數據,而且具備運行時的類型檢查。雙保險很大程度上提高了代碼的安全性。

一個典型的例子就是標準庫裏的containers:

package list // import "container/list"

Package list implements a doubly linked list.

To iterate over a list (where l is a *List):

    for e := l.Front(); e != nil; e = e.Next() {
        // do something with e.Value
    }

type Element struct{ ... }
type List struct{ ... }
    func New() *List

type Element struct {

        // The value stored with this element.
        Value interface{}
        // Has unexported fields.
}
    Element is an element of a linked list.

func (e *Element) Next() *Element
func (e *Element) Prev() *Element

type List struct {
        // Has unexported fields.
}
    List represents a doubly linked list. The zero value for List is an empty
    list ready to use.

func New() *List
func (l *List) Back() *Element
func (l *List) Front() *Element
func (l *List) Init() *List
func (l *List) InsertAfter(v interface{}, mark *Element) *Element
func (l *List) InsertBefore(v interface{}, mark *Element) *Element
func (l *List) Len() int
func (l *List) MoveAfter(e, mark *Element)
func (l *List) MoveBefore(e, mark *Element)
func (l *List) MoveToBack(e *Element)
func (l *List) MoveToFront(e *Element)
func (l *List) PushBack(v interface{}) *Element
func (l *List) PushBackList(other *List)
...

這就是在上一大節中的方案2的類型安全強化版。接口的工做原理本文不會詳述。

但事情遠沒有結束,假設咱們要對一個數組實現indexOf的通用算法呢?你的第一反應大概是下面這段代碼:

func IndexOfInterface(arr []interface{}, value interface{}) int {
	for i, v := range arr {
		if v == value {
			return i
		}
	}

	return -1
}

這裏你會接觸到interface的第一個坑。

interface會進行嚴格的類型檢查

看看下面代碼的輸出,你能解釋爲何嗎?

func ExampleIndexOfInterface() {
    arr := []interface{}{uint(1),uint(2),uint(3),uint(4),uint(5)}
	fmt.Println(IndexOfInterface(arr, 5))
    fmt.Println(IndexOfInterface(arr, uint(5)))
    // Output:
    // -1
    // 4
}

會出現這種結果是由於interface的相等須要類型和值都相等,字面量5的值是int,因此沒有搜索到相等的值。

想要避免這種狀況也不難,建立一個Comparable接口便可:

type Comparator interface {
	Compare(v interface{}) bool
}

func IndexOfComparator(arr []Comparator, value Comparator) int {
	for i,v := range arr {
		if v.Compare(value) {
			return i
		}
	}
	return -1
}

這回咱們不會出錯了,由於字面量根本不能傳入函數,由於內置類型都沒實現Comparator接口。

內置類型何去何從

然而這是接口的第二個坑,咱們不得不爲內置類型建立包裝類和包裝方法。

假設咱們還想把前文的arr直接傳入IndexOfComparator,那一定獲得編譯器的抱怨:

cannot use arr (type []interface {}) as type []Comparator in argument to IndexOfComparator

爲了使用這個函數咱們不得不對代碼進行修改:

type MyUint uint

func (u MyUint) Compare(v interface{}) bool {
	value := v.(MyUint)
	return u == value
}

arr2 := []Comparator{MyUint(1),MyUint(2),MyUint(3),MyUint(4),MyUint(5)}
fmt.Println(IndexOfComparator(arr2, MyUint(5)))

咱們但願泛型能簡化代碼,但如今卻反其道而行之了。

性能陷阱

第三個,也是被人詬病最多的,是接口帶來的性能降低。

咱們對以下幾個函數作個簡單的性能測試:

func IndexOfByReflect(arr interface{}, value interface{}) int {
	arrValue := reflect.ValueOf(arr)
	length := arrValue.Len()
	for i := 0; i < length; i++ {
		if arrValue.Index(i).Interface() == value {
			return i
		}
	}
	return -1
}

func IndexOfInterface(arr []interface{}, value interface{}) int {
	for i, v := range arr {
		if v == value {
			return i
		}
	}

	return -1
}

func IndexOfInterfacePacking(value interface{}, arr ...interface{}) int {
	for i, v := range arr {
		if v == value {
			return i
		}
	}

	return -1
}

這是測試代碼(golang1.15.2):

const ArrLength = 500
var _arr []interface{}
var _uintArr []uint

func init() {
	_arr = make([]interface{}, ArrLength)
	_uintArr = make([]uint, ArrLength)
	for i := 0; i < ArrLength - 1; i++ {
		_uintArr[i] = uint(rand.Int() % 10 + 2)
		_arr[i] = _uintArr[i]
	}
	_arr[ArrLength - 1] = uint(1)
	_uintArr[ArrLength - 1] = uint(1)
}

func BenchmarkIndexOfInterface(b *testing.B) {
	for i := 0; i < b.N; i++ {
		IndexOfInterface(_arr, uint(1))
	}
}

func BenchmarkIndexOfInterfacePacking(b *testing.B) {
	for i := 0; i < b.N; i++ {
		IndexOfInterfacePacking(uint(1), _arr...)
	}
}

func indexOfUint(arr []uint, value uint) int {
	for i,v := range arr {
		if v == value {
			return i
		}
	}

	return -1
}

func BenchmarkIndexOfUint(b *testing.B) {
	for i := 0; i < b.N; i++ {
		indexOfUint(_uintArr, uint(1))
	}
}

func BenchmarkIndexOfByReflectInterface(b *testing.B) {
	for i := 0; i < b.N; i++ {
		IndexOfByReflect(_arr, uint(1))
	}
}

func BenchmarkIndexOfByReflectUint(b *testing.B) {
	for i := 0; i < b.N; i++ {
		IndexOfByReflect(_uintArr, uint(1))
	}
}

咱們吃驚地發現,直接使用interface比原生類型慢了10倍,若是使用反射並接收原生將會慢整整100倍!

另外一個使用接口的例子是比較slice是否相等,咱們沒有辦法直接進行比較,須要藉助輔助手段,在我之前的這篇博客有詳細的講解。性能問題一樣很顯眼。

複合類型的迷思

interface{}是接口,而[]interface{}只是一個普通的slice。複合類型中的接口是不存在協變的。因此下面的代碼是有問題的:

func work(arr []interface{}) {}

ss := []string{"hello", "golang"}
work(ss)

相似的問題其實在前文裏已經出現過了。這致使咱們沒法用interface統一處理slice,由於interface{}並非slice,slice的操做沒法對interface使用。

爲了解決這個問題,golang的sort包給出了一個頗爲曲折的方案:

sort爲了能處理slice,不得不包裝了常見的基本類型的slice,爲了兼容自定義類型包裏提供了Interface,須要你本身對自定義類型的slice進行包裝。

這實現就像是千層餅,一環套一環,即便內部的quicksort寫得再漂亮性能也是要打很多折扣的。

最後也是最重要的

對於獲取接口類型變量的值,咱們須要類型斷言,然而類型斷言是運行時進行的:

var i interface{}
i = 1
s := i.(string)

這會致使panic。若是不想panic就須要第二個變量去獲取是否類型斷言成功:s, ok := i.(string)

然而真正的泛型是在編譯期就能發現這類錯誤的,而不是等到程序運行得如火如荼時忽然由於panic退出。

泛型帶來的影響,以及拯救

完全從沒有泛型的泥沼中解放

一樣是上面的IndexOf的例子,有了泛型咱們能夠簡單寫爲:

package main

import (
	"fmt"
)

func IndexOf[T comparable](arr []T, value T) int {
    for i, v := range arr {
        if v == value {
            return i
        }
    }

    return -1
}

func main() {
	q := []uint{1,2,3,4,5}
	fmt.Println(IndexOf(q, 5))
}

comparable是go2提供的內置設施,表明全部可比較類型,你能夠在這裏運行上面的測試代碼。

泛型函數會自動作類型推導,字面量能夠用於初始化uint類型,因此函數正常運行。

代碼簡單幹淨,並且沒有性能問題(至少官方承諾泛型的絕大部分工做會在編譯期完成)。

再舉個slice判斷相等的例子:

func isEqual[T comparable](a,b []T) bool {
    if len(a) != len(b) {
        return false;
    }

    for i := range a {
        if a[i] != b[i] {
            return false
        }
    }

    return true
}

除了大幅簡化代碼以外,泛型還將給咱們帶來以下改變:

  • 真正的類型安全,像isEqual([]int, []string)這樣的代碼在編譯時就會被發現並被咱們修正
  • 雖然泛型也不支持協變,但slice等複合類型只要符合參數推導的規則就能被使用,限制更少
  • 沒有了接口和反射,性能自沒必要說,編譯期就能肯定變量類型的話還能夠增長代碼被優化的機會

能夠說泛型是真正救人於水火。這也是泛型最終能進入go2提案的緣由。

泛型的代價

最後說了這麼多泛型的必要性,也該是時候談談泛型之暗了。

其實目前golang的泛型還在提案階段,雖然已經有了預覽版,但從此變數仍是不少,因此這裏只能針對草案簡單說說兩方面的問題。

第一個仍是類型系統的割裂問題,golang使用的泛型系統比typwscript更加嚴格,any約束的類型甚至沒法使用賦值運算以外的其餘內置運算符。所以想要類型能比較大小的時候一定建立自定義類型和自定義的類型約束,內置類型是沒法添加方法的,因此須要包裝類型。

解決這個問題不難,一條路是golang官方提供內置類型的包裝類型,而且實現java那樣的自動拆裝箱。另外一條路是支持相似rust的運算符重載,例如add表明+mul表明*,這樣只須要將內置運算符進行簡單的映射便可兼容內置類型,同時又能知足自定義類型。不過鑑於golang官方一直對運算符重載持否認態度,方案2也只能想一想了。

另外一個黑暗面就是泛型如何實現,現有的主流方案不是類型擦除(java,typescript),就是將泛型代碼看做模板進行實例化代碼生成(c++,rust),另外還有個另類的c#在運行時進行實例化。

目前社區仍然偏向於模板替換,採用類型字典的方案暫時沒法處理泛型struct,實現也很是複雜,因此反對聲很多。若是最終敲定了模板方案,那麼golang要面對的新問題就是連接時間過長和代碼膨脹了。一份泛型代碼能夠生產數份相同的實例,這些實例須要在連接階段被連接器剔除,這會致使連接時間爆增。代碼膨脹是老生常談的問題了,更大的二進制文件會致使啓動更慢,代碼裏的雜音更多致使cpu緩存利用率的降低。

連接時間的優化社區有人提議能夠在編譯期標記各個實例提早去重,由於golang各個代碼直接是有清晰的聯繫的,不像c++文件之間單獨編譯最終須要在連接階段統一處理。代碼膨脹目前沒有辦法,並且代碼膨脹會不會對性能產生影響,影響多大可否限定在可接受範圍都仍是未知數。

但無論怎麼說,咱們都須要泛型,由於帶來的遠比失去的要多。

參考

https://colobu.com/2016/04/14/Golang-Generics-Proposal/

https://go.googlesource.com/proposal/+/refs/heads/master/design/go2draft-type-parameters.md

https://go.googlesource.com/proposal/+/refs/heads/master/design/go2draft-contracts.md

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

https://blog.golang.org/generics-next-step

https://github.com/golang/proposal/blob/master/design/generics-implementation-gcshape.md

https://stackoverflow.com/questions/4184954/are-there-standard-queue-implementations-for-c

相關文章
相關標籤/搜索