当前位置: 首页 > 科技观察

简单易懂的 Go 泛型使用和实现原理介绍

时间:2023-03-21 18:54:09 科技观察

Go泛型的使用和实现原理简单易懂的介绍简要总结。首先,让我们看看泛型解决的核心问题。问题假设我们要实现一个简单的树数据结构。每个节点都有一个值。在Go1.18之前,实现这种结构的典型方式如下:typeNodestruct{valueinterface{}}这在大多数情况下效果很好,但也有一些缺点。首先,interface{}可以是任何东西。如果我们想限制值可能持有的类型,比如整数和浮点数,我们只能在运行时检查这个限制。func(nNode)IsValid()bool{switchn.value.(type){caseint,float32,float64:returntruedefault:returnfalse}}无法在编译时限制类型,比如类型判断上面这是许多Go库中的常见做法。其次,在Node中操作值是繁琐且容易出错的。对一个值做任何事情都涉及某种类型的断言,即使您可以安全地假设该值包含一个int值。number,ok:=node.value.(int)if!ok{//...}double:=number*2这些只是使用interface{}带来的一些不便,不提供类型安全,可能导致Unrecoverable运行时错误。解决方案我们不接受任意数据类型或具体类型,而是定义一个名为T的占位符类型作为值的类型。请注意,此代码尚未编译。typeNode[T]struct{valueT}首先需要声明泛型类型T,用在结构或函数名后的方括号中。T可以是任何类型,并且只有在实例化具有显式类型的Node时才会将T推导为该类型。n:=Node[int]{value:5,}通用Node被实例化为Node[int](整数节点),因此T是一个int。类型约束在上面的实现中,T的声明缺少一个必要的信息:类型约束。类型约束用于进一步限制可以是T的可能类型。Go本身提供了一些预定义的类型约束,但也可以使用自定义类型约束。typeNode[Tany]struct{valueT}任意类型(any)约束允许T实际上是任意类型。如果需要比较节点值,有一个可比较的类型约束,满足这个预定义约束的类型可以使用==进行比较。typeNode[Tcomparable]struct{valueT}任何类型都可以用作类型约束。Go1.18引入了一种新的接口语法,可以嵌入其他数据类型。类型数值接口{int|浮动32|float64}这意味着一个接口不仅可以定义一组方法,还可以定义一组类型。使用Numeric接口作为类型约束意味着该值可以是整数或浮点数。typeNode[TNumeric]struct{valueT}重获类型安全泛型类型参数相对于使用interface{}的巨大优势在于T的最终类型是在编译时推导的。为T定义一个完全删除运行时检查的类型约束。如果用作T的类型不满足类型约束,则代码将无法编译。编写泛型代码时,您可以像已经知道T的最终类型一样编写代码。func(nNode[T])Value()T{returnn.value}上面的函数返回的是n.Value,它的类型是T。因此,返回值为T,如果T是整数,则返回类型已知为int。因此,返回值可以直接作为整数使用,无需任何类型断言。n:=Node[int]{value:5,}double:=n.Value()*2在编译时恢复类型安全让Go代码更可靠,更不容易出错。泛型使用场景IanLanceTaylor的WhenToUseGenerics中列出了泛型的典型使用场景,归结为三种主要情况:使用内置的容器类型,如切片、映射和通道来实现通用数据结构,例如如链表或树编写一个函数,其实现对于许多类型都是相同的,例如排序函数通常,当您不想对您正在操作的值的内容做出假设时,请考虑使用泛型。我们示例中的Node不太关心它持有的值。当不同的类型有不同的实现时,泛型不是一个好的选择。另外,不要将Read(rio.Reader)之类的接口函数签名更改为Read[Tio.Reader](rT)之类的通用签名。性能要了解泛型的性能及其在Go中的实现,首先要了解泛型的两种最常见的实现方式。这是对各种属性的深入研究以及围绕它们的讨论的简要介绍。你可能不需要太在意Go中泛型的性能。虚拟方法表在编译器中实现泛型的一种方法是使用虚拟方法表。通用函数被修改为只接受指针作为参数。然后将这些值分配到堆上,并将指向这些值的指针传递给泛型函数。这样做是因为无论指针指向什么类型,它看起来总是一样的。如果值是对象,泛型函数需要调用那些对象上的方法,它就不能再这样做了。该函数只有一个指向对象的指针,并且不知道它们的方法在哪里。因此,它需要一个可以查询方法内存地址的表:VirtualMethodTable。这种所谓的动态调度已经被Go和Java等语言的接口所使用。VirtualMethodTable不仅可以用来实现泛型,还可以用来实现其他类型的多态。但是,派生这些指针并调用虚函数比直接调用函数要慢,并且使用虚方法表会阻止编译器进行优化。一种更简单的单态化方法是单态化(Monomorphization),编译器为每个被调用的数据类型生成泛型函数的副本。funcmax[TNumeric](a,bT)T{//...}larger:=max(3,5)由于上面显示的max函数是用两个整数调用的,编译器会生成max的副本状态化时为int。funcmaxInt(a,bint)int{//...}larger:=maxInt(3,5)最大的好处是Monomorphization带来的运行时性能明显优于使用VirtualMethodTable。直接方法调用不仅效率更高,而且适用于整个编译器的优化链。然而,这是以较长的编译时间为代价的,并且为所有相关类型生成通用函数的副本非常耗时。Go的实现这两种方法中哪一种最适合Go?快速编译很重要,但运行时性能也很重要。为了满足这些要求,Go团队决定在实现泛型时混合使用这两种方法。Go使用单态化,但试图减少需要制作的函数副本的数量。它不是为每个类型创建一个副本,而是在内存中为每个布局创建一个副本:int、float64、Node和其他所谓的“值类型”在内存中看起来并不相同,因此泛型函数将为所有这些类型。与值类型相反,指针和接口在内存中始终具有相同的布局。编译器将为指针和接口调用生成通用函数的副本。就像虚方法表一样,泛型函数接收指针,所以需要一个表来动态查找方法地址。Go实现中的字典具有与虚方法表相同的性能特征。结论这种混合方法的好处是您可以在使用值类型的调用中获得Monomorphization的性能优势,而只需在使用指针或接口的调用中支付VirtualMethodTable的成本。在性能讨论中经常被忽视的是,所有这些收益和成本都只与函数调用有关。通常,大部分执行时间都花在函数内部。调用方法的性能开销可能不会成为性能瓶颈。即使是,也需要先考虑优化函数实现,再考虑调用开销。