一種理解 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 的數組,有以下特點:

  1. 長度屬於類型的一部分,因此 [4]int[5]int 類型的變量不能互相賦值,也不能互相強轉。

  2. 數組變量並非指針,因此作爲參數傳遞時會引起全量拷貝。當然,可以使用對應指針類型作爲參數類型避免此拷貝。

可以看出,由於存在長度這個枷鎖,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)

相較數組,切片有以下好處:

  1. 操作靈活,顧名思義,支持強大的切片操作。

  2. 脫去了長度的限制,傳參時,不同長度的切片都可以以 []T 形式傳遞。

  3. 切片賦值傳參時不會複製整個底層數組,只會複製上述 slice 結構體本身。

  4. 藉助一些內置函數,如 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)

相關函數。切片相關的內置函數主要有:

  1. 用於創建的 make

  2. 用於擴展的 append

  3. 用於移動的 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 三個變量來刻畫一個窗口視圖,其中 ptrptr+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 時,可以從兩個層面來入手:

  1. 底層數據(底層數組)

  2. 上層視圖(切片)

視圖有三個關鍵變量,數組指針(ptr)、有效長度(len)、視圖容量(cap)。通過切片表達式(slice expression)可以從數組生成切片、從切片生成切片,此操作不會發生數組數據的拷貝。通過 append 進行追加操作時,根據本視圖的 cap 而定是否進行數組拷貝,並返回一個指向新數組的視圖。

參考

  1. 酷殼 coolshell :Go 編程模式:切片,接口,時間和性能

  2. 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