第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,长度固定。- 数组一旦声明,长度不可变;编译期可见的越界会被编译器直接拒绝,运行期越界触发
runtime panic: index out of range。 len(a)和cap(a)对数组返回相同值,且都是编译期常量,可用于常量表达式。
(2) 为什么这样设计
Go 的数组刻意做得“低级“:它直接对应一段连续内存,没有任何 header、没有指针间接、没有扩容逻辑。这样数组可以:
- 零成本映射到 C ABI,方便 cgo 和 unsafe 操作;
- 作为切片和 map 桶的底层物理存储;
- 在编译期就完成长度校验,避免运行时开销。
(3) 工程实践与常见坑
- 数组的下标若为常量表达式,编译期即可校验;非常量下标会在运行时注入边界检查。
- 越界访问会触发
runtime panic: index out of range,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 长度数组“这类错误。
- 零开销抽象:因为长度是类型的一部分,编译器始终知道数组的
sizeof,可以用ptr + offset*sizeof(T)直接寻址,不需要运行时读 header。 - 配合值语义:数组赋值就是
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暴露给调用方,否则不同长度的调用方都得手写转换。
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,没有间接层;这让数组可以无缝喂给io.Reader.Read、crypto/sha256等 API。 - 零拷贝 cgo:cgo 中
[N]byte可以直接当 C 的char[N]用,没有 header 干扰。
(3) 工程实践与常见坑
- 元素类型若带 padding(如
[3]struct{ a byte; b uint64 }),数组也会带 padding;做内存映射或二进制协议时要小心跨平台对齐差异。 &a(类型*[N]T)和&a[0](类型*T)数值相同但类型不同,传给 C 时要注意。- 大数组放在栈上可能撑爆栈;编译器会自动逃逸到堆,但仍是连续分配。
- 数组在内存中是直接嵌入到外层结构体的,不像切片只嵌入 24 字节 header——这是
[32]byte比[]byte更紧凑的原因。
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(别名)问题,便于推理程序行为。
- 安全:函数内部修改不会逃逸到调用方,除非显式传指针。
代价是大数组拷贝昂贵,因此 Go 鼓励用切片(一个 24 字节的 header)代替大数组传参,详见 第3章 Slice。
(3) 工程实践与常见坑
- 想让函数修改数组:传
*[N]T,或返回新数组。 range循环中的元素也是拷贝:for _, v := range a中v是元素副本,修改v不影响a。- 数组字面量赋值给 map / slice 元素也是值拷贝,注意性能。
- 嵌入到 struct 的数组随 struct 一起被值拷贝,大数组 struct 的赋值同样昂贵。
2.5 Array 的复制成本
(1) 是什么
数组复制是 O(N) 的 memmove,成本 = N * sizeof(T) 字节的内存拷贝。对小数组(如 [4]float64,32 字节)几乎免费;对大数组(如 [1 << 20]byte,1 MiB)则是实打实的内存带宽消耗。
(2) 底层实现
编译器(cmd/compile/internal/walk)会把数组赋值/传参 lowering 为:
- 小数组:若干条 MOV 指令,或一次
memmove; - 大数组:调用
runtime.memmove,本质是按字长拷贝、首尾对齐处理。
伪代码层面等价于:
// 编译器为数组赋值 a = b 生成的等价代码
//go:nosplit
func arrayCopy[T, N](dst, src *[N]T) {
runtime.memmove(unsafe.Pointer(dst), unsafe.Pointer(src), uintptr(N)*unsafe.Sizeof(src[0]))
}
(3) 工程实践与常见坑
| 操作 | 成本 | 说明 |
|---|---|---|
b := a(小数组) | 几条 MOV | 编译器内联 |
b := a(大数组) | 一次 memmove | O(N) 内存带宽 |
f(a) 传参 | 同上 | 按值拷贝 |
a == b | O(N) | 逐元素比较 |
var a [N]T | 0 | 仅预留栈空间,不清零 |
a := [N]T{} | O(N) | 显式零值,可能 memset |
- 传参坑:
func f(a [1024]byte)每次调用拷贝 1 KiB;改成func f(a *[1024]byte)或func f(a []byte)即可避免。 - 返回值坑:返回大数组同样会拷贝(编译器可能做返回值优化 RVO,但不应依赖)。
- 比较成本:
a == b对数组是逐元素比较,O(N);大数组比较前先用len或快速哈希预筛。
2.6 Array 为什么很少使用
(1) 现象
日常 Go 代码中,几乎看不到数组,全是切片。原因可归纳为三点:
| 维度 | 数组 [N]T | 切片 []T |
|---|---|---|
| 长度 | 编译期固定 | 运行时可变 |
| 类型兼容 | [3]int ≠ [4]int | []int 兼容所有长度 |
| 传参成本 | O(N) 全量拷贝 | O(1) 24 字节 header |
| 扩容 | 不支持 | append 自动扩容 |
| 比较 | 同长度可 == | 不能 == |
(2) 为什么这样设计
数组“低级“到不便携:每改一个长度都要改类型签名,等于把“长度“这一运行期数据硬编码进类型系统。绝大多数业务场景下,长度是运行期才知道的(从配置、网络、数据库读出来),用数组无法表达。
Go 因此把切片设计成“数组的第一公民视图“,把数组降级为“实现细节“,只在需要值语义或固定布局时才暴露给程序员。
(3) 工程实践与常见坑
- 业务代码用
[]T,不要用[N]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而不是[]byte:避免堆分配、避免调用方误改长度、保证 32 字节布局,可以放在栈上。 -
需要值语义的固定容器:希望赋值即深拷贝、作为 map key、做
==比较。 -
零堆分配的热点路径:
[16]byte在栈上,没有逃逸,没有 GC 压力。 -
二进制协议 / cgo:
[N]byte直接对应 C 的char[N],没有 header 干扰。 -
常量查找表:编译期初始化的
[N]T表(如 CRC 表)可作为包级只读常量,写入只读段,多协程共享无锁。
当长度是“领域常量“而非“运行期数据“时,数组才是好选择。
2.8 Array 在 Runtime 中的用途
Go runtime 自己大量使用数组,因为 runtime 不能依赖切片带来的堆分配和 header 间接。几个典型例子:
(1) mcache 的 span 数组
// runtime/mcache.go(简化)
type mcache struct {
alloc [numSpanClasses]*mspan // numSpanClasses = _NumSizeClasses*2 = 136
// ...
}
alloc 是一个固定长度数组,每个槽位指向一个特定 size class 的 mspan。这是经典的“用数组当哈希表“的用法:size class 是密集小整数,直接索引,O(1) 且无分支。
(2) hmap 的桶数组
map 的底层 hmap 持有一个 buckets unsafe.Pointer,它在内存上等价于 *[2^B]bmap——一个长度为 2^B 的桶数组;每个桶固定 8 槽:
// runtime/map.go(简化)
type hmap struct {
count int
B uint8
buckets unsafe.Pointer // 实质是 [2^B]bmap
oldbuckets unsafe.Pointer // 扩容时的旧桶数组
// ...
}
type bmap struct {
tophash [8]uint8 // 8 槽的顶部哈希,[8]uint8 是数组
// 后跟 8 个 key、8 个 value、1 个 overflow 指针(由编译器生成布局)
}
桶内部的 tophash [8]uint8 也是数组——固定 8 槽,缓存友好,整个 bmap 大小正好 8 字节对齐。
(3) G/M/P 调度器中的固定缓冲
p(Processor)内部有本地运行队列等固定大小结构(runq [256]guintptr),g 的栈参数也用数组承载。这些地方需要“零间接、定长、值语义“,数组正好满足。
(4) timer 与 defer 池
runtime 的 timers 堆、deferpool 等结构里也有 [...]T 形式的定长槽,避免在关键路径上分配。
总结:runtime 用数组,是因为它没有 header、布局确定、可栈上分配、元素为非指针类型时不增加 GC 扫描负担——这些正是性能与正确性的硬要求。理解这一点,能帮你理解为什么切片(一个有 header 的引用类型)不是万能的。
本章小结
- 数组
[N]T是定长、值类型、连续内存容器,长度属于类型的一部分。 - 数组赋值/传参是 O(N) 拷贝,安全但昂贵;编译器 lowering 为
memmove。 - 数组很少直接用,但在固定长度(哈希、棋盘、协议)、值语义、零分配热点、runtime 内部等场景不可或缺。
- 数组是切片的物理基础:切片只是“一段数组 + 长度 + 容量“的 header,下一章我们详细拆解。