首页 星云 工具 资源 星选 资讯 热门工具
:

PDF转图片 完全免费 小红书视频下载 无水印 抖音视频下载 无水印 数字星空

golang的类型转换

编程知识
2024年09月30日 07:01

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

本文索引

一行奇怪的代码

事情始于年初时我对标准库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这样在简单的语法规则里暗藏杀机的语言还是挺有意思的,如果只想着速成的话指不定什么时候就踩到地雷了。

From:https://www.cnblogs.com/apocelipes/p/18441034
本文地址: http://www.shuzixingkong.net/article/2410
0评论
提交 加载更多评论
其他文章 解密prompt系列39. RAG之借助LLM优化精排环节
RAG这一章我们集中看下精排的部分。粗排和精排的主要差异其实在于效率和效果的balance。粗排和精排的主要差异其实在于效率和效果的balance。粗排模型复杂度更低,需要承上启下,用较低复杂度的模型
解密prompt系列39.  RAG之借助LLM优化精排环节 解密prompt系列39.  RAG之借助LLM优化精排环节 解密prompt系列39.  RAG之借助LLM优化精排环节
Windows平台下安装与配置MySQL9
要在Windows平台下安装MySQL,可以使用图行化的安装包。图形化的安装包提供了详细的安装向导,以便于用户一步一步地完成对MySQL的安装。本节将详细介绍使用图形化安装包安装MySQL的方法。 1.2.1 安装MySQL 要想在Windows中运行MySQL,需要32位或64位Windows操作
Windows平台下安装与配置MySQL9 Windows平台下安装与配置MySQL9 Windows平台下安装与配置MySQL9
10款好用的开源 HarmonyOS 工具库
大家好,我是 V 哥,今天给大家分享10款好用的 HarmonyOS的工具库,在开发鸿蒙应用时可以用下,好用的工具可以简化代码,让你写出优雅的应用来。废话不多说,马上开整。 1. efTool efTool是一个功能丰富且易用的兼容API12的HarmonyOS工具库,通过诸多实用工具类的使用,旨在
10款好用的开源 HarmonyOS 工具库
ServiceMesh 2:控制面和数据面的职责(图文总结)
★ ServiceMesh系列 1 Service Mesh介绍 之前的章节我们详细介绍了ServiceMesh的基础知识. ServiceMesh 是最新一代的微服务架构,作为一个基础设施层,能够与业务解耦,并解决复杂网络拓扑下微服务与微服务之间的通信。其实现形态一般为轻量级网络代理,并与应用Si
ServiceMesh 2:控制面和数据面的职责(图文总结) ServiceMesh 2:控制面和数据面的职责(图文总结) ServiceMesh 2:控制面和数据面的职责(图文总结)
从零开始学机器学习——逻辑回归
首先给大家介绍一个很好用的学习地址:https://cloudstudio.net/columns 在之前的学习中,我们学习了直线线性回归与多项式回归,我们今天的主题则是逻辑回归,我记得在前面有讲解过这两个回归的区别,那么今天我们主要看下逻辑回归有哪些特征需要我们识别的。 逻辑回归 逻辑回归主要用于
从零开始学机器学习——逻辑回归 从零开始学机器学习——逻辑回归 从零开始学机器学习——逻辑回归
.NET 开源 EF Core 批处理扩展工具,真好用
前言 Entity Framework Core(EF Core)作为 .NET 生态系统中受欢迎的对象关系映射器(ORM),其轻量级、可扩展性和支持多个数据库引擎而备受青睐。 本文将介绍一款.NET 的开源 EF Core 批处理扩展工具,它极大地提升了数据处理的效率和性能。来看看如何轻松集成到我
.NET 开源 EF Core 批处理扩展工具,真好用 .NET 开源 EF Core 批处理扩展工具,真好用 .NET 开源 EF Core 批处理扩展工具,真好用
PasteForm最佳CRUD实践,实际案例PasteTemplate详解之3000问(三)
作为“贴代码”力推的一个CRUD实践项目PasteTemplate,在对现有的3个项目进行实战后效果非常舒服!下面就针对PasteForm为啥我愿称为最佳CRUD做一些回答: 哪里可以下载这个PasteForm的项目案例 目前“贴代码”对外使用PasteForm的项目有"贴Builder(
PasteForm最佳CRUD实践,实际案例PasteTemplate详解之3000问(三) PasteForm最佳CRUD实践,实际案例PasteTemplate详解之3000问(三) PasteForm最佳CRUD实践,实际案例PasteTemplate详解之3000问(三)
记一次Razor Pages无法编译问题及解决
解决方案写在前面:更新Visual Studio及相关组件,本人版本自17.8.0更新至17.11.4 缘起于公司的一个业务接口,在有一些信息需要在应用内嵌的webview中展示,信息不少,涉及的前端技术不复杂,但是拼字符串太罗嗦,所以想到了添加一个Razor页面,所以,常规逻辑,在服务上注册&#3
记一次Razor Pages无法编译问题及解决 记一次Razor Pages无法编译问题及解决 记一次Razor Pages无法编译问题及解决