跳转至

切片

切片介绍

切片是Go语言中非常常用的数据类型之一,使用方式和数组一样,但是其长度并不固定,我们可以向切片中追加元素,它会在容量不足时自动扩容。

声明

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

[]int

[]interface{}

从切片的定义能推测出,切片在编译期间的生成的类型只会包含切片中的元素类型,即 int 或者 interface{}

cmd/compile/internal/types.NewSlice 是编译期间用于创建切片类型的函数,源码:https://github.com/golang/go/blob/go1.20.1/src/cmd/compile/internal/types/type.go#L637-L659

// NewSlice returns the slice Type with element type elem.
func NewSlice(elem *Type) *Type {
    if t := elem.cache.slice; t != nil {
        if t.Elem() != elem {
            base.Fatalf("elem mismatch")
        }
        if elem.HasTParam() != t.HasTParam() || elem.HasShape() != t.HasShape() {
            base.Fatalf("Incorrect HasTParam/HasShape flag for cached slice type")
        }
        return t
    }

    t := newType(TSLICE)
    t.extra = Slice{Elem: elem}
    elem.cache.slice = t
    if elem.HasTParam() {
        t.SetHasTParam(true)
    }
    if elem.HasShape() {
        t.SetHasShape(true)
    }
    return t
}

上述方法返回结构体中的 extra 字段是一个只包含切片内元素类型的结构,也就是说切片内元素的类型都是在编译期间确定的,编译器确定了类型之后,会将类型存储在 extra 字段中帮助程序在运行时动态获取

cmd/compile/internal/types.Type 源码如下:https://github.com/golang/go/blob/go1.20.1/src/cmd/compile/internal/types/type.go#L132-L202

// A Type represents a Go type.
//
// There may be multiple unnamed types with identical structure. However, there must
// be a unique Type object for each unique named (defined) type. After noding, a
// package-level type can be looked up by building its unique symbol sym (sym =
// package.Lookup(name)) and checking sym.Def. If sym.Def is non-nil, the type
// already exists at package scope and is available at sym.Def.(*ir.Name).Type().
// Local types (which may have the same name as a package-level type) are
// distinguished by the value of vargen.
type Type struct {
    // extra contains extra etype-specific fields.
    // As an optimization, those etype-specific structs which contain exactly
    // one pointer-shaped field are stored as values rather than pointers when possible.
    //
    // TMAP: *Map
    // TFORW: *Forward
    // TFUNC: *Func
    // TSTRUCT: *Struct
    // TINTER: *Interface
    // TFUNCARGS: FuncArgs
    // TCHANARGS: ChanArgs
    // TCHAN: *Chan
    // TPTR: Ptr
    // TARRAY: *Array
    // TSLICE: Slice
    // TSSA: string
    // TTYPEPARAM:  *Typeparam
    // TUNION: *Union
    extra interface{}

    // width is the width of this Type in bytes.
    width int64 // valid if Align > 0

    // list of base methods (excluding embedding)
    methods Fields
    // list of all methods (including embedding)
    allMethods Fields

    // canonical OTYPE node for a named type (should be an ir.Name node with same sym)
    obj Object
    // the underlying type (type literal or predeclared type) for a defined type
    underlying *Type

    // Cache of composite types, with this type being the element type.
    cache struct {
        ptr   *Type // *T, or nil
        slice *Type // []T, or nil
    }

    vargen int32 // unique name for OTYPE/ONAME

    kind  Kind  // kind of type
    align uint8 // the required alignment of this type, in bytes (0 means Width and Align have not yet been computed)

    flags bitset8

    // For defined (named) generic types, a pointer to the list of type params
    // (in order) of this type that need to be instantiated. For instantiated
    // generic types, this is the targs used to instantiate them. These targs
    // may be typeparams (for re-instantiated types such as Value[T2]) or
    // concrete types (for fully instantiated types such as Value[int]).
    // rparams is only set for named types that are generic or are fully
    // instantiated from a generic type, and is otherwise set to nil.
    // TODO(danscales): choose a better name.
    rparams *[]*Type

    // For an instantiated generic type, the base generic type.
    // This backpointer is useful, because the base type is the type that has
    // the method bodies.
    origType *Type
}

数据结构

编译期间的切片是 cmd/compile/internal/types.Slice 类型,源码:https://github.com/golang/go/blob/go1.20.1/src/cmd/compile/internal/types/type.go#L476-L479

// Slice contains Type fields specific to slice types.
type Slice struct {
    Elem *Type // element type
}

但是在运行时切片会转换成 reflect.SliceHeader 结构体,源码:https://github.com/golang/go/blob/go1.20.1/src/reflect/value.go#L2752-L2764

// SliceHeader is the runtime representation of a slice.
// It cannot be used safely or portably and its representation may
// change in a later release.
// Moreover, the Data field is not sufficient to guarantee the data
// it references will not be garbage collected, so programs must keep
// a separate, correctly typed pointer to the underlying data.
//
// In new code, use unsafe.Slice or unsafe.SliceData instead.
type SliceHeader struct {
    Data uintptr
    Len  int
    Cap  int
}
  • Data 是指向数组的指针;
  • Len 是当前切片的长度;
  • Cap 是当前切片的容量,即 Data 数组的大小。

Data 是一片连续的内存空间,这片内存空间可以用于存储切片中的全部元素,数组中的元素只是逻辑上的概念,底层存储其实都是连续的,所以可以将切片理解成一片连续的内存空间加上长度与容量的标识

如下图所示:

image-20230226121433436

会发现切片与数组的关系非常密切,切片引入了一个抽象层,提供了对数组中部分连续片段的引用,而作为数组的引用,可以在运行区间可以修改它的长度和范围

当切片底层的数组长度不足时就会触发扩容,切片指向的数组可能会发生变化,不过在上层看来切片是没有变化的,上层只需要与切片打交道不需要关心数组的变化

切片初始化

Go 语言中包含三种初始化切片的方式:

  • 通过下标的方式获得数组或者切片的一部分
  • 使用字面量初始化新的切片
  • 使用关键字 make 创建切片

使用下标

使用下标创建切片是最原始也最接近汇编语言的方式,它是所有方法中最为底层的一种,编译器会将 arr[0:3] 或者 slice[0:3] 等语句转换成 OpSliceMake 操作

可以通过下面的代码来验证:

package main

import "fmt"

func main() {
    arr := [3]int{1, 2, 3}
    slice := arr[0:1]
    fmt.Println(slice)
}

生成 SSA

GOSSAFUNC=main go build main.go

其中 slice := arr[0:1] 语句在 decompose builtin 阶段对应的代码如下图所示:

image-20230226180242506

SliceMake 操作会接受四个参数创建新的切片,元素类型、数组指针、切片大小和容量

v1 (?) = InitMem <mem>
v2 (?) = SP <uintptr>
v3 (?) = SB <uintptr>
v4 (?) = Addr <*uint8> {type:[3]int} v3
v5 (+6) = StaticLECall <*[3]int,mem> {AuxCall{runtime.newobject}} [16] v4 v1
v6 (6) = SelectN <mem> [1] v5
v7 (6) = SelectN <*[3]int> [0] v5 (&arr[*[3]int], slice.ptr[*int])
v8 (?) = Addr <*[3]int> {main..stmp_0} v3
v9 (6) = Move <mem> {[3]int} [24] v7 v8 v6
v10 (?) = Const64 <int> [1] (slice.len[int], a.len[int], a.cap[int])
v13 (?) = Const64 <int> [3] (slice.cap[int])
...
v23 (+7) = SliceMake <[]int> v7 v10 v13
...

需要注意的是使用下标初始化切片不会拷贝原数组或者原切片中的数据,它只会创建一个指向原数组的切片结构体,所以修改新切片的数据也会修改原切片

package main

import "fmt"

func main() {
    oldArr := [5]int{1, 2, 3, 4, 5}
    // 使用下标创建切片
    newSlice := oldArr[0:3]
    fmt.Println("修改切片前-> oldArr:", oldArr, "newSlice:", newSlice)
    // 修改新切片值
    newSlice[0] = 100
    fmt.Println("修改切片后-> oldArr:", oldArr, "newSlice:", newSlice)
}

/** 输出
修改切片前-> oldArr: [1 2 3 4 5] newSlice: [1 2 3]
修改切片后-> oldArr: [100 2 3 4 5] newSlice: [100 2 3]
*/

使用字面量

当使用字面量创建新的切片时

[]int{1, 2, 3}

cmd/compile/internal/walk/complit.slicelit 函数会在编译期间将它展开成如下所示的代码片段:

var vstat [3]int
vstat[0] = 1
vstat[1] = 2
vstat[2] = 3

var vauto *[3]int = new([3]int)
*vauto = vstat
slice := vauto[:]

对上述代码分析:

  1. 根据切片中的元素数量对底层数组的大小进行推断并创建一个数组
  2. 将这些字面量元素存储到初始化的数组中
  3. 创建一个同样指向 [3]int 类型的数组指针
  4. 将静态存储区的数组 vstat 赋值给 vauto 指针所在的地址
  5. 通过 [:] 操作获取一个底层使用 vauto 的切片;这一步中的 [:] 就是使用下标创建切片的方法,从这一点我们也能看出 [:] 操作是创建切片最底层的一种方法

slicelit 源码:https://github.com/golang/go/blob/go1.20.1/src/cmd/compile/internal/walk/complit.go#L288-L415

使用 make

如果使用字面量的方式创建切片,大部分的工作都会在编译期间完成;但是当使用 make 关键字创建切片时,很多工作都需要运行时的参与

调用方必须向 make 函数传入切片的大小以及可选的容量

类型检查期间的 cmd/compile/internal/typecheck/typecheck.typecheck1函数会校验入参,源码位置:https://github.com/golang/go/blob/go1.20.1/src/cmd/compile/internal/typecheck/typecheck.go#L667-L669

    case ir.OMAKE:
        n := n.(*ir.CallExpr)
        return tcMake(n)

最终调用 cmd/compile/internal/typecheck/func.tcMake,源码位置:https://github.com/golang/go/blob/go1.20.1/src/cmd/compile/internal/typecheck/func.go#L624-L731

对于切片类型,会执行:

    case types.TSLICE:
        // ...
        if ir.IsConst(l, constant.Int) && r != nil && ir.IsConst(r, constant.Int) && constant.Compare(l.Val(), token.GTR, r.Val()) {
            base.Errorf("len larger than cap in make(%v)", t)
            n.SetType(nil)
            return n
        }
        nn = ir.NewMakeExpr(n.Pos(), ir.OMAKESLICE, l, r)

上述函数不仅会检查 len 是否传入,还会保证传入的容量 cap 一定大于或者等于 len

除了校验参数之外,当前函数会将 OMAKE 节点转换成 OMAKESLICE,该节点会被 walkMakeSlice 处理,源码:https://github.com/golang/go/blob/go1.20.1/src/cmd/compile/internal/walk/expr.go#L294-L296

    case ir.OMAKESLICE:
        n := n.(*ir.MakeExpr)
        return walkMakeSlice(n, init)

中间代码生成的 cmd/compile/internal/walk/builtin.walkMakeSlice 函数会依据下面两个条件转换 OMAKESLICE 类型的节点:

  1. 切片的大小和容量是否足够小
  2. 切片是否发生了逃逸,最终在堆上初始化

当切片发生逃逸或者非常大时,运行时需要 runtime.makeslice 或者 makeslice.makeslice64 在堆上初始化切片,如果当前的切片不会发生逃逸并且切片非常小的时候,那么切片运行时最终会被分配在栈中

源码:https://github.com/golang/go/blob/go1.20.1/src/cmd/compile/internal/walk/builtin.go#L377-L445

// walkMakeSlice walks an OMAKESLICE node.
func walkMakeSlice(n *ir.MakeExpr, init *ir.Nodes) ir.Node {
    l := n.Len
    r := n.Cap
    if r == nil {
        r = safeExpr(l, init)
        l = r
    }
    t := n.Type()
    if t.Elem().NotInHeap() {
        base.Errorf("%v can't be allocated in Go; it is incomplete (or unallocatable)", t.Elem())
    }
    if n.Esc() == ir.EscNone {
        if why := escape.HeapAllocReason(n); why != "" {
            base.Fatalf("%v has EscNone, but %v", n, why)
        }
        // var arr [r]T
        // n = arr[:l]
        i := typecheck.IndexConst(r)
        if i < 0 {
            base.Fatalf("walkExpr: invalid index %v", r)
        }

        // cap is constrained to [0,2^31) or [0,2^63) depending on whether
        // we're in 32-bit or 64-bit systems. So it's safe to do:
        //
        // if uint64(len) > cap {
        //     if len < 0 { panicmakeslicelen() }
        //     panicmakeslicecap()
        // }
        nif := ir.NewIfStmt(base.Pos, ir.NewBinaryExpr(base.Pos, ir.OGT, typecheck.Conv(l, types.Types[types.TUINT64]), ir.NewInt(i)), nil, nil)
        niflen := ir.NewIfStmt(base.Pos, ir.NewBinaryExpr(base.Pos, ir.OLT, l, ir.NewInt(0)), nil, nil)
        niflen.Body = []ir.Node{mkcall("panicmakeslicelen", nil, init)}
        nif.Body.Append(niflen, mkcall("panicmakeslicecap", nil, init))
        init.Append(typecheck.Stmt(nif))

        t = types.NewArray(t.Elem(), i) // [r]T
        var_ := typecheck.Temp(t)
        appendWalkStmt(init, ir.NewAssignStmt(base.Pos, var_, nil))  // zero temp
        r := ir.NewSliceExpr(base.Pos, ir.OSLICE, var_, nil, l, nil) // arr[:l]
        // The conv is necessary in case n.Type is named.
        return walkExpr(typecheck.Expr(typecheck.Conv(r, n.Type())), init)
    }

    // n escapes; set up a call to makeslice.
    // When len and cap can fit into int, use makeslice instead of
    // makeslice64, which is faster and shorter on 32 bit platforms.

    len, cap := l, r

    fnname := "makeslice64"
    argtype := types.Types[types.TINT64]

    // Type checking guarantees that TIDEAL len/cap are positive and fit in an int.
    // The case of len or cap overflow when converting TUINT or TUINTPTR to TINT
    // will be handled by the negative range checks in makeslice during runtime.
    if (len.Type().IsKind(types.TIDEAL) || len.Type().Size() <= types.Types[types.TUINT].Size()) &&
        (cap.Type().IsKind(types.TIDEAL) || cap.Type().Size() <= types.Types[types.TUINT].Size()) {
        fnname = "makeslice"
        argtype = types.Types[types.TINT]
    }
    fn := typecheck.LookupRuntime(fnname)
    ptr := mkcall1(fn, types.Types[types.TUNSAFEPTR], init, reflectdata.MakeSliceElemRType(base.Pos, n), typecheck.Conv(len, argtype), typecheck.Conv(cap, argtype))
    ptr.MarkNonNil()
    len = typecheck.Conv(len, types.Types[types.TINT])
    cap = typecheck.Conv(cap, types.Types[types.TINT])
    sh := ir.NewSliceHeaderExpr(base.Pos, t, ptr, len, cap)
    return walkExpr(typecheck.Expr(sh), init)
}

walkNew 分配内存的源码:https://github.com/golang/go/blob/go1.20.1/src/cmd/compile/internal/walk/builtin.go#L499-L514

// walkNew walks an ONEW node.
func walkNew(n *ir.UnaryExpr, init *ir.Nodes) ir.Node {
    t := n.Type().Elem()
    if t.NotInHeap() {
        base.Errorf("%v can't be allocated in Go; it is incomplete (or unallocatable)", n.Type().Elem())
    }
    if n.Esc() == ir.EscNone {
        if t.Size() > ir.MaxImplicitStackVarSize {
            base.Fatalf("large ONEW with EscNone: %v", n)
        }
        return stackTempAddr(init, t)
    }
    types.CalcSize(t)
    n.MarkNonNil()
    return n
}

此临界值定义在cmd/compile/internal/ir/cfg.MaxImplicitStackVarSize 变量中,默认为 64KB,可以通过指定编译时smallframes 标识进行更新,因此,make([]int64,1023)与make([]int64,1024)实现的细节是截然不同的

对应的 MaxImplicitStackVarSize 默认值定义,源码:https://github.com/golang/go/blob/go1.20.1/src/cmd/compile/internal/ir/cfg.go#L7-L26

var (
    // MaxStackVarSize is the maximum size variable which we will allocate on the stack.
    // This limit is for explicit variable declarations like "var x T" or "x := ...".
    // Note: the flag smallframes can update this value.
    MaxStackVarSize = int64(10 * 1024 * 1024)

    // MaxImplicitStackVarSize is the maximum size of implicit variables that we will allocate on the stack.
    //   p := new(T)          allocating T on the stack
    //   p := &T{}            allocating T on the stack
    //   s := make([]T, n)    allocating [n]T on the stack
    //   s := []byte("...")   allocating [n]byte on the stack
    // Note: the flag smallframes can update this value.
    MaxImplicitStackVarSize = int64(64 * 1024)

    // MaxSmallArraySize is the maximum size of an array which is considered small.
    // Small arrays will be initialized directly with a sequence of constant stores.
    // Large arrays will be initialized by copying from a static temp.
    // 256 bytes was chosen to minimize generated code + statictmp size.
    MaxSmallArraySize = int64(256)
)

切片扩容

扩容触发

关键字 append 是触发切片扩容的主要方式,但不是每次使用 append 函数就一定会扩容,看下面代码示例:

package main

import "fmt"

func main() {
    // --------- 不会扩容 ---------
    // 定义切片a,容量为4
    a := make([]int, 3, 4)
    fmt.Printf("append 前,a -> len:%v cap:%v value:%v \n", len(a), cap(a), a)
    a = append(a, 1)
    fmt.Printf("append 后,a -> len:%v cap:%v value:%v \n", len(a), cap(a), a)

    // --------- 会扩容 ---------
    // 定义切片b,容量为3
    b := make([]int, 3, 3)
    fmt.Printf("append 前,b -> len:%v cap:%v value:%v \n", len(b), cap(b), b)
    b = append(b, 1)
    fmt.Printf("append 后,b -> len:%v cap:%v value:%v \n", len(b), cap(b), b)
}

/** 输出
append 前,a -> len:3 cap:4 value:[0 0 0]
append 后,a -> len:4 cap:4 value:[0 0 0 1]
append 前,b -> len:3 cap:3 value:[0 0 0]
append 后,b -> len:4 cap:6 value:[0 0 0 1]
*/

代码分析:

  • 切片 a 容量为 4,目前长度为 3 (初始化 3 个 0),所以还能再容纳一个元素,在执行 append 时,不会执行扩容,所以 cap 还是 4
  • 切片 b 容量为 3,目前长度也为 3 (初始化 3 个 0),如果再 append, 切片容量会不足,便会进行扩容,所以 cap 为 6

容量判定

当切片的容量不足时,append 函数在运行时会调用 runtime.growslice 函数为切片扩容

扩容是为切片分配新的内存空间,并拷贝原切片中元素到新空间的过程

runtime.growslice 源码 https://github.com/golang/go/blob/go1.20.1/src/runtime/slice.go#L126-L284

func growslice(oldPtr unsafe.Pointer, newLen, oldCap, num int, et *_type) slice {
    // ...

    newcap := oldCap
    doublecap := newcap + newcap
    if newLen > doublecap {
        newcap = newLen
    } else {
        const threshold = 256
        if oldCap < threshold {
            newcap = doublecap
        } else {
            // Check 0 < newcap to detect overflow
            // and prevent an infinite loop.
            for 0 < newcap && newcap < newLen {
                // 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 = newLen
            }
        }
    }

    // ...
}

上面的代码显示了扩容的核心逻辑,Go 语言中切片扩容的策略整理如下:

  • 如果新申请容量(cap)大于 2 倍的旧容量(old.cap),则最终容量(newcap)是新申请的容量(cap
  • 如果旧切片的长度小于 256,则最终容量是旧容量的 2 倍,即 newcap=doublecap
  • 如果旧切片长度大于或等于 256,则最终容量从旧容量开始循环增加原来的 1/4 以及一个固定值 192,直到最终容量大于或等于新申请的容量为止,即 newcap ≥ cap
  • 如果最终容量计算值溢出,即超过了 int 的最大范围,则最终容量就是新申请容量

growslice 函数会根据切片的类型,分配不同大小的内存。为了对齐内存,申请的内存可能大于 实际的类型大小×容量大小

切片复制

代码示例

package main

import "fmt"

func main() {
    /// 定义切片
    oldSli := []int{100, 200, 300}

    // 复制切片
    copySli := oldSli
    fmt.Println("修改前 -> copySli: ", copySli, "oldSli:", oldSli)

    // 修改复制后的切片
    copySli[0] = 99
    fmt.Println("修改后 -> copySli: ", copySli, "oldSli:", oldSli)
}

/** 输出
修改前 -> copySli:  [100 200 300] oldSli: [100 200 300]
修改后 -> copySli:  [99 200 300] oldSli: [99 200 300]
*/

切片的复制其实也是值复制,但这里的值复制指对于运行时 SliceHeader 结构的复制,但底层指针仍然指向相同的底层数据的数组地址,因此可以理解为数据进行了引用传递

切片的这一特性使得即便切片中有大量数据,在复制时的成本也比较小,这与数组有显著的不同。

使用 Copy

复制的切片不会改变指向底层的数据源,但有些时候我们希望建一个新的数组,并且与旧数组不共享相同的数据源,这时可以使用 copy 函数。

package main

import "fmt"

func main() {
    // 定义切片
    oldSli := []int{100, 200, 300}

    // 使用 copy 复制切片
    copySli := make([]int, len(oldSli), cap(oldSli))
    copy(copySli, oldSli)
    fmt.Println("修改前 -> copySli: ", copySli, "oldSli:", oldSli)

    // 修改复制后的切片
    copySli[0] = 99
    fmt.Println("修改后 -> copySli: ", copySli, "oldSli:", oldSli)
}

/** 输出
修改前 -> copySli:  [100 200 300] oldSli: [100 200 300]
修改后 -> copySli:  [99 200 300] oldSli: [100 200 300]
*/

copy 函数在运行时主要调用了 runtime.memmove 函数,用于实现内存的复制

如果采用协程调用的方式 go copy(numbers1,numbers) 或者加入了 race 检测,则会转而调用运行时 runtime.slicecopy 函数

无论是编译期间拷贝还是运行时拷贝,两种拷贝方式都会通过 runtime.memmove 将整块内存的内容拷贝到目标的内存区域中