当前位置: 首页 > 科技观察

说的是切片,但好像不仅仅是切片?

时间:2023-03-19 01:59:46 科技观察

本文转载自微信公众号《地鼠指北》,作者:新世界杂货店。转载本文请联系歌斐智贝公众号。切片底层结构切片与结构转换话不多说,让我们回到本文的主题。在我们正式了解切片的底层结构之前,让我们先看几行代码。typemySlicestruct{datauintptrlenintcapint}s:=mySlice{}fmt.Println(fmt.Sprintf("%+v",s))//{data:0len:0cap:0}s1:=make([]int,10)s1[2]=2fmt.Println(fmt.Sprintf("%+v,len(%d),cap(%d)",s1,len(s1),cap(s1)))//[0020000000],len(10),cap(10)s=*(*mySlice)(unsafe.Pointer(&s1))fmt.Println(fmt.Sprintf("%+v",s))//{data:824634515456len:10cap:10}fmt.Printf("%p,%v\n",s1,unsafe.Pointer(s.data))//0xc0000c2000,0xc0000c2000在上面的代码中,通过获取切片的地址并转换为*mySlice,成功获取切片的长度和容量。和指针之类的东西。而这个指针指向的是存放真实数据的数组,下面我们来验证一下。//数据被强制到一个数组s2:=(*[5]int)(unsafe.Pointer(s.data))s3:=(*[10]int)(unsafe.Pointer(s.data))//修改数组中的数据后,切片中对应位置的值也会发生变化。)//[0020400000]至此,slice的底层结构就可以揭晓了,但是为了进一步验证,我们继续测试将结构转化为slice的过程。var(//长度为5的数组dt[5]ints4[]int)s5:=mySlice{//将数组地址赋给datadata:uintptr(unsafe.Pointer(&dt)),len:2,cap:5,}//结构强转为slices4=*((*[]int)(unsafe.Pointer(&s5)))fmt.Println(s4,len(s4),cap(s4))//[00]25//修改数组中的值,slice的内容也会改变dt[1]=3fmt.Println(dt,s4)//[03000][03]通过以上三段代码,我们将使用切片的底层结构作为结构体的形式来更清晰的表达。如下图,第一部分(Data)是指向数组的地址,第二部分(Len)是切片的长度,也就是数组使用的长度,第三部分(Cap)是数组的长度。总结:切片是对数组的包装,底层还是用数组来存储数据。多说一句:reflect包在操作切片时使用了reflect.SliceHeader结构,详见https://github.com/golang/go/blob/master/src/reflect/value.go#L2329runtime使用切片结构时扩容见https://github.com/golang/go/blob/master/src/runtime/slice.go#L12unsafe题外话unsafe包的使用几乎离不开前面部分的Demo。当然,本文不是介绍这个包的用法,只是作为题外话简单看看为什么不安全。funcotherOP(a,b*int){reflect.ValueOf(a)reflect.ValueOf(b)}var(a=new(int)b=new(int))otherOP(a,b)//如果调用这个函数,最终的输出会变成*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(a))+unsafe.Sizeof(int(*a))))=1fmt.Println(*a,*b)的不管otherOP有没有注释,上一代的输出都是不一致的。当变量逃逸到堆中时,变量a和变量b的内存地址是相邻的,所以可以通过变量a的地址来设置变量b的值。在没有逃逸到堆的时候,设置变量b的值是不生效的,所以我们无从知道是哪块内存被修改了。这种不确定性在老徐看来就是需要谨慎使用这个包。的原因。上面demo补充说明:reflect.ValueOf会调用底层的escapes方法,保证对象逃逸到堆中。Go使用了按大小划分的自由链表内存分配器和多级缓存,所以a和b变量的大小是一样的,而且这个demo变量比较少的情况下,很可能会分配到一个连续的内存空间。创建一个切片。有四种创建切片的方法。第一种是直接通过var声明变量,第二种是通过类型推导,第三种是通过make创建,第四种是通过slice表达式创建。//通过变量声明的方式创建vara[]int//类型推导b:=[]int{1,2,3}//make创建c:=make([]int,2)//c:=make([]int,0,5)//切片表达式d:=c[:3]上面的例子中,前三个没什么好说的,老徐主要介绍第四个,以及它的相关限制和注意事项。简单的切片表达式对于字符串、数组、数组指针、切片可以使用如下表达式(切片指针不能使用以下表达式):s[low:high]//生成切片的长度为高-低通过以上表达式创建新的子字符串或切片。请特别注意,在字符串上使用此表达式既不会生成字符串切片也不会生成字节切片,而是会生成子字符串。另外,老徐在Go中字符串的编码问题中提到,Go中的字符串是以utf8字节片存储的,所以当我们使用这种方式获取包含中文等特殊字符的特殊字符时,可能会出现意想不到的结果。正确的获取子串的方式应该是先转成符文切片再截取。上面的表达式已经可以很方便的创建一个新的slice了,不过更方便的是low和high也可以省略。s[2:]//sameass[2:len(a)]s[:3]//sameass[0:3]s[:]//sameass[0:len(a)]下标限制为不同的类型使用切片表达式,low和high取值范围不同。对于字符串和数组/数组指针,low和high的范围是0<=low<=len(s)。对于切片来说,low和high的取值范围是0<=low<=cap(s)。切片面试题系列之一就是对这个知识点的考察。切片容量切片表达式生成的切片共享底层数组,因此切片的容量是底层数组的长度减去low。可以推断出下面代码的输出是38和313。var(s1[10]ints2[]int=make([]int,10,15))s:=s1[2:5]fmt.Println(len(s),cap(s))s=s2[2:5]fmt.Println(len(s),cap(s))返回一个完整的切片表达式说实话这个方法真的不常用用过的。虽然可以控制切片的容量,但是老徐在实际开发中并没有用到。用过的。其完整的表达式如下:s[low:high:max]这个表达式有几点需要注意:它只适用于数组,数组指针和切片不适用于字符串。不同于简单的slice表达式,它只能忽略下标low,忽略后下标默认值为0。和简单的切片表达式一样,全切片表达式生成的切片底层数组共享下标限制对于数组/数组指针,下标取值范围为0<=low<=high<=max<=len(s)。对于切片,下标的范围是0<=low<=high<=cap(s)。就是第二系列切片面试题中对这个知识点的考察。SliceCapacity前面说过,这个切片表达式可以控制切片的容量。在一定low的情况下,可以在允许的范围内通过改变max的值来改变slice的容量,容量的计算方式是max-low。切片扩展runtime/slice.go文件的growslice函数实现了切片的扩展逻辑。在正式分析内部逻辑之前,我们先看一下growslice的函数签名:typeslicestruct{arrayunsafe.Pointerlenintcapint}funcgrowslice(et*_type,oldslice,capint)slice第一个参数_type是Go语言类型的运行时表示,其中包含很多元信息,例如:类型大小、对齐方式和类型。第二个参数是要扩展的切片的信息。第三个参数是实际需要的容量,即原有容量加上新增元素的数量,老徐简称为需要容量。为了更容易理解所需容量的含义,我们先来看一段代码:s:=[]int{1,2,3}//此时slice的长度和容量都是3s=append(s,4)//这个需要的容量是3+1s1:=[]int{1,2,3}//分片长度和容量都是3s1=append(s1,4,5,6)//所需容量为3+3扩容逻辑有了以上概念,我们再来看分片扩容算法:上图中的逻辑总结如下:首先,如果所需容量大于当前容量的两倍,新容量是所需容量。第二,判断当前容量是否大于1024,如果当前容量小于1024,则新增容量等于当前容量的两倍。如果当前容量大于或等于1024,则新容量循环增加1/4倍新容量,直到新容量大于或等于所需容量。老徐在这里特别提醒一下,跟0比较有用。一开始老徐也觉得这个逻辑多此一举,但有一天他突然发现,这其实是对塑料溢出的一种判断。因为平时开发很少考虑这个问题,一时震惊了。也许我们和高手的代码差距只是缺少对溢出的判断。还有一个有意思的地方就是切片的逻辑一开始不是这样的。逻辑并不复杂,即使是初学者也能毫无压力地写出来。然而,就是这样一个简单的逻辑,也经过了多个版本的迭代,才有了今天的样子。有一说一,在老徐看来,1.6的扩容逻辑并不优雅。想到这里,一种“我赢了”的感觉油然而生。程序员的快乐就是这么简单。计算内存容量上一节的扩展逻辑是理想的内存分配容量,但真正的内存分配是非常复杂的。在Go1.6中,分片扩展分配内存有四种情况,即类型大小为1字节、类型大小为指针大小、类型大小为2的n次方等。切片扩展的内存分配在不同的Go版本中略有不同。这里只介绍1.16中类型大小为2的n次方时的内存分配。直接上传下面代码:varshiftuintptrifsys.PtrSize==8{//Maskshiftforbettercodegeneration.//et.size=1<>shift)结合以上,uintptr(newcap)<>shift可以理解为capmem/et.size。uintptr(newcap)<