配套可跑脚本(五个,都不用装 Go):
·代码/嵌入到底是什么.py—— struct 嵌入从零讲,用__getattr__把 Go 的「提升」显式演一遍(配合 2.7)
·代码/切片到底是什么.py—— slice 是个「窗口」,用 Python 照搬 指针+len+cap 的机制(配合 2.9)
·代码/标签在干什么.py—— 反引号里那串标签:序列化是什么·标签本身不干事·平铺vs嵌套(配合 2.10)
·代码/指针到底在解决什么.py——*和&:值vs引用·用 nil 表达"没传"·为什么传指针才能换掉(配合 2.11)
·代码/用Python重写一遍shape和drawing.py
——把shape.go和drawing.go逐段翻译成 Python,Go 原文放在注释里。
本机不用装 Go,对着读就行。
读法:第一部分顺着读(心智模型,一行 Go 都没有);
第二部分是十个机制,每个都在 qpaint 真代码里出现过;
第三部分当字典查,不要通读;第四部分是验收(逐行读两个真函数)。
完整源码在
代码/qpaint源码/下,v31(手写 HTTP)和 v41(用框架)两个版本都有。
序 · 41~44 讲怎么走
这四讲是同一个服务端的连续演进。上一章(26~32 讲)做出了浏览器端,并顺手做了一个假服务端——能跑,但数据只在内存里、没有用户、没有真数据库。
| 讲 | 服务端变成了什么 | 被逼出来的新东西 |
|---|---|---|
| 41 | 换掉手写的 HTTP 处理 | RPC 框架、业务/协议分层、真正的单元测试 |
| 42 | 支持多租户 + 换成真数据库 | 使用界面怎么定、mongodb 表结构与索引、事务 |
| 43 | 有了真的帐号与授权 | 帐号 / 凭据 / 属性的区分、OAuth 2.0 |
| 44 | 不自己造授权服务 | dex、ID Token、不造轮子的判断 |
第 0 步(本篇) Go 底子 —— 让代码从"天书"变成"看得懂的字"
第 1 步 41 讲 · RPC 框架、分层与单元测试 ← 见 带读-15
第 2 步 42 讲 · 使用界面、数据结构与算法 ← 带读-16
第 3 步 43 讲 · 帐号与授权 ← 带读-17
第 4 步 44 讲 · dex、ID Token 与不造轮子 ← 带读-18
收尾 后端四讲带走了什么 ← 带读-19
服务端只有三个业务文件,先认脸:
| 文件 | 层 | 干什么 | 行数 |
|---|---|---|---|
shape.go |
业务 · Model | 图形的数据定义:Point / Line / Rect / Ellipse / Path | 89 |
drawing.go |
业务 · Model | 一张图 + 一个文档:Drawing 管一张图里的图形,Document 管所有图 |
241 |
service.go |
协议层 | 把 HTTP 请求翻译成对上面两个的方法调用 | 286 → 169 |
★ 只有
service.go跟 HTTP 有关系。另外两个文件里不出现任何网络的东西。
这是 29 讲刻意做的,而 41 讲要兑现它。
第一部分 · 先建立心智模型
这一部分一行 Go 代码都没有。先知道"它为什么长成那样",再看代码。
1.1 Go 和 Python 的五件大事
| # | Go | Python | 读代码时的后果 |
|---|---|---|---|
| 1 | 静态类型,先编译 | 动态类型,直接跑 | 到处都要写类型,而且类型写在名字后面(id string,不是 string id) |
| 2 | 没有异常,错误是返回值 | try/except |
满屏 if err != nil { return }——这是最大的视觉冲击 |
| 3 | 没有类继承,只有"嵌入" | class A(B) |
shape.go 里那些没有字段名的行就是它 |
| 4 | 接口是隐式的 | 靠 isinstance / Protocol |
找不到 Line implements Shape 这种话,因为不需要写 |
| 5 | 要自己选值还是指针 | 对象天然是引用 | 满屏 * 和 & |
1.2 ★ 一条 Python 里根本不存在的规则:首字母大小写 = 可见性
这条不知道,qpaint 的代码结构会完全看不懂。
Go 里没有
public/private关键字。它用【首字母大小写】表示:首字母大写 → 导出(public) 包外面可以用 首字母小写 → 不导出(private) 只有本包内部能用
对着 shape.go 看,立刻就明白它为什么这么写:
| 名字 | 大小写 | 意思 |
|---|---|---|
Shape Line Rect Point ShapeStyle |
大写 | 对外的 API——别的包(比如协议层)要用它们 |
shapeBase lineData rectData pathData |
小写 | 内部实现细节——只是为了拆分结构,不想让外面看见 |
GetID() |
大写 | 对外的方法(Shape 接口要它) |
newDrawing() makeDrawingID() |
小写 | 内部构造函数 |
NewDocument() |
大写 | 对外的构造函数 |
★★ 所以在 Go 里"改可见性"= 改一个字母。
这也是为什么 42 讲一改需求,shape.go的可见性就整批变了——
那不是重构,那是 Go 表达"这个东西现在对外了/不对外了"的唯一方式。
1.3 为什么 Go 代码看起来那么啰嗦
因为第 1.1 节的第 2 条。 一个 Python 里三行的事:
drawing = self.doc.get(id) # 出错就抛异常,往上冒
drawing.add(shape)
在 Go 里是六行:
drawing, err := p.doc.Get(id) // 一次拿两个:结果 + 错误
if err != nil { // 自己检查
return err // 自己往上传
}
return drawing.Add(shape)
★ 这不是 Go 写得差,是它的一个明确取舍:把"出错了怎么办"变成必须当场写出来的代码,
而不是一个可以忘掉的隐式路径。而这直接解释了 41 讲那个"省掉 40% 代码":v31 里每个处理函数都要写四五遍
if err != nil { ReplyError(w, err); return }——框架替你把这一坨收走了。
换句话说:Go 的这个取舍让"RPC 框架"的收益比在 Python 里更明显。
第二部分 · 九个机制(全部出自 qpaint 真代码)
python3 代码/用Python重写一遍shape和drawing.py
2.1 声明::= / var / 零值
id := shape.GetID() // 声明 + 赋值,类型自动推断。≈ Python 的 id = ...
var err error // 只声明,得到【零值】
零值是 Go 特有的省心之处:变量一定有初始值,不存在"未定义"。
| 类型 | 零值 | Python 里最近的 |
|---|---|---|
int float64 |
0 |
—— |
string |
"" |
—— |
bool |
false |
—— |
| 指针 · slice · map · interface | nil |
None |
★
:=只能在函数内部用;包级别(文件最外层)必须写var。
所以你会在drawing.go末尾看到:var ( idDrawingBase int64 = 10000 )。
2.2 ★ 先学会读「名字 类型」:函数签名与 struct 字段
这是读 Go 的第一道关,也是最容易读错的一处。 看这一行:
func (p *Drawing) Add(shape Shape) (err error) {
它有三个括号,不是"两组参数":
func (p *Drawing) Add (shape Shape) (err error) {
╰──── ① ────╯ ╰─── ② ───╯ ╰─── ③ ───╯
这是谁的方法 收什么 还回什么
= self 参数 返回值
对上 Python:
def Add(self, shape: Shape) -> None:
# ↑① ↑② ↑③
★ Go 把
self挪到了函数名【前面】的括号里,把返回值挪到了函数名【后面】的括号里。
err error 就是「名字 + 类型」
Go 的写法是 名字 类型(Python 是 名字: 类型)。所以这四行是同一种东西:
| Go 写法 | 读作 | Python |
|---|---|---|
shape Shape |
名字 shape,类型 Shape |
shape: Shape |
id string |
名字 id,类型 string |
id: str |
n int |
名字 n,类型 int |
n: int |
err error |
名字 err,类型 error |
err: Exception |
★★ 关键:
error只是一个类型名,跟string、int完全平级(它本质是个只有一个Error() string方法的接口,见 2.4)。之所以
err error看起来怪,是因为"名字"和"类型"这两个词长得像。换个名字就一眼看穿:
(e error)、(失败原因 error)——都合法,都是同一件事。
★★ 同一条规则的第三个位置:struct 的字段
shape.go 里这一行,卡人的程度跟 err error 一样:
ID ShapeID `json:"id"`
╰┬╯ ╰──┬──╯ ╰────┬───╯
│ │ └─ ③ 序列化成 JSON 时叫什么名字
│ └─ ② 类型
└─ ① 字段名
它还是「名字 类型」——只不过位置换到了 struct 里面。
① ID 是字段名,而它必须大写
- 大写 = 导出(1.2 节),包外面能访问 → 协议层要读它
- 而且:Go 的
encoding/json只能序列化大写(导出)的字段。
写成小写id,JSON 序列化会直接忽略它,出来是个空对象。
★ 所以
ID必须大写,这不是风格偏好,是硬性要求。
② ShapeID 是类型 —— 而它就是 string
翻到 shape.go 最上面(第 5~6 行):
type coord = float64
type ShapeID = string
注意那个等号。 带 = 的 type 是别名:ShapeID 和 string 是同一个类型,只是换了个名字。所以这两行完全等价:
ID ShapeID // 真代码这么写
ID string // 一模一样的意思
同一条规则的三个实例摆在一起看:
| Go 写的 | 拆开 | 等价于 | Python |
|---|---|---|---|
err error |
名字 err + 类型 error |
—— | err: Exception |
ID ShapeID |
名字 ID + 类型 ShapeID |
ID string |
ID: str |
X coord |
名字 X + 类型 coord |
X float64 |
X: float |
Pt1 Point |
名字 Pt1 + 类型 Point |
—— | Pt1: Point |
★★ 这两个词长得像、中间又没有符号分隔,所以特别容易读错。
记住:Go 里两个词并排,永远是「名字 类型」。中间没有冒号、没有等号。
那为什么不直接写 string? 纯粹为了让代码读起来像业务语言:
func (p *Drawing) Get(id ShapeID) (shape Shape, err error) // 一眼知道要的是"图形的 ID"
func (p *Drawing) Get(id string) (shape Shape, err error) // 只知道要个字符串
★ 代价:因为它是【别名】(带
=),编译器完全不区分——
你传一个普通string进去也照样通过。所以它没有任何类型安全的收益,只有可读性的收益。去掉那个
=写成type ShapeID string,那就是一个新类型了,传普通string会编译报错。
qpaint 选了别名(宽松那种)。(这个区别也列在 3.2 的坑 #2)
③ 反引号里的 json:"id"
Go 要求字段大写才能被序列化,但 JSON 的习惯是小写。所以用标签改回来:
ID ShapeID `json:"id"` // Go 里叫 ID(必须大写),JSON 里叫 id
产生的是 {"id": "1", ...} 而不是 {"ID": "1", ...}。详见 2.10。
收束:一行三部分,各有各的理由
| 部分 | 为什么这么写 |
|---|---|
ID 大写 |
包外要能访问 + JSON 序列化只认大写字段(硬性) |
ShapeID |
string 的别名,为了让签名读起来像业务语言 |
json:"id" |
把 JSON 里的名字改回小写,迁就前端习惯 |
返回值为什么也要括号?因为可以返回好几个
func f() int // 1 个返回值 → 括号可以【省掉】
func f() (int, error) // 2 个 → 必须用括号
func f() (shape Shape, err error) // 2 个,而且都起了名字
★ 括号是给"可能有多个"准备的。 而 Go 里多返回值是常态——因为错误是当成返回值传回来的(2.4)。
顺带:给返回值起名字是可选的,两种都合法、功能完全一样:
func (p *Drawing) Add(shape Shape) error // 只写类型,不起名
func (p *Drawing) Add(shape Shape) (err error) // 起名叫 err ← qpaint 用这种
起名的唯一好处是可以写光秃秃的 return(等于 return err)——见 2.5。
拿 qpaint 真代码练一遍
// 0 个参数,2 个返回值
func (p *Document) Add() (drawing *Drawing, err error)
// 1 个参数,1 个返回值
func (p *Drawing) Add(shape Shape) (err error)
// 1 个参数,2 个返回值
func (p *Drawing) Get(id ShapeID) (shape Shape, err error)
// 2 个参数,1 个返回值
func (p *Drawing) Sync(shapes []ShapeID, changes []Shape) (err error)
翻成 Python:
def Add(self) -> tuple[Drawing, Exception]
def Add(self, shape: Shape) -> Exception
def Get(self, id: str) -> tuple[Shape, Exception]
def Sync(self, shapes: list[str], changes: list[Shape]) -> Exception
★★ 口诀:把那个空格念成冒号
这条规则你会在四个位置反复撞上(参数、返回值、struct 字段、变量声明),
而每次卡住的根因都一样:Go 在名字和类型之间什么符号都不放。
Python 有冒号,一眼分得开: Points : list[Point]
Go 只有一个空格: Points []Point
★★ 读的时候在心里把那个空格念成冒号,就再也不会错:
Points []Point → Points : []Point err error → err : error ID ShapeID → ID : ShapeID shape Shape → shape : Shape
而右边那半(类型)有明显的辨识特征,认出来就能反推左边是名字:
| 类型长这样 | 例子 |
|---|---|
[] 开头 |
[]Point、[]ShapeID、[]Shape |
* 开头 |
*Drawing、*shapeOnDrawing、*restrpc.Env |
map[ 开头 |
map[ShapeID]*shapeOnDrawing |
[N] 开头(有数字) |
[2]string ← 这是数组不是切片(2.9) |
| 内置类型名 | string、int、int64、float64、bool、error |
| 首字母大写的已定义类型 | Point、Shape、ShapeStyle、ShapeID、Drawing |
★ 还有一个免费的帮手:Go 的格式化工具
gofmt会把 struct 字段的类型对齐成一列。
所以你看到的那些多余空格是自动加的,而**"右边那一列"天然就是类型**:type pathData struct { Points []Point // ← 左边一列全是名字 Close bool // 右边一列全是类型 Style ShapeStyle }
读任何 Go 函数签名的四步
func (谁的方法) 名字(参数们) (返回值们) {
↑ 有 → 这是方法 ↑ 名字 类型 ↑ 名字 类型
没有 → 普通函数 (只有一个时括号可省)
| 步 | 看什么 |
|---|---|
| 1 | 函数名前面有括号吗?有 → 这是方法,那个变量就是 self;没有 → 普通函数 |
| 2 | 函数名(顺便看首字母大小写 = 对外还是内部,见 1.2) |
| 3 | 紧跟的括号 = 参数,每项 名字 类型 |
| 4 | 再后面的括号 = 返回值,每项也是 名字 类型(名字可省,只有一个时括号也可省) |
★ 试一试:
func newDrawing(id string) *Drawing
—— 函数名前没有括号 → 普通函数(不属于任何类型);
1 个参数id string;1 个返回值,类型*Drawing,没起名字,所以括号省了;
首字母小写 → 包内私有(对外的构造函数是大写的NewDocument)。
★★ 汇总:五个位置,一条规则
「名字 类型」这条规则一共出现在五个位置,写法完全一样:
func (p *Drawing) Add(shape Shape) (err error) {
// ╰────┬───╯ ╰─────┬────╯ ╰────┬───╯
// ① receiver ② 参数 ③ 返回值
type shapeBase struct {
ID ShapeID // ④ struct 字段
}
var idDrawingBase int64 // ⑤ 变量声明
| # | 位置 | 例子 | 念成冒号 |
|---|---|---|---|
| ① | receiver(函数名前的括号) | p *Drawing |
p : *Drawing |
| ② | 参数 | shape Shape |
shape : Shape |
| ③ | 返回值 | err error |
err : error |
| ④ | struct 字段 | ID ShapeID |
ID : ShapeID |
| ⑤ | 变量声明 | idDrawingBase int64 |
idDrawingBase : int64 |
★★ 五个位置,一条规则:名字在前,类型在后,中间只有一个空格。
最容易卡住的六个例子,全都是这一条:
err error·ID ShapeID·Points []Point·shapes map[...]·d Drawing·d *Drawing而
d Drawing和d *Drawing的差别只在类型那半多了个星号——
Drawing和*Drawing是两个不同的类型,不是"同一个类型的两种传法"(2.11)。
顺带:为什么 Go 的顺序会让人不适应
其实 Go 和 Python 的顺序是【一样的】——名字在前。
x: int # Python
x int // Go
Pascal、TypeScript、Rust、Kotlin、Swift 全都是名字在前;反而 C / C++ / Java 那种"类型在前"是少数派。
★ 所以不适应的不是顺序,是 Go 把那个冒号也省了。
这就是"念成冒号"这个口诀能救你的原因。
Go 为什么故意跟 C 反过来?因为 C 会把类型【拆散】:
int a[5]; // C:类型是"5 个 int 的数组",但 int 在左、[5] 在右
int *p; // C:类型是 int*,星号却贴在名字上
int (*fp)(int, int) // C:函数指针 —— 已经没法从左往右读了
a [5]int // Go:类型 [5]int 是完整的一坨,在名字右边
p *int // Go:类型 *int 完整
fp func(int, int) int // Go:从左往右直接念下来
★★ Go 的取舍:牺牲"像 C",换来"类型永远是完整的一段,永远在名字右边"。
这也是为什么上面那个辨识法有效——类型那半是连续的一整块,认出它就行。
2.3 方法 = 把 self 写在函数名前面
func (p *Drawing) Add(shape Shape) (err error) {
// ^^^^^^^^^^^^ 这一段就是 self
def Add(self, shape: Shape) -> None:
| Go | Python |
|---|---|
func (p *Drawing) Add(...) |
def Add(self, ...) |
p |
self(Go 里叫什么随你,qpaint 一律用 p) |
*Drawing |
无需写——Python 对象天然是引用 |
★ 记住这一条,Go 代码的一半就能读了:看到
func (x *Y) Z(...),
就读成"Y类的方法Z"。
2.4 多返回值 + 错误是值
drawing, err := p.doc.Get(id) // 一次返回两个东西
if err != nil {
return
}
Go 的约定:错误永远是最后一个返回值,类型是 error。 而 error 本身只是一个接口(有个 Error() string 方法)。
qpaint 用的"错误"直接借了操作系统的错误码:
| Go 里写的 | 意思 | 后来被协议层翻成 |
|---|---|---|
syscall.EEXIST |
已存在 | HTTP 409 |
syscall.ENOENT |
不存在 | HTTP 404 |
syscall.EINVAL |
参数不对 | HTTP 400 |
★ 这就是 41 讲第 1.4 节那五步苦工里第⑤步(把错误翻成状态码)的原料。
业务层只说"不存在",它不知道 404 这个数字——这正是分层。
2.5 命名返回值 · 裸 return · 以及那三行惯用法
func (p *Drawing) Add(shape Shape) (err error) {
...
return // ← 后面什么都不写,等于 return err
}
(err error) 给返回值起了名字,于是它已经是个变量(零值 nil),return 就能直接返回它。
★ 看到光秃秃一个
return,就去看函数签名里那些带名字的返回值。
Python 没有这个东西,是纯粹的 Go 特性。
★★ 把上一节和这一节连起来:那三行惯用法
Go 代码里出现最多的就是这三行,而它难懂的地方恰好在 2.4 和 2.5 的接缝上:
drawing, err := p.doc.Get(id)
if err != nil {
return
}
逐行:
| 行 | 在干什么 |
|---|---|
drawing, err := p.doc.Get(id) |
调 Get,它一次返回两个值:结果 + 错误。左边同时接住(2.4) |
if err != nil { |
nil ≈ None。这句是"err 不是空的" = “出错了” |
return |
裸 return —— 提前退出 |
Python 里同一件事是一行:
drawing = self.doc.get(id) # 出错就抛异常,自动往上冒
Go 没有异常,所以"自动往上冒"这件事必须手写出来。三行 = Python 的零行。
★ 关键疑问:return 什么都不写,那错误去哪了?
这是整个惯用法唯一难懂的一处。答案在函数签名里:
func (p *Service) GetShape(env *restrpc.Env) (shape Shape, err error) {
// ╰────┬───╯
// 这里就声明了一个变量 err(初值 nil)
id := env.Args[0]
drawing, err := p.doc.Get(id)
// ╰─┬─╯
// 这个 err 就是【上面签名里那个】——
// 赋值给它的那一刻,错误就已经装进返回值里了
if err != nil {
return // ← 所以这一句等于 return shape, err
}
...
}
★★ Python 的
raise一个动作干了两件事:把错误装好 + 往上抛。Go 把这两件事拆开了:① 装好 —— drawing, err := ... 顺手就做了(赋值给了返回值变量) ② 抛出 —— if err != nil { return }所以那个
return不是"什么都不返回",它返回的是签名里那些带名字的返回值当前的值。
顺带解释为什么这里用 := 而不是 =::= 的规则是左边至少有一个新变量。
drawing —— 新的 ✓
err —— 签名里已经声明过了
→ 满足规则,所以能用 :=;而且 Go 复用已有的那个 err(不新建)。这正是上面那一步能成立的前提。
完整对照
// Go:错误是值,必须当场处理
func GetShape(...) (shape Shape, err error) {
drawing, err := p.doc.Get(id)
if err != nil {
return
}
return drawing.Get(shapeID)
}
# Python:错误是异常,自动往上冒
def GetShape(...) -> Shape:
drawing = self.doc.get(id) # 出错自动抛,不用写
return drawing.get(shapeID)
★ 这就是 41 讲能"省掉 40% 代码"的主体——我数过真源码:
这个套路在 v31 的service.go里出现了 17 次,
而且每次后面还跟着一句ReplyError(w, err); return(把错误翻成 HTTP 状态码)。
框架把"翻成状态码"那一半收走了,于是 v41 里只剩 8 次纯粹的if err != nil { return }。
⚠ 一个真会咬人的坑:裸 return 只在函数最外层安全
如果那一行写在 if / for 里面:
func f() (err error) {
if 条件 {
drawing, err := g() // ★ 这个 err 是【新建的】,只在 if 里面有效
if err != nil {
return // ← 返回的是【外面】那个 err,它还是 nil
} // 错误被吞掉了!
_ = drawing
}
return
}
因为
err在内层算"新变量"(外层那个属于另一个作用域),:=会新建一个遮住外面的。
qpaint 的代码都写在函数最外层,所以没这个问题——但你自己写的时候要留意
(go vet -shadow能查这类问题)。
2.6 defer:函数结束前记得做这件事
p.mutex.Lock()
defer p.mutex.Unlock() // ← 不管从哪条路径 return,都会执行
with self.mutex: # 进入时 Lock,离开时 Unlock
...
★
defer= Python 的with/try...finally。
它解决的是同一个问题:函数有五个 return 分支,别漏掉解锁那一句。
多个defer按后进先出执行。
2.7 ★★ struct 嵌入(embedding)—— shape.go 的核心机制
python3 代码/嵌入到底是什么.py # ★ 这一节强烈建议先跑这个
这一节从零讲。 真代码里同时挤了三样陌生东西(
struct、“没有字段名”、反引号标签),
一起看必然懵。先一样一样来。
第 0 步 · 先说清 struct 是什么
type shapeBase struct {
ID ShapeID
}
func (p *shapeBase) GetID() ShapeID { return p.ID }
@dataclass
class shapeBase:
ID: str = ""
def GetID(self): return self.ID # ← Python 把方法写在 class 里面
★
struct= 只有字段的类。方法不写在里面,而是单独挂在外面(2.3 那个 receiver)。
字段那一行ID ShapeID怎么读(以及ShapeID到底是什么)见 2.2。
差别只是"方法写在哪"——Go 写在 struct 外面,Python 写在 class 里面。
struct本身没什么神秘的,就是一组字段。
第 1 步 · ★★ 普通字段 vs 嵌入:差别就是【有没有写字段名】
把同一个 Line 的两种写法并排放(反引号标签先遮住不看,它跟嵌入完全无关,见 2.10):
// ── 写法 A · 普通字段(写了字段名)──
type Line struct {
base shapeBase // ← 字段名 base,类型 shapeBase
data lineData // ← 字段名 data,类型 lineData
}
// ── 写法 B · 嵌入(把字段名【删掉】)──
type Line struct {
shapeBase // ← 【只有类型,没有名字】
lineData
}
★★ 嵌入的全部动作就是:把字段名删掉。
没有新语法、没有关键字、没有extends——就是少写了一个词。所以你在
shape.go里看到"一行只有一个类型名"的时候,那就是嵌入。
第 2 步 · 写法 A 用起来有多啰嗦
| 你想做 | 写法 A 得这么写 |
|---|---|
| 取 ID | line.base.ID ← 必须写中间那一层 .base |
| 调方法 | line.base.GetID() |
Line 自己有 GetID 方法吗 |
✗ 没有! 只有 line.base 有 |
最后一行是要命的地方。 Go 的接口是隐式的——只要你有那个方法,你就是那个接口(2.8):
type Shape interface { GetID() ShapeID }
而写法 A 的 Line 自己没有 GetID 方法,所以 Line 不是一个 Shape。要想是,你得手写一句转发:
func (p *Line) GetID() ShapeID { return p.base.GetID() }
★ 而
shape.go里有【四种】图形(Line / Rect / Ellipse / Path)——这句转发要写四遍。
第 3 步 · ★★ 写法 B:嵌入的规则只有两句话
① 不写字段名时,【字段名就等于类型名】
嵌入 shapeBase → 这个字段就叫 shapeBase
所以 line.shapeBase.ID 【是合法的】
——这证明它真的只是一个"省略了名字的普通字段",不是魔法
② 里面的字段和方法,【可以省略中间那一层直接访问】
line.ID ≡ line.shapeBase.ID
line.GetID() ≡ line.shapeBase.GetID()
这个"省略中间层"的动作,官方叫【提升(promotion)】
脚本用 Python 的 __getattr__ 把"提升"这个动作显式写出来了——Go 编译器干的就是这件事:
class Line:
嵌入的类型 = ("shapeBase", "lineData")
def __init__(self, ID, pt1, pt2):
self.shapeBase = shapeBase(ID) # ★ 规则①:字段名 = 类型名
self.lineData = lineData(pt1, pt2)
def __getattr__(self, 名字): # ★ 规则②:自己没有的,去嵌入的字段里找
for 类型名 in self.嵌入的类型:
o = self.__dict__.get(类型名)
if o is not None and hasattr(o, 名字):
return getattr(o, 名字)
raise AttributeError(名字)
跑出来:
b.shapeBase.ID → '1' ← 规则①:显式写法,字段名就是类型名
b.ID → '1' ← 规则②:中间那层被省掉了
b.GetID() → '1' ← 方法也被提升
b.长度() → 29.97 ← 另一个嵌入的也一样
Line 自己有 GetID 吗? → True ★ 有了!
★★ 于是不用写任何转发,
Line就自动满足了Shape接口。
四种图形 × 一句转发 = 四句样板代码,一句都不用写。
第 4 步 · 回到真代码:89 行的 shape.go 就干了这一件事
type shapeBase struct { ID ShapeID }
func (p *shapeBase) GetID() ShapeID { return p.ID }
type Line struct { shapeBase; lineData }
type Rect struct { shapeBase; rectData }
type Ellipse struct { shapeBase; ellipseData }
type Path struct { shapeBase; pathData }
shapeBase(有 ID 字段 + GetID 方法)
│ 被嵌入
├──────────┬──────────┬──────────┐
Line Rect Ellipse Path
│ │ │ │
四个都白拿了 ID 和 GetID() → 四个都自动是 Shape
★★ 这就是 qpaint 图形体系【全部】的"继承"机制。
没有 class、没有 extends、没有 implements——就是"把字段名删掉"。
第 5 步 · 跟 Python 的继承像不像
★★ 一句话:Go 的嵌入,写起来像【组合】(里面真有一个
shapeBase字段),
用起来像【继承】(字段和方法都能直接访问)。
| 对比项 | Go 的嵌入 | Python 的继承 |
|---|---|---|
| 本质 | 组合(真有那个字段) | 继承 |
| 能按名字访问吗 | ✓ line.shapeBase.ID |
✗ 没有这种写法 |
有 super() 吗 |
✗ 没有 | ✓ 有 |
| 有多态派发吗 | ✗ 没有 | ✓ 有 |
| 能嵌 / 继承多个吗 | ✓(qpaint 就嵌了两个) | ✓ 多继承 |
★ "能按名字访问"是关键证据:
line.shapeBase.ID合法,说明嵌入真的只是个普通字段。★ "没有多态派发"是最大的区别:如果
shapeBase.GetID()内部调了另一个方法,
它永远调shapeBase自己的那个,不会调到Line覆盖的版本。
Go 有意不做这件事——它不想要继承那套复杂性。
第 6 步 · 一句话记住
嵌入 = 把字段名删掉
删掉之后:
① 字段名默认等于类型名 line.shapeBase.ID 仍然能写
② 里面的字段和方法被【提升】 line.ID / line.GetID() 可以直接写
③ 于是外层【自动获得】了那些方法
④ 于是外层【自动满足】了要那些方法的接口(2.8)
2.8 ★ 接口:它是一张「方法要求清单」
先拧一个词:interface 不是"类"。Go 的接口里没有任何实现、没有字段——
它就是一张方法要求清单。
第 1 步 · 哪部分是 Go 的语法,哪部分是自己起的名字
type itfEnv interface {
↑ ↑ ↑
Go关键字 自己起的 Go关键字
固定语法只有这么点:
type <你起的名字> interface {
<你要求的方法列表>
}
这一下就能把你已经见过的三种 type 统一起来:
type shapeBase struct { ID ShapeID } // 定义成 struct
type itfEnv interface { OpenEnv(...) } // 定义成 interface
type ShapeID = string // 定义成别名
★
type的意思是"我要定义一个类型";后面跟的struct/interface/= 别名是"定义成哪一种"。
第 2 步 · interface 和 struct 的区别
struct |
interface |
|
|---|---|---|
| 里面装的是 | 一组字段 | 一组方法要求 |
| 有实现吗 | 字段有实际的值 | 完全没有实现,只有方法签名 |
| 能造出实例吗 | 能:&Line{ID: "1"} |
不能,它只是一张清单 |
| Python 里对应 | @dataclass |
Protocol |
type Shape interface {
GetID() ShapeID
}
from typing import Protocol
class Shape(Protocol):
def GetID(self) -> str: ...
没有 implements,没有 class Line(Shape)。 只要 Line 有 GetID(),它就是 Shape(2.7 那条嵌入链的终点)。
第 3 步 · ★ 关键问题:谁定义接口、谁使用接口
看 41 讲原文那段最费解的代码:
type itfEnv interface {
OpenEnv(rcvr interface{}, w *http.ResponseWriter, req *http.Request) error
CloseEnv()
}
★ 它不是 qpaint 定义的,是 restrpc 框架里定义的。
而且注意它小写开头(1.2 节)→ 包内私有,框架自己用,
你在自己的代码里根本看不到、也用不到这个名字。
框架拿它来问一个问题:
"你传给我的这个 Env,有没有 OpenEnv 和 CloseEnv 这两个方法?"
有 → 好,请求开始前我调你的 OpenEnv,结束后我调你的 CloseEnv
没有 → 那就跳过这套机制
qpaint 传的 restrpc.Env 恰好有这两个方法 → 满足。而你也可以传自己写的 Env,只要有这两个方法。
第 4 步 · ★★ Go 接口最反直觉、也最有用的一点
你写自己的 Env 时,不需要写任何一句"我实现了 itfEnv"——你甚至不需要知道 itfEnv 这个名字存在。
// 你自己写的,完全不 import restrpc、完全不提 itfEnv
type 我的Env struct { 开始时间 time.Time }
func (p *我的Env) OpenEnv(rcvr interface{}, w *http.ResponseWriter, req *http.Request) error {
p.开始时间 = time.Now()
return nil
}
func (p *我的Env) CloseEnv() {
log.Printf("耗时 %v", time.Since(p.开始时间))
}
// 就这样。它已经满足 itfEnv 了。
对比 Java / C#:那些语言里你必须写 class MyEnv implements ItfEnv,
也就是实现方必须知道接口的名字。
★★ Go 反过来:接口通常由【使用方】定义,实现方不声明、也不需要 import。
后果:框架和你的代码可以完全不认识对方。
这就是 41 讲第 2.5 节说"Env是一个接缝"的全部技术基础——
接缝之所以是接缝,正因为两边都不用知道对方。
第 5 步 · ⚠ 同一个词在那段代码里出现了两次,意思不一样
type itfEnv interface { // ← ① 关键字,定义一个接口
OpenEnv(rcvr interface{}, ...) error // ← ② 空接口,一个具体类型
CloseEnv()
}
interface{}(后面带一对空大括号)是"零个方法的接口"。
零个要求 → 所有类型都满足它 → 所以它的实际含义是"任意类型"。
Go 1.18 之后可以写成any,同一个东西。Python 里 ≈object,或者干脆不写类型注解。
所以那个参数读作:rcvr 是随便什么东西(框架把你的 Service 对象原样递给你)。
| 写法 | 是什么 | 含义 |
|---|---|---|
interface |
关键字 | 用来定义接口 |
interface{} / any |
一个具体类型 | 零个方法要求 → 任意类型 |
顺带:w *http.ResponseWriter 那个 * 是指向接口的指针——很不常见的写法,
而它正是 41 讲那个"为什么以指针方式传入"的问题(传指针才能替换掉它,见 2.11 和 带读-15 · 2.5)。
第 6 步 · itf 这个前缀
作者自己的命名习惯,itf = interface。
Go 社区更常见的是加
-er后缀:Reader、Writer、Stringer、Handler
——“能干这件事的东西”。看到-er结尾的类型名,八成是个接口。
2.9 ★ map / slice,以及最难的那一行
shapes map[ShapeID]*shapeOnDrawing // dict[str, shapeOnDrawing]
shapes = make(map[ShapeID]*shapeOnDrawing) // = {} ★ map 必须先 make
points []Point // list[Point]
shapes = make([]Shape, n) // = [None] * n
| Go | Python |
|---|---|
make(map[K]V) |
{} |
make([]T, n) |
[零值] * n |
append(s, x) |
s.append(x) |
len(x) |
len(x) |
delete(m, k) |
del m[k] |
for i := 0; i < n; i++ |
for i in range(n) |
for k, v := range m |
for k, v in m.items() |
现在看这一行——drawing.go 里最难读的一行:
if _, ok := p.shapes[id]; ok {
return syscall.EEXIST
}
它叠了三个 Python 没有的东西:
| # | 是什么 |
|---|---|
| ① | 查 map 可以返回两个值:值 + "在不在"的布尔 |
| ② | _ = 丢掉第一个(我不关心值,只关心在不在) |
| ③ | if 可以带一个初始化语句:分号前初始化、分号后判断;而且 ok 的作用域只在这个 if 里 |
Python 一行写完:
if id in self.shapes:
raise FileExistsError
★★ 记住这个对应:Go 用"两个返回值"表达 Python 的
in。
要值的时候就不丢掉:drawing, ok := p.data[id] if !ok { return nil, syscall.ENOENT }同一个"comma-ok"套路在 Go 里还用于类型断言(
v, ok := x.(Line))和读通道。
★★ 先说清 slice(切片)是什么:它是一个「窗口」
python3 代码/切片到底是什么.py # ★ 这一小节强烈建议先跑这个
前面一直在用"切片"这个词,只给了个"≈ Python 的
list"的翻译。这两件事差别很要紧,先说清。
Go 里有两个东西,别混
| 写法 | 是什么 | |
|---|---|---|
| 数组 array | [5]int ← 中括号里有数字 |
长度是类型的一部分([5]int 和 [3]int 是两个不同类型);传参整个拷贝 |
| 切片 slice | []int ← 中括号里没数字 |
长度可变,而且它不装数据 |
Go 里 95% 的场合用切片,数组很少直接用(
service.go那个routeTable是[][2]string——外层切片、内层数组)。
Python 里只有一个list,所以你从来不用区分这两个。
★★ 切片 = 指针 + len + cap,三个数
┌──────────────────────────────────────────┐
│ 指针 → 底层数组从哪开始 │
│ len → 我这个窗口有多长 │
│ cap → 从起点到数组末尾还剩多少空间 │
└──────────────────────────────────────────┘
★★ 数据不在切片里,在它指向的那块数组里。切片只是"我看数组的哪一段"。
所以
Points []Point这个声明里还没有任何数组——Points的值是nil,
它还没指向任何东西。底层数组是后来append或make的时候才出现的(2.1 零值)。
取范围:写法照搬 Python,行为不一样
s[1:3] s[:3] s[2:] s[:] // ★ 含头不含尾,跟 Python 一模一样
但脚本跑出来的行为差了一大截:
Go 侧
a = [10, 20, 30, 40, 50] (len=5 cap=5)
b := a[1:3] → b = [20, 30] (起点=1 len=2 cap=4)← cap=4,因为共享同一块数组
b[0] = 99
a = [10, 99, 30, 40, 50] ★ a 也变了!
Python 侧
pb = pa[1:3]; pb[0] = 99
pa = [10, 20, 30, 40, 50] ← pa 没变
★★ Go 的
s[1:3]是开一个新窗口看同一块数组;Python 的a[1:3]是拷贝一份。
Go 这么设计是为了不拷贝数据——切一百万元素的切片是 O(1)。代价是你必须记住它们共享。要真拷贝得显式写:
b := make([]int, 2) copy(b, a[1:3])
三处 Go 不支持,一处会咬中文
| Python | Go | |
|---|---|---|
| 负数索引 | s[-1]、s[-3:] |
✗ 不支持,要写 s[len(s)-1]、s[len(s)-3:] |
| 步长 | s[::2]、s[::-1] 反转 |
✗ 不支持,反转自己写循环 |
| 越界 | s[10:20] 返回空 |
✗ panic |
s := "你好世界"
len(s) → 12 // ★ 不是 4!每个汉字 UTF-8 占 3 字节
s[0:1] → 半个字节,乱码
[]rune(s)[0:2] → "你好" // ✓ 要按【字符】切,先转 []rune
★ Go 里处理中文最容易踩的坑:
len()给的是【字节数】。
字符数用len([]rune(s))或utf8.RuneCountInString(s)。
Python 天然按字符:len("你好世界")→ 4。
⚠ 最微妙的一条:append 可能让共享突然断掉
x = [10, 20, 30] (len=3 cap=5)
y := x[0:2] (len=2 cap=5)
① y = append(y, 99) —— cap 还够 → 写进共享数组
x = [10, 20, 99] ★ x 的第 3 个元素被 y 的 append 改掉了!
② 再 append 几次,cap 用完 → 换一块新数组,共享断了
x = [10, 20, 99] ← x 不再受影响
★★ 所以
append的返回值【必须接住】:s = append(s, x) // ✓ 正确 append(s, x) // ✗ 结果丢了因为它可能返回一个指向新数组的切片。
这也是为什么 Go 的append是个函数,而不是像 Python 的list.append那样原地改的方法。
一张表收口
| 问题 | Go slice | Python list |
|---|---|---|
| 本质 | 指针+len+cap 的窗口 | 数据本身的容器 |
s[a:b] |
开新窗口,共享数据 | 拷贝一份 |
s[a:b] 的成本 |
O(1) | O(b-a) |
| 负数索引 / 步长 | ✗ / ✗ | ✓ / ✓ |
| 越界 | panic | 返回空 |
| 追加 | s = append(s, x) |
s.append(x)(原地) |
| 中文字符串切片 | 按字节,会切坏 | 按字符 |
★★ 一句话:Go 的切片是"看数组的一个窗口",Python 的 list 是"数据本身"。
记住这一句,上面所有差别都能推出来。
★ 「map 必须先 make」是什么意思
Go 把"声明一个变量"和"造出那个东西"分成了两件事。
var m map[string]int // ① 只声明 —— m 现在是 nil,【没有实际的表】
m["a"] = 1 // ★ panic: assignment to entry in nil map
n := make(map[string]int) // ② 声明 + 造出来
n["a"] = 1 // ✓ 正常
d = {} # ★ Python 这一行【同时】干了两件事:声明 + 造出来
d["a"] = 1 # ✓
★ Python 的
{}就等于 Go 的make(map[...])。
Python 里根本没有"只声明不创建"这种状态,所以你从没遇到过这个问题。
为什么 Go 会有 nil map 这种状态? 因为零值(2.1):每个变量都有初始值,而 map 的零值是 nil。
而陷阱在于它的不对称——nil map 不是完全不能用:
| 操作 | 对 nil map |
|---|---|
len(m) |
0,不报错 |
m["a"] 读 |
返回零值(0),不报错 |
for k := range m |
循环 0 次,不报错 |
m["a"] = 1 写 |
✗ panic! |
★★ 读 nil map 一切正常,只有写会炸。 所以这个 bug 会一直藏着,直到某天有人写它。
心智模型:map 内部是一个指向哈希表的指针,
nil= 没有那张表。
读——"表里有 a 吗?"没有表就当没有 → 返回零值,合理;
写——"往那张表里写"→ 没有表,往哪写? → panic。
qpaint 在哪 make 的:
func newDrawing(id string) *Drawing {
p := &Drawing{
ID: id,
shapes: make(map[ShapeID]*shapeOnDrawing), // ★ 必须有这一句
}
p.list.init()
return p
}
漏掉这一句,
Add第一次执行就 panic。
而ID那个字段不用 make——只有 map / slice / channel 需要,struct 和基本类型的零值直接可用。
★ Points []Point 是什么意思,以及 []T 和 [N]T 的区别
又是「名字 类型」(2.2):
Points []Point
╰──┬─╯ ╰──┬──╯
字段名 类型
[]Point 读作"Point 的切片(slice)" ≈ Python 的 list[Point]。
★ 注意顺序跟 Python 反过来:
Go: []Point 方括号在【前】,元素类型在后 Python: list[Point] 容器在前,元素类型在方括号【里】
qpaint 里的真实例子:
| Go | Python | 出处 |
|---|---|---|
Points []Point |
Points: list[Point] |
pathData:折线的一串点 |
shapes []ShapeID |
shapes: list[str] |
Sync 的参数:一串图形 ID |
changes []Shape |
changes: list[Shape] |
Sync 的参数:一串图形 |
中括号里有没有数字,是两种不同的东西
[]Point // 切片 slice —— 长度【可变】
[2]string // 数组 array —— 长度【固定】为 2
这个区别在 service.go 那个路由表里就用上了:
var routeTable = [][2]string{
{"POST /drawings", "PostDrawings"},
{"GET /drawings/*", "GetDrawing"},
...
}
拆开读:
[][2]string
╰┬╯╰───┬──╯
│ └─ [2]string = 固定 2 个 string 的【数组】
└─ 这种数组的【切片】(可以有任意多行)
routeTable: list[tuple[str, str]] = [
("POST /drawings", "PostDrawings"),
("GET /drawings/*", "GetDrawing"),
]
★ 用
[2]string而不是[]string,是在说"每一行【一定】是两个字符串"——
一个 URL 模式、一个方法名。类型本身就在表达约束。
★★ slice 和 map 有一个重要的不对称
var s []Point // nil slice
s = append(s, p) // ✓ 完全正常!append 会替你分配
var m map[string]int // nil map
m["a"] = 1 // ✗ panic
★★
append对 nil slice 是安全的,但写 nil map 会 panic。
所以"必须先 make"这条主要是针对 map;slice 你直接append就行。
qpaint 里 slice 也用了 make,但目的不同:
shapes = make([]Shape, n) // 造一个长度 n 的切片,元素都是零值(nil)
shapes = [None] * n # 然后逐个填
——因为接下来要按下标赋值 shapes[i] = item.data,所以得先有位置。
★★ 一个真实的例子:为什么这个 map 的 value 必须是指针
drawing.go 里这一行,是理解"Go 为什么要你自己选值还是指针"最好的实例:
shapes map[ShapeID]*shapeOnDrawing
↑ ↑ ╰──┬──╯ ╰──────┬──────╯
字段名 关键字 键的类型 值的类型(★ 带星号)
先说清两件事
① 这一行只是声明,没有创建。 它在 type Drawing struct { ... } 里面,是在说"Drawing 有一个叫 shapes 的字段"。真正创建在别处:
func newDrawing(id string) *Drawing {
p := &Drawing{
ID: id,
shapes: make(map[ShapeID]*shapeOnDrawing), // ★ 这里才创建
}
p.list.init()
return p
}
★ Go 的 map 必须
make才能用。 只声明不make,零值是nil——读它不报错(返回零值),
但往里写会 panic。这跟 Python 的d = {}不一样,那一行同时干了声明和创建。
② value 是 *shapeOnDrawing,带星号——是"指向它的指针",不是它本身。
而这个星号不是写法偏好,是必须的。原因要先看 shapeOnDrawing 是什么。
shapeOnDrawing 是一个链表节点
type shapeOnDrawing struct {
front *shapeOnDrawing // 前一个节点
back *shapeOnDrawing // 后一个节点
data Shape // ★ 真正的图形装在这
}
为什么要套这一层?因为 Drawing 需要两种完全不同的访问方式:
| 需求 | 需要什么数据结构 |
|---|---|
按 ID 快速找到某个图形(GET /drawings/1/shapes/3) |
map,O(1) 查找 |
按层次顺序遍历(谁盖在谁上面,即画图程序的 z-order;SetZorder 要能调整) |
有序的链表 |
一个数据结构做不到这两件事,所以 qpaint 用了两个,让它们指向同一批节点:
type Drawing struct {
shapes map[ShapeID]*shapeOnDrawing // 按 ID 找 → 拿到节点
list shapeOnDrawing // 链表的头(哨兵节点)→ 顺着走一圈
}
shapes(map) list(环形双向链表)
"1" ──┐ 哨兵 ⇄ 节点A ⇄ 节点B ⇄ 节点C ⇄ 回到哨兵
"2" ──┼──▶ 同一批节点 ↑ ↑ ↑
"3" ──┘ 就是 map 指向的那三个
★★ 所以为什么 value 必须是指针
因为 map 和链表必须指向【同一个】节点。
如果写成
map[ShapeID]shapeOnDrawing(去掉星号),map 里存的就是节点的拷贝。
于是链表调整了front/back,map 里那份完全不知道——两个结构立刻对不上。
p.list.insertBack(dgshape) // 把节点挂到链表上
p.shapes[id] = dgshape // 同一个 dgshape 也放进 map
// ★ 两边是同一个对象,改一边另一边就变
★★ 而 Python 里你从来不用做这个选择——所有对象天然是引用,
d[k] = 节点存的就是引用。
Go 把这件事交给你,代价是你必须想清楚:“我要的是同一个,还是一份拷贝?”(2.11)
顺带注意 list shapeOnDrawing 没有星号——它是个值,直接嵌在 Drawing 里当哨兵头节点。
p.list.init() 把它的 front/back 都指向自己,串成一个环,insertBack 才能工作。
2.10 字段标签(tag)—— 它决定了网络协议长什么样
python3 代码/标签在干什么.py # ★ 这一节强烈建议先跑这个
第 0 步 · 先说清「序列化」是什么
内存里的对象 网络上传的文本
Line{ID:"1", Pt1:...} ──序列化──▶ {"id":"1","line":{...}}
◀─反序列化──
序列化(serialize / marshal)= 把内存里的对象变成能在网络上传的文本;反序列化 = 反过来。
★ 这就是 41 讲第 1.4 节那五步苦工里的第②步和第④步:
② 把 body 的 JSON 变成对象 ← 反序列化 ④ 把返回值变成 JSON 写回去 ← 序列化而标签的唯一作用,就是告诉序列化器"这个字段在 JSON 里该叫什么名字"。
第 1 步 · 为什么需要标签:大小写打架了
两条规矩迎面撞上:
| 规矩 | |
|---|---|
| Go 那边 | 字段必须大写才能被序列化(2.2 讲过,这是硬性的) |
| JSON 那边 | 惯例是小写(前端、各家 API 都这么写) |
所以需要一个"改名"的机制。标签就是干这个的:
ID ShapeID `json:"id"`
╰┬╯ ╰────┬───╯
Go 里叫 ID JSON 里叫 id
Python 里最接近的是 pydantic 的
Field(alias="id")。
第 2 步 · ★★ 标签本身什么也不干 —— 它只是一段字符串
Go 的标签就是一个字符串字面量,贴在字段后面。编译器几乎不管它,只是把它存进类型信息里。真正读它的是库,在运行时通过反射拿出来。
脚本里我们自己当那个库,写了一个"读标签的序列化器",然后加一个开关对比:
① 不读标签(用 Go 的字段名)
{"ID": "1", "line": {"Pt1": {"X": 2.0, "Y": 3.0}, ...}}
② 读标签
{"id": "1", "line": {"pt1": {"x": 2.0, "y": 3.0}, ...}}
★★ 同一个对象、同一个序列化器,只差"要不要读标签"这一个开关。
这就证明了:标签什么也不干,是库在读它。推论:换一个不认识
json:的库,这些标签就完全不起作用——所以标签总要写明"给谁看的"。
第 3 步 · 标签的语法
`json:"points,omitempty"`
╰─┬╯ ╰──┬─╯ ╰───┬────╯
│ │ └─ 选项,逗号分隔,可以有多个
│ └─ JSON 里的名字
└─ 给哪个库看的(这里是 encoding/json)
反引号里可以塞多个库的标签,用空格分开:
Name string `json:"name" bson:"name" xml:"Name"`
// json 库只读 json: 那段,mongodb 驱动只读 bson: 那段,互不干扰
// (42 讲上 mongodb 的时候会用到 bson 标签)
常见选项:
| 选项 | 作用 |
|---|---|
omitempty |
值为"空"(0 / "" / false / nil / 空切片)时,整个字段不输出 |
- |
json:"-" 完全不序列化这个字段(比如密码) |
string |
把数字当字符串输出(对付 JS 大整数精度问题) |
pathData 的 Points 和 Close 都带了 omitempty,脚本实测:
有点、且闭合 → {"points": [{"x": 1.0, "y": 2.0}], "close": true, "style": {...}}
没有点、未闭合 → {"style": {...}}
★ points 和 close 【整个消失了】
目的是让协议包更小、更干净——“没有就别传”。
第 4 步 · ★★ 嵌入字段的标签:平铺 还是 嵌套
这是 shape.go 最关键的一处,也最容易读错:
type Line struct {
shapeBase `json:",inline"` // ← 名字那半是【空的】 → 平铺
lineData `json:"line"` // ← 名字是 "line" → 嵌套在 "line" 下
}
encoding/json 对嵌入字段(2.7 那个"没有字段名"的)的规则只有两条:
| 标签里 | 结果 |
|---|---|
| 没给名字 | 保持"匿名"状态 → 它的字段平铺到外层(这是默认行为) |
| 给了名字 | 当成一个普通的命名字段 → 嵌套在那个 key 下 |
⚠ 一处需要澄清(我上一版写得不准):
json:",inline"里那个inline
不是标准库的选项——encoding/json不认识它,会忽略。
真正起作用的是"逗号前面是空的",也就是"没给名字"。所以这个
inline更像是作者写给人看的注释,提醒你"这里是平铺的"。
匿名字段本来就默认平铺,不写inline效果一样。
对比一下改了会怎样:
真代码(平铺 + 嵌套)
{"id": "1", "line": {"pt1": ..., "pt2": ..., "style": ...}}
假设 shapeBase 也给个名字(json:"base")
{"base": {"id": "1"}, "line": {...}}
★ id 被包进了 "base" 里 —— 协议就变了,前端得跟着改
第 5 步 · 收口:标签就是你对外的承诺
把 shape.go 里跟一条直线相关的七个标签连起来看:
shapeBase.ID `json:"id"`
Line.shapeBase `json:",inline"` ← 平铺
Line.lineData `json:"line"` ← 嵌套
lineData.Pt1/Pt2 `json:"pt1"` `json:"pt2"`
lineData.Style `json:"style"`
Point.X/Y `json:"x"` `json:"y"`
ShapeStyle.LineWidth `json:"lineWidth"`
产生的 JSON:
{
"id": "1",
"line": {
"pt1": {"x": 2.0, "y": 3.0},
"pt2": {"x": 15.0, "y": 30.0},
"style": {"lineWidth": 3, "lineColor": "red"}
}
}
★★ 而 41 讲原文那段测试脚本里的
$(line1)——【一模一样】。所以这一节的标题不是夸张:
改一个反引号里的字符串 → 网络协议变了 → 前端代码要改 → 测试脚本要改而 40 讲第 7.1 节说过:已发布的 API 是一份不能撤回的承诺。
也就是说:这些反引号,是你对外承诺的一部分。
2.11 ★ 指针:* 和 &
python3 代码/指针到底在解决什么.py # ★ 这一节强烈建议先跑这个
第 0 步 · 你其实已经有这个直觉了,只是没意识到
def 改列表(x): x.append(4)
def 改数字(x): x += 1
a = [1,2,3]; 改列表(a); print(a) # [1, 2, 3, 4] ★ 变了
b = 1; 改数字(b); print(b) # 1 ★ 没变
★ "值 vs 引用"这件事你早就在用了,只是 Python 【按类型】帮你定了:
容器(list/dict/对象)→ 引用;数字/字符串/元组 → 不可变,用起来像值。★★ Go 不按类型定,【按你写不写那个星号定】。
所以 Go 里同一个类型既能当值用、也能当指针用——多了两个符号,换来了选择权。
第 1 步 · Go 默认是【值】,也就是拷贝一份
func 改(d Drawing) { d.ID = "改了" } // 值:收到【拷贝】
func 改(d *Drawing) { d.ID = "改了" } // 指针:收到【地址】
值传递之后: Drawing(ID='1') ★ 没变,改的是拷贝
指针传递之后: Drawing(ID='改了') ★ 变了
★ Go 传参默认整个拷贝——这跟 Python 完全相反(Python 传对象一律是引用)。
第 2 步 · ★★ 同一个星号,两个位置,两个意思
| 符号 | 出现在哪 | 意思 | qpaint 里的例子 |
|---|---|---|---|
*T |
【类型】的位置 | "指向 T 的指针"这个类型 | p *Drawing、map[K]*shapeOnDrawing、func(...) *Drawing |
&x |
【表达式】里 | 取 x 的地址(值→指针) |
&Drawing{ID: id}、&Line{...} |
*p |
【表达式】里 | 取指针指向的值(指针→值) | *p.Line、*w = 包装器{...} |
nil |
—— | 空指针 / 空接口 | ≈ Python 的 None |
★★ 判断法:看它在「名字 类型」的哪一半(2.2 那条规则)。
在类型那半 → 它是类型的一部分;在表达式里 → 它是一个操作。
真代码里两者同时出现:
func newDrawing(id string) *Drawing { // ← 类型位置:返回"Drawing 的指针"
p := &Drawing{ID: id} // ← 表达式:取地址
return p
}
第 3 步 · 一个贴心简化:访问字段不用写解引用
p := &Drawing{ID: "1"}
p.ID // ✓ 直接写 —— Go 自动帮你解引用
(*p).ID // 等价,但没人这么写
C 语言里得写
p->ID,Go 统一成了.。
★ 所以读 qpaint 时你几乎看不到*p这种解引用写法——这就是为什么"先把*和&当成对象引用、忽略它"是可行的。唯一的例外:当你要替换指针指向的那个值本身时,必须写
*p(第 6 步)。
第 4 步 · 方法的 receiver 为什么几乎总是指针
func (p *Drawing) Add(...) // 指针 receiver:能改原对象
func (p Drawing) Add(...) // 值 receiver:收到拷贝,【改不动】
qpaint 的方法全部用 *,因为 Add / Delete / Set / SetZorder 都要改状态。
★ 但 shapeBase.GetID() 只读,为什么也用指针? 因为 Go 有一条方法集规则:
指针 receiver 的方法,只属于 *T,不属于 T。
Line (值) → 没有 GetID → 【不满足 Shape 接口】
*Line(指针)→ 有 GetID → 【满足 Shape 接口】
★★ 这就解释了真代码里那个
&:func (p *serviceShape) Get() Shape { return &Line{shapeBase: shapeBase{p.ID}, lineData: *p.Line} // ↑ 必须是 &Line 而不是 Line,否则它不是 Shape,编译就报错 }
第 5 步 · ★★ 取地址到底有什么用:按「不用它就做不到」排序
大多数教材把"避免大对象拷贝"排第一,这是误导。 那只是性能优化。
下面前四条才是不用指针就真的做不到的事。
① ★★ 表达「同一个」—— 这条无法替代
值语义根本无法表达"两个地方看的是同一个东西"。这不是慢,是做不到。
qpaint 里有一处,如果不用指针,整个服务端会静默失效:
func (p *Document) Get(id string) (drawing *Drawing, err error) // ← 返回指针
func (p *Service) PostShapes(aShape *serviceShape, env *restrpc.Env) (err error) {
drawing, err := p.doc.Get(id)
if err != nil { return }
return drawing.Add(aShape.Get()) // ★ 往拿到的这个 drawing 里加图形
}
假设把 Get 改成返回值类型 Drawing:
doc 的 map 里: "10001" → Drawing{shapes: {...}}
Get 返回: 那个 Drawing 的【一份拷贝】
drawing.Add(线): 图形被加进了【拷贝】里
函数返回: 拷贝被丢弃
★ map 里那个 Drawing 一个字没变
★★
POST /drawings/10001/shapes会返回 200,但什么都没存进去。
不报错、不 panic、静静地什么都不做。而且这条修不了——你没法"返回一个能改到 map 里那个对象的值"。
指针的第一用处,就是让Get拿回来的是"那个 Drawing",而不是"一个长得一样的 Drawing"。
同类的还有 2.9 那个 map[ShapeID]*shapeOnDrawing:map 和链表必须看同一批节点,值语义直接做不到。
② ★ 表达「可能没有」(nil)—— 这条也无法替代
type serviceShape struct {
ID string `json:"id"`
Path *pathData `json:"path,omitempty"` // ★ 四个都是指针
Line *lineData `json:"line,omitempty"`
Rect *rectData `json:"rect,omitempty"`
Ellipse *ellipseData `json:"ellipse,omitempty"`
}
func (p *serviceShape) Get() Shape {
if p.Path != nil { return &Path{...} } // ★ 靠 nil 判断
if p.Line != nil { return &Line{...} }
...
}
客户端传来的 JSON 只带其中一个:{"id":"1","line":{...}} 是直线,{"id":"2","rect":{...}} 是矩形。服务端要判断"到底是哪一种"。脚本实测两种写法:
── 写法 A:值类型 Line lineData ──
JSON 里【没有】 line → 字段是 lineData((0,0)→(0,0))
JSON 里传了个全 0 的 line → 字段是 lineData((0,0)→(0,0))
✗ 两种情况【完全分不清】
── 写法 B:指针类型 Line *lineData(真代码)──
JSON 里【没有】 line → nil
JSON 里传了个全 0 的 line → lineData((0,0)→(0,0))
✓ 一眼分得清
★★ Go 没有
Optional,nil指针就是它。
Python 里你直接用None或"line" in body——所以从来不需要这一招。
搭配json:",omitempty"(2.10)就是完整的"可选字段"机制。
③ ★ 让别人能【替换掉】你手上的东西
*w = 我的包装器{原始: *w}
传值只能读。要包一层做审计日志,必须能改写调用方手上那个变量本身——这就是 41 讲那个问题的答案,也是整个中间件机制(第 7 步详讲)。
④ 方法接收者:不用指针,API 会变得没法用
func (p *Drawing) Add(shape Shape) error // 指针
func (p Drawing) Add(shape Shape) Drawing // 值:只能返回一个新的 Drawing
值语义下每次调用都得写 drawing = drawing.Add(线);而 Drawing 在 map 里的话就是 doc[id] = doc[id].Add(线)——而且一旦两个地方持有同一张图,立刻就错了(回到第①条)。
⑤ 避免大对象拷贝 —— 最不重要的一条
它只是性能优化。前四条才是"不用就做不到"。
第 5b 步 · 反过来问:那为什么不像 Python 那样全用引用?
因为值语义有三个真实的好处,Go 有意保留它:
| 好处 | |
|---|---|
| 能放栈上,不产生垃圾 | 值不逃逸就不用 GC 管;全是指针会给 GC 极大压力 |
| 没有别名问题(aliasing) | 你手上这份东西没人能偷偷改,读代码时不用担心"它会不会在别处变了" |
| 小结构体拷贝比追指针快 | Point 才 16 字节,拷贝一次比解引用一次更快(cache locality) |
看 qpaint 怎么混用的——这个选择很清楚:
Pt1 Point // 值:小、不共享、拷了也无所谓
Style ShapeStyle // 值
lineData // 值:嵌在 Line 里
list shapeOnDrawing // 值:哨兵头节点,只有一个,不共享
shapes map[ShapeID]*shapeOnDrawing // 指针:★ 要和链表共享
data Shape // 接口:内部本来就是指针
★★ 一句话:Go 默认值语义(安全、快、无 GC 压力),只在四种情况下显式转指针——
要共享同一个 · 要表达"没有" · 要被替换 · 要改接收者。Python 全用引用,所以你从来不用做这个决定;
代价是你也没法要求"这份数据没人能改"。
第 6 步 · ★ 逐字读那一行:& 和 * 同时出现
return &Line{shapeBase: shapeBase{p.ID}, lineData: *p.Line}
╰┬╯ ╰──────┬───────────────╯ ╰────┬─────╯
│ │ │
│ │ └─ *p.Line:【解引用】
│ │ p.Line 是 *lineData(指针)
│ │ *p.Line 取出那个 lineData 的值
│ │ → 拷贝一份填进 Line 里
│ │
│ └─ shapeBase{p.ID}:结构体字面量,按【位置】赋值
│ (shapeBase 只有一个字段 ID,可以不写字段名)
│
└─ &Line{...}:造一个 Line 并【取它的地址】
→ 返回 *Line,因为只有 *Line 才满足 Shape(第 4 步)
★ 一行里同时用了取地址和解引用,方向相反:
& 值 → 指针 * 指针 → 值
第 7 步 · ★★ 回答 41 讲那个问题:为什么 ResponseWriter 以指针传入
OpenEnv(rcvr interface{}, w *http.ResponseWriter, req *http.Request) error
╰────────┬─────────╯
指向【接口】的指针 —— 很罕见的写法
为什么?因为框架想让你【替换掉】那个东西,不只是读它。 脚本实测:
传值之后,调用方手上还是: 原始ResponseWriter ✗ 换不掉
传指针之后,调用方手上是: 包装器(原始ResponseWriter) ★ 被换掉了
Go 里对应的写法:
func (p *我的Env) OpenEnv(rcvr interface{}, w *http.ResponseWriter, req *http.Request) error {
*w = 我的包装器{原始: *w} // ★ 用 *w 替换掉调用方手上那个接口值
return nil
}
func (p *我的Env) CloseEnv() {
// 请求结束了,从包装器里把记录下来的响应内容拿出来写审计日志
}
★★ 一句话:传值 = 只能读;传指针 = 能换掉。
原文那句"假设我们要给 RPC 框架扩展 API 审计日志的功能,就需要接管并记录用户返回的 HTTP 包,
这时我们就需要改写ResponseWriter"——"改写"两个字,靠的就是这个指针。这就是整个中间件机制的底层技术(见 带读 15 · 2.5)。
第三部分 · 速查表(当字典查,别通读)
3.1 Python ↔ Go 一页对照
| Python | Go |
|---|---|
x = 1 |
x := 1 |
def f(a: int) -> str: |
func f(a int) string { |
class A: + def m(self) |
type A struct{} + func (p *A) m() |
class A(B): |
type A struct { B }(嵌入) |
raise X / try/except |
return err / if err != nil |
None |
nil |
{} / dict[K,V] |
make(map[K]V) / map[K]V |
[] / list[T] |
[]T / make([]T, n) |
x in d |
_, ok := d[x]; ok |
for x in xs: |
for _, x := range xs { |
for i in range(n): |
for i := 0; i < n; i++ { |
while True: |
for { |
with lock: |
lock.Lock() + defer lock.Unlock() |
isinstance(x, T) |
v, ok := x.(T) |
Protocol |
interface |
object / 不写类型 |
interface{} / any |
下划线开头 _x 表示私有(约定) |
小写首字母表示私有(强制) |
3.2 五个不知道会卡住的坑
| # | 坑 |
|---|---|
| 1 | 首字母大写 = 导出。shapeBase 和 Shape 的差别是可见性,不是命名风格(1.2 节) |
| 2 | type X = Y 是别名,type X Y 是新类型。一个等号差很多 |
| 3 | := 只能在函数内。包级别用 var |
| 4 | map 必须先 make,否则往 nil map 里写会 panic。但读 nil map 完全正常——所以这个 bug 藏得住(2.9 详解) |
| 5 | map 不是并发安全的,而且并发读写是直接崩溃(不是结果不对)——见 4.3 |
3.2b Go 真正反直觉的两条【命名】规则
| 规则 | 反直觉在哪 | 在哪一节 |
|---|---|---|
| 首字母大小写 = 可见性 | 别的语言用 public / private 关键字;Go 用大小写,而且是强制的,不是约定 |
1.2 · 2.2 |
接口常用 -er 后缀 |
Reader、Writer、Handler——名词形态的动词,第一次见会以为是个类 |
2.8 |
好消息是这两条规则数量少、没有例外。
而"名字在前类型在后"那件事其实和 Python 一样,只是少了冒号(见 2.2 末尾)。
3.3 Go 里【没有】的东西(省得你去找)
异常 · 类继承 · super() · 三目运算符 ? : · 默认参数 · 函数重载 · 装饰器 · 列表推导式 · while(只有 for)。
第四部分 · 验收:逐行读两个真函数
4.1 业务层:Drawing.Add(drawing.go 第 103 行)
func (p *Drawing) Add(shape Shape) (err error) {
id := shape.GetID()
dgshape := &shapeOnDrawing{ data: shape }
p.mutex.Lock()
defer p.mutex.Unlock()
if _, ok := p.shapes[id]; ok {
return syscall.EEXIST
}
p.list.insertBack(dgshape)
p.shapes[id] = dgshape
return
}
| 行 | 读法 |
|---|---|
func (p *Drawing) Add(shape Shape) (err error) |
Drawing 的方法 Add,收一个 Shape,返回一个命名的 err(2.2 · 2.3 · 2.5) |
id := shape.GetID() |
声明 + 推断类型(2.1)。注意它调的是接口方法——不管进来的是 Line 还是 Rect(2.8) |
dgshape := &shapeOnDrawing{...} |
造一个对象并取地址(2.11)。shapeOnDrawing 小写 = 内部类型(1.2) |
p.mutex.Lock() / defer ...Unlock() |
≈ with self.mutex:(2.6) |
if _, ok := p.shapes[id]; ok { |
≈ if id in self.shapes:(2.9) |
return syscall.EEXIST |
返回"已存在"这个错误值,协议层会把它翻成 409(2.4) |
return |
光秃秃的 return = return err,此时 err 是 nil(2.5) |
4.2 协议层:Service.GetShape(v41 的 service.go 第 88 行)
func (p *Service) GetShape(env *restrpc.Env) (shape Shape, err error) {
id := env.Args[0]
drawing, err := p.doc.Get(id)
if err != nil {
return
}
shapeID := env.Args[1]
return drawing.Get(shapeID)
}
| 行 | 读法 |
|---|---|
(shape Shape, err error) |
两个命名返回值。框架会把 shape 自动序列化成 JSON、把 err 自动翻成状态码 |
env.Args[0] |
URL 里的第一个通配参数(/drawings/*/shapes/* 里的第一个 *) |
drawing, err := p.doc.Get(id) |
多返回值(2.4) |
if err != nil { return } |
出错就返回——注意它不写状态码,那是框架的事 |
return drawing.Get(shapeID) |
直接把业务层的两个返回值原样透传出去 |
★★ 整个函数里没有一个字提到 HTTP。
这就是 41 讲那句"看起来不再那么像 HTTP 处理函数,倒像一个普通函数"的字面意思。
对比 v31 的同一个函数(service.go),
那一版的签名是(w http.ResponseWriter, req *http.Request, args []string)——满是协议。
4.3 顺手记一个真实的坑
回头看真源码,Add 加了锁,但 Get 没加:
func (p *Drawing) Get(id ShapeID) (shape Shape, err error) {
if dgshape, ok := p.shapes[id]; ok { // ★ 没有 Lock
return dgshape.data, nil
}
return nil, syscall.ENOENT
}
★ 在 Go 里这件事比在 Python 里严重得多:Go 的 map 不是并发安全的,
而且并发读写不是"结果不对",是运行时直接崩溃:
fatal error: concurrent map read and map write。教学代码简化情有可原,但这一条值得记住:Go 的 map 必须自己加锁(或用
sync.Map)。
(42 讲把这一层换成 mongodb 之后,这个问题自然消失——并发控制交给数据库了。)
4.4 九个自测问题
| # | 问题 |
|---|---|
| 1 | func (p *Service) PostShapes(aShape *serviceShape, env *restrpc.Env) (err error) —— 几个参数?几个返回值?分别叫什么、什么类型? |
| 2 | 为什么 shapeBase 小写、Shape 大写?这个差别在 Go 里意味着什么? |
| 3 | type Line struct { shapeBase; lineData } 里两个字段都没有名字,这叫什么?它让 Line 白拿了什么? |
| 4 | if _, ok := p.shapes[id]; ok { 翻译成 Python 是哪一行?三个陌生点各是什么? |
| 5 | 一个函数最后光秃秃写着 return,它返回的是什么?错误是在哪一步被装进返回值的? |
| 6 | type itfEnv interface {...} 里哪些字是 Go 的关键字?interface 和 interface{} 差在哪?你要写一个自己的 Env,需要在代码里提到 itfEnv 吗? |
| 7 | 为什么 ResponseWriter 要以指针方式传入? |
| 8 | shapes map[ShapeID]*shapeOnDrawing —— key 和 value 分别是什么?如果把星号去掉会出什么问题? |
| 9 | var m map[string]int 之后,len(m)、读 m["a"]、写 m["a"]=1 分别会怎样?[]string 和 [3]string 差在哪? |
答案
1. 这是 Service 的方法(函数名前面那个括号里的 p 就是 self)。
2 个参数:aShape(类型 *serviceShape)、env(类型 *restrpc.Env)。
1 个返回值:err(类型 error)。
记住三个括号各管一件事:①谁的方法 ②参数 ③返回值;每项都是 名字 类型,
而 error 只是个类型名,跟 string、int 平级。
2. Go 用首字母大小写表示可见性(没有 public/private 关键字):大写 = 导出,包外可用;小写 = 只有本包内部能用。所以 Shape/Line/Point 是对外的 API,shapeBase/lineData 是内部实现细节。→ 在 Go 里"改可见性"就是改一个字母。
★ 而且还有一条硬性的:Go 的 encoding/json 只能序列化大写(导出)的字段。
所以 shapeBase 里的 ID 必须大写,否则返回给前端的 JSON 里【根本不会有这个字段】——
而 41 讲那个测试脚本第一件事就是断言 {"id": "10001"},会直接失败。
3. 叫嵌入(embedding)。它把 shapeBase 的字段和方法都提升到外层:line.ID 和 line.GetID() 都能直接用。于是 Line 自动满足了 Shape 接口——因为 Go 的接口是隐式的,不用声明。89 行的 shape.go 就干了这一件事。
4. ≈ if id in self.shapes:。三个陌生点:① 查 map 返回两个值(值 + 在不在);② _ 丢掉第一个;③ if 可以带初始化语句(分号前初始化、分号后判断),且 ok 只在这个 if 里有效。
5. 返回命名返回值当前的值。func Add(...) (err error) 里 err 已经是个变量(零值 nil),所以 return ≡ return err。看到光秃秃的 return 就去看函数签名。
错误是在 drawing, err := p.doc.Get(id) 那一行装进去的——因为那个 err 就是签名里声明的那个变量。
所以 Go 把 Python raise 的两件事拆成了:赋值(装好)+ 提前 return(抛出)。
6. type 和 interface 是关键字,itfEnv/OpenEnv/CloseEnv 都是作者自己起的名字。
interface 是关键字(用来定义接口);interface{}(带空大括号)是一个具体类型——
零个方法要求,所以所有类型都满足,实际含义是"任意类型"(新写法 any)。
写自己的 Env 一个字都不用提 itfEnv:只要有那两个方法就自动满足。
Go 的接口是隐式实现的,而且通常由【使用方】定义——这正是"框架和你的代码互不认识"的原因。
7. 因为传指针,框架才能替换掉你手上那个对象。要做 API 审计日志,就得包一层自己的 ResponseWriter 去接管并记录返回内容。传值 = 只能读;传指针 = 能换掉。 这一条就是整个中间件机制(详见 带读-15 · 2.5)。
8. key 是 ShapeID(也就是 string),value 是 *shapeOnDrawing(指向节点的指针);格式永远是 map[键]值。
去掉星号后 map 里存的会变成节点的拷贝——链表改了 front/back,map 里那份不会跟着变,
按 ID 查出来的节点和链表上的节点变成两个不同的东西,层次顺序和查找结果就对不上了。
指针在这里保证"map 和链表指向同一个节点"。
(另外这一行只是【声明】,真正创建要靠 make。)
9. len(m) → 0,不报错;读 m["a"] → 返回零值 0,不报错;写 m["a"]=1 → panic。
读没事,只有写会炸——这个不对称是它藏得住的原因(心智模型:map 内部是指向哈希表的指针,nil = 没有那张表)。
[]string 是切片,长度可变;[3]string 是数组,长度固定为 3。中括号里有没有数字,是两种不同的类型。
顺带:append 对 nil slice 是安全的,所以"必须先 make"主要是针对 map。
下一步
有了 Go 底子,41 讲那几处费解的地方就都能读了:
1.4 "RPC 框架"到底是什么(五步苦工里你只写第三步)
2.4 Env = with 语句 ← 靠本篇 2.6 · 2.8
2.5 "传指针" = 中间件机制 ← 靠本篇 2.11
2.6 自动路由的方法名规则
三 业务/协议分层,以及一个可观测的指标
对照阅读:前端那半边的预备课是
带读 00 · 读懂 qpaint 需要的前端底子(JavaScript + 浏览器)。
