Appearance
03 类型与数据:值语义、切片、map、字符串与结构体
详细程度:标准 · 约 45 分钟 材料:TB10 到 TB17、TB11、TB14 · 旧长版:
notes/p1_5-basics.md前置:01 Go 的世界观
先看一个长得像、行为相反的现象
下面两段代码做的事几乎一模一样:声明一个集合,赋给另一个变量,改新变量的第一个元素。唯一区别是类型,一个数组一个切片。先猜猜两行输出,再往下读。
go
package main
import "fmt"
func main() {
a := [3]int{1, 2, 3}
b := a
b[0] = 9
fmt.Println("array:", a, b)
s := []int{1, 2, 3}
t := s
t[0] = 9
fmt.Println("slice:", s, t)
}text
array: [1 2 3] [9 2 3]
slice: [9 2 3] [9 2 3]数组 a 原封不动,切片 s 却被"隔空"改了。b := a 和 t := s 写起来一模一样,行为完全相反,根源在于 Go 默认是值语义:赋值、传参、range 都拷贝。数组是"整块值",拷贝它就是把每个元素复制一遍;切片却是一个小小的描述符,拷贝描述符并不拷贝它指向的那块数据。
所以读任何 Go 代码,第一个问题永远是:这里拷贝了什么,共享了什么。真正"共享"的只有那几个内部带指针的类型:切片、map、channel、函数、指针,还有装着指针的接口,它们被拷贝的是一个小描述符,指向的数据仍是同一份。这一节后面每个坑,都是这个问题的回答。
和你已知语言的差异
| 问题 | Java / Kotlin | Rust | TS | Go |
|---|---|---|---|---|
| 集合变量赋值的语义 | 引用(共享同一对象) | [T; N] 是值(T: Copy 时复制) | 引用(共享同一对象) | 数组是值,切片共享底层数组 |
| 默认可变性 | 对象可变,String 不可变 | 默认不可变 | 可变 | 字符串不可变,切片/map 可变 |
| 字符与字符串 | char 是 UTF-16 单元 | char 是 Unicode 标量 | string 是 UTF-16 | rune 是码点,string 是 UTF-8 |
| 隐式类型转换 | 有隐式拓宽 | 无隐式转换 | 宽松 | 无隐式转换(无类型常量除外) |
| 枚举 | enum | enum | enum/联合 | 没有 enum,用 const + iota |
表里最反直觉的是第一行和第三行:Go 同时有"值"的数组和"引用"的切片,两个长得像的集合语义相反;而 Go 的"字符"不是 char,字符串内部是 UTF-8 编码的字节序列,这决定了取长度和切片的很多坑。下面按值语义、切片、map、字符串、结构体的顺序逐个讲。
一、值语义与零值
先建立最底层的认知:Go 的类型分三类,基本数据类型(数值、字符串、布尔)、复合数据类型(数组、切片、map、结构体等)、接口类型。〔TB12 p1-2〕其中数值类型又分平台无关的和平台相关的,int、uint、uintptr 长度随平台变化,可移植的代码不要依赖它的长度。〔TB12 p4-5〕
类型之间要求"一模一样",没有隐式转换,这是 01 节"显式"那条的延伸。更隐蔽的是整型溢出:结果超出边界就悄悄回绕,不报错也不 panic。int8 的 127 加 1 得 -128,uint8 的 1 减 2 得 255。〔TB12 p5〕所以选整型要看清取值范围,别随手用 int8、uint8 这类窄类型做计数或循环条件。
每个类型都有一个零值,也就是不初始化时自动得到的默认值:数值 0,布尔 false,字符串空串,指针、切片、map、channel、函数、接口为 nil,结构体是各字段零值的组合。Go 的设计主张"零值可用":var mu sync.Mutex 就能直接 Lock,var buf bytes.Buffer 就能直接 Write,不用像 C 那样先调 pthread_mutex_init。〔TB17 p10-12〕
但零值可用有两个例外要记牢,它们是最常见的运行时 panic 来源。先看 nil map 和 nil 切片:
go
package main
import "fmt"
func main() {
var m map[string]int
fmt.Println(m["x"], len(m)) // 读 nil map 没问题
var ns []int
ns = append(ns, 1) // nil 切片 append 没问题
fmt.Println(ns)
}text
0 0
[1]nil map 读和取长度都正常,nil 切片 append 也正常。但 nil map 不能写:
go
var m map[string]int
m["x"] = 1 // panic: assignment to entry in nil map为什么读没事写就 panic?因为 map 的底层哈希表在 nil 时还没分配,读一个不存在的 key 可以直接返回零值,而写入必须往哈希表里插槽,没表可插,运行时只能 panic。map 必须先 make 或字面值初始化才能写。〔TB16 p4-6〕切片则不同,append 发现底层数组是 nil 时自己会去分配,所以零值切片可直接用。
二、数组是值,切片是窗口
数组的长度是类型的一部分,[5]int 与 [6]int 是两个不同类型,不能互传;变量代表整个数组,赋值和传参都是整块拷贝。〔TB15 p2-3〕切片是三元组:指向底层数组的指针、len、cap。〔TB15 p8-9〕拷贝切片只拷贝这个三元组(三个字大小),所以传参开销恒定,但改元素会改到同一个底层数组。〔TB15 p9-10〕开头那个现象就是这两条规则的直接后果。
append 的行为是两段式:容量够,就写进同一个底层数组;容量不够,就分配新数组、把旧数据拷过去,之后新旧切片脱钩。用代码看最清楚:
go
package main
import "fmt"
func main() {
base := make([]int, 3, 4)
sub := base[1:3]
sub = append(sub, 6) // 容量够,写进 base 的底层数组
fmt.Println(base[:4])
sub = append(sub, 7) // 超容量,换新数组
sub[0] = 100
fmt.Println(base[:4], sub)
}text
[0 0 0 6]
[0 0 0 6] [100 0 6 7]sub 从 base 切出,cap 是 3(从下标 1 到数组末尾还有 3 个位置)。第一次 append 只写进第 4 个位置,base 被"顺带"改了;第二次 append 超出容量,sub 换了新数组,之后改 sub[0] 不再影响 base。所以"切片是否共享"取决于容量,不是谁都能一眼看穿。这也是为什么永远要写 s = append(s, ...):append 可能换数组,返回值必须接住,别假定两个切片还共享。
扩容轨迹本机 go1.26.4 实测是 1 2 4 8 16 32 64 128 256 512 848 1280 1792 2560,小容量翻倍,256 之后增长放缓。不要依赖这些具体数字,只需要记住"可能换数组"这一件事。
想让函数改调用方切片的内容,直接传切片(共享底层数组);想让函数增删元素(可能换数组),必须返回新切片,或传指向切片的指针。
三、map
map 是无序的键值对,用 key 唯一关联到一个 value。四条规则直接记住:
- key 必须支持
==比较,切片、map、函数不能当 key。〔TB16 p3〕 v := m[k]查不到返回零值;要区分"不存在"和"值恰为零",用v, ok := m[k]。〔TB16 p8-9〕- 遍历顺序不保证,而且运行时故意让每次遍历的起点随机,逻辑绝不能依赖遍历顺序;要稳定顺序,先把 key 取出来排序。〔TB16 p9-12〕
- map 变量本身只是一个指向运行时结构的描述符(材料称它是引用类型),传参只拷贝描述符,函数内的修改调用方可见。〔TB16 p12〕
另外 map 不是并发安全的:多个 goroutine 同时读写,运行时检测到会直接 fatal error: concurrent map writes(或 read and map write)终止进程,这种 fatal error 不能 recover。要加锁或用 sync.Map(并发章详述)。
关于 map 的底层实现,老课程讲的是"哈希桶加 tophash、每个 bucket 8 个槽、溢出挂链"的版本。〔TB16 p14-15〕但本机 go1.26.4 的实现已经换成了基于 Abseil Swiss Table 的设计:源码头部注释明写 The map design is based on Abseil's "Swiss Table" map design,grep -n "Swiss" $(go env GOROOT)/src/internal/runtime/maps/map.go 就能看到。用法与语义不变,桶结构那套细节不用背。
四、字符串、字节与 rune
Go 原生支持 string 类型,和 C 的"以 \0 结尾的字符数组"划清界限。字符串不可变,s[0] = 'k' 编译不过,但可以整体重新赋值;没有结尾 \0,len 是 O(1);源码和内存里都是 UTF-8 编码。〔TB13 p3-6〕它在内存里是一个 {Data, Len} 描述符,传参只拷贝描述符,不拷贝内容。〔TB13 p10-12〕
最容易错的是"长度"有两种视角:
go
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
str := "中国人"
fmt.Println(len(str), utf8.RuneCountInString(str), str[0])
for i, r := range str {
fmt.Print(i, ":", string(r), " ")
}
fmt.Println()
}text
9 3 228
0:中 3:国 6:人len 是字节数("中国人"三个汉字各 3 字节),s[i] 取到的是单个字节(228 是"中"的第一个字节),range 才按 rune 迭代,索引是字节偏移 0、3、6。〔TB13 p5-6〕〔TB13 p12〕rune 是 int32 的别名,代表一个 Unicode 码点。所以处理中文、emoji 时别用下标切字符串,会从多字节字符中间切断。
五、结构体、定义类型与别名
结构体把多个字段聚合成一个实体。在定义结构体之前,先分清自定义类型的两种写法:type T S 定义一个新类型,与 S 是两个类型,只有底层类型相同才能显式转换;type T = S 是别名,完全等价,多用在大规模渐进式重构时给类型改名。〔TB17 p2-5〕比如 type MyInt int 和 int 底层类型相同,能 int(x) 互转,但 type MyInt string 和 int 底层类型不同,int(x) 直接编译不过。
初始化结构体推荐带字段名 T{Field: v}:顺序无关,以后新增字段不会破坏旧代码;常写 T{} 而少用 new(T)。〔TB17 p13-14〕空结构体 struct{} 占 0 字节,常拿来做 channel 里的"事件"信号,只传信号不传数据。〔TB17 p6-7〕结构体不能递归包含自身,除非通过指针、切片、map 间接包含。〔TB17 p8-9〕
最后是字段顺序影响大小这件事,很多人第一次听说时会愣一下:
go
package main
import (
"fmt"
"unsafe"
)
type Bad struct {
a bool
b int64
c bool
}
type Good struct {
b int64
a bool
c bool
}
func main() {
fmt.Println(unsafe.Sizeof(Bad{}), unsafe.Sizeof(Good{}))
}text
24 16两个结构体字段完全一样,只是顺序不同,Bad 占 24 字节,Good 占 16。原因是编译器为了对齐,会在字段间插入填充:int64 必须放在 8 的倍数地址上,Bad 里 bool 后面要补 7 字节再放 int64,末尾再补 7 字节凑成 8 的倍数。〔TB17 p16-17〕〔TB17 p17-19〕对热路径上的大量小结构体,调一下字段顺序是免费的内存优化。
六、常量与 iota
常量在编译期求值,运行时不会变,并发访问不需要同步。〔TB14 p2〕Go 在常量上有两个"创新"。一是无类型常量:不写类型的常量可以按上下文隐式转成别的类型,转换发生在编译期,所以不破坏类型安全;转不了(如溢出 int8)就编译报错。〔TB14 p4-6〕这让 const n = 13 能和 int8、int64 等混用而不用写显式转型,但换成会溢出的常量值编译器立刻拦下。
二是用常量实现枚举。Go 没有 enum 关键字,枚举用 const 块加 iota:
go
package main
import "fmt"
type Weekday int
const (
Sunday Weekday = iota
Monday
Tuesday
Wednesday
Thursday
Friday
Saturday
)
func main() {
fmt.Println(Sunday, Monday, Tuesday, Saturday)
}text
0 1 2 6iota 在每个 const 块里从 0 开始,每行加 1,后续行隐式重复前一行的表达式,所以 Monday、Tuesday、Saturday 依次是 1、2、6;每个 const 块独立计数。〔TB14 p8-12〕
七、作用域与遮蔽
作用域在编译期决定,外层声明的标识符对内层可见。真正咬人的是 :=:它在内层块里会新建变量,遮蔽外层同名变量,这是最阴险的低级 bug。〔TB11 p1-2〕〔TB11 p4〕
go
package main
import "fmt"
func main() {
x := 1
var err error
if _, err := fmt.Sscan("12", &x); err != nil {
// 这里的 err 是 if 语句里的新变量
}
fmt.Println(x, err)
}text
12 <nil>x 被改成了 12(Sscan 真的写进了 &x),但外层 err 还是 nil,因为 if 里的 := 声明了一个新的 err,遮蔽了外层的,错误被静默丢弃。这类 bug 难查就在于代码看起来"确实处理了 err",其实处理的是另一个变量。
隐式代码块不止花括号:if、for、switch 自身各有一层,switch 的每个 case 又各有一层。〔TB11 p3〕预定义标识符(new、len、true 等)不是关键字,可以被遮蔽:type new int 之后 new(int) 就编译不过。〔TB11 p4-5〕实践上两条:不要用预定义名当变量或类型名;函数保持短小,缩短遮蔽的查找范围。
Go 1.22 起 for 循环的变量每次迭代新建一份,闭包捕获不会再拿到同一个变量(前提是该 module 的 go.mod 里 go 行不低于 1.22;老 module 保持旧行为):
go
package main
import "fmt"
func main() {
var fs []func()
for i := range 3 {
fs = append(fs, func() { fmt.Print(i, " ") })
}
for _, f := range fs {
f()
}
fmt.Println()
}text
0 1 2老教程里"循环变量被所有 goroutine 共享、闭包都打印最后一个值"的坑,在 go.mod 声明 1.22 及以上的 module 里已不存在。
小结
- Go 默认值语义,赋值传参都拷贝;真正共享的只有带指针的类型:切片、map、channel、函数、接口、指针。
- 数组是值(整块拷贝),切片是三元组(指针、len、cap),拷贝描述符不拷贝数据。
- append 可能换数组,永远接住返回值;切片是否共享取决于容量。
- map:key 要可比较,读不到返零值要用
v, ok区分,遍历无序,不是并发安全。 - 字符串不可变、UTF-8 字节序列,
len数字节,range数 rune。 type T S是新类型,type T = S是别名;结构体字段顺序影响内存占用。- 无类型常量按上下文隐式转型,枚举用 const + iota。
:=在内层块会新建变量遮蔽外层;1.22 起 for 循环变量每次迭代独立。
常见坑
- 以为两个切片独立,其实共享底层数组;append 后是否脱钩取决于容量。
- 用
len(s)当字符个数,对中文得到的是字节数。〔TB13 p5-6〕 if x, err := f(); ...里的err遮蔽外层err,错误被静默丢弃。〔TB11 p1-2〕- 整型溢出悄悄回绕,不报错也不 panic,窄整型做计数时要小心。〔TB12 p5〕
- nil map 写入 panic、把遍历顺序当成稳定的:见第三节。
判断题与答案
先自己答,再看后面。
- 函数
func f(s []int) { s = append(s, 1) },调用方的切片变了吗?如果s的容量还有富余呢? var m map[string][]int; m["a"] = append(m["a"], 1)哪一步出问题?- 为什么
x := 1; if true { x := 2 }之后外层x还是 1? - 结构体
struct{ a bool; b int64; c bool }为什么不是 10 字节?
答案:
- 调用方的切片值没变,因为
append的返回值只赋给了f里的局部变量s。容量有富余时,新元素确实写进了共享的底层数组,但调用方的len没变,看不到;容量不足时换新数组,调用方完全无感。 - 读
m["a"]和append都没问题(nil map 可读,nil 切片可 append),出问题的是最后的赋值m["a"] = ...:它要向 nil map 写入,nil map 不可写,直接 panicassignment to entry in nil map。 - 内层
x := 2用:=在内层块里新建了一个同名变量,遮蔽了外层x;赋值只发生在内层变量上,外层不受影响。 - 编译器为对齐插入填充:
int64要对齐到 8 的倍数,bool后面补 7 字节,末尾bool后再补 7 字节凑成 8 的倍数,实际是 24 字节,不是 1+8+1=10。
在项目里
dnsproxi-go/cache/cache.go里Cache用map[Key]*Entry、slot map[Key]int、access map[Key]uint64三张表,Key是自定义结构体当 map key(可比较)。dnsproxi-go/wire/wire.go处理 DNS 报文的字节切片:ParseHeader(b []byte)直接读传入切片,ParseName则把各标签append进新切片,一处是视图一处是拷贝,可以对照着看。dnsproxi-go/main.go的Config把环境变量读成结构体,loadConfig用envOr补默认地址,各功能开关为空或 0 即关闭。
想深入:正文之外的干货
TB10 变量声明的选择规则
- 变量声明的本质是向编译器提供内存边界信息:静态语言编译器不会自己推断,动态语言解释器在运行时按赋值分析,还能随时改变边界。〔TB10 p1-2〕
- 通用声明是四段式
var a int = 10。省类型的var b = 13按初值定默认类型(整型 int、浮点 float64、复数 complex128),这种糖必须同时赋初值,var b单独写编译不过。〔TB10 p3-6〕 - 包级变量只能用 var,不能用
:=;要显式类型时惯用法是转型写法var b = int32(17),而不是var b int32 = 17,这样同一个 var 块里声明形式统一。〔TB10 p7-9〕 - 局部变量反过来:延迟初始化只能
var err error,声明即初始化就用短声明a := int32(17);分支控制变量直接写进 if/for 头部,标准库strings.LastIndexAny的if as, isASCII := makeASCIISet(chars); isASCII是范本。〔TB10 p10-12〕 - 短声明
a := 12再省掉 var,类型仍由编译器推导,也支持一行多声明a, b, c := 12, 'A', "hello";两颗糖只是去掉显式类型信息,声明的本质没变。〔TB10 p6〕 - 就近原则:声明尽量靠近首次使用处,这是作用域最小化的手段。包级变量只被一个方法用,就紧邻该方法声明(http.ErrNoCookie 就贴着 Cookie 方法);多处使用才放文件头部。〔TB10 p10〕
- 类型写在变量名后面不是标新立异,官方博客 Declaration Syntax 的解释:相比 C,复杂声明(函数指针等)从左往右就能读顺,不用由里向外拆。〔TB10 p15-17〕
TB11 五层隐式代码块
- 代码块分显式和隐式,隐式共五层,按范围从大到小:宇宙块、包块、文件块、if/for/switch 自身的隐式块、switch/select 每个 case 子句各成一块。控制语句的隐式块在它显式大括号之外,所以 switch 头部短声明的变量比 case 里看到的更外层。〔TB11 p3-4〕
- 宇宙块住着预定义标识符(true、len、new、iota 等),它们不是关键字,内层同名声明就能遮蔽;文件块里最常见的标识符是导入的包名,同包两个源文件都要用包 B,就各自 import 一遍。〔TB11 p4-6〕
- 接收器、参数、命名返回值没被大括号包裹,但作用域是整个函数体块,不是更小。〔TB11 p6-7〕
- if/else if 链的隐式块层层嵌套,每个 if 条件里声明的变量在后面所有 else if 和 else 分支里都可见:
if a := 1; false {} else if b := 2; false {} else { println(a, b, c) }能编译,a、b、c 在最内层都有值。这是配合err != nil惯用法的刻意设计,但和 switch 隐式块叠加短声明就成了遮蔽陷阱。〔TB11 p7-8,10〕 - 检测只能兜底:go vet 从 1.14 起默认不含遮蔽检查,要单独装 shadow 插件(
go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@latest)再go vet -vettool=$(which shadow) -strict;课件里的三连遮蔽案例它只报出 err 一处,不能全信。〔TB11 p10-11〕 - 包块里的标识符是包顶层声明的常量、类型、变量、函数(不含方法);跨包只能用导出标识符,条件有二:声明于包块(或字段名/方法名)且首字母大写,缺一不可。〔TB11 p5〕
- 短变量声明撞上控制语句是遮蔽高发区,肉眼不易识别,这正是 shadow 工具存在的理由。〔TB11 p10〕
- 同代码块里重名是"重声明",语法直接禁止;不同代码块里同名是"重名",各自独立,内层的新变量遮住外层的。两个词不是一回事,读报错信息时别混。〔TB11 p13〕
- 老课件里
type new int加赋值的写法在 Go 1.17 起已经编译报错(cannot use new value as type int),要遮蔽预定义 new 得写成别名type new = int;翻老教程遇到这段代码要知道它过时了。〔TB11 p13〕
TB12 数值类型的底层细节
- 有符号与无符号的本质差别是最高位是否解释为符号位;Go 整型用补码编码,负数是"原码逐位取反加 1",不能把最高位简单当负号读。同一比特序列按 uint8 读是 129,按 int8 读是 -127。〔TB12 p2-3〕
- Go 1.13 起字面值支持二进制
0b10000001、新八进制0o700和数字分隔符5_3_7(要求 go.mod 的 go 行不低于 1.13);按进制输出用 %b、%o、%O、%x、%X。〔TB12 p6-7〕 - 浮点只有 float32 和 float64,没有 float,全是平台无关的。内存表示按 IEEE 754 分符号位、阶码、尾数,float32 是 1+8+23 位,float64 是 1+11+52 位,阶码 = 指数 + 偏移值(float32 偏移 127)。〔TB12 p7-10〕
- float32 尾数只有 23 位,2^24=16777216 能精确表示,下一个可表示的数是 2^24+2,16777217.0 会被舍回去,所以
f1 == f2是 true。日常用 float64;金钱计算用最小货币单位(分)的整数,0.1+0.2 不等于 0.3 同源。〔TB12 p11〕〔TB12 p18-20〕 - 浮点字面值有十进制直白形式(.15、82.,前导 0 和末尾 0 都可省)和科学计数法两种,科学计数法还分十进制底数(1.5e-3)与十六进制底数(0x2p4);写出来有时都猜不到真实值。〔TB12 p11-13〕
- 数值类型小结:
type MyInt = int32别名与原类型完全等价可直接互赋,主要场景是重构;type MyInt int32是新类型,数值性质相同但不能直接混算。〔TB12 p14-15,17〕 - 自定义数值类型的两个动机:一是给原生类型建立更高级的抽象,二是原生类型(int、float64 这些)不能附加方法,想要方法只能先定义新类型。〔TB12 p19〕
- 十进制浮点手工转二进制是四步:整数小数分别转、小数点归一到 1.xxx、指数加偏移值算阶码、拼符号阶码尾数;日常只读不写,知道流程即可。〔TB12 p9-10〕
TB13 字符串操作的隐性开销
- 不可变换来的好处:字符串可以随便跨 goroutine 共享,不用加锁;相同的字符串值编译器只分配一块存储。〔TB13 p3-4〕
- 代价是所有"修改"类操作都要分配新内存:string 与 []byte、[]rune 的每次双向转型都拷贝,热路径上反复转是隐性开销。〔TB13 p15-16〕
- 拼接用 +/+= 体验最好但不是性能最优,大量拼接用 strings.Builder 或 strings.Join;留言区共识是 fmt.Sprintf 通常最差,以自己的 benchmark 为准。〔TB13 p14-17〕
- 比较是逐字节字典序:长度不同直接判不同,长度相同先比数据指针是否同一块底层存储,再比内容。Go 的 == 比内容,Java 的 == 比对象同一性,拿 Java 语义套 Go 会踩坑。〔TB13 p14-15〕〔TB13 p18-20〕
- 想窥视底层结构,老材料用 reflect.StringHeader{Data, Len} 加 unsafe.Pointer;该结构体自 Go 1.20 起弃用,现行写法是 unsafe.StringData(切片配 unsafe.SliceData)。〔TB13 p10-12〕
- string 的价值就在"统一抽象":字符串常量、变量、字面值的类型都归一为 string,编译器替你做类型校验、防缓冲区溢出、求长 O(1),C 手搓字符数组的五类缺陷一次清掉。〔TB13 p1-2〕
- 字符串有两种迭代结果:常规
for i := 0; i < len(s); i++是字节视角,逐个吐 UTF-8 字节;for range是字符视角,每轮给码点值和该字符起始字节偏移(0/3/6);数字符个数用 utf8.RuneCountInString。〔TB13 p12-14〕 rune在 builtin 里就是type rune = int32的别名定义,一个 rune 实例即一个 Unicode 字符;字符字面值还能写成 '\u4e2d'、'\x27'、'\047' 多种形式,彼此等价。〔TB13 p6-7〕
TB14 iota 的技巧与边界
- 对比 C 就懂 Go 常量:宏常量类型不安全、调试时无法按名查看;C 的 const 本质还是变量,连数组声明长度都当不了。Go 常量编译期求值还有个收益:常量表达式里的整数除零、字符串索引越界,编译期就报错。〔TB14 p3〕〔TB14 p12〕
- C 枚举"首常量 0、后续加 1"被拆成两个可独立组合的特性:隐式重复上一行表达式,加 iota 行偏移指示器。位标志
1 << iota、任意起始值、跳值都靠这两个组合出来。〔TB14 p7-9〕 - 略值技巧:首行写
_ = iota让枚举从 1 开始(syscall 包的 IPV6_V6ONLY 就是这么写的);中段用_占位跳过某个值。〔TB14 p9-10〕 - iota 有边界:syscall 编号、错误码、颜色 rgba 值这类"值即标准"的常量用显式字面值,方便对照文档;API 发布后不能往枚举中部插入,插入会改掉已发布的值,只能追加。〔TB14 p12-15〕
TB15 数组与切片的补充
- 数组类型的两个属性是元素类型和长度,声明
var arr [N]T里 N 必须是整型字面值或常量表达式,编译期就要定死。〔TB15 p2〕 - 数组变量在 C 里会退化成指向首元素的指针,Go 里变量代表整个数组,迭代与传参都是纯值拷贝,开销随长度增长;这是切片被引入的直接原因。〔TB15 p6-7〕
- 数组占一整块连续内存,总大小等于各元素大小之和;下标越界或负数,编译器直接报错。〔TB15 p3-5〕
- make([]byte, 6, 10) 建切片时第二参是 len、第三参是 cap,省略 cap 时 cap 等于 len。〔TB15 p9〕
- 数组初始化有两个省事写法:
[...]int{21, 22, 23}让编译器数长度;稀疏数组用下标赋值[...]int{99: 39},得到 [100]int,其余元素是零值。〔TB15 p4〕 - 完整的切片化语法是三索引
arr[low:high:max]:len 是 high-low,cap 是 max-low,省略 max 时默认取到数组末尾。正文里base[1:3]的 cap 是 3,就是 max 省略后的结果。〔TB15 p9-10〕 - nil 切片和空切片不是一回事:
var s1 []int是 nil(s1 == nil 为 true),s2 := []int{}已初始化(== nil 为 false),JSON 序列化等场景两者行为有差异。〔TB15 p15-17〕 - 大多数场合用切片替代数组:传递开销恒定,还带下标访问、边界校验、动态扩容;Go 指针不支持算术运算,数组指针给不了这些。〔TB15 p14-15〕
- 一个底层数组可以被多个切片同时"开窗",任一切片改元素,其他切片立刻可见(
sl2[2] = 14后sl1[0]也是 14,两处指向同一个 arr[3]);基于切片再切片同理。所谓"独立切片"只在各自扩容换数组之后才成立。〔TB15 p11〕 - 扩容的具体动作:len 追平 cap 时 append 在运行时重新分配底层数组并拷贝旧元素,从 nil 切片逐个 append 的容量轨迹是 1、2、4、4、8(本例 int 元素新容量翻倍),旧数组随后被 GC。翻倍只适用小容量,别把它当语言承诺。〔TB15 p11-13〕
- 解绑是双向的:切片触到容量上限触发扩容后才和原数组脱钩,脱钩前 append 会写回原数组,脱钩后改切片元素原数组也不再跟着变。正文 base/sub 那段代码就是这两步的完整演示。〔TB15 p13-14〕
TB16 map 的几条硬约束
- delete(m, key) 是唯一删键方法,键不存在也不报错;map 不能调用 cap,这是与切片的一个不同。〔TB16 p7〕〔TB16 p9〕
- 语法层的 map 操作都会被编译器重写成运行时调用:make 到 runtime.makemap、m[k]=v 到 mapassign、v := m[k] 到 mapaccess1、v, ok := m[k] 到 mapaccess2、delete 到 mapdelete。〔TB16 p13〕
&m[key]编译期就报 cannot take the address of m[key]:扩容会搬动 value,持有地址不安全,这条禁令由编译器强制。〔TB16 p20〕- 扩容的代价被摊平:元素数超过负载因子上限或溢出桶过多时触发扩容,搬迁不是一次做完,而是挂在后续每次 assign/delete 上渐进进行,扩容期间新旧数组并存,查找要双查。〔TB16 p18〕
TB17 结构体的初始化与填充
- 结构体类型字面值
type T struct { Field1 T1; ... FieldN Tn },字段名在一个结构体内必须唯一。〔TB17 p5-6〕 - 按字段顺序写复合字面值有三个问题:字段增删要同步改初始化代码、字段多时容易错位、含非导出字段直接编译错;go vet 的 composites 规则专门警告这种写法,官方不推荐。〔TB17 p12-13〕
- 非导出字段零值不可用(如 time.Timer 内部的 runtimeTimer)或初始化逻辑复杂时,给类型配专用构造函数
func NewT(...) *T,把复杂逻辑和未导出字段都收进函数内部,标准库大量用这个模式。〔TB17 p14-15〕 - 对齐分两个阶段:先逐字段对齐(int64 必须落在 8 的倍数地址),再把总大小圆整到对齐系数的倍数。尾部也要补是为了数组:数组元素连续存放,T[1] 的地址等于 T[0] 地址加 sizeof(T),不圆整则后续元素全错位。早期 Sparc 遇到未对齐地址直接段错误,x86-64 不崩但存取变慢。〔TB17 p17-20〕
- 嵌入字段只写类型不写名:Book 里写一个 Person,既能 book.Person.Phone 也能用语法糖 book.Phone,转发机制在 04 节展开。〔TB17 p7-8〕
- 想进一步控制布局可以主动填充:用空标识符
_当字段名占位,runtime 的 mstats 里就有_ [1 - _NumSizeClasses%2]uint32,为的是 32 位平台上把后面的字段对齐到 64bit,好做原子操作。〔TB17 p20-21〕
以上省去的:TB16 的哈希桶内部细节(正文已注明 go1.26 换 Swiss Table,不用背)、TB13 与 TB17 的字符/字符串字面值形式和 time.Timer 内部字段(现行源码已改)、TB12 的复数与浮点手工推导、TB15 的多维数组拆解与 TB15 的 zerobase 实现细节(运行时实现,非语言承诺),均为选读、过时或与正文重复。
想深入去哪查
- 类型与零值:TB12、TB17;字符串:TB13;常量与 iota:TB14;数组与切片:TB15;map:TB16;作用域:TB11;变量声明:TB10。
- 旧长版:
notes/p1_5-basics.md(含 Rust 对照与练习预测题)。 - 切片与字符串的更底层结构(
StringHeader、unsafe窥视):TB13 p10-12、TB15 p8-10。