Go 泛型入门:从重复函数到类型参数的自然过渡

本文用切片工具函数、约束和业务集合示例讲解 Go 泛型的基本用法,帮助初学者理解什么时候该用类型参数,什么时候保持具体代码更好。

泛型不是为了把所有代码都写成抽象

Go 很长时间没有泛型,所以早期 Go 代码大量依赖具体类型、小接口、函数参数和适度重复。后来 Go 加入类型参数后,很多重复工具函数终于可以写得更自然,比如 ContainsMapFilterSet 这类集合操作。但泛型并不意味着所有代码都应该泛型化。

初学者学习泛型时,最容易走两个极端:一种是害怕它,继续为每个类型复制函数;另一种是刚学会 [T any],就想把所有业务结构都改成泛型。更稳的方式是先从非常具体的重复开始,观察类型参数到底解决了什么问题。

这篇文章不追求炫技,只讲 Go 泛型最常见、最值得入门掌握的部分:类型参数、约束、切片工具函数、可比较类型和业务代码里的使用边界。

从两个重复函数开始

没有泛型时,你可能会写:

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"}, "Go"))
fmt.Println(Contains([]int{1, 2, 3}, 2))

T 是类型参数,comparable 是约束。因为函数里使用了 ==,所以 T 必须是可比较类型。字符串、整数、布尔、指针、部分结构体都可以比较;切片、map、函数不能直接比较。

这就是泛型最自然的使用场景:逻辑完全一样,类型不同,并且类型之间有共同操作能力。

any 不是什么都能做

你可能看到这样的写法:

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

anyinterface{} 的别名,表示没有额外约束。这个函数只取第一个元素,不需要比较、不需要加减、不需要调用方法,所以 any 足够。

调用:

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

注意 any 不代表你能对 T 做任何操作。下面代码不能编译:

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

因为 any 没有说明 T 支持 +。Go 泛型是静态类型系统的一部分,编译器仍然要求所有操作在约束里说得清楚。

写一个数字约束

如果要支持加法,可以定义约束:

type Number interface {
	~int | ~int64 | ~float64
}

func Sum[T Number](items []T) T {
	var total T
	for _, item := range items {
		total += item
	}
	return total
}

调用:

fmt.Println(Sum([]int{1, 2, 3}))
fmt.Println(Sum([]float64{1.5, 2.5}))

| 表示类型集合里的候选类型。~int 表示底层类型是 int 的自定义类型也可以使用。

例如:

type Cents int64

如果约束只写 int64Cents 不能用;写 ~int64,底层类型为 int64 的自定义类型也能用。

这对业务类型很重要。你可能用 type UserID int64type Cents int64 增加语义,泛型约束应该允许这些类型参与合适的通用操作。

泛型切片工具函数

过滤:

func Filter[T any](items []T, keep func(T) bool) []T {
	result := make([]T, 0, len(items))
	for _, item := range items {
		if keep(item) {
			result = append(result, item)
		}
	}
	return result
}

使用:

type User struct {
	Name   string
	Active bool
}

activeUsers := Filter(users, func(user User) bool {
	return user.Active
})

映射:

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
}

使用:

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

这些函数很常见,但也要克制。业务代码里一两层 FilterMap 很清楚;如果链条很长,调试和阅读反而不如普通 for 循环直接。

一个简单 Set

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"))

泛型让这种容器类型更自然。没有泛型时,你要么写 StringSetIntSet,要么用 interface{} 损失类型安全。现在可以保留类型检查,同时复用实现。

什么时候不要用泛型

如果函数只服务一个业务类型,直接写具体类型通常更好:

func ActivateUser(user *User) {
	user.Active = true
}

没必要写成:

func Activate[T interface{ SetActive(bool) }](value T) {
	value.SetActive(true)
}

这类抽象没有减少真实复杂度,只是让代码更难懂。

泛型适合稳定的通用算法和容器,不适合隐藏业务语义。判断标准可以很简单:如果调用者看到泛型函数后更容易理解,就用;如果必须先研究一堆类型参数和约束,才能看懂普通业务动作,那就过度了。

小结

Go 泛型解决的是“相同逻辑、不同类型”的复用问题。类型参数写在方括号里,约束决定这个类型能做什么操作。any 表示没有额外约束,comparable 支持 ==!=,类型集合可以表达数字等能力。

入门阶段最适合从 ContainsFirstFilterMapSet 这类工具函数学起。它们能让你直观看到泛型的价值,也不会一下子把业务代码抽象得太复杂。

泛型是工具,不是风格目标。Go 代码最终还是要清楚、具体、容易维护。

继续阅读

探索更多技术文章

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

全部文章 返回首页