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 
}

即使 ab 都是字节数组,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)
	}
}

在代码片段中,ab 都是 [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,而它不支持指向切片的指针。现在您知道原因了。