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

相关文章
相关标签/搜索