把切片说得明明白白

119 阅读2分钟

1.1 前言

数组和切片都是是 Go 语言中常见的数据结构,而两者因为部分的相似性又常常被混淆,其实在实际使用上数组和切片有着巨大的差别,在认识切片之前我们应该先重新认识一下数组。

1.2 概述

1.2.1 数组

数组是由相同类型元素的集合组成的数据结构,计机会为数组分配一块连续的内存来保存其中的元素,我们可以利用索引快速访问特定元素,常见的数组大多都是一维的线性数组,而多维数组在数值和图形计算领域却有比较常见的应用。

我们通常会从数组的存储元素类型和最大存储元素的个数来描述一个数组,而 Go 语言中我们使用如下方式表示数组类型。

[10]int
[200]interface{}

Go 语言中数组在初始化后大小就不可改变了,存储元素相同但大小不同的数组类型在 Go 语言中是完全不同的,只有二者都相同才能被认定为同一类型。在 Go 语言的编译期间数组类型由cmd/compile/internal/types.NewArray函数生成,该类型包含两个字段分别是元素类型 Elem 和数组的大小 Bound,这俩共同构成了数组类型。

func NewArray(elem *Type, bound int64) *Type {
    if bound < 0 {
        Fatalf("NewArray: invalid bound %v", bound)
    }
    t := New(TARRAY)
    //数组类型的描述
    t.Extra = &Array{Elem: elem, Bound: bound}
    t.SetNotInHeap(elem.NotInHeap())
    return t
}

1.2.2 切片

切片是 Go 语言特有的数据结构,即动态数组,其长度不固定,我们可以向切片中追加元素,它会在容量不足时自动扩容。在 Go 语言中我们更多的时候使用的是切片而非数组。

在 Go 语言中,切片类型的声明方式与数组有一些相似,不过由于切片的长度是动态的,所以声明时只需要指定切片中的元素类型:

[]int
[]interface{}

由此可见,在编译期间生成的切片类型只会包含切片中的元素类型,该切片类型由cmd/compile/internal/types.NewSlice函数生成。

func NewSlice(elem *Type) *Type {
    if t := elem.Cache.slice; t != nil {
        if t.Elem() != elem {
            Fatalf("elem mismatch")
        }
        return t
    }

    t := New(TSLICE)
    //切片类型的描述
    t.Extra = Slice{Elem: elem}
    elem.Cache.slice = t
    return t
}

由此可见,切片内元素的类型是在编译期间就确定了,并将类型存储在Extra字段中帮助程序在运行时动态获取。

1.3 数据结构

1.3.1 数组结构

数组是一个固定长度的存储相同数据类型的数据结构,数组中的元素被存储在一段连续的内存空间中。它是最简单的数据结构之一,大多数现代编程语言都内置数组支持。

image.png

1.3.2 切片结构

在编译期间的切片是cmd/compile/internal/types.Slice类型的,但是在运行时切片可以由如下的reflect.SliceHeader结构体表示,其中:

  • Data是指向数组的指针;
  • Len是当前切片的长度;
  • Cap是当前切片的容量,即Data数组的大小:
type SliceHeader struct {
    Data uintptr
    Len  int
    Cap  int
}

Data是一片连续的内存空间,这和链表或者集合有着本质的区别,这片空间可以存储切片的全部元素,数组中的元素在底层存储是连续的,所以我们可以将切片理解为一片连续的内存空间加上长度和容量的标识。

image.png

从上图可见切片其实是对数组中部分连续片段的引用,我们可以在运行期间修改其长度和范围,当切片底层的数组长度不足时就会触发扩容,切片指向的数组可能会发生变化,但是在上层切片看来是没有变化的,上层只需要关心切片而不需要关心数组。但是其下层数组在扩容时又会导致相关的性能问题,这是需要开发人员注意的。

1.4 初始化及使用

1.4.1 初始化数组

Go 语言的数组初始化有以下两种方式,其中第二种可以在编译期间自动推导数组大小,算是 Go 语言中的语法糖。

arr1 := [3]int{1, 2, 3}
arr2 := [...]int{1, 2, 3}

而 Go 语言对数组大小的推导则会在cmd/compile/internal/gc.typecheckcomplit中完成。

func typecheckcomplit(n *Node) (res *Node) {
    ...
    if n.Right.Op == OTARRAY && n.Right.Left != nil && n.Right.Left.Op == ODDD {
        n.Right.Right = typecheck(n.Right.Right, ctxType)
        if n.Right.Right.Type == nil {
            n.Type = nil
            return n
        }
        elemType := n.Right.Right.Type

        length := typecheckarraylit(elemType, -1, n.List.Slice(), "array literal")

        n.Op = OARRAYLIT
        n.Type = types.NewArray(elemType, length)
        n.Right = nil
        return n
    }

而这个cmd/compile/internal/gc.typecheckcomplit方法其实调用了typecheckarraylit通过遍历元素的方式来计算数组中元素的数量。所以,以上两种数组初始化的方式区别只体现在编译期间,其实在运行时是完全等价的,我们可以通过第二种方式减少一些开发时的工作量。

1.4.2 初始化切片

Go 语言初始化切片有三种方式:

  1. 通过下标的方式获得数组或者切片的一部分;
  2. 使用字面量初始化新的切片;
  3. 使用关键字 make 创建切片;
arr[0:3] or slice[0:3]    // 1
slice := []int{1, 2, 3}   // 2
slice := make([]int, 10)  // 3

使用下标

该方式最原始也最接近汇编语言的方式,是最为底层的一种方式,编译器会将arr[0:3]或者slice[0:3]等语句转换为OpSliceMake操作,OpSliceMake的实现如下。

// ch03/op_slice_make.go
package opslicemake

func newSlice() []int {
	arr := [3]int{1, 2, 3}
	slice := arr[0:1]
	return slice
}

以上代码在通过GOSSAFUNC变量编译后可以的到一系列 SSA 中间代码,其中slice := arr[0:1]语句对应的代码如下。

v27 (+5) = SliceMake <[]int> v11 v14 v17

name &arr[*[3]int]: v11
name slice.ptr[*int]: v11
name slice.len[int]: v14
name slice.cap[int]: v17

其实SliceMake操作就是接收了四个参数创建新切片而已,分别是元素类型、数组指针、切片大小和容量。需要注意的是,使用下标初始化切片并不会拷贝原数组或原切片,只是创建了一个指向原数组或者原切片结构体的指针,所以修改新切片的同时原切片也会被修改。

文章参考:左书祺《Go语言设计与编译》——「编译原理」