如何编写更安全的Golang代码,利用泛型实现?
摘要:从Go 1.18正式引入泛型,再到Go 1.21大量泛型函数类型进入标准库开始已经过去了三年。尽管有着不支持类型特化、不支持泛型方法、实现方式有少量运行时开销、使用指针类型时不够直观等限制,泛型编程还是在golang社区和各种项目中遍地开
从Go 1.18正式引入泛型,再到Go 1.21大量泛型函数/类型进入标准库开始已经过去了三年。尽管有着不支持类型特化、不支持泛型方法、实现方式有少量运行时开销、使用指针类型时不够直观等限制,泛型编程还是在golang社区和各种项目中遍地开花甚至硕果累累了。
不过也因为泛型功能上的种种限制,大多数代码中对其的应用仍然只停留在最基本的层面——仅仅减少重复代码上。但golang泛型的威力远不止如此,即使不能进行复杂的类型编程,泛型也可以让你的代码变得更安全、更健壮。
这篇文章要说的是泛型在强化代码安全性和健壮性方面的应用。
强化代码类型安全
第一个应用是强化类型安全,让类型错误尽可能在编译阶段就全部暴露出来。
我手上正好有这样一个系统,系统里有A、B、C三种不同类型的消息,我们的系统只接收C类型的消息,也只发送A或者B类型的消息。每种消息都实现了自己的序列化方法,当然为了例子足够简洁,这里我做了很大的简化:
type A struct {
ID uint64
Name string
}
func (a *A) Encode() string {
return fmt.Sprintf("A: %#v", a)
}
type B struct {
Name string
Age uint32
CompanyID uint32
}
func (b *B) Encode() string {
return fmt.Sprintf("B: %#v", b)
}
type C struct {
RequestID string
Name string
}
func (c *C) Encode() string {
return fmt.Sprintf("C: %#v", c)
}
如果意外发送了C类型的消息,其他的服务会出现错误。
A和B类型的消息只是字段不太一样,发送的逻辑是完全相同的,所以很自然我们为了DRY原则会写出下面这样的代码:
type Encoder interface {
Encode() string
}
func SendMessage(msg Encoder) {
fmt.Println(msg.Encode())
// 其他一些发送数据和校验的逻辑
}
这是最自然不过的,既然逻辑都一样,而且A和B的操作确实有一定关联性,那么我们就没必要把发送代码写两遍,定义一个能同时容纳A和B的接口,再把接口作为SendMessage的参数类型即可。
这样的代码其实是很不安全的,因为C也实现了Encoder接口,所以函数可以错误地发送C导致整个系统崩溃。
作为泛型时代之前的解决办法,我们只能在函数中加上类型断言或者type switch,但这会带来不小的运行时开销,同时也不能避免代码被误用,根本原因在于我们不能控制接口被哪些类型实现,因此无法避免一个我们不期望的类型被作为参数传入。
有了泛型情况就不一样了,我们现在可以在编译阶段就检查出所有误用并且几乎不需要支付运行时开销。
然而想实现这个效果会很难,你可能会写出这样的代码:
func SendMessage[T A | B](msg *T) {
fmt.Println(msg.Encode())
}
遗憾的是这样的代码会收获编译错误:msg.Encode undefined (type *T is pointer to type parameter, not type parameter)。这是个常见错误了,直接取泛型变量的指针大多数时候都会报这种错,我以前的博客里有解释过原因,这里不再赘述。
你也许会灵机一动,直接让T本身是指针类型不就行了吗:
- func SendMessage[T A | B](msg *T) {
+ func SendMessage[T *A | *B](msg T) {
fmt.Println(msg.Encode())
}
这回确实有变化,只不过是报错信息变了:msg.Encode undefined (type T has no field or method Encode)。
这是因为golang规定如果泛型的类型约束是具体的类型,那么允许在泛型对象上执行的只有内置的那些加减乘除以及==、a[123]这样的操作,并且多个类型之间允许的操作会取交集。很遗憾,方法调用并不在允许的范围内。对于写惯了其他语言中泛型代码的开发者来说,go的这类限制多少有点自废武功的意味。
