Skip to content

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 / KotlinRustTSGo
集合变量赋值的语义引用(共享同一对象)[T; N] 是值(T: Copy 时复制)引用(共享同一对象)数组是值,切片共享底层数组
默认可变性对象可变,String 不可变默认不可变可变字符串不可变,切片/map 可变
字符与字符串char 是 UTF-16 单元char 是 Unicode 标量string 是 UTF-16rune 是码点,string 是 UTF-8
隐式类型转换有隐式拓宽无隐式转换宽松无隐式转换(无类型常量除外)
枚举enumenumenum/联合没有 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 6

iota 在每个 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 里已不存在。

小结 ​

  1. Go 默认值语义,赋值传参都拷贝;真正共享的只有带指针的类型:切片、map、channel、函数、接口、指针。
  2. 数组是值(整块拷贝),切片是三元组(指针、len、cap),拷贝描述符不拷贝数据。
  3. append 可能换数组,永远接住返回值;切片是否共享取决于容量。
  4. map:key 要可比较,读不到返零值要用 v, ok 区分,遍历无序,不是并发安全。
  5. 字符串不可变、UTF-8 字节序列,len 数字节,range 数 rune。
  6. type T S 是新类型,type T = S 是别名;结构体字段顺序影响内存占用。
  7. 无类型常量按上下文隐式转型,枚举用 const + iota。
  8. := 在内层块会新建变量遮蔽外层;1.22 起 for 循环变量每次迭代独立。

常见坑 ​

  1. 以为两个切片独立,其实共享底层数组;append 后是否脱钩取决于容量。
  2. 用 len(s) 当字符个数,对中文得到的是字节数。〔TB13 p5-6〕
  3. if x, err := f(); ... 里的 err 遮蔽外层 err,错误被静默丢弃。〔TB11 p1-2〕
  4. 整型溢出悄悄回绕,不报错也不 panic,窄整型做计数时要小心。〔TB12 p5〕
  5. nil map 写入 panic、把遍历顺序当成稳定的:见第三节。

判断题与答案 ​

先自己答,再看后面。

  1. 函数 func f(s []int) { s = append(s, 1) },调用方的切片变了吗?如果 s 的容量还有富余呢?
  2. var m map[string][]int; m["a"] = append(m["a"], 1) 哪一步出问题?
  3. 为什么 x := 1; if true { x := 2 } 之后外层 x 还是 1?
  4. 结构体 struct{ a bool; b int64; c bool } 为什么不是 10 字节?

答案:

  1. 调用方的切片值没变,因为 append 的返回值只赋给了 f 里的局部变量 s。容量有富余时,新元素确实写进了共享的底层数组,但调用方的 len 没变,看不到;容量不足时换新数组,调用方完全无感。
  2. 读 m["a"] 和 append 都没问题(nil map 可读,nil 切片可 append),出问题的是最后的赋值 m["a"] = ...:它要向 nil map 写入,nil map 不可写,直接 panic assignment to entry in nil map。
  3. 内层 x := 2 用 := 在内层块里新建了一个同名变量,遮蔽了外层 x;赋值只发生在内层变量上,外层不受影响。
  4. 编译器为对齐插入填充: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。