go 孪生兄弟切片和数组

208 阅读2分钟

我正在参加「掘金·启航计划」不知道大家在刚开始学习数组和切片的时候,是否总是分不清数组和切片?我们可以类比其他语言时,总是能发现有很多相似的使用方法,比如数组都是大小不变的,切片是什么呢?在python中切片是一个范围内对应的元素。而在golang中切片是什么呢?如何扩容呢?下面我们一起来寻找这些问题的答案。

数组

在go官网中切片类型是建立在数组类型之上的一种抽象,因此在了解切片前我们一定要理解数组。

什么是数组?

相同唯一类型的元素、大小不变。

// 第一种
var arr [5]int
// 第二种
var arr = [5]int{1,3,4,2,5} 

还可以通过索引来赋值

arr := [...]int{9: -1}
// 输出如下:
0 0 0 0 0 0 0 0 0 -1

上面的例子是通过元素的个数来确定数组的大小

数组的遍历

for-in

a := [5]int{2, 3, 4, 6, 7}

for i :=0; i< len(a); i++ {
    fmt.Printf("%d\t", a[i])
}

for-range

a := [5]int{2, 3, 4, 6, 7}
for i,v := range a {
    fmt.Printf("index:%d,value:%d\t",i,v)
}

for-range遍历中i,v分别为数组的索引和对应的值。

切片(Slice)

上面我们说到数组有着大小固定不变的特点,但是如果我们想要其中部分的元素,那么数组就不好使了,因此在go语言中切片可以灵活的改变元素个数、扩展容量的特点,切片得到广泛使用。

如何声明一个切片?

// 直接声明
var slice []int
// make 创建
slice := make([]int, 2, 5) // 2: 长度; 5:容量

// 也可以通过字面量来定义
slice := []int{1,2,3,4,5}

接下来我们还是按照老套路来分析代码在运行时是如何执行的,找到 src/runtime/slice.go的代码,下面我们将切片的数据结构,创建,扩容,append,copy的原理。

切片的数据结构

type slice struct {
	array unsafe.Pointer
	len   int
	cap   int
}

slice的结构体主要包含了三个属性,数组,长度,容量。这里的数组是指向底层数组的起始位置。这里我们借用下官网的图片。

创建一个切片

s := make([]byte, 5)

其结构如下:

此时创建了一个长度为5的切片,那此时的容量是多少呢? 我们可以通过**cap()**得到对应的大小,即2。容量是从创建切片的索引开始的底层数组中元素的数量。此时切片在内存分配的大小为切片元素大小*切片的容量,然后调用mallocgc()函数进行内存分配。具体代码如下:

func makeslice(et *_type, len, cap int) unsafe.Pointer {
	mem, overflow := math.MulUintptr(et.size, uintptr(cap))
	if overflow || mem > maxAlloc || len < 0 || len > cap {
		// NOTE: Produce a 'len out of range' error instead of a
		// 'cap out of range' error when someone does make([]T, bignumber).
		// 'cap out of range' is true too, but since the cap is only being
		// supplied implicitly, saying len is clearer.
		// See golang.org/issue/4085.
		mem, overflow := math.MulUintptr(et.size, uintptr(len))
		if overflow || mem > maxAlloc || len < 0 {
			panicmakeslicelen()
		}
		panicmakeslicecap()
	}

	return mallocgc(mem, et, true)
}

零切片

如上面的方式创建切片后,此时切片的内部元素是什么?我们不妨打印出来看看

s := make([]byte, 5)
fmt.Println(s)
// 输出如下:
[0 0 0 0 0]

此时的切片称为零切片,由于其初始化后,对应元素类型的默认值赋值。当然不同类型下,默认值是不同的;int类型是0,指针类型为nil,string类型为"", bool类型为false

当我们声明切片时将切片的长度和容量设置为0,那会是怎样的情况?

空切片

当切片长度和容量均为0时,此时的切片称为空切片。数组指针指向相同一个内存地址,即unsafe.Pointer(&pointer)。

s := make([]int, 0)

切片拷贝

当声明一个切片后,现在有个需求是取其中几个元素的切片,就可以如下面的代码得到切分出对应的切片:

s := []int{1,3,5,6,7,8}
fmt.Println("before:", s)
s = s[2:4]
fmt.Println("after:", s)
//输出如下:
before: [1 3 5 6 7 8]
after: [5 6]

如上图所示,此时创建了一个指向原始数组的新切片,修改了切片的元素,并重新给原始切片赋值,改变了切片的内存地址。

当 [:]和copy()复制切片数据,看下面的代码执行结果会是怎样的呢?

s := []int{1, 3, 5, 6, 7, 8}
fmt.Printf("before:%v,add:%p\n", s, s)
s1 := s[:]
fmt.Printf("after:%v,add:%p\n", s1, s1)
s2 := make([]int, 10, 10)
copy(s2, s)
fmt.Printf("copy:%v,add:%p\n", s2, s2)

// console:
before:[1 3 5 6 7 8],add:0xc0000c8030
after:[1 3 5 6 7 8],add:0xc0000c8030
copy:[1 3 5 6 7 8 0 0 0 0],add:0xc0000ac190

从上面我们可以很容易得出以下结论:

[:] 和 赋值 = 都是拷贝数据内存地址,新的变量和旧的变量指向相同的地址,当新的变量修改切片的元素值时,旧的变量的切片的元素相应的会改变;

copy()是将数据本身拷贝一份,创建一个新的对象,拷贝的数据指向这个新对象对应的内存空间地址。新对象的变化不会影响老对象的数据。

我们将拷贝数据本身这种方式称为深拷贝;拷贝引用数据地址的方式称为浅拷贝。

append()

append 作用就是在slice里面最后一个元素后面追加一个或多个元素,然后返回最新的切片。可以类比于java中的list.add()方法。

var a = make([]string, 5, 10)
for i := 0; i < 10; i++ {
    a = append(a, fmt.Sprintf("%v", i))
}
fmt.Println(a)

// console:
[     0 1 2 3 4 5 6 7 8 9]

从上面的例子,有没有发现一个问题? 在创建切片时容量定义为10,通过循环10次在切片a上添加对应的值后,此时的切片a 已经超出最大容量,是不是应该考虑扩展其容量问题,该扩大多少呢?

还是用上面的例子来看看切片a是不是真的扩容了,我们只需要打印对应的内存地址和容量大小。代码和结果如下:

var a = make([]string, 5, 10)
for i := 0; i < len(a); i++ {
    fmt.Printf("add:%p, len:%v, cap:%v\n", &a[i], len(a), cap(a))
}
for i := 0; i < 10; i++ {
    a = append(a, fmt.Sprintf("%v", i))
    fmt.Printf("add:%p, len:%v, cap:%v\n", a, len(a), cap(a))
}

// console:
add:0xc000000140, len:5, cap:10
add:0xc000000150, len:5, cap:10
add:0xc000000160, len:5, cap:10
add:0xc000000170, len:5, cap:10
add:0xc000000180, len:5, cap:10
add:0xc000000140, len:6, cap:10
add:0xc000000150, len:7, cap:10
add:0xc000000160, len:8, cap:10
add:0xc000000170, len:9, cap:10
add:0xc000000180, len:10, cap:10
add:0xc0000022d0, len:11, cap:20
add:0xc0000022e0, len:12, cap:20
add:0xc0000022f0, len:13, cap:20
add:0xc000002300, len:14, cap:20
add:0xc000002310, len:15, cap:20

如结果所展示,前5个是创建切片时给的默认值,从140-180生成连续的内存地址用来存放默认值;在len=6时,就是append插入的值,此时内存地址指向了140,容量依然为10,直到内存地址2d0,容量扩大了2倍,变为20。这里我们得到了两个信息:1.扩展后的切片依然指向的是原来的底层数组地址;2.扩容了2倍。到此我们通过生成汇编代码再看运行时代码是如何执行的。

CALL	runtime.makeslice(SB)
...
MOVQ	BX, main..autotmp_28+96(SP)
MOVQ	AX, main..autotmp_29+112(SP)
LEAQ	type.string(SB), AX
MOVQ	main.a.ptr+104(SP), BX
MOVQ	CX, DX
MOVQ	SI, CX
MOVQ	DX, SI
PCDATA	$1, $6
CALL	runtime.growslice(SB)
LEAQ	1(BX), DX
MOVQ	main..autotmp_28+96(SP), BX
MOVQ	main.a.len+80(SP), SI

这里我们重点放在runtime的调用函数上,找到 src/runtime/slice.go 下的 growslice 函数

// growslice 在append中处理切片的扩容
// 该函数输入切片元素的类型,旧切片和新的需要最小容量这些传入参数,
// 返回了一个新的容量和已复制旧数据的切片.
// 这个新的切片被赋值了旧切片的长度,而不是新的容量。
func growslice(et *_type, old slice, cap int) slice {
	...

	if cap < old.cap {
		panic(errorString("growslice: cap out of range"))
	}

	if et.size == 0 {
        // append 不应创建一个空指针长度却不为0的切片。
		// 出现这种情况时,我们假定append不需要保留旧的底层数组。
		return slice{unsafe.Pointer(&zerobase), old.len, cap}
	}

	newcap := old.cap
	doublecap := newcap + newcap
    // 新切片需要的最小容量大于两倍容量时,则直接为新的最小容量
	if cap > doublecap {
		newcap = cap
	} else {
		const threshold = 256
        // 旧切片容量 < 256时,新切片容量扩容2倍
		if old.cap < threshold {
			newcap = doublecap
		} else {
            // >= 256,则按照扩容1.25倍
			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 goarch.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)
	}

	// The check of overflow in addition to capmem > maxAlloc is needed
	// to prevent an overflow which can be used to trigger a segfault
	// on 32bit architectures with this example program:
	//
	// type T [1<<27 + 1]int64
	//
	// var d T
	// var s []T
	//
	// func main() {
	//   s = append(s, d, d, d, d)
	//   print(len(s), "\n")
	// }
	if overflow || capmem > maxAlloc {
		panic(errorString("growslice: cap out of range"))
	}

	var p unsafe.Pointer
	if et.ptrdata == 0 {
		p = mallocgc(capmem, nil, false)
		// The append() that calls growslice is going to overwrite from old.len to cap (which will be the new length).
		// Only clear the part that will not be overwritten.
		memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
	} else {
		// Note: can't use rawmem (which avoids zeroing of memory), because then GC can scan uninitialized memory.
		p = mallocgc(capmem, et, true)
		if lenmem > 0 && writeBarrier.enabled {
			// Only shade the pointers in old.array since we know the destination slice p
			// only contains nil pointers because it has been cleared during alloc.
			bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(old.array), lenmem-et.size+et.ptrdata)
		}
	}
	memmove(p, old.array, lenmem)

	return slice{p, old.len, newcap}
}

扩容结论:

  • 当新申请容量 > 两倍原有容量,则扩容后容量为新申请容量大小
  • 当old.cap < 256, 则每次扩容为原来的 2 倍
  • 当old.cap >= 256, 则每次扩容扩为原来的 1.25 倍