數組和切片是 Go 語言中常見的數據結構,不少剛剛使用 Go 的開發者每每會混淆這兩個概念,數組做爲最多見的集合在編程語言中是很是重要的,除了數組以外,Go 語言引入了另外一個概念 — 切片,切片與數組有一些相似,可是它們的不一樣之處致使使用上會產生巨大的差異。git
這裏咱們將從 Go 語言 編譯期間 的工做和運行時來介紹數組以及切片的底層實現原理,其中會包括數組的初始化以及訪問、切片的結構和常見的基本操做。github
數組是由相同類型元素的集合組成的數據結構,計算機會爲數組分配一塊連續的內存來保存數組中的元素,咱們能夠利用數組中元素的索引快速訪問元素對應的存儲地址,常見的數組大多都是一維的線性數組,而多維數組在數值和圖形計算領域卻有比較常見的應用。golang
數組做爲一種數據類型,通常狀況下由兩部分組成,其中一部分表示了數組中存儲的元素類型,另外一部分表示數組最大可以存儲的元素個數,Go 語言的數組類型通常是這樣的:編程
[10]int [200]interface{}
Go 語言中數組的大小在初始化以後就沒法改變,數組存儲元素的類型相同,可是大小不一樣的數組類型在 Go 語言看來也是徹底不一樣的,只有兩個條件都相同纔是同一個類型。數組
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 }
編譯期間的數組類型 Array
就包含兩個結構,一個是元素類型 Elem
,另外一個是數組的大小上限 Bound
,這兩個字段構成了數組類型,而當前數組是否應該在堆棧中初始化也在編譯期間就肯定了。數據結構
Go 語言中的數組有兩種不一樣的建立方式,一種是咱們顯式指定數組的大小,另外一種是編譯器經過源代碼自行推斷數組的大小:app
arr1 := [3]int{1, 2, 3} arr2 := [...]int{1, 2, 3}
後一種聲明方式在編譯期間就會被『轉換』成爲前一種,下面咱們先來介紹數組大小的編譯期推導過程。編程語言
這兩種不一樣的方式會致使編譯器作出不一樣的處理,若是咱們使用第一種方式 [10]T
,那麼變量的類型在編譯進行到 類型檢查 階段就會被推斷出來,在這時編譯器會使用 NewArray
建立包含數組大小的 Array
類型,而若是使用 [...]T
的方式,雖然在這一步也會建立一個 Array
類型 Array{Elem: elem, Bound: -1}
,可是其中的數組大小上限會是 -1
的結構,這意味着還須要後面的 typecheckcomplit
函數推導該數組的大小:函數
func typecheckcomplit(n *Node) (res *Node) { // ... switch t.Etype { case TARRAY, TSLICE: var length, i int64 nl := n.List.Slice() for i2, l := range nl { i++ if i > length { length = i } } if t.IsDDDArray() { t.SetNumElem(length) } } }
這個刪減後的 typecheckcomplit
函數經過遍歷元素來推導當前數組的長度,咱們能看出 [...]T
類型的聲明不是在運行時被推導的,它會在類型檢查期間就被推斷出正確的數組大小。
雖然 [...]T{1, 2, 3}
和 [3]T{1, 2, 3}
在運行時是徹底等價的,可是這種簡短的初始化方式也只是 Go 語言爲咱們提供的一種語法糖,對於一個由字面量組成的數組,根據數組元素數量的不一樣,編譯器會在負責初始化字面量的 anylit
函數中作兩種不一樣的優化:
func anylit(n *Node, var_ *Node, init *Nodes) { t := n.Type switch n.Op { case OSTRUCTLIT, OARRAYLIT: if n.List.Len() > 4 { vstat := staticname(t) vstat.Name.SetReadonly(true) fixedlit(inNonInitFunction, initKindStatic, n, vstat, init) a := nod(OAS, var_, vstat) a = typecheck(a, ctxStmt) a = walkexpr(a, init) init.Append(a) break } fixedlit(inInitFunction, initKindLocalCode, n, var_, init) // ... } }
當數組的元素小於或者等於四個時,fixedlit
會負責在函數編譯以前將批了語法糖外衣的代碼轉換成原有的樣子:
func fixedlit(ctxt initContext, kind initKind, n *Node, var_ *Node, init *Nodes) { var splitnode func(*Node) (a *Node, value *Node) // ... for _, r := range n.List.Slice() { a, value := splitnode(r) a = nod(OAS, a, value) a = typecheck(a, ctxStmt) switch kind { case initKindStatic: genAsStatic(a) case initKindLocalCode: a = orderStmtInPlace(a, map[string][]*Node{}) a = walkstmt(a) init.Append(a) default: Fatalf("fixedlit: bad kind %d", kind) } } }
因爲傳入的類型是 initKindLocalCode
,上述代碼會將原有的初始化語法拆分紅一個聲明變量的語句和 N 個用於賦值的語句:
var arr [3]int arr[0] = 1 arr[1] = 2 arr[2] = 3
可是若是當前數組的元素大於 4 個時,anylit
方法會先獲取一個惟一的 staticname
,而後調用 fixedlit
函數在靜態存儲區初始化數組中的元素並將臨時變量賦值給當前的數組:
func fixedlit(ctxt initContext, kind initKind, n *Node, var_ *Node, init *Nodes) { var splitnode func(*Node) (a *Node, value *Node) // ... for _, r := range n.List.Slice() { a, value := splitnode(r) setlineno(value) a = nod(OAS, a, value) a = typecheck(a, ctxStmt) switch kind { case initKindStatic: genAsStatic(a) default: Fatalf("fixedlit: bad kind %d", kind) } } }
假設,咱們在代碼中初始化 []int{1, 2, 3, 4, 5}
數組,那麼咱們能夠將上述過程理解成如下的僞代碼:
var arr [5]int statictmp_0[0] = 1 statictmp_0[1] = 2 statictmp_0[2] = 3 statictmp_0[3] = 4 statictmp_0[4] = 5 arr = statictmp_0
總結起來,若是數組中元素的個數小於或者等於 4 個,那麼全部的變量會直接在棧上初始化,若是數組元素大於 4 個,變量就會在靜態存儲區初始化而後拷貝到棧上,這些轉換後的代碼纔會繼續進入 中間代碼生成 和 機器碼生成 兩個階段,最後生成能夠執行的二進制文件。
不管是在棧上仍是靜態存儲區,數組在內存中其實就是一連串的內存空間,表示數組的方法就是一個指向數組開頭的指針,這一片內存空間不知道本身存儲的是什麼變量:
數組訪問越界的判斷也都是在編譯期間由靜態類型檢查完成的,typecheck1
函數會對訪問的數組索引進行驗證:
func typecheck1(n *Node, top int) (res *Node) { switch n.Op { case OINDEX: ok |= ctxExpr l := n.Left r := n.Right t := l.Type switch t.Etype { case TSTRING, TARRAY, TSLICE: why := "string" if t.IsArray() { why = "array" } else if t.IsSlice() { why = "slice" } if n.Right.Type != nil && !n.Right.Type.IsInteger() { yyerror("non-integer %s index %v", why, n.Right) break } if !n.Bounded() && Isconst(n.Right, CTINT) { x := n.Right.Int64() if x < 0 { yyerror("invalid %s index %v (index must be non-negative)", why, n.Right) } else if t.IsArray() && x >= t.NumElem() { yyerror("invalid array index %v (out of bounds for %d-element array)", n.Right, t.NumElem()) } else if Isconst(n.Left, CTSTR) && x >= int64(len(n.Left.Val().U.(string))) { yyerror("invalid string index %v (out of bounds for %d-byte string)", n.Right, len(n.Left.Val().U.(string))) } } } //... } }
不管是編譯器仍是字符串,它們的越界錯誤都會在編譯期間發現,可是數組訪問操做 OINDEX
會在編譯期間被轉換成兩個 SSA 指令:
PtrIndex <t> ptr idx Load <t> ptr mem
編譯器會先獲取數組的內存地址和訪問的下標,而後利用 PtrIndex
計算出目標元素的地址,再使用 Load
操做將指針中的元素加載到內存中。
數組的賦值和更新操做 a[i] = 2
也會生成 SSA 期間就計算出數組當前元素的內存地址,而後修改當前內存地址的內容,其實會被轉換成以下所示的 SSA 操做:
LocalAddr {sym} base _ PtrIndex <t> ptr idx Store {t} ptr val mem
在這個過程當中會確實可以目標數組的地址,再經過 PtrIndex
獲取目標元素的地址,最後將數據存入地址中,從這裏咱們能夠看出不管是數組的尋址仍是賦值都是在編譯階段完成的,沒有運行時的參與。
數組其實在 Go 語言中沒有那麼經常使用,更加常見的數據結構實際上是切片,切片其實就是動態數組,它的長度並不固定,能夠追加元素並會在切片容量不足時進行擴容。
在 Golang 中,切片類型的聲明與數組有一些類似,因爲切片是『動態的』,它的長度並不固定,因此聲明類型時只須要指定切片中的元素類型:
[]int []interface{}
從這裏的定義咱們其實也能推測出,切片在編譯期間的類型應該只會包含切片中的元素類型,NewSlice
就是編譯期間用於建立 Slice
類型的函數:
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 }
咱們能夠看到上述方法返回的類型 TSLICE
的 Extra
字段是一個只包含切片內元素類型的 Slice{Elem: elem}
結構,也就是說切片內元素的類型是在編譯期間肯定的。
編譯期間的切片其實就是一個 Slice
類型,可是在運行時切片其實由以下的 SliceHeader
結構體表示,其中 Data
字段是一個指向數組的指針,Len
表示當前切片的長度,而 Cap
表示當前切片的容量,也就是 Data
數組的大小:
type SliceHeader struct { Data uintptr Len int Cap int }
Data
做爲一個指針指向的數組其實就是一片連續的內存空間,這片內存空間能夠用於存儲切片中保存的所有元素,數組其實就是一片連續的內存空間,數組中的元素只是邏輯上的概念,底層存儲其實都是連續的,因此咱們能夠將切片理解成一片連續的內存空間加上長度與容量標識。
與數組不一樣,數組中大小、其中的元素還有對數組的訪問和更新在編譯期間就已經所有轉換成了直接對內存的操做,可是切片是運行時纔會肯定的結構,全部的操做還須要依賴 Go 語言的運行時來完成,咱們接下來就會介紹切片的一些常見操做的實現原理。
首先須要介紹的就是切片的建立過程,Go 語言中的切片總共有兩種初始化的方式,一種是使用字面量初始化新的切片,另外一種是使用關鍵字 make
建立切片:
slice := []int{1, 2, 3} slice := make([]int, 10)
咱們先來介紹如何使用字面量的方式建立新的切片結構,[]int{1, 2, 3}
其實會在編譯期間由 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[:]
[3]int
類型的數組指針;vstat
賦值給 vauto
指針所在的地址;[:]
操做獲取一個底層使用 vauto
的切片;[:]
以及相似的操做 [:10]
其實都會在 SSA 代碼生成 階段被轉換成 OpSliceMake
操做,這個操做會接受四個參數建立一個新的切片,切片元素類型、數組指針、切片大小和容量。
若是使用字面量的方式建立切片,大部分的工做就都會在編譯期間完成,可是當咱們使用 make
關鍵字建立切片時,在 類型檢查 期間會檢查 make
『函數』的參數,調用方必須傳入一個切片的大小以及可選的容量:
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 } // ... } }
make
參數的檢查都是在 typecheck1
函數中完成的,它不只會檢查 len
,並且會保證傳入的容量 cap
必定大於或者等於 len
;隨後的中間代碼生成階段會把這裏的 OMAKESLICE
類型的操做都轉換成以下所示的函數調用:
makeslice(type, len, cap)
當切片的容量和大小不能使用 int
來表示時,就會實現 makeslice64
處理容量和大小更大的切片,不管是 makeslice
仍是 makeslice64
,這兩個方法都是在結構逃逸到堆上初始化時才須要調用的,若是當前的切片不會發生逃逸而且切片很是小的時候,make([]int, 3, 4)
纔會被轉換成以下所示的代碼:
var arr [4]int n := arr[:3]
在這時,數組的初始化和 [:3]
操做就都會在編譯階段完成大部分的工做,前者會在靜態存儲區被建立,後者會被轉換成 OpSliceMake
操做。
接下來,咱們回到用於建立切片的 makeslice
函數,這個函數的實現其實很是簡單:
func makeslice(et *_type, len, cap int) unsafe.Pointer { mem, overflow := math.MulUintptr(et.size, uintptr(cap)) if overflow || mem > maxAlloc || len < 0 || len > cap { mem, overflow := math.MulUintptr(et.size, uintptr(len)) if overflow || mem > maxAlloc || len < 0 { panicmakeslicelen() } panicmakeslicecap() } return mallocgc(mem, et, true) }
上述代碼的主要工做就是用切片中元素大小和切片容量相乘計算出切片佔用的內存空間,若是內存空間的大小發生了溢出、申請的內存大於最大可分配的內存、傳入的長度小於 0 或者長度大於容量,那麼就會直接報錯,固然大多數的錯誤都會在編譯期間就檢查出來,mallocgc
就是用於申請內存的函數,這個函數的實現仍是比較複雜,若是遇到了比較小的對象會直接初始化在 Golang 調度器裏面的 P 結構中,而大於 32KB 的一些對象會在堆上初始化。
初始化後會返回指向這片內存空間的指針,在以前版本的 Go 語言中,指針會和長度與容量一塊兒被合成一個 slice
結構返回到 makeslice
的調用方,可是從 020a18c5 這個 commit 開始,構建結構體 SliceHeader
的工做就都由上層在類型檢查期間完成了:
func typecheck1(n *Node, top int) (res *Node) { switch n.Op { // ... case OSLICEHEADER: switch t := n.Type n.Left = typecheck(n.Left, ctxExpr) l := typecheck(n.List.First(), ctxExpr) c := typecheck(n.List.Second(), ctxExpr) l = defaultlit(l, types.Types[TINT]) c = defaultlit(c, types.Types[TINT]) n.List.SetFirst(l) n.List.SetSecond(c) // ... } }
OSLICEHEADER
操做會建立一個以下所示的結構體,其中包含數組指針、切片長度和容量,它是切片在運行時的表示:
type SliceHeader struct { Data uintptr Len int Cap int }
正是由於大多數對切片類型的操做並不須要直接操做原 slice
結構體,因此 SliceHeader
的引入可以減小切片初始化時的開銷,這個改動可以減小 0.2% 的 Go 語言包大小而且可以減小 92 個 panicindex
的調用。
對切片常見的操做就是獲取它的長度或者容量,這兩個不一樣的函數 len
和 cap
其實被 Go 語言的編譯器當作是兩種特殊的操做 OLEN
和 OCAP
,它們會在 SSA 生成階段 被轉換成 OpSliceLen
和 OpSliceCap
操做:
func (s *state) expr(n *Node) *ssa.Value { switch n.Op { case OLEN, OCAP: switch { case n.Left.Type.IsSlice(): op := ssa.OpSliceLen if n.Op == OCAP { op = ssa.OpSliceCap } return s.newValue1(op, types.Types[TINT], s.expr(n.Left)) // ... } // ... } }
除了獲取切片的長度和容量以外,訪問切片中元素使用的 OINDEX
操做也都在 SSA 中間代碼生成期間就轉換成對地址的獲取操做:
func (s *state) expr(n *Node) *ssa.Value { switch n.Op { case OINDEX: switch { case n.Left.Type.IsSlice(): p := s.addr(n, false) return s.load(n.Left.Type.Elem(), p) // ... } // ... } }
切片的操做基本都是在編譯期間完成的,除了訪問切片的長度、容量或者其中的元素以外,使用 range
遍歷切片時也是在編譯期間被轉換成了形式更簡單的代碼,咱們會在後面的章節中介紹 range
關鍵字的實現原理。
向切片中追加元素應該是最多見的切片操做,在 Go 語言中咱們會使用 append
關鍵字向切片中追加元素,追加元素會根據是否 inplace
在中間代碼生成階段轉換成如下的兩種不一樣流程,若是 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)
咱們會先對切片結構體進行解構獲取它的數組指針、大小和容量,若是新的切片大小大於容量,那麼就會使用 growslice
對切片進行擴容並將新的元素依次加入切片並建立新的切片,可是 slice = apennd(slice, 1, 2, 3)
這種 inplace
的表達式就只會改變原來的 slice
變量:
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
上述兩段代碼的邏輯其實差很少,最大的區別在於最後的結果是否是賦值會原有的變量,不過從 inplace
的代碼能夠看出 Go 語言對相似的過程進行了優化,因此咱們並不須要擔憂 append
會在數組容量足夠時致使發生切片的複製。
到這裏咱們已經瞭解了在切片容量足夠時如何向切片中追加元素,可是若是切片的容量不足時就會調用 growslice
爲切片擴容:
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 } } }
擴容其實就是須要爲切片分配一塊新的內存空間,分配內存空間以前須要先肯定新的切片容量,Go 語言根據切片的當前容量選擇不一樣的策略進行擴容:
肯定了切片的容量以後,咱們就能夠開始計算切片中新數組的內存佔用了,計算的方法就是將目標容量和元素大小相乘:
var overflow bool var lenmem, newlenmem, capmem uintptr switch { // ... default: lenmem = uintptr(old.len) * et.size newlenmem = uintptr(cap) * et.size capmem, overflow = math.MulUintptr(et.size, uintptr(newcap)) capmem = roundupsize(capmem) newcap = int(capmem / et.size) } var p unsafe.Pointer if et.kind&kindNoPointers != 0 { p = mallocgc(capmem, nil, false) memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem) } else { p = mallocgc(capmem, et, true) if writeBarrier.enabled { bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(old.array), lenmem) } } memmove(p, old.array, lenmem) return slice{p, old.len, newcap} }
若是當前切片中元素不是指針類型,那麼就會調用 memclrNoHeapPointers
函數將超出當前長度的位置置空並在最後使用 memmove
將原數組內存中的內容拷貝到新申請的內存中, 不過不管是 memclrNoHeapPointers
仍是 memmove
函數都使用目標機器上的彙編指令進行實現,例如 WebAssembly 使用以下的命令實現 memclrNoHeapPointers
函數:
TEXT runtime·memclrNoHeapPointers(SB), NOSPLIT, $0-16 MOVD ptr+0(FP), R0 MOVD n+8(FP), R1 loop: Loop Get R1 I64Eqz If RET End Get R0 I32WrapI64 I64Const $0 I64Store8 $0 Get R0 I64Const $1 I64Add Set R0 Get R1 I64Const $1 I64Sub Set R1 Br loop End UNDEF
growslice
函數最終會返回一個新的 slice
結構,其中包含了新的數組指針、大小和容量,這個返回的三元組最終會改變原有的切片,幫助 append
完成元素追加的功能。
切片的拷貝雖然不是一個常見的操做類型,可是倒是咱們學習切片實現原理必需要談及的一個問題,當咱們使用 copy(a, b)
的形式對切片進行拷貝時,編譯期間會被轉換成 slicecopy
函數:
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 }
上述函數的實現很是直接,它將切片中的所有元素經過 memmove
或者數組指針的方式將整塊內存中的內容拷貝到目標的內存區域:
相比於依次對元素進行拷貝,這種方式可以提供更好的性能,可是須要注意的是,哪怕使用 memmove
對內存成塊進行拷貝,可是這個操做仍是會佔用很是多的資源,在大切片上執行拷貝操做時必定要注意性能影響。
數組和切片是 Go 語言中重要的數據結構,因此瞭解它們的實現可以幫助咱們更好地理解這門語言,經過對它們實現的分析,咱們知道了數組和切片的實現同時依賴編譯器和運行時兩部分。
數組的大多數操做在 編譯期間 都會轉換成對內存的直接讀寫;而切片的不少功能就都是在運行時實現的了,不管是初始化切片,仍是對切片進行追加或擴容都須要運行時的支持,須要注意的是在遇到大切片擴容或者複製時可能會發生大規模的內存拷貝,必定要在使用時減小這種狀況的發生避免對程序的性能形成影響。
轉載自: