Go 1.18 泛型初体验:先从三个小函数开始理解

本文从 Go 1.18 前后的学习语境出发,用 Contains、Map 和 Set 三个小例子讲解泛型入门,帮助初学者理解类型参数的实际价值。

为什么 2022 年大家都在讨论泛型

如果你在 2022 年学习 Go,很难绕开一个话题:Go 1.18 带来了泛型。对很多从 Java、C#、TypeScript 转过来的人来说,泛型并不陌生;但对长期写 Go 的人来说,这是语言风格里一次很重要的变化。过去 Go 社区习惯用具体类型、小接口、代码生成和适度重复来解决复用问题。泛型出现后,一些集合类和算法类代码终于可以写得更自然。

不过,泛型并不是“终于可以把 Go 写成另一门语言”的许可。Go 的核心审美仍然是简单、清楚、直接。泛型最适合处理那些逻辑完全一样、只是元素类型不同的代码,比如查找、过滤、映射、集合、栈、队列。普通业务函数如果本来就只服务一个类型,就没必要为了新特性强行抽象。

这篇文章只讲三个小函数:ContainsMap 和一个简单 Set。它们足够展示泛型解决的问题,也不会把你带进复杂类型系统。

从重复的 Contains 开始

没有泛型时,你可能写过这种函数:

func ContainsString(items []string, target string) bool {
	for _, item := range items {
		if item == target {
			return true
		}
	}
	return false
}

func ContainsInt(items []int, target int) bool {
	for _, item := range items {
		if item == target {
			return true
		}
	}
	return false
}

两段函数完全一样,只是类型不同。泛型可以这样写:

func Contains[T comparable](items []T, target T) bool {
	for _, item := range items {
		if item == target {
			return true
		}
	}
	return false
}

调用:

fmt.Println(Contains([]string{"Go", "PHP", "Python"}, "Go"))
fmt.Println(Contains([]int{1, 2, 3}, 2))

T 是类型参数,comparable 是约束。因为函数里用了 ==,所以元素类型必须支持比较。Go 不允许你对任意类型使用 ==,比如切片和 map 就不能直接比较。约束让编译器知道这个泛型函数里允许哪些操作。

any 表示没有额外要求

再写一个取第一个元素的函数:

func First[T any](items []T) (T, bool) {
	var zero T
	if len(items) == 0 {
		return zero, false
	}
	return items[0], true
}

any 表示没有额外约束。这个函数不比较、不加减、不调用方法,只是返回切片里的元素,所以任何类型都可以。

调用:

name, ok := First([]string{"小林", "阿周"})
if ok {
	fmt.Println(name)
}

这里有一个细节:空切片时要返回 zero。泛型函数里你不知道 T 到底是什么类型,所以需要用 var zero T 得到它的零值。对 string 是空字符串,对 int 是 0,对指针是 nil。

初学者容易误以为 any 就是什么操作都能做。不是这样。下面代码不能编译:

func Add[T any](a, b T) T {
	return a + b
}

因为 any 没有告诉编译器 T 支持 +

Map:把一种切片变成另一种切片

func Map[T any, R any](items []T, convert func(T) R) []R {
	result := make([]R, 0, len(items))
	for _, item := range items {
		result = append(result, convert(item))
	}
	return result
}

使用:

type User struct {
	ID   int64
	Name string
}

users := []User{
	{ID: 1, Name: "小林"},
	{ID: 2, Name: "阿周"},
}

names := Map(users, func(user User) string {
	return user.Name
})

fmt.Println(names)

这里有两个类型参数:T 是输入元素类型,R 是输出元素类型。Map 的逻辑很稳定:遍历输入,调用转换函数,把结果放进新切片。

这种工具函数适合简单转换。如果转换逻辑很复杂,或者链式调用很多层,普通 for 循环可能更清楚。Go 的泛型不是为了让所有代码都函数式化,而是减少那些明确、机械、重复的代码。

一个简单 Set

Go 没有内置 Set,但可以用 map 实现:

type Set[T comparable] struct {
	items map[T]struct{}
}

func NewSet[T comparable]() *Set[T] {
	return &Set[T]{
		items: make(map[T]struct{}),
	}
}

func (s *Set[T]) Add(item T) {
	s.items[item] = struct{}{}
}

func (s *Set[T]) Has(item T) bool {
	_, ok := s.items[item]
	return ok
}

func (s *Set[T]) Len() int {
	return len(s.items)
}

使用:

tags := NewSet[string]()
tags.Add("Go")
tags.Add("Backend")

fmt.Println(tags.Has("Go"))
fmt.Println(tags.Len())

没有泛型时,你可能写 StringSetIntSet,或者用 map[interface{}]struct{}。前者重复,后者损失类型安全。泛型版本既复用代码,又保留类型检查。

什么时候不要急着用泛型

如果你只是写一个用户注册函数:

func RegisterUser(input RegisterInput) error {
}

不要为了“抽象”写成:

func Register[T Validatable](input T) error {
}

除非你真的有多个类型共享同一套稳定流程,并且泛型让调用方更清楚,否则具体函数更好。业务代码最重要的是语义,不是复用形式。

一个实用判断是:泛型是否让调用处更简单?是否保留了类型安全?是否减少了真实重复?如果只是让函数签名更难读,那就先不用。

小结

Go 1.18 的泛型主要通过类型参数和约束解决复用问题。any 表示没有额外约束,comparable 表示可以比较,类型参数能让 ContainsMapSet 这类工具函数写得更自然。

初学阶段不要从复杂约束开始,先从小函数练手。你会更容易理解泛型的真正价值:不是炫技,而是在合适的地方减少重复,同时保持 Go 代码一贯的清楚和类型安全。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页