Go:数组和切片,深入研究。
欢迎,
在本文中,我们将全面探讨 Go 中数组和切片的基础知识,深入探讨它们的内部结构,以及为什么它们虽然功能相似,但行为却截然不同。我们将分 5 个主题来探讨数组和切片及其行为和区别:
- 默认值或“零”值
- 声明和初始化数组和切片
- 数组和切片的值部分
- 操作数组和切片
- 切片的潜在怪癖
- 优化代码性能的技巧
注意事项
len
是一个内置函数,返回切片数组中的项目数cap
是一个内置函数,用于返回数组或切片的容量。数组的容量等于其长度,而切片的容量是切片在需要调整大小之前可以容纳的最大元素数(切片容量可能大于其长度)。fmt.Println
是 Go 包中的一个函数fmt
,用于将一行打印到标准输出。它接受一个或多个值作为参数,并将它们打印到控制台,每个值之间用空格分隔,后跟一个换行符。
默认值或“零”值
在 Go 中,如果声明一个变量而没有显式初始化,该变量会被自动设置为其类型的零值。零值是声明特定类型的变量但未显式初始化时赋予该变量的默认值。例如,如果像这样声明一个 int 变量:
var x int
x 的值将被初始化为 0。
在其他语言(如 Javascript)中,未初始化变量的值是undefined
每种类型的零值如下:
int
:0float
:0.0bool
: 错误的string
: ""(空字符串)pointer
:零struct
:所有字段均设置为其各自类型的零值
Go 中数组的零值是指所有元素都设置为其对应类型的零值的数组。例如,假设有一个整数数组:
var arr [5]int
该数组的零值为:
[0, 0, 0, 0, 0]
类似地,如果你有一个字符串数组:
var arr [5]string
该数组的零值为:
["", "", "", "", ""]
在 Go 中,切片的零值为 nil,即长度为 0、容量为 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}]
在 Go 中,要创建包含不同类型的元素的数组,可以使用interface{}
类型。Go
中的接口是一种类型,它定义了一组类型必须实现的方法。任何实现了接口中列出的所有方法的类型都被称为满足该接口,并被视为属于该接口类型。特殊接口类型interface{}
没有方法,这意味着 Go 中的每种类型都满足此接口。
package main
import "fmt"
func main() {
//Array containing 5 items of different type
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() {
// Using shorthand syntax
cars := [3]string{"Tesla", "Ferrari", "Benz"}
fmt.Println(cars) // => [Tesla Ferrari Benz]
// Using ... inplace of array length
digits := [...]int{10, 20, 30, 40}
fmt.Println(digits) // => [10 20 30 40]
// Using len keyword
countries := [len(digits)]string{"China", "India", "Kenya"}
fmt.Println(countries) // => [China India Kenya]
}
请注意,您不能:=
在全局范围内使用简写语法
声明和初始化切片
要声明切片,我们使用格式var name []int
,声明数组和切片之间的唯一区别是,对于切片,我们省略了长度。
示例
import "fmt"
func main() {
// A slice of intergers
var intSlice []int
fmt.Println(intSlice) // => []
// A slice of intergers
var stringSlice []string
fmt.Println(stringSlice) // => []
}
要在 Go 中初始化切片,可以使用 make 函数。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 中数组和切片之间最重要的区别:数组只有一个部分,而切片有直接部分和间接部分(两个部分)。这意味着 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 中,赋值时不会复制底层值部分,只会复制直接值。这意味着,当我们复制数组时,我们复制的是它的元素(因为它只有直接值部分);但是当我们复制切片时,我们复制的是它的直接值部分len
,即 、cap
和pointer to elements
间接值部分(实际元素不会被复制)。
数组复制示例
x := [5]int{3, 6, 9, 12, 15}
y := v
在上面的代码示例中,我们初始化了一个数组x
,然后创建了另一个变量y
,并将的值分配(复制)x
给y
。
这是发生的情况的图表表示
当我们复制一个数组时,所有元素都会被复制到一个单独的内存块中。在上面的代码示例中,如果我们对x
数组进行修改,它不会受到影响y
,反之亦然,我们稍后会详细讨论这一点。
切片复制示例
x := []int{2,4,6,8,10}
y := x
在上面的代码示例中,我们初始化了一个切片x
,然后创建了另一个切片y
,并将的值分配(复制)x
给y
。
从上图可以看出,的直接部分(Len、Cap 和 Elem 指针)x
被复制到单独的内存中,但和仍然共享相同的底层部分(数组元素)。因此,当你修改 的元素时,由于和共享相同的底层元素,因此和可以具有不同的长度和容量,因为它们位于各自独立的内存中( 稍后会详细介绍)。y
x
y
x
y
x
y
操作数组和切片
在本节中,我们将讨论操作数组和切片。
数组
由于 Go 中的数组具有固定的常量长度,因此可以对数组执行的唯一操作是从零开始更改特定索引处的值。
第一个元素是0
第二个元素是1
第三个元素,2
依此类推。
示例
package main
import "fmt"
func main() {
var fruits [6]string // Declare string array with zero values
fmt.Println(fruits) // => [ ] (string zero value "")
// 🍊 change first element
fruits[0] = "Orange"
fmt.Println(fruits) // => [Orange ]
//🍋 change last element
fruits[5] = "Lemon"
fmt.Println(fruits) // => [Orange Lemon]
// 🍌🍉🍐🍏change all
fruits[1] = "Banana"
fruits[2] = "Watermelon"
fruits[3] = "Pear"
fruits[4] = "Apple"
fmt.Println(fruits) // => [Orange Banana Watermelon Pear Apple Lemon]
//🍍 Dont' like oranges? change first element again
fruits[0] = "Pineapple"
fmt.Println(fruits) // => [Pineapple Banana Watermelon Pear Apple Lemon]
//Modify array of integers
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}
// get the first element
first := nums[0]
fmt.Println(first) // => 1
// get third element
fmt.Println(nums[2]) // => 3
// last
fmt.Println(nums[6]) // => 7
//alternatively
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
,从 index 处的元素开始start
,到 index 处的元素(但不包括)为止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
}
注意容量也从零开始,因此如果使用5
切片的容量s
调用cap(s)
将返回4
如果start
索引为零,则可以省略它;s[:end]
同样,如果end
索引是数组的末尾,则可以省略它,如下所示s[start:]
示例
package main
import "fmt"
func main() {
s := []string{"g", "o", " ", "i", "s", " ", "s", "w", "e", "e", "t"}
// copy from 0 to index 2 (index 2 is exclusive)
goSubSlice := s[:2]
fmt.Println(goSubSlice) // => [g o]
// copy from index 3 to end
isSweetSubSlice := s[3:]
fmt.Println(isSweetSubSlice) // => [i s s w e e t]
// copy all items
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
// change n1 at index 4 to 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 时,它会影响包括主切片在内的所有其他子切片。这是因为它们共享相同的底层数组元素,因此每当我们对一个子切片进行更改时,它都会影响所有其他子切片。
以下是上述切片和子切片的图表,可以帮助您理解正在发生的事情。
从上图我们可以看到,所有子切片共享同一个底层数组,但是它们跨越底层数组的不同部分。当我们在子切片的某个索引处进行更改时,跨度为该索引的所有子切片也会被修改。
当您修改 的索引 4 处的元素时n3
,您也会修改数组索引 8 处的元素(n
),因为n3
和 n 共享同一个底层数组。因此,对 的更改n3[4]
也会反映在 中n
。同样,对索引 4 到 9(含)之间的 元素所做的任何更改n
也将反映在 中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 创建新数组,并且它将不再与其他子切片共享相同的底层数组。
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}
// Appending slice to slice
s = append(s, s2...)
fmt.Println(s) // => [10 20 30 40 50 60 70 80 90]
// Appending multiple values
s = append(s, 100, 110, 120)
fmt.Println(s) // => [10 20 30 40 50 60 70 80 90 100 110 120]
}
切片的深度值复制
深度复制只是意味着复制切片的底层数组而不是直接部分,因此目标切片不会与源切片共享相同的底层内存。
使用追加进行深度复制
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]
//Modifying slice2 doesn't affect slice1
slice2[0] = 100
fmt.Println(slice1) // => [1 2 3 4 5 6]
fmt.Println(slice2) // => [100 2 3 4 5 6]
// copying a range of items
slice3 := []int{}
slice3 = append(slice3, slice1[3:5]...)
fmt.Println(slice3) // => [4 5]
//Again slice3 and slice1 doesn't share underlying array
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
// copy a range of items
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
,则只会复制 的第一个len(dst)
元素src
。要对整个切片进行深层复制,必须确保dst
有足够的容量来容纳 的所有元素src
。
切片的潜在怪癖
如前所述,子切片仅复制切片的直接部分并共享相同的底层数组。因此,当我们有一个sBig
大小为 10 MB 的切片时,我们会sTiny
从 创建一个大小为 3 个字节的子切片sBig
,sTiny
并且sBig
将引用相同的底层数组。您可能知道 Go 是垃圾收集的,这意味着当它不被引用(无法访问)时它会自动释放内存。所以在和
的情况下,即使我们只需要3 个字节,它仍将继续在内存中,因为它引用了与 相同的底层数组。为了解决这个问题,我们进行了深度复制,以便不与 共享底层数组,因此它可以被垃圾收集从而释放内存。 例子sBig
sTiny
sTiny
sBig
sTiny
sBig
sTiny
sBig
var gopherRegexp = regexp.MustCompile("gopher")
func FindGopher(filename string) []byte {
//Reading a very huge file 1,000,000,000 bytes (1GB)
b, _ := ioutil.ReadFile(filename)
//Taking a just 6 byte sub slice
gopherSlice := gopherRegexp.Find(b)
return gopherSlice
}
从上面的例子中,我们读取了一个非常大的文件(1GB),并返回了它的一个子切片(仅 6 个字节),因为它gopherSlice
仍然引用与这个大文件相同的底层数组,这意味着即使我们不再使用这 1GB 的内存,也无法被垃圾回收。
如果多次调用该FindGopher
函数,你的程序可能会耗尽计算机的所有内存。为了解决这个问题,就像我之前说的,我们进行了一次深层复制,这样gopherSlice
就不会与这个大切片共享相同的底层数组。
示例
var gopherRegexp = regexp.MustCompile("gopher")
func FindGopher(filename string) []byte {
//Reading a very huge file 1,000,000,000 bytes (1GB)
b, _ := ioutil.ReadFile(filename)
//Taking a just 6 byte sub slice
gopherSlice := make([]byte, len("gopher"))
// Make a deep copy
copy(gopherSlice, gopherRegexp.Find(b...)
return gopherSlice
}
现在 Go 语言垃圾收集器可以释放约 1GB 的内存
优化代码性能的技巧
就像我之前说的,Go 中数组和切片之间最重要的区别是它们的值部分的不同,这与 Go 值复制成本一起是它们性能不同的原因。
价值复制成本
赋值、参数传递、使用range
关键字循环等都涉及值复制。值越大,复制成本就越高,复制 10 MB 比复制 10 字节要耗时更长。我们还了解到,只有直接部分会被复制。
示例
array := [100]int{1,2,3,4,5,6, ..., 100}
slice := []int{1,2,3,4,5,6, ..., 100}
从上面的例子中,我们创建了一个数字数组1-100
和一个同样包含数字的切片1-100
。复制数组时,所有元素都会被复制,因此值的复制成本为(假设在 64 位架构下为8 * 100 = 800 bytes
1 )。但是复制切片时,仅复制直接部分( ,和),因此值的复制成本为。即使切片和数组都包含 100 个元素,数组的值复制成本也远高于切片。int
8 bytes
len
cap
elements pointer
8 + 8 + 8 = 24 bytes
从上述场景来看,性能问题仅影响数组而非切片,我将重点讨论如何在使用数组时兼顾性能。此外,对于较小的数组,这些性能差异可以忽略不计,不值得为了提高速度而付出努力。
这里唯一的事情是不使用range
关键字来循环数组
package main
import "fmt"
func main() {
// Don't do this
arr := [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
<span class="c">// arr is copied</span>
<span class="k">for</span> <span class="n">key</span><span class="p">,</span> <span class="n">value</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">arr</span> <span class="p">{</span>
<span class="n">fmt</span><span class="o">.</span><span class="n">Println</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="n">value</span><span class="p">)</span>
<span class="p">}</span>
<span class="c">// Do this instead</span>
<span class="k">for</span> <span class="n">i</span> <span class="o">:=</span> <span class="m">0</span><span class="p">;</span> <span class="n">i</span> <span class="o"><</span> <span class="nb">len</span><span class="p">(</span><span class="n">arr</span><span class="p">);</span> <span class="n">i</span><span class="o">++</span> <span class="p">{</span>
<span class="n">fmt</span><span class="o">.</span><span class="n">Println</span><span class="p">(</span><span class="n">i</span><span class="p">,</span> <span class="n">arr</span><span class="p">[</span><span class="n">i</span><span class="p">])</span>
<span class="p">}</span>
}
结论
如果您有任何疑问,请在评论中提出,我很乐意回答;如果您希望我
写点什么,请在评论中写下。