Appearance
01 Go 的世界观与差异总表
详细程度:标准 · 约 40 分钟 材料:TB00、TB01、TB02、TBX01、TB19、TB20、TB21、TBX08 · 旧长版:
notes/p1-toolchain.md、notes/p2-lang-core.md前置:任意一门主流语言
先看一个让人皱眉的现象
下面这段代码,写过 C 或 Java 的人都会觉得毫无问题,但它就是编译不过。先别往下翻,猜一下报错写的是什么。
go
package main
import "fmt"
func main() {
var a int16 = 5
var b int = 8
fmt.Println(a + b)
}text
invalid operation: a + b (mismatched types int16 and int)在 C 里,a + b 会把 int16 悄悄提升成 int 再相加,程序员不用表态;Go 却让你必须先把两个数变成同一类型。第一次碰到的人多半会骂"这语言怎么这么轴"。但它不是没能力做自动转换,而是故意不做。这条线贯穿了 Go 的整个设计,源头是五条设计哲学:简单、显式、组合、并发、面向工程。〔TB02 p2〕
这五条是后面每一章的判断尺子。以后你遇到任何"为什么 Go 不提供 X"的问题,先回到这里问一句:X 是不是违反了其中一条。绝大多数时候答案是"是"。
和你已知语言的差异
先把 Go 放在你熟悉的语言旁边,看一张总表。表里每一行都会在本节或后面的章节展开,现在只需要扫一遍,感受差异的密度。
| 维度 | Java / Kotlin | Rust | TS / JS | Go |
|---|---|---|---|---|
| 类型检查 | 静态强类型 | 静态强类型 | 静态(擦除),运行时动态 | 静态强类型,变量类型不可变〔TBX01 p2-4〕 |
| 复用手段 | 继承、默认方法 | trait、泛型 | 继承、mixin | 嵌入加接口,无继承 |
| 错误 | 异常(checked 与 unchecked) | Result 与 ? | throw 与 try | 返回 error 值,panic 只留给"程序即将崩溃"〔TB02 p14〕 |
| 并发 | 线程、虚拟线程 | 线程、async 运行时 | 事件循环 | goroutine 加 channel,运行时调度 |
| 内存 | 分代 GC | 所有权,无 GC | GC | 并发 GC,无分代 |
| 包管理 | Maven、Gradle | Cargo,取最新兼容版本 | npm,取最新匹配版本 | module,最小版本选择〔TB06 p12-14〕 |
| 泛型 | 擦除 | 单态化 | 擦除 | 1.18 起支持〔TBX01 p11〕 |
| 工具链 | 多个工具拼接 | cargo 一体 | tsc、eslint、prettier、jest 拼接 | 一个 go 命令覆盖构建、测试、格式、剖析〔TB02 p10〕 |
| 可见性 | public/private | pub | export | 标识符首字母大写即导出〔TB04 p7〕 |
| 默认参数、重载 | Java 有重载无默认参数,Kotlin 两者都有 | 两者都没有(泛型和宏另说) | 默认参数有,重载只有签名声明 | 两者都没有,默认参数是故意不做的〔TB02 p9〕 |
表里最扎眼的是最后一行:默认参数和函数重载,Go 一个都不给。这不是"还没做",是作者在 API 设计上划的一条线,第五节会讲为什么。
一、五条哲学从哪来
Go 不是凭空"想简单一点",它是被 Google 内部的 C++ 逼出来的。2007 年,Rob Pike 等三人在一次 C++ 编译要等近一个小时的间隙里,聊出了这门新语言的雏形:C++ 太复杂、编译太慢、并发支持又弱,他们想要一门写起来快、构建快、能天然利用多核的语言。〔TB01 p2-3〕第二天三人在会议室又细聊了一轮,会后格瑞史莫发出了一封题为 "prog lang discussion" 的邮件,对这门语言的功能特性做了第一轮归纳,成了它的第一版设计稿。〔TB01 p3〕
所以这五条哲学不是口号,是对一组真实痛点的回答。把每条落在语言里的证据列出来:
| 哲学 | 含义 | 语言里的证据 |
|---|---|---|
| 简单 | 做减法,少而足够,保持生产力 | 25 个关键字;没有类、构造函数、继承、结构化异常〔TB02 p2-3〕 |
| 显式 | 没有暗箱操作,开发者要知道每个操作 | int16 与 int 不能直接相加;错误经返回值上抛〔TB02 p3-5〕 |
| 组合 | 降低耦合,靠组合而非层级扩展 | 类型之间没有父子关系;接口隐式满足;包独立〔TB02 p5-8〕 |
| 并发 | 面向多核,并发是结构设计方法 | goroutine、channel、select 内建;栈初始 2KB〔TB02 p8〕 |
| 面向工程 | 把构建慢、依赖乱、代码难读这类问题纳入语言设计 | 未使用的导入无法编译;禁止包循环依赖;故意没有默认函数参数;gofmt〔TB02 p9-10〕 |
注意"简单"和"显式"的分工:简单管"东西少",显式管"剩下的东西必须看得见"。两者合起来,就是你开头遇到的 int16 + int 报错。
二、显式:回到开头的那个报错
C 编译器看到 a + b 会自动做整数提升,程序员不用表态;Go 认为这个"体贴"是暗箱操作:你以为在算 int16,编译器偷偷换成了 int。显式哲学的意思就是,任何会影响代码行为的转换,都要你自己写出来。〔TB02 p4-5〕
改法只有一行,把你想要的语义说清楚:
go
var a int16 = 5
var b int = 8
fmt.Println(a + int16(b)) // 或 int(a) + b,取决于你想要谁的类型多写一个转换,换来的是读代码的人(包括三个月后的你)不用猜中间发生了什么。Rust 在这个点上和 Go 站同一边:类型转换同样必须显式;区别是 Rust 用 as 或 From,Go 用 T(x) 这种内置转换。
与显式一脉相承的是 Go 的静态强类型:一个变量声明成什么类型,就不能再改。var a int = 1 之后再 a = "hello" 编译不过,因为类型在编译期就定死了。〔TBX01 p2-4〕Python 的变量可以先存整数再存字符串,Go 不提供这个自由度。
显式哲学的第二落点是错误处理。Go 没有异常机制,函数/方法的错误通过 return 显式返回,调用方拿到一个普通的值,处理不处理、在哪处理都摆在明面上,没有 throw 那种隐式向上传播的控制流。〔TB02 p5〕社区提出过好几个新的错误处理方案,Tony Bai 的说法是这些方案或多或少都带隐式成分,被 Go 团队一一否决,这与显式哲学不无关系。〔TB02 p5〕错误处理的具体写法,见 05 节。
三、组合:没有继承,靠什么扩展
Java/Kotlin 程序员建模的第一反应是定义基类,然后继承。Go 里没有 extends,类型之间也没有父子关系,每个类型都是一座孤岛。〔TB02 p5-6〕扩展能力的办法是把这些孤岛"拼"起来:结构体里嵌入别的类型做垂直扩展,函数参数写成接口类型做水平扩展。〔TB02 p6-7〕
垂直方向靠类型嵌入:在结构体里只写类型名、不写字段名,就把那个类型已有的方法"借"进新类型。它是编译器生成的转发,不是继承,被嵌入的类型和新类型之间没有任何父子关系,甚至互相不知道对方存在。〔TB02 p6〕
水平方向靠接口。最直观的一点是:Go 的类型实现接口不需要写任何关键字,方法集合覆盖了就算。看下面这段:
go
package main
import "fmt"
type Greeter interface {
Greet() string
}
type Cat struct{ Name string }
func (c Cat) Greet() string { return "meow, I'm " + c.Name }
type Dog struct{ Name string }
func (d Dog) Greet() string { return "woof, I'm " + d.Name }
func greetAll(gs []Greeter) {
for _, g := range gs {
fmt.Println(g.Greet())
}
}
func main() {
greetAll([]Greeter{Cat{"Tom"}, Dog{"Rex"}})
}text
meow, I'm Tom
woof, I'm Rex注意 Cat 和 Dog 从头到尾没有声明自己"实现"了 Greeter,它们只是恰好有 Greet() string 这个方法;greetAll 要的也只是"能 Greet"这个能力,不管传进来的是什么。对比 Java:你得先定义 interface Greeter,再让 Cat implements Greeter,实现方必须知道接口的存在;Go 把"声明实现"这一步整个删掉了。〔TB02 p5〕这就是组合加接口的用法,第 04 节要展开的全部内容。
四、并发:语言内建,不是库
2007 年前后 CPU 主频涨不动了,性能竞争转向多核。Go 从设计第一天就把"面向多核"当原则,所以 goroutine、channel、select 是语言的一部分,不是某个库。〔TB02 p8〕
go
package main
import "fmt"
func main() {
ch := make(chan string)
go func() { ch <- "hello from goroutine" }()
fmt.Println(<-ch)
}text
hello from goroutinego 是关键字,chan 是内建类型,<- 是运算符,全都不用 import。goroutine 是用户态轻量线程,运行时默认给每个 goroutine 只分配 2KB 栈,切换也不陷入内核,所以一个程序里开成千上万个 goroutine 很常见。〔TB02 p8〕(补一句本机验证过的细节:自 Go 1.19 起运行时会按历史平均栈用量调整新 goroutine 的初始栈大小,runtime/stack.go 里有 adaptivestackstart,所以 2KB 是起点和下限,不是永远固定的值。)而且所有 Go 代码都在 goroutine 里执行,连运行时的代码也不例外,goroutine 不是"另开的线程",而是代码运行的基本载体。这个设计和 Rust、JS 都不同:Rust 要自己选 async 运行时(tokio、async-std 之类),JS 是单线程加事件循环,Go 则是语言和运行时一体化提供并发,用户不用挑。
这里要提醒一句:goroutine 便宜,不等于免费。每个 goroutine 都要有退出路径,否则就是泄漏,第 06 节会展开。并发也不只是语法问题,它是一种程序结构设计方法:把独立计算拆开,再用 channel 和 select 把它们连接起来。〔TB02 p8〕
五、面向工程:把工程问题写进语言
很多语言把"构建慢、依赖乱、代码难读"当成使用层面的事,Go 的设计者不这么看,他们把这些工程问题直接写进了语言和工具的设计里。〔TB02 p9〕
未使用的导入是编译错误,不是警告。 下面这个程序直接编译失败:
go
package main
import (
"fmt"
"os"
)
func main() {
fmt.Println("x")
}text
"os" imported and not used为什么这么严?因为 Go 要保证任何程序的依赖树是精确的:你 import 了就一定用了,构建时就不会把没用到的包也编译进来,大工程的增量构建才快得起来。〔TB02 p9〕
故意没有默认函数参数。 理由是一条工程观察:很多开发者会靠默认参数往函数上堆越来越多的形参,来掩盖 API 设计本身的缺陷,结果函数参数越堆越多,清晰度下降。一刀切掉这个机制,逼你把 API 设计得参数更少、更直接。〔TB02 p9〕
配套的是工具链:gofmt 统一代码风格,别人在争论缩进和换行的时候,Go 团队已经把答案定死了;go build、go test、go doc、性能剖析都收在同一个 go 命令下,不用像 JS 那样自己拼一堆工具。〔TB02 p10〕
六、几个值得多想一步的取舍
世界观这一节的价值,不在背下五条口号,而在理解几个具体取舍。它们平时不太显眼,但会在你写 Go 的第一周反复出现。
没有异常,不等于没有 panic。 panic 表示的是"程序即将崩溃"这种严重状态,不是用来做常规错误处理的。错误是常态,走返回值;API 内部如果可能 panic,应该自己 recover 并转成 error 返回,不要把它抛给上层。〔TB02 p14〕
运行时随二进制一起交付。 垃圾回收、并发调度、栈管理都在 runtime 里,runtime 会和你的源码一起编译并链接进目标程序,哪怕一个 hello world 也带着它。〔TB01 p12-13〕代价是二进制体积更大;收益是部署简单,纯 Go 程序(不用 cgo)默认静态链接,拷一个文件过去就能跑。
有兼容性承诺,也自带电池。 Go 1.0(2012)起,官方承诺符合 Go1 规范的源码向后兼容,十年前的代码今天还能编译;配合简单哲学,这十年新增的语言特性也屈指可数。〔TB01 p7〕〔TB00 p3〕标准库方面,net/http、crypto、encoding 能直接撑起生产级服务,第三方依赖可以少很多。〔TB02 p9-10〕
标准库是学写法的范本。 http.HandlerFunc 是 Russ Cox 利用"函数是一等公民且能挂方法"这个特性设计的;io.Reader/io.Writer 奠定了 Go 的 I/O 结构。想学地道的 Go 写法,多读标准库。〔TB01 p5〕〔TB00 p12〕
它的自我定位是服务器端。 Go 创建的最初目的,就是构建流行的、高性能的服务器端编程语言,用来替代当时主流的 Java 和 C++;在带 GC 和运行时的语言里,它的性能名列前茅。〔TB00 p4〕这个定位决定了后面所有章节的语境:你在写的是后端服务,不是脚本。
简单哲学的回报是极低的门槛。 Tony Bai 回忆 2011 年他用 Rob Pike 的教程,一天就学完了 Go 的全部语法,一周就能写简单实用的小程序。〔TB00 p3〕这不是吹嘘,而是"简单"这条哲学的直接结果:关键字少、特性克制,换来的就是入门快,代价则是很多基础设施要自己搭。
语言叫 Go,不叫 golang。 初学者常把它写成 golang,其实 "golang" 只是官网域名的拼法(当时 go.com 被占用),语言本身就叫 Go。〔TB01 p4〕
七、迁移者最容易犯的七个错误
Tony Bai 把"用别的语言的思维写 Go"叫作"形神皆丧",并指出另一个更根本的问题是没建立"设计意识":能不能选对语言元素,搭出程序的骨架。尤其要弄清不同语言元素所在的层次,否则只能一直停留在 hello world 的世界里。〔TB00 p6〕
七个高频错误,逐个对号入座:
- 用类和继承建模:改成小结构体、小接口、嵌入,见 04 节。
- 用 panic 当异常:错误是常态,走返回值,见 05 节。
- 把 goroutine 当免费的:创建便宜,但每个都要有退出路径,见 06 节。
- 提供方先写接口:Go 的接口由使用方定义,见 04 节。
- 给每个字段写 getter 和 setter:Go 习惯直接访问字段,只在需要不变量时加方法。〔TBX04 p11-12〕
- 想要最新版依赖:Go 取满足所有依赖方要求的最小版本,构建可重现,见 02 节。
- 先找大而全的框架:先看标准库够不够。
给新语言做体检也有个快办法,不必从头读语法书:先问它强类型吗?静态还是动态?内建类型有哪些?有结构体、字典、泛型吗?〔TBX01 p1-2〕回答完这些再读规范,比从头啃快得多。Go 的答案是:静态强类型,原生复合类型(数组、切片、map、结构体、channel),泛型有但克制。
小结
- Go 的每个"缺失"都是刻意取舍,判断标准是五条哲学:简单、显式、组合、并发、面向工程。
- 显式:不隐式转换类型,错误经返回值上抛,没有看不见的控制流。
- 组合:没有继承,靠类型嵌入(垂直)和接口(水平)扩展。
- 并发:goroutine 加 channel 是语言内建,栈初始 2KB,但不是免费的。
- 面向工程:未使用导入是编译错误,没有默认参数,gofmt 统一风格,一个
go命令包办工具链。 - panic 留给"程序即将崩溃",不是异常机制;运行时随二进制交付;Go 1.0 起有兼容性承诺。
- 迁移的头号敌人是"用别的语言的思维写 Go"。
常见坑
- 以为
int16和int能直接相加:必须显式转型。 - 把 panic 当异常用:错误走返回值,panic 表示程序要崩了。
- 以为没有继承就没法做多态:组合加接口完全够,见 04 节。
- 一上来就找全家桶框架:先看标准库能不能覆盖。
- 想给每个字段加 getter/setter:Go 直接访问字段是常态。
- 以为导入未使用的包只是警告:它是编译错误,会挡住构建。
判断题与答案
先自己答,再看后面的答案。
var x int32 = 1; var y int = 2; _ = x + y能编译吗?为什么 Go 这样设计?- 为什么 Go 没有默认函数参数?(提示:想想 API 设计缺陷会被什么掩盖。)〔TB02 p9〕
- 一个库大量用 panic 表达常规失败,这个 API 设计应该怎么改?
- Go 程序的二进制里,为什么连 hello world 都带着垃圾回收器?
答案
- 不能,
int32和int类型不同,Go 不做隐式数值转换。这样设计是为了显式:任何类型转换都得自己写清楚,避免"编译器偷偷提升"带来的意外。 - 默认参数会被用来给函数不断加形参,掩盖 API 设计缺陷,让函数参数越堆越多、可读性下降,所以一刀切掉了。
- 不该让调用方到处 recover。库应该在自己的边界内把 panic 接住并转成
error返回,panic 只留给程序 bug 和即将崩溃的情况。〔TB02 p14〕 - 因为 Go 的 runtime 随二进制一起交付,垃圾回收、并发调度、栈管理都在里面,hello world 也带着它,换来单文件部署的便利。
在项目里
dnsproxi-go/ 把这节讲的东西落到了实处(module 与目录布局见 02 节)。入口 main.go 的 main 先 loadConfig() 读环境变量,再 newLogger(...) 建日志,最后 p.Run(ctx) 启动代理;Run 出错时 logger.Error("run failed", ...) 然后 os.Exit(1),全程没有异常,也没有隐式初始化。
cache/cache.go 能看到「显式」落到数据上的样子:Cache 结构体里直接放一个 mu sync.RWMutex 字段,零值就能用,不用单独初始化;而 m、slot、access 三张 map 必须由 NewCap 显式 map[Key]...{} 建好,因为 nil map 不能写。这里没有嵌入或接口组合的现成例子,那部分看 04 节。
想深入:正文之外的干货
TB00 学习路线与设计意识
- 弯路共四条:入错行盲目投入、只看不练、用其他语言的思维写 Go(作者叫它"形神皆丧")、没建立"设计意识"。后两条是思维问题,不是语法问题,背多少关键字都没用。〔TB00 p6〕
- "设计意识"指选对语言元素、搭出程序骨架的能力,前提是分清各语言元素所在的层次;分不清,就一直停在 hello world 的世界里。〔TB00 p6〕
- 作者的五阶段路线对应三诀窍:心定(认同设计哲学,就是本节)、手勤(环境、程序结构、项目布局、构建)、脑勤(数据与算法加小项目,然后接口与 goroutine)。〔TB00 p6-8〕
- 接口和 goroutine 之所以被单独划进"核心篇",是因为它们是仅有的两个能影响程序结构设计的语法元素:goroutine 管动态的并发骨架,接口管静态的组合骨架。〔TB00 p7〕
- 作者从 C 转过来,2011 年看 Rob Pike 的教程一天学完全部语法、一周写出可用小程序;这是"简单"哲学换来的入门速度。〔TB00 p3〕
TB01 历史细节
- 三位创始人个个有来头:Ken Thompson 是图灵奖得主、Unix 之父、C 语法的联合发明人;Rob Pike 领导过 Plan 9、是 UTF-8 的最初设计者;Robert Griesemer 参与设计过 HotSpot 虚拟机和 V8 引擎。〔TB01 p2〕
- 初版设计思路是在 C 的基础上修缺陷、删诟病特性、加缺失功能:用 import 替代 include,去掉宏,加上垃圾回收和接口。〔TB01 p3〕
- 2008 年 Ian Lance Taylor 独立写了 gcc 前端,成为第二个能编译 Go 的编译器,证明了这门语言定义得足够严谨,他本人也因此成为团队第四人。〔TB01 p4-5〕
- Russ Cox 是第五位成员,后来长期负责核心技术团队;
http.HandlerFunc和io.Reader/io.Writer都出自他手。〔TB01 p5〕 - 编译器前端指词法分析到中间代码生成,后端指优化和目标代码生成;所谓 gcc 前端,就是读 Go 源码、产出 gcc 后端能识别的中间代码。〔TB01 p12〕
- 命名小史:语言叫 Go 不叫 golang,"golang"只是官网域名(go.com 被占了);早期按后缀拆过 goc、goa、gol 三个工具,后来撤销,只留统一的
go命令。〔TB01 p3-4〕 - 2009 年开源当年就拿下 TIOBE 年度语言;2012 年 3 月 Go 1.0 发布,同时给出 Go1 兼容性承诺:符合规范的源码保证向后兼容,十年前的代码今天仍能编译。〔TB01 p7-8〕
TB02 哲学的更多证据
- 类型嵌入是"能力继承"而非数据继承:方法按名字匹配,被嵌入类型与新类型没有父子关系、没有上下转型。标准库里
sync.Pool内部的poolLocalInternal就是靠嵌入Mutex白拿锁能力(本机 go1.26 源码里嵌入仍在,字段有变化)。〔TB02 p6〕 - 接口自己也能嵌接口:
io.ReadWriter就是把Reader和Writer嵌进来,凑出"又能读又能写";这是标准库的惯用法。〔TB02 p6-7〕 - 水平组合的接口参数你天天在用:
io.ReadAll(r io.Reader)、io.Copy(dst Writer, src Reader)只认能力不认类型;注意旧教程里的ioutil.ReadAll从 Go 1.16 起废弃,改用io.ReadAll。〔TB02 p7〕 - 关键字 25 个是主流语言里最少的,还有配套的减法:变量初始为零值、数组自带边界检查、首字母大小写决定可见性。〔TB02 p2-3〕
- 包的规则里藏了两个"工程向"决定:包路径必须唯一但包名不必唯一,所以不同仓库可以有同名包;类型别名是为大规模重构准备的,改名不动调用方。〔TB02 p9〕
- 作者答"性能敏感场景怎么办":GC 开销小、延迟低,但不是没有;逐帧计算这类场景直接上 C/C++/Rust,面向服务的架构天然允许各服务各选语言。〔TB02 p12〕
- Rob Pike 自己也检讨:Go 的变量声明方式还是多了,重来一次会再砍掉一种。"简单"是深思熟虑的取舍,不是没做过特性。〔TB02 p13〕
TBX01 泛型的两条实现路线
- 学新语言先问七个类型问题:强类型吗?动态还是静态?内建类型有哪些?有结构体吗?有字典吗?有泛型吗?按这张清单读规范,比从头啃快得多。〔TBX01 p1-2〕
- 别把两个维度搅在一起:Python 是强类型但动态(
int + str抛 TypeError,但变量可以先存 int 再存 str),JS 是弱类型且动态(整数加字符串会被转成拼接),Go 是强类型且静态。〔TBX01 p2-3〕 - 动态类型的代价不只有难读:编译期没有类型信息,对象的内存布局就定不下来,解释执行性能也差;所以小脚本用 Python、大型多人项目用 Java 这类静态语言,是按场景选,不是谁更好。〔TBX01 p4〕
- 泛型的动机是消灭重复:int 栈写完,float 栈除了元素类型全一样,C++ 用
template <typename T>让核心逻辑只写一遍;最常见的用武之地是容器。〔TBX01 p5-7〕 - 两条实现路线决定了泛型的行为:C++ 按类型参数生成新类型(实例化),
Stack<int>和Stack<string>在机器码里是两个真的不同的类型;Java 为了兼容旧字节码选择擦除,ArrayList<Integer>和ArrayList<String>运行时是同一个类,想靠泛型参数重载方法只会报 name clash。〔TBX01 p7-11〕 - 类型级函数是看泛型的另一个视角:
template <typename T> class vector是输入类型、输出类型的函数,vector裸名不是合法类型;C++ 还允许值当类型参数(template <int n>),于是能写出编译期算斐波那契的模板元编程,递归忘了写特化基例就直接把编译器撑爆。〔TBX01 p7-9〕 - Go 1.18 的类型参数走的是"产生新类型"一侧,约束用 interface 写,对标 Rust 的 trait;标准库从 1.21 起自带泛型的
slices、maps包。〔TBX01 p11-13〕
TB99 怎么继续走
- 进阶路线是 T 字形:一竖是语言深度(reflect、cgo、unsafe、编译器、调度、GC 的实现),一横是工程宽度(标准库加工具链)。Kelsey Hightower 的话:不知道何时不该用一种工具,就还没掌握它。〔TB99 p3-4〕
- 检验学会了的办法是公开输出(费曼学习法):写笔记、博客、做分享,把理解暴露给别人评判;起步阶段不怕错、不怕没人看。〔TB99 p4-5〕
- 选 Go 的理由可以落到数字上:可汗学院 2019 年把在线平台从 Python 迁到 Go,代码变多了,但性能提升近 10 倍。〔TB00 p4〕
- 结束语里"泛型是 Go 最后一次大的语法更新"的预言已被打破:1.23 的 range over func、1.24 的泛型类型别名还在往前走,别拿任何"已定论"当终点。〔TB99 p7-8〕
TB19 循环:只有一种 for
- C/C++/Java/Rust/Python 都给了不止一种循环语句,Go 只保留 for 一种,这是"做一件事仅有一种方法"的践行,也是"简单"哲学落到控制结构上的样子。〔TB19 p1-2〕
- 经典形式四部分:
for preStmt; condition; postStmt {},前置语句只执行一次、循环变量用短声明(作用域限 for 隐式代码块),条件必须是布尔表达式,后置语句每轮迭代后更新循环变量。〔TB19 p2-4〕 - 除循环体外三部分都可省,但省前置或后置时分号必须留着,这是语法要求;只留条件时可省分号,
for i < 10 {}是日常第二常用形式;条件恒真就整体省掉,for {}等价for true {},用最简形式。〔TB19 p4-6〕 - for range 是把四部分折叠进语义的语法糖:i、v 就是循环变量,隐含条件等价
i < len(sl),隐含后置是"取下一个元素的下标和值赋给 i、v"。〔TB19 p6-7〕 - 只要下标写
for i := range sl;只要值用for _, v := range sl,_不能省,否则两种写法无法区分;两个都不要就写for range sl(Go 1.4 起支持,等价for _, _ =)。〔TB19 p7-8〕 - string 的 for range 每次给的是 Unicode 码点(rune)而不是字节,下标 i 是该码点 UTF-8 编码首字节的字节偏移("中国人"输出 0/3/6);经典形式按下标循环则逐字节,两种写法语义不同。〔TB19 p8-9〕
- map 只能用 for range 遍历(经典形式不支持),map 没有下标概念;channel 也用 for range 读,无数据时阻塞在读上,channel 关闭时循环结束,这是隐含的循环条件。〔TB19 p9-11〕
- continue 中断本轮迭代回到条件判断;Go 给 continue/break 加了 label 支持:嵌套循环里
continue outerloop直接结束内层循环、回到外层继续下一迭代,等价于在外层放一个不带 label 的 continue。〔TB19 p11-13〕 - 别用 goto 替代带 label 的 continue:goto 会把内外层循环全部终结,再从 label 处重新开始整个嵌套循环,语义完全不同。〔TB19 p13〕
- break 彻底终结当前循环语句;不带 label 只跳出最内层,跳外层要
break outerloop。〔TB19 p13-15〕 - 坑一(Go 1.22 前)循环变量重用:for range 的 i、v 整个循环只声明一次、逐轮重用,goroutine 闭包捕获的是同一个变量,循环结束后输出全是最终值(5 个 goroutine 全打 4 5);修法是给闭包加参数按值绑定。〔TB19 p15-17〕Go 1.22 起改成每轮迭代新建变量,这个坑在新代码里消失了,但读老代码要认得它。〔TB19 p17〕
- 坑二:参与循环的是 range 表达式的副本。数组被 range 时拷贝整个数组,首轮改 a[1]/a[2],后面取到的仍是旧值 2、3;切片副本的结构体里 array 指针还指向原底层数组,所以改动可见。数组想看到修改,用
range a[:]、range &a或循环内a[i]直取。〔TB19 p18-21〕 - 坑三:range map 的副本与原变量指向同一个 map,遍历中删改会影响后续迭代且结果随机;新增的键值对是否被遍历取决于它落在哪个 hash bucket、是否排在遍历位置之后。边遍历边改 map 的代码要格外小心。〔TB19 p21-23〕
TB20 switch:修掉 C 的三处老毛病
- 分支多时 switch 比 if/else if 链简洁紧凑:扩展名分发的例子里 if 链写成 switch 后每个 case 一行,可读性一眼见高下。〔TB20 p1-3〕
- 一般形式
switch initStmt; expr { case ... }:initStmt 可短声明 switch 自用的临时变量,作用域限 switch 隐式代码块,就近声明把变量作用域压到最小。〔TB20 p3-4〕 - 求值次序固定:先求 switch 表达式,再按 case 书写顺序自上而下逐一求值比较,表达式列表从左到右;一旦匹配,后面的 case 表达式不再求值(哪怕也能匹配)。default 与书写位置无关,只在所有 case 都没匹配时执行。〔TB20 p4-5〕
- 配合求值次序有个小优化:把匹配概率高的 case 排前面,列表里概率最高的放最左。〔TB20 p5〕
- C 的 switch 表达式只能是 int 或枚举;Go 放宽到"类型支持比较操作"即可:整型、布尔、字符串、复数、元素可比较的数组、字段可比较的结构体都行。〔TB20 p5-7〕
- switch 表达式是恒为 true 的布尔值时可整体省略,直接
switch { case cond1: ... }当 if 链用;带 initStmt 时分号不能省,因为 initStmt 是语句。〔TB20 p7〕 - case 支持逗号分隔的表达式列表
case 1, 2, 3, 4, 5:,一个分支多值匹配;C 要串联多个空 case 共享逻辑,Go 一行搞定。〔TB20 p7-9〕 - Go 取消了 C 的隐式穿透:每个 case 执行完就结束 switch,"一个 case 一件事"是安全默认;真要穿透就显式写 fallthrough,这是显式哲学的又一处落地。〔TB20 p9〕
- fallthrough 的细节:它直接执行下一个 case 的分支代码而不对下一个 case 的表达式求值;可以连续穿透直到 default;如果已经是最后一个 case 且后面没有 default,再写 fallthrough 直接编译报错。〔TB20 p9-10〕
- type switch 是 switch 专属语法:
switch x.(type)里的 x 必须是接口类型变量,求值结果是它的动态类型,case 后面跟具体类型;用switch v := x.(type)还能拿到动态类型对应的值(v 是值,不是类型)。〔TB20 p10-12〕 - 限定接口后 case 也要守规矩:switch 后用具体接口类型 I 时,case 只能写实现了 I 的类型,写没实现的类型(如 int、string)编译器报
impossible type switch case。〔TB20 p13〕 - 坑:不带 label 的 break 跳出的是"所在最内层的 for、switch 或 select"。for 里嵌 switch 找第一个偶数,break 只出了 switch 不出 for,结果是最后一个偶数 12 而不是 6;修法是给 for 打 label 再
break loop,select 同样会拦截 break。〔TB20 p14-16〕
TB21 函数是一等公民
- 函数声明五部分:func、函数名(同包唯一、遵守首字母大小写导出规则)、参数列表(参数名在前类型在后)、返回值列表、函数体;函数体都可省(那种声明在 Go 之外用汇编实现,靠链接器按函数名接上)。〔TB21 p1-3〕
- 看懂函数声明的钥匙是把它等价成变量声明:函数名就是变量名,func 加参数列表加返回值列表构成函数类型;参数列表与返回值列表的组合叫函数签名,是判断两个函数类型是否相同的唯一依据,表述时省去参数名,如
func(io.Writer, string, ...interface{}) (int, error)。〔TB21 p3-5〕 - 每个函数声明只是对应函数类型的一个实例,和
var a int = 13里 a 是 int 的实例一个道理;匿名函数func(){}之于函数变量,如同T{}之于结构体变量。但日常定义函数仍用传统声明,别改用变量声明形式。〔TB21 p5〕 - Go 参数恒为值传递:实参的内存表示逐位拷贝给形参。整型、数组、结构体拷贝自身,开销与自身大小成正比;string、切片、map 的内存表示是数据内容的"描述符",只拷描述符不拷数据,开销固定,这就是"浅拷贝",所以往函数里传大切片不贵。〔TB21 p6〕
- 值传递两个例外由编译器介入:形参是接口类型时实参先装箱赋给接口;形参是变长参数时零个或多个实参被打包转换。〔TB21 p6〕
- 变长参数本质是切片:
elems ...int的%T打出来就是[]int,函数体内 len、append、elems...展开全能用。〔TB21 p6-7〕 - 多返回值是 Go 特有(C/C++/Java 没有),三个形式:无返回值、单值不加括号、两个及以上加括号;Go 的错误处理机制整个建立在多返回值之上。〔TB21 p7-8〕
- 具名返回值是特例不是默认:defer 里修改外层函数返回值时更优雅,返回值多时省去 return 后的长串(标准库 time 的 parseNanoseconds 用裸 return)。〔TB21 p8-9〕
- Ward Cunningham 对一等公民的解释:创建和使用无限制、可以像值一样对待,能存进变量、能作参数、能在函数内创建、能作返回值。Go 的函数四条全满足。〔TB21 p9〕
- 特征落到语法上:匿名函数可赋值给变量(
%T打出完整函数类型);函数内可创建函数并返回,引用了包裹函数局部变量的匿名函数就是闭包,闭包与包裹函数共享同一变量,变量生命周期随闭包延长;函数能作参数(time.AfterFunc(d, func(){...}));函数还有自己的类型,标准库基于函数类型定义了net/http.HandlerFunc、sort的 visitFunc。〔TB21 p9-12〕 - 应用一(转型实现接口):
greeting函数的类型与http.Handler接口的 ServeHTTP 方法签名一致,但函数本身不能直接传(不实现接口);http.HandlerFunc(greeting)把函数显式转型为带 ServeHTTP 方法的 HandlerFunc 类型,原理与MyInt(x)依赖底层类型 int 相同。这就是 01 正文"函数是一等公民且能挂方法"的完整机制。〔TB21 p12-14〕 - 应用二(闭包固定高频参数):
partialTimes(x)返回func(y int) int { return times(x, y) },把高频乘数固定住生成专用函数(timesTwo、timesThree);留言区点破这就是柯里化,函数参数越多(5 个以上)收益越大。〔TB21 p14-18〕
TBX08 他山之石:怎么继续找资料
- 国内 Go 书少有三个原因:语言年轻(2021 年才 12 岁,比最年轻的 Java 还小 14 岁);还没当上哪个领域的"品类代名词"(Web 是 Java、深度学习是 Python、前端是 JS,Go 在云原生、微服务占优但不算代名词);翻译有时滞。国内流行节点是 2015 年第一届 GopherChina(Go 1.4)之后,同年 Go 1.5 自举加 GC 延迟大降才彻底铺开。〔TBX08 p2-3〕
- 书单(按作者推荐度从低到高):《The Way To Go》是 Go 百科全书,成书于 Go 1.0 时代(2012.3),无中文版,无闻在 GitHub 发起过中译项目;《Go 101》(老貘)相当于 Go 的"标准解读",开源电子书与付费版并行,更新与 Go 演化同步。〔TBX08 p3-6〕
- 再往上三本:《Go 语言学习笔记》(雨痕)精华在源码剖析,基于 1.5 讲内存分配、GC、调度、channel、defer 的实现;《Go 语言实战》薄而直击 Go 与其他语言的不同(类型系统、并发、标准库、测试),适合先建立框架;《Go 程序设计语言》(tgpl,Kernighan 参与)是人手一本的圣经,对标 K&R。〔TBX08 p7-11〕
- 读书教训比书单更值钱:有人《Go 语言实战》读一遍全忘、《Go 程序设计语言》读到第 9 页的 map 就懵,只看不练无效,边看边练最有效;先吃透一门课再计划读书(一本中文一本英文加官方博客),资料不在多而在精。〔TBX08 p16-17〕
- 最权威的资料是官方文档,必读六件:语言规范、module 参考文档、命令参考手册、Effective Go、标准库包参考手册、常见问答;主线开发中文档在 tip.golang.org。〔TBX08 p12-13〕
- 信息流够用就好:博客盯 Go 官博、Russ Cox、Rob Pike、Dave Cheney 几个源头;播客一个 Go Time 就够(200 多期);演讲按时间顺序过一遍 Go 官方演讲归档和 GopherCon 系;国内订阅 Go 语言爱好者周刊和 Gopher 日报两个邮件列表足矣。〔TBX08 p13-15〕
- 两个最容易被忽视的宝藏:Go 项目的官方 issue 列表,能实时看演进状态、挖某个特性的来龙去脉,对深入理解特性极有帮助;Go 代码 review 站点,读核心团队的评审过程和意见,学的就是 Go 的编码原则与地道惯用法,也是理解设计哲学的入口。〔TBX08 p15〕
(TB99 是全课的结束语,其余内容是课程回顾与团队动态,此处只取上面两条仍站得住的判断。TB01 里"Go 1.5 自举""TIOBE 年度语言"等叙述为当时事实,不代表当前状态;TB00 的五阶段学习清单对应原课程目录,本手册的 26 节结构已另行组织,不再照搬。)
想深入去哪查
- 设计哲学全文:TB02 全讲;历史与生态位:TB01、TB99。
- 学习路线与"设计意识":TB00 p6-8。
- 类型系统与泛型的比较视角:TBX01。
- 旧长版:
notes/p2-lang-core.md(Rust 经验换直觉)、notes/p1-toolchain.md(cargo 到 go 的命令映射)。