面试题
最近Go 101的作者公布了11道Go面试题,十分乏味,打算写一个系列对每道题做具体解析。欢送大家关注。
大家能够看上面这道对于slice
的题目,通过这道题咱们能够对slice
的个性和注意事项有一个深刻了解。
package mainimport "fmt"func main() { a := [...]int{0, 1, 2, 3} x := a[:1] y := a[2:] x = append(x, y...) x = append(x, y...) fmt.Println(a, x)}
- A: [0 1 2 3] [0 2 3 3 3]
- B: [0 2 3 3] [0 2 3 3 3]
- C: [0 1 2 3] [0 2 3 2 3]
- D: [0 2 3 3] [0 2 3 2 3]
大家能够在评论区留下你们的答案。这道题有几个考点:
slice
的底层数据结构是什么?给slice
赋值,到底赋了什么内容?- 通过
:
操作失去的新slice
和原slice
是什么关系?新slice
的长度和容量是多少? append
在背地到底做了哪些事件?slice
的扩容机制是什么?
解析
咱们先一一解答下面的问题。
slice的底层数据结构
talk is cheap, show me the code
. 间接上slice
的源码:
slice
定义在src/runtime/slice.go
第15行,源码地址:https://github.com/golang/go/...。
Pointer
定义在src/unsafe/unsafe.go
第184行,源码地址:https://github.com/golang/go/...。
type slice struct { array unsafe.Pointer len int cap int}type Pointer *ArbitraryType
slice
实际上是一个构造体类型,蕴含3个字段,别离是
- array: 是指针,指向一个数组,切片的数据理论都存储在这个数组里。
- len: 切片的长度。
- cap: 切片的容量,示意切片以后最多能够存储多少个元素,如果超过了现有容量会主动扩容。
因而给slice
赋值,实际上都是给slice
里的这3个字段赋值。看起来这像是一句正确的废话,然而置信我,记住这句话能够帮忙你十分清晰地了解对slice
做批改后slice
里3个字段的值是怎么变的,slice
指向的底层数组的数据是怎么变的。
:
宰割操作符
:
宰割操作符有几个特点:
:
能够对数组或者slice
做数据截取,:
失去的后果是一个新slice
。- 新
slice
构造体里的array
指针指向原数组或者原slice
的底层数组,新切片的长度是:
左边的数值减去右边的数值,新切片的容量是原切片的容量减去:
右边的数值。 :
的右边如果没有写数字,默认是0,左边没有写数字,默认是被宰割的数组或被宰割的切片的长度。a := make([]int, 0, 4) // a的长度是0,容量是4b := a[:] // 等价于 b := a[0:0], b的长度是0,容量是4c := a[:1] // 等价于 c := a[0:1], b的长度是1,容量是4d := a[1:] // 编译报错 panic: runtime error: slice bounds out of rangee := a[1:4] // e的长度3,容量3
:
宰割操作符左边的数值有下限,下限有2种状况
- 如果宰割的是数组,那下限是是被宰割的数组的长度。
- 如果宰割的是切片,那下限是被宰割的切片的容量。留神,这个和下标操作不一样,如果应用下标索引拜访切片,下标索引的最大值是(切片的长度-1),而不是切片的容量。
一图胜千言,咱们通过上面的示例来解说下切片宰割的机制。
下图示意slice
构造,ptr
示意array
指针,指向底层数组,len
和cap
别离是切片的长度和容量。
step1: 咱们通过代码s := make([]byte, 5, 5)
来创立一个切片s
,长度和容量都是5,构造示意如下:
step2: 当初对切片s
做宰割s2 := s[2:4]
,失去一个新切片s2
,构造如下。
s2
还是指向原切片s
的底层数组,只不过指向的起始地位是下标索引为2的地位。s2
的长度len(s2)
是2,因为s2 := s[2:4]
只是截取了切片s
下标索引为2和3的2个元素。s2
的容量cap(s2)
是3,因为从s2
指向的数组地位到底层数组开端,能够存3个元素。- 因为长度是2,所以只有
s2[0]
和s2[1]
是无效的下标索引拜访。然而,容量为3,s2[0:3]
是一个无效的宰割表达式。
step3: 对切片s
做宰割s3 := s2[:cap(s2)]
,失去一个新切片s3
,构造如下:
s3
指向切片s2
的底层数组,同样也是s
的底层数组,指向的起始地位是s2
的起始地位,对应数组下标索引为2的地位。s3
的长度len(s3)
是3,因为s3 := s2[:cap(s2)]
截取了切片s2
下标索引为0,1,2的3个元素。s3
的容量cap(s3)
是3,因为从s3
指向的数组地位到底层数组开端,能够存3个元素。
因而,对数组或者切片做:
宰割操作产生的新切片还是指向原来的底层数组,并不会把原底层数组的元素拷贝一份到新的内存空间里。
正是因为他们指向同一块内存空间,所以对原数组或者原切片的批改会影响宰割后的新切片的值,反之亦然。
append机制
要理解append的机制,间接看源码阐明。
// The append built-in function appends elements to the end of a slice. If// it has sufficient capacity, the destination is resliced to accommodate the// new elements. If it does not, a new underlying array will be allocated.// Append returns the updated slice. It is therefore necessary to store the// result of append, often in the variable holding the slice itself:// slice = append(slice, elem1, elem2)// slice = append(slice, anotherSlice...)// As a special case, it is legal to append a string to a byte slice, like this:// slice = append([]byte("hello "), "world"...)func append(slice []Type, elems ...Type) []Type
append函数返回的是一个切片,append在原切片的开端增加新元素,这个开端是切片长度的开端,不是切片容量的开端。
func test() { a := make([]int, 0, 4) b := append(a, 1) // b=[1], a指向的底层数组的首元素为1,然而a的长度和容量不变 c := append(a, 2) // a的长度还是0,c=[2], a指向的底层数组的首元素变为2 fmt.Println(a, b, c) // [] [2] [2]}
如果原切片的容量足以蕴含新减少的元素,那append函数返回的切片构造里3个字段的值是:
- array指针字段的值不变,和原切片的array指针的值雷同,也就是append是在原切片的底层数组返回的切片还是指向原切片的底层数组
- len长度字段的值做相应减少,减少了N个元素,长度就减少N
- cap容量不变
如果原切片的容量不够存储append新减少的元素,Go会先调配一块容量更大的新内存,而后把原切片里的所有元素拷贝过去,最初在新的内存里增加新元素。append函数返回的切片构造里的3个字段的值是:
- array指针字段的值变了,不再指向原切片的底层数组了,会指向一块新的内存空间
- len长度字段的值做相应减少,减少了N个元素,长度就减少N
- cap容量会减少
留神:append不会扭转原切片的值,原切片的长度和容量都不变,除非把append的返回值赋值给原切片。
那么问题来了,新切片的容量是依照什么规定计算得进去的呢?
slice扩容机制
slice
的扩容机制随着Go
的版本迭代,是有变动的。目前网上大部分的说法是上面这个:
当原 slice 容量小于1024
的时候,新 slice 容量变成原来的2
倍;原 slice 容量超过1024
,新 slice 容量变成原来的1.25
倍。
这里明确通知大家,这个论断是谬误的。
slice
扩容的源码实现在src/runtime/slice.go
里的growslice
函数,源码地址:https://github.com/golang/go/...。
Go 1.18的扩容实现代码如下,et是切片里的元素类型,old是原切片,cap等于原切片的长度+append新增的元素个数。
func growslice(et *_type, old slice, cap int) slice { // ... newcap := old.cap doublecap := newcap + newcap if cap > doublecap { newcap = cap } else { const threshold = 256 if old.cap < threshold { newcap = doublecap } else { // Check 0 < newcap to detect overflow // and prevent an infinite loop. for 0 < newcap && newcap < cap { // Transition from growing 2x for small slices // to growing 1.25x for large slices. This formula // gives a smooth-ish transition between the two. newcap += (newcap + 3*threshold) / 4 } // Set newcap to the requested cap when // the newcap calculation overflowed. if newcap <= 0 { newcap = cap } } } var overflow bool var lenmem, newlenmem, capmem uintptr // Specialize for common values of et.size. // For 1 we don't need any division/multiplication. // For sys.PtrSize, compiler will optimize division/multiplication into a shift by a constant. // For powers of 2, use a variable shift. switch { case et.size == 1: lenmem = uintptr(old.len) newlenmem = uintptr(cap) capmem = roundupsize(uintptr(newcap)) overflow = uintptr(newcap) > maxAlloc newcap = int(capmem) case et.size == goarch.PtrSize: lenmem = uintptr(old.len) * goarch.PtrSize newlenmem = uintptr(cap) * goarch.PtrSize capmem = roundupsize(uintptr(newcap) * goarch.PtrSize) overflow = uintptr(newcap) > maxAlloc/goarch.PtrSize newcap = int(capmem / goarch.PtrSize) case isPowerOfTwo(et.size): var shift uintptr if goarch.PtrSize == 8 { // Mask shift for better code generation. shift = uintptr(sys.Ctz64(uint64(et.size))) & 63 } else { shift = uintptr(sys.Ctz32(uint32(et.size))) & 31 } lenmem = uintptr(old.len) << shift newlenmem = uintptr(cap) << shift capmem = roundupsize(uintptr(newcap) << shift) overflow = uintptr(newcap) > (maxAlloc >> shift) newcap = int(capmem >> shift) default: lenmem = uintptr(old.len) * et.size newlenmem = uintptr(cap) * et.size capmem, overflow = math.MulUintptr(et.size, uintptr(newcap)) capmem = roundupsize(capmem) newcap = int(capmem / et.size) } // ... return slice{p, old.len, newcap}}
newcap是扩容后的容量,先依据原切片的长度、容量和要增加的元素个数确定newcap大小,最初再对newcap做内存对齐失去最初的newcap。
答案
咱们回到本文最开始的题目,逐行解析每行代码的执行后果。
代码 | 切片对应后果 |
---|---|
a := [...]int{0, 1, 2, 3} | a是一个数组,长度是4,值是[ 0 1 2 3] |
x := a[:1] | x是一个切片,切片里的指针指向数组a的首元素,值是[0],长度1,容量4 |
y := a[2:] | y是一个切片,切片里的指针指向数组a的第2个元素,值是[2 3],长度2,容量2 |
x = append(x, y...) | x的残余容量还有3个,足以存储y里的2个元素,所以x不会扩容,x的值是[0 2 3],长度3,容量4。因为x, a, y都指向同一块内存空间,所以x的批改影响了a和y。 a的值变为[0 2 3 3],长度4,容量4 y的值变为[3 3],长度2,容量2 |
x = append(x, y...) | x的残余容量只有1个,不足以存储y里的2个元素,所以要扩容。append(x, y)的后果是失去一个新切片,值是[0 2 3 3 3],长度5,容量8。 append的返回值赋值给x,所以切片x会指向扩容后的新内存。 |
fmt.Println(a, x) | a的值还是[0 2 3 3]没有变动,所以打印后果是[0 2 3 3] [0 2 3 3 3 3 ],答案是B |
加餐:copy机制
Go的内置函数copy
能够把一个切片里的元素拷贝到另一个切片,源码定义在src/builtin/builtin.go
,代码如下:
// The copy built-in function copies elements from a source slice into a// destination slice. (As a special case, it also will copy bytes from a// string to a slice of bytes.) The source and destination may overlap. Copy// returns the number of elements copied, which will be the minimum of// len(src) and len(dst).func copy(dst, src []Type) int
copy
会从原切片src
拷贝 min(len(dst), len(src))
个元素到指标切片dst
,
因为拷贝的元素个数min(len(dst), len(src))
不会超过指标切片的长度len(dst)
,所以copy
执行后,指标切片的长度不会变,容量不会变。
留神:原切片和指标切片的内存空间可能会有重合,copy
后可能会扭转原切片的值,参考下例。
package mainimport "fmt"func main() { a := []int{1, 2, 3} b := a[1:] // [2 3] copy(a, b) // a和b内存空间有重叠 fmt.Println(a, b) // [2 3 3] [3 3]}
总结
对于slice,时刻想着对slice做了批改后,slice里的3个字段:指针,长度,容量是怎么变的。
slice
是一个构造体类型,外面蕴含3个字段:指向数组的array
指针,长度len
和容量cap
。给slice赋值是对slice
里的指针,长度和容量3个字段别离赋值。:
宰割操作符的后果是一个新切片,新slice
构造体里的array
指针指向原数组或者原slice
的底层数组,新切片的长度是:
左边的数值减去右边的数值,新切片的容量是原切片的容量减去:
右边的数值。:
宰割操作符左边的数值下限有2种状况:- 如果宰割的是数组,那下限是是被宰割的数组的长度。
- 如果宰割的是切片,那下限是被宰割的切片的容量。留神,这个和下标操作不一样,如果应用下标索引拜访切片,下标索引的最大值是(切片的长度-1),而不是切片的容量。
- 对于
append
操作和copy
操作,要分明背地的执行逻辑。 打印
slice
时,是依据slice
的长度来打印的a := make([]int, 1, 4) // a的长度是1,容量是4b := append(a, 1) // 往a的开端增加元素1,b=[0 1], a的长度还是1,a和b指向同一个底层数组fmt.Println(a, b) // [0] [0 1]
- Go在函数传参时,没有传援用这个说法,只有传值。网上有些文章写Go的
slice
,map
,channel
作为参数是传援用,这是谬误的,能够参考我之前的文章Go有援用变量和援用传递么?
开源地址
文章和示例代码开源地址在GitHub: https://github.com/jincheng9/...
公众号:coding进阶
集体网站:https://jincheng9.github.io/
思考题
留下2道思考题,欢送大家在评论区留下你们的答案。
题目1:
package mainimport "fmt"func main() { a := []int{1, 2} b := append(a, 3) c := append(b, 4) d := append(b, 5) fmt.Println(a, b, c[3], d[3])}
题目2
package mainimport "fmt"func main() { s := []int{1, 2} s = append(s, 4, 5, 6) fmt.Println(len(s), cap(s))}
References
- https://go101.org/quizzes/sli...
- https://go.dev/blog/slices-intro
- https://github.com/golang/go/...
- https://qcrao91.gitbook.io/go...