數組
數組是一個由固定長度的特定類型元素組成的序列,一個數組可以由零個或多個元
素組成。數組的長度是數組類型的組成部分。因為數組的長度是數組類型的一個部
分,不同長度或不同類型的數據組成的數組都是不同的類型,因此在Go語言中很少
直接使用數組(不同長度的數組因為類型不同無法直接賦值)。
定義方式:
var a [3]int // 定義一個長度為3的int類型數組, 元素全部為0 var b = [...]int{1, 2, 3} // 定義一個長度為3的int類型數組, 元素為 1, 2, 3 var c = [...]int{2: 3, 1: 2} // 定義一個長度為3的int類型數組, 元素為 0, 2, 3 var d = [...]int{1, 2, 4: 5, 6} // 定義一個長度為6的int類型數組, 元素為 1, 2, 0, 0, 5, 6
Go語言中數組是值語義。一個數組變量即表示整個數組,它並不是隱式的指向第一
個元素的指針(比如C語言的數組),而是一個完整的值。當一個數組變量被賦值
或者被傳遞的時候,實際上會復制整個數組。如果數組較大的話,數組的賦值也會
有較大的開銷。為了避免復制數組帶來的開銷,可以傳遞一個指向數組的指針,但
是數組指針並不是數組。
var a = [...]int{1, 2, 3} // a 是一個數組 var b = &a // b 是指向數組的指針 fmt.Println(a[0], a[1]) // 打印數組的前2個元素 fmt.Println(b[0], b[1]) // 通過數組指針訪問數組元素的方式和數組類似 for i, v := range b { // 通過數組指針迭代數組的元素 fmt.Println(i, v) }
對於數組類型來說, len 和 cap 函數返回的結果始終是一
樣的,都是對應數組類型的長度。
遍歷數組:
for i := range a { fmt.Printf("a[%d]: %d\n", i, a[i]) } for i, v := range b { fmt.Printf("b[%d]: %d\n", i, v) } for i := 0; i < len(c); i++ { fmt.Printf("c[%d]: %d\n", i, c[i]) }
用 for range 方式迭代的性能可能會更好一些,因為這種迭代可以保證不會出現
數組越界的情形,每輪迭代對數組元素的訪問時可以省去對下標越界的判斷。
用 for range 方式迭代,還可以忽略迭代時的下標:
var times [5][0]int for range times { fmt.Println("hello") }
其中 times 對應一個 [5][0]int 類型的數組,雖然第一維數組有長度,但是數
組的元素 [0]int 大小是0,因此整個數組占用的內存大小依然是0。沒有付出額外
的內存代價,我們就通過 for range 方式實現了 times 次快速迭代。
數組不僅僅可以用於數值類型,還可以定義字符串數組、結構體數組、函數數組、
接口數組、管道數組等等:
// 字符串數組 var s1 = [2]string{"hello", "world"} var s2 = [...]string{"你好", "世界"} var s3 = [...]string{1: "世界", 0: "你好", } // 結構體數組 var line1 [2]image.Point var line2 = [...]image.Point{image.Point{X: 0, Y: 0}, image.Poin t{X: 1, Y: 1}} var line3 = [...]image.Point{{0, 0}, {1, 1}} // 圖像解碼器數組 var decoder1 [2]func(io.Reader) (image.Image, error) var decoder2 = [...]func(io.Reader) (image.Image, error){ png.Decode, jpeg.Decode, } // 接口數組 var unknown1 [2]interface{} var unknown2 = [...]interface{}{123, "你好"} // 管道數組 var chanList = [2]chan int{}
空的數組:
var d [0]int // 定義一個長度為0的數組 var e = [0]int{} // 定義一個長度為0的數組 var f = [...]int{} // 定義一個長度為0的數組
長度為0的數組在內存中並不占用空間。空數組雖然很少直接使用,但是可以用於
強調某種特有類型的操作時避免分配額外的內存空間,比如用於管道的同步操作:
c1 := make(chan [0]int) go func() { fmt.Println("c1") c1 <- [0]int{} }() <-c1
在這里,我們並不關心管道中傳輸數據的真實類型,其中管道接收和發送操作只是
用於消息的同步。對於這種場景,我們用空數組來作為管道類型可以減少管道元素
賦值時的開銷。當然一般更傾向於用無類型的匿名結構體代替:
c2 := make(chan struct{}) go func() { fmt.Println("c2") c2 <- struct{}{} // struct{}部分是類型, {}表示對應的結構體值 }() <-c2
字符串
一個字符串是一個不可改變的字節序列,字符串通常是用來包含人類可讀的文本數
據。和數組不同的是,字符串的元素不可修改,是一個只讀的字節數組。每個字符
串的長度雖然也是固定的,但是字符串的長度並不是字符串類型的一部分。
Go語言字符串的底層結構在 reflect.StringHeader 中定義:
type StringHeader struct { Data uintptr Len int }
字符串結構由兩個信息組成:第一個是字符串指向的底層字節數組,第二個是字符
串的字節的長度。字符串其實是一個結構體,因此字符串的賦值操作也就
是 reflect.StringHeader 結構體的復制過程,並不會涉及底層字節數組的復
制。
我們可以看看字符串“Hello, world”本身對應的內存結構:
字符串雖然不是切片,但是支持切片操作,不同位置的切片底層也訪問的同一塊內
存數據(因為字符串是只讀的,相同的字符串面值常量通常是對應同一個字符串常
量):
s := "hello, world" hello := s[:5] world := s[7:] s1 := "hello, world"[:5] s2 := "hello, world"[7:]
字符串和數組類似,內置的 len 函數返回字符串的長度。也可以通
過 reflect.StringHeader 結構訪問字符串的長度
fmt.Println("len(s):", (*reflect.StringHeader)(unsafe.Pointer(&s )).Len) // 12 fmt.Println("len(s1):", (*reflect.StringHeader)(unsafe.Pointer(& s1)).Len) // 5 fmt.Println("len(s2):", (*reflect.StringHeader)(unsafe.Pointer(& s2)).Len) // 5
如果不想解碼UTF8字符串,想直接遍歷原始的字節碼,可以將字符串強制轉
為 []byte 字節序列后再行遍歷(這里的轉換一般不會產生運行時開銷):
for i, c := range []byte("世界abc") { fmt.Println(i, c) }
Go語言除了 for range 語法對UTF8字符串提供了特殊支持外,還對字符串
和 []rune 類型的相互轉換提供了特殊的支持。
fmt.Printf("%#v\n", []rune("世界")) // []int32{19990 , 30028} fmt.Printf("%#v\n", string([]rune{'世', '界'})) // 世界
從上面代碼的輸出結果來看,我們可以發現 []rune 其實是 []int32 類型,這里
的 rune 只是 int32 類型的別名,並不是重新定義的類型。 rune 用於表示每個
Unicode碼點,目前只使用了21個bit位。
字符串相關的強制類型轉換主要涉及到 []byte 和 []rune 兩種類型。每個轉換
都可能隱含重新分配內存的代價,最壞的情況下它們的運算時間復雜度都
是 O(n) 。不過字符串和 []rune 的轉換要更為特殊一些,因為一般這種強制類
型轉換要求兩個類型的底層內存結構要盡量一致,顯然它們底層對應
的 []byte 和 []int32 類型是完全不同的內部布局,因此這種轉換可能隱含重新
分配內存的操作。
切片(slice)
切片就是一種簡化版的動態數組。因為動態數組的長度是不固定,切片
的長度自然也就不能是類型的組成部分了。
切片的結構定義, reflect.SliceHeader :
type SliceHeader struct { Data uintptr Len int Cap int }
可以看出切片的開頭部分和Go字符串是一樣的,但是切片多了一個 Cap 成員表示
切片指向的內存空間的最大容量(對應元素的個數,而不是字節數)。
下圖是 x= []int{2,3,5,7,11} 和 y := x[1:3] 兩個切片對應的內存結構。
讓我們看看切片有哪些定義方式:
var ( a []int // nil切片, 和 nil 相等, 一般用來表示一個不存在的切片 b = []int{} // 空切片, 和 nil 不相等, 一般用來表示一個空的集合 c = []int{1, 2, 3} // 有3個元素的切片, len和cap都為3 d = c[:2] // 有2個元素的切片, len為2, cap為3 e = c[0:2:cap(c)] // 有2個元素的切片, len為2, cap為3 f = c[:0] // 有0個元素的切片, len為0, cap為3 g = make([]int, 3) // 有3個元素的切片, len和cap都為3 h = make([]int, 2, 3) // 有2個元素的切片, len為2, cap為3 i = make([]int, 0, 3) // 有0個元素的切片, len為0, cap為3 )
和數組一樣,內置的 len 函數返回切片中有效元素的長度,內置的 cap 函數返回
切片容量大小,容量必須大於或等於切片的長度。也可以通
過 reflect.SliceHeader 結構訪問切片的信息(只是為了說明切片的結構,並不
是推薦的做法)。切片可以和 nil 進行比較,只有當切片底層數據指針為空時切
片本身為 nil ,這時候切片的長度和容量信息將是無效的。如果有切片的底層數
據指針為空,但是長度和容量不為0的情況,那么說明切片本身已經被損壞了(比
如直接通過 reflect.SliceHeader 或 unsafe 包對切片作了不正確的修改)。
遍歷切片的方式和遍歷數組的方式類似:
for i := range a { fmt.Printf("a[%d]: %d\n", i, a[i]) } for i, v := range b { fmt.Printf("b[%d]: %d\n", i, v) } for i := 0; i < len(c); i++ { fmt.Printf("c[%d]: %d\n", i, c[i]) }
在對切片本身賦值或參數傳
遞時,和數組指針的操作方式類似,只是復制切片頭信息
( reflect.SliceHeader ),並不會復制底層的數據。對於類型,和數組的最大
不同是,切片的類型和長度信息無關,只要是相同類型元素構成的切片均對應相同
的切片類型。
添加切片元素
內置的泛型函數 append 可以在切片的尾部追加 N 個元素:
var a []int a = append(a, 1) // 追加1個元素 a = append(a, 1, 2, 3) // 追加多個元素, 手寫解包方式 a = append(a, []int{1,2,3}...) // 追加一個切片, 切片需要解包
不過要注意的是,在容量不足的情況下, append 的操作會導致重新分配內存,可
能導致巨大的內存分配和復制數據代價。即使容量足夠,依然需要用 append 函數
的返回值來更新切片本身,因為新切片的長度已經發生了變化。
除了在切片的尾部追加,我們還可以在切片的開頭添加元素:
var a = []int{1,2,3} a = append([]int{0}, a...) // 在開頭添加1個元素 a = append([]int{-3,-2,-1}, a...) // 在開頭添加1個切片
在開頭一般都會導致內存的重新分配,而且會導致已有的元素全部復制1次。因
此,從切片的開頭添加元素的性能一般要比從尾部追加元素的性能差很多。
由於 append 函數返回新的切片,也就是它支持鏈式操作。我們可以將多
個 append 操作組合起來,實現在切片中間插入元素:
var a []int a = append(a[:i], append([]int{x}, a[i:]...)...) // 在第i個位置插入x a = append(a[:i], append([]int{1,2,3}, a[i:]...)...) // 在第i個位置插入切片
每個添加操作中的第二個 append 調用都會創建一個臨時切片,並將 a[i:] 的內
容復制到新創建的切片中,然后將臨時創建的切片再追加到 a[:i] 。
可以用 copy 和 append 組合可以避免創建中間的臨時切片,同樣是完成添加元
素的操作:
a = append(a, 0) // 切片擴展1個空間 copy(a[i+1:], a[i:]) // a[i:]向后移動1個位置 a[i] = x // 設置新添加的元素
第一句 append 用於擴展切片的長度,為要插入的元素留出空間。第二
句 copy 操作將要插入位置開始之后的元素向后挪動一個位置。第三句真實地將新
添加的元素賦值到對應的位置。操作語句雖然冗長了一點,但是相比前面的方法,
可以減少中間創建的臨時切片。
用 copy 和 append 組合也可以實現在中間位置插入多個元素(也就是插入一個切
片):
a = append(a, x...) // 為x切片擴展足夠的空間 copy(a[i+len(x):], a[i:]) // a[i:]向后移動len(x)個位置 copy(a[i:], x) // 復制新添加的切片
稍顯不足的是,在第一句擴展切片容量的時候,擴展空間部分的元素復制是沒有必
要的。沒有專門的內置函數用於擴展切片的容量, append 本質是用於追加元素而
不是擴展容量,擴展切片容量只是 append 的一個副作用。
刪除切片元素
根據要刪除元素的位置有三種情況:從開頭位置刪除,從中間位置刪除,從尾部刪
除。其中刪除切片尾部的元素最快:
a = []int{1, 2, 3} a = a[:len(a)-1] // 刪除尾部1個元素 a = a[:len(a)-N] // 刪除尾部N個元素
刪除開頭的元素可以直接移動數據指針:
a = []int{1, 2, 3} a = a[1:] // 刪除開頭1個元素 a = a[N:] // 刪除開頭N個元素
刪除開頭的元素也可以不移動數據指針,但是將后面的數據向開頭移動。可以
用 append 原地完成(所謂原地完成是指在原有的切片數據對應的內存區間內完
成,不會導致內存空間結構的變化):
a = []int{1, 2, 3} a = append(a[:0], a[1:]...) // 刪除開頭1個元素 a = append(a[:0], a[N:]...) // 刪除開頭N個元素
也可以用 copy 完成刪除開頭的元素:
a = []int{1, 2, 3} a = a[:copy(a, a[1:])] // 刪除開頭1個元素 a = a[:copy(a, a[N:])] // 刪除開頭N個元素
對於刪除中間的元素,需要對剩余的元素進行一次整體挪動,同樣可以
用 append 或 copy 原地完成:
a = []int{1, 2, 3, ...} a = append(a[:i], a[i+1:]...) // 刪除中間1個元素 a = append(a[:i], a[i+N:]...) // 刪除中間N個元素 a = a[:i+copy(a[i:], a[i+1:])] // 刪除中間1個元素 a = a[:i+copy(a[i:], a[i+N:])] // 刪除中間N個元素
刪除開頭的元素和刪除尾部的元素都可以認為是刪除中間元素操作的特殊情況。
切片內存技巧
切片高效操作的要點是要降低內存分配的次數,盡量保證 append 操作不會超
出 cap 的容量,降低觸發內存分配的次數和每次分配內存大小。
避免切片內存泄漏
可以將感興趣的數據復制到一個新的切片中(數據的傳值是Go語
言編程的一個哲學,雖然傳值有一定的代價,但是換取的好處是切斷了對原始數據
的依賴):
假設切片里存放的是指針對象,那么
下面刪除末尾的元素后,被刪除的元素依然被切片底層數組引用,從而導致不能及
時被自動垃圾回收器回收(這要依賴回收器的實現方式):
var a []*int{ ... } a = a[:len(a)-1] // 被刪除的最后一個元素依然被引用, 可能導致GC操作被阻礙
保險的方式是先將需要自動內存回收的元素設置為 nil ,保證自動回收器可以發
現需要回收的對象,然后再進行切片的刪除操作:
var a []*int{ ... } a[len(a)-1] = nil // GC回收最后一個元素內存 a = a[:len(a)-1] // 從切片刪除最后一個元素
當然,如果切片存在的周期很短的話,可以不用刻意處理這個問題。因為如果切片
本身已經可以被GC回收的話,切片對應的每個元素自然也就是可以被回收的了。
Go語言實現中非0大小數組的長度不得超過
2GB,因此需要針對數組元素的類型大小計算數組的最大長度范圍( []uint8 最
大2GB, []uint16 最大1GB,以此類推,但是 []struct{} 數組的長度可以超
過2GB)。