【導讀】本文以圖示的方式給出一種理解 slice 的模型的方法,分析了在特殊場景的slice用法。
概述Golang 中 slice 極似其他語言中數組,但又有諸多不同,因此容易使初學者產生一些誤解,並在使用時不易察覺地掉進各種坑中。
本篇小文,首先從 Go 語言官方博客出發,鋪陳官方給出的 slice 的相關語法;其次以圖示的方式給出一種理解 slice 的模型;最後再總結分析一些特殊的使用情況。如不願看繁瑣敘述過程,可直接跳到最後小結看總結。
基本語法本部分主要出自 Go 的官方博客。在 Go 語言中,切片(slice)和數組(array)是伴生的,切片基於數組,但更為靈活,因此在 Go 中,作為切片底層的數組反而很少用到。但,要理解切片,須從數組說起。
數組(array)Go 中的數組由類型+長度構成,與 C 和 C++ 不同的是,Go 中不同長度的數組是為不同的類型,並且變量名並非指向數組首地址的指針。
// 數組的幾種初始化方式
var a [4]int // 變量 a 類型為 [4]int 是一個 type,每個元素自動初始化為 int 的零值(zero-value)
b := [5]int{1,2,3,4} // 變量 b 類型為 [5]int 是不同於 [4]int 的類型,且 b[4] 會自動初始化為 int 的零值
c := [...]int{1,2,3,4,5} // 變量 c 被自動推導為 [5]int 類型,與 b 類型同
func echo(x [4]int) {
fmt.Println(x)
}
echo(a) // echo 調用時,a 中所有元素都會被複製一遍, 因為 Go 函數調用是傳值
echo(b) // error
echo(([4]int)c) // error總結一下,Go 的數組,有以下特點:
長度屬於類型的一部分,因此 [4]int 和 [5]int 類型的變量不能互相賦值,也不能互相強轉。數組變量並非指針,因此作為參數傳遞時會引起全量拷貝。當然,可以使用對應指針類型作為參數類型避免此拷貝。可以看出,由於存在長度這個枷鎖,Go 數組的作用大大受限。Go 不能夠像 C/C++ 一樣,任意長度數組都可以轉換為指向相應類型的指針,進而進行下標運算。當然,Go 也不需如此,因為它有更高級的抽象——切片。
切片(slices)在 Go 代碼中,切片使用十分普遍,但切片底層基於數組:
type slice struct {
array unsafe.Pointer // 指向底層數組的指針;對,golang 也是有指針的
len int // 切片長度
cap int // 底層數組長度
}
// 切片的幾種初始化方式
s0 := make([]byte, 5) // 藉助 make 函數,此時 len = cap = 5,每個元素初始化為 byte 的 zero-value
s1 := []byte{0, 0, 0, 0, 0} // 字面值初始化,此時 len = cap = 5
var s2 []byte // 自動初始化為 slice 的「零值(zero-value)」:nil
// make 方式同時指定 len/cap,需滿足 len <= cap
s3 := make([]byte, 0, 5) // 切片長度 len = 0, 底層數組 cap = 5
s4 := make([]byte, 5, 5) // 等價於 make([]byte, 5)相較數組,切片有以下好處:
脫去了長度的限制,傳參時,不同長度的切片都可以以 []T 形式傳遞。切片賦值、傳參時不會複製整個底層數組,只會複製上述 slice 結構體本身。藉助一些內置函數,如 append/copy ,可以方便的進行擴展和整體移動。切片操作。使用切片操作可以對切片進行快速的截取、擴展、賦值和移動。
// 截取操作,左閉右開;若始於起點,或止於終點,則可省略對應下標
// 新得到的切片與原始切片共用底層數組,因此免於元素複製
b := []byte{'g', 'o', 'l', 'a', 'n', 'g'}
b1 := b[1:4] // b1 == []byte{'o', 'l', 'a'}
b2 := b[:2] // b2 == []byte{'g', 'o'}
b3 := b[2:] // b3 == []byte{'l', 'a', 'n', 'g'}
b4 := b[:] // b4 == b
// 擴展操作,需藉助 append 函數
// 可能會引起底層數組的重新分配,後面會詳細分析
// 等價於 b = append(b, []byte{',', 'h', 'i'}...)
b = append(b, ',', 'h', 'i') // b 現為 {'g', 'o', 'l', 'a', 'n', 'g', ',', 'h', 'i'}
// 賦值操作,需藉助 copy 函數
copy(b[:2], []byte{'e', 'r'}) // b 現為 {'e', 'r', 'l', 'a', 'n', 'g', ',', 'h', 'i'}
// 移動操作,需藉助 copy
copy(b[2:], b[6:]) // 移動長度取 min(len(dst), len(src))
b = b[:5] // b 現為 {'e', 'r', ',', 'h', 'i'}參數傳遞。不同長度、容量的切片都可以通過 []T 形式傳遞。
b := []int{1,2,3,4}
c := []int{1,2,3,4,5}
func echo(x []int) {
fmt.Println(x)
}
echo(b) // 傳遞參數時,會重新生成一個共享底層數組,len 和 cap 都相同的切片結構體
echo(c)相關函數。切片相關的內置函數主要有:
下面分別說說其特點。make 函數在創建切片時(它還可以用來創建很多其他內置結構體)的籤名為 func make([]T, len, cap) []T 。該函數會首先創建一個 cap 長度的數組,然後新建一個 slice 結構體,指向該數組,並根據參數初始化 len 和 cap。append 在修改切片底層數組後,但不會改變原切片,而是返回一個具有新長度新的切片結構體。為什麼不在原地修改原切片呢?因為 Go 中函數是傳值的,當然這也體現了 Go 中某種函數式思想的偏好。因此,append(s, 'a', b'') 並不會修改切片 s 本身,需要對 s 重新賦值:s = append(s, 'a', b'')才能達到對變量 s 的修改目的。需注意,append 時,如果底層數組容量(cap) 不夠,會按類似於 C++ 中的 vector 底層機制,新建一個足夠容納所有元素的數組,並將原數組值複製過去後,再進行追加。原切片底層數組如果沒有其他切片變量引用後,會由在 GC 時進行回收。copy 函數更像個語法糖,將對切片的批量賦值封裝為一個函數,注意拷貝長度會取兩個切片中較小者。並且,不用擔心同一個切片的子切片移動時出現覆蓋現象,舉個例子:
package main
import (
"fmt"
)
// 直覺認為的 copy 函數實現
// 但此種實現會造成同一個切片的子切片進行複製時的覆蓋現象
// 因此 copy 在實現時應該藉助了額外的空間 or 從後往前複製
func myCopy(dst, src []int) {
l := len(dst)
if len(src) < l {
l = len(src)
}
for i := 0; i < l; i++ {
dst[i] = src[i]
}
}
func main() {
a := []int{0,1,3,4,5,6}
copy(a[3:], a[2:]) // a = [0 1 3 3 4 5]
// myCopy(a[3:], a[2:]) // a = [0 1 3 3 3 3]
fmt.Println(a)
}copy 一個常見的使用場景是,需要往切片中間插入一個元素時,用 copy 將插入點之後的片段整體後移。
切片模型初用切片時,常常感覺其規則龐雜,難以盡記;於是我常想有沒有什麼合適的模型來刻畫切片的本質。某天突然冒出個不成熟的想法:切片是隱藏了底層數組的一種線性讀寫視圖。切片這種視圖規避了 C/C++ 語言中常見的指針運算操作,因為用戶可以通過切片派生來免於算偏移量。切片僅用 ptr/cap/len 三個變量來刻畫一個窗口視圖,其中 ptr 和 ptr+cap 是窗口的起止界限,len 是當前窗口可見長度。可以通過下標來切出一個新的視圖,Go 會自動計算新的 ptr/len/cap ,所有通過切片表達式派生的視圖都指向同一個底層數組。
切片派生會自動共享底層數組,以避免數組拷貝,提升效率;追加元素時,如果底層數組容量不夠,append 會自動創建新數組並返回指向新數組的切片視圖,而原來切片視圖仍然指向原數組。
切片使用本小節將匯總一些 slice 使用時的一些有意思的點。零值(zero-value)和空值(empty-value)。go 中所有類型都是有零值的,並以其作為初始化時的默認值。slice 的零值是 nil。
func add(a []int) []int { // nil 可以作為參數傳給 []int 切片類型
return append(a, 0, 1, 2)
}
func main() {
fmt.Println(add(nil)) // [0 1 2]
}可以通過 make 創建一個空 slice,其 len/cap 與零值一致,但是也會有如下小小區別,如兩者皆可,推薦用 nil。
func main() {
a := make([]int, 0)
var b []int
fmt.Println(a, len(a), cap(a)) // [] 0 0
fmt.Printf("%#v\n", a) // []int{}
fmt.Println(a==nil) // false
fmt.Println(b, len(b), cap(b)) // [] 0 0
fmt.Printf("%#v\n", b) // []int(nil)
fmt.Println(b==nil) // true
}append 語義。append 會首先將元素追加到底層數組,然後構造一個新的 slice 返回。也就是說,即使我們不使用返回值,相應的值也會被追加到底層數組。
func main() {
a := make([]int, 0, 5)
_ = append(a, 0, 1, 2)
fmt.Println(a) // []
fmt.Println(a[:5]) // [0 1 2 0 0];通過切片表達式,擴大窗口長度,就可以看到追加的值
fmt.Println(a[:6]) // panic;長度越界了
}從 array 生成 slice。可以通過切片語法,通過數組 a 生成所需長度切片 s ,此時:s 底層數組即為 a。換言之,對數組使用切片語法也不會造成數組的拷貝。
func main() {
a := [7]int{1,2,3}
s := a[:4]
fmt.Println(s) // [1 2 3 0]
a[3] = 4 // 修改 a,s 相應值也跟著變化,說明 s 的底層就是 a
fmt.Println(s) // [1 2 3 4]
}切片時修改視圖右界。在上述提出的視圖模型中,進行切片操作時,新生成的切片左界限會隨著 start 參數而變化,但是右界一直未變,即為底層數組結尾。如果我們想修改其右界,可以通過三參數切片(Full slice Expression),增加一個 limited-capacity 參數。該特性的一個使用場景是,如果我們想讓新的 slice 在 append 時不影響原數組,就可以通過修改其右界,在 append 時發現 cap 不夠強制生成一個新的底層數組。
小結本文核心目的在於提出一個易於記憶和理解 slice 模型,以拆解 slice 使用時千變萬化的複雜度。總結一下,我們在理解 slice 時,可以從兩個層面來入手:
視圖有三個關鍵變量,數組指針(ptr)、有效長度(len)、視圖容量(cap)。通過切片表達式(slice expression)可以從數組生成切片、從切片生成切片,此操作不會發生數組數據的拷貝。通過 append 進行追加操作時,根據本視圖的 cap 而定是否進行數組拷貝,並返回一個指向新數組的視圖。
參考酷殼 coolshell :Go編程模式:切片,接口,時間和性能The Go Blog:Go slices:usage and internals