今天我们来说说一个大家每天都在做但很少深入思考的操作——类型转换。 本文索引 一行奇怪的代码 go的类型转换 数值类型之间互相转换 unsafe相关的转换 字符串到byte和rune切片的转换 slice转换成数组 底层类型相同时的转换 别的语言里是个啥情况 总结 一行奇怪的代码 事情始于年初时我对
今天我们来说说一个大家每天都在做但很少深入思考的操作——类型转换。
本文索引
- 一行奇怪的代码
- go的类型转换
- 数值类型之间互相转换
- unsafe相关的转换
- 字符串到byte和rune切片的转换
- slice转换成数组
- 底层类型相同时的转换
- 别的语言里是个啥情况
- 总结
事情始于年初时我对标准库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里则会运行时报错。
但事实是在没添加开头的那个字段前这种转换是合法的而且在泛型类型中很容易出现。
到这里你可能还是有点云里雾里,不过没关系,看完下一节你会云开雾散的。
golang里不存在隐式类型转换,因此想要将一个类型的值转换成另一个类型,只能用这样的表达式
Type(value)
。表达式会把value复制一份然后转换成Type类型。
对于无类型常量规则要稍微灵活一些,它们可以在上下文里自动转换成相应的类型,详见我的另一篇文章 golang中的无类型常量 。
抛开常量和cgo,golang的类型转换可以分为好几类,我们先来看一些比较常见的类型。
这是相当常见的转换。
这个其实没什么好说的,大家应该每天都会写类似的代码:
c := int(a+b)
d := float64(c)
数值类型之间可以相互转换,整数和浮点之间也会按照相应的规则进行转换。数值在必要的时候会发生回绕/截断。
这个转换相对来说也比较安全,唯一要注意的是溢出。
unsafe.Pointer
和所有的指针类型之间都可以互相转换,但从
unsafe.Pointer
转换回来不保证类型安全。
unsafe.Pointer
和
uintptr
之间也可以互相转换,后者主要是一些系统级api需要使用。
这些转换在go的runtime以及一些重度依赖系统编程的代码里经常出现。这些转换很危险,建议非必要不使用。
这个转换的出现频率应该仅次于数值转换:
fmt.Println([]byte("hello"))
fmt.Println(string([]byte{104, 101, 108, 108, 111}))
这个转换go做了不少优化,所以有时候行为和普通的类型转换有点出入,比如很多时候数据复制会被优化掉。
rune就不举例了,代码上没有太大的差别。
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
}
类型参数只是在
Store
和
Load
的时候用来进行
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这样在简单的语法规则里暗藏杀机的语言还是挺有意思的,如果只想着速成的话指不定什么时候就踩到地雷了。