第2章 Array
数组是 Go 中最“老实“的容器:定长、值语义、内存连续,它也是切片的物理基础。
2.1 Array 是什么
(1) 是什么
数组(Array)是 Go 语言中一种定长、同质、值类型的容器类型,把 N 个同类型元素连续存放在一段内存里。它的类型签名是 [N]T,例如 [5]int 表示“5 个 int 组成的数组“。
package main
import "fmt"
func main() {
var a [5]int // 零值数组:5 个 0
b := [5]int{1, 2, 3, 4, 5} // 字面量
c := [...]int{10, 20, 30} // 长度由元素推断,类型仍为 [3]int
fmt.Println(a, b, c)
}
要点:
[...]int{...}中的...只是编译期推断语法糖,编译后c的类型仍然是[3]int,长度固定。- 字面量可以带索引:
[...]int{9: 0}的长度由最大索引 + 1 推断,类型是[10]int;未显式给值的元素为零值。 - 数组一旦声明,长度不可变;编译期可见的越界会被编译器直接拒绝,运行期越界触发 panic,消息形如
panic: runtime error: index out of range [7] with length 5(带具体下标和长度)。 - 对数组变量
a,len(a)和cap(a)返回相同的常量。更一般地,数组表达式若包含必须求值的函数调用或 channel 接收,相关len/cap调用不一定是常量。
(2) 为什么这样设计
Go 数组的表示就是连续排列的元素,没有 slice header,也没有扩容逻辑。这样数组可以:
- 精确表达固定数量元素的内存布局;cgo 会把部分 C 数组或 union 映射为相应 Go 数组,但普通 Go 数组与 C 类型之间仍需显式转换并遵守 cgo 指针规则;
- 作为切片 backing store,以及 Runtime 定长表和 map group 数据区的组成部分;
- 让编译器在编译期掌握总大小,并校验常量下标。
(3) 工程实践与常见坑
- 数组的下标若为常量表达式,编译期即可校验;非常量下标会在运行时注入边界检查。
- 越界访问会触发
runtime error: index out of range [i] with length npanic,Go 运行时默认插入边界检查(可用-gcflags="-B"关闭,强烈不推荐)。 - 数组是切片的“地基“,理解数组有助于理解切片的指针从哪里来(见 第3章 Slice)。
2.2 为什么长度属于类型
(1) 现象
在 Go 中,[3]int 和 [4]int 是两个完全不同的类型,不能互相赋值,也不能用 == 比较:
package main
func main() {
var a [3]int
var b [4]int
// a = b // 编译错误:cannot use b (type [4]int) as type [3]int
// _ = a == b // 编译错误:invalid operation
_ = a
_ = b
}
(2) 为什么这样设计
把长度编码进类型,是 Go(继承自 Pascal/C 谱系)的一组协同设计:
- 类型安全:不同长度的数组互不兼容,编译器在编译期就阻止了“4 长度数组塞进 3 长度数组“这类错误。
- 布局静态可知:因为长度是类型的一部分,编译器知道数组的大小和元素偏移,不需要读取 slice header;非常量下标仍可能需要边界检查。
- 配合值语义:数组赋值在语义上复制全部元素。编译器可按大小和目标架构选择寄存器传递、若干 load/store、
memmove,或消除不可观察的拷贝。
在类型系统层面,[N]T 中的 N 是类型的一部分(不是泛型参数)。Go 1.18 的泛型允许把“类型“作为参数,但目前还不能把整数常量作为类型参数——这是 Go 泛型当前的明确限制,也是为什么 [N]T 仍是内建特殊语法:
// 以下写法目前编译不过:N 不能是 int 类型参数
// func Sum[T any, N int](a [N]T) T { ... }
(3) 工程实践与常见坑
- 函数参数写
[5]int就只能接受 5 长度数组;想接受任意长度请用[]int切片。 - 同长度数组之间可以
==和!=(元素类型必须可比较),按元素逐个比较。 - 数组类型可以作为 map key(元素类型可比较时);相等性按元素类型的
==语义递归比较,不是对任意数组简单执行逐字节比较。 - 固定长度本身属于契约时可以在 API 中使用
[N]T,例如哈希摘要;长度只是偶然实现细节时通常用 slice 更易演进。
2.3 Array 的内存布局
(1) 是什么
一个 [N]T 数组在内存中就是 N 个连续的 T,总大小为 N * sizeof(T),对齐为 alignof(T)。
[5]int32 在 64 位平台(int32 4 字节,对齐 4):
偏移: 0 4 8 12 16
+---+---+---+---+---+
| a | b | c | d | e | 每格 4 字节
+---+---+---+---+---+
总大小 = 5 * 4 = 20 字节,对齐 = 4
可以用 unsafe.Sizeof 和 unsafe.Alignof 验证:
package main
import (
"fmt"
"unsafe"
)
func main() {
var a [5]int32
fmt.Println(unsafe.Sizeof(a)) // 20
fmt.Println(unsafe.Alignof(a)) // 4
fmt.Println(len(a), cap(a)) // 5 5
}
(2) 为什么这样设计
- 连续 + 紧凑:除了元素自身需要的 padding 外没有额外开销,对 CPU 缓存友好,适合数值计算和哈希计算。
- 可寻址性:
&a[i]直接得到*T。需要传给接收[]T的 API 时可显式切片为a[:],该操作只构造 slice header,不复制元素。 - 与 cgo 的布局协作:cgo 会为某些 C 定长布局生成 Go 数组表示。跨边界传指针仍需类型转换、生命周期管理,并满足 pinning 与“Go 内存不得含未固定 Go 指针”等规则。
(3) 工程实践与常见坑
- 元素类型若带 padding(如
[3]struct{ a byte; b uint64 }),数组也会带 padding;做内存映射或二进制协议时要小心跨平台对齐差异。 &a(类型*[N]T)和&a[0](类型*T)数值相同但类型不同,传给 C 时要注意。- 大数组会增大栈帧;编译器也可能因大小或逃逸把它放到堆上。Go 栈可增长,但热点函数仍应结合
-m=2、栈深和 profile 评估布局。 - 数组字段把元素直接嵌入外层结构体;slice 字段只嵌入 header,数据另有 backing store。前者是否更省总内存取决于元素数量、共享关系、平台字宽和分配方式,不能只比较字段本身的大小(例如
[4]int64字段内联占 32 字节,[]int64字段的 header 占 24 字节,但后者的数据在别处另计)。
2.4 为什么 Array 是值类型
(1) 是什么
Go 中数组赋值 = 整段拷贝,函数传参也是拷贝:
package main
import "fmt"
func modify(a [3]int) {
a[0] = 999
}
func main() {
a := [3]int{1, 2, 3}
modify(a)
fmt.Println(a) // [1 2 3] 没变
}
(2) 为什么这样设计
Go 选择“一切赋值都是值拷贝“的统一模型,数组也不例外。这与 C(数组退化为指针)和 Java(数组是引用)都不同。原因:
- 一致性:
int、struct、array都是值类型,赋值语义统一,避免“什么时候是引用什么时候是值“的心智负担。 - 可预测性:值拷贝后两份独立,没有 aliasing(别名)问题,便于推理程序行为。
- 安全:函数内部修改不会逃逸到调用方,除非显式传指针。
代价是大数组的独立值语义可能需要复制很多元素;无需独立副本时可传指针或 slice。slice 传参复制的是 header(64 位平台通常为 24 字节),并与调用方共享 backing store,语义也随之改变。
(3) 工程实践与常见坑
- 想让函数修改数组:传
*[N]T,或返回新数组。 range循环中的元素也是拷贝:for _, v := range a中v是元素副本,修改v不影响a。- 数组字面量赋值给 map / slice 元素也是值拷贝,注意性能。
- 嵌入到 struct 的数组随 struct 一起被值拷贝,大数组 struct 的赋值同样昂贵。
2.5 Array 的复制成本
(1) 是什么
数组复制的语言语义是复制 N 个元素,数量级为 O(N)。实际机器码可能被内联、合并或消除;若大数组拷贝真实发生,就会消耗相应内存带宽。
(2) 底层实现
编译器会按具体类型、逃逸、ABI 和优化结果处理数组赋值/传参:小值可能使用寄存器或若干 load/store,较大且无法消除的副本可能使用 runtime.memmove。
伪代码层面等价于:
copy dst[0:N] from src[0:N] // 仅表示赋值效果,不是可调用的 Go 伪泛型
(3) 工程实践与常见坑
| 操作 | 成本 | 说明 |
|---|---|---|
b := a(小数组) | 复制全部元素 | 可能用寄存器或若干 load/store,也可能被消除 |
b := a(大数组) | 复制全部元素 | 真实发生时可能调用 memmove,最坏消耗 O(N) 带宽 |
f(a) 传参 | 同上 | 按值拷贝 |
a == b | O(N) | 逐元素比较 |
var a [N]T | 语义上全部零初始化 | 物理清零可能与栈帧初始化合并或因后续覆盖被消除 |
a := [N]T{} | 同样全部零初始化 | 与 var 的性能差异不是语言契约 |
- 传参坑:
func f(a [1024]byte)赋予形参独立数组值;真实副本未被优化时会搬运 1 KiB。改成*[1024]byte或[]byte只复制指针/header,但会引入共享与可变别名,应按 API 语义选择。 - 返回值成本:大数组具有值语义,但 ABI 可让被调函数直接写入调用方结果槽,也可能消除中间副本;用 benchmark 和反汇编判断具体路径。
- 比较成本:
a == b最坏需要检查全部元素。若同一大值会被反复比较,可评估缓存哈希或使用领域内已有摘要;为单次比较临时计算哈希通常不会更省工作。
2.6 Array 为什么很少使用
(1) 现象
运行期长度的业务集合通常使用 slice,数组则更多出现在摘要、地址、协议字段和内部缓冲等固定长度场景。主要差异如下:
| 维度 | 数组 [N]T | 切片 []T |
|---|---|---|
| 长度 | 编译期固定 | 运行时可变 |
| 类型兼容 | [3]int ≠ [4]int | []int 兼容所有长度 |
| 传参语义 | 复制全部元素 | 复制 header,共享 backing store |
| 扩容 | 不支持 | append 自动扩容 |
| 比较 | 同长度可 == | 不能 == |
(2) 为什么这样设计
数组“低级“到不便携:每改一个长度都要改类型签名,等于把“长度“这一运行期数据硬编码进类型系统。绝大多数业务场景下,长度是运行期才知道的(从配置、网络、数据库读出来),用数组无法表达。
切片为运行期长度提供了统一视图,而数组保留固定长度和值语义。二者不是高低层级关系:API 应根据长度是不是契约的一部分来选择。
(3) 工程实践与常见坑
- 长度来自配置、网络或用户输入时通常用
[]T;长度是协议、摘要或矩阵维度的一部分时数组更准确。 - 公共 API 是否暴露
[N]T取决于固定长度能否提供有效的编译期约束,而不是一律禁止。 - 测试中如果只想验证前 N 个元素,用切片子段
got[:N]即可,不需要把数据装进数组。
2.7 Array 适合哪些场景
虽然数组很少用,但有些场景它比切片更好:
-
长度真正固定的物理量:星期
[7]string、月份[12]int、棋盘[8][8]byte、SHA-256 输出[32]byte。package main import ( "crypto/sha256" "fmt" ) func main() { sum := sha256.Sum256([]byte("hello")) // 返回 [32]byte,不是 []byte fmt.Printf("%x\n", sum) }sha256.Sum256返回[32]byte,把摘要长度编码进类型并提供独立值语义。结果可在栈或堆上,取决于调用点的逃逸与编译器决定,API 本身不承诺“绝不分配”。 -
需要值语义的固定容器:希望赋值即深拷贝、作为 map key、做
==比较。 -
可内联存储的热点路径:小数组未逃逸时可留在栈帧或外层对象内,避免独立 backing-store 分配;需用当前工具链验证。
-
二进制协议 / cgo 生成类型:定长数组能准确表达固定布局,但序列化仍需处理字节序、padding 和跨平台对齐,cgo 还需遵守指针规则。
-
查找表:包级数组可在初始化时构造并共享读取,但 Go 没有数组常量,普通全局数组仍可被写。需要强不可变边界时不要导出可写变量,可通过函数返回值或只读访问 API 封装。
当长度是“领域常量“而非“运行期数据“时,数组才是好选择。
2.8 Slice 与 Array 的互相转换
数组切片为 slice 是老规则(a[:]),反方向的转换是后来补齐的两块拼图:
- Go 1.17+:slice 可转换为数组指针
(*[N]T)(s),不复制元素,结果与 slice 共享底层数组。 - Go 1.20+:slice 可直接转换为数组值
[N]T(s),复制前 N 个元素,结果独立。
package main
import "fmt"
func main() {
s := []int{1, 2, 3, 4, 5}
p := (*[3]int)(s) // Go 1.17+:数组指针,共享底层数组
p[0] = 99
fmt.Println(s[0]) // 99,写入对 s 可见
a := [3]int(s) // Go 1.20+:数组值,复制独立
a[1] = -1
fmt.Println(s[1], a) // 2 [99 -1 3]
}
两种转换都要求 len(s) >= N(注意是 len 不是 cap),长度不足会在运行期 panic,消息形如:
panic: runtime error: cannot convert slice with length 2 to array or pointer to array with length 3
工程上的典型用法:把 []byte 形式的哈希值转回定长类型,如 key := [32]byte(hashSlice);或用 (*[N]T)(s) 在已知长度的热点路径上帮助编译器消除后续索引的边界检查。nil slice 只能转换为 N == 0 的数组(指针)。
2.9 Array 在 Runtime 中的用途
Go runtime 同时大量使用数组和 slice。定长数组适合容量在实现中固定、需要内联布局或希望避免额外 backing-store 分配的结构,下面是几个当前版本的例子:
(1) mcache 的 span 数组
// runtime/mcache.go(简化)
type mcache struct {
alloc [numSpanClasses]*mspan // Go 1.26.4: NumSizeClasses*2 = 136
// ...
}
alloc 是按 span class 直接索引的定长表;scan/noscan 会形成不同 span class。它是密集整数索引表,不是哈希表,具体 class 数属于版本实现。
(2) Swiss Table 的 group
Go 1.24+ 的当前 map 实现每个 group 有 8 个槽,8 个 control byte 打包在一个 uint64 中;实现先批量筛选 H2,再比较候选 key:
// internal/runtime/maps/group.go,控制区的真实表示
type ctrlGroup uint64 // 8 个 control byte
// key/value 槽位紧随控制区,实际偏移由 map 类型元数据计算;
// 这里没有一个可供用户依赖的 group[K, V] Go 结构体。
固定数组让 Runtime 一次处理整组控制字并保持槽位局部性。完整结构和旧 hmap/bmap 对照见第5章 Map。
(3) G/M/P 调度器中的固定缓冲
p(Processor)内部有本地运行队列 runq [256]guintptr。容量属于当前调度器实现,数组让队列存储直接内联在 p 中;业务代码不能据此推导调度公平性或队列上限行为。
(4) defer 池的固定缓冲
Go 1.26.4 的 p 含 deferpoolbuf [32]*_defer,并让 deferpool slice 指向这块内联缓冲。相对地,每 P 的 timer heap 本身是 slice;不能把所有 Runtime 容器都概括成固定数组。
总结:Runtime 在容量固定时使用数组,主要看中内联布局与静态大小;数组载荷不含指针时也无需扫描元素。slice 同样是值类型,但其 header 指向独立 backing store,适合容量动态变化的结构。两者在 Runtime 中各有用途。
本章小结
- 数组
[N]T是定长、值类型、连续内存容器,长度属于类型的一部分。 - 数组赋值/传参具有复制全部元素的值语义;机器码可使用寄存器、load/store、
memmove或消除副本。 - 数组适合固定长度(摘要、矩阵、协议)、值语义、内联存储和 Runtime 定长表;是否产生堆分配由逃逸与编译器决定。
- 数组是切片的物理基础:切片只是“一段数组 + 长度 + 容量“的 header,下一章我们详细拆解。