这篇文章我们将讨论 Go 语言中数组与切片(slice),深入探究它们的内部结构以及为什么它们表现不一样,即使它们能做类似的事情。
我们将从以下几个方面讨论数组和切片的表现差异:
Go 语言中,声明变量时如果没有显式地赋值初始化,该变量将会自动地设置成对应类型的零值。零值是在声明但未显式初始化变量时,分配给变量的特定类型对应的默认值。例如,如果像下面这样声明一个 int 型变量:
var x int
x 的初始值为 0。
不同类型的零值如下所示:
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 mainimport "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 mainimport "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 mainimport "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 mainimport "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 mainimport "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 mainimport "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 mainimport "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 mainimport "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 mainimport "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 mainimport (
"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 mainimport "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 mainimport "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
推荐阅读