云原生系列Go语言篇-泛型Part 1

本文涉及的产品
服务治理 MSE Sentinel/OpenSergo,Agent数量 不受限
注册配置 MSE Nacos/ZooKeeper,118元/月
应用实时监控服务-可观测链路OpenTelemetry版,每月50GB免费额度
简介: “Don’t Repeat Yourself”是常见的软件工程建议。与其重新创建一个数据结构或函数,不如重用它,因为对重复的代码保持更改同步非常困难。在像 Go 这样的强类型语言中,每个函数参数及每个结构体字段的类型必须在编译时确定。

“Don’t Repeat Yourself”是常见的软件工程建议。与其重新创建一个数据结构或函数,不如重用它,因为对重复的代码保持更改同步非常困难。在像 Go 这样的强类型语言中,每个函数参数及每个结构体字段的类型必须在编译时确定。这种严格性使编译器能够帮助验证代码是否正确,但有时会希望重用不同类型的函数的逻辑或在重用不同类型的结构体字段。Go 通过类型参数提供了这个功能,俗称为泛型。本章中,读者将了解为什么需要泛型,Go 的泛型实现可以做什么,不能做什么,以及如何正确使用泛型。

泛型减少重复代码并增强类型安全

Go 是一种静态类型语言,这意味着在编译代码时会检查变量和参数的类型。内置类型(字典、切片、通道)和函数(如 lencapmake)可接受并返回不同具体类型的值,但直到 Go 1.18,用户自定义的 Go 类型和函数都无法做到这一点。

如果读者熟悉动态类型语言,这类语言中类型在代码运行时才会进行确定,你可能不理解为什么要用泛型,以及它有什么用。如果将其视为“类型参数”,可能会有助于理解。截至目前,我们按函数指定的参数来赋值调用。在以下代码中,我们指定 Min 接受两个 float64 类型的参数并返回一个float64

func Min(v1, v2 float64) float64 {
    if v1 < v2 {
        return v1
    }
    return v2
}

类似地,我们按声明结构体时所指定的字段类型创建结构体。这里Node中有一个类型为int的字段和一个类型为*Node的字段。

type Node struct {
    val int
    next *Node
}

但有些情况下编写函数或结构体时,在使用之前不指定参数或字段的具体类型会很有用。

泛型类型的场景很容易理解。在前面,我们学习了一个int类型的二叉树。如果需要一个用于字符串或 float64 的二叉树,并保证类型安全,有几种选择。第一种是为每种类型编写一个自定义树,但是这么多重复的代码既冗长又容易出错。

在没有泛型的情况下,避免重复代码的唯一方法是修改树实现,使用接口来指定如何排序。接口类似这样:

type Orderable interface {
    // Order returns:
    // a value < 0 when the Orderable is less than the supplied value,
    // a value > 0 when the Orderable is greater than the supplied value,
    // and 0 when the two values are equal.
    Order(any) int
}

有了Orderable,我们就可以修改Tree的实现来提供支持:

type Tree struct {
    val         Orderable
    left, right *Tree
}
func (t *Tree) Insert(val Orderable) *Tree {
    if t == nil {
        return &Tree{val: val}
    }
    switch comp := val.Order(t.val); {
    case comp < 0:
        t.left = t.left.Insert(val)
    case comp > 0:
        t.right = t.right.Insert(val)
    }
    return t
}

对于OrderableInt类型,可以插入int值:

type OrderableInt int
func (oi OrderableInt) Order(val any) int {
    return int(oi - val.(OrderableInt))
}
func main() {
    var it *Tree
    it = it.Insert(OrderableInt(5))
    it = it.Insert(OrderableInt(3))
    // etc...
}

这段代码虽可正常运行,但无法让编译器验证插入数据结构的相同值。若有OrderableString类型:

type OrderableString string
func (os OrderableString) Order(val any) int {
    return strings.Compare(string(os), val.(string))
}

以下代码可正常编译:

var it *Tree
it = it.Insert(OrderableInt(5))
it = it.Insert(OrderableString("nope"))

Order函数使用any表示传入的值。这会使Go的一个主要优势产生短路,即编译时类型安全检查。在编译代码深度对已包含OrderableIntTree插入OrderableString时,编译器接受了该代码。但在运行时程序会panic:

panic: interface conversion: interface {} is main.OrderableInt, not string

可以测试第8章的GitHub代码库sample_code/non_generic_tree目录中的这段代码。

现在,由于Go引入了泛型,可以一次性为多个类型实现数据结构并在编译时检测出不兼容的数据。很快就会讲到如何正确使用。

虽然没有泛型的数据结构不太方便,但真正的局限在于函数编写。Go标准库中的多个实现是因为最初未包含泛型而做出的决策。例如,Go中没有编写多个处理不同数值类型的函数,而是使用带有足够大范围以精确表示几乎每种其他数值类型的float64参数来实现诸如math.Maxmath.Minmath.Mod这样的函数。 (不影响具有大于253 - 1 或小于-253 - 1的intint64uint。)

还有有一功能没有泛型就无法实现。不能创建一个由接口指定变量的新实例,也不能指定两个具有相同接口类型的参数也具有同样的实体类型。没有泛型,就无法不借助反向就编写一个处理所有类型切片的函数,而那又会牺牲一些性能并带来编译时类型安全问题(sort.Slice就是如此)。也就是说在Go引入泛型之前,处理切片的函数(如map, reduce, and filter) 需要针对不同类型切片进行反复实现。虽然简单的算法很容易拷贝,但很多(也许不是大多数)软件工程师会觉得因为编译器无法智能地自动实现出现重复代码很让人抓狂。

在Go语言中引入泛型

自Go首发以来,一直有呼声要求将泛型添加到该语言中。Go的开发负责人Russ Cox于2009年写了一篇博客文章,解释了为什么最初未包含泛型。Go着重快速编译器、可读性代码和良好的执行时间,而他们所了解的泛型实现都无法同时满足这三个条件。经过十年的研究,Go团队已经找到了一种可行的方法,详见类型参数提案

可以通过栈来了解Go中的泛型是如何运作的。如果读者没有计算机科学背景,栈是一种数据类型,其中的值以后进先出(LIFO)的顺序添加和删除。这就像一堆等待清洗的盘子;一开始的放在底部,只有先处理后添加的那些盘子才能够拿到它们。我们来看如何使用泛型创建栈:

type Stack[T any] struct {
    vals []T
}
func (s *Stack[T]) Push(val T) {
    s.vals = append(s.vals, val)
}
func (s *Stack[T]) Pop() (T, bool) {
    if len(s.vals) == 0 {
        var zero T
        return zero, false
    }
    top := s.vals[len(s.vals)-1]
    s.vals = s.vals[:len(s.vals)-1]
    return top, true
}

有几个需要注意的地方。首先,类型声明后使用[T any]。类型参数放在了方括号内。书写方式与变量参数相同,首先是类型名称,然后是类型约束。可为类型参数选择任意名称,但通常习惯使用大写字母。Go使用接口来指定可以使用哪些类型。如可使用任何类型,使用全局标识符any来指定。在Stack声明内部,我们声明vals的类型为[]T

接下来,看一下方法声明。就像我们在vals声明中使用了T,此处也是一样的。在接收器部分,我们还使用Stack[T]Stack来引用类型。

最后,泛型使零值处理产生了变化。在Pop中,我们不能只返回nil,因为对于值类型(如int),这不是一个有效值。获取泛型的零值的最简单方法是使用var声明一个变量并返回,因为根据定义,如果未赋值,var会将其变量初始化为零值。

使用泛型类型与使用非泛型类型非常相似:

func main() {
    var intStack Stack[int]
    intStack.Push(10)
    intStack.Push(20)
    intStack.Push(30)
    v, ok := intStack.Pop()
    fmt.Println(v, ok)
}

唯一的不同是在声明变量时对Stack指定了希望包含的类型,本例中为int。如尝试将字符串压入栈,编译器会捕获到。添加如下行:

intStack.Push("nope")

会得到编译错误:

cannot use "nope" (untyped string constant) as int value
  in argument to intStack.Push

可在The Go Playground中测试我们的泛型栈可查看第8章的GitHub代码库sample_code/stack目录中的代码。

下面对该栈添加一个是否包含某值的方法:

func (s Stack[T]) Contains(val T) bool {
    for _, v := range s.vals {
        if v == val {
            return true
        }
    }
    return false
}

可惜无法编译。报错如下:

invalid operation: v == val (type parameter T is not comparable with ==)

就像interface{}没表明什么,any也一样。只能存储any类型的值和提取。需要对其它类型才能使用==。因几乎所有Go类型都可以使用==!=进行比较,在全局代码块中新定义了一个名为comparable的接口。可comparable修改的Stack定义:

type Stack[T comparable] struct {
    vals []T
}

然后就可以使用这个新方法了:

func main() {
    var s Stack[int]
    s.Push(10)
    s.Push(20)
    s.Push(30)
    fmt.Println(s.Contains(10))
    fmt.Println(s.Contains(5))
}

输出的结果为:

true
false

可测试第8章的GitHub代码库sample_code/comparable_stack目录中我们所更新的栈

稍后我们会学习如何创建泛型二叉树。在此之前,先讲解一些概念:泛型函数、接口如何使用泛型以及类型名。

泛型函数抽象算法

我们也可以编写函数。前面提到没有泛型会很难编写适用所有类型的映射、归约(reduce)和过滤实现。泛型使其变得简单。以下是类型参数提案中的一些实现:

// Map turns a []T1 to a []T2 using a mapping function.
// This function has two type parameters, T1 and T2.
// This works with slices of any type.
func Map[T1, T2 any](s []T1, f func(T1) T2) []T2 {
    r := make([]T2, len(s))
    for i, v := range s {
        r[i] = f(v)
    }
    return r
}
// Reduce reduces a []T1 to a single value using a reduction function.
func Reduce[T1, T2 any](s []T1, initializer T2, f func(T2, T1) T2) T2 {
    r := initializer
    for _, v := range s {
        r = f(r, v)
    }
    return r
}
// Filter filters values from a slice using a filter function.
// It returns a new slice with only the elements of s
// for which f returned true.
func Filter[T any](s []T, f func(T) bool) []T {
    var r []T
    for _, v := range s {
        if f(v) {
            r = append(r, v)
        }
    }
    return r
}

函数将类型参数放在函数名和变量参数之间。MapReduce有两个类型参数,都是any类型,而Filter为一个参数。运行如下代码时:

words := []string{"One", "Potato", "Two", "Potato"}
filtered := Filter(words, func(s string) bool {
    return s != "Potato"
})
fmt.Println(filtered)
lengths := Map(filtered, func(s string) int {
    return len(s)
})
fmt.Println(lengths)
sum := Reduce(lengths, 0, func(acc int, val int) int {
    return acc + val
})
fmt.Println(sum)

会得到如下输出:

[One Two]
[3 3]
6

读者可自行使用Go Playground第8章的GitHub代码库sample_code/map_filter_reduce目录中的代码进行测试。

泛型和接口

可以使用任意接口来进行类型约束,不只是有anycomparable。例如希望创建一个存储任意实现了fmt.Stringer的同类型两个值的类型。泛型使得我们可以在编译时进行这一强制:

type Pair[T fmt.Stringer] struct {
    Val1 T
    Val2 T
}

也可以创建带类型参数的接口。例如,下面有一个包含指定类型值比较方法并返回float64的接口。还内嵌了fmt.Stringer

type Differ[T any] interface {
    fmt.Stringer
    Diff(T) float64
}

我们会使用这两个类型创建对比函数。该函数接口两个包含Differ类型字段的Pair实例,返回带更接近值的Pair

func FindCloser[T Differ[T]](pair1, pair2 Pair[T]) Pair[T] {
    d1 := pair1.Val1.Diff(pair1.Val2)
    d2 := pair2.Val1.Diff(pair2.Val2)
    if d1 < d2 {
        return pair1
    }
    return pair2
}

FindCloser接收包含实现了Differ接口的字段的Pair实例。Pair要求两个字段的类型相同,并且该类型实现fmt.Stringer接口,该函数要求更高。如果Pair实例中的字段未实现Differ,编译器会不允许使用带FindCloserPair实例。

下面定义几个实现Differ接口的类型:

type Point2D struct {
    X, Y int
}
func (p2 Point2D) String() string {
    return fmt.Sprintf("{%d,%d}", p2.X, p2.Y)
}
func (p2 Point2D) Diff(from Point2D) float64 {
    x := p2.X - from.X
    y := p2.Y - from.Y
    return math.Sqrt(float64(x*x) + float64(y*y))
}
type Point3D struct {
    X, Y, Z int
}
func (p3 Point3D) String() string {
    return fmt.Sprintf("{%d,%d,%d}", p3.X, p3.Y, p3.Z)
}
func (p3 Point3D) Diff(from Point3D) float64 {
    x := p3.X - from.X
    y := p3.Y - from.Y
    z := p3.Z - from.Z
    return math.Sqrt(float64(x*x) + float64(y*y) + float64(z*z))
}

该代码的使用如下:

func main() {
    pair2Da := Pair[Point2D]{Point2D{1, 1}, Point2D{5, 5}}
    pair2Db := Pair[Point2D]{Point2D{10, 10}, Point2D{15, 5}}
    closer := FindCloser(pair2Da, pair2Db)
    fmt.Println(closer)
    pair3Da := Pair[Point3D]{Point3D{1, 1, 10}, Point3D{5, 5, 0}}
    pair3Db := Pair[Point3D]{Point3D{10, 10, 10}, Point3D{11, 5, 0}}
    closer2 := FindCloser(pair3Da, pair3Db)
    fmt.Println(closer2)
}

可在The Go Playground中运行或查看第8章的GitHub代码库sample_code/generic_interface目录中的代码。

使用类型名指定运算符

泛型还需要体现另外一点:运算符。divAndRemainder函数可正常操作int,而应用于其它类型则需要进行类型转换,并且uint可存储的值远大于int。如果要为divAndRemainder编写一个泛型版本,需要一种方式来指定可使用/%。Go泛型通过类型元素来实现,由接口内的一种或多种类型名指定:

type Integer interface {
    int | int8 | int16 | int32 | int64 |
        uint | uint8 | uint16 | uint32 | uint64 | uintptr
}

使用内嵌实现组合一节中,我们学习过嵌套接口表明所包含的接口的方法接包括内嵌接口的方法。类型元素指定类型参数可赋哪些类型,以及支持哪些运算符。通过|来分隔具体类型。允许的运算符为对所有列出类型有效的那些。模运算符(%) 仅对整型有效,所有我们列举了所有的整型。(可以不加byterune,因为它们分别是uint8int32的类型别名。)

注意带类型元素的接口仅对类型约束有效。将它们用作变量、字段、返回值或参数类似会报编译时错误。

现在可以编写divAndRemainder的泛型版本,通过uint内置类型使用该函数(或其它Integer中所列的类型):

func divAndRemainder[T Integer](num, denom T) (T, T, error) {
    if denom == 0 {
        return 0, 0, errors.New("cannot divide by zero")
    }
    return num / denom, num % denom, nil
}
func main() {
    var a uint = 18_446_744_073_709_551_615
    var b uint = 9_223_372_036_854_775_808
    fmt.Println(divAndRemainder(a, b))
}

默认,类型名完全匹配。如对divAndRemainder使用底层为Integer所列类型的自定义类型,会出现错误。以下代码:

type MyInt int
var myA MyInt = 10
var myB MyInt = 20
fmt.Println(divAndRemainder(myA, myB))

会报如下错误:

MyInt does not satisfy Integer (possibly missing ~ for int in Integer)

错误文本提示了如何解决这一问题。如果希望类型名对那些以这些类型为底层类型的类型也有效,在类型名前加~。那么我们的Integer定义就变成了:

type Integer interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 |
        ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}

可在The Go Playground第8章的GitHub代码库sample_code/type_terms目录下查看divAndRemainder的泛型版本。

类型名让我们可以定义用于编写泛型比较函数的类型:

type Ordered interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 |
        ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
        ~float32 | ~float64 |
        ~string
}

Ordered接口列举了所有支持==, !=, <, >, <=>=运算符的类型。因为指定一种可进行排序变量的方式非常有用,所以在Go 1.21的cmp中定义了这个Ordered接口。该包还定义了两个比较函数。Compare函数根据第一个参数是否小于、等于或大于第二个参数返回-1, 0或1,而Less函数在第一个参数小于第二个参数时返回true

将同时具有类型元素和方法元素的接口用作类型参数完全合法。例如,可以指定一种类型的底层类型必须为int并且具备String() string方法:

type PrintableInt interface {
    ~int
    String() string
}

注意Go会允许我们声明其实无法实例化的类型参数接口。如果在PrintableInt中把~int换成了int,就不会有满足的有效类型,因为int不带方法。这样不好,但编译器会进行补救。如果声明了带这种类型参数的类型或函数,企图使用时会导致编译错误。假设声明了这些类型:

type ImpossiblePrintableInt interface {
    int
    String() string
}
type ImpossibleStruct[T ImpossiblePrintableInt] struct {
    val T
}
type MyInt int
func (mi MyInt) String() string {
    return fmt.Sprint(mi)
}

虽然无法实例化ImpossibleStruct,编译器对这些声明不会报错。不过在使用ImpossibleStruct时,编译器就会报错了。以下代码:

s := ImpossibleStruct[int]{10}
s2 := ImpossibleStruct[MyInt]{10}

会报编译时错误:

int does not implement ImpossiblePrintableInt (missing String method)
MyInt does not implement ImpossiblePrintableInt (possibly missing ~ for
int in constraint ImpossiblePrintableInt)

可在The Go Playground第8章的GitHub代码库sample_code/impossible目录下测试这段代码。

除了内置的原生类型外,类型名也可以是切片、字典、数组、通道、结构体甚至函数。它最大的用处是用于保证类型参数具有指定底层类型或一到多个方法。

本文来自正在规划的Go语言&云原生自我提升系列,欢迎关注后续文章。

相关文章
|
3月前
|
运维 监控 算法
监控局域网其他电脑:Go 语言迪杰斯特拉算法的高效应用
在信息化时代,监控局域网成为网络管理与安全防护的关键需求。本文探讨了迪杰斯特拉(Dijkstra)算法在监控局域网中的应用,通过计算最短路径优化数据传输和故障检测。文中提供了使用Go语言实现的代码例程,展示了如何高效地进行网络监控,确保局域网的稳定运行和数据安全。迪杰斯特拉算法能减少传输延迟和带宽消耗,及时发现并处理网络故障,适用于复杂网络环境下的管理和维护。
|
3月前
|
编译器 Go
揭秘 Go 语言中空结构体的强大用法
Go 语言中的空结构体 `struct{}` 不包含任何字段,不占用内存空间。它在实际编程中有多种典型用法:1) 结合 map 实现集合(set)类型;2) 与 channel 搭配用于信号通知;3) 申请超大容量的 Slice 和 Array 以节省内存;4) 作为接口实现时明确表示不关注值。此外,需要注意的是,空结构体作为字段时可能会因内存对齐原因占用额外空间。建议将空结构体放在外层结构体的第一个字段以优化内存使用。
|
3月前
|
存储 Go
Go 语言入门指南:切片
Golang中的切片(Slice)是基于数组的动态序列,支持变长操作。它由指针、长度和容量三部分组成,底层引用一个连续的数组片段。切片提供灵活的增减元素功能,语法形式为`[]T`,其中T为元素类型。相比固定长度的数组,切片更常用,允许动态调整大小,并且多个切片可以共享同一底层数组。通过内置的`make`函数可创建指定长度和容量的切片。需要注意的是,切片不能直接比较,只能与`nil`比较,且空切片的长度为0。
Go 语言入门指南:切片
|
3月前
|
开发框架 前端开发 Go
eino — 基于go语言的大模型应用开发框架(二)
本文介绍了如何使用Eino框架实现一个基本的LLM(大语言模型)应用。Eino中的`ChatModel`接口提供了与不同大模型服务(如OpenAI、Ollama等)交互的统一方式,支持生成完整响应、流式响应和绑定工具等功能。`Generate`方法用于生成完整的模型响应,`Stream`方法以流式方式返回结果,`BindTools`方法为模型绑定工具。此外,还介绍了通过`Option`模式配置模型参数及模板功能,支持基于前端和用户自定义的角色及Prompt。目前主要聚焦于`ChatModel`的`Generate`方法,后续将继续深入学习。
551 7
|
3月前
|
存储 缓存 监控
企业监控软件中 Go 语言哈希表算法的应用研究与分析
在数字化时代,企业监控软件对企业的稳定运营至关重要。哈希表(散列表)作为高效的数据结构,广泛应用于企业监控中,如设备状态管理、数据分类和缓存机制。Go 语言中的 map 实现了哈希表,能快速处理海量监控数据,确保实时准确反映设备状态,提升系统性能,助力企业实现智能化管理。
54 3
|
3月前
|
存储 开发框架 Devops
eino — 基于go语言的大模型应用开发框架(一)
Eino 是一个受开源社区优秀LLM应用开发框架(如LangChain和LlamaIndex)启发的Go语言框架,强调简洁性、可扩展性和可靠性。它提供了易于复用的组件、强大的编排框架、简洁明了的API、最佳实践集合及实用的DevOps工具,支持快速构建和部署LLM应用。Eino不仅兼容多种模型库(如OpenAI、Ollama、Ark),还提供详细的官方文档和活跃的社区支持,便于开发者上手使用。
747 8
|
3月前
|
存储 缓存 安全
Go 语言中的 Sync.Map 详解:并发安全的 Map 实现
`sync.Map` 是 Go 语言中用于并发安全操作的 Map 实现,适用于读多写少的场景。它通过两个底层 Map(`read` 和 `dirty`)实现读写分离,提供高效的读性能。主要方法包括 `Store`、`Load`、`Delete` 等。在大量写入时性能可能下降,需谨慎选择使用场景。
|
3月前
|
SQL 安全 Java
阿里双十一背后的Go语言实践:百万QPS网关的设计与实现
解析阿里核心网关如何利用Go协程池、RingBuffer、零拷贝技术支撑亿级流量。 重点分享: ① 如何用gRPC拦截器实现熔断限流; ② Sync.Map在高并发读写中的取舍。
128 0
|
3月前
|
存储 算法 Go
Go语言实战:错误处理和panic_recover之自定义错误类型
本文深入探讨了Go语言中的错误处理和panic/recover机制,涵盖错误处理的基本概念、自定义错误类型的定义、panic和recover的工作原理及应用场景。通过具体代码示例介绍了如何定义自定义错误类型、检查和处理错误值,并使用panic和recover处理运行时错误。文章还讨论了错误处理在实际开发中的应用,如网络编程、文件操作和并发编程,并推荐了一些学习资源。最后展望了未来Go语言在错误处理方面的优化方向。
|
3月前
|
存储 算法 安全
基于 Go 语言的公司内网管理软件哈希表算法深度解析与研究
在数字化办公中,公司内网管理软件通过哈希表算法保障信息安全与高效管理。哈希表基于键值对存储和查找,如用户登录验证、设备信息管理和文件权限控制等场景,Go语言实现的哈希表能快速验证用户信息,提升管理效率,确保网络稳定运行。
54 0