加载中...

配套可跑脚本(五个,都不用装 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.godrawing.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 只是一个类型名,跟 stringint 完全平级(它本质是个只有一个 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 只能序列化大写(导出)的字段。
    写成小写 idJSON 序列化会直接忽略它,出来是个空对象。

★ 所以 ID 必须大写,这不是风格偏好,是硬性要求。

ShapeID 是类型 —— 而它就是 string

翻到 shape.go 最上面(第 5~6 行):

type coord   = float64
type ShapeID = string

注意那个等号。=type别名ShapeIDstring同一个类型,只是换了个名字。所以这两行完全等价:

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)
内置类型名 stringintint64float64boolerror
首字母大写的已定义类型 PointShapeShapeStyleShapeIDDrawing

★ 还有一个免费的帮手: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 Drawingd *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 的约定:错误永远是最后一个返回值,类型是 errorerror 本身只是一个接口(有个 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 { nilNone。这句是"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) 只要 LineGetID(),它就是 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 后缀ReaderWriterStringerHandler
——“能干这件事的东西”。看到 -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
它还没指向任何东西。底层数组是后来 appendmake 的时候才出现的(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 大整数精度问题)

pathDataPointsClose 都带了 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 *Drawingmap[K]*shapeOnDrawingfunc(...) *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]*shapeOnDrawingmap 和链表必须看同一批节点,值语义直接做不到。

② ★ 表达「可能没有」(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 没有 Optionalnil 指针就是它。
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 首字母大写 = 导出shapeBaseShape 的差别是可见性,不是命名风格(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 后缀 ReaderWriterHandler——名词形态的动词,第一次见会以为是个类 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,此时 errnil(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 的关键字?interfaceinterface{} 差在哪?你要写一个自己的 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 只是个类型名,跟 stringint 平级。

2. Go 用首字母大小写表示可见性(没有 public/private 关键字):大写 = 导出,包外可用;小写 = 只有本包内部能用。所以 Shape/Line/Point对外的 APIshapeBase/lineData内部实现细节。→ 在 Go 里"改可见性"就是改一个字母。
★ 而且还有一条硬性的:Go 的 encoding/json 只能序列化大写(导出)的字段
所以 shapeBase 里的 ID 必须大写,否则返回给前端的 JSON 里【根本不会有这个字段】——
而 41 讲那个测试脚本第一件事就是断言 {"id": "10001"},会直接失败。

3.嵌入(embedding)。它把 shapeBase字段和方法都提升到外层line.IDline.GetID() 都能直接用。于是 Line 自动满足了 Shape 接口——因为 Go 的接口是隐式的,不用声明。89 行的 shape.go 就干了这一件事。

4.if id in self.shapes:。三个陌生点:① 查 map 返回两个值(值 + 在不在);② _ 丢掉第一个;③ if 可以带初始化语句(分号前初始化、分号后判断),且 ok 只在这个 if 里有效。

5. 返回命名返回值当前的值func Add(...) (err error)err 已经是个变量(零值 nil),所以 returnreturn err。看到光秃秃的 return 就去看函数签名。
错误是在 drawing, err := p.doc.Get(id) 那一行装进去的——因为那个 err 就是签名里声明的那个变量。
所以 Go 把 Python raise 的两件事拆成了:赋值(装好)+ 提前 return(抛出)

6. typeinterface 是关键字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"]=1panic
读没事,只有写会炸——这个不对称是它藏得住的原因(心智模型:map 内部是指向哈希表的指针,nil = 没有那张表)。
[]string切片,长度可变;[3]string数组,长度固定为 3。中括号里有没有数字,是两种不同的类型。
顺带:append 对 nil slice 是安全的,所以"必须先 make"主要是针对 map。


下一步

带读 15 · 41 讲:RPC 框架、分层与单元测试

有了 Go 底子,41 讲那几处费解的地方就都能读了:
    1.4  "RPC 框架"到底是什么(五步苦工里你只写第三步)
    2.4  Env = with 语句              ← 靠本篇 2.6 · 2.8
    2.5  "传指针" = 中间件机制         ← 靠本篇 2.11
    2.6  自动路由的方法名规则
    三    业务/协议分层,以及一个可观测的指标

对照阅读:前端那半边的预备课是
带读 00 · 读懂 qpaint 需要的前端底子(JavaScript + 浏览器)。

公告栏
这是我的个人知识库。
记录技术,也记录生活 —— 读过的、试过的、想明白的,都堆在这儿。
最新文章
网站资讯
文章数目 :
5
已运行时间 :
本站总字数 :
15.7k
本站访客数 :
本站总访问量 :
最后更新时间 :
全局知识图谱
当前页面 已访问 文章 标签
ESC 关闭 · 滚轮缩放 · 拖拽移动 · Ctrl+G 开关