没有泛型时,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{} 传入。你还失去了类型检查。[]string 的每个元素和字符串指针在内存布局上连续紧凑,而 []interface{} 的每个元素是一个带有类型信息的胖指针,占用更多内存且无法直接转换。为了消除几行重复,引入更多不确定性,通常不划算。
Go 里有一种很实用的判断标准:“重复是否正在造成维护问题?“如果只是两个短函数结构相似,但类型不同、逻辑稳定、很少修改,可以先保持具体。等到三种、五种类型都需要类似操作,且逻辑复杂到容易改漏时,再考虑抽象。
这个思路在泛型时代仍然成立。不是看到重复就要用泛型,而是评估抽象的收益:代码行数减少了多少?可读性变好了还是变差了?维护者需要理解多少新概念?
用接口复用行为,不复用数据形状
Go 的接口设计哲学是"鸭子类型”——行为一致即可复用,不关心具体类型。这是 Go 最核心的设计理念之一。
接口适合表达行为。比如很多类型都能写入数据,只要实现 io.Writer:
package main
import (
"bytes"
"fmt"
"io"
"os"
)
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{"Line 1: Revenue", "Line 2: Expenses", "Line 3: Profit"})
也可以写到内存:
var buf bytes.Buffer
err := WriteReport(&buf, []string{"a", "b", "c"})
fmt.Println(buf.String())
这里没有泛型,也没有 interface{} 容器,但复用非常自然。因为函数真正需要的不是"某种具体类型”,而是"能写字节的能力"(io.Writer 接口定义的那个 Write 方法)。
这就是 Go 接口最舒服的地方:当你围绕行为设计,而不是围绕数据万能化设计,代码会很清楚。
另一个标准库经典例子是 io.Copy:
func Copy(dst io.Writer, src io.Reader) (written int64, err error)
它可以在文件、网络连接、内存缓冲区之间任意组合复制数据。这种设计的美妙之处在于,参与 Copy 的类型不需要知道彼此的存在,只要各自实现了 Reader 或 Writer 接口即可。这种松耦合是 Go 并发和模块化设计的基础。
再看一个贴近业务场景的例子:
type Validator interface {
Validate() error
}
func ValidateAll(items []Validator) error {
for _, item := range items {
if err := item.Validate(); err != nil {
return err
}
}
return nil
}
订单、用户、地址等不同类型的结构体只要实现 Validate() error,就可以放进同一个切片做批量校验。这比泛型方案更灵活,因为不同结构体不需要共享同一个类型参数约束。
用函数参数注入变化点
排序就是一个好例子。你不需要为每种结构体写一种排序框架,只要传入比较函数:
type User struct {
Name string
Score int
Age 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
})
按年龄降序排序:
SortUsers(users, func(a, b User) bool {
return a.Age > b.Age
})
函数参数适合把变化点放出去。过滤也类似:
func FilterUsers(users []User, keep func(User) bool) []User {
result := make([]User, 0, len(users))
for _, user := range users {
if keep(user) {
result = append(result, user)
}
}
return result
}
使用:
// 只保留分数大于 80 的用户
highScorers := FilterUsers(allUsers, func(u User) bool {
return u.Score > 80
})
// 只保留成年人
adults := FilterUsers(allUsers, func(u User) bool {
return u.Age >= 18
})
这不是为了追求函数式风格,而是让复用边界清楚:遍历逻辑固定,保留规则变化。
interface{} 要用在边界,不要塞满业务
interface{}(Go 1.18 后写作 any)可以接收任意类型:
func PrintValue(v any) {
fmt.Printf("%v\n", v)
}
这适合日志、JSON 中间状态、通用调试输出、框架扩展点。但如果普通业务函数也到处接收 any,调用方和编译器都不知道你需要什么。
反例:
func Save(value any) error {
// value 到底是什么?
return nil
}
它看起来通用,但调用方不知道能保存哪些类型,函数内部也要写大量类型断言。更好的方式是明确类型:
func SaveUser(user User) error {
// ...
return nil
}
func SaveArticle(article Article) error {
// ...
return nil
}
或者用接口表达能力:
type Validatable interface {
Validate() error
}
func ValidateAndSave(value Validatable, saver func(Validatable) error) error {
if err := value.Validate(); err != nil {
return err
}
return saver(value)
}
any 是工具,不是默认答案。在框架层(HTTP handler 的参数解析、ORM 的 Scan)使用 any 是合理的,因为框架需要处理用户传入的任意类型。但在业务层大量使用 any,相当于主动放弃 Go 的类型安全优势。
一个常见的折中方案是使用类型断言 switch:
func FormatJSON(v any) (string, error) {
switch val := v.(type) {
case string:
return fmt.Sprintf("\"%s\"", val), nil
case int, int64, float64:
return fmt.Sprintf("%v", val), nil
case bool:
return fmt.Sprintf("%t", val), nil
case nil:
return "null", nil
default:
return "", fmt.Errorf("unsupported type: %T", v)
}
}
虽然用了 any,但处理逻辑清晰可控,比直接反射更安全。
代码生成适合稳定重复
如果你真的需要为很多类型生成类似代码,比如数据库访问、序列化、枚举字符串转换,可以考虑代码生成。Go 支持 go generate:
//go:generate stringer -type=OrderStatus
type OrderStatus int
const (
OrderStatusPending OrderStatus = iota
OrderStatusProcessing
OrderStatusShipped
OrderStatusDelivered
OrderStatusCancelled
)
执行:
go install golang.org/x/tools/cmd/stringer@latest
go generate ./...
生成后的文件:
func (i OrderStatus) String() string {
return [...]string{"Pending", "Processing", "Shipped", "Delivered", "Cancelled"}[i]
}
代码生成适合"重复很多、规则稳定、生成物可审查"的场景。它不适合为了一点小重复就引入复杂生成流程。生成代码也应该提交到仓库,除非团队明确约定构建时生成。
另一个常用的生成工具是 protobuf 编译器(protoc),它根据 .proto 文件生成 Go 的结构体和方法。这在微服务领域几乎是标准做法。
还有 protobuf 的 protoc-gen-go、数据库到结构体映射的 sqlc、GraphQL schema 到 Go 代码的 gqlgen,都是代码生成思想的具体体现。
入门阶段不必急着写自定义生成器,但要代码生成是一种选择。Go 社区在没有泛型的年代,很多工具都通过代码生成解决重复问题。
Go 1.18 之后泛型如何改变格局
Go 1.18 引入泛型后,前面的一些场景有了新的解法。但泛型不是银弹,理解它与现有模式的配合关系很重要。
之前的 Contains 函数,现在可以用泛型:
func Contains[T comparable](items []T, target T) bool {
for _, item := range items {
if item == target {
return true
}
}
return false
}
调用很自然:
Contains([]int{1, 2, 3}, 2)
Contains([]string{"a", "b", "c"}, "b")
但泛型要小心使用。如果约束条件过于复杂(comparable 还好,如果是自定义接口约束),调用者理解成本会上升。而且不是所有场景泛型都比接口好。例如 io.Reader 和 io.Writer 的设计如果用泛型取代,会要求每个使用了 Reader 的函数都带上类型参数,这会让标准库变得异常繁琐。
Go 团队成员 Russ Cox 在泛型设计文档中明确提到:Go 的泛型原则之一是"不会用它来取代接口",因为接口表达的是行为抽象,而泛型表达的是类型参数化,二者解决不同层次的问题。
常见陷阱与 FAQ
Q1: 函数参数注入和泛型哪个更好?
如果变化点是某种行为(比如比较规则、过滤条件),函数参数更合适。如果变化点是具体的数据类型([]int vs []string),且逻辑完全一样,泛型更合适。
Q2: 我有很多 CRUD 操作,应该写泛型 CRUD 吗?
不太建议。业务 CRUD 虽然结构相似,但每个实体都有独特的校验规则、关联关系、事件触发。强行泛型化会让每个实体都受限于通用接口,遇到特例时反而束手束脚。代码生成(如 sqlc)可能是更好的方案。
Q3: 如何判断重复是否需要消除?
问自己三个问题:
- 这个重复是否会导致"改一处要改十处"的维护负担?
- 消除重复后,代码是否更容易理解还是更难理解?
- 消除重复所需的抽象,是否比重复本身更复杂?
如果答案分别是"是"、“更容易”、“不更复杂”,就去消除它。否则,接受一点清楚的重复。
Q4: 泛型是否意味着接口过时了?
绝不。接口表达的是行为契约(“你能做什么”),泛型表达的是类型参数(“你是什么类型”)。
io.Reader接口比泛型 Reader 更适合 Go 生态- 排序比较函数用函数参数注入比泛型更灵活
- 框架扩展点仍然适合接口
实战对比:一个排序工具的不同实现
下面用三种方式实现一个通用的排序工具,展示不同复用策略的差异。
方法一:函数参数注入(推荐)
func SortBy[T any](items []T, less func(a, b T) bool) {
sort.Slice(items, func(i, j int) bool {
return less(items[i], items[j])
})
}
调用:
type Product struct {
Name string
Price float64
}
products := []Product{
{Name: "Apple", Price: 3.5},
{Name: "Banana", Price: 1.2},
{Name: "Cherry", Price: 5.0},
}
// 按价格排序
SortBy(products, func(a, b Product) bool {
return a.Price < b.Price
})
// 按名称排序
SortBy(products, func(a, b Product) bool {
return a.Name < b.Name
})
方法二:接口抽象
type ByPrice []Product
func (a ByPrice) Len() int { return len(a) }
func (a ByPrice) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a ByPrice) Less(i, j int) bool { return a[i].Price < a[j].Price }
调用:
sort.Sort(ByPrice(products))
方法三:泛型约束(Go 1.18+)
type Ordered interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
~float32 | ~float64 | ~string
}
func SortSlice[T Ordered](items []T) {
sort.Slice(items, func(i, j int) bool {
return items[i] < items[j]
})
}
对于复杂结构体排序,方法一最灵活。对于简单类型的切片排序,方法三最简洁。
小结
在 2020 年的 Go 里,没有泛型并不意味着无法复用。你可以接受一点清楚的重复,用接口表达行为,用函数参数注入变化点,用具体类型保持编译期安全,必要时使用代码生成。interface{} 适合框架边界和通用工具,不适合普通业务里到处传播。
复用的目标不是把代码行数压到最少,而是让变化更容易管理。如果抽象让调用处更难懂、类型更模糊、错误更晚暴露,那它就不是好抽象。
Go 的克制在这里体现得很明显:先写清楚,再观察重复是否真的痛,再选择最小的复用方式。泛型出现之后,这个原则依然成立。先选择最自然的表达方式,发现有真实痛点时再引入类型参数,而不是为了用泛型而用泛型。
最终,好的代码标准不是"用了多少高级特性",而是"下一个读这段代码的人能否快速理解"。
Go 从设计之初就避开了 C++ 模板那样的复杂泛型系统,选择了接口和组合作为主要复用机制。泛型的加入填补了某些场景下的空白,但它没有改变 Go 的核心哲学——简单、清晰、显式。无论是泛型前还是泛型后,判断一个抽象是否优秀的标准始终只有一个:它是否让代码更易于理解和维护。
在实际工程中,最好的做法是掌握多种工具,根据场景灵活选择。接口处理行为抽象,函数参数处理策略注入,泛型处理类型参数化,代码生成处理 boilerplate。没有一种方案能通吃所有问题,但能认清它们的边界,你就在成为一名优秀 Go 工程师的路上了。
性能对比与基准测试
理解 Go 泛型之前怎么复用代码 的最佳方式是通过基准测试观察实际行为。下面是一个基本的测试框架:
func BenchmarkMain(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = i
}
}
运行 go test -bench=. -benchmem 可以得到每个操作的耗时和内存分配数据。对比不同实现时,建议固定输入规模,跑多次取平均值。机器负载、CPU 频率和缓存状态都会影响结果,所以重要的优化应该在稳定环境中反复验证。
常见错误与最佳实践
错误一:性能优化过早
很多初学者在代码刚写好就开始担心性能,结果引入了不必要的复杂度。正确的做法是先用清晰的写法实现功能,在性能问题真实出现时再通过 profile 定位热点,再针对性优化。
错误二:忽略边界条件
空输入、超大输入、并发场景、系统资源耗尽等边界条件往往是 bug 的来源。写代码时养成习惯:每个函数都问自己,空值怎么办?错误怎么处理?资源泄漏有没有可能?
错误三:错误处理不完整
Go 的错误处理要求显式检查。常见问题是只在最外层处理错误,中间层把 error 吞掉或转换后丢失了上下文。使用 fmt.Errorf 配合 %w 保留原始错误链,上层可以用 errors.Is 判断。
错误四:并发代码缺少同步
Go 的并发模型很简洁,但共享内存访问必须同步。不要凭感觉认为"这里应该不会并发访问"就省略锁或原子操作。用 go test -race 验证并发安全性。
生产环境注意事项
生产环境的代码比本地开发要求更高。以下是一些通用原则:
- 日志要克制:不要记录敏感信息,不要在热路径上打印大量日志。
- 超时和取消:所有外部调用都要有超时。使用
context.WithTimeout或context.WithDeadline。 - 资源限制:限制请求体大小、并发连接数、内存使用。
- 优雅关闭:http.Server 要设置 Shutdown 超时,goroutine 要有退出机制。
- 可观测性:至少记录关键指标(QPS、延迟、错误率)。
测试策略
好的测试应该覆盖正常路径、错误路径和边界条件。表驱动测试是 Go 社区推荐的方式:
func TestExample(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{"valid", "hello", "HELLO"},
{"empty", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := strings.ToUpper(tt.input)
if got != tt.want {
t.Fatalf("ToUpper(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
实战 FAQ
Q: 这个功能在旧版 Go 中能用吗?
A: 需要看具体功能引入的版本。建议使用最新的稳定版 Go。
Q: 第三方库更好还是标准库更好?
A: 能标准库解决先用标准库。第三方库引入依赖成本和许可证风险。
Q: 写测试时发现代码难测怎么办?
A: 这通常意味着代码耦合度太高。考虑把大函数拆成小函数,把外部依赖抽象成接口。
Q: 怎么判断代码算不算过度设计?
A: 问自己:这个抽象让调用方更简单了吗?减少了多少重复?维护成本是增加还是减少了?
小结
Go 泛型之前怎么复用代码 是 Go 开发中非常实用的技能。关键不是记住所有 API,而是理解背后的设计原则和适用边界。先让代码工作,再让它正确,最后才考虑让它更快。清晰的代码比聪明的代码更有价值。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。