主题
Go 类型系统
写 Go 时,选对类型只是第一步。接下来还要知道:把一个值交给函数,函数能不能改到调用方的数据?一个容器看起来是空的,能不能直接往里放东西?一段中文的长度,到底应该怎么算?这篇把基本类型、数组、slice、map 和字符串放在一起讲,重点是把这些行为判断清楚。
先认一下各自的用途。表中的“复制”指普通赋值和参数传递,不是序列化,也不是递归复制所有关联对象。Go 传参会复制参数值;值里包含引用时,复制之后仍可能访问同一份数据。[1]
| 想表达什么 | 常用类型 | 赋值后要注意什么 |
|---|---|---|
| 真或假、计数、近似数值 | bool、整数、浮点数 | 复制当前数值;转换类型时还要检查范围 |
| 长度本身就是约束,例如 32 字节摘要 | [32]byte | 整个数组值被复制 |
| 元素数量会变的同类序列 | []T,即 slice | 复制的是切片描述,底层数组可能共享 |
| 根据键找值 | map[K]V | 两份 map 值仍访问同一个映射 |
| 不可修改的文本或字节内容 | string | 字符串内容不可修改,len 按字节计算 |
| 要修改的字节缓冲区 | []byte | 它就是 slice,同样有共享和扩容问题 |
下面的输出来自 2026-10-13 在 Go 1.27.2、Linux/amd64 上的实际运行。语言规则依据官方规范;扩容容量等实现结果单独标出,不把它们当成跨版本承诺。文末有全部代码、测试和故意编译失败的反例,复制到一个目录即可复现。
基本类型:先让编译器知道这个值代表什么
常用类型怎样选
Go 的预声明基本类型如下。byte 和 rune 是别名,分别与 uint8、int32 是同一个类型;名字不同,是为了提示读代码的人,这里处理的是字节还是 Unicode 码点。[2]
| 类别 | 类型 | 要记住的区别 |
|---|---|---|
| 布尔 | bool | 只有 true 和 false,不能拿整数代替条件 |
| 有符号整数 | int8、int16、int32、int64 | 固定位数,既能表示负数也能表示非负数 |
| 无符号整数 | uint8、uint16、uint32、uint64 | 固定位数,只表示非负数;例如 uint8 的范围是 0~255 |
| 常用整数 | int、uint | 宽度由实现决定,为 32 或 64 位;len、cap 返回 int |
| 浮点数 | float32、float64 | IEEE 754 二进制浮点数,用于允许舍入的计算 |
| 复数 | complex64、complex128 | 实部、虚部分别采用 float32 或 float64 |
| 字符串 | string | 不可变的字节序列,零值是 "" |
| 字节、码点 | byte、rune | 分别是 uint8、int32 的别名 |
| 指针位模式用整数 | uintptr | 能容纳指针的位模式;它仍是整数,不是保活对象的指针 |
通常的计数、下标用 int 比较顺手。文件格式或网络协议明确规定了 32 位、64 位时,用固定位宽类型,不把本机的 int 宽度当成协议。这是依据类型范围给出的选择建议。精确金额也需要先规定单位、范围和舍入方式;把金额写成 float64,不会让十进制小数自动变得精确。[2:1][3]
不同数值类型不能因为“都装着数字”就直接混用:int 变量和 int64 变量相加会编译失败。字面量的情况稍有不同,var n int64 = 1 可以成立,因为未指定类型的常量 1 能由目标类型表示。n := 1 没有这个目标类型,整数常量便采用默认的 int。[4]
显式转换也不等于检查范围。
go
n := 257
fmt.Println(uint8(n))text
1n 是运行时的整数值,转成 8 位无符号整数时只留下低 8 位,257 就变成了 1。想拒绝超范围输入,应先判断 n < 0 || n > 255。但若直接写 uint8(257),257 仍是常量,编译器会拒绝不能由 uint8 表示的常量转换。文末反例的实际诊断是 constant 257 overflows uint8。两者差别在“常量能否表示”和“非常量整数怎样转换”,并不矛盾。[5]
定义类型与别名解决的问题不同
go
type UserID int64
type IDAlias = int64
var id UserID = 42
var alias IDAlias = 42
fmt.Printf("%T %T\n", id, alias)text
main.UserID int64type UserID int64 创建一个新的定义类型,底层类型是 int64。再定义一个 OrderID int64,便不能把 UserID 变量直接赋给 OrderID 变量;要互转必须明确写出来。用户 ID 和订单 ID 都用裸 int64 时,编译器分不清它们,定义类型能帮忙挡住一部分参数传错。它不会检查 ID 是否真实存在,这仍是业务逻辑的工作。[6]
type IDAlias = int64 只给原类型另起一个名字,输出仍是 int64。它适合保持原有类型身份的场景,例如迁移 API 的类型名。若目的是区分两种业务含义,别名达不到这个效果。[6:1]
零值是起点,不保证所有操作都能做
没有写初始值的变量也有确定的值:数值是 0,布尔是 false,字符串是空串;指针、slice、map、函数、channel、接口是 nil。数组和结构体则逐个元素、字段取零值。[7]
go
var enabled bool
var title string
var ptr *int
fmt.Printf("%t %q %t\n", enabled, title, ptr == nil)text
false "" true因此不必为了“清空”整数再赋一次 0。但 nil 指针不能随意解引用,nil map 也不能直接插入。后面用 slice 和 map 对照看,能看出“零值可读”和“零值可写”并不是一回事。
数组与 slice:固定的一组元素,和查看数组的一段范围
| 比较项 | 数组 [N]T | slice []T |
|---|---|---|
| 长度是不是类型的一部分 | 是,[3]int 和 [4]int 类型不同 | 不是,不同长度都可以是 []int |
| 值包含什么 | N 个元素 | 指向底层数组某处的引用、长度、容量 |
| 普通赋值复制什么 | 每个元素的值 | 切片描述,通常继续共享元素 |
| 长度能否改变 | 不能 | 可以重新切片,或接收 append 的返回值 |
| 零值 | N 个元素各取零值 | nil,长度和容量都是 0 |
能否用 == 比较两份 | 元素类型可比较时可以 | 不可以,只能与 nil 比较 |
同样传入函数,为什么修改结果不同
go
func changeArray(a [3]int) { a[0] = 9 }
func changeSlice(s []int) {
s[0] = 9
s = s[:1]
fmt.Println("inside:", s)
}
a := [3]int{1, 2, 3}
changeArray(a)
fmt.Println("array:", a)
s := []int{1, 2, 3}
changeSlice(s)
fmt.Println("outside:", s)text
array: [1 2 3]
inside: [9]
outside: [9 2 3]数组参数 a 是三个整数的副本,改副本的第一个整数不会改到原数组。slice 参数也是按值传递,但这次复制的是描述同一段底层数组的信息。s[0] = 9 沿着引用找到共享元素,因此调用方能看到 9;s = s[:1] 只把函数里的描述改短,所以调用方的长度仍为 3。[1:1][9:1]
下面画的是调用期间的关系。两个 slice 描述是分开的,指向的数组却是同一个;它不是“整个 slice 按引用传递”。图中的三部分对应本次 Go 运行时的 slice 表示,程序行为仍应以语言规范为准。[10]
正在绘制图示…
查看图源代码
flowchart LR
C["调用方 s<br/>起点 0 · len 3 · cap 3"] --> A["同一个底层数组<br/>0:9 · 1:2 · 2:3"]
F["函数参数 s<br/>起点 0 · len 1 · cap 3"] --> A
数组也不保证“复制之后整个对象树独立”。若元素是指针或 slice,数组复制的是这些元素的值,其中的引用仍可能共享。判断共享时,要继续看元素是什么类型。[1:2]
len 决定能读哪些元素,cap 决定能扩到哪里
创建 slice 的常用写法:
| 写法 | 初始状态 | 适用场景 |
|---|---|---|
var s []int | nil,长度和容量都为 0 | 暂时没有元素,之后直接 append |
s := []int{1, 2} | 长度 2,有两个初值 | 已经知道内容 |
s := make([]int, 2) | 长度和容量都是 2,元素为 0 | 接下来按下标填两个位置 |
s := make([]int, 0, 100) | 长度 0,容量 100 | 预计追加约 100 项,仍从第 0 项开始追加 |
make 的第二个参数是长度,第三个参数是容量。预留了容量,不等于那些元素已经在当前长度里。规范要求索引小于 len(s);重新切片则可以在容量允许的范围内扩大。[11]
go
s := make([]int, 2, 4)
fmt.Println(s, len(s), cap(s))
s = s[:4]
s[2] = 7
t := s[1:3]
fmt.Println(t, len(t), cap(t))text
[0 0] 2 4
[0 7] 2 3第一步不能直接访问 s[2],因为当前只有下标 0、1。s = s[:4] 把长度扩到 4 后才可以。t 从原数组下标 1 开始,到下标 3 之前结束,所以能看到两个元素;从它的起点到数组末尾还有三个位置,容量就是 3。常见误写 make([]T, n) 后再追加 n 个元素,会得到前面 n 个零值加后面 n 个新元素;要先预留容量而不增加长度,应写 make([]T, 0, n)。[11:1][12]
nil slice 和非 nil 的空 slice 没有元素,却不是同一种状态:
go
var a []int
b := []int{}
fmt.Println(a == nil, b == nil, len(a), len(b))
a = append(a, 5)
fmt.Println(a)text
true false 0 0
[5]只问“有没有元素”,用 len(s) == 0;问“是否是 nil”,才用 s == nil。nil slice 可以遍历、可以追加,不能按下标访问不存在的元素。接口是否区分 nil 与空集合,应看对应 API 的约定,不从两者打印起来很像来推断。[9:2][12:1]
append 与复制:先问底层数组还共不共用
容量够时,append 会写到原数组里
go
base := []int{10, 20, 30, 40}
left := base[:2]
result := append(left, 99)
fmt.Println("base:", base)
fmt.Println("left:", left, "result:", result)text
base: [10 20 99 40]
left: [10 20] result: [10 20 99]left 长度是 2,容量却是 4。追加第三个元素不必换数组,99 就写进原数组下标 2,覆盖了原来的 30。left 本身仍长 2,返回的 result 才长 3,所以 append 的结果必须被使用,常见写法是 s = append(s, x)。只修改元素和改变切片长度,是两个不同动作。[12:2]
正在绘制图示…
查看图源代码
flowchart LR
B["base<br/>len 4 · cap 4"] --> A["底层数组<br/>10 · 20 · 99 · 40"]
L["left<br/>len 2 · cap 4"] --> A
R["append 返回的 result<br/>len 3 · cap 4"] --> A
这种共享无需并发就会发生。把一小段 slice 交给另一个函数,那个函数只是追加内容,也可能覆盖你仍然使用的后半段。
限制容量只能隔开追加,不能隔开已有元素
接着上面的 base:
go
fenced := base[:2:2]
fenced[0] = 11
detached := append(fenced, 77)
detached[0] = 88
fmt.Println("base:", base)
fmt.Println("fenced:", fenced, "detached:", detached)text
base: [11 20 99 40]
fenced: [11 20] detached: [88 20 77]三下标切片的格式是 s[low:high:max],长度为 high-low,容量为 max-low。base[:2:2] 把容量也截到 2,追加第三个元素时容量不足,必须获得新数组并复制已有元素。之后改 detached[0] 不再影响原数组。[11:2][12:3]
但第一句 fenced[0] = 11 仍然修改了 base,因为那时两个描述还指向同一个数组。限制容量适合防止后续追加覆盖邻接数据,不是只读保护,也不是深复制。
要独立的一份元素,用 copy 或 Clone
go
cloned := slices.Clone(base)
cloned[0] = -1
fmt.Println("base:", base, "clone:", cloned)
nested := [][]int{{1, 2}}
shallow := slices.Clone(nested)
shallow[0][0] = 9
fmt.Println("nested:", nested)text
base: [11 20 99 40] clone: [-1 20 99 40]
nested: [[9 2]]第一份是 []int,整数元素被复制到新的存储里。第二份是 [][]int,外层元素本身是 slice,复制外层只会复制这些 slice 描述,内层数组仍然共享。slices.Clone 的文档明确说它做的是浅复制;需要独立的二维数据,必须继续复制每一行。[13]
也可以用 dst := make([]T, len(src)); copy(dst, src)。copy 返回实际复制的元素数,由源、目标的较短长度决定,不看目标还剩多少容量。因此 make([]int, 0, 3) 接收不到任何元素,先把长度设为 3 才能复制三个。文末测试专门检查了这一点。[12:4]
复制还可以解决内存保留问题:一个长期保存的小 slice 可能让原来的大数组一直可达。只保留几十个字节时,把需要的部分复制出来,便不再通过它引用整块缓冲区。是否值得做,要看原数组多大、保存多久以及复制成本;这里没有测量具体回收时机,不能据此宣称固定能节省多少内存。[14]
扩容为什么不能背成“每次翻倍”
语言保证的是:容量足够就复用数组,容量不足就分配足够大的新数组。新的容量具体是多少,语言不规定。[12:5]
下面给同样长度的 []int、[]byte 各追加一个元素:
go
ints := make([]int, 512)
bytes := make([]byte, 512)
ints = append(ints, 1)
bytes = append(bytes, 1)
fmt.Println("int:", len(ints), cap(ints))
fmt.Println("byte:", len(bytes), cap(bytes))本次 Go 1.27.2、Linux/amd64 的实际输出:
text
int: 513 848
byte: 513 896同样从 512 增到 513,容量并不相同。在本次工具链的 runtime/slice.go 中,nextslicecap 先计算增长目标:小容量倾向于翻倍,大容量逐渐过渡到较小增长比例;若一次请求很多元素,则先满足请求。growslice 还会根据元素大小和分配器规格取整,所以仅背一个倍数解释不了最后的容量。这里描述的是固定发行版本的实现,不能把 848、896 或阈值写进业务逻辑。[10:1]
知道大概会追加多少项时,可以预留容量来减少中途增长;预留过大也会占更多空间。这是时间与空间的取舍,本篇没有做性能基准,不给出“快几倍”的结论。
map:查找方便,但有存在性、共享和并发边界
先记常用操作,再区分“没有”和“值为零”
| 操作 | 写法 | 边界 |
|---|---|---|
| 创建可写 map | make(map[string]int) 或字面量 | make 的可选数字是容量提示,不是元素个数或大小上限 |
| 查值 | v := m[k] | 不存在时返回值类型的零值 |
| 查值并判断存在 | v, ok := m[k] | ok 才能区分不存在与零值 |
| 插入、覆盖 | m[k] = v | nil map 会 panic |
| 删除 | delete(m, k) | 不存在或 map 为 nil 也可调用 |
| 清空 | clear(m) | 删除所有项,但不承诺具体内存归还时机 |
| 数量、遍历 | len(m)、for k, v := range m | 遍历次序没有保证 |
这些行为由 map、索引和内置函数的规范定义。[15][16]
go
var missing map[string]int
v, ok := missing["x"]
fmt.Println(len(missing), v, ok)
delete(missing, "x")
m := map[string]int{"b": 2, "a": 0}
v, ok = m["a"]
fmt.Println(v, ok)text
0 0 false
0 true两次都得到 0,但一个没记录,一个确实存着 0。库存、次数、开关配置这类数据,很容易把两种含义弄混。nil map 可以读、遍历、删除,写入则会 panic;需要写入时先 make。它与可以直接 append 的 nil slice 不同。[16:1]
map 赋值会共享,稳定输出要自己排序
继续使用上面的 m:
go
alias := m
alias["a"] = 7
for _, k := range slices.Sorted(maps.Keys(m)) {
fmt.Println(k, m[k])
}
copyMap := maps.Clone(m)
copyMap["a"] = 8
fmt.Println(m["a"], copyMap["a"])
clear(copyMap)
fmt.Println(len(m), len(copyMap))text
a 7
b 2
7 8
2 0改 alias 就是改同一个映射,所以 m["a"] 变成了 7。maps.Clone 才建立另一份映射,在本例中整数值互不影响。它也只是浅复制:若值是 slice 或指针,其中引用的对象还可能共享。[1:3][17]
输出中 a 在 b 前面,是代码显式排序的结果。maps.Keys 返回迭代器,slices.Sorted 收集后排序;这两个入口自 Go 1.23 可用。直接 range m 没有稳定顺序保证,既不能假定插入顺序,也不能假定每次一定不同。需要签名、可重复快照或稳定测试输出时,应明确排序规则。[18]
同一个 goroutine 在遍历时删掉尚未访问的项,该项不会再被产出;新插入的项可能出现,也可能不出现。这是迭代语义,不是并发安全保证。[18:1]
map 的键为什么不能是 slice
键需要可比较,即能按该类型的规则使用 == 和 !=。[19]
| 类型 | 能否作为 map 的键 |
|---|---|
| 布尔、整数、浮点数、复数、字符串 | 可以;浮点 NaN 等特殊值仍要另看相等语义 |
| 指针、channel | 可以,比较身份而不是指向对象的全部内容 |
| 数组 | 元素类型可比较时可以 |
| 结构体 | 所有字段类型可比较时可以 |
| slice、map、函数 | 不可以 |
any 等接口类型 | 类型声明允许,但实际放进去的动态值也必须可比较 |
map[any]int 并不绕过限制。放入 []int{1} 作为键,会在运行时 panic;文末测试捕获了这次 panic。若要用固定长度摘要作键,[32]byte 就比 []byte 合适。若表示的是变长字节内容,可以根据业务语义转成 string。这属于键的建模选择,不要用指针地址代替本来需要的内容相等。[15:1][19:1]
map 元素不能取址,结构体要取出再放回
下面两句都会编译失败,完整反例在文末:
go
_ = &m["x"] // m 是 map[string]Item
m["x"].N = 2map 索引表达式不能作为可取址的变量,虽然语言特许 m[k] = v 和 m[k]++ 这样的 map 元素赋值。因而,不能通过 &m[k] 保存元素地址,也不能把 map 中结构体的某个字段当作可直接赋值的位置。不要把“当前实现可能搬动元素”当作这条规则的必要前提;限制来自语言规范。[20]
要更新结构体值,可以取出副本、修改、再写回:
go
m := map[string]Counter{"a": {N: 1}}
c := m["a"]
c.N++
m["a"] = c
fmt.Println(m["a"].N)
p := map[string]*Counter{"a": {N: 1}}
p["a"].N++
fmt.Println(p["a"].N)text
2
2第二种存的是指针,通过指针可以修改它指向的 Counter,并没有对 map 元素自身取址。选择指针会增加共享对象和 nil 的管理责任:键不存在时 p[k] 是 nil,继续访问字段会 panic。值类型适合明确取出、改好、写回;指针适合确实需要共享同一个可变对象的场景。[20:1][16:2]
普通 map 不替你协调多个 goroutine
多个 goroutine 可以在正确发布 map 后共同只读,前提是期间没人写它。一旦有写入,读、写、删除、清空和遍历等并发访问必须用同步手段协调,不能认为“写不同的键就没事”。运行时有时会报并发 map 错误,但没报错不代表访问安全。[21]
最直接的方案是围绕访问加 sync.Mutex,或者让一个 goroutine 负责 map,其他 goroutine 发请求。锁的范围要覆盖整个操作,例如 m["n"]++ 包含读取和写回,不能只保护其中一半。文末 TestMapWithMutex 让 4 个 goroutine 各加 250 次,等待结束后验证结果为 1000;race 检查通过。这只验证这段加锁代码实际执行的路径,不是对任意 map 使用方式的证明。[22][23]
map 内部采用什么表结构、什么时候扩容,属于运行时实现。业务代码应依赖查找、赋值、迭代的语义,不把底层表结构当成接口约定。
string、byte、rune:先选对计量单位
一个字节、一个码点、一个可见字符并不总是一回事
string 是不可变字节序列,内容可以不是合法 UTF-8。byte 表示一个字节;rune 是 int32 的别名,处理文本时通常用来保存 Unicode 码点。一个 UTF-8 码点可能占多个字节。[24][25]
| 操作 | 处理的单位或含义 | 常见用途 |
|---|---|---|
len(s) | 字节数 | 报文大小、存储长度 |
s[i] | 第 i 个字节 | 按协议处理字节 |
s[low:high] | 按字节偏移切片 | 已经知道合法边界时截取 |
for i, r := range s | i 是字节偏移,r 是解码后的 rune | 按码点遍历 |
utf8.RuneCountInString(s) | 码点数;无效编码按解码规则处理 | 只需要计数,不需要构建 []rune |
[]byte(s) | 得到可修改的字节序列 | 修改缓冲区、接字节 API |
[]rune(s) | 解码为码点序列 | 需要按码点下标处理 |
utf8.ValidString(s) | 是否是合法 UTF-8 | 验证外部输入的编码 |
go
s := "A中🙂"
fmt.Println(len(s), utf8.RuneCountInString(s))
fmt.Printf("% x\n", []byte(s))
for i, r := range s {
fmt.Printf("%d %U\n", i, r)
}
fmt.Println(utf8.ValidString(s[:2]))text
8 3
41 e4 b8 ad f0 9f 99 82
0 U+0041
1 U+4E2D
4 U+1F642
falseA 占 1 字节,中 占 3 字节,🙂 占 4 字节,共 8 字节、3 个码点。因此 range 的下标是 0、1、4,而不是 0、1、2。s[:2] 拿到 A 和 中 的第一个字节,截断了 UTF-8 编码,所以验证失败。字符串切片不会自动替你寻找码点边界。[24:1][26][25:1]
正在绘制图示…
查看图源代码
flowchart LR
S["字符串 A中🙂"] --> B["字节偏移<br/>0:41 · 1:e4 · 2:b8 · 3:ad<br/>4:f0 · 5:9f · 6:99 · 7:82"]
B --> R["UTF-8 解码<br/>0 → A<br/>1 → 中<br/>4 → 🙂"]
如果业务要“最多前 n 个码点”,可以在确认输入编码策略后按 rune 处理。但用户眼中的一个字符还可能包含多个码点,例如字母加组合重音、组合 emoji。光标移动、按可见字符截断等需求涉及 Unicode 字素簇分段,单纯 []rune 仍不够。[27]
改转换后的字节,不会改掉原字符串
接着上面的 s:
go
b := []byte(s)
b[0] = 'B'
snapshot := string(b)
b[0] = 'C'
fmt.Println(s, snapshot, string(b))
fmt.Println(string(rune(65)), strconv.Itoa(65))text
A中🙂 B中🙂 C中🙂
A 65[]byte(s) 让你得到可修改的字节内容,改 b 不影响原字符串;string(b) 得到的字符串也不会因为后续再改 b 而改变。这是安全 Go 中可依赖的语义。至于编译器是否在某个上下文省略复制、是否发生堆分配,不能只看转换语法就下结论,需要对具体代码测量。[28]
最后一行是另一个常见误用:整数转字符串的含义是“把这个整数当成 Unicode 码点编码”,所以 65 得到 A。要数字的十进制文本,用 strconv.Itoa 或对应的 Format 函数。[28:1][29]
编码不合法时,range 不会直接报错
go
s := "\xffA"
fmt.Println(utf8.ValidString(s))
for i, r := range s {
fmt.Printf("%d %U\n", i, r)
}
fmt.Println(utf8.RuneCountInString("é"), utf8.RuneCountInString("e\u0301"))
fmt.Println("é" == "e\u0301")text
false
0 U+FFFD
1 U+0041
1 2
false无效的 0xff 被解码成替换字符 U+FFFD,遍历向前推进一个字节,再读到 A。如果 API 要拒绝无效 UTF-8,应明确调用验证函数,不把“range 跑完了”当成编码正确。[26:1][25:2]
后面两种 é 一种是单一码点,一种是 e 加组合重音,码点数分别为 1、2。它们的字节不同,Go 的字符串相等比较就返回 false。是否需要 Unicode 规范化,要由用户名、搜索、展示等具体规则决定;语言不会自动把视觉上相近的文本改成同一串字节。[19:2][27:1]
到这里,类型选择背后的判断就具体了:长度约束写进数组类型,变长序列用 slice,按键访问用 map;共享数据先说清谁能修改,文本操作先说清按字节、码点还是可见字符。遇到行为拿不准的代码,画出值与引用的关系,再写一个只包含这一步操作的例子,通常比背口诀更容易找到原因。
本次验证与复现
示例没有第三方依赖。为固定本次环境,go.mod 声明 Go 1.27.0,本次实际工具链为 Go 1.27.2;这不表示每一个语法特性都从 Go 1.27 才有。maps.Keys 和 slices.Sorted 要求至少 Go 1.23。把下面四份文件按标注路径保存,在目录里运行:
bash
go version
go run .
go test -count=1 -v .
go test -count=1 -race .
go vet .
go build -o invalid.a testdata/compile_fail.go最后一条是故意失败的编译检查,不要把它的退出码 1 当成示例损坏。其余命令本次全部通过;race 构建需要所在平台支持竞态检测及相应 C 编译器。8 个 Example 测试逐行核对正文的确定性输出,另外 3 个 Test 检查越界等 panic、copy 的长度规则和加锁计数。具体扩容容量只记录观察,不写成测试的跨平台断言。[23:1]
编译反例本次给出的核心诊断如下,文件路径和行号略去:
text
invalid operation: i + j (mismatched types int and int64)
cannot use uid (variable of int64 type UserID) as OrderID value in variable declaration
constant 257 overflows uint8
invalid operation: s == s (slice can only be compared to nil)
invalid operation: cannot take address of m["x"] (map index expression of struct type Item)
cannot assign to struct field m["x"].N in map
cannot assign to text[0] (neither addressable nor a map index expression)测试中的 panic 都在单独子测试里恢复并断言,不靠肉眼看程序是否崩溃。没有进行性能基准、GC 回收时机测量、未加锁 map 的故障注入,也没有据此声称其他平台或版本的容量相同。
完整可复现代码
go.mod
go
module example.com/go-types
go 1.27.0main.go
代码块 · 160 行 · 点击收起 / 展开
go
package main
import (
"fmt"
"maps"
"slices"
"strconv"
"unicode/utf8"
)
type UserID int64
type IDAlias = int64
func basics() {
var id UserID = 42
var alias IDAlias = 42
n := 257
fmt.Printf("%T %T\n", id, alias)
fmt.Println(uint8(n))
var enabled bool
var title string
var ptr *int
fmt.Printf("%t %q %t\n", enabled, title, ptr == nil)
}
func changeArray(a [3]int) { a[0] = 9 }
func changeSlice(s []int) {
s[0] = 9
s = s[:1]
fmt.Println("inside:", s)
}
func copying() {
a := [3]int{1, 2, 3}
changeArray(a)
fmt.Println("array:", a)
s := []int{1, 2, 3}
changeSlice(s)
fmt.Println("outside:", s)
}
func lengthAndCapacity() {
s := make([]int, 2, 4)
fmt.Println(s, len(s), cap(s))
s = s[:4]
s[2] = 7
t := s[1:3]
fmt.Println(t, len(t), cap(t))
var a []int
b := []int{}
fmt.Println(a == nil, b == nil, len(a), len(b))
a = append(a, 5)
fmt.Println(a)
}
func sharing() {
base := []int{10, 20, 30, 40}
left := base[:2]
result := append(left, 99)
fmt.Println("base:", base)
fmt.Println("left:", left, "result:", result)
fenced := base[:2:2]
fenced[0] = 11
detached := append(fenced, 77)
detached[0] = 88
fmt.Println("base:", base)
fmt.Println("fenced:", fenced, "detached:", detached)
cloned := slices.Clone(base)
cloned[0] = -1
fmt.Println("base:", base, "clone:", cloned)
nested := [][]int{{1, 2}}
shallow := slices.Clone(nested)
shallow[0][0] = 9
fmt.Println("nested:", nested)
}
func growth() {
ints := make([]int, 512)
bytes := make([]byte, 512)
ints = append(ints, 1)
bytes = append(bytes, 1)
fmt.Println("int:", len(ints), cap(ints))
fmt.Println("byte:", len(bytes), cap(bytes))
}
func mapBasics() {
var missing map[string]int
v, ok := missing["x"]
fmt.Println(len(missing), v, ok)
delete(missing, "x")
m := map[string]int{"b": 2, "a": 0}
v, ok = m["a"]
fmt.Println(v, ok)
alias := m
alias["a"] = 7
for _, k := range slices.Sorted(maps.Keys(m)) {
fmt.Println(k, m[k])
}
copyMap := maps.Clone(m)
copyMap["a"] = 8
fmt.Println(m["a"], copyMap["a"])
clear(copyMap)
fmt.Println(len(m), len(copyMap))
}
type Counter struct{ N int }
func mapValues() {
m := map[string]Counter{"a": {N: 1}}
c := m["a"]
c.N++
m["a"] = c
fmt.Println(m["a"].N)
p := map[string]*Counter{"a": {N: 1}}
p["a"].N++
fmt.Println(p["a"].N)
}
func textUnits() {
s := "A中🙂"
fmt.Println(len(s), utf8.RuneCountInString(s))
fmt.Printf("% x\n", []byte(s))
for i, r := range s {
fmt.Printf("%d %U\n", i, r)
}
fmt.Println(utf8.ValidString(s[:2]))
b := []byte(s)
b[0] = 'B'
snapshot := string(b)
b[0] = 'C'
fmt.Println(s, snapshot, string(b))
fmt.Println(string(rune(65)), strconv.Itoa(65))
}
func invalidText() {
s := "\xffA"
fmt.Println(utf8.ValidString(s))
for i, r := range s {
fmt.Printf("%d %U\n", i, r)
}
fmt.Println(utf8.RuneCountInString("é"), utf8.RuneCountInString("e\u0301"))
fmt.Println("é" == "e\u0301")
}
func main() {
for _, demo := range []struct {
name string
run func()
}{
{"basics", basics}, {"copying", copying},
{"length and capacity", lengthAndCapacity}, {"sharing", sharing},
{"growth", growth}, {"map basics", mapBasics}, {"map values", mapValues},
{"text units", textUnits}, {"invalid text", invalidText},
} {
fmt.Println("==", demo.name, "==")
demo.run()
}
}main_test.go
代码块 · 135 行 · 点击收起 / 展开
go
package main
import (
"slices"
"sync"
"testing"
)
func Example_basics() {
basics()
// Output:
// main.UserID int64
// 1
// false "" true
}
func Example_copying() {
copying()
// Output:
// array: [1 2 3]
// inside: [9]
// outside: [9 2 3]
}
func Example_lengthAndCapacity() {
lengthAndCapacity()
// Output:
// [0 0] 2 4
// [0 7] 2 3
// true false 0 0
// [5]
}
func Example_sharing() {
sharing()
// Output:
// base: [10 20 99 40]
// left: [10 20] result: [10 20 99]
// base: [11 20 99 40]
// fenced: [11 20] detached: [88 20 77]
// base: [11 20 99 40] clone: [-1 20 99 40]
// nested: [[9 2]]
}
func Example_mapBasics() {
mapBasics()
// Output:
// 0 0 false
// 0 true
// a 7
// b 2
// 7 8
// 2 0
}
func Example_mapValues() {
mapValues()
// Output:
// 2
// 2
}
func Example_textUnits() {
textUnits()
// Output:
// 8 3
// 41 e4 b8 ad f0 9f 99 82
// 0 U+0041
// 1 U+4E2D
// 4 U+1F642
// false
// A中🙂 B中🙂 C中🙂
// A 65
}
func Example_invalidText() {
invalidText()
// Output:
// false
// 0 U+FFFD
// 1 U+0041
// 1 2
// false
}
func TestRuntimePanics(t *testing.T) {
tests := []struct {
name string
run func()
}{
{"index uses len", func() { s := make([]int, 2, 4); _ = s[2] }},
{"write nil map", func() { var m map[string]int; m["x"] = 1 }},
{"slice key in any", func() { m := map[any]int{}; m[[]int{1}] = 1 }},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("expected panic")
}
}()
tc.run()
})
}
}
func TestCopyUsesLength(t *testing.T) {
src := []int{1, 2, 3}
dst := make([]int, 0, 3)
if n := copy(dst, src); n != 0 {
t.Fatalf("copied %d", n)
}
dst = dst[:3]
if n := copy(dst, src); n != 3 || !slices.Equal(dst, src) {
t.Fatalf("copy: n=%d, dst=%v", n, dst)
}
dst[0] = 9
if src[0] != 1 {
t.Fatal("copy shares integer storage")
}
}
func TestMapWithMutex(t *testing.T) {
m := make(map[string]int)
var mu sync.Mutex
var wg sync.WaitGroup
for range 4 {
wg.Add(1)
go func() {
defer wg.Done()
for range 250 {
mu.Lock()
m["n"]++
mu.Unlock()
}
}()
}
wg.Wait()
if m["n"] != 1000 {
t.Fatalf("got %d", m["n"])
}
}testdata/compile_fail.go
go
package fail
type UserID int64
type OrderID int64
type Item struct{ N int }
func invalid() {
var i int
var j int64
_ = i + j
var uid UserID
var oid OrderID = uid
_ = oid
_ = uint8(257)
s := []int{1}
_ = s == s
m := map[string]Item{"x": {N: 1}}
_ = &m["x"]
m["x"].N = 2
text := "abc"
text[0] = 'A'
}依据
脚注链接定位到正文所用的规则;规范和标准库文档另与本次官方 Go 1.27.2 发行包中的 doc/go_spec.html、包源码和 go doc 交叉核对。容量观察只来自本篇程序,没有沿用其他平台的测量。
unsafe.Pointer文档:uintptr是整数,GC 不把它当作对象引用。 ↩︎Go 规范:slice 类型;官方教程 Go Slices: usage and internals。 ↩︎ ↩︎ ↩︎
固定证据为 Go 1.27.2 官方发行包 内
src/runtime/slice.go,本次读取了slice(第 16 行)、growslice(第 178 行)、nextslicecap(第 326 行)。发行包 SHA-256 已核对为ecbadb99091a3f46e31f5f934b068b1864eafa7995211b39eaddf76996045fe5。官方博客 The mechanics of append 可帮助理解描述与共享;其中的历史实现示例不是本篇的当前增长算法来源。 ↩︎ ↩︎slices.Clone:元素按赋值复制,是浅复制;本次也读取了 Go 1.27.2 的包文档。 ↩︎官方博客 小 slice 保留底层大数组。这里采用可达性解释,不从本例推断具体 GC 时机。 ↩︎
Go 规范:索引表达式、删除 map 元素、clear;官方教程 Go maps in action。 ↩︎ ↩︎ ↩︎
maps.Clone:新 map 的键、值通过普通赋值设置,因此是浅复制。 ↩︎Go 规范:range;
maps.Keys、slices.Sorted,两个 API 的文档标注自 Go 1.23 可用。 ↩︎ ↩︎Go 规范:比较运算。数组、结构体是否可比较取决于元素或字段;含接口时还可能遇到动态值不可比较的运行时 panic。 ↩︎ ↩︎ ↩︎
Go FAQ:map 为什么不默认支持并发读写;Go 内存模型。 ↩︎
sync.Mutex、sync.WaitGroup。锁保护临界区,等待所有写入完成后再检查结果。 ↩︎Go 数据竞争检测器:运行方式、支持环境及“只检测实际执行到的竞争”的限制。 ↩︎ ↩︎
Go 规范:字符串类型、索引;官方文章 Strings, bytes, runes and characters in Go。 ↩︎ ↩︎
Go 1.27.2
unicode/utf8:ValidString、RuneCountInString、DecodeRuneInString及无效编码的处理。 ↩︎ ↩︎ ↩︎Unicode UAX #29 文本分段,定义字素簇;Go 官方字符串文章讨论字符、码点与不自动规范化的区别。 ↩︎ ↩︎
Go 规范:字符串与 slice、整数之间的转换;字符串不可变与转换结果的语义共同约束后续修改行为。 ↩︎ ↩︎
strconv.Itoa:返回整数的十进制文本。 ↩︎