文章目录
在 Go 语言的生态中,
slice
(切片)无疑是日常开发中的绝对主角。然而,在我们深入 slice
的灵活与强大之前,必须先理解其背后那个坚实而略显"固执"的基石—— array
(数组)。
很多开发者可能会忽略数组,认为它不够灵活。但事实上,不理解数组,你对切片的认知就会停留在表面。Go 中所有的数据,包括切片,都是构建在数组之上的。本文将带你回归本源,深入探索 Go 语言中数组的定义、使用及其核心原理。
一、什么是数组 (Array)?
在 Go 中,数组的定义非常严格:一个由固定长度、同一类型元素组成的序列。
这句话包含了两个至关重要的特性:
- 固定长度 (Fixed-Length):一旦声明,数组的长度就不可改变。这个长度是其类型信息的一部分。
- 同一类型 (Homogeneous Type):数组中的所有元素都必须是相同的类型。
最关键的一点是:数组的长度是其类型的一部分。这意味着 [3]int
和 [4]int
在 Go 语言中是两种完全不同的、不兼容的类型。
package main
import "fmt"
func main() {
var a [3]int
var b [4]int
// a = b // 这行代码会编译错误: cannot use b (variable of type [4]int) as [3]int value in assignment
fmt.Printf("a 的类型是: %T\n", a)
fmt.Printf("b 的类型是: %T\n", b)
}
输出:
a 的类型是: [3]int
b 的类型是: [4]int
二、数组的创建与初始化
Go 提供了几种灵活的方式来初始化一个数组,适用于各种数据类型。
方式一:标准声明
这是最基础的声明方式,它会创建一个数组,并将其所有元素初始化为该类型的零值。
// 声明一个包含5个整数的数组,所有元素默认为 0
var intArr [5]int
fmt.Println("intArr:", intArr) // 输出: intArr: [0 0 0 0 0]
// 声明一个包含3个字符串的数组,所有元素默认为空字符串 ""
var strArr [3]string
fmt.Printf("strArr: %q\n", strArr) // 输出: strArr: ["" "" ""]
方式二:使用数组字面量 (Array Literal)
你可以在声明时直接提供初始值。
// 声明并初始化一个长度为3的整数数组
b := [3]int{10, 20, 30}
fmt.Println("b:", b) // 输出: b: [10 20 30]
// 如果提供的初始值少于数组长度,其余元素会自动用零值填充
c := [5]int{1, 2}
fmt.Println("c:", c) // 输出: c: [1 2 0 0 0]
// 声明一个字符串数组
d := [2]string{"hello", "world"}
fmt.Println("d:", d) // 输出: d: [hello world]
方式三:使用 ...
自动推断长度
如果你不想手动数元素的个数,可以使用 ...
让编译器为你代劳。这是一个非常实用和推荐的技巧。
// 编译器会自动计算长度为4
e := [...]int{1, 2, 3, 4}
fmt.Printf("e: %v, 类型: %T\n", e, e) // 输出: e: [1 2 3 4], 类型: [4]int
// 同样适用于其他类型
f := [...]string{"Go", "is", "fun"}
fmt.Printf("f: %v, 类型: %T\n", f, f) // 输出: f: [Go is fun], 类型: [3]string
方式四:指定索引进行初始化
你还可以通过 索引:值
的方式来初始化特定的元素,这对于创建稀疏数组或初始化特定位置的值非常方便。
// 创建一个长度为12的数组,只初始化特定位置
g := [12]int{0: 100, 5: 500, 11: 1100}
fmt.Println("g:", g) // 输出: g: [100 0 0 0 0 500 0 0 0 0 0 1100]
// 也可以用于自定义结构体类型
type Point struct{ X, Y int }
h := [...]Point{{1, 2}, {3, 4}, {X: 9}} // 混合使用
fmt.Println("h:", h) // 输出: h: [{1 2} {3 4} {9 0}]
三、数组的操作
数组的操作相对简单直接。
- 访问与修改:通过索引
[]
来访问和修改元素。Go 会进行边界检查,访问越界的索引会导致运行时 panic。 - 遍历:推荐使用
for...range
循环,它能同时提供索引和值(注意:循环变量是元素的 副本,对其赋值不会修改原数组;如需就地修改请使用索引或取元素地址)。
notes := [...]string{"do", "re", "mi"}
// 1. 访问与修改(索引法)
notes[0] = "DO" // 修改第 0 个元素
fmt.Println("修改后:", notes)
// fmt.Println(notes[3]) // 越界访问会 panic: index out of range [3] with length 3
// 2. 遍历(for-range)
for i, note := range notes {
fmt.Printf("索引 %d, note=%s\n", i, note)
}
// 注意:note 是副本,下面的操作不会改变原数组
for _, note := range notes {
note = "XX" // 修改的是副本
}
fmt.Println("for-range 后:", notes) // 仍然是 [DO re mi]
四、核心原理:数组是值类型 (Value Type)
这是理解数组最核心、最关键的一点。在 Go 中,数组是值类型,而不是引用类型。
这意味着,当你将一个数组赋值给另一个变量,或者将它作为参数传递给一个函数时,Go 会完整地复制整个数组的内容。新变量得到的是原始数组的一个全新副本。
让我们用一个例子来证明这一点:
package main
import "fmt"
// 这个函数接收一个数组作为参数
func modifyArray(arr [3]int) {
arr[0] = 999
fmt.Println("函数内部的数组:", arr)
}
func main() {
// 原始数组
original := [3]int{1, 2, 3}
fmt.Println("--- 赋值操作 ---")
// 将 original 赋值给 copied,这里发生了完整的拷贝
copied := original
copied[0] = 100
fmt.Println("Original 数组:", original) // original 不受影响
fmt.Println("Copied 数组:", copied)
fmt.Println("\n--- 函数传参 ---")
fmt.Println("调用函数前的 Original 数组:", original)
modifyArray(original) // 传递给函数的也是一个副本
fmt.Println("调用函数后的 Original 数组:", original) // original 依然不受影响
}
输出:
--- 赋值操作 ---
Original 数组: [1 2 3]
Copied 数组: [100 2 3]
--- 函数传参 ---
调用函数前的 Original 数组: [1 2 3]
函数内部的数组: [999 2 3]
调用函数后的 Original 数组: [1 2 3]
这个结果清晰地表明:无论是赋值还是函数传参,操作的都是数组的副本,原始数组安然无恙。这也解释了为什么我们不常将大数组直接作为函数参数——因为每次调用都会产生语义上的拷贝开销。
如何实现引用传递?
如果你确实需要修改原始数组,应该传递指向数组的指针。
func modifyArrayByPointer(arr *[3]int) {
arr[0] = 999 // 通过指针修改原始数组
}
// 在 main 函数中调用
modifyArrayByPointer(&original)
fmt.Println("通过指针修改后:", original) // 输出: [999 2 3]
五、数组 vs. 切片:一图胜千言
特性 | 数组 (Array) | 切片 (Slice) |
---|---|---|
长度 | 固定的,是类型的一部分 | 可变的 |
类型定义 | [N]T (例如 [5]int ) |
[]T (例如 []int ) |
传递行为 | 值传递 (拷贝整个数组) | 值传递 (复制切片头,共享底层数组) |
灵活性 | 低,长度不可变 | 高,可以动态增删 |
主要用途 | 作为切片的底层数据结构;或在需要精确控制内存布局的场景 | Go 程序中最常用的序列类型 |
六、更多补充知识点
- 可比较性与 map 键:如果元素类型可比较,数组可使用
==
/!=
,也能作为map
的键。 - 数组永不为 nil:数组是值类型,其零值为所有元素的零值集合,而非
nil
。 len
是编译期常量:对固定长度数组使用len
会在编译期求值,利于边界校验与优化。- 零长度数组:
var z [0]int
合法,可用于类型级标记或结构体对齐技巧。 - 多维数组内存布局:
[M][N]T
本质是"数组的数组",在内存中按行优先连续存储,有利于 CPU 缓存。 - 数组指针
*[N]T
vs. 切片[]T
:前者仅包含一个指针且无边界信息,后者包含指针、长度、容量,访问时具备边界检查。
总结
虽然在日常的 Go 开发中,我们 99% 的时间都在使用切片,但理解数组的本质至关重要。它就像是冰山在水下的部分,支撑着水面上的切片。
请记住这几点:
- 数组是固定长度的序列。
- 数组是值类型,赋值和传参都会导致完整拷贝。
- 数组的长度是其类型的一部分,
[3]int
和[4]int
是不同的类型。
正是因为数组的这些"固执"特性,才催生了 slice
这样更灵活、更强大的数据结构。掌握了数组,你对切片的理解才会更加深刻。
参考:
https://go.dev/doc/effective_go.html#arrays