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,长度固定。
  • 数组一旦声明,长度不可变;编译期可见的越界会被编译器直接拒绝,运行期越界触发 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.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,没有间接层;这让数组可以无缝喂给 io.Reader.Readcrypto/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(数组是引用)都不同。原因:

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

代价是大数组拷贝昂贵,因此 Go 鼓励用切片(一个 24 字节的 header)代替大数组传参,详见 第3章 Slice

(3) 工程实践与常见坑

  • 想让函数修改数组:传 *[N]T,或返回新数组。
  • range 循环中的元素也是拷贝:for _, v := range av 是元素副本,修改 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(大数组)一次 memmoveO(N) 内存带宽
f(a) 传参同上按值拷贝
a == bO(N)逐元素比较
var a [N]T0仅预留栈空间,不清零
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 适合哪些场景

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

  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 而不是 []byte:避免堆分配、避免调用方误改长度、保证 32 字节布局,可以放在栈上。

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

  3. 零堆分配的热点路径[16]byte 在栈上,没有逃逸,没有 GC 压力。

  4. 二进制协议 / cgo[N]byte 直接对应 C 的 char[N],没有 header 干扰。

  5. 常量查找表:编译期初始化的 [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,下一章我们详细拆解。