Skip to content
Go
Go 复合类型

Go 复合类型

数组、切片(Slice)、映射(Map)、结构体(Struct)、指针与嵌入(Embedding)完整速查,基线 go1.27.0。本文所有带"实测"标注的输出,都是在 go1.27.0 windows/amd64、模块 go 指令为 1.27.0 的环境下编译运行得到的。


1. 数组(Array)

数组是固定长度、同类型元素的值类型序列。

// 声明
var a [3]int                     // [0, 0, 0],长度是类型的一部分
b := [3]int{1, 2, 3}             // [1, 2, 3]
c := [...]int{1, 2, 3, 4}        // 自动推断长度 [4]int
d := [5]int{0: 10, 4: 50}        // 索引初始化 [10, 0, 0, 0, 50]
特性 说明
类型固定 [3]int 和 [4]int 是不同类型
值语义 赋值和传参会复制整个数组
可比较 同类型数组可用 == 比较(元素逐一比较)
a := [3]int{1, 2, 3}
b := a                           // 完整复制
b[0] = 99
fmt.Println(a[0])                // 1(未受影响)

fmt.Println([3]int{1, 2, 3} == [3]int{1, 2, 3})  // true

注意:大数组传参代价高(复制所有元素)。实际工作中极少直接使用数组,切片是 Go 中的主力数据结构——数组更多是作为切片的底层载体,或者在需要"长度参与类型系统"(如定长消息帧、[4]byte 这样的协议字段)时登场。


2. 切片(Slice)

切片是数组的动态视角,由一个底层数组指针 + 长度 + 容量组成。

底层结构

// runtime/slice.go(简化)
type slice struct {
    array unsafe.Pointer          // 指向底层数组的指针
    len   int                     // 当前元素个数
    cap   int                     // 从 array 起点到底层数组末尾的可用长度
}
s := make([]int, 3, 5)

底层数组:[0, 0, 0, ?, ?]    (cap=5,后两个空闲)
          ↑         ↑
         array     array+cap
len=3:   [0, 0, 0]

理解这个三元结构,就理解了切片几乎所有"诡异行为":切片本身只有 24 字节(64 位平台),赋值/传参复制的是这个头,不是元素;但头里带着底层数组指针,所以两个切片可能共享同一片内存。

创建方式

// 方式一:字面量
s := []int{1, 2, 3}

// 方式二:make
s := make([]int, 3)               // len=3, cap=3
s := make([]int, 3, 5)            // len=3, cap=5(预分配容量)

// 方式三:从数组/切片截取
arr := [5]int{1, 2, 3, 4, 5}
s := arr[1:4]                     // [2, 3, 4],len=3, cap=4(cap 从起点算到数组末尾:5-1)
s := arr[:3]                      // [1, 2, 3],len=3, cap=5
s := arr[2:]                      // [3, 4, 5],len=3, cap=3

// 方式四:nil 切片 vs 空切片
var s []int                       // nil 切片(len=0, cap=0,s == nil 为 true)
s := []int{}                      // 空切片(len=0, cap=0,s == nil 为 false)
s := make([]int, 0)               // 空切片

nil 切片和空切片在 append、range、len、copy 下的行为完全一致(range 零次、append 直接可用),唯一的实质区别出现在序列化:实测 json.Marshal(nil切片) 得 null,json.Marshal([]int{}) 得 []。接口返回"空列表"时如果想让前端拿到 [] 而不是 null,就不要返回 nil 切片。

知道大概规模时用 make([]T, 0, cap) 预分配容量,可以省掉一整轮扩容复制。

append 与扩容

s := []int{1, 2, 3}
s = append(s, 4)                  // [1, 2, 3, 4]
s = append(s, 5, 6, 7)            // 追加多个元素
s = append(s, otherSlice...)      // 展开另一个切片

扩容规则见 go1.27.0 runtime/slice.go 的 nextslicecap:旧 cap < 256 时直接翻倍;≥ 256 后按 newcap += (newcap + 3*256) >> 2 递增(约 1.25 倍再加 192 个元素,源码注释称这是 2x 到 1.25x 的平滑过渡);算出的容量还会经 roundupsize 按字节向上取整到分配器的 size class,所以实际 cap 只比公式值大、不会小。实测 go1.27.0(windows/amd64,元素为 int)连续 append 时的 cap 跃迁序列:

4 → 8 → 16 → 32 → 64 → 128 → 256 → 512 → 848 → ...

(512 按公式增长到 832,取整到 size class 后落在 848。)另一个容易忽略的细节:对 nil 切片 append 第一个元素,直接得到 cap=4 而不是 1(实测)。

注意:append 可能返回新的底层数组。如果原切片容量不足,append 会分配新数组并复制数据,原切片不受影响。始终将 append 的返回值赋回原变量:s = append(s, val)。

切片截取与底层共享(核心陷阱)

parent := []int{1, 2, 3, 4, 5}
child := parent[1:3]              // [2, 3],len=2,cap=4

// 两者共享底层数组!
child[0] = 99
fmt.Println(parent)               // [1, 99, 3, 4, 5](实测:parent 也被改了)

// append 只要没超出 child 的 cap,写入的也是共享的底层数组
child = append(child, 100)
fmt.Println(parent)               // [1, 99, 3, 100, 5](实测:100 落在 parent[3],覆盖了 4)

注意 append 的位置:child 覆盖 parent[1..2],追加槽位是 parent[3],不是紧接着的可见元素之后——这正是这类 bug 隐蔽的原因。

避免底层共享的方法:

// 方式一:限制 cap(三索引截取,Go 1.2+)
child := parent[1:3:3]            // [2, 3],len=2,cap=2(第三个索引 3 是底层数组内的上限位置)
                                  // cap = 3-1 = 2,append 必然分配新数组

// 方式二:显式复制
child := make([]int, 2)
copy(child, parent[1:3])

// 方式三:slices.Clone(Go 1.21+)
import "slices"
child := slices.Clone(parent[1:3])

copy 函数

src := []int{1, 2, 3, 4, 5}
dst := make([]int, 3)
n := copy(dst, src)               // n=3, dst=[1,2,3](按 min(len(dst), len(src)) 截断)

copy 允许 dst 和 src 来自同一个底层数组的区间重叠,行为类似 memmove。

遍历与删除

// 遍历
for i, v := range s { }

// 删除索引 i 的元素(保持顺序)
s = append(s[:i], s[i+1:]...)

// 删除索引 i(不保持顺序,更快)
s[i] = s[len(s)-1]
s = s[:len(s)-1]

// 过滤
filtered := s[:0]
for _, v := range s {
    if keep(v) {
        filtered = append(filtered, v)
    }
}

// Go 1.21+:slices.Delete、slices.DeleteFunc(推荐,边界处理更省心)
import "slices"
s = slices.Delete(s, i, i+1)
s = slices.DeleteFunc(s, func(v int) bool { return v < 0 })

3. Map

Map 是无序的键值对集合,引用类型。

底层一瞥

map 变量本身是指向运行时内部结构的指针,m := m2 复制的是这个指针,不是内容。Go 1.24 起内置 map 的底层实现换成了 Swiss Tables(官方 Go 1.24 release notes 确认;1.24 当时提供 GOEXPERIMENT=noswissmap 退回旧实现,该开关此后已移除——实测 go1.27.0 传它会直接报 go.exe: unknown GOEXPERIMENT swissmap,internal/goexperiment 里也没有对应文件了),旧的"桶数组 + 链地址"教学模型已经不完全对应现实,但对外语义没有任何变化:键哈希、无序、遍历顺序每次随机、非并发安全。写业务代码不需要关心内部结构,只需要记住结论——不要假设遍历顺序,需要有序就取出 key 排序或交给 slices/maps 包。

创建与基本操作

// 方式一:make
m := make(map[string]int)

// 方式二:字面量初始化
m := map[string]int{
    "a": 1,
    "b": 2,
}

// 基本操作
m["c"] = 3                       // 写入
v := m["a"]                      // 读取(key 不存在返回零值)
v, ok := m["z"]                  // 安全读取(ok 表示 key 是否存在)
delete(m, "a")                   // 删除(key 不存在不会 panic;对 nil map 执行 delete 也是安全的空操作,实测)

// 遍历(无序)
for k, v := range m {
    fmt.Println(k, v)
}

nil map vs 空 map

var m1 map[string]int             // nil map
m2 := map[string]int{}            // 空 map

// 读操作(都安全)
v := m1["key"]                    // 返回零值,不 panic

// 写操作
m1["key"] = 1                     // panic: assignment to entry in nil map(实测)
m2["key"] = 1                     // OK

注意:nil map 可以读、可以 len、可以 delete,但不能写。声明 map 后必须用 make 或字面量初始化后才能写入。一般在声明时直接 make 或 := map[K]V{}。结构体字段里的 map 忘初始化是这类 panic 的高发场景。

map 的不可比较性

m1 := map[string]int{"a": 1}
m2 := map[string]int{"a": 1}
// fmt.Println(m1 == m2)          // 编译错误!map 之间不能用 == 比较(只能与 nil 比较)

// 如需内容级比较,用 maps 包(Go 1.21+)
import "maps"
fmt.Println(maps.Equal(m1, m2))   // true(Go 1.21+)

注意:map 类型只允许和 nil 做 ==/!= 比较,两个 map 变量之间的任何比较都是编译错误;内容级别的相等判断交给 maps.Equal。

并发安全

Go 内置的 map 不是并发安全的:

// 错误:并发读写 map 会引发 fatal error
go func() { for { m["key"] = 1 } }()
go func() { for { _ = m["key"] } }()
// fatal error: concurrent map read and map write(读写并发)或 concurrent map writes(写写并发)

// 解决方案一:sync.RWMutex
var mu sync.RWMutex
mu.RLock(); v := m["key"]; mu.RUnlock()
mu.Lock(); m["key"] = v; mu.Unlock()

// 解决方案二:sync.Map(特定场景)
var sm sync.Map
sm.Store("key", 1)
v, ok := sm.Load("key")

sync.Map 只适用于两种场景:(1) key 只写一次但读取多次;(2) 多个 goroutine 操作大体不相交的 key 集合。其他场景"普通 map + sync.RWMutex“性能更好,也更容易维护。完整的并发方案对比见 08-并发编程.md。

map 的容量提示

m := make(map[string]int, 100)    // 预分配约 100 个元素的空间

容量参数让 map 跳过从小到大的反复 rehash。数据量已知(比如从一次 len(dbRows) 出发)就把它传给 make;数据量未知就别勉强,这个提示只在"量级明确"时有意义。


4. 结构体(Struct)

声明与初始化

type Person struct {
    Name  string
    Age   int
    Email string
}

// 初始化方式
p1 := Person{"Alice", 30, "alice@example.com"}     // 无 key 按顺序(不推荐,见下方注意)
p2 := Person{                                      // 按字段名(推荐)
    Name:  "Bob",
    Age:   25,
    Email: "bob@example.com",
}
p3 := Person{Name: "Charlie"}                       // 部分字段,其余为零值
p4 := new(Person)                                   // 返回 *Person,字段全零值

注意:无 key 的字面量必须给每个字段赋值,漏字段直接编译失败——[]Person{{"Alice", 30}, {"Bob", 25}}(Person 有 3 个字段)报 too few values in struct literal of type Person(实测)。字段顺序调整、增删都会静默破坏无 key 写法,所以团队规范几乎都强制使用带字段名的 key 形式。

访问与修改

p.Name = "Dave"
fmt.Println(p.Age)

// 指针自动解引用
ptr := &Person{Name: "Eve", Age: 22}
fmt.Println(ptr.Name)             // 不需要 (*ptr).Name
ptr.Age = 23                      // 直接通过指针修改

可比性

type Point struct{ X, Y int }
p1 := Point{1, 2}
p2 := Point{1, 2}
fmt.Println(p1 == p2)             // true(所有字段可比较时可用 == ,逐字段比较)

// 含有 map、slice、function 字段的结构体不能比较
type Foo struct {
    Data []int
}
// f1 == f2 → 编译错误

规则只有一条:结构体可比较 当且仅当每个字段都可比较。嵌入字段同样参与比较(它的每个字段都要可比较);指针字段比较的是地址而非指向的值。需要深比较时用 reflect.DeepEqual 或 1.21+ 的 cmp.Equal。

内存对齐

结构体的实际字节布局由编译器按目标平台的对齐规则决定(还可能重排字段以减少 padding),unsafe.Sizeof 能看到真实占用;除非写序列化协议或做极致内存优化,日常开发不需要手动干预对齐。

空结构体 struct{}

empty := struct{}{}               // 零内存占用(0 字节)
set := make(map[string]struct{})  // 用做 set:value 不占内存
set["key"] = struct{}{}

// chan 用作纯信号
done := make(chan struct{})
done <- struct{}{}

struct{} 占用 0 字节。用它做 map 的 value 实现 set 比 map[string]bool 语义更准确(没有"false 是不存在还是被删过"的歧义);信号 channel 用 chan struct{} 而非 chan bool 也是同理——接收方根本不关心值。

结构体标签(Tags)

type User struct {
    ID     int    `json:"id" db:"user_id" validate:"required"`
    Name   string `json:"name" db:"name"`
    Score  int    `json:"score,omitempty"`          // omitempty: 零值时省略
    secret string `json:"-"`                         // -: 忽略该字段
}

标签通过反射获取:

field, _ := reflect.TypeOf(User{}).FieldByName("Name")
tag := field.Tag.Get("json")      // "name"

标签是运行期由消费方库解释的字符串,编译器不校验:拼错的 key(如 jzon)不会报错,只会被静默忽略。给字段写标签后值得用一行单测确认序列化结果。

嵌入(Embedding)——Go 的"继承"方案

type Animal struct {
    Name string
}

func (a Animal) Speak() string {
    return "..."
}

type Dog struct {
    Animal                          // 嵌入 Animal(无字段名,字段名就是类型名)
    Breed string
}

// 传统写法:手动嵌套
d1 := Dog{
    Animal: Animal{Name: "Buddy"},
    Breed:  "Golden Retriever",
}

// Go 1.27 起等价(提升字段名直接作 key,见下一小节)
d2 := Dog{Name: "Buddy", Breed: "Golden Retriever"}

fmt.Println(d2.Name)              // "Buddy"(提升的字段)
fmt.Println(d2.Speak())           // "..."(提升的方法)
fmt.Printf("d2: %+v\n", d2)       // 实测输出:d2: {Animal:{Name:Buddy} Breed:Golden Retriever}
fmt.Println(d1 == d2)             // true——两种写法产物完全一致

从底层看,嵌入不是继承,是语法糖:编译器把内层类型的字段和方法按名字"提升"到外层的选择器解析里,但内存布局仍是外层结构体内嵌一份内层结构体(或一个指针)。Dog 的类型仍是 Dog,不是 Animal;方法调用时 receiver 仍然是 Animal,不是 Dog。Go 没有类继承,需要多态请用接口,见 06-接口与泛型.md。

Go 1.27:结构体字面量的 key 可以用"提升字段名”

为什么需要这个:Go 1.26 及以前,提升字段只能用来读写(cfg.Host = "localhost"),初始化却必须钻嵌套——嵌入层一多,一个普通配置字面量就变成"括号套括号":

cfg := Config{Name: "app", DB: DB{Host: "localhost", Port: 5432}}

Go 1.27 放宽了语言规范(spec:“Each key must be a valid field selector for a (possibly promoted) field”):嵌入结构体的提升字段名,可以直接当 key 用。

type DB struct {
    Host string
    Port int
}

type Config struct {
    Name string
    DB                              // 值类型嵌入
}

cfg := Config{Name: "app", Host: "localhost", Port: 5432}
fmt.Printf("cfg: %+v\n", cfg)
// 实测输出(go1.27.0):
// cfg: {Name:app DB:{Host:localhost Port:5432}}

编译器把 Host、Port 沿值类型嵌入链找下去,填进 cfg.DB 的对应字段;与嵌套写法产出的值完全相等(实测把两种写法各构造一份再 == 比较,结果为 true)。多层值嵌入也能穿透:

type Base struct{ ID int }
type Mid struct{ Base }
type Top struct {
    Mid
    Name string
}

top := Top{Name: "t", ID: 7}        // ID 从两层之下的 Base 提升上来
fmt.Printf("top: %+v\n", top)
// 实测输出:top: {Mid:{Base:{ID:7}} Name:t}

三个边界,全是编译错误(以下错误串均实测):

  1. 点号 key 仍然不合法。放宽的是"提升字段的短名",不是"任意选择器路径":

    _ = Outer{DB.Host: "x"}
    // invalid field name DB.Host in struct literal
  2. 只能穿值类型嵌入,指针嵌入不行。指针嵌入的字段不能隐式解引用着去填:

    type Inner struct{ X int }
    type H struct {
        *Inner
        Y int
    }
    _ = H{X: 1}
    // invalid implicit pointer indirection to reach X

    指针嵌入仍要用旧写法:H{Inner: &Inner{X: 5}, Y: 2}(实测可用;注意嵌入指针未初始化时读 h.X 会 panic,见第 8.5 节)。

  3. 遮蔽时 key 绑外层字段;同一字面量里不能混写嵌入字段和它的提升字段。名字冲突的解析规则与选择器一致——浅层优先:

    type Shadow struct {
        X int                        // 外层自己的 X
        Inner                        // Inner 也有 X
    }
    s := Shadow{X: 1}
    fmt.Printf("shadow: %+v\n", s)
    // 实测输出:shadow: {X:1 Inner:{X:0}} —— 改的是外层 Shadow.X
    
    _ = Outer{Inner: Inner{X: 1}, X: 2}
    // cannot specify promoted field X and enclosing embedded field Inner

注意:这个语法由模块的版本门控——go.mod 的 go 指令(即编译的 -lang 版本)必须 ≥ 1.27。同样的代码在 go 1.26.0 的模块里,go build 实测报:

use of promoted field DB.Host in struct literal of type Config requires go1.27 or later (-lang was set to go1.26; check go.mod)

go vet 报同一处错误(少了括号里的提示尾巴):use of promoted field DB.Host in struct literal of type Config requires go1.27 or later。也就是说错误串里出现 “check go.mod” 时,要动的是 go.mod 而不是代码。

存量代码里的手写嵌套形式永远不会被强制改写,两种写法可以共存。深层配置结构、多个值类型嵌入叠加时新写法收益最大;反之,当"某个字段来自哪个嵌入"本身就是想表达的信息时,嵌套写法反而更自说明。


5. 指针

x := 42
p := &x                          // p 的类型是 *int,指向 x
fmt.Println(*p)                   // 42(解引用)
*p = 100                          // 通过指针修改

var nilPtr *int                   // nil 指针
// *nilPtr                         // panic: runtime error: invalid memory address or nil pointer dereference(实测)

new vs make

函数 用途 返回值
new(T) 分配 T 的零值,返回 *T 指针
make(T, ...) 仅用于 slice、map、chan T 本身(非指针)
p := new(int)                     // *int,指向 0
s := make([]int, 3)               // []int,[0, 0, 0]

// new 很少使用,通常用 &T{} 代替(还能顺便给字段赋初值)
p := &Person{Name: "Ann"}

Go 1.26 起 new 还接受值表达式:new(int64(300)) 返回指向 300 的 *int64(实测)。传类型的旧用法 new(T) 不变。这个形式在"要一个指向非零值的指针"时很顺手,比如给结构体的 *int 可选字段填默认值。

值传递 vs 指针传递

func byValue(p Person) { p.Name = "changed" }     // 不影响调用方(操作的是副本)
func byPtr(p *Person)  { p.Name = "changed" }     // 影响调用方

选择指南:需要修改接收者、结构体较大、或要用指针接收者方法时选指针;小型结构体、不可变语义选值。方法接收者的完整选择规则见 05-函数与方法.md。

slice、map、channel 本身已经是"引用语义"(复制的是头部/指针,指向同一片底层数据),不需要再取指针传递——除非你要替换调用方手里的整个切片头(比如在其内部做 s = append(...) 并让对方看到),那才需要 *[]T。


6. slices 包(Go 1.21+)

Go 1.21 新增 slices 标准库,提供泛型切片工具(以下均为 go1.27 编译实测可用):

import "slices"

s := []int{3, 1, 2}
slices.Sort(s)                    // 实测 [1 2 3],原地排序
slices.SortFunc(s, func(a, b int) int { return b - a })        // 实测 [3 2 1],cmp 返回负/零/正
slices.SortStableFunc(s, func(a, b int) int { return a - b })  // 稳定排序,实测回到 [1 2 3]
i, found := slices.BinarySearch(s, 2)   // 要求已排序;实测 i=1, found=true(返回插入位置与命中标志)

slices.Index(s, 2)                // 实测 1(找不到返回 -1)
slices.IndexFunc(s, func(v int) bool { return v == 3 })  // 实测 2
slices.Contains(s, 3)             // 实测 true(元素需 comparable)
slices.Max(s)                     // 实测 3
slices.Min(s)                     // 实测 1
slices.Equal(s, []int{1, 2, 3})   // 实测 true,逐元素 ==
cl := slices.Clone(s)             // 浅拷贝(安全分离底层数组)
slices.Reverse(cl)                // 实测 cl=[3 2 1],s 保持 [1 2 3] 不受影响
s = slices.Delete(s, 0, 2)        // 实测 [3]:删除 [0:2],后面的元素前移
fmt.Println(slices.DeleteFunc([]int{1, -2, 3, -4},
        func(v int) bool { return v < 0 }))  // 实测 [1 3]
fmt.Println(slices.Compact([]int{1, 1, 2}))  // 实测 [1 2],连续重复只留一个

注意:slices.Min/Max 对空切片直接 panic(官方文档 “It panics if x is empty”,go1.27 go doc 确认),调用前先判 len。另外 SortFunc/SortStableFunc 的比较回调是 func(a, b E) int(负/零/正)而不是布尔比较器。

日常优先用 slices 而不是手写循环排序/删除:边界语义都处理好了——返回修改后的新切片头,Delete 还会把腾出的尾部槽位清零避免对象滞留(go doc 明确 “Delete zeroes the elements s[len(s)-(j-i):len(s)]"),可读性也更好。


7. maps 包(Go 1.21+)

import "maps"

m1 := map[string]int{"a": 1, "b": 2}
m2 := map[string]int{"a": 1, "b": 2}
fmt.Println(maps.Equal(m1, m2))    // true
clone := maps.Clone(m1)            // 浅拷贝(对 nil map 调用返回 nil,实测)
maps.Copy(clone, m2)               // 把 src 全部键值对并入 dst;注意它没有返回值(实测 go doc 签名)
maps.DeleteFunc(m1, func(k string, v int) bool { return v < 0 })

// Keys/Values 返回 iter.Seq 迭代器,可直接 range(实测 go doc:Keys 返回 iter.Seq[K],需模块 go ≥ 1.23)
for k := range maps.Keys(m1) { _ = k }

maps.Equal 要求 value 本身可比较(逐对 ==);value 是切片/结构体嵌套这类要深比较的,得自定义比较逻辑。maps.Keys 的文档明确提示:迭代顺序不指定、两次调用之间也不保证一致——需要有序就收集后 slices.Sort。


8. 常见陷阱

8.1 slice 底层共享

a := []int{1, 2, 3, 4, 5}
b := a[1:3]                        // len=2, cap=4,b 的写窗口一直到 a 的末尾
b[0] = 9                           // 改的是 a[1]!
b = append(b, 100)                 // 实测:a 变成 [1 9 3 100 5]——append 落在 a[3]
// 解决方案:三索引切片限制 cap(第三个索引是底层数组内的边界位置,cap = 边界 - 起点)
c := a[1:3:3]                      // len=2, cap=2,append 必分配新数组,a 不受影响

8.2 append 的新切片头没有用回去

s := make([]int, 0, 2)
t := append(s, 1)                  // 结果赋给了 t,没有写回 s
fmt.Println(s, len(s))             // 实测:[] 0 —— s 完全看不到这个 1
fmt.Println(t, len(t))             // 实测:[1] 1

把 append(s, 1) 写成独立的表达式语句,gc 编译器会直接拒绝(实测报错 append(s, 1) (value of type []int) is not used;用 -gcflags=-lang=go1.18 复测同样报错,这个检查与语言版本无关)。所以现实中真正的坑是赋错了变量或只改了一半:append 返回的新切片头没有写回调用方持有的那个变量——尤其常见于函数内部对参数切片 s = append(s, x) 后不返回,调用方的 len 永远不会变。记住一条纪律:改切片就写 s = append(s, ...),并把这个头传出去。

8.3 遍历时修改 map

map 遍历过程中插入新元素,新元素可能出现在当前遍历中,也可能不出现——行为是不确定的。要"遍历 + 删"就配合 maps.DeleteFunc,或先收集 key(maps.Keys 迭代器收集到切片)再统一改。

8.4 nil map 写入 panic

var m map[string]int
m["key"] = 1                       // panic: assignment to entry in nil map(实测)

结构体字段、函数返回的 map 都可能"看起来存在其实 nil”。构造/初始化时显式 make,或在写入前 if m == nil { m = make(...) }。

8.5 指针接收者嵌入

type A struct{ Name string }
func (a *A) SetName(n string) { a.Name = n }

type B struct {
    *A                              // 嵌入指针
}

b := B{A: &A{}}                    // 必须初始化 A
b.SetName("test")                   // OK
fmt.Println(b.Name)                 // "test"
// 但空指针窗口就在初始化:var bad B; bad.SetName("test")
// → panic: runtime error: invalid memory address or nil pointer dereference(实测)

指针嵌入解决了"提升的是指针接收者方法"和"共享同一实例"的问题,但代价是空指针窗口:字面量里忘填 A,任何走到提升方法的调用都 panic。且如 4 节所述,Go 1.27 的提升字段 key 写法穿不过指针嵌入(invalid implicit pointer indirection),嵌套初始化仍是唯一形式。

8.6 range 遍历切片得到的元素是副本

items := []Person{
    {Name: "Alice", Age: 30},
    {Name: "Bob", Age: 25},
}
for _, p := range items {
    p.Age = 0                       // 修改的是副本!items 不变
}
for i := range items {
    items[i].Age = 0                // 通过索引修改原数据
}

需要"循环变量是指针"的场景用 for i := range items 取 &items[i](Go 1.22+ 每轮迭代变量独立,见 03-控制流.md)。


9. 实战建议

  • 序列一律优先切片;数组只用于长度参与类型语义(协议帧、固定 key)或作为切片的底层数组。
  • 能预估规模就预分配:make([]T, 0, n)、make(map[K]V, n),能省掉一整段扩容复制和 rehash。
  • 结构体字面量永远带字段名;模块升上 go.mod go 1.27 后,值类型嵌入的初始化改用提升字段 key,深层嵌套配置的可读性会有明显改善(见 10-版本变迁.md 的版本变更全表)。
  • 需要"切片交出去之后别被改到底层数据",用 slices.Clone 或三索引截取封死 cap,而不是赌调用方守规矩。
  • map 的并发策略在动手写之前就定:读多写少 sync.RWMutex、写少读极多 copy-on-write、key 空间天然分片就用普通 map + 分锁;sync.Map 只留给官方文档说的那两种场景。
  • 嵌入组合优先值类型(提升字段 key、可比较、无空指针窗口);只有"共享同一实例"或"可选/可变"才用指针嵌入,并接受初始化必须显式填嵌套字段的代价。