6.Go 复合数据类型

背景与动机#

Go 的复合数据类型,本质上是在回答“如何组织一组值”这个问题:

  • 数组解决“固定个数、同类型元素”的连续存储问题。
  • 切片解决“在数组之上做动态视图”的问题。
  • 映射解决“通过键快速查值”的问题。
  • 结构体解决“把多个不同字段组合成一个业务实体”的问题。

这 4 类类型经常一起出现,但它们的语义完全不同。学习重点不是会写字面量,而是理解:

  1. 哪些类型是值语义,拷贝时会复制数据。
  2. 哪些类型内部带引用语义,赋值后会共享底层状态。
  3. 哪些零值可直接用,哪些零值只能读不能写。
  4. 数组、切片、mapstruct 分别适合什么建模场景。

核心原理拆解#

数组:长度属于类型的一部分#

数组类型写作:

[3]int
[5]string

它的核心特征有两个:

  1. 元素类型固定。
  2. 长度也是类型的一部分。

这意味着:

  • [3]int[4]int 是两个不同类型。
  • 数组值赋值给另一个变量时,会复制整个数组内容。
  • 数组大小固定,创建后长度不能变。

例如:

var a [3]int
b := [3]int{1, 2, 3}
c := b
c[0] = 99

修改 c 不会影响 b,因为数组默认是完整值拷贝。

数组的零值也很直接:每个元素都取其元素类型的零值。

var a [3]int // [0 0 0]

数组适合:

  • 大小固定的容器
  • 需要值语义的场景
  • 明确依赖固定长度的算法或协议边界

不适合:

  • 元素个数经常变化的业务集合

【语言特性 - 数组与切片的底层关系图】

image.png
image.png

切片:对底层数组的一段可变视图#

切片类型写作:

[]int
[]string

切片不是数组本身,而是一个描述符。可以把它理解为“指向某段底层数组的窗口”,这个窗口至少包含 3 个关键信息:

  • 指向底层数组某位置的指针
  • 长度 len
  • 容量 cap

因此切片最重要的性质不是“可变长”,而是:

  • 切片赋值时,复制的是切片头部,不是底层元素
  • 多个切片可能共享同一底层数组
  • 通过一个切片修改元素,可能会影响另一个切片看到的结果

例如:

a := [5]int{1, 2, 3, 4, 5}
s1 := a[1:4]
s2 := s1
s2[0] = 99

这时 a[1]s1[0] 也会变,因为它们共享同一底层数组。

lencap 的区别#

  • len(slice):当前视图中可直接访问的元素个数
  • cap(slice):从当前起点到底层数组末尾,还能扩展到多大

例如:

a := [5]int{1, 2, 3, 4, 5}
s := a[1:3]

此时:

  • len(s) == 2
  • cap(s) == 4

image.png
image.png

append 的本质#

append 会在现有切片后追加元素。

可能发生两种情况:

  1. 容量足够:直接写到底层数组后面,仍然共享原数组
  2. 容量不足:分配新数组,把旧数据复制过去,再返回新切片

所以 append 后是否还共享原底层数组,取决于是否发生扩容。这也是切片最容易让人误判的地方。

零值切片#

var s []int

此时:

  • s == nil
  • len(s) == 0
  • cap(s) == 0

但它通常可以直接 append,这是切片零值设计里很实用的一点。

映射:键到值的哈希关联#

map 类型写作:

map[string]int
map[int]bool

它表达的是“通过键查值”的关系,而不是顺序容器。

核心语义:

  • 键类型必须可比较
  • map 是引用式语义的数据结构,赋值后多个变量会共享同一底层哈希表
  • map 的读取在 key 不存在时会返回值类型零值
  • map 的遍历顺序不保证稳定

例如:

m := map[string]int{
"alice": 90,
"bob": 80,
}

取值时有两种常见形式:

score := m["alice"]
score, ok := m["tom"]

第二种更重要,因为它能区分:

  • 键存在,对应值刚好是零值
  • 键根本不存在

nil map 的边界#

var m map[string]int

这是合法零值,此时:

  • 可以读
  • 可以 len
  • 可以和 nil 比较
  • 不能直接写

例如:

m["x"] = 1

会触发 panic。因为 nil map 还没有真正分配底层哈希表。

要写入,通常先:

m = make(map[string]int)

【图:工程实践 - map 读写与零值行为图】

图意:这张图具体说明 nil mapmake 后的 mapkey -> value 桶结构如何变化,重点看上方 nil map 只能读不能写、中间 make 后才有真实存储,最后理解 map 的零值不是“完全不可用”,而是“未初始化写入能力”。

image.png
image.png

结构体:把不同字段组合成一个值#

结构体类型写作:

type User struct {
Name string
Age int
}

它的作用不是“模拟类”,而是“组合字段形成一个新的值类型”。

结构体的核心特点:

  • 字段可以是不同类型
  • 结构体整体是值类型
  • 赋值、传参时默认复制整个结构体
  • 字段零值递归成立

例如:

u1 := User{Name: "Tom", Age: 18}
u2 := u1
u2.Age = 20

修改 u2 不会影响 u1,因为结构体默认也是值拷贝。

匿名结构体与具名结构体#

具名结构体:

type Point struct {
X int
Y int
}

匿名结构体:

p := struct {
X int
Y int
}{X: 1, Y: 2}

匿名结构体适合局部临时建模;具名结构体适合稳定业务实体。

结构体与方法接收者的关系#

虽然结构体本身只是字段组合,但 Go 常通过“结构体 + 方法”表达行为。这里先记住一条:

  • 结构体是常见的方法接收者类型
  • 但结构体本身不等于面向对象类

4 类类型放在一起看,差别比相似点更重要#

  • 数组:固定长度,值语义,拷贝复制全部元素
  • 切片:变长视图,轻量头部,可能共享底层数组
  • map:键值关联,读零值友好,但零值不可写
  • struct:字段组合,值语义,适合建模实体

【语言特性 - 数组、切片、map、struct 对比图】

image.png
image.png

最小可运行代码示例#

package main
import "fmt"
type User struct {
Name string
Age int
}
func main() {
arr := [3]int{1, 2, 3}
arrCopy := arr
arrCopy[0] = 99
s := []int{10, 20, 30}
s2 := s
s2[0] = 100
m := map[string]int{"go": 1}
m["go"] = 2
u := User{Name: "Tom", Age: 18}
u2 := u
u2.Age = 20
fmt.Println("array:", arr, arrCopy)
fmt.Println("slice:", s, s2)
fmt.Println("map:", m)
fmt.Println("struct:", u, u2)
}

运行后可以看到:

  • 数组拷贝后互不影响
  • 切片赋值后共享底层元素修改结果
  • map 可按键更新
  • 结构体拷贝后互不影响

常见陷阱与错误示例#

1. 把切片当成“独立数组”#

错误理解:

s1 := []int{1, 2, 3}
s2 := s1
s2[0] = 99

如果你以为只改了 s2,那就是误判。这里通常也改到了 s1,因为底层数组共享。

2. 认为 append 一定不会影响原切片#

这是不对的。若容量够,append 可能直接改原底层数组;若容量不够,才可能分配新数组。是否共享,取决于扩容是否发生。

3. 给 nil map 直接赋值#

错误示例:

var m map[string]int
m["x"] = 1

这会 panic。要先 make(map[string]int)

4. 用 map 读取结果直接判断键是否存在#

错误示例:

m := map[string]int{"a": 0}
if m["b"] == 0 {
// 误以为 key 存在且值为 0
}

这里无法区分“值就是 0”还是“键不存在”。应写成:

v, ok := m["b"]
_ = v
_ = ok

5. 把结构体大对象随手按值传来传去#

结构体是值语义,字段很多时,频繁拷贝会带来不必要成本,也容易让人误判“这里是不是在修改原对象”。

6. 误以为数组和切片可以直接互换#

数组和切片是不同类型:

  • [3]int 不是 []int
  • 切片可由数组切出来
  • 但它们不是同一种东西

性能影响或设计取舍#

数组的取舍#

优点:

  • 内存连续
  • 类型信息稳定
  • 值语义明确

代价:

  • 长度固定
  • 拷贝成本随大小增长
  • 在业务代码里不如切片灵活

切片的取舍#

优点:

  • 使用最灵活
  • append 和切片表达式很自然
  • 适合绝大多数顺序集合场景

代价:

  • 共享底层数组容易产生副作用
  • 扩容时会分配和复制
  • 容易因为保留大底层数组而额外占内存

map 的取舍#

优点:

  • 按键访问效率高
  • 建模字典、索引、集合非常直接
  • 读取缺失 key 行为友好

代价:

  • 遍历无序
  • 零值不可直接写
  • 相比切片有更高的管理开销

struct 的取舍#

优点:

  • 最贴近业务建模
  • 字段明确,编译期类型安全强
  • 值语义清楚,适合表达“一个完整对象值”

代价:

  • 大结构体频繁拷贝有成本
  • 如果字段设计混乱,会很快演变成“数据袋”

面试高频问题#

1. 数组和切片的本质区别是什么#

数组是固定长度的值类型,长度属于类型一部分;切片是对底层数组的一段视图,内部包含指针、长度和容量,赋值时通常共享底层数据。

2. 为什么 nil slice 通常能 append,但 nil map 不能写#

因为切片的 append 可以在需要时分配新的底层数组;而 map 写入必须依赖已经存在的底层哈希表,nil map 没有这个结构,所以写入会 panic。

3. map 读取一个不存在的 key 会发生什么#

不会 panic,会返回值类型的零值。如果要区分“零值”与“缺失”,应使用 value, ok := m[key]

4. 结构体和数组为什么都常说是值类型#

因为它们赋值、传参时默认复制整个值本身,而不是默认共享同一份底层状态。只是如果结构体字段里又包含切片、map、指针,这些字段内部仍可能共享引用对象。

5. append 后原切片一定不变吗#

不一定。若原切片容量足够,append 可能直接写入同一底层数组;若容量不足,才可能重新分配新数组。

6. 什么场景优先用数组,什么场景优先用切片#

固定长度、协议边界明确、强调值语义时优先数组;绝大多数日常集合处理、动态增长场景优先切片。

一句话总结#

数组是固定长度的值容器,切片是数组上的动态视图,map 是按键访问的关联容器,结构体是字段组合形成的业务值类型;真正要掌握的不是字面量写法,而是它们各自的值语义、共享语义、零值行为和适用边界。

文章目录

文章目录