数组和切片是Go言语供给的两种根本数据结构,数组的概念咱们应该都很熟悉,相同类型元素的集合,且元素在内存中接连存储,能够十分便利的经过下标拜访数组元素;那么什么是切片呢?切片能够理解为动态数组,也便是说数组长度(最大能够存储的元素数目)能够动态调整。切片是咱们日常开发最常用的数据结构之一,应该要点学习。

数组

  数组的界说与运用十分简略,如下面实例所示:

package main
import "fmt"
func main() {
var arr [3]int
//数组拜访
arr[0] = 100
arr[1] = 200
arr[2] = 300
//arr最大能够存储三个整数,下标从0开端,最大为2
//Invalid array index 3 (out of bounds for 3-element array);拜访越界,无法编译经过
//arr[3] = 400
fmt.Println(len(arr), arr) //len回来数组长度
var arr1 [5]int
//数组的类型包括:元素类型 + 数组长度,恣意一项不等,阐明数组类型不同,无法彼此赋值
//Cannot use 'arr' (type [3]int) as type [5]int
//arr1 = arr
fmt.Println(arr1)
}

var arr1 [5]int //数组的类型包括:元素类型 + 数组长度,恣意一项不等,阐明数组类型不同,无法彼此赋值 //Cannot use 'arr' (type [3]int) as type [5]int //arr1 = arr fmt.Println(arr1) }

  在运用数组过程中,需求要点注意下标最大值为len – 1,不要呈现拜访越界情况。Go言语数组和C言语数组运用十分相似,可是在数组作为函数参数运用时分,仍是有少许不同的。

  首先要明确一点的是,Go言语函数传参都是传值的(输入参数会复制一份),而不是传递引用(输入参数的地址),因而尽管你在函数内部修正了输入参数,可是调用方变量并没有改动,如下面案例:

package main
import "fmt"
func main() {
arr := [6]int{1,2,3,4,5,6}
testArray(arr)
fmt.Println(arr)  //原数组未产生修正:[1 2 3 4 5 6]
}
func testArray(arr [6]int) {
arr[0] = 0
arr[5] = 500
fmt.Println(arr) //修正数组元素:[0 2 3 4 5 500]
}

func testArray(arr [6]int) { arr[0] = 0 arr[5] = 500 fmt.Println(arr) //修正数组元素:[0 2 3 4 5 500] }

  学习过C言语数组的伙伴可能会比较疑问,C言语在这种情况下,调用方数组元素是会同步产生改动的。Go言语是怎样做到的呢?上面说过,Go言语函数传参都是传值的,所以Go言语会把数组一切元素悉数复制一份,这样函数内部修正的数组就和原数组没有任何关系了。

  咱们能够简略看看Go言语汇编代码,Go言语自身供给有编译东西:

//-N 制止优化 -l 制止内联 -S 输出汇编
go tool compile -S -N -l test.go
"".main STEXT size=125
//MOVQ 复制8字节数据
0x0026 00038 (test.go:6)  MOVQ  1, "".arr+48(SP)
  0x002f 00047 (test.go:6)  MOVQ  2, "".arr+56(SP)
0x0038 00056 (test.go:6)  MOVQ  3, "".arr+64(SP)
  0x0041 00065 (test.go:6)  MOVQ  4, "".arr+72(SP)
0x004a 00074 (test.go:6)  MOVQ  5, "".arr+80(SP)
  0x0053 00083 (test.go:6)  MOVQ  6, "".arr+88(SP)
//MOVUPS 复制16字节数组,数组6个元素复制三次
0x005c 00092 (test.go:7)  MOVUPS  "".arr+48(SP), X0
0x0061 00097 (test.go:7)  MOVUPS  X0, (SP)
0x0065 00101 (test.go:7)  MOVUPS  "".arr+64(SP), X0
0x006a 00106 (test.go:7)  MOVUPS  X0, 16(SP)
0x006f 00111 (test.go:7)  MOVUPS  "".arr+80(SP), X0
0x0074 00116 (test.go:7)  MOVUPS  X0, 32(SP)
0x0079 00121 (test.go:7)  CALL  "".testArray(SB)
……
"".testArray STEXT nosplit
0x000f 00015 (test.go:11) SUBQ  $136, SP
0x0026 00038 (test.go:12) MOVQ  0, "".arr+144(SP)
  0x0032 00050 (test.go:13) MOVQ  500, "".arr+184(SP)

0x0026 00038 (test.go:12) MOVQ 0,"".arr+144(SP)0x003200050(test.go:13)MOVQ0, "".arr+144(SP) 0x0032 00050 (test.go:13) MOVQ 500, "".arr+184(SP)

  不要被汇编两个字吓到,只要了解虚拟内存结构(要点函数栈桢结构),了解寄存器概念,了解一些常见指令含义,上面逻辑就十分清楚了。”CALL testArray”便是函数调用,其上面几条指令便是参数准备,能够很明显看到参数是对原数组的一个复制。上述案例栈桢结构如下图所示:

切片

  切片能够理解为动态数组,根本运用和数组比较相似,都是接连存储,能够按下标拜访;动态的含义是,切片的容量是能够调整的,往切片追加元素时,Go言语底层判别数组容量是否足够,假如不行则触发扩容操作。

根本操作

  咱们先看一个小案例,以此了解切片的初始化、拜访、追加元素等根本操作,以及切片的长度以及容量:

package main
import "fmt"
func main() {
//声明并初始化切片
slice := []int{1,2,3}
slice[0] = 100
//len:切片长度,即切片存储了几个元素;cap:切片容量,即切片底层数组最多能存储元素数目
fmt.Println(len(slice), cap(slice), slice) //上述声明方法,切片长度/容量都等于3: 3 3 [100 2 3]
//往切片追加元素,注意切片slice容量是3,此刻追加元素会触发扩容操作
slice = append(slice, 4)
fmt.Println(len(slice), cap(slice), slice) //切片现已扩容,此刻容量是6(一般按双倍容量扩容): 4 6 [100 2 3 4]
//切片的容量尽管是6,但长度是4,拜访下标5越界
//slice[5] = 5 //panic: runtime error: index out of range [5] with length 4
//也能够根据make函数声明切片;第二个参数为切片长度,第三个参数为切片容量(能够省略,默许容量等于长度)
slice1 := make([]int, 4, 8)
slice1[1] = 1
slice1[2] = 2
fmt.Println(len(slice1), cap(slice1), slice1) //4 8 [0 1 2 0]
//切片遍历拜访
for idx, v := range slice {
printSliceValue(idx, v)  //printSliceValue自己随便界说就行
}
}

//切片遍历拜访 for idx, v := range slice { printSliceValue(idx, v) //printSliceValue自己随便界说就行 } }

  函数len用于获取切片长度,cap用于获取切片容量;切片长度指切片元素数目,拜访下标最大为len – 1,切片容量指切片底层数组最多能存储的元素数目;append函数用于往切片追加元素,该函数会判别切片容量,假如容量不行则触发扩容操作,一般按照容量两倍扩容。make是Go言语供给的变量初始化函数,可用于初始化一些内置类型变量,如切片,map,管道chan等。

  咱们能够经过for range方法遍历切片,range能够获取当时遍历元素的索引以及元素值,那么问题来了,遍历过程中修正元素值,切片的元素会修正吗?如下面的案例:

package main
import "fmt"
func main() {
slice := make([]int, 10, 10)
for i := 0; i < 10; i ++ {
slice[i] = i
}
for idx, v := range slice {
v += 100
printSliceValue(idx, v)
}
fmt.Println(slice)   //输出 [0 1 2 3 4 5 6 7 8 9]
}
func printSliceValue(idx, val int) {
fmt.Println(idx, val)
}

func printSliceValue(idx, val int) { fmt.Println(idx, val) }

  清楚明了,这么修正元素v的值,切片slice的元素不会改动。为什么呢?由于这儿索引值v仅仅切片元素的一个复制,修正副本值,原值必定是不会改动的。那么想在遍历中修正切片的值怎样办?能够经过slice[idx]形式修正,这样拜访到的才是切片原值。

  切片还有一个常见操作:截取,即截取该切片的一部分生成一个新的切片,语法格式为”slice[start:end]”,start与end均表明下标,左开又闭(新切片包括下标start元素,不包括下标end元素),新切片长度为end – start。

package main
import "fmt"
func main() {
slice := []int{1,2,3,4,5,6,7,8,9,10}
//切片截取
slice1 := slice[2:5]
//修正新切片slice1元素,slice元素会改动吗?
slice1[0] = 100
fmt.Println(len(slice), cap(slice), slice)
fmt.Println(len(slice1), cap(slice1), slice1)
//slice1追加多个元素,超越其cap触发扩容
slice1 = append(slice1, 11,12,13,14,15,16,17,18,19,20,21,22)
//再次修正slice1元素,slice元素会改动吗?
slice1[0] = 200
fmt.Println(len(slice), cap(slice), slice)
fmt.Println(len(slice1), cap(slice1), slice1)
}
/**
输出:
10 10 [1 2 100 4 5 6 7 8 9 10]
3 8 [100 4 5]
10 10 [1 2 100 4 5 6 7 8 9 10]
15 16 [200 4 5 11 12 13 14 15 16 17 18 19 20 21 22]
**/

10 10 [1 2 100 4 5 6 7 8 9 10] 15 16 [200 4 5 11 12 13 14 15 16 17 18 19 20 21 22] **/

  剖析输出结构,经过截取生成新切片slice1之后,修正slice1元素,slice元素居然也被改动了!这是为什么呢?由于切片底层也是根据数组完成,截取后两个切片共用同一个底层数组,所以修正元素才会相互影响。那为什么append触发扩容之后,又不影响了呢?由于扩容会申请新的数组,也便是说slice1底层数组变了,与slice底层数组剥离了,此刻修正元素必定不会相互影响了。

  另外注意,slice1 := slice[2:5]截取切片后,slice长度是3,可是容量是8;由于slice1与slice共用底层数组,而底层数组最大容量是10,可是slice1却是从底层数组索引2开端,所以slice1的容量便是10 – 2 = 8 了。

  最终咱们再考虑一个问题,前面咱们介绍数组在传递的时分是按值传递,函数内部修正数组元素,调用方数组并没有改动?那么切片呢?咱们需求紧记一点,Go言语传参都是按值传递的?那便是了,切片和数组一样,也不会改动。是这样吗?咱们用一个小案例验证下:

package main
import "fmt"
func main() {
slice := make([]int, 2, 10)
slice[0] = 1
slice[1] = 2
fmt.Println(len(slice), cap(slice), slice)   //初始切片长度2,容量10:2 10 [1 2]
testSlice(slice)
fmt.Println(len(slice), cap(slice), slice)   //切片长度容量都没有改动,可是切片元素改动了:2 10 [100 200]
}
func testSlice(slice []int) {
slice[0] = 100
slice[1] = 200
slice = append(slice, 300)
fmt.Println(len(slice), cap(slice), slice) //修正切片元素,并追加一个元素,切片长度3,容量10:3 10 [100 200 300]
}

func testSlice(slice []int) { slice[0] = 100 slice[1] = 200 slice = append(slice, 300) fmt.Println(len(slice), cap(slice), slice) //修正切片元素,并追加一个元素,切片长度3,容量10:3 10 [100 200 300] }

  形似和猜想的不一样啊,testSlice函数中修正了切片元素,main函数中slice切片元素也同步改动了;而testSlice函数追加元素,改动了切片长度,可是main函数中slice切片长度却没有改动。why?Go言语传参到底是传值仍是传引用呢?Go言语确实是按值传参的。长度和容量都是切片的值,所以即便testSlice函数修正了main函数中也不会改动,可是底层数组却是共用的,testSlice函数修正了main函数中会同步修正。

  看到这儿可能你仍是有些利诱,不用担心,学习下一小节切片完成原理之后,相信你会恍然大悟。

完成原理

  咱们一直说切片便是动态数组,这是怎样做到动态的呢?都知道数组是接连内存存储的,所以想追加元素十分麻烦,需求申请更大的接连内存空间,复制一切数组元素,功能十分大。切片也是根据数组完成的,只不过采纳预分配战略,一般切片的容量都比切片长度大,这样再往切片追加元素时,就能够避免内存分配以及数据复制。这样一来,切片也需求记载更多的信息:如数组首地址,用于存储元素;容量,记载底层数组最多能够存储的元素数目;长度,记载现已存储的元素数目。容量减长度,便是数组剩下长度了,即该切片在触发扩容之前,还能追加的元素数目。

  切片的界说在runtime/slice.go文件,如下:

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

  和咱们猜想的一样,切片包括三个字段,其实array是一个指针,指向底层数组收地址。该文件还界说了一些常用的切片操作函数:

//make创建切片底层完成
func makeslice(et *_type, len, cap int) unsafe.Pointer
//切片追加元素时,容量缺乏扩容完成方法
func growslice(et *_type, old slice, cap int) slice
//切片数据复制
func slicecopy(toPtr unsafe.Pointer, toLen int, fromPtr unsafe.Pointer, fromLen int, width uintptr) int

  当咱们运用make函数创建切片类型时,底层便是调用makeslice函数分配数组,其间第一个参数type表明切片存储的元素类型,因而数组所需内存巨细应该是元素巨细乘数组容量。makeslice函数完成十分简略,如下:

func makeslice(et *_type, len, cap int) unsafe.Pointer {
//math.MulUintptr回来a * b,一起判别是否产生溢出
mem, overflow := math.MulUintptr(et.size, uintptr(cap))
//省略了一些参数校验逻辑
return mallocgc(mem, et, true) //mallocgc函数用于分配内存,第三个参数表明是否初始化内存为全零
}

return mallocgc(mem, et, true) //mallocgc函数用于分配内存,第三个参数表明是否初始化内存为全零 }

  函数makeslice好像仅仅申请了切片底层数组内存,那么结构体slice中的其他字段呢?怎样保护呢?函数参数传递切片时,传递的到底是什么呢?这就需求咱们剖析汇编代码了。Go程序如下:

package main
import "fmt"
func main() {
slice := make([]int, 4, 10)
slice[0] = 100
printInt(len(slice))
printInt(cap(slice))
testSlice(slice)
}
func printInt(a int) {
fmt.Println(a)
}
func testSlice(slice []int) {
fmt.Println(slice)
}

func testSlice(slice []int) { fmt.Println(slice) }

  编译后的汇编代码如下:

"".main STEXT size=153
//makeslice第一个参数是类型指针,这儿便是type.int
0x0018 00024 (test.go:6)  LEAQ  type.int(SB), AX
//准备第二个参数
0x001f 00031 (test.go:6)  MOVL  4, BX
  //准备第三个参数
  0x0024 00036 (test.go:6)  MOVL  10, CX
//函数调用;函数回来值即数组首地址,在AX寄存器
0x0029 00041 (test.go:6)  CALL  runtime.makeslice(SB)
//下面三行汇编是结构slice结构:数组首地址 + len + cap
0x002e 00046 (test.go:6)  MOVQ  AX, "".slice+32(SP)
0x0033 00051 (test.go:6)  MOVQ  4, "".slice+40(SP)
  0x003c 00060 (test.go:6)  MOVQ  10, "".slice+48(SP)
//AX寄存器存储数组首地址,即赋值slice[0] = 100
0x0047 00071 (test.go:7)  MOVQ  $100, (AX)
//+40(SP)即切片的len,复制到AX寄存器作为参数传递
0x004e 00078 (test.go:8)  MOVQ  "".slice+40(SP), AX
0x0053 00083 (test.go:8)  MOVQ  AX, ""..autotmp_1+24(SP)
0x0058 00088 (test.go:8)  CALL  "".printInt(SB)
//+48(SP)即切片的cap,复制到AX寄存器作为参数传递
0x005d 00093 (test.go:9)  MOVQ  "".slice+48(SP), AX
0x0062 00098 (test.go:9)  MOVQ  AX, ""..autotmp_1+24(SP)
0x0067 00103 (test.go:9)  CALL  "".printInt(SB)
//复制slice结构:数组首地址 + len + cap,结构函数testSlice输入参数
0x006c 00108 (test.go:11) MOVQ  "".slice+32(SP), AX
0x0071 00113 (test.go:11) MOVQ  "".slice+40(SP), BX
0x0076 00118 (test.go:11) MOVQ  "".slice+48(SP), CX
0x0080 00128 (test.go:11) CALL  "".testSlice(SB)

//复制slice结构:数组首地址 + len + cap,结构函数testSlice输入参数 0x006c 00108 (test.go:11) MOVQ "".slice+32(SP), AX 0x0071 00113 (test.go:11) MOVQ "".slice+40(SP), BX 0x0076 00118 (test.go:11) MOVQ "".slice+48(SP), CX 0x0080 00128 (test.go:11) CALL "".testSlice(SB)

  函数输入参数能够在栈上,也能够运用寄存器传递输入参数,比方上述代码,AX是第一个输入参数,BX、CX依次是第二个、第三个输入参数;函数回来值也是既能够在栈上,也能够运用寄存器,上面代码运用AX寄存器作为第一个回来值。

  究竟slice结构十分简略明了,三个8字节,数组首地址 + len + cap,所以能够很便利的经过汇编代码结构。而len(slice)获取切片长度,cap(slice)获取切片容量更是简略,slice地址偏移8字节、16字节便是了。

  另外注意testSlice函数调用,复制了slice结构作为函数参数,底层数组呢?必定仍是共用的,所以在函数testSlice内部修正了切片元素,调用方也会同步修正;而在函数testSlice内部append触发的扩容,却不回影响调用方切片的len以及cap。这也处理了咱们上一小节留下的一些疑问。

  上述案例示意图如下所示:

扩容

  append用于往切片追加元素,其底层完成会判别切片容量,假如容量缺乏,则触发扩容。append通常有两种写法:1)追加一个切片到另一个切片;2)追加元素到一个切片。如下面案例所示:

package main
import "fmt"
func main() {
slice := make([]int, 0, 100)
slice = append(slice, 10, 20, 30)
slice1 := []int{1, 2, 3}
slice = append(slice, slice1...)
fmt.Println(slice,slice1) //[10 20 30 1 2 3] [1 2 3]
}

fmt.Println(slice,slice1) //[10 20 30 1 2 3] [1 2 3] }

  append函数完成在哪呢?假如你查看runtime/slice.go文件,会发现好像没有appendslice函数,却是有growslice切片扩容的完成。append函数其实是编译阶段生成的,并没有源码,这儿直接给出两种写法下的中心逻辑:

//参阅1:cmd/compile/internal/walk/assign.go:appendSlice
//参阅2:cmd/compile/internal/walk/builtin.go:walkAppend
// expand append(l1, l2...) to
//   init {
//     s := l1
//     n := len(s) + len(l2)
//     // Compare as uint so growslice can panic on overflow.
//     if uint(n) > uint(cap(s)) {
//       s = growslice(s, n)
//     }
//     s = s[:n]
//     memmove(&s[len(l1)], &l2[0], len(l2)*sizeof(T))
//   }
// Rewrite append(src, x, y, z)
//   init {
//     s := src
//     const argc = len(args) - 1
//     if cap(s) - len(s) < argc {
//      s = growslice(s, len(s)+argc)
//     }
//     n := len(s)
//     s = s[:n+argc]
//     s[n] = a
//     s[n+1] = b
//     ...
//   }

// Rewrite append(src, x, y, z) // init { // s := src // const argc = len(args) - 1 // if cap(s) - len(s) < argc { // s = growslice(s, len(s)+argc) // } // n := len(s) // s = s[:n+argc] // s[n] = a // s[n+1] = b // ... // }

  能够看到,在容量缺乏时,都是经过growslice来扩容的。函数growslice在切片容量较小时,按照两倍扩容;切片容量较大时,扩容25%。确认切片容量后,便是申请内存,一起复制切片数据到新数组。有爱好的读者能够研讨下growslice函数的源码。

复制

  最终咱们再探索一个问题:不管是切片的截取,传参等,底层数组初始都是共用的,修正一个切片的元素必然影响另一个切片,有没有办法完成切片的完全复制呢?复制后两个切片数组也是隔离的,互不影响。这种完全复制能够根据Go言语内置函数copy完成:

package main
import "fmt"
func main() {
slice := []int{1,2,3,4,5}
slice1 := make([]int, len(slice), 10)
copy(slice1, slice)
slice1[0] = 100
fmt.Println(slice, slice1)
}
/**
[1 2 3 4 5] [100 2 3 4 5]
**/

/** [1 2 3 4 5] [100 2 3 4 5] **/

  能够看到,修正切片slice1元素之后,slice切片元素没有产生改动。这儿又有疑问了,copy函数的完成逻辑是怎样的呢?是runtime/slice.go文件中的slicecopy函数吗?只能说不完全是,Go言语在编译阶段判别,假如切片元素类型包括指针,则copy对应typedslicecopy函数;假如需求一些运行时变量,则copy对应slicecopy函数;否则编译阶段直接生成汇编代码,这儿直接给出该汇编代码的中心逻辑:

//参阅:cmd/compile/internal/walk/builtin.go:walkCopy
// Lower copy(a, b) to a memmove call or a runtime call.
//
// init {
//   n := len(a)
//   if n > len(b) { n = len(b) }
//   if a.ptr != b.ptr { memmove(a.ptr, b.ptr, n*sizeof(elem(a))) }
// }

// Lower copy(a, b) to a memmove call or a runtime call. // // init { // n := len(a) // if n > len(b) { n = len(b) } // if a.ptr != b.ptr { memmove(a.ptr, b.ptr, n*sizeof(elem(a))) } // }

总结

  到这儿数组和切片的根本上算是讲解结束了,是不是没想到居然有这么多细节点需求注意。数组的按值传参一定要记得,切片的slice结构界说一定要清楚,结合该结构界说,考虑切片的截取,传参,扩容等现象,应该就比较好理解了。

本文来源:【1-2 Golang】Go言语快速入门—数组与切片