利用泛型编写更安全的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的这类限制多少有点自废武功的意味。
好消息是稍微绕一条路,我们也可以达成相同的效果:
type SendAble[T A | B] interface {
*T
Encoder
}
func SendMessage[T A | B, PT SendAble[T]](msg *T) {
ptrMsg := PT(msg)
fmt.Println(ptrMsg.Encode())
}
通过引入新的类型约束SendAble,我们可以限制参数的类型了。SendAble中的*T表示被约束的类型只能是T的指针,而我们限制了T只能是A或者B;第二行则包含了Encoder的方法,这要求这个指针类型也必须实现了这些方法。新代码中的ptrMsg := PT(msg)则把指针类型转换成了另一个类型参数PT,PT拥有Encode方法因此可以正常调用,而且编译器在类型推导中不会把*T当成类型参数的指针,而是实际的类型T的指针,这也避免了最初一版代码的报错。
这个模式虽然有些绕,但形式相当固定,因此很容易掌握,你可以当成一些golang的惯用法来看待。现在如果我们传递了C类型的变量到函数中,编译器会报错:C does not satisfy A | B (C missing in main.A | main.B)。错误描述还是多少有点不尽人意,但总比运行时出问题要好得多。
除了代码稍微复杂了一些,这段代码本质上是调用了泛型的接口,虽然编译器做了很多优化,但难免还是会因为golang选择的泛型实现方式导致一点点的性能下降。不过比起类型断言来说,这点下降影响往往没有前者那么大。
这只是使用泛型保护类型安全的一个比较常见也比较简单的例子,充分利用泛型特性可以在保证代码简洁的同时让代码更安全。
保证常量安全
golang中的常量很简单,类型只能是整数、浮点、字符串或者以这些为底层类型的自定义类型。
对于1、2、3、4、5这样的数字常量,golang默认都是int类型。大多数时候这都是我们希望的,然而有时候也会带来烦恼:
func handleOdd(n int)
func handleEven(n int)
假设我们有两个分别处理奇偶数的函数handleOdd和handleEven,函数参数类型自然只能是int,但int的取值实在是太宽泛了,对于我们的函数来说里面有接近二分之一的值是不可接受的。
然而除了运行时检查参数之外,我们并没有其他的手段避免错误的值被传入函数,尽管这些值很可能是常量,人工检查一眼就能发现错误的那种。
这和上一节提到的interface意外接受错误的类型一样,属于如何从某个大集合中获取满足特定条件的元素的子集,只不过讨论的对象从变量变成了常量。
在前泛型时代我们只能靠运行时检查解决问题,当然在泛型时代因为golang的限制我们也没法解决上面的奇偶数检查问题,但对于更具体的实际场景来说,泛型刚好能派上用场。
例子同样选自生产环境中的系统,这个系统里有一个请求发送组件,它接受特定格式的数据对象和一个url,通过http请求把数据发送至url,然后再把返回结果存进特定格式的对象里,伪代码如下:
type ARequest struct{}
type AResponse struct{}
func (a *ARequest) RequestData() string { return "A Request" }
func (a *AResponse) ResponseData() string { return "A Response" }
type BRequest struct{}
type BResponse struct{}
func (a *BRequest) RequestData() string { return "B Request" }
func (a *BResponse) ResponseData() string { return "B Response" }
type Requester interface {
RequestData() string
}
type Responser interface {
ResponseData() string
}
type Endpoint string
const (
AURL Endpoint = "https://a/api"
BURL Endpoint = "https://b/api"
)
func SendRequest(url Endpoint, req Requester) Resopnser {
//...
}
是的,发送逻辑是单一且固定的,所以我们又使用接口来删除冗余代码,只保留一个泛用的SendRequest函数。但问题在于,url、request和reponse是严格配对的,而我们的函数可以接受他们的任意组合,比如我们只允许SendRequest(AURL, &ARequest{}),但即使写了SendRequest(AURL, &BRequest{})代码也能正常通过编译,这会导致系统在运行时崩溃或者更遭的遇到一些难以排查的脏数据问题。这就是常量安全问题最常见的一种体现。
当然你还是可以在运行时通过字符串比较和类型断言来做校验,但代码会很复杂而且有不低的性能开销。但所有参数我们其实在编译时就知道了,url都是常量,参数类型和返回值类型也是已知的,只不过编译器不知道他们之间的配对关系。换句话说,只要我们把常量和类型之间的配对关系以某种方式告诉编译器,那么就有机会把这些参数校验放在编译时完成,根本不需要付出运行时代价,也不会让代码变得过于复杂。
正好泛型编程中的Phantom Type可以解决这种区分常量以及类型配对的问题。
所谓Phantom Type,其实就是把一些简单的类型泛型化加上类型参数,但这些类型参数只是简单占位和该泛型类型的值无关也不参与实际的计算和处理:
type PhantomString[T any] string
type PhantomInt[T, U any] int
PhantomString和PhantomInt仍然可以当做字符串和整形来使用,但因为加上了类型参数,所以即使他们底层的值相同,也会因为类型不同而被视为不同的常量:
const (
AP PhantomString[int] = "hello"
BP PhantomString[int] = "hello"
)
// AP和BP因为有完全不同的类型,所有即使值相同,他们也是不同的
const (
A PhantomInt[int, uint] = 1
B PhantomInt[int, float64] = 1
C PhantomInt[[]rune, string] = 1
)
// 同理ABC也是完全不同的
可以看到类型参数本身和类型的值没有任何关联,就像幻影一样,所以得名Phantom Type。
熟悉Haskell或者c++模板元编程的开发者应该知道这种技巧,通过赋予常量不同的类型,我们可以靠类型系统来区分这些常量。而且泛型允许的类型参数可以有多个,所以我们还能把类型之间的组合关系绑定到这些类型化的常量上。
因此上面的例子可以使用Phantom Type改写:
// 将URL常量和请求/应答类型进行绑定
type Endpoint[Req Requester, Resp Responser] string
const (
AURL Endpoint[*ARequest, *AResponse] = "https://a/api"
BURL Endpoint[*BRequest, *BResponse] = "https://b/api"
)
func SendMessage[Req Requester, Resp Responser](url Endpoint[Req, Resp], req Req) Resp {
// 可以直接把url转回string
fmt.Printf("send request to: %s\n", string(url))
var ret Resp
return ret
}
func main() {
ret := SendMessage(AURL, &ARequest{})
fmt.Println(ret.ResponseData())
// 编译报错
// SendMessage(AURL, &BRequest{})
}
现在我们为常量绑定了请求和应答的类型,常量传入函数后编译器会自动推导出请求参数和返回值必须与常量绑定的类型一致,任何不匹配都会报错。比如注释中的表达式:in call to SendMessage, type *BRequest of &BRequest{} does not match inferred type *ARequest for Req。这次的报错信息也相当清晰。
利用Phantom Type的代码整体上也远比运行时检查清晰简洁,而且这次我们不会付出任何运行时的性能代价,所有检查都在编译代码时就完成了。
不过有一点需要注意,golang不会自动推导函数返回值的类型,这里我们通过Endpoint绑定请求/应答类型,能够让编译器推导出所有类型参数,但其他场景下得注意这个限制,有时候需要明确给出所有类型参数才行,这时候代码可能就没那么简洁了。
总结
本文只是简单介绍了两种最常见的泛型增强代码安全性的用法,实际上还有很多实用技巧等待大家去发现。
核心思想很简单:利用泛型的类型参数来绑定类型之间的关系,并通过不同的类型来区分不同种类的值。上一节里把这两种思想综合运用之后可以得到既安全又简洁的代码。
在即将发布的 Go 1.26 版本中,泛型的实用性将进一步增强。尽管限制仍然很多,但利用好泛型不仅可以少写代码,还可以让你的代码安全性更上一层楼。


浙公网安备 33010602011771号