golang的类型转换

今天我们来说说一个大家每天都在做但很少深入思考的操作——类型转换。

本文索引

一行奇怪的代码

事情始于年初时我对标准库sync做一些改动的时候。

改动会用到标准库在1.19新添加的atomic.Pointer,出于谨慎,我在进行变更之前泛泛通读了一遍它的代码,然而一行代码引起了我的注意:

// A Pointer is an atomic pointer of type *T. The zero value is a nil *T.
type Pointer[T any] struct {
    // Mention *T in a field to disallow conversion between Pointer types.
    // See go.dev/issue/56603 for more details.
    // Use *T, not T, to avoid spurious recursive type definition errors.
    _ [0]*T

    _ noCopy
    v unsafe.Pointer
}

并不是noCopy,这个我在golang拾遗:实现一个不可复制类型详细讲解过。

引起我注意的地方是_ [0]*T,它是个匿名字段,且长度为零的数组不会占用内存。这并不影响我要修改的代码,但它的作用是什么引起了我的好奇。

还好这个字段自己的注释给出了答案:这个字段是为了防止错误的类型转换。什么样的类型转换需要加这个字段来封锁呢。带着疑问我点开了给出的issue链接,然后看到了下面的例子:

package main

import (
	"math"
	"sync/atomic"
)

type small struct {
	small [64]byte
}

type big struct {
	big [math.MaxUint16 * 10]byte
}

func main() {
	a := atomic.Pointer[small]{}
	a.Store(&small{})

	b := atomic.Pointer[big](a) // type conversion
	big := b.Load()

	for i := range big.big {
		big.big[i] = 1
	}
}

例子程序会导致内存错误,在Linux环境上它会有很大概率导致段错误。为什么呢?因为big的索引值大大超过了small的范围,而我们实际上在Pointer只存了一个small对象,所以在最后的循环那里我们发生了索引越界,而且go并没有检测到这个越界。

当然,go也没有义务去检测这种越界,因为用了unsafe(atomic.Pointer是对unsafe.Pointer的包装)之后类型安全和内存安全就只能靠用户自己来负责了。

这里根本上的问题在于,atomic.Pointer[small]atomic.Pointer[big]之间没有任何关联,它们应该是完全不同的类型不应该发生转换(如果对此有疑惑,可以搜索下类型构造器相关的资料,通常这种泛型的类型构造器产生的类型之间是不应该有任何关联性的),尤其是go是一门强类型语言,类似的事情在c++无法通过编译而在python里则会运行时报错。

但事实是在没添加开头的那个字段前这种转换是合法的而且在泛型类型中很容易出现。

到这里你可能还是有点云里雾里,不过没关系,看完下一节你会云开雾散的。

go的类型转换

golang里不存在隐式类型转换,因此想要将一个类型的值转换成另一个类型,只能用这样的表达式Type(value)。表达式会把value复制一份然后转换成Type类型。

对于无类型常量规则要稍微灵活一些,它们可以在上下文里自动转换成相应的类型,详见我的另一篇文章golang中的无类型常量

抛开常量和cgo,golang的类型转换可以分为好几类,我们先来看一些比较常见的类型。

数值类型之间互相转换

这是相当常见的转换。

这个其实没什么好说的,大家应该每天都会写类似的代码:

c := int(a+b)
d := float64(c)

数值类型之间可以相互转换,整数和浮点之间也会按照相应的规则进行转换。数值在必要的时候会发生回绕/截断。

这个转换相对来说也比较安全,唯一要注意的是溢出。

unsafe相关的转换

unsafe.Pointer和所有的指针类型之间都可以互相转换,但从unsafe.Pointer转换回来不保证类型安全。

unsafe.Pointeruintptr之间也可以互相转换,后者主要是一些系统级api需要使用。

这些转换在go的runtime以及一些重度依赖系统编程的代码里经常出现。这些转换很危险,建议非必要不使用。

字符串到byte和rune切片的转换

这个转换的出现频率应该仅次于数值转换:

fmt.Println([]byte("hello"))
fmt.Println(string([]byte{104, 101, 108, 108, 111}))

这个转换go做了不少优化,所以有时候行为和普通的类型转换有点出入,比如很多时候数据复制会被优化掉。

rune就不举例了,代码上没有太大的差别。

slice转换成数组

go1.20之后允许slice转换成数组,在复制范围内的slice的元素会被复制:

s := []int{1,2,3,4,5}
a := [3]int(s)
a[2] = 100
fmt.Println(s)  // [1 2 3 4 5]
fmt.Println(a)  // [1 2 100]

如果数组的长度超过了slice的长度(注意不是cap),则会panic。转换成数组的指针也是可以的,规则完全相同。

底层类型相同时的转换

上面讨论的几种虽然很常见,但其实都可以算是特例。因为这些转换只限于特定的类型之间且编译器会识别这些转换并生成不同的代码。

但go其实还允许一类更宽泛的不需要那么多特殊处理的转换:底层类型相同的类型之间可以互相转换。

举个例子:

type A struct {
    a int
    b *string
    c bool
}

type B struct {
    a int
    b *string
    c bool
}

type B1 struct {
    a1 int
    b *string
    c bool
}

type A1 B

type C int
type D int

A和B是完全不同的类型,但它们的底层类型都是struct{a int;b *string;c bool;}。C和D也是完全不同的类型,但它们的底层类型都是int。A1派生自B,A1和B有着相同的底层类型,所有A1和A也有相同的底层类型。B1因为有个字段的名字和别人都不一样,所以没人和它的底层类型相同。

粗暴一点说,底层类型(underlying type)是各种内置类型(int,string,slice,map,...)以及struct{...}(字段名和是否export会被考虑进去)。内置类型和struct{...}的底层类型就是自己。

只要底层类型相同,类型之间就能互相转换:

func main() {
    text := "hello"
    a := A{1, &text, false}
    a1 := A1(a)
    fmt.Printf("%#v\n", a1) // main.A1{a:1, b:(*string)(0xc000014070), c:false}
}

A1和B还能算有点关系,但和A是真的八竿子打不着,我们的程序可以编译并且运行的很好。这就是底层类型相同的类型之间可以互相转换的规则导致的。

另外struct tag在转换中是会被忽略的,因此只要字段名字和类型相同,不管tag是不是相同的都可以进行转换。

这条规则允许了一些没有关系的类型进行双向的转换,咋一看好像这个规则是在乱来,但这玩意儿也不是完全没用:

type IP []byte

考虑这样一个类型,IP可以表示为一串byte的序列,这是RFC文档上明确说明的,所以我们这么定义合情合理(事实上大家也都是这么干的)。因为是byte的序列,所以我们自然会把一些处理byte切片的方法/函数用在IP上以实现代码复用和简化开发。

问题是这些代码都假定自己的参数/返回值是[]byte而不是IP,我们知道IP其实就是[]byte,但go不允许隐式类型转换,所以直接拿IP的值去掉这些函数是不行的。考虑一下如果没有底层类型相同的类型之间可以相互转换这个规则,我们要怎么复用这些函数呢,肯定只能走一些unsafe的歪门邪道了。与其这样不如允许[]byte(ip)IP(bytes)的转换。

为啥不限制住只允许像IP[]byte之间这样的转换呢?因为这样会导致类型检查变得复杂还要拖累编译速度,go最看重的就是编译器代码简单以及编译速度快,自然不愿意多检查这些东西,不如直接放开标准让底层类型相同类型的互相转换来的简单快捷。

但这个规则是很危险的,正是它导致了前面说的atomic.Pointer的问题。

我们看下初版的atomic.Pointer的代码:

type Pointer[T any] struct {
    _ noCopy
    v unsafe.Pointer
}

类型参数只是在StoreLoad的时候用来进行unsafe.Pointer到正常指针之间的类型转换的。这会导致一个致命缺陷:所有atomic.Pointer都会有相同的底层类型struct{_ noCopy;v unsafe.Pointer;}

所以不管是atomic.Pointer[A]atomic.Pointer[B]还是atomic.Pointer[small]atomic.Pointer[big],它们都有相同的底层类型,它们之间可以任意进行转换。

这下就彻底乱了套,虽说用户得自己为unsafe负责,但这种明摆着的甚至本来就不该编译通过的错误现在却可以在用户毫无防备的情况下出现在代码里——普通开发者可不会花时间关心标准库是怎么实现的所以不知道atomic.Pointer和unsafe有什么关系。

go的开发者最后添加了_ [0]*T,这样对于实例化的每一个atomic.Pointer,只要T不同,它们的底层类型就会不同,上面的错误的类型转换就不可能发生。而且选用*T还能防止自引用导致atomic.Pointer[atomic.Pointer[...]]这样的代码编译报错。

现在你应该也能理解为什么我说泛型类型最容易遇见这种问题了:只要你的泛型类型是个结构体或者其他复合类型,但在字段或者复合类型中没有使用到泛型类型参数,那么从这个泛型类型实例化出来的所有类型就有可能有相同的底层类型,从而允许issue里描述的那种完全错误的类型转换出现。

别的语言里是个啥情况

对于结构化类型语言,像go这样底层类型相同就可以互相转换属于基操,不同语言会适当放宽/限制这种转换。说白了就是只认结构不认其他的,结构相同的东西你怎么折腾都算是同一类。因此issue描述的问题在这些语言里属于not even wrong这个级别,需要改变设计来回避类似的问题。

对于使用名义类型系统的语言,名字相同的算同一类不同的哪怕结构上一样也是不同类型。顺带一提,c++、golang、rust都属于这一类型。golang的底层类型虽然在类型转换和类型约束上表现得像结构化类型,但总体行为上仍然偏向于名义类型,官方并没有明确定义自己到底是哪种类型系统,所以权当是我的一家之言也行。

完全的结构化类型语言不怎么多见,我们就以常见的名义类型语言c++和使用鸭子类型的python为例。

在python中我们可以自定义类型的构造函数,因此可以在构造函数中实现类型转换的逻辑,如果我们没有自定义构造函数或者其他的可以返回新类型的类方法,那两个类型之间默认是无法进行转换。所以在python中是不会出现和go一样的问题的。

c++和python类似,用户不自定义的话默认不会存在任何转换途径。和python不一样的地方在于c++除了构造函数之外还有转换运算符并且支持在规则限制下的隐式转换。用户需要自己定义转换构造函数/转换运算符并且在语法规则的限制下才能实现两个不同类型间的转换,这个转换是单向还是双向和python一样由用户自己控制。所以c++中也不存在go的问题。

还有rust、Java、...我就不一一列举了。

总而言之这也是go大道至简的一个侧面——创造一些别的语言里很难出现的问题然后用简洁的手段去修复。

总结

我们复习了go里的类型转换,还顺便踩了一个相关的坑。

在这里给几个建议:

  • 想用泛型又不想踩坑:尽量在结构体字段或者复合类型里使用泛型类型参数,使用_ [0]*T这样的字段不仅使代码难以理解,还会让类型的初始化变麻烦,不到atomic.Pointer这样万不得以的时候我并不推荐使用。
  • 不用泛型但害怕别的类型和自己的类型有相同的底层类型:不用怕,在自定义类型上少用类型转换的语法就行了,如果你真的需要在相关自定义类型之间转换,定义一些toTypeA之类的方法,这样转换过程就是你控制的不再是go默认的了。
  • 在内置类型和基于这些类型的自定义类型之间转换:这个没啥好担心的,因为本就是你就是我我就是你的关系。实在觉得不舒服可以不用type T []int,把类型定义换成type T struct { data []int },代价除了代码变啰嗦外还有很多接受切片参数的函数和range循环没法直接用了。

像go这样在简单的语法规则里暗藏杀机的语言还是挺有意思的,如果只想着速成的话指不定什么时候就踩到地雷了。