Go 泛型之前怎么复用代码:接口、函数参数和清楚的重复

本文从 2020 年 Go 的语境出发,讲解没有泛型时如何用接口、函数参数、代码生成和适度重复组织可复用代码。

没有泛型时,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 的克制在这里体现得很明显:先写清楚,再观察重复是否真的痛,再选择最小的复用方式。

继续阅读

探索更多技术文章

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

全部文章 返回首页