Go 语言中的数组和切片看似简单易懂,但实际上,Go 在底层做了很多工作。本文将深入探讨 Go 数组的内部工作机制,并揭示 for-range 循环中可能遇到的陷阱。
什么是数组?
Go 语言中的数组与其他编程语言中的数组类似,它们具有固定的大小,并将相同类型的元素存储在连续的内存位置。
这种设计使得 Go 可以快速访问每个元素,因为它们的地址是根据数组的起始地址和元素的索引计算出来的。
func main() {
arr := [5]byte{0, 1, 2, 3, 4}
println("arr", &arr)
for i := range arr {
println(i, &arr[i])
}
}
// arr 0x1400005072b
// 0 0x1400005072b
// 1 0x1400005072c
// 2 0x1400005072d
// 3 0x1400005072e
// 4 0x1400005072f
从上面的代码输出中,我们可以观察到:
- 数组
arr
的地址与其第一个元素的地址相同。 - 由于我们的元素类型是
byte
,因此每个元素的地址相差 1 个字节。
这意味着我们可以通过知道第一个元素(或数组)的地址和元素的大小来访问数组中的任何元素。让我们使用 int
数组和 unsafe
包来验证这一点:
func main() {
a := [3]int{99, 100, 101}
p := unsafe.Pointer(&a[0])
a1 := unsafe.Pointer(uintptr(p) + 8)
a2 := unsafe.Pointer(uintptr(p) + 16)
fmt.Println(*(*int)(p))
fmt.Println(*(*int)(a1))
fmt.Println(*(*int)(a2))
}
// Output:
// 99
// 100
// 101
需要注意的是,上面的示例仅用于演示如何使用 unsafe
包直接访问内存,不建议在生产环境中使用这种方式。
在 Go 中,类型为 T 的数组本身并不是一种类型,而是具有特定大小和类型 T 的数组才被视为一种类型。例如:
func main() {
a := [5]byte{}
b := [4]byte{}
fmt.Printf("%T\n", a) // [5]uint8
fmt.Printf("%T\n", b) // [4]uint8
// 尝试将 [4]byte 类型的变量 b 赋值给 [5]byte 类型的变量 a 会导致编译错误
a = b
}
即使 a
和 b
都是字节数组,Go 编译器也将它们视为完全不同的类型。
数组字面量
在 Go 中,有多种方法可以初始化数组,以下是一些示例:
var arr1 [10]int // [0 0 0 0 0 0 0 0 0 0]
// 使用值初始化,自动推断长度
arr2 := [...]int{1, 2, 3, 4, 5} // [1 2 3 4 5]
// 使用索引初始化,自动推断长度
arr3 := [...]int{11: 3} // [0 0 0 0 0 0 0 0 0 0 0 3]
// 结合索引和值初始化
arr4 := [5]int{1, 4: 5} // [1 0 0 0 5]
arr5 := [5]int{2: 3, 4, 4: 5} // [0 0 3 4 5]
除了第一种方式,其他方式都同时定义和初始化了数组的值,这被称为“复合字面量”。
当我们创建包含少于 4 个元素的数组时,Go 会生成指令,将值逐个放入数组中。例如,当我们执行 arr := [3]int{1, 2, 3, 4}
时,实际发生的情况是:
arr := [4]int{}
arr[0] = 1
arr[1] = 2
arr[2] = 3
arr[3] = 4
这种策略被称为“本地代码初始化”。
对于包含 4 个或更多元素的数组,编译器会在二进制文件中创建数组的静态表示形式,这被称为“静态初始化”策略。
数组操作
数组的长度在其类型中编码。尽管数组没有 cap
属性,但我们仍然可以获取它:
func main() {
a := [5]int{1, 2, 3}
println(len(a)) // 5
println(cap(a)) // 5
}
容量等于长度,但这并不重要,重要的是我们在编译时就知道这一点。
切片是从数组中获取切片的一种方式,其完整形式由语法 [start:end:capacity]
表示。通常,您会看到它的变体:[start:end]
、[:end]
、[start:]
、[:]
。
start
是要包含在新切片中的第一个元素的索引(包含),end
是要从新切片中排除的最后一个元素的索引(不包含),capacity
是一个可选参数,用于指定新切片的容量。
让我们暂时忽略 capacity
,它将在下一篇文章中详细解释。
func main() {
a := [5]int{0, 1, 2, 3, 4}
// 从 a[1] 到 a[3-1] 创建一个新的切片
b := a[1:3] // [1 2]
// 从 a[0] 到 a[3-1] 创建一个新的切片
c := a[:3] // [0 1 2]
// 从 a[1] 到 a[5-1] 创建一个新的切片
d := a[1:] // [1 2 3 4]
}
如果缺少任何索引,则它们默认为:
start
默认为 0。end
默认为原始切片或数组的长度。capacity
默认为原始切片的容量或原始数组的长度。
新长度通过从结束索引减去开始索引来确定,新容量通过从容量参数(如果提供)或原始容量减去开始索引来确定。
数组是值类型
在某些其他语言中,数组变量基本上是指向数组第一个元素的指针。当您将数组传递给函数时,实际传递的是指针,而不是整个数组。因此,在函数中更改数组元素将影响原始数组。
相反,Go 将数组视为值类型。这意味着 Go 中的数组变量表示整个数组,而不仅仅是对其第一个元素的引用,即使打印 &a
会给出与 &a[0]
相同的地址。
当您在 Go 中将数组传递给函数时,会复制整个数组:
func doSomething(a [5]byte) {
a[0] = 1
}
func main() {
a := [5]byte{}
doSomething(a)
fmt.Println(a)
}
// [0 0 0 0 0]
输出符合预期,因为我们修改的是复制的数组,而不是原始数组。
For-Range 循环中的陷阱
当您使用 for-range 循环遍历数组时,会发生一件有趣的事情。让我们从一个简单的例子开始:
func main() {
a := [3]int{1, 2, 3}
b := [3]int{4, 5, 6}
for i, v := range a {
if i == 1 {
a = b
}
fmt.Println(v)
}
}
在代码片段中,a
和 b
都是 [3]int
,因此我们可以为它们赋值,但我们在循环中为 a
赋值 b
。
您认为输出是什么?我有三个选项:1 2 3、1 5 6 或 1 2 6。
当我们迭代到索引 1 时,我们立即更改数组,因此输出应该是 1 2 6,因为在我们赋值之前,v
已经被求值为 2。出乎意料的是,输出是 1 2 3,就像什么都没发生一样。那么,a
真的改变了吗?我们的赋值是否没有生效?
实际上,Go 确实进行了一次复制,但复制对我们隐藏了,只有 v
可以看到复制的数组。我们在循环中使用的数组 a
仍然是我们的原始 a
,如果您在循环后打印它,它将是 [4 5 6]
。
这意味着它就像按值传递一样。如果我们的数组远大于几个元素,那么进行这样的复制将是低效的,Go 团队通过允许使用指向数组的指针进行 for-range 来优化这一点。
func main() {
a := [3]int{1, 2, 3}
b := [3]int{4, 5, 6}
for i, v := range &a {
if i == 1 {
a = b
}
fmt.Println(v)
}
}
现在的输出是 1 2 6,正如我们最初预期的那样。
但这里的关键不是鼓励在循环内更改数组,我不建议这样做。相反,它表明 Go 支持使用指向数组的指针进行 for-range,而它不支持指向切片的指针。现在您知道原因了。