第6章 使用泛型

# 第6章 使用泛型

在编程过程中,经常会遇到这样的情况:你编写了一个函数,使用某种类型(比如整数)的值进行一些计算,但随着开发的推进,突然需要对另一种数据类型(比如float64)执行相同的操作。于是,你复制粘贴第一个函数,并修改其名称和数据类型。这种情况最明显、最常见的例子可能就是容器数据类型,比如映射(maps)和集合(sets)。你先为整数值构建一个容器类型,然后为字符串构建同样的容器类型,接着再为结构体构建,以此类推。

泛型(Generics)是一种在编译时通过代码模板来替代代码复制粘贴的方法。首先,你创建一个函数模板(泛型函数)或数据类型模板(泛型类型)。通过指定类型来实例化泛型函数或类型。编译器负责使用你提供的类型实例化模板,并检查实例化后的泛型类型或函数是否能与你提供的类型一起编译。

在本章中,你将学习如何在常见场景中使用泛型函数和泛型类型:

  • 泛型函数
    • 编写一个用于数字相加的泛型函数
    • 将约束声明为接口
    • 将泛型函数用作适配器和访问器
  • 泛型类型
    • 编写一个类型安全的集合
    • 有序映射——使用多个类型参数

# 泛型函数

泛型函数是一种将类型作为参数的函数模板。泛型函数必须能在其参数的所有可能类型赋值下编译通过。泛型函数能够接受的类型由 “类型约束(type constraints)” 定义。在本节中,我们将学习这些概念。

# 编写一个用于数字相加的泛型函数

一个用于说明泛型的很好的入门示例是数字相加函数。这些数字可以是各种类型的整数或浮点数。在这里,我们将研究几个具有不同功能的方法。

# 操作方法……

一个接受intfloat64类型数字的泛型求和函数如下:

func Sum[T int | float64](values ...T) T {
    var result T
    for _, x := range values {
        result += x
    }
    
    return result
}
1
2
3
4
5
6
7
8

[T int | float64]结构为Sum函数定义了类型参数:

  • T是类型名称。例如,如果你为int类型实例化Sum函数,那么T就是int
  • int | float64表达式是T的类型约束。在这种情况下,它表示 “T要么是int,要么是float64”。该约束告诉编译器,Sum函数只能为intfloat64类型的值实例化。

正如我之前解释的,泛型函数只是一个模板。例如,你不能声明一个函数变量并将其赋值为Sum,因为Sum不是一个真正的函数。以下语句为int类型实例化Sum泛型函数:

fmt.Println(Sum[int](1, 2, 3))
1

在很多情况下,编译器可以推断出类型参数,所以以下代码也是有效的。由于所有参数都是int值,编译器会推断这里指的是Sum[int]

fmt.Println(Sum(1, 2, 3))
1

但在以下情况中,实例化的函数是Sum[float64],参数会被解释为float64值:

fmt.Println(Sum[float64](1, 2, 3))
1

泛型函数必须能在所有可能的T类型下成功编译。在这个例子中,T可以是intfloat64,所以函数体必须在TintTfloat64时都有效。类型约束能让编译器生成有意义的编译时错误。例如,[T int | float64 | big.Int]约束无法编译,因为result += x对于big.Int类型无法编译通过。

Sum函数对从intfloat64派生的类型不起作用,例如:

type ID int
1

即使ID本质是int类型,Sum[ID]也会导致编译错误,因为ID是一个新类型。要包含所有从int派生的类型,在约束中使用~int,例如:

func Sum[T ~int | ~float64](values ...T) T{...}
1

这个声明将处理所有从intfloat64派生的类型。

# 将约束声明为接口

在声明新函数时不断重复约束并不实用。相反,你可以在接口中以类型列表或方法列表的形式定义它们。

# 操作方法……

Go语言的接口指定了一个方法集。Go语言泛型的实现扩展了这个定义,使得接口在用作约束时定义类型集。由于基本类型(如int)没有方法,这需要一些改变来适应基本类型。所以在将接口用作约束时,有两种语法:

  1. 类型列表指定可替代类型参数的类型列表。例如,以下UnsignedInteger约束接受所有无符号整数类型以及所有从无符号整数派生的类型:
type UnsignedInteger interface {
    ~uint8 | ~uint16 | ~uint32 | ~uint64
}
1
2
3
  1. 方法集指定可接受类型必须实现的方法。以下Stringer约束接受所有具有String() string方法的类型:
type Stringer interface {
    String() string
}
1
2
3

这些约束可以组合使用。例如,以下UnsignedIntegerStringer约束接受从无符号整数类型派生且具有String() string方法的类型:

type UnsignedIntegerString interface {
    UnsignedInteger
    Stringer
}
1
2
3
4

Stringer接口既可以用作约束,也可以用作普通接口。UnsignedIntegerUnsignedIntegerString接口只能用作约束。

# 将泛型函数用作访问器和适配器

泛型函数为类型安全的访问器和类型适配器提供了实用的解决方案。例如,用常量值初始化一个*int变量需要声明一个临时值,而这可以通过泛型函数简化。本方法包含几个这样的访问器和适配器。

# 操作方法……

这个泛型函数可以从任意值创建一个指针:

func ToPtr[T any](value T) *T {
    return &value
}
1
2
3

这可用于在不使用临时变量的情况下初始化指针:

type UpdateRequest struct {
    Name *string
    ...
}

...

request := UpdateRequest{Name: ToPtr("test"), }
1
2
3
4
5
6
7
8

类似地,这个泛型函数可以从任意值创建一个切片:

func ToSlice[T any](value T) []T {
    return []T{value}
}

func main() {
    fmt.Println(ToSlice(1))
    // 打印一个int切片: [1]
}
1
2
3
4
5
6
7
8

以下泛型函数返回切片的最后一个元素:

func Last[T any](slice []T) (T, bool) {
    if len(slice) == 0 {
        var zero T
        return zero, false
    }
    
    return slice[len(slice)-1], true
}
1
2
3
4
5
6
7
8

如果切片为空,它将返回false

以下泛型函数可用于将返回一个值和一个错误的函数适配到只接受值的上下文中。如果有错误,该函数会引发恐慌(panic):

func Must[T any](value T, err error) T {
    if err != nil {
        panic(err)
    }
    
    return value
}
1
2
3
4
5
6
7

这将f() (T, error)函数适配为Must(f()) T

# 从泛型函数返回零值

如前所述,泛型函数必须能在类型约束允许的所有可能类型下编译。在创建零值时,这可能会引发问题。

# 操作方法……

要创建一个参数化类型的零值,只需声明一个变量:

func Search[T []E, E comparable](slice T, value E) (E, bool) {
    for _, v := range slice {
        if v == value {
            return v, true
        }
    }
    
    // 像这样声明一个零值
    var zero E
    return zero, false
}
1
2
3
4
5
6
7
8
9
10
11

# 对泛型参数使用类型断言

有时,你需要在泛型函数中根据值的类型执行不同操作。这需要用到类型断言或类型开关,二者都适用于接口。然而,并不能保证函数会针对接口进行实例化。本方法展示如何实现上述需求。

# 如何操作...

假设你有一个泛型函数,它对整数的处理方式有所不同:

func Print[T any](value T) {
    // 以下代码无法工作,因为value不一定是interface{}类型。
    if intValue, ok:=value. (int); ok {
    	...
    } else {
    	...
    }
}
1
2
3
4
5
6
7
8

为了使其生效,你必须确保value是一个接口:

func Print[T any](value T) {
    // 将value转换为接口
    valueIntf := any{value)
    if intValue, ok:=valueIntf. (int); ok {
    	// value是一个整数
    } else {
    	// value不是一个整数
    }
}
1
2
3
4
5
6
7
8
9

同样的思路也适用于类型开关:

func Print[T any](value T) {
    switch v: = any(value).(type) {
    case int:
    	// value是一个整数
    
    default:
    	// value不是一个整数
    }
}
1
2
3
4
5
6
7
8
9

# 泛型类型

泛型函数的语法可以自然地扩展到泛型类型。泛型类型具有相同的类型参数和约束,并且该类型的每个方法也会隐式地拥有与类型本身相同的参数。

# 编写类型安全的集合

类型安全的集合可以使用map[T]struct{}来实现。需要注意的是,T不能是任意类型。只有可比较类型才能作为映射的键,Go语言中有一个预定义的约束来满足这一需求。

# 如何操作...

  1. 使用映射声明一个参数化的集合类型:
type Set[T comparable] map[T]struct{}
1
  1. 使用相同的类型参数声明该类型的方法。在声明方法时,只能通过名称引用类型参数:
// Has方法用于判断集合中是否包含给定的值
func (s Set[T]) Has(value T) bool {
    _, exists := s[value]
    return exists
}

// Add方法用于向集合s中添加值
func (s Set[T]) Add(values ...T) {
    for _, v := range values {
    	s[v] = struct{}{}
    }
}

// Remove方法用于从集合s中删除值
func (s Set[T]) Remove(values ...T) {
    for _, v := range values {
    	delete(s, v)
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
  1. 如果有必要,为新类型创建一个泛型构造函数:
// NewSet函数用于创建一个新的集合
func NewSet[T comparable]() Set[T] {
	return make(Set[T])
}
1
2
3
4
  1. 实例化该类型以使用它:
stringSet := NewSet[string]()
1

请注意,NewSet函数显式地使用字符串类型参数进行实例化。编译器无法推断你想要的类型,所以你必须明确写出NewSet[string]()。然后编译器会实例化Set[string]类型,这也会实例化该类型的所有方法 。

# 有序映射——使用多个类型参数

这个有序映射的实现允许你通过结合切片和映射来保持添加到映射中的元素的顺序。

# 如何操作...

  1. 定义一个包含两个类型参数的结构体:
type OrderedMap[Key comparable, Value any] struct {
    m     map[Key]Value
    slice []Key
}
1
2
3
4

由于Key将用作映射的键,所以它必须是可比较的。对值的类型没有限制。 2. 定义该类型的方法。现在,方法使用KeyValue进行声明:

// Add方法用于向映射中添加键值对
func (m *OrderedMap[Key, Value]) Add(key Key, value Value) {
    _, exists := m.m[key]
    if exists {
    	m.m[key] = value
    } else {
    	m.slice = append(m.slice, key)
    	m.m[key] = value
    }
}

// ValueAt方法返回给定索引处的值
func (m *OrderedMap[Key, Value]) ValueAt(index int) Value {
	return m.m[m.slice[index]]
}

// KeyAt方法返回给定索引处的键
func (m *OrderedMap[Key, Value]) KeyAt(index int) Key {
	return m.slice[index]
}

// Get方法返回与键对应的值,以及键是否存在
func (m *OrderedMap[Key, Value]) Get(key Key) (Value, bool) {
	v, bool := m.m[key]
	return v, bool
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
提示
接收器的类型参数是按位置匹配的,而不是按名称。换句话说,你可以如下定义一个方法:
go<br/>func (m *OrderedMap[K, V]) ValueAt(index int) V {<br/>return m.m[m.slice[index]]<br/>}<br/>
这里,K代表KeyV代表Value
  1. 如果有必要,定义一个构造函数泛型函数:
func NewOrderedMap[Key comparable, Value any]() *OrderedMap[Key, Value] {
	return &OrderedMap[Key, Value]{
		m:     make(map[Key]Value),
		slice: make([]Key, 0),
	}
}
1
2
3
4
5
6
提示
在这种情况下,构造函数是必要的,因为我们需要初始化泛型结构体中的映射。每次向容器中添加内容时都检查映射是否为nil会很麻烦。你必须在拥有一个零值即可用的容器类型所带来的便利,与每次添加内容时检查nil映射所付出的性能代价之间做出选择。
上次更新: 2025/06/04, 17:59:49

社区交流

讨论与留言

前往 GitHub Issues →

本留言区仅对应当前文章,欢迎补充观点、提出问题或帮助修正文中疏漏。 留言由 GitHub/Gitalk 提供,需要使用 GitHub 登录。

最近更新
第 1 课:专栏导论与安全边界
07-01
第 3 课:安装编译与调试环境
07-01
第 4 课:第三方库下载、编译与依赖管理
07-01
更多文章>