Go数组与切片全面大解析~

一、数组

1、数组定义

在Go中,数组是同一种数据类型元素的集合。数组在声明时其类型与数组长度就已确定,使用数组时可以修改数组元素,但数组长度大小不可以改变。

数组的定义:

go 复制代码
var 数组变量名 [数组长度]T

例如:

go 复制代码
// 声明一个长度为3,类型为int的数组arr
var arr [3]int

上述声明一个长度为3,类型为int的数组arr,数组的长度必须是常量且长度是数组类型的一部分,一旦数组定义好后,长度不能改变。例如[5]int[10]int是不同的类型。

数组可以通过下标进行访问,下标是从0开始,最后一个元素下标是:len-1,访问越界(下标在合法范围之外),则触发访问越界,会panic。

2、数组初始化

  • 声明数组时,默认初始化指定数据类型的零值,或者初始化数组时可以使用初始化列表来设置数组元素的值,并且可以指定索引值的方式来初始化数组。
go 复制代码
func main() {
    var intArr [3]int                              // 数组会初始化为int类型的零值
    var floatArr [3]float64 = [3]float64{1.1, 2.2} //使用指定的初始值完成初始化, 未指定完则用零值填充
    var strArr [3]bool = [3]bool{0: true, 2: true} //使用指定的初始值完成初始化, 可指定索引进行初始化
    fmt.Println(intArr)                            // [0 0 0]
    fmt.Println(floatArr)                          // [1.1 2.2 0]
    fmt.Println(strArr)                            // [true false true]
}
  • 在数组初始化时,可以让编译器根据给出的初始值的个数自行推断数组的长度。
go 复制代码
func main() {
    var intArr = [...]int{1, 2, 3}             // 自行推断数组的长度
    var numArr = [...]int{1: 1, 3: 2}          // 指定索引初始化值
    fmt.Println(intArr)                        // [1 2 3]
    fmt.Println(numArr)                        // [0 1 0 2]
    fmt.Printf("type of intArr: %T\n", intArr) // type of intArr: [3]int
    fmt.Printf("type of numArr: %T\n", numArr) // type of numArr: [4]int
}

3、数组遍历

  • 使用for循环指定索引遍历
  • 使用for range遍历
go 复制代码
func main() {
    var language = [...]string{"java", "go", "c++"}
    // 1、使用for循环遍历
    for i := 0; i < len(language); i++ {
       fmt.Println(language[i])
    }

    // 2、使用for range遍历
    for index, value := range language {
       fmt.Println(index, value)
    }
}

4、数组类型

在Go语言中,Go的数组类型为值类型,在赋值与传参时,都会拷贝整个数组,即数组的副本,此时改变副本的值,不会改变原数组的值。

go 复制代码
func main() {
    var language = [...]string{"java", "go", "c++"}
    fmt.Println("modify before: ", language)
    modifyArray(language)
    fmt.Println("modify after: ", language)
}

func modifyArray(arr [3]string) {
    arr[0] = "javascript"
}

// 执行结果
modify before:  [java go c++]
modify after:  [java go c++]

若传入数组的地址,将modifyArray的接收参数改为指针类型,即可在函数内修改原数组的值。

go 复制代码
func main() {
    var language = [...]string{"java", "go", "c++"}
    fmt.Println("modify before: ", language)
    modifyArray(&language)
    fmt.Println("modify after: ", language)
}

func modifyArray(arr *[3]string) {
    arr[0] = "javascript"
}

// 执行结果
modify before:  [java go c++]
modify after:  [javascript go c++]

上述代码中,通过接收*[3]string的数组指针,modifyArray函数内的数组指针变量指向同一个底层数组 ,此时修改的是原数组而非数组的拷贝值。[n]*T表示指针数组,数组的每个元素存放的是指针元素,而*[n]T表示数组类型的指针。

由于Go数组为值类型,因此支持"==""!="比较操作符,因为Go数组在任何时候在内存总是被初始化过。

5、多维数组

二维数组的声明及初始化如下:

go 复制代码
func main() {
    arr := [3][2]string{
       {"java", "go"},
       {"mysql", "redis"},
       {"kafka", "rabbitmq"},
    }
    fmt.Println(arr) // [[java go] [mysql redis] [kafka rabbitmq]]
}

二、切片

1、切片的概念

切片(slice)是对数组一个连续片段的引用,切片可以看做是一种简化版的动态数组,切片的长度可以随时变化(可以在运行时修改,即扩容,最小为 0 最大为相关数组的长度),长度并不是类型的组成部分,提供了一个相关数组的动态窗口,slice是一个长度可变的数组

切片是一个引用类型 ,底层引用一个数组对象。一个slice由三个部分构成:指针、长度和容量 。指针指向第一个slice元素对应的底层数组元素的地址,但slice指针指向的第一个元素并不一定就是底层数组的第一个元素。

切片是可索引的,可以用 len() 函数获取切片的长度。计算容量函数 cap() 可以测量切片最长可以达到多少:容量等于切片从第一个元素开始,到相关数组末尾的元素个数。如果 s 是一个切片,cap(s)容量就是从 s[0] 到数组末尾的数组长度。切片的长度不会超过容量,所以对于切片来说不等式永远成立:0 <= len(s) <= cap(s)

多个slice之间可以共享底层的数据,并且引用的数组部分区间可能重叠。因为切片是引用,所以它们不需要使用额外的内存并且比使用数组更有效率,所以在 Go 代码中 切片比数组更常用。

2、切片的定义

(1)切片声明

声明切片 的格式:var variable []TypeName。其中variable切片变的变量名,TypeName则表示切片中的元素类型。一个切片在未初始化之前默认为 nil,长度为 0。

go 复制代码
func main() {
    // 声明切片类型
    var strSlice []string                     // 声明一个字符串切片
    var numSlice = []int{}                    // 声明一个整型切片并初始化
    var boolSlice = []bool{true, false, true} // 声明一个布尔切片并初始化
    fmt.Println(strSlice)                     // []
    fmt.Println(numSlice)                     // []
    fmt.Println(boolSlice)                    // [true false true]
}

切片也可以用类似数组的方式初始化:var x = []int{2, 3, 5, 7, 11} 或者x := []int{2, 3, 5, 7, 11}。这样就创建了一个长度为 5 的底层数组并且创建了一个相关切片。

(2)切片表达式

切片表达式可以从字符串、数组、切片的指针去初始化一个新的切片。切片实际上是指向底层数组,可以通过切片表达式得到新的切片。切片表达式格式var slice []type = arr[start:end]。该表达式表示切片slice指向底层数组arr,且由数组arrstart索引到end-1索引之间的元素构成的子集,即 slice1[0] 就等于 arr[start]

go 复制代码
func main() {
    num := [5]int{1, 2, 3, 4, 5}
    slice := num[1:3]
    fmt.Printf("slice: %v len: %d cap: %d", slice, len(slice), cap(slice))
}

// 执行结果
slice: [2 3] len: 2 cap: 4

如果初始化表达式为:var slice []type = arr[:] ,那么 slice 就等于完整的 arr 数组,是arr[0:len(arr)] 的一种缩写,另外一种表述方式是:slice = &arr

go 复制代码
func main() {
    arr := [...]int{1, 2, 3, 4, 5}
    slice1 := arr[:]
    slice2 := arr[0:len(arr)]
    slice3 := &arr
    fmt.Println(slice1)
    fmt.Println(slice2)
    fmt.Println(slice3)
}

执行结果:
[1 2 3 4 5]
[1 2 3 4 5]
&[1 2 3 4 5]

上述切片初始化中,可以注意到slice2的初始化为arr[0:len(arr)],而len(arr)为数组arr的长度,arr数组不存在len(arr)值的下标,说明该切片表达式为左闭右开,举个例子:

go 复制代码
func main() {
    arr := [...]int{1, 2, 3, 4, 5}
    slice := arr[1:3]
    fmt.Println(slice)
}
执行结果:
[2 3]

上述例子可以看出,arr数组下标3为元素4,而切片slice的范围则到元素3。

类似地,arr[2:]arr[2:len(arr1)] 相同,都包含了数组从第三个到最后的所有元素。arr[:3]arr[0:3] 相同,包含了从第一个到第三个元素。

(3)make()构造切片

make 的使用方式是:func make([]T, len, cap),make()函数可以动态的创建一个切片。上述表达式中:

  • T: 切片的元素类型
  • len: 切片中元素的数量
  • cap: 切片的容量
go 复制代码
func main() {
    num := make([]int, 2, 10)
    fmt.Println(num) // [0 0]
    fmt.Println(len(num)) // 2
    fmt.Println(cap(num)) // 10
}

当底层关联数组还没有定义时,可以使用 make() 函数来创建一个切片,同时创建好相关数组:var slice []type = make([]type, len),简写为slice := make([]type, len)len 是数组的长度并且也是 slice 的初始长度。

make 接受 2 个参数:元素的类型以及切片的元素个数 。例如,slice := make([]int, 10),其中容量cap(slice) == 长度len(slice) == 10

如果想创建一个 slice 且不包含整个底层数组,而只是占用以 len 为个数,那么使用make函数slice := make([]type, len, cap)

使用 make 生成的切片的内存结构:

3、切片的本质

切片的本质实际上就是对底层数组的封装,切片包含的三个信息:指向底层数组的指针、切片的长度len,切片的容量cap。

  • 数组num := [8]int{0, 1, 2, 3, 4, 5, 6, 7},切片s1 := num[:5]
  • 切片s2 := num[3:6]

4、切片的使用

(1)切片遍历

slice的遍历与数组类似,同样可以使用for或者for range循环遍历切片元素。

go 复制代码
func main() {
    arr := [...]int{1, 2, 3, 4, 5}
    for i := range arr {
       fmt.Printf("arr[%d]: %d\n", i, arr[i])
    }
    fmt.Println()
    for i, v := range arr {
       fmt.Printf("arr[%d]: %d\n", i, v)
    }
    fmt.Println()
    for i := 0; i < len(arr); i++ {
       fmt.Printf("arr[%d]: %d\n", i, arr[i])
    }
}

(2)切片空判断

如果需要检查切片是否为空,不应该使用s == nil来判断,应始终使用len(s) == 0来进行切片判空。

go 复制代码
func main() {
    var s1 []string
    fmt.Printf("s1:nil=%t, len=%d, cap=%d\n", s1 == nil, len(s1), cap(s1))

    s2 := []string{}
    fmt.Printf("s2:nil=%t, len=%d, cap=%d\n", s2 == nil, len(s2), cap(s2))

    s3 := make([]string, 0)
    fmt.Printf("s3:nil=%t, len=%d, cap=%d\n", s3 == nil, len(s3), cap(s3))
}

// 执行结果
s1:nil=true, len=0, cap=0
s2:nil=false, len=0, cap=0
s3:nil=false, len=0, cap=0

我们知道切片是由指向底层数组的指针、切片的长度len,切片的容量cap三部分组成的,由上述代码的执行结果可知,s1、s2、s3三个切片的len和cap都为0,即都为空切片。但只有s1切片与nil进行比较的结果为nil

nil是为pointer、channel、func、interface、map或slice类型预定义的标识符,代表这些类型的零值。

由此可知在Go中,nil为上述类型的零值,即切片的默认零值为nil,所以上述代码中s1nil切片nil切片除了长度len和容量cap为0外,其切片指针不指向任何底层数组 (切片指针为nil)。而s2s3切片虽然长度len和容量cap为0,但是其指针指向底层数组,只是其底层数组为空数组,这也是nil切片空切片的区别,但nil切片也属于空切片

  • nil切片(切片指针为nil)与空切片(切片指针不为nil)的长度为0,容量由指向的底层数组决定。
  • 空切片 != nil切片
  • nil切片的ptr指针是nil,而空切片的ptr指针指向底层数组的地址

回到刚才的问题:

不应该使用s == nil来判断切片是否为空,应始终使用len(s) == 0来进行切片判空

简单来说就是:切片指向了底层数组,其切片指针不为nil,但切片指针指向的底层数组为空数组也属于空切片的范畴,因此使用s == nil判空不总是正确的检查方法。

一个nil值的切片并没有底层数组,一个nil值的切片的长度和容量都是0。但不能说一个长度和容量都是0的切片一定是nil

(3)切片赋值

由于切片是引用类型,因此可能会出现两个切片指向同一个底层数组,这个时候需要注意的是,对一个切片的修改会影响到另一个切片,其原因是修改了切片指向的底层数组。

go 复制代码
func main() {
    num := [5]int{1, 2, 3, 4, 5} // 初始化数组
    s1 := num[1:3]
    s2 := num[:4]
    fmt.Println(num) // [1 2 3 4 5]
    fmt.Println(s1)  // [2 3]
    fmt.Println(s2)  // [1 2 3 4]
    s1[0] = 100
    // 对切片s1修改,实际上修改了指向的底层数组num,切片s2也受到影响
    fmt.Println(num) // [1 100 3 4 5]
    fmt.Println(s1)  // [100 3]
    fmt.Println(s2)  // [1 100 3 4]
}

(4)添加元素

Go内置的函数append() 可以在切片的尾部追加 N 个元素,可以一次性添加一个或多个元素,也可以添加另一个切片中的元素。

go 复制代码
func main() {
    var a []int
    a = append(a, 1)       // 追加 1 个元素
    fmt.Println(a)         // [1]
    a = append(a, 2, 3, 4) // 追加多个元素, 手写解包方式
    fmt.Println(a) // [1 2 3 4]
    a = append(a, []int{5, 6, 7}...) // 追加 1 个切片, 切片需要解包
    fmt.Println(a) // [1 2 3 4 5 6 7]
}

nil切片可以在append()函数中直接使用,无需初始化。

除了在切片的尾部追加,还可以在切片的开头添加元素:

go 复制代码
func main() {
    var a = []int{1, 2, 3}
    a = append([]int{0}, a...)          // 在开头添加 1 个元素
    fmt.Println(a)                      // [0 1 2 3]
    a = append([]int{-3, -2, -1}, a...) // 在开头添加 1 个切片
    fmt.Println(a) // [-3 -2 -1 0 1 2 3]
}

在开头一般都会导致内存的重新分配,而且会导致已有的元素全部复制一 次。因此,从切片的开头添加元素的性能一般要比从尾部追加元素的性能差很多。

前面说到切片指向一个底层数组,切片的容量由底层数组决定。

  • 如果底层数组容量足够则直接添加新增元素。
  • 若当底层数组不能容纳新增的元素时,切片就会自动按照一定的策略进行扩容,此时该切片指向的底层数组就会更换。
go 复制代码
func main() {
    var numSlice []int
    for i := 0; i < 10; i++ {
       numSlice = append(numSlice, i)
       fmt.Printf("value: %v len: %d cap: %d ptr: %p\n", numSlice, len(numSlice), cap(numSlice), numSlice)
    }
}

// 执行结果
value: [0] len: 1 cap: 1 ptr: 0xc0000aa058
value: [0 1] len: 2 cap: 2 ptr: 0xc0000aa0a0
value: [0 1 2] len: 3 cap: 4 ptr: 0xc0000a8080
value: [0 1 2 3] len: 4 cap: 4 ptr: 0xc0000a8080
value: [0 1 2 3 4] len: 5 cap: 8 ptr: 0xc0000c40c0
value: [0 1 2 3 4 5] len: 6 cap: 8 ptr: 0xc0000c40c0
value: [0 1 2 3 4 5 6] len: 7 cap: 8 ptr: 0xc0000c40c0
value: [0 1 2 3 4 5 6 7] len: 8 cap: 8 ptr: 0xc0000c40c0
value: [0 1 2 3 4 5 6 7 8] len: 9 cap: 16 ptr: 0xc0000d6100
value: [0 1 2 3 4 5 6 7 8 9] len: 10 cap: 16 ptr: 0xc0000d6100
  • append()函数将元素追加到切片的最后并返回该切片。
  • 切片numSlice的容量变化为1,2,4,8,16自动进行扩容,每次扩容后都是扩容前的2倍。

从上述代码可以看到,每次cap发生扩容时,指向底层数组的指针就会发生变化,即指向了新的底层数组。

扩容操作往往发生在append()函数调用时,所以通常都需要用原变量接收append函数的返回值。后续也会探讨append()函数的扩容策略。

(5)删除元素

使用切片本身的特性来删除元素,若需要从切片a中删除索引为index的元素,则可以a = append(a[:index], a[index+1:]...)

根据要删除元素的位置有三种情况:从开头位置删除,从中间位置删除,从尾部删除。其中删除切片尾部的元素最快:

go 复制代码
a := []int{1, 2, 3}
a = a[:len(a)-1]   // 删除尾部 1 个元素
a = a[:len(a)-N]   // 删除尾部 N 个元素

删除开头的元素可以直接移动数据指针:

go 复制代码
a := []int{1, 2, 3}
a = a[1:] // 删除开头 1 个元素
a = a[N:] // 删除开头 N 个元素

删除指定index索引的元素:

go 复制代码
func main() {
    a := []int{1, 2, 3}
    fmt.Println(a) // [1 2 3]
    a = append(a[:1], a[1+1:]...)
    fmt.Println(a) // [1 3]
}

(6)切片复制

刚才说到,由于切片是引用类型,因此当两个切片指向指向了同一块内存地址时,即同一个底层数组,则有可能修改一个切片的元素值会导致另一个切片受到影响。

go 复制代码
func main() {
    num := [5]int{1, 2, 3, 4, 5} // 初始化数组
    s1 := num[1:3]
    s2 := num[:4]
    fmt.Println(num) // [1 2 3 4 5]
    fmt.Println(s1)  // [2 3]
    fmt.Println(s2)  // [1 2 3 4]
    s1[0] = 100
    // 对切片s1修改,实际上修改了指向的底层数组num,切片s2也受到影响
    fmt.Println(num) // [1 100 3 4 5]
    fmt.Println(s1)  // [100 3]
    fmt.Println(s2)  // [1 100 3 4]
}

Go中的内建函数copy()函数可以将一个切片的数据复制到一个新的数组中。

copy()函数的函数签名:

go 复制代码
func copy(dst, src []Type) int
  • src:提供数据来源的切片
  • dst:目标切片
  • []Type: 切片类型
go 复制代码
func main() {
    src := []int{1, 2, 3, 4, 5}
    dst := make([]int, 5, 5)
    copy(dst, src)   //使用copy()函数将切片src中的元素复制到切片dst
    fmt.Println(src) // [1 2 3 4 5]
    fmt.Println(dst) // [1 2 3 4 5]
    dst[0] = 1000
    fmt.Println(src) // [1 2 3 4 5]
    fmt.Println(dst) // [1000 2 3 4 5]
}

5、 切片的底层原理

(1)底层实现

Go切片的数据结构定义在源码的runtime/slice.go中,具体的struct如下:

go 复制代码
// go1.19 runtime/slice.go
type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

上述结构体中:

  • array unsafe.Pointer: 执行底层数组的指针,也是切片起点的地址;
  • len int : 切片长度,即 slice 中实际存放了多少个元素。
  • cap int: 切片容量,即 slice 分配了足够用于存放多少个元素的空间,一般与底层数组的长度挂钩,切片容量大小,容量必须大于或等于切片的长度。

(2)扩容策略

append()函数可以在切片中用于添加元素,而当添加元素的时候,若切片的容量不足,则会发送扩容,扩容策略的执行原理对于理解slice底层是如何工作的非常重要。具体可以查看runtime/slice.go源码中查看扩容策略:

如下代码为Go1.19中runtime/slice.go文件下的growslice函数中的部分代码:

go 复制代码
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
       }
    }
}

由上述代码可知,大致的扩容策略流程为:

  • 第1行中,将最终容量(newcap)设置为旧容量(old.cap),并在第二行中计算旧容量的两倍容量(doublecap)。
  • 第3行中,判断新申请容量大小(cap,为传入函数中的参数)是否大于两倍的旧容量(doublecap),如果大于则使用新申请容量作为扩容后的切片容量,将cap赋值给newcap
  • 若第3行的判断不成立,则进入else,在第6行中,设置了一个常量阈值(threshold)为256
  • 第7行中,如果旧容量(old.cap)小于设定的常量阈值(threshold = 256),则最终容量(newcap)使用使用两倍的旧容量(doublecap)
  • 否则,在12行至17行中,最终容量在小于指定容量(cap)的区间内,在循环内按照(newcap + 3*threshold) / 4递增。
  • 在第20,21行中,如果计算出的最终容量(newcap)溢出,则最终容量(cap)就是新申请容量(cap)

不同于旧版本的扩容策略,旧版本中使用1024作为临界点。Go新版本的扩容策略优化了扩容的策略,通过减少常量阈值(threshold)并固定增加一个常数容量,这样使得新容量在阈值前后扩容的系数不会从2x突变到1.25x,而是两者之间的平滑过渡。可以执行如下代码查看切片的扩容情况:

go 复制代码
func main() {
    var slice = make([]int, 0, 0)
    var newCap int
    oldCap := cap(slice)

    for i := 0; i < 2000; i++ {
       slice = append(slice, i)
       newCap = cap(slice)

       if newCap > oldCap {
          fmt.Printf("i = %d, currentLen = %d, oldCap = %d, newCap = %d\n", len(slice), i, oldCap, newCap)
       }
    }
}

思维导图

相关推荐
姜学迁1 小时前
Rust-枚举
开发语言·后端·rust
爱学习的小健2 小时前
MQTT--Java整合EMQX
后端
北极小狐2 小时前
Java vs JavaScript:类型系统的艺术 - 从 Object 到 any,从静态到动态
后端
【D'accumulation】2 小时前
令牌主动失效机制范例(利用redis)注释分析
java·spring boot·redis·后端
2401_854391082 小时前
高效开发:SpringBoot网上租赁系统实现细节
java·spring boot·后端
Cikiss2 小时前
微服务实战——SpringCache 整合 Redis
java·redis·后端·微服务
Cikiss2 小时前
微服务实战——平台属性
java·数据库·后端·微服务
OEC小胖胖3 小时前
Spring Boot + MyBatis 项目中常用注解详解(万字长篇解读)
java·spring boot·后端·spring·mybatis·web
2401_857617623 小时前
SpringBoot校园资料平台:开发与部署指南
java·spring boot·后端
计算机学姐3 小时前
基于SpringBoot+Vue的在线投票系统
java·vue.js·spring boot·后端·学习·intellij-idea·mybatis