Golang基础笔记三之数组和切片

本文首发于公众号:Hunter后端

原文链接:Golang基础笔记三之数组和切片

这一篇笔记介绍 Golang 里的数组和切片,以下是本篇笔记目录:

  1. 数组定义和初始化
  2. 数组属性和相关操作
  3. 切片的创建
  4. 切片的长度和容量
  5. 切片的扩容
  6. 切片操作

1、数组定义与初始化

第一篇笔记的时候介绍过数组的定义与初始化,这里再介绍一下。

数组是具有固定长度的相同类型元素的序列。

这里有两个点需要注意,数组的长度是固定的,数组的元素类型是相同的,且在定义的时候就确定好的。

1. 一维数组

我们可以通过下面几种方式对数组进行定义和赋值:

go 复制代码
    var arr [3]int
    arr[0] = 1
    arr[1] = 2
    arr[2] = 3
    fmt.Println("arr: ", arr)

也可以在定义的时候直接对其赋值:

go 复制代码
    var arr [3]int = [3]int{1, 2, 3}
    fmt.Println("arr: ", arr)

或者定义的时候不指定数量,自动获取:

go 复制代码
    var arr = [...]int{1, 2, 3}
    fmt.Println("arr: ", arr)

还可以在定义的时候,指定索引位置的值:

go 复制代码
    var arr = [...]string{0: "Peter", 3: "Tome", 1: "Hunter"}
    fmt.Println("arr: ", arr)

2. 多维数组

多维数组一般是二维数组用的较多,示例如下,表示一个两行三列的二维数组:

go 复制代码
    var s [2][3]int
    for i := 0; i < 2; i++ {
        for j := 0; j < 3; j++ {
            s[i][j] = 0
        }
    }
    fmt.Println(s)
    // [[0 0 0] [0 0 0]]

2、数组属性和相关操作

1. 获取数组长度和容量

获取数组长度和容量分别使用 len() 和 cap() 函数。

go 复制代码
    arr := [...]int{2, 3, 4}
    fmt.Println("len: ", len(arr))
    fmt.Println("cap: ", cap(arr))

对于数组而言,数组的长度是固定的,所以其长度和容量都是一样的。

对于长度和容量的概念,我们在后面介绍切片的时候,再详细介绍。

2. 数组的复制

我们可以通过 copy() 函数将一个数组复制到另一个数组,其返回值是复制元素的个数:

go 复制代码
    arr := [3]int{2, 3, 4}
    var arr2 [3]int
    numCopied := copy(arr2[:], arr[:])
    fmt.Printf("复制的元素个数:%d, arr2:%v\n", numCopied, arr2)

3. 数组的排序

我们可以引入 sort 包,使用 sort.Ints() 函数对数组进行排序:

go 复制代码
import "sort"
func main() {
    arr := [3]int{5, 2, 4}
    sort.Ints(arr[:])
    fmt.Println(arr) // 2, 4, 5
}

3、切片的创建

切片是对数组的一个连续片段的引用,它本身不存储数据,而是指向底层数据。

切片由三部分组成:指针,长度,容量。

指针指向引用的数组的起始位置,长度则是切片中元素的数量,容量则是从切片起始位置到底层数据末尾的元素数量。

下面介绍切片创建的几种方式。

1. 引用数组创建切片

切片本身的定义就是对数组的引用,所以可以通过引用数组的方式来创建切片:

go 复制代码
var arr = [3]int{1, 2, 3}
var slice = arr[1:]
fmt.Println(slice) // [2 3]

在这里,切片 slice 是从 arr 第二个元素开始引用,因此 slice 的内容是 [2, 3]。

注意,这里 slice 的切片是引用的 arr 数组,所以他们指向的是同一个内存空间,如果修改切片内的元素,会同步影响数组的元素,而修改数组的元素,也会影响切片内容:

go 复制代码
    var arr = [3]int{1, 2, 3}
    var slice = arr[1:]
    fmt.Printf("修改前: arr:%v, slice:%v\n", arr, slice)
    // 修改前: arr:[1 2 3], slice:[2 3]

    arr[1] = 7
    fmt.Printf("修改 arr 后,arr:%v, slice:%v\n", arr, slice)
    // 修改 arr 后,arr:[1 7 3], slice:[7 3]

    slice[1] = 10
    fmt.Printf("修改 slice 后,arr:%v, slice:%v\n", arr, slice)
    // 修改 slice 后,arr:[1 7 10], slice:[7 10]

2. 创建数组的方式创建切片

使用创建数组的方式不定义其长度,创建的就是一个切片:

go 复制代码
slice := []int{1, 2, 3}

3. make 的方式创建切片

使用 make 的方式创建切片,可以指定切片的长度和容量,其格式如下:

go 复制代码
var 切片名 []type = make([]type, len, [cap])

make 函数接受三个参数,第一个就是切片类型,第二个是切片长度,第三个是切片容量,其中切片容量是可选参数,如不填写则默认等于切片长度。

以下是一个创建切片的示例:

go 复制代码
    slice := make([]int, 3)
    fmt.Printf("slice length:%d, cap:%d\n", len(slice), cap(slice)) // 3 3

4、切片的长度和容量

切片的长度和容量分别使用 len()cap() 函数来获取。

长度的概念很好理解,就是切片的元素个数就是它的长度。

而对于容量,可以理解是这个切片预留的总长度,而如果切片是从数组中引用而来,其定义是 从切片的第一个元素到引用数组的最后一个元素的长度就是切片的容量

对于下面这个 arr,其长度是 5,两个切片分别从第三个和第五个元素开始引用:

go 复制代码
    arr := [5]int{1,2,3,4,5}
    slice1 := arr[2:4]
    slice2 := arr[4:]

对于 slice1, 它的长度就是 2,因为它引用的元素个数是两个

slice2 的长度是 1,它是从第五个元素开始引用,直到数组结尾。

但是两个切片的容量因为其开始引用的下标的不同而不一致,原数组总长度为 5

slice1 是从下标为 2 开始引用,所以它的容量是 5-2=3

slice2 是从下标为 4 开始引用,它的容量是 5-4=1

go 复制代码
    fmt.Printf("slice1 length:%d, cap:%d\n", len(slice1), cap(slice1))
    // slice1 length:2, cap:3

    fmt.Printf("slice2 length:%d, cap:%d\n", len(slice2), cap(slice2))
    // slice2 length:1, cap:1

5、切片的扩容

当我们向一个切片添加元素,且其长度超出了定义的容量大小,这个就涉及到切片扩容的概念。

首先,我们可以创建一个切片,然后查看其长度和容量:

go 复制代码
    slice := make([]int, 2, 2)
    slice[0] = 1
    slice[1] = 2
    fmt.Printf("slice length:%d, cap:%d, %p\n", len(slice), cap(slice), slice)
    // slice length:2, cap:2, addr:0xc0000120c0

注意,在上面创建 slice 的时候,它的长度和容量如果是一样的话,可以默认不填写 cap 参数,这里为了表示清楚,所以显式指定其长度和容量。

当我们向 slice 再添加一个元素,就已经超出了其容量大小了,因此切片会自动进行扩容,其容量会变为原来的两倍,接着可以看到切片地址已经发生了变化:

go 复制代码
    slice = append(slice, 3)
    fmt.Printf("slice length:%d, cap:%d, addr:%p\n", len(slice), cap(slice), slice)
    // slice length:3, cap:4, addr:0xc000020160

而如果再往其中添加两个元素,其容量又会扩大,变为原来的两倍,变成 8:

go 复制代码
    slice = append(slice, 4)
    slice = append(slice, 5)
    fmt.Printf("slice length:%d, cap:%d, addr:%p\n", len(slice), cap(slice), slice)
    // slice length:5, cap:8, addr:0xc00001c180

切片自动扩容规律

关于 Golang 里切片的自动扩容规律,之前搜索到这样一个扩容规律:

  1. 如果新元素追加后所需要的容量超过原容量的两倍,新容量会直接设为所需的容量
  2. 当原切片长度小于 1024 时,新容量会是原来容量的两倍
  3. 当原切片的大于等于 1024 时,新容量会是原来容量 1.25 倍

但是这个规律并不完全准确,下面是基于 go1.22.6 版本做的相应的测试:

go 复制代码
    slice := make([]int, 2)
    for _ = range 1028 {
        slice = append(slice, 1)
        fmt.Printf("slice length:%d, cap:%d, addr:%p\n", len(slice), cap(slice), &slice)
    }

对于输出的结果,后面的 cap 的变化趋势是 32, 64, 128, 256, 512, 848, 1280。

可以看到,在容量为 512 之前,确实是遵循两倍扩容的规律,但是 512 之后的扩容规律则不再是两倍,而且 1024 之后的扩容也不是 1.25 倍。

因此,去查询相关资料和源代码,发现切片自动扩容的计算分为两个阶段,第一个阶段是扩容容量计算阶段,第二个阶段是内存对齐阶段。

1) 扩容容量计算

第一阶段进行扩容容量计算的源代码如下:

go 复制代码
func nextslicecap(newLen, oldCap int) int {
    newcap := oldCap
    doublecap := newcap + newcap
    if newLen > doublecap {
        return newLen
    }
    const threshold = 256
    if oldCap < threshold {
        return doublecap
    }
    for {
        newcap += (newcap + 3*threshold) >> 2
        if uint(newcap) >= uint(newLen) {
            break
        }
    }
    if newcap <= 0 {
        return newLen
    }
    return newcap
}

其大体逻辑为:

  1. 当新切片的长度大于原容量的两倍时,直接将新容量设为所需的容量
  2. 当原容量小于 256 时,新容量为原容量的两倍
  3. 当原容量大于等于 256 时,新容量的计算规则为 新容量 = 原容量 + (原容量 + 3 * 256) / 4, 直到这个新容量大于等于新切片的长度

在这个逻辑里,当原容量的逐步增大,新容量跟原容量的关系会逐渐向 1.25 倍靠拢,这也是之前搜索到的 1.25 倍这个数字的来源。

2) 内存对齐

而在第二个内存对齐阶段,会进一步根据切片的元素的类型,使用一个函数来进行计算,由此适配到具体的需要扩容的容量大小。

比如,我们使用字符串切片来进行测试,返回的扩容容量的大小也是不一样的:

go 复制代码
    slice := make([]string, 2)
    for _ = range 1028 {
        slice = append(slice, "hell")
        fmt.Printf("slice length:%d, cap:%d, addr:%p\n", len(slice), cap(slice), &slice)
    }

因此,对于切片的自动扩容规律这个问题,我们可以如下回答:

  1. 当新切片长度大于原容量的两倍时,新容量会直接设为所需的容量
  2. 当原容量小于 256 时,新容量为原容量的两倍
  3. 当原容量大于等于 256 时,新容量的计算规则为 新容量 = 原容量 + (原容量 + 3 * 256) / 4, 直到这个新容量大于等于新切片的长度

且在上面的计算规则之后,得出的新容量会进一步根据切片元素类型的大小进行内存对齐,通过 roundupsize() 函数得到最终的容量大小。

6、切片操作

1. 增

可以使用 append() 函数对切片尾部增加元素:

go 复制代码
    slice := []int{1, 2, 3}
    fmt.Println("slice: ", slice)

    slice = append(slice, 4)
    fmt.Println("slice: ", slice)

如果同时增加多个元素,可以如下操作:

go 复制代码
    slice = append(slice, 5, 6)
    fmt.Println("slice: ", slice)

也可以把一个切片中的全部元素添加到原切片中:

go 复制代码
    slice2 := []int{7, 8}
    slice = append(slice, slice2...)
    fmt.Println("slice: ", slice)

但是如果想要把元素插入切片中的指定位置,Golang 没有提供对应的方法,所以只能通过对切片进行拆分然后拼接的方式:

go 复制代码
    targetIndex := 2
    targetValue := 3

    slice := []int{1, 2, 4, 5}

    slice = append(slice, -1)

    copy(slice[targetIndex+1:], slice[targetIndex:])

    slice[targetIndex] = targetValue
    fmt.Println("slice: ", slice) // [1 2 3 4 5]

2. 删

同样,Golang 也没有提供直接删除某个元素,或者根据索引下标删除元素的方法,但我们还是可以通过拼接的方式来实现。

比如想要删除切片中下标为 2 的元素,可以先将该索引后的元素都往挪一个下标,然后对切片进行截断:

go 复制代码
    targetIndex := 2

    slice := []int{1, 2, 3, 3, 4, 5}

    copy(slice[targetIndex:], slice[targetIndex+1:])

    slice = slice[:len(slice)-1]
    fmt.Println("slice: ", slice)  // slice:  [1 2 3 4 5]

同理,如果想要删除切片中某个指定值的元素,可以先遍历一遍切片,然后不等于该值的元素组合成一个新的切片。

3. 改

如果想要更改切片中某个下标的值,可以直接通过下标的方式进行更改:

go 复制代码
    targetIndex := 2
    targetValue := 100

    slice := []int{1, 2, 3, 3, 4, 5}

    slice[targetIndex] = targetValue
    fmt.Println("slice: ", slice)  // slice:  [1 2 100 3 4 5]

4. 查

如果想要查找切片中是否包含某个元素,也只能通过遍历的方式来进行查找,或者如果数组是有序的,可以通过二分查找等方式自己实现对应的方法。

相关推荐
XHunter2 天前
Golang基础笔记十六之反射
golang基础笔记
XHunter4 天前
Golang基础笔记十五之sync
golang基础笔记
XHunter9 天前
Golang基础笔记十四之文件操作
golang基础笔记
XHunter11 天前
Golang基础笔记十三之context
golang基础笔记
XHunter16 天前
Golang基础笔记十二之defer、panic、error
golang基础笔记
XHunter18 天前
Golang基础笔记十一之日期与时间处理
golang基础笔记
XHunter23 天前
Golang基础笔记十之goroutine和channel
golang基础笔记
XHunter25 天前
Golang基础笔记九之方法与接口
golang基础笔记
XHunter1 个月前
Golang基础笔记二之字符串及其操作
golang基础笔记