一種理解 Golang Slice 的模型

2022-01-07 Go開發大全

【導讀】本文以圖示的方式給出一種理解 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

相關焦點

  • Golang 筆記(三):一種理解 Slice 的模型
    本篇小文,首先從 Go 語言官方博客出發,鋪陳官方給出的 slice 的相關語法;其次以圖示的方式給出一種理解 slice 的模型;最後再總結分析一些特殊的使用情況,以期在多個角度對 slice 都有個更清晰側寫。如不願看繁瑣敘述過程,可直接跳到最後小結看總結。
  • 徹底理解Golang Slice
    看完這篇文章,下面這些高頻面試題你都會答了吧實現原理slice是無固定長度的數組,底層結構是一個結構體,包含如下3個屬性一個 slice 在 golang 中佔用 24 個 bytestype 「golang中通過語法糖,使得我們可以像聲明array一樣,自動創建slice結構體」根據索引位置取切片slice 元素值時,默認取值範圍是(0~len(slice)-1),一般輸出slice時,通常是指 slice[0:len(slice)-1],根據下標就可以輸出所指向底層數組中的值主要特性引用類型
  • 從Golang Slice的內存洩漏來理解Slice的使用邏輯
    其中Slice的內存洩漏是最容易中招的,看看這個PR:https://github.com/golang/go/pull/32138,Golang官方都踩了坑。本文將就其中的Slice內存洩漏的情況做分析,並介紹Slice實現和使用的一些關鍵邏輯。
  • Golang【源碼系列】深入了解slice
    slice的內存布局了解了slice的結構,我們來直觀的看一個slice實例在內存中的布局:取a的切片[3:6]賦值為c,c對應的內存布局如下:取切片(截取)語法為左閉右開區間,複製的切片長度即為新切片的len,這個很好理解,但是新切片的cap為什麼是5?
  • 關於Golang切片Slice和append的有趣問題
    /【4】    for i := 0; i < 10; i++ {     y = append(y, i)    }    fmt.Println("y=", y, &y[0]) //y= [20 2 10 20 0 1 2 3 4 5 6 7 8 9] 0xc0000ba000 【5】}解釋【1】因為y是x的slice
  • 《8小時轉職Golang工程師》
    置頂 本視頻偏入門級,主要是針對後端想快速低成本掌握golang開發人群學習,如您已經掌握golang請繞行。
  • 「語言學習」Go語言之slice特性
    Go語言學習slice介紹和說明golang的數據結構也很多,如List,array,map等,但是有個很特別的數據結構是slice,也叫切片那麼什麼是slice呢?其實slice也算是golang語言特有的數據結構,底層是以數組作為支撐;啥概念呢,就是說在申請一塊內存進行數組的存放的時候,slice就像數組對外開放的一扇窗口,讓你看到想給你看到的內容。
  • Golang slice 使用技巧
    在 Go 語言項目中大量的使用 slice, 我總結三年來對 slice 的一些操作技巧,以方便可以高效的使用 slice, 並使用 slice 解決一些棘手的問題。slice 的基本操作先熟悉一些 slice 的基本的操作, 對最常規的 : 操作就可玩出很多花樣。
  • 體驗golang語言的風騷編程
    如果要使用指 針,那麼就需要用到後面介紹的slice類型了。數組可以使用另一種:=來聲明a := [3]int{1, 2, 3} b := [10]int{1, 2, 3} c := [...]int{4, 5, 6} 3、 go語言強大的slice操作golang 中的 slice 非常強大,讓數組操作非常方便高效。在開發中不定長度表示的數組全部都是 slice 。
  • golang標準庫template
    可以使用管道符號|連結多個命令,用法和unix下的管道類似:|前面的命令將運算結果(或返回值)傳遞給後一個命令的最後一個位置。需要注意的是,並非只有使用了|才是pipeline。Go template中,pipeline的概念是傳遞數據,只要能產生數據的,都是pipeline。
  • 跟 Dave Cheney 大神重學 Go Slice:有新收穫
    Header 想要理解 slice 是如何做到本身是一個類,並且又是一個指針的話,就得理解  slice 的底層結構[1]。為了說明這些,程式設計師可以直觀的將 square() 的形參理解為 main 中 v 的副本。
  • 「Golang」for range 使用方法及避坑指南
    前言循環控制結構[1]是一種在各種程式語言中常用的程序控制結構,其與順序控制結構、選擇控制結構組成了程序的控制結構,程序控制結構是指以某種順序執行的一系列動作
  • Golang最細節篇— struct{} 空結構體究竟是啥?
    定義的各種姿勢原生定義a := struct{}{}struct{}  可以就認為是一種類型,a 變量就是 struct {}  類型的一種變量,地址為 runtime.zerobase ,大小為 0 ,不佔內存。
  • Golang 性能測試 (1)
    ) { slice := ints[0:100] t.ResetTimer() for i := 0; i < t.N; i++ { sort.Ints(slice) }}// 長度為 1w 的數據使用上述代碼排序func BenchmarkQsort10k(t *testing.B) { slice := ints
  • 深入理解Golang Channel 結構
    Golang 使用 Groutine 和 channels 實現了 CSP(Communicating Sequential Processes) 模型,channles 在 goroutine 的通信和同步中承擔著重要的角色。
  • 聽說你還搞不懂Golang的Slice?看這一篇就夠了!
    上一篇傳送門:Go by Example-圖解數組初識切片切片(slice)也是一種數據結構,它和數組非常相似,但它是圍繞動態數組的概念設計的,可以按需自動改變大小。切片的動態增長是通過內置函數 append 來實現的,這個函數可以快速且高效地增長切片。
  • 深入理解Golang 接口
    itab 可以理解為 pair<interface type, concrete type> 。當然 itab 裡面還包含一些其他信息,比如 interface 裡面包含的 method 的具體實現。下面細說。itab 的結構如下。
  • golang中interface接口的深度解析
    這種做法的學名叫做Structural Typing,有人也把它看作是一種靜態的Duck Typing。    要這個值實現了接口的方法。我們再來看golang的實現方式,同C++一樣,golang也為每種類型創建了一個方法集,不同的是接口的虛表是在運行時專門生成的,而c++的虛表是在編譯時生成的(但是c++虛函數表表現出的多態是在運行時決定的).例如,當例子中當首次遇見s := Stringer(b)這樣的語句時,golang會生成Stringer接口對應於Binary類型的虛表,並將其緩存。
  • 獲得了「官方自己都會踩的」坑認證:slice 類型內存洩露的邏輯
    因為 slice 相比 map 要容易復用,在性能敏感的場景,只要能用 slice 來代替 map 就都換成了 slice + sync.Pool 來進行復用,fasthttp 裡有很多這方面的實踐,之前也有人做了比較好的總結,參考 這裡[3] 和 這裡[4]。
  • 理解 go interface 的 5 個關鍵點
    既然空的 interface 可以接受任何類型的參數,那麼一個 interface{}類型的 slice 是不是就可以接受任何類型的 slice ?func printAll(vals []interface{}) { //1 for _, val := range vals { fmt.Println(val) }}func main(){ names := []string{"stanley", "david", "oscar"} printAll(names)}上面的代碼是按照我們的假設修改的,執行之後竟然會報