Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第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(带具体下标和长度)。
  • 对数组变量 alen(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 n panic,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.Sizeofunsafe.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(数组是引用)都不同。原因:

  • 一致性intstructarray 都是值类型,赋值语义统一,避免“什么时候是引用什么时候是值“的心智负担。
  • 可预测性:值拷贝后两份独立,没有 aliasing(别名)问题,便于推理程序行为。
  • 安全:函数内部修改不会逃逸到调用方,除非显式传指针。

代价是大数组的独立值语义可能需要复制很多元素;无需独立副本时可传指针或 slice。slice 传参复制的是 header(64 位平台通常为 24 字节),并与调用方共享 backing store,语义也随之改变。

(3) 工程实践与常见坑

  • 想让函数修改数组:传 *[N]T,或返回新数组。
  • range 循环中的元素也是拷贝:for _, v := range av 是元素副本,修改 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 == bO(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 适合哪些场景

虽然数组很少用,但有些场景它比切片更好:

  1. 长度真正固定的物理量:星期 [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 本身不承诺“绝不分配”。

  2. 需要值语义的固定容器:希望赋值即深拷贝、作为 map key、做 == 比较。

  3. 可内联存储的热点路径:小数组未逃逸时可留在栈帧或外层对象内,避免独立 backing-store 分配;需用当前工具链验证。

  4. 二进制协议 / cgo 生成类型:定长数组能准确表达固定布局,但序列化仍需处理字节序、padding 和跨平台对齐,cgo 还需遵守指针规则。

  5. 查找表:包级数组可在初始化时构造并共享读取,但 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 的 pdeferpoolbuf [32]*_defer,并让 deferpool slice 指向这块内联缓冲。相对地,每 P 的 timer heap 本身是 slice;不能把所有 Runtime 容器都概括成固定数组。

总结:Runtime 在容量固定时使用数组,主要看中内联布局与静态大小;数组载荷不含指针时也无需扫描元素。slice 同样是值类型,但其 header 指向独立 backing store,适合容量动态变化的结构。两者在 Runtime 中各有用途。

本章小结

  • 数组 [N]T 是定长、值类型、连续内存容器,长度属于类型的一部分。
  • 数组赋值/传参具有复制全部元素的值语义;机器码可使用寄存器、load/store、memmove 或消除副本。
  • 数组适合固定长度(摘要、矩阵、协议)、值语义、内联存储和 Runtime 定长表;是否产生堆分配由逃逸与编译器决定。
  • 数组是切片的物理基础:切片只是“一段数组 + 长度 + 容量“的 header,下一章我们详细拆解。