没有泛型时,Go 依然能写可维护代码
在 2020 年学习 Go,一个经常被问到的问题是:Go 没有泛型,遇到重复代码怎么办?比如要写 []int 的查找,又要写 []string 的查找;要写通用队列,又要处理不同类型;要写工具函数,又不想复制很多版本。这个问题很真实,但答案不是“到处用 interface{}”,也不是“所有重复都必须消灭”。
Go 的工程风格一直比较克制。它鼓励清楚的类型、明确的接口、简单的函数和可读的重复。没有泛型时,常见复用方式包括:用接口表达行为,用函数参数注入策略,用具体类型写小函数,必要时使用代码生成,实在需要通用容器时才使用 interface{}。
这篇文章站在 Go 1.18 泛型出现之前的语境里,讲一套入门阶段仍然很有价值的判断方法。即使后来 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
}
它们确实重复。但这类重复并不一定糟糕。两个函数都很短,类型明确,调用处也清楚:
if ContainsString(tags, "Go") {
}
如果为了消除重复改成:
func Contains(items []interface{}, target interface{}) bool {
for _, item := range items {
if item == target {
return true
}
}
return false
}
调用处反而麻烦,因为 []string 不能直接当成 []interface{} 传入。你还失去了类型检查。为了消除几行重复,引入更多不确定性,通常不划算。
Go 里有一种很实用的判断:重复是否正在造成维护问题?如果只是两个短函数结构相似,但类型不同、逻辑稳定,可以先保持具体。
用接口复用行为,不复用数据形状
接口适合表达行为。比如很多类型都能写入数据,只要实现 io.Writer:
func WriteReport(w io.Writer, lines []string) error {
for _, line := range lines {
if _, err := fmt.Fprintln(w, line); err != nil {
return err
}
}
return nil
}
这个函数可以写到文件:
file, err := os.Create("report.txt")
if err != nil {
return err
}
defer file.Close()
err = WriteReport(file, []string{"a", "b"})
也可以写到内存:
var buf bytes.Buffer
err := WriteReport(&buf, []string{"a", "b"})
fmt.Println(buf.String())
这里没有泛型,也没有 interface{} 容器,但复用非常自然。因为函数真正需要的不是“某种具体类型”,而是“能写字节的能力”。
这就是 Go 接口最舒服的地方:当你围绕行为设计,而不是围绕数据万能化设计,代码会很清楚。
用函数参数注入变化点
排序就是一个好例子。你不需要为每种结构体写一种排序框架,只要传入比较函数:
type User struct {
Name string
Score int
}
func SortUsers(users []User, less func(a, b User) bool) {
sort.Slice(users, func(i, j int) bool {
return less(users[i], users[j])
})
}
按分数排序:
SortUsers(users, func(a, b User) bool {
return a.Score > b.Score
})
按名字排序:
SortUsers(users, func(a, b User) bool {
return a.Name < b.Name
})
函数参数适合把变化点放出去。过滤也类似:
func FilterUsers(users []User, keep func(User) bool) []User {
var result []User
for _, user := range users {
if keep(user) {
result = append(result, user)
}
}
return result
}
这不是为了追求函数式风格,而是让复用边界清楚:遍历逻辑固定,保留规则变化。
interface{} 要用在边界,不要塞满业务
interface{} 可以接收任意类型:
func PrintValue(v interface{}) {
fmt.Printf("%v\n", v)
}
这适合日志、JSON 中间状态、通用调试输出、框架扩展点。但如果普通业务函数也到处接收 interface{},调用方和编译器都不知道你需要什么。
比如:
func Save(value interface{}) error {
}
它看起来通用,但调用方不知道能保存哪些类型,函数内部也要写大量类型判断。更好的方式是明确:
func SaveUser(user User) error {
}
func SaveArticle(article Article) error {
}
或者用接口表达能力:
type Validatable interface {
Validate() error
}
func ValidateAndSave(value Validatable) error {
if err := value.Validate(); err != nil {
return err
}
return nil
}
interface{} 是工具,不是默认答案。
代码生成适合稳定重复
如果你真的需要为很多类型生成类似代码,比如数据库访问、序列化、枚举字符串转换,可以考虑代码生成。Go 有 go generate:
//go:generate stringer -type=OrderStatus
type OrderStatus int
执行:
go generate ./...
代码生成适合“重复很多、规则稳定、生成物可审查”的场景。它不适合为了一点小重复就引入复杂生成流程。生成代码也应该提交到仓库,除非团队明确约定构建时生成。
入门阶段不必急着写生成器,但要知道这是一种选择。Go 社区在没有泛型的年代,很多工具都通过代码生成解决重复问题。
小结
在 2020 年的 Go 里,没有泛型并不意味着无法复用。你可以接受一点清楚的重复,用接口表达行为,用函数参数注入变化点,用具体类型保持编译期安全,必要时使用代码生成。interface{} 适合框架边界和通用工具,不适合普通业务里到处传播。
复用的目标不是把代码行数压到最少,而是让变化更容易管理。如果抽象让调用处更难懂、类型更模糊、错误更晚暴露,那它就不是好抽象。
Go 的克制在这里体现得很明显:先写清楚,再观察重复是否真的痛,再选择最小的复用方式。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。