Golang 入門 : 切片(slice)


切片(slice)是 Golang 中一種比較特殊的數據結構,這種數據結構更便於使用和管理數據集合。切片是圍繞動態數組的概念構建的,可以按需自動增長和縮小。切片的動態增長是通過內置函數 append() 來實現的,這個函數可以快速且高效地增長切片,也可以通過對切片再次切割,縮小一個切片的大小。因為切片的底層也是在連續的內存塊中分配的,所以切片還能獲得索引、迭代以及為垃圾回收優化的好處。
本文將介紹 Golang 切片的基本概念和用法,演示環境為 ubuntu 18.04 & go1.10.1。

切片的內部實現

切片是一個很小的對象,它對底層的數組(內部是通過數組保存數據的)進行了抽象,並提供相關的操作方法。切片是一個有三個字段的數據結構,這些數據結構包含 Golang 需要操作底層數組的元數據:

這 3 個字段分別是指向底層數組的指針、切片訪問的元素的個數(即長度)和切片允許增長到的元素個數(即容量)。

切片的創建和初始化

在 Golang 中可以通過多種方式創建和初始化切片。是否提前知道切片所需的容量通常會決定如何創建切片

通過 make() 函數創建切片
使用 Golang 內置的 make() 函數創建切片,此時需要傳入一個參數來指定切片的長度:

// 創建一個整型切片
// 其長度和容量都是 5 個元素
slice := make([]int, 5)

此時只指定了切片的長度,那么切片的容量和長度相等。也可以分別指定長度和容量:

// 創建一個整型切片
// 其長度為 3 個元素,容量為 5 個元素
slice := make([]int, 3, 5)

分別指定長度和容量時,創建的切片,底層數組的長度是指定的容量,但是初始化后並不能
訪問所有的數組元素。

注意,Golang 不允許創建容量小於長度的切片,當創建的切片容量小於長度時會在編譯時刻報錯:

// 創建一個整型切片
// 使其長度大於容量
myNum := make([]int, 5, 3)

編譯上面的代碼,會收到下面的編譯錯誤:
len larger than cap in make([]int)

通過字面量創建切片
另一種常用的創建切片的方法是使用切片字面量,這種方法和創建數組類似,只是不需要指定[]運算符里的值。初始的長度和容量會基於初始化時提供的元素的個數確定:

// 創建字符串切片
// 其長度和容量都是 3 個元素
myStr := []string{"Jack", "Mark", "Nick"}
// 創建一個整型切片
// 其長度和容量都是 4 個元素
myNum := []int{10, 20, 30, 40}

當使用切片字面量創建切片時,還可以設置初始長度和容量。要做的就是在初始化時給出所需的長度和容量作為索引。下面的語法展示了如何使用索引方式創建長度和容量都是100個元素的切片:

// 創建字符串切片
// 使用空字符串初始化第 100 個元素
myStr := []string{99: ""}

區分數組的聲明和切片的聲明方式
當使用字面量來聲明切片時,其語法與使用字面量聲明數組非常相似。二者的區別是:如果在 [] 運算符里指定了一個值,那么創建的就是數組而不是切片。只有在 [] 中不指定值的時候,創建的才是切片。看下面的例子:

// 創建有 3 個元素的整型數組
myArray := [3]int{10, 20, 30}
// 創建長度和容量都是 3 的整型切片
mySlice := []int{10, 20, 30}

nil 和空切片

有時,程序可能需要聲明一個值為 nil 的切片(也稱nil切片)。只要在聲明時不做任何初始化,就會創建一個 nil 切片

// 創建 nil 整型切片
var myNum []int

在 Golang 中,nil 切片是很常見的創建切片的方法。nil 切片可以用於很多標准庫和內置函數。在需要描述一個不存在的切片時,nil 切片會很好用。比如,函數要求返回一個切片但是發生異常的時候。下圖描述了 nil 切片的狀態:

空切片和 nil 切片稍有不同,下面的代碼分別通過 make() 函數和字面量的方式創建空切片:

// 使用 make 創建空的整型切片
myNum := make([]int, 0)
// 使用切片字面量創建空的整型切片
myNum := []int{}

空切片的底層數組中包含 0 個元素,也沒有分配任何存儲空間。想表示空集合時空切片很有用,
比如,數據庫查詢返回 0 個查詢結果時。下圖描述了空切片的狀態:

不管是使用 nil 切片還是空切片,對其調用內置函數 append()、len() 和 cap() 的效果都是一樣的。

為切片中的元素賦值

對切片里某個索引指向的元素賦值和對數組里某個索引指向的元素賦值的方法完全一樣。使
用 [] 操作符就可以改變某個元素的值,下面是使用切片字面量來聲明切片:

// 創建一個整型切片
// 其容量和長度都是 5 個元素
myNum := []int{10, 20, 30, 40, 50}
// 改變索引為 1 的元素的值
myNum [1] = 25

通過切片創建新的切片

切片之所以被稱為切片,是因為創建一個新的切片,也就是把底層數組切出一部分。通過切片創建新切片的語法如下:

slice[i:j]
slice[i:j:k]

其中 i 表示從 slice 的第幾個元素開始切,j 控制切片的長度(j-i),k 控制切片的容量(k-i),如果沒有給定 k,則表示切到底層數組的最尾部。下面是幾種常見的簡寫形式:

slice[i:]  // 從 i 切到最尾部
slice[:j]  // 從最開頭切到 j(不包含 j)
slice[:]   // 從頭切到尾,等價於復制整個 slice

讓我們通過下面的例子來理解通過切片創建新的切片的本質:

// 創建一個整型切片
// 其長度和容量都是 5 個元素
myNum := []int{10, 20, 30, 40, 50}
// 創建一個新切片
// 其長度為 2 個元素,容量為 4 個元素
newNum := slice[1:3]

執行上面的代碼后,我們有了兩個切片,它們共享同一段底層數組,但通過不同的切片會看到底層數組的不同部分:

注意,截取新切片時的原則是 "左含右不含"。所以 newNum 是從 myNum 的 index=1 處開始截取,截取到 index=3 的前一個元素,也就是不包含 index=3 這個元素。所以,新的 newNum 是由 myNum 中的第2個元素、第3個元素組成的新的切片構,長度為 2,容量為 4。切片 myNum 能夠看到底層數組全部 5 個元素的容量,而 newNum 能看到的底層數組的容量只有 4 個元素。newNum 無法訪問到底層數組的第一個元素。所以,對 newNum 來說,那個元素就是不存在的

共享底層數組的切片
需要注意的是:現在兩個切片 myNum 和 newNum 共享同一個底層數組。如果一個切片修改了該底層數組的共享
部分,另一個切片也能感知到(請參考前圖):

// 修改 newNum 索引為 1 的元素
// 同時也修改了原切片 myNum 的索引為 2 的元素
newNum[1] = 35

把 35 賦值給 newNum 索引為 1 的元素的同時也是在修改 myNum 索引為 2 的元素:

切片只能訪問到其長度內的元素
切片只能訪問到其長度內的元素,試圖訪問超出其長度的元素將會導致語言運行時異常。在使用這部分元素前,必須將其合並到切片的長度里。下面的代碼試圖為 newNum 中的元素賦值:

// 修改 newNum 索引為 3 的元素
// 這個元素對於 newNum 來說並不存在
newNum[3] = 45

上面的代碼可以通過編譯,但是會產生運行時錯誤:
panic: runtime error: index out of range

切片擴容

相對於數組而言,使用切片的一個好處是:可以按需增加切片的容量。Golang 內置的 append() 函數會處理增加長度時的所有操作細節。要使用 append() 函數,需要一個被操作的切片和一個要追加的值,當 append() 函數返回時,會返回一個包含修改結果的新切片。函數 append() 總是會增加新切片的長度,而容量有可能會改變,也可能不會改變,這取決於被操作的切片的可用容量。

myNum := []int{10, 20, 30, 40, 50}
// 創建新的切片,其長度為 2 個元素,容量為 4 個元素
newNum := myNum[1:3]
// 使用原有的容量來分配一個新元素
// 將新元素賦值為 60
newNum = append(newNum, 60)

執行上面的代碼后的底層數據結構如下圖所示:

此時因為 newNum 在底層數組里還有額外的容量可用,append() 函數將可用的元素合並入切片的長度,並對其進行賦值。由於和原始的切片共享同一個底層數組,myNum 中索引為 3 的元素的值也被改動了。

如果切片的底層數組沒有足夠的可用容量,append() 函數會創建一個新的底層數組,將被引用的現有的值復制到新數組里,再追加新的值,此時 append 操作同時增加切片的長度和容量:

// 創建一個長度和容量都是 4 的整型切片
myNum := []int{10, 20, 30, 40}
// 向切片追加一個新元素
// 將新元素賦值為 50
newNum := append(myNum, 50)

當這個 append 操作完成后,newSlice 擁有一個全新的底層數組,這個數組的容量是原來的兩倍:

函數 append() 會智能地處理底層數組的容量增長。在切片的容量小於 1000 個元素時,總是會成倍地增加容量。一旦元素個數超過 1000,容量的增長因子會設為 1.25,也就是會每次增加 25%的容量(隨着語言的演化,這種增長算法可能會有所改變)。

限制切片的容量

在創建切片時,使用第三個索引選項引可以用來控制新切片的容量。其目的並不是要增加容量,而是要限制容量。允許限制新切片的容量為底層數組提供了一定的保護,可以更好地控制追加操作。

// 創建長度和容量都是 5 的字符串切片
fruit := []string{"Apple", "Orange", "Plum", "Banana", "Grape"}

下面嘗試使用第三個索引項來完成切片操作:

// 將第三個元素切片,並限制容量
// 其長度為 1 個元素,容量為 2 個元素
myFruit := fruit[2:3:4]

這個切片操作執行后,新切片里從底層數組引用了 1 個元素,容量是 2 個元素。具體來說,新切片引用了 Plum 元素,並將容量擴展到 Banana 元素:

如果設置的容量比可用的容量還大,就會得到一個運行時錯誤:
myFruit := fruit[2:3:6]
panic: runtime error: slice bounds out of range

內置函數 append() 在操作切片時會首先使用可用容量。一旦沒有可用容量,就會分配一個新的底層數組。這導致很容易忘記切片間正在共享同一個底層數組。一旦發生這種情況,對切片進行修改,很可能會導致隨機且奇怪的問題,這種問題一般都很難調查。如果在創建切片時設置切片的容量和長度一樣,就可以強制讓新切片的第一個 append 操作創建新的底層數組,與原有的底層數組分離。這樣就可以安全地進行后續的修改操作了:

myFruit := fruit[2:3:3]
// 向 myFruit 追加新字符串
myFruit = append(myFruit, "Kiwi")

這里,我們限制了 myFruit 的容量為 1。當我們第一次對 myFruit 調用 append() 函數的時候,會創建一個新的底層數組,這個數組包括 2 個元素,並將水果 Plum 復制進來,再追加新水果 Kiwi,並返回一個引用了這個底層數組的新切片。因為新的切片 myFruit 擁有了自己的底層數組,所以杜絕了可能發生的問題。我們可以繼續向新切片里追加水果,而不用擔心會不小心修改了其他切片里的水果。可以通過下圖來理解此時內存中的數據結構:

將一個切片追加到另一個切片
內置函數 append() 也是一個可變參數的函數。這意味着可以在一次調用中傳遞多個值。如果使用 … 運算符,可以將一個切片的所有元素追加到另一個切片里:

// 創建兩個切片,並分別用兩個整數進行初始化
num1 := []int{1, 2}
num2 := []int{3, 4}
// 將兩個切片追加在一起,並顯示結果
fmt.Printf("%v\n", append(num1, num2...))

輸出的結果為:
[1 2 3 4]
在返回的新的切片中,切片 num2 里的所有值都追加到了切片 num1 中的元素后面。

遍歷切片

切片是一個集合,可以迭代其中的元素。Golang 有個特殊的關鍵字 range,它可以配合關鍵字 for 來迭代切片里的元素

myNum := []int{10, 20, 30, 40, 50}
// 迭代每一個元素,並顯示其值
for index, value := range myNum {
    fmt.Printf("index: %d value: %d\n", index, value)
}

輸出的結果為:

index: 0 value: 10
index: 1 value: 20
index: 2 value: 30
index: 3 value: 40
index: 4 value: 50

當迭代切片時,關鍵字 range 會返回兩個值。第一個值是當前迭代到的索引位置,第二個值是該位置對應元素值的一份副本。需要強調的是,range 創建了每個元素的副本,而不是直接返回對該元素的引用。要想獲取每個元素的地址,可以使用切片變量和索引值:

myNum := []int{10, 20, 30, 40, 50}
// 修改切片元素的值
// 使用空白標識符(下划線)來忽略原始值
for index, _ := range myNum {
    myNum[index] += 1
}
for index, value := range myNum {
    fmt.Printf("index: %d value: %d\n", index, value)
}

輸出的結果為:

index: 0 value: 11
index: 1 value: 21
index: 2 value: 31
index: 3 value: 41
index: 4 value: 51

關鍵字 range 總是會從切片頭部開始遍歷。如果想對遍歷做更多的控制,可以使用傳統的 for 循環配合 len() 函數實現:

myNum := []int{10, 20, 30, 40, 50}
// 從第三個元素開始迭代每個元素
for index := 2; index < len(myNum); index++ {
    ...
}

切片間的拷貝操作

Golang 內置的 copy() 函數可以將一個切片中的元素拷貝到另一個切片中,其函數聲明為:

func copy(dst, src []Type) int

它表示把切片 src 中的元素拷貝到切片 dst 中,返回值為拷貝成功的元素個數。如果 src 比 dst 長,就截斷;如果 src 比 dst 短,則只拷貝 src 那部分:

num1 := []int{10, 20, 30}
num2 := make([]int, 5)
count := copy(num2, num1)
fmt.Println(count)
fmt.Println(num2)

運行這段單面,輸出的結果為:

3
[10 20 30 0 0]

3 表示拷貝成功的元素個數。

把切片傳遞給函數

函數間傳遞切片就是要在函數間以值的方式傳遞切片。由於切片的尺寸很小,在函數間復制和傳遞切片成本也很低。
讓我們創建一個包含 100 萬個整數的切片,並將這個切片以值的方式傳遞給函數 foo():

myNum := make([]int, 1e6)
// 將 myNum 傳遞到函數 foo()
slice = foo(myNum)
// 函數 foo() 接收一個整型切片,並返回這個切片
func foo(slice []int) []int {
...
return slice
} 

在 64 位架構的機器上,一個切片需要 24 字節的內存:指針字段需要 8 字節,長度和容量字段分別需要 8 字節。由於與切片關聯的數據包含在底層數組里,不屬於切片本身,所以將切片復制到任意函數的時候,對底層數組大小都不會有影響。復制時只會復制切片本身,不會涉及底層數組:

在函數間傳遞 24 字節的數據會非常快速、簡單。這也是切片效率高的地方。不需要傳遞指針和處理復雜的語法,只需要復制切片,按想要的方式修改數據,然后傳遞回一份新的切片副本。

總結

切片是 Golang 中比較有特色的一種數據類型,既為我們操作集合類型的數據提供了便利的方式,又能夠高效的在函數間進行傳遞,因此在代碼中切片類型被使用的相當廣泛。

參考:
The Go Programming Language Specification
《Go 語言實戰》
《Go語言編程入門與實戰技巧》
Go基礎系列:Go slice詳解


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM