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

本文回顾 Go 1.18 泛型出现前的代码复用方式,涵盖接口表达行为、函数参数注入策略和 interface{} 的边界使用,帮助理解泛型引入的设计背景。

没有泛型时,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: 如何判断重复是否需要消除?

问自己三个问题:

  1. 这个重复是否会导致"改一处要改十处"的维护负担?
  2. 消除重复后,代码是否更容易理解还是更难理解?
  3. 消除重复所需的抽象,是否比重复本身更复杂?

如果答案分别是"是"、“更容易”、“不更复杂”,就去消除它。否则,接受一点清楚的重复。

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 验证并发安全性。

生产环境注意事项

生产环境的代码比本地开发要求更高。以下是一些通用原则:

  1. 日志要克制:不要记录敏感信息,不要在热路径上打印大量日志。
  2. 超时和取消:所有外部调用都要有超时。使用 context.WithTimeout 或 context.WithDeadline。
  3. 资源限制:限制请求体大小、并发连接数、内存使用。
  4. 优雅关闭:http.Server 要设置 Shutdown 超时,goroutine 要有退出机制。
  5. 可观测性:至少记录关键指标(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,而是理解背后的设计原则和适用边界。先让代码工作,再让它正确,最后才考虑让它更快。清晰的代码比聪明的代码更有价值。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练