Go 數組與切片

數組(Array)與切片(Slice)是 Go 語言中的入門知識,其中,切片是我學習 Go 語言後最先使用的一種集合類型,雖然對它的理解還有些似是而非,但可以確定的是,在存放一組類型相同的元素時,不管元素類型是整形還是字符串,都可以使用切片,因此,在最初我將它理解爲 PHP 語言裏的數組。

但很快我就瞭解到 Go 語言中也有數組類型,只是在開發中不太常見,切片與數組,兩者並不能混爲一談。既然如此,切片究竟是什麼呢?在實際應用中,它和數組支持相同的操作方法,都用來存放多個相同類型的元素,但爲什麼又說切片不是數組呢?兩者的使用場景又有什麼不同呢?不知道大家是不是和我一樣,對二者傻傻分不清楚。

爲了方便理解,我整理了一張表格用來參考。

我們知道,在 Go 語言中,聲明變量類型的方式有好幾種,比如使用關鍵詞 var ,又比如使用字面量方式,如果要聲明的變量是一個集合類型,則還需要用到 [] 操作符,其中,在 [] 操作符中設定數值的叫數組,如:var [5]int;未設定數值的叫切片,如:var []int

認識數組

數組作爲集合類型中的一種,它在很多編程語言中都是重要的數據類型,雖然在不同語言中的表現形式有所不同。在 Go 語言中,數組是一個長度固定的序列,用來存放多個相同類型的元素,數組的長度在聲明時由 [] 操作符中的數值確定,假設 [] 中的數值爲 5 ,則表示數組長度等於 5 ,最多隻能添加 5 個元素,程序會從內存空間中申請長度爲 5 的連續內存塊供該數組使用。此外,還有一種情況,如果 [] 操作符中的數值被 ... 代替,這時 Go 語言則會根據元素個數計算其長度。需要特別注意的是,數組長度設定後不可更改,也由此說它是固定長度序列。

Go 語言提供不只一種方式聲明變量,在聲明數組時,使用關鍵詞 var 或字面量方式好像區別不大,它們在創建時都需要指定類型及長度,並完成初始化。其長度相同,所佔內存大小也相同,區別在於它們的初始值不同。使用 var 聲明的數組,元素值對應類型的零值,使用字面量聲明數組,每個元素對應具體的值。

// var 聲明一個包含5個元素的整型數組     
var arr [5]int  

// 字面量方式聲明一個包含5個元素的整型數組,並使用1、2、3初始化
arr1 := [5]int{1,2,3}       

// [...]自動計算數組長度
arr2 := [...]int{1,2,3,4,5}

我是使用關鍵詞 var 創建的一輛名叫 arr ,有 5 節車廂的數組小火車~

數組長度固定的特點,註定它操作的方法並不多,它支持簡單的訪問元素與修改。我們在創建數組前無法預知元素個數,需要新增或刪除元素,此時,數組就不支持該操作了。而且數組是值類型,作爲參數傳遞給函數時使用值傳遞方式,需要在源數組基礎上完整複製一個新數組傳遞給被調用函數,這種方式在傳遞大數組時成本較高,除非我們使用指針傳入數組地址。

arr := [5]string{"Go","語","言","編","程"}

// 1.訪問元素
fmt.Println(arr[0])       // 輸出:Go

// 2.修改元素值
arr[0] = "PHP"
fmt.Println(arr)          // 輸出:[PHP 語 言 編 程]

// 3.基於數組創建切片
// 數據類型
fmt.Printf("%T \n",arr)   // 輸出:[5]string
// 從數組中截取一段數據
slice := arr[3:5]
// 類型發生變化
fmt.Println(slice)        // 輸出:[編 程]
fmt.Printf("%T \n",slice) // 輸出:[]string

雖然數組在項目開發中不常直接使用,但某種意義上它也無處不在,最主要的原因是它的切片操作。切片,顧名思義就是從數組上切出的那段數據,數據從數組上切下來,自然就不能再稱它爲數組了。

認識切片

先有數組纔有切片,切片是基於數組創建的一種數據結構。結構體的定義在 reflect.SliceHeader 中,它由 3 個字段組成,分別表示指向底層數組的指針、長度、容量。切片跟數組最大的不同是其長度可變,可擴容,可縮減,因此大家也叫它 “動態數組”。

type SliceHeader struct {
  Data uintptr   // 指向底層數組的指針,也是切片開始位置
  Len  int      // 切片長度
  Cap  int      // 切片容量,表示最多可存儲元素個數,與底層數組長度相等
 }
// 使用 var 聲明一個未初始化的切片,也叫 ni 切片,長度與容量都爲 0
var slice []string  
fmt.Println(*(*reflect.SliceHeader)(unsafe.Pointer(&slice))) // 輸出:{0 0 0}

// 使用切片字面量創建切片並初始化,切片長度爲 5 ,容量與長度相等
slice1 := []string{"Go","語","言","編","程"} 
fmt.Println(*(*reflect.SliceHeader)(unsafe.Pointer(&slice1))) // 輸出:{824634354664 5 5}

// 使用 make 聲明並初始化切片,切片長度爲 5 ,容量爲 10
slice2 := make([]string,5,10)  // 沒有分配任何內存空間?data指向不爲空
fmt.Println(*(*reflect.SliceHeader)(unsafe.Pointer(&slice2))) // 輸出:{824634101288 5 10}

我是跑在數組上的小切片~ 使用關鍵詞 var 聲明的切片也叫 nil 切片,該切片未初始化,長度與容量都爲 0,對於未初始化的切片,不能通過下標直接訪問或賦值,使用該切片時,需要用到 append() 函數修改切片長度再添加元素值。

使用字面量方式創建的切片初始值是我們定義的具體值,長度和容量等於元素個數相等,通過下標訪問元素或修改元素值,同樣需要通過 append() 函數修改切片長度。

關鍵詞 make 在函數內使用,我們通過它可以創建帶有長度和容量的切片,元素初始值爲切片類型的零值,通過下標訪問元素或修改元素值,通過 append() 函數增加切片長度。如果創建的切片長度等於 0 ,那麼它是一個空切片。

當我們想要創建長度爲 0 的切片,可以使用 var 創建一個 nil 切片,也使用 make 創建一個空切片,兩種方式創建出的切片長度與容量都爲 0 。

nil 切片、空切片與零切片是切片的三種狀態,nil 切片是指在聲明時未做初始化的切片,不用分配內存空間,一般使用 var 創建。使用 make 創建的空切片需要分配內存空間,nil 切片與空切片的長度、容量都爲 0 ,如果我們要創建長度容量爲 0 的切片,官方推薦 nil 切片。零切片指初始值爲類型零值的切片。

// 創建 nil 切片
var slice []int
fmt.Println(slice,*(*reflect.SliceHeader)(unsafe.Pointer(&slice))) // 輸出:[] {0 0 0}

// 創建空切片
slice2 := make([]int,0)
slice3 := []int{}
fmt.Println(slice2,*(*reflect.SliceHeader)(unsafe.Pointer(&slice2))) // 輸出:[] {18504816 0 0}
fmt.Println(slice3,*(*reflect.SliceHeader)(unsafe.Pointer(&slice3))) // 輸出:[] {18504816 0 0}

// 創建零切片
slice4 := make([]int,2,5)
fmt.Println(slice4,*(*reflect.SliceHeader)(unsafe.Pointer(&slice4))) // 輸出:[0 0] {824634474496 2 5}
slice := []string{"Go","語","言","編","程"}

// 1.訪問元素
fmt.Println(slice[0])         // 輸出:Go

// 2.修改元素
slice[0] = "PHP"
fmt.Println(slice[0])         // 輸出:[PHP 語 言 編 程]
  1. 追加新元素(擴容操作)

append() 函數 是 Go 語言裏專爲切片類型提供的操作函數。切片長度不足時,使用 append() 函數,可在切片頭部或尾部追加新元素,但是,由於在頭部追加元素會導致內存重新分配,所有元素將複製一次,因此大多數情況下推薦在尾部追加。追加的新元素可以是一個或多個,甚至是一個切片。append() 函數能實現切片自動增加長度並按需擴容。

slice := []string{"Go","語","言","編","程"}
// 1.在切片尾部追加一個元素
slice = append(slice, "!")
fmt.Println(slice,len(slice), cap(slice))   // 輸出:[Go 語 言 編 程 !] 6 10

// 2.在切片尾部追加多個元素
slice = append(slice,"!","!")
fmt.Println(slice,len(slice), cap(slice)) // 輸出:[Go 語 言 編 程 ! ! !] 8 10

// 3.在切片尾部追加切片
slice = append(slice,[]string{"!","!"}...)
fmt.Println(slice,len(slice), cap(slice)) // 輸出:[Go 語 言 編 程 ! ! ! ! !] 10 10

// 4.在切片頭部追加切片
slice = append([]string{"最","愛"},slice...)
fmt.Println(slice,len(slice), cap(slice)) // 輸出:[最 愛 Go 語 言 編 程 ! ! ! ! !] 12 12

一個數組包含類型與長度兩部分,切片的底層是一個數組,切片容量等於數組長度,在這個前提下,我們創建切片並修改它的長度時,如果長度小於容量(數組長度),那麼函數將直接在原底層數組上增加新的元素,如果切片長度超出容量(數組長度),append() 函數就會創建一個新的底層數組,再將源數組的值複製過來,我們可以通過對比 SliceHeader.Data 字段觀察底層數組發生的變化。

// 發生擴容前
slice := []string{"Go","語","言","編","程"}
fmt.Println(*(*reflect.SliceHeader)(unsafe.Pointer(&slice)))    // 輸出:{824634458112 5 5}

// 發生擴容後
slice = append(slice, "!") 
fmt.Println(*(*reflect.SliceHeader)(unsafe.Pointer(&slice)))    // 輸出:{824634466464 6 10}

append() 擴容邏輯大概是這樣,當 size 小於 1024 字節時,按乘以 2 的長度創建新的底層數組,超過 1024 字節時,按 1/4 增加。

  1. 刪除切片元素(縮容操作)

前面說到切片是動態的數組,靈活又方便,既能使用 append() 函數對它進行擴容,也能使用 [:] 運算符在源切片上創建新的切片,實現元素的刪除。:左邊是開始位,右邊是結束位,它們表示元素開始與結束的選取範圍。

// 創建一個空切片
slice := make([]string,0)
fmt.Println(slice,*(*reflect.SliceHeader)(unsafe.Pointer(&slice)))  // 輸出:[] {18541680 0 0}

// 使用 append 函數在切片尾部追加一個切片,觸發擴容,內存重新分配
slice = append(slice,[]string{"最","愛","Go","語","言","編","程","!","!"}...)
fmt.Println(slice,*(*reflect.SliceHeader)(unsafe.Pointer(&slice)))  // 輸出:[最 愛 Go 語 言 編 程 ! !] {824634204160 8 9}

// 從切片尾部刪除元素,賦值給新切片,新切片與源切片共享同一個底層數組
newSlice := slice[0:8]
fmt.Println(newSlice,*(*reflect.SliceHeader)(unsafe.Pointer(&newSlice))) // 輸出:[最 愛 Go 語 言 編 程 !] {824634204160 8 9}

// 從切片尾部刪除元素,創建新切片和新的底層數組
var newSlice2 []string
newSlice2 = append(newSlice2,slice[0:8]...)
fmt.Println(newSlice2,*(*reflect.SliceHeader)(unsafe.Pointer(&newSlice2))) // 輸出:[最 愛 Go 語 言 編 程 !] {824634212352 8 8}

// 從切片頭部和尾部刪除元素並返回新切片,內存重新分配
slice = slice[2:7]
fmt.Println(slice,*(*reflect.SliceHeader)(unsafe.Pointer(&slice)))  // 輸出:[Go 語 言 編 程] {824634204192 5 7}

通過上面的代碼,我們得出一個結論:從切片尾部刪除元素不會觸發縮容,僅長度發生變化,從切片頭部刪除元素則會觸發縮容,長度及容量都發生變化。所以刪除的元素不再使用後,一般建議申請新的內存空間,創建新的切片來接收要保留的元素,這樣可以避免原底層數組內存無效佔用,從源切片頭部刪除不存在此問題。

  1. 拷貝切片

Go 語言提供了內置函數 copy() 用來拷貝切片。在前面的代碼裏用 append() 函數演示瞭如何從切片頭部或尾部刪除元素,那如果想從中間刪除應該怎麼做?還有,前面說到當切片容量不足以添加新元素時,append() 函數會依據規則創建新的大容量底層數組,在此基礎創建新切片,再將源切片的內容拷貝到新切片中,那它是如何操作的呢?

// 源切片
slice := []string{"Go","語","言","言","言","編","程","!"}
// 創建新切片
newSlice := make([]string,6)
// 選擇範圍拷貝到新切片
at := copy(newSlice,slice[0:3])
copy(newSlice[at:],slice[5:8])
fmt.Println(newSlice)  // 輸出:[Go 語 言 編 程 !]

copy() 函數的操作使用 append() 函數也能實現,只不過 append() 函數每次複製數據都需要創建臨時切片,相比性能上 copy() 函數更勝一籌。

說到拷貝,它可分爲深拷貝與淺拷貝,我們一般默認值類型的數據是深拷貝,引用類型的數據是淺拷貝,那深拷貝與淺拷貝有什麼區別呢?深拷貝是指使用數據時,基於原始數據創建一個副本,有獨立的底層數據空間,之後的操作都在副本上進行,對原始數據沒有任何影響。反之,操作對原始數據有影響的叫做淺拷貝,比如拷貝原始數組地址。我們可以回想一下,在前面的切片操作中,哪些是深拷貝,哪些是淺拷貝。

  1. 判斷 2 個字符串切片是否相等

Go 語言標準庫中有專門的方法判斷兩個字節切片是否相等,卻沒有針對字符串切片的,當我們要判斷兩個字符串切片是否相等時,可以使用 reflect 包提供的 DeepEqual() 方法,或者自定義。但不管哪種,在使用前都要先定義什麼是相等,一個切片包含類型、長度、容量和元素值,按照以往的經驗,當類型、元素值、長度一致時,我們便認爲這兩個切片是相等的。只是,使用自定義方法需要先確定類型再比較,而使用 reflect.DeepEqual() 可以比較兩個不確定類型的值,但是需要付出很大的性能代價,所以通常我們還是使用前者。

// 創建兩個長度相同、容量不同的切片
slice1 := make([]string,2,5)
slice1[0] = "Go"
slice1[1] = "語"
slice2 := make([]string,2,6)
slice2[0] = "Go"
slice2[1] = "語"
// 方法1:自定義方法
status := true
if len(slice1) != len(slice2){
   status = false
}else{
   for k,_ := range slice1 {
    if slice1[k] != slice2[k] {
     status = false
    }
   }
}
fmt.Println(status)     // 輸出:true
// 方法2:使用 reflect.DeepEqual
fmt.Println(reflect.DeepEqual(slice1,slice2))  // 輸出:true

總結

數組跟切片是 Go 語言中很基礎的內容,它們的操作也不僅限於前面介紹的那些。切片在某種程度上是來源於數組的,所以他們可以支持相同的操作方法,但切片的形式更爲動態,操作方法和使用場景也更加豐富。

這次總結 Go 語言數組跟切片的內容花費了很長時間,儘管如此,仍然有一些” 未解之謎 “,對於已瞭解的內容也不敢說都理解恰當,我將帶着這些問題,繼續 Go 語言學習之路。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/d7Hzts5p5L58UTkprr3fuA