一種理解 Golang Slice 的模型
【導讀】本文以圖示的方式給出一種理解 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
-
用於擴展的 append
-
用於移動的 copy
下面分別說說其特點。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
轉自:青藤木鳥
鏈接:https://www.qtmuniao.com/2021/01/09/go-slice/
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/Ld-HVoFmaCH00LTvfcx7uw