天天看點

Go Slice學習

聲明:本文章是個人學習筆記,内容來自:https://draveness.me/golang/docs/part2-foundation/ch03-datastructure/golang-array-and-slice/?from=from_parent_mindnote

切片,即動态數組,其長度并不固定,我們可以向切片追加元素,它會在容量不足時自動擴容。切片的長度是動态的,聲明時隻需要指定切片中的元素類型。

var nums []int
var T []interface
           

切片在編譯期間生成的類型隻會包含切片中的元素類型。切片内元素的類型都是在編譯期間确定的,編譯器确定了類型以後,會将類型存儲在

Extra

字段中幫助程式在運作時動态擷取。

資料結構

運作時切片由如下的

reflect.SliceHeader

結構體表示:

type SliceHeader struct{
	Data uintptr // 指向數組的指針
	Len int		// 目前切片的長度
	Cap int		// 目前切片的容量,即Data數組的大小
}
           

Data

是一片連續的記憶體空間,這片記憶體空間可以用于存儲切片中的全部元素,數組中的元素隻是邏輯上的概念,底層存儲其實都是連續的,是以可以将切片了解成一片連續的記憶體空間加上長度與容量的辨別。

Go Slice學習

切片引入了一個抽象層,提供了對數組中部分連續片段的引用, 而作為數組的引用,可以在運作期間修改它的長度和範圍。當切片底層的數組長度不足時就會觸發擴容,切片指向的數組可能會發生變化,但是在上層看切片時沒有變化的。

初始化

Go語言提供三種初始化切片的方式:

  1. 通過下标的方式獲得數組或者切片的一部分
  2. 使用字面量初始化新的切片
  3. 使用關鍵字

    make

    建立切片
arr[0:3] or slice[0:3]
slice := []int{1,2,3}
slice := make([]int,10)
           

使用下标

使用下标初始化切片不會拷貝原數組或原切片中的資料,它隻會建立一個指向原數組的切片結構體,是以修改新切片的資料也會修改原切片。

package main

import "log"

func main() {
	s := []int{1, 2, 3, 4, 5, 6, 7, 8, 9}
	log.Printf("s: %p", &s)
	log.Printf("s: %+v", s)

	s1 := s[6:9]
	log.Printf("s1: %p", &s1)
	log.Printf("s1: %+v", s1)

	s1[0] = 99
	log.Printf("s: %+v", s)
	log.Printf("s1: %+v", s1)
	s1 = append(s1, 12)
	log.Printf("s: %+v", s)
	log.Printf("s1: %+v", s1)
}
           

輸出:

➜  go run main.go
2021/07/05 17:15:05 s: 0xc0000a6040
2021/07/05 17:15:05 s: [1 2 3 4 5 6 7 8 9]
2021/07/05 17:15:05 s1: 0xc0000a6080
2021/07/05 17:15:05 s1: [7 8 9]
2021/07/05 17:15:05 s: [1 2 3 4 5 6 99 8 9]
2021/07/05 17:15:05 s1: [99 8 9]
2021/07/05 17:15:05 s: [1 2 3 4 5 6 99 8 9]
2021/07/05 17:15:05 s1: [99 8 9 12]
           

字面量

當使用字面量

[]int{1,2,3}

建立新的切片時,cmd/compile/internal/gc.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

    的切片

關鍵字

如果使用字面量的方式建立切片,大部分的工作都會在編譯期間完成。但是

make

關鍵字建立切片時,很多工作都需要運作時的參與;調用方必須向 make 函數傳入切片的大小以及可選的容量,類型檢查期間的

cmd/compile/internal/gc.typecheck1

函數會校驗入參:

func typecheck1(n *Node, top int) (res *Node) {
	switch n.Op {
	...
	case OMAKE:
		args := n.List.Slice()

		i := 1
		switch t.Etype {
		case TSLICE:
			if i >= len(args) {
				yyerror("missing len argument to make(%v)", t)
				return n
			}

			l = args[i]
			i++
			var r *Node
			if i < len(args) {
				r = args[i]
			}
			...
			if Isconst(l, CTINT) && r != nil && Isconst(r, CTINT) && l.Val().U.(*Mpint).Cmp(r.Val().U.(*Mpint)) > 0 {
				yyerror("len larger than cap in make(%v)", t)
				return n
			}

			n.Left = l
			n.Right = r
			n.Op = OMAKESLICE
		}
	...
	}
}
           

上述函數不僅會檢查 len 是否傳入,還會保證傳入的容量 cap 一定大于或者等于 len。除了校驗參數之外,目前函數會将 OMAKE 節點轉換成 OMAKESLICE,中間代碼生成的函數會根據下面兩個條件轉換

OMAKESLICE

類型的節點:

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

當切片發生逃逸或者非常大時,運作時需要

runtime.makeslice

在堆上初始化切片,如果目前的切片不會發生逃逸并且切片非常小的時候,

make([]int, 3, 4)

會被直接轉換成如下所示的代碼:

var arr [4]int
n:= arr[:3]
           

建立切片的過程中如果發生了以下錯誤會直接出發運作時錯誤并崩潰:

  1. 記憶體空間的大小發生了溢出
  2. 申請的記憶體大于最大可配置設定的記憶體
  3. 傳入的長度小于0或者長度大于容量

追加與擴容

使用

append

進行追加操作時,會根據傳回值是否會覆寫原變量,選擇進入兩種流程,如果

append

傳回的新切片不需要指派回原有的變量,就會進入如下的處理流程:

// append(slice, 1, 2, 3)
ptr, len, cap := slice
newlen := len + 3
if newlen > cap {
    ptr, len, cap = growslice(slice, newlen)
    newlen = len + 3
}
*(ptr+len) = 1
*(ptr+len+1) = 2
*(ptr+len+2) = 3
return makeslice(ptr, newlen, cap)
           

先解構切片結構體擷取它的數組指針、大小和容量,如果在追加元素後切片的大小大于容量,那麼就會調用

runtime.growslice

對切片進行擴容并将新的元素依次加入切片。

如果使用

slice = append(slice, 1, 2, 3)

語句,那麼

append

後的切片會覆寫原切片,這時

cmd/compile/internal/gc.state.append

方法會使用另一種方式展開關鍵字:

// slice = append(slice, 1, 2, 3)
a := &slice
ptr, len, cap := slice
newlen := len + 3
if uint(newlen) > uint(cap) {
   newptr, len, newcap = growslice(slice, newlen)
   vardef(a)
   *a.cap = newcap
   *a.ptr = newptr
}
newlen = len + 3
*a.len = newlen
*(ptr+len) = 1
*(ptr+len+1) = 2
*(ptr+len+2) = 3
           
func growslice(et *_type, old slice, cap int) slice {
	newcap := old.cap
	doublecap := newcap + newcap
	if cap > doublecap {
		newcap = cap
	} else {
		if old.len < 1024 {
			newcap = doublecap
		} else {
			for 0 < newcap && newcap < cap {
				newcap += newcap / 4
			}
			if newcap <= 0 {
				newcap = cap
			}
		}
	}
           

在配置設定記憶體空間之前需要先确定新的切片容量,運作時根據切片的目前容量選擇不同的政策進行擴容:

  1. 如果期望容量大于目前容量的兩倍就會使用期望容量;
  2. 如果目前切片的長度小于1024就會将容量翻倍;
  3. 如果目前切片的長度大于1024就會每次增加25%的容量,直到新容量大于期望容量;

切片拷貝

當使用

copy(a,b)

對切片進行拷貝時,會分為兩種情況進行處理拷貝操作,如果目前

copy

不是在運作時調用的,

copy(a,b)

會被直接轉換成下面的代碼:

n:=len(a)
if n> len(b){
	n= len(a)
}
if a.ptr!=b.ptr{
	memmove(a.ptr,b.ptr,n*sizeof(elem(a)))
}
           

memmove

負責拷貝記憶體。

如果拷貝發生在運作時,例如當使用

go copy(a,b)

,拷貝實作方式如下:

func slicecopy(to, fm slice, width uintptr) int {
	if fm.len == 0 || to.len == 0 {
		return 0
	}
	n := fm.len
	if to.len < n {
		n = to.len
	}
	if width == 0 {
		return n
	}
	...

	size := uintptr(n) * width
	if size == 1 {
		*(*byte)(to.array) = *(*byte)(fm.array)
	} else {
		memmove(to.array, fm.array, size)
	}
	return n
}
           

整塊拷貝記憶體會占用非常多的資源,在大切片上執行拷貝操作時要注意對性能的影響。

參考:https://draveness.me/golang/docs/part2-foundation/ch03-datastructure/golang-array-and-slice/?from=from_parent_mindnote