【譯】Go 語言:深入探究 array 與 slice
你好,我是小四,你情商高,也可以叫我四哥~
這篇文章我們將討論 Go 語言中數組與切片 (slice),深入探究它們的內部結構以及爲什麼它們表現不一樣,即使它們能做類似的事情。
我們將從以下幾個方面討論數組和切片的表現差異:
-
默認值和零值
-
聲明和初始化數組和切片
-
數組和切片的值部分
-
操作數組和切片
-
關於切片的潛在陷阱
-
代碼優化的小技巧
默認值和零值
Go 語言中,聲明變量時如果沒有顯式地賦值初始化,該變量將會自動地設置成對應類型的零值。零值是在聲明但未顯式初始化變量時,分配給變量的特定類型對應的默認值。例如,如果像下面這樣聲明一個 int 型變量:
var x int
x 的初始值爲 0。
不同類型的零值如下所示:
-
int: 0
-
float: 0.0
-
bool: false
-
string: ""
-
pointer: nil
-
struct: 所有字段對應的類型零值
Go 語言中數組的零值是一個數組,所有元素的值是對應類型的零值。例如,如果你有一個整型數組:
var arr [5]int
數組的零值是:
[0, 0, 0, 0, 0]
類似的,如果有一個 string 數組:
var arr [5]string
數組的零值爲:
["", "", "", "", ""]
Go 語言裏,切片的零值是 nil,是長度和容量爲 0、底層沒有對應數組的切片。例如:
var slice []int
fmt.Println(slice == nil) // => true
聲明、初始化數組
Go 中聲明數組的語法是:var name [L]T,var 是 Go 語言聲明變量的關鍵字,name 是變量名稱 (需要符合變量命名要求),L 是數組的長度 (必須是常量),T 是數組元素的類型。
//Array of 5 Intergers
var nums [5]int
fmt.Println(nums) // => [0 0 0 0 0]
//Array of 10 strings
var strs [10]string
fmt.Println(nums) // => [ ]
// Nested arrays 多維數組
var nested = [3][5]int{
{1, 2, 3, 4, 5},
{6, 7, 8, 9, 10},
{11, 12, 13, 13, 15},
}
fmt.Println(nested) // => [[1 2 3 4 5] [6 7 8 9 10] [11 12 13 13 15]]
數組初始化可以簡單地理解爲是爲變量賦值,格式爲 var name = [L]T{...}
,其中 ...
表示 T 類型的數組元素。
//Intializing an array containing 10 intergers
var nums = [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
fmt.Println(nums) // => [1 2 3 4 5 6 7 8 9 10]
//Intializing an array containing 10 strings
var strs = [10]string{"one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"}
fmt.Println(strs) // => [one two three four five six seven eight nine ten]
//Nested arrays
var nested = [3][2]int{}
你也可以創建一個結構體類型的數組:
type Car struct {
Brand string
Color string
Price float32
}
//Array of 5 items of type Car
var arrayOfCars = [5]Car{
{Brand: "Porsche", Color: "Black", Price: 20_000.00},
{Brand: "Volvo", Color: "White", Price: 8_000.00},
{Brand: "Honda", Color: "Blue", Price: 7_000.00},
{Brand: "Tesla", Color: "Black", Price: 50_000.00},
{Brand: "Kia", Color: "Red", Price: 5_000.98},
}
fmt.Println(arrayOfCars) // => [{Porsche Black 20000} {Volvo White 8000} {Honda Blue 7000} {Tesla Black 50000} {Kia Red 5000.98}]
如果想要創建具有不同類型元素的數組,可以使用 interface{}
類型。接口是 Go 的一種類型,它定義了其他類型必須實現一組方法。任何實現接口中列出的所有方法的類型,都被認爲是實現了該接口,認爲是該接口類型。特殊的接口類型 interface{}
沒有定義方法,意味着所有的類型都實現了該接口。
package main
import "fmt"
func main() {
// 數組包含不同的類型
var randomsArray = [5]interface{}{"Hello world!", 35, false, 33.33, 'A'}
fmt.Println(randomsArray) // => [Hello world! 35 false 33.33 65]
}
初始化數組的其他方式:
import "fmt"
func main() {
// 使用短變量聲明方式
cars := [3]string{"Tesla", "Ferrari", "Benz"}
fmt.Println(cars) // => [Tesla Ferrari Benz]
// 使用 ... 代替數組長度
digits := [...]int{10, 20, 30, 40}
fmt.Println(digits) // => [10 20 30 40]
// 使用 len 關鍵字
countries := [len(digits)]string{"China", "India", "Kenya"}
fmt.Println(countries) // => [China India Kenya]
}
注意,聲明全局變量時不能使用短變量聲明的方式::=
。
聲明和初始化切片
聲明切片的語法是:var name []int
,這種方式與聲明數組唯一區別是聲明切片是可以忽略長度。
例如:
import "fmt"
func main() {
// 整型切片
var intSlice []int
fmt.Println(intSlice) // => []
// 字符串切片
var stringSlice []string
fmt.Println(stringSlice) // => []
}
Go 語言裏面可以使用 make()
函數初始化切片,該函數有三個參數:切片元素類型、切片長度和切片容量 (可以忽略),語法是:make([]T, len, cap)
例如,想要創建長度爲 5、容量爲 10 的整型切片可以使用如下代碼:
package main
import "fmt"
func main() {
// With capacity
slice1 := make([]int, 5, 10)
fmt.Println(len(slice1), cap(slice1)) // => 5 10
// Without capacity
slice2 := make([]int, 5)
fmt.Println(len(slice2), cap(slice2)) // => 5 5
}
如果初始化的時候忽略了容量,則容量與長度時一樣的。
除了 make()
函數之外,也可以通過賦值操作直接初始化切片。
slice := []int{1, 2, 3}
fmt.Println(len(slice), cap(slice)) // => 3 3
數組和切片的值部分
這是 Go 中數組與切片最重要的區別,數組只包含一部分而切片由直接和間接兩部分組成。這意味着,數組是固定長度的數據結構,由存儲元素的連續內存塊組成。切片是動態大小,並引用底層數組的連續內存塊。
爲了更好地理解,通過下面示例看看數組和切片的值部分。
var arr = [5]int{1,2,3,4,5}
var slice = []int{1,2,3,4,5}
數組:
切片:
從上圖我們可以得出,數組是相同類型元素的固定大小集合、存儲在連續的內存塊中,另外一方面,切片由指向底層數組的指針、長度和容量組成。
切片的直接部分的內部結構:
type _slice struct {
// referencing underlying elements
elements unsafe.Pointer
// number of elements
len int
// capacity of the slice
cap int
}
當拷貝數組和切片時,發生了什麼?
在 Go 中,賦值時底層值不會被拷貝,只有直接值會被拷貝。這意味着,當我們拷貝數組時,將會得到一份值得副本。而當拷貝切片時,我們將會拷貝它的直接部分,比如長度、容量和指向底層數組的指針。
數組拷貝示例:
x := [5]int{3, 6, 9, 12, 15}
y := v
上面的例子中,我們初始化了數組 x,接着通過賦值的方式創建了變量 y,它是 x 的副本。
當我們複製一個數組時,所有元素將會被拷貝到另一個單獨的內存塊中。在上面的代碼中,加入我們修改 x 並不會影響到 y,反之亦然。我們稍後會詳細討論。
切片拷貝示例:
x := []int{2,4,6,8,10}
y := x
上面的代碼中,我們初始化了一個切片 x,接着創建了另一個切片 y,並將 x 賦給 y。
從上圖可以看出,x 的直接部分被拷貝到 y 對應的內存中,但是 x 和 y 共享底層數組,所以當修改 x 的元素時也會影響到 y。但是 x 和 y 可以有不同的長度和容量,因爲它們存儲在各自單獨的內存中。這塊我們將在後面詳細討論。
操作數組和切片
在本節中,我們將討論操作數組和切片。
數組
因爲 Go 中的數組長度是固定的,所以唯一可以對數組進行的操作是更改數組的元素值。
示例:
package main
import "fmt"
func main() {
var fruits [6]string // 聲明字符串數組(默認零值)
fmt.Println(fruits) // => [ ] (字符串零值 "")
// 🍊 修改索引爲 0 的元素值
fruits[0] = "Orange"
fmt.Println(fruits) // => [Orange ]
//🍋 修改最後面一個元素值
fruits[5] = "Lemon"
fmt.Println(fruits) // => [Orange Lemon]
// 修改所有元素
fruits[1] = "Banana"
fruits[2] = "Watermelon"
fruits[3] = "Pear"
fruits[4] = "Apple"
fmt.Println(fruits) // => [Orange Banana Watermelon Pear Apple Lemon]
// 再次修改
fruits[0] = "Pineapple"
fmt.Println(fruits) // => [Pineapple Banana Watermelon Pear Apple Lemon]
// 修改整型數組
evenNumbers := [5]int{2, 4, 6, 8, 10}
evenNumbers[0] = 12
fmt.Println(evenNumbers) // => [12 4 6 8 10]
evenNumbers[3] = 20
fmt.Println(evenNumbers) // => [12 4 6 20 10]
}
訪問數組值:
import "fmt"
func main() {
nums := [7]int{1, 2, 3, 4, 5, 6, 7}
// 獲取第一個元素
first := nums[0]
fmt.Println(first) // => 1
// 獲取第三個元素
fmt.Println(nums[2]) // => 3
// 獲取最後一個元素
fmt.Println(nums[6]) // => 7
// 或者使用下面這種方式獲取最後一個元素
fmt.Println(nums[len(nums)-1]) // => 7
}
如果被修改元素的索引值大於等於數組長度,將會報 panic 錯誤。
package main
import "fmt"
func main() {
nums := [7]int{1, 2, 3, 4, 5, 6, 7}
outOfBound := nums[7]
}
invalid argument: array index 7 out of bounds [0:6]
切片
切片是 Go 中非常有用的數據類型,它提供了一種靈活方便的方式來操作數據集合。它可以像數組一樣被訪問和修改,但也有一些特殊的用法,使得切片更強大。下面我們將更詳細地探討其中一些用法。
切片表達式
切片表達式的簽名:s[start:end:cap]
基於切片 s 創建一個新的切片 (可以包括原切片的所有元素),包含的元素從索引 start
處開始,到但不包括 end
索引處的元素,cap
是新創建子切片的容量,是可選的。如果 cap
省略,子切片的容量等於其長度。子切片的長度計算公式:end - start。
示例:
package main
import "fmt"
func main() {
slice := []int{1, 2, 3, 4, 5, 6}
subSlice := slice[1:4]
fmt.Println(subSlice) // => [2 3 4]
fmt.Println(len(subSlice), cap(subSlice)) // => 3 3
subSliceWithCap := slice[1:4:5]
fmt.Println(subSliceWithCap) // => [2 3 4]
fmt.Println(len(subSliceWithCap), cap(subSliceWithCap)) // => 3 4
}
如果開始索引爲零,則可以省略,例如: s[:end],同樣,如果結束索引是數組的結尾,則也可以省略它,例如:s[start:]。
package main
import "fmt"
func main() {
s := []string{"g", "o", " ", "i", "s", " ", "s", "w", "e", "e", "t"}
// 複製索引 0 到 2 的元素(不包括索引 2 的元素)
goSubSlice := s[:2]
fmt.Println(goSubSlice) // => [g o]
// 複製從索引 3 開始的所有元素
isSweetSubSlice := s[3:]
fmt.Println(isSweetSubSlice) // => [i s s w e e t]
// 複製所有元素
copySlice := s[:]
fmt.Println(copySlice) // => [g o i s s w e e t]
}
之前我們討論了切片值的部分以及只有切片的直接部分會被複制,那麼當我們創建一個子切片時實際上會發生什麼?
我們通過示例來說明:
package main
import "fmt"
func main() {
n := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
n1 := n[:6]
n2 := n[3:8]
n3 := n[4:10]
fmt.Println(n1, len(n1), cap(n1)) // => [1 2 3 4 5 6] 6 10
fmt.Println(n2, len(n2), cap(n2)) // => [4 5 6 7 8] 5 7
fmt.Println(n3, len(n3), cap(n3)) // => [5 6 7 8 9 10] 6 6
// 將索引 4 的元素值改爲 15
n1[4] = 15
fmt.Println(n) // => [1 2 3 4 15 6 7 8 9 10]
fmt.Println(n1) // => [1 2 3 4 15 6]
fmt.Println(n2) // => [4 15 6 7 8]
fmt.Println(n3) // => [15 6 7 8 9 10]
}
注意,當我們將 n1[4] 更改爲 15 時,它會影響所有其他子切片,包括主切片。這是因爲它們共享相同的底層數組元素,因此每當我們對子切片進行更改時,它會影響所有其他子切片。
下面這張圖可以幫助我們理解上面的代碼:
從上圖可以看出,所有的子切片共享相同的底層數組,但是各自包括的元素不同。當我們修改某一索引處的元素時,包含該元素的切片也會被修改。
當你修改 n3 索引 4 的元素時,數組 n 索引 8 的元素也會被修改,因爲 n3 和 n 共享底層數組。同樣,對於 n 中索引 4 到 9(包括 9)之間的元素所做的任何更改也會影響到 n3,因爲 n3 包含這些元素。
n3[4] = 18
fmt.Println(n) // => [1 2 3 4 15 6 7 8 18 10]
fmt.Println(n1) // => [1 2 3 4 15 6]
fmt.Println(n2) // => [4 15 6 7 8]
fmt.Println(n3) // => [15 6 7 8 18 10]
切片追加元素
Go 的 append() 函數允許我們向切片末尾添加元素,語法如下:
func append(s []T, x ...T) []T
s 是要追加的切片,x 是要追加的一個或多個 T 類型元素的列表,函數將返回一個包含追加元素的新切片。
示例:
s := []int{1, 2, 3}
s = append(s, 4, 5, 6)
fmt.Println(s) // => s is now [1, 2, 3, 4, 5, 6]
注意,如果底層數組的容量不足以容納附加的元素,則 append() 函數將分配一個新的、更大容量的數組保存結果。如果 append 操作之後創建了一個更大的數組,則新切片將不在與原來的子切片共享相同的底層數組。
我們來看一個例子:
package main
import "fmt"
func main() {
n := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
n1 := n[:6]
n2 := n[3:8]
n3 := n[4:10]
fmt.Println(n1, len(n1), cap(n1)) // => [1 2 3 4 5 6] 6 10
fmt.Println(n2, len(n2), cap(n2)) // => [4 5 6 7 8] 5 7
fmt.Println(n3, len(n3), cap(n3)) // => [5 6 7 8 9 10] 6 6
}
上面的代碼,n2 的長度 5、容量爲 7,這意味着在不創建新的底層數組的情況下,還可以再多追加兩個元素,並且和其他子切片共享相同的底層數組。
n2 = append(n2, 100)
fmt.Println(n) // => [1 2 3 4 15 6 7 8 100 10]
fmt.Println(n1) // => [1 2 3 4 15 6]
fmt.Println(n2) // => [4 15 6 7 8, 100]
fmt.Println(n3) // => [15 6 7 8 100 10]
n2 = append(n2, 101)
fmt.Println(n) // => [1 2 3 4 15 6 7 8 100 101]
fmt.Println(n1) // => [1 2 3 4 15 6]
fmt.Println(n2) // => [4 15 6 7 8 100 101]
fmt.Println(n3) // => [15 6 7 8 100 101]
// Check the capacity and length of n2
fmt.Println(cap(n2), len(n2)) // => 7 7
當我們追加更多元素時,它會影響到 n 和 n3,但現在 n2 的容量已經等於它的長度,因此追加一個新的元素將會爲 n2 創建一個新的數組,它將不再與其他子切片共享相同的底層數組。
n2 = append(n2, 102)
fmt.Println(n) // => [1 2 3 4 15 6 7 8 100 101]
fmt.Println(n1) // => [1 2 3 4 15 6]
fmt.Println(n2) // => [4 15 6 7 8 100 101 102]
fmt.Println(n3) // => [15 6 7 8 100 101]
從上面的代碼可以看出只有 n2 被修改了,其他的切片沒有受到影響。
追加多個元素
示例:
package main
import "fmt"
func main() {
s := []int{10, 20, 30, 40, 50, 60}
s2 := []int{70, 80, 90}
// 往一個切片追加另一切片
s = append(s, s2...)
fmt.Println(s) // => [10 20 30 40 50 60 70 80 90]
// 追加多個元素
s = append(s, 100, 110, 120)
fmt.Println(s) // => [10 20 30 40 50 60 70 80 90 100 110 120]
}
深度拷貝切片
深度拷貝是指拷貝切片的底層數組而不是直接部分,因此目標切片不會與源切片共享相同的底層數組。
使用 append() 實現深度拷貝:
package main
import (
"fmt"
)
func main() {
slice1 := []int{1, 2, 3, 4, 5, 6}
slice2 := []int{}
slice2 = append(slice2, slice1...)
fmt.Println(slice1) // => [1 2 3 4 5 6]
fmt.Println(slice2) // => [1 2 3 4 5 6]
// 修改 slice2 不會影響到 slice1
slice2[0] = 100
fmt.Println(slice1) // => [1 2 3 4 5 6]
fmt.Println(slice2) // => [100 2 3 4 5 6]
// 拷貝一定範圍內的值
slice3 := []int{}
slice3 = append(slice3, slice1[3:5]...)
fmt.Println(slice3) // => [4 5]
// slice3 與 slice1 不會共享底層數組
slice3[0] = -10
fmt.Println(slice1) // => [1 2 3 4 5 6]
fmt.Println(slice3) // => [-10 5]
}
Go 中,可以使用 copy() 函數對切片進行深度拷貝。深度拷貝會創建一個新的切片,並拷貝原始切片的元素,新的切片將擁有自己獨立的元素副本。
語法如下:
func copy(dst, src []T) int
dst 是目標切片,src 是源切片。兩個切片必須有相同的元素類型 T。該函數返回複製的元素數量,取 dst 和 src 長度的最小值。
示例:
package main
import "fmt"
func main() {
s := []int{1, 2, 3}
t := make([]int, len(s))
copy(t, s)
fmt.Println(t) // => [1, 2, 3], and is a deep copy of s
t = make([]int, len(s)-1)
copy(t, s[0:2])
fmt.Println(t) // => [1, 2], and is a deep copy of s
}
注意,如果目標切片 dst 的長度小於源切片 src 的長度,則只會複製 src 的前 len(dst) 個元素。要深度複製整個切片,必須確保 dst 具有足夠的容量以容納 src 的所有元素。
切片的潛在陷阱
正如之前提到過,子切片與原切片共享底層數組。因此,當我們從一個大小爲 10MB 的切片 sBig 創建一個大小爲 3 字節的子切片 sTiny 時,sTiny 和 sBig 將引用相同的底層數組。你可能知道 Go 是通過垃圾回收機制來自動釋放不再被引用的內存的。因此在這種情況下,即使我們只需要 3 個字節的 sTiny,sBig 仍將繼續存在於內存中,因爲 sTiny 引用了與 sBig 相同的底層數組。爲了解決這個問題,我們可以進行深度複製,這樣 sTiny 不會與 sBig 共享相同的底層數組,因此它可以被垃圾回收,從而釋放內存。
var gopherRegexp = regexp.MustCompile("gopher")
func FindGopher(filename string) []byte {
// 讀取大文件 1,000,000,000 bytes (1GB)
b, _ := ioutil.ReadFile(filename)
// 只取一個 6 字節的子切片
gopherSlice := gopherRegexp.Find(b)
return gopherSlice
}
上面的示例中,我們讀取了一個非常大的文件(1GB)並返回了它的一個子切片(僅 6 個字節)。由於 gopherSlice 仍然引用與大文件相同的底層數組,這意味着 1GB 的內存即使我們不再使用它也無法被垃圾回收。如果多次調用 FindGopher 函數,則程序可能會耗盡計算機的所有內存。爲了解決這個問題,就像之前說過的一樣,我們可以進行深度複製,這樣 gopherSlice 就不會再與巨大的切片共享底層數組。
var gopherRegexp = regexp.MustCompile("gopher")
func FindGopher(filename string) []byte {
// 讀取大文件 1,000,000,000 bytes (1GB)
b, _ := ioutil.ReadFile(filename)
// 只取一個 6 字節的子切片
gopherSlice := make([]byte, len("gopher"))
// 深度拷貝
copy(gopherSlice, gopherRegexp.Find(b...)
return gopherSlice
}
這樣寫的話,Go 的垃圾回收器可以釋放大約 1G 的內存。
優化代碼性能的技巧
就像我之前提到的,Go 中數組和切片最重要的區別在於他們值部分不同,再加上 Go 複製時的成本,這是它們性能差異的原因。
值拷貝開銷
值分配、參數傳遞、使用 range 關鍵字循環等,都涉及值拷貝。值大小越大,拷貝的代價就越大,複製 10M 字節所需的時間將比複製 10 字節的時間更長。而拷貝切片時只有直接部分會被複制。
示例:
array := [100]int{1,2,3,4,5,6, ..., 100}
slice := []int{1,2,3,4,5,6, ..., 100}
在上面的例子,我們創建了一個包含 1-100 數字的數組和一個包含 1-100 數字的切片。當我們拷貝數組時,所有元素都被複制,因此值拷貝的代價將是 8 * 100 = 800 字節(64 位架構中 1 個 int 佔用 8 字節),但是當我們複製切片時,只有直接部分被複制(長度,容量和元素指針),因此值拷貝的代價將是 8 + 8 + 8 = 24 字節。儘管切片和數組都包含 100 個元素,但數組的值複製代價比切片的要大得多。
從上面情況來看,如果有這方面性能問題,首先考慮是數組導致的而不是切片。我將專注於如何在考慮性能的情況下使用數組。此外,對於小數組,與切片相比,拷貝的時候性能差異微不足道,就無需再費精力考慮如何優化了。
不要使用 range 關鍵字來遍歷數組,像下面這樣:
package main
import "fmt"
func main() {
// Don't do this
arr := [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
// arr is copied
for key, value := range arr {
fmt.Println(key, value)
}
// Do this instead
for i := 0; i < len(arr); i++ {
fmt.Println(i, arr[i])
}
}
via: https://dev.to/dawkaka/go-arrays-and-slices-a-deep-dive-dp8
作者:Yussif Mohammed
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/uTdhLMfXG-AF21PhKqTpww