这一章从大一新生的起点开始。你可以已经会 Python 的变量、if、循环和最简单的函数,但不需要会 C、指针或 Go。目标不是把 Go 讲完,而是让你能顺着 Pysolate Spine 的 Host 代码读下去:Go 程序在 Wasm 外面创建 Runner,向 Guest 传入 JSON,接收结果,再负责关闭资源。
每节都按“问题 → 小例子 → 术语 → 仓库中的对应代码 → 常见误解/自测”展开。代码块前如果写着“完整程序”,它有自己的 package main,只使用标准库,可以保存为 .go 文件运行;写着“不完整片段”的代码只用于对照,不能单独运行。
1. 一个 Go 文件为什么从 package 开始?
小问题: Python 可以直接运行一个脚本。Go 为什么先写 package main,还要有 main 函数?
示例: package 是源文件所属的包(package,一组放在同一目录、可以一起编译的 Go 文件)。main 包表示可执行程序;其中名为 main 的函数是程序入口。库代码通常使用别的包名,例如仓库根目录的 Go 文件是 package spine,它提供 Runner,而不是直接启动命令。
术语: 模块(module)是一个可独立构建的 Go 工程边界,模块路径写在 go.mod 的 module 行:pysolate-spine。导入(import)使用模块路径加包目录;导入名默认来自包声明,也可以用别名改名。
对应真实代码: cmd/demo/main.go 是 package main,导入 spine "pysolate-spine"。spine 是本地别名,右侧仍是模块路径;它调用 spine.New 和 Runner 的方法。根目录的 runner.go 是 package spine,因此同一模块里的命令可以把它作为库使用。
常见误解 / 自测: package main 不是“所有 Go 文件都必须写 main”;库文件写 package spine 才能被导入。试着回答:如果把 cmd/demo/main.go 的包名改成 spine,它还会是可直接运行的命令吗?
Go 的大小写还决定包外可见性:New、Runner、Run 等首字母大写的名字可以从别的包使用;run、newGuest 等小写名字只在本包内部使用。Run 和 run 是两个不同名字。这里的“导出”是 Go 包的可见性规则,不要与 Wasm 导出函数、或 Host 工具授权混成一回事。
2. 变量、类型和零值:Go 不等运行时才决定类型
小问题: Python 变量可以先放整数、后放字符串。Go 的变量是什么?
示例: Go 变量有静态类型,类型在编译时确定。var count int 声明一个整数变量;count = 3 只能赋整数。函数内部常用短声明 name := "book",它同时声明并赋值,不能在没有新变量的情况下重复使用 :=。var 不写初始值时,Go 会放入零值:整数是 0,字符串是 "",布尔值是 false,指针、切片、map、接口和函数值的零值是 nil。
类型不是装饰。uint32 和 uint64 在本项目里对应 Wasm ABI 的无符号整数;[]byte 是字节切片;any 是可以装入不同具体值的接口类型。不同类型之间不会因为“看起来像数字”就自动转换。
术语: 零值(zero value)是变量声明后、没有显式赋值时的合法初始值。它让很多结构体可以直接使用,但 nil map 的“读取”和“写入”仍有不同规则,详见下一章。
对应真实代码: runner.go 的 Runner 字段有 image []byte;没有 prepared 基线时它保持 nil。New 用 make(map[string]bool) 创建 earlyReads,因为后面要写入 map。Output 的 Value json.RawMessage 也可能是 nil,这和成功的 JSON null 要结合上下文理解。
控制流写法也有边界:if err != nil { ... } 不需要括号;Go 没有 while 关键字,只有 for 的不同写法;switch 可以按值分支,也可以不写表达式。大括号是语法的一部分,不能省略。
常见误解 / 自测: := 不是“把值转成任意类型”;它只是局部变量的声明语法。为什么 var r *Runner 可以声明,但直接拿它完成 r.Run 会出问题?Go 允许把 nil 作为指针 receiver 传入方法,是否出错取决于方法是否解引用它;当前 Run 需要一个已创建的 Runner。先区分“有一个指针变量”和“指针指向一个已创建对象”。
3. 函数、多返回值和 error:失败也是返回值
小问题: Python 用异常表示失败,Go 为什么到处写 value, err := ...?
示例: Go 函数可以返回多个值。函数签名描述参数和返回值的类型规则;它不是一次函数调用。内建 error 接口要求一个 Error() string 方法,错误值可通过它给出消息。error 是一个接口类型,约定 nil 表示没有错误;非 nil 表示调用失败。调用者通常先检查错误,再使用正常结果。Go 不会自动把错误抛到更外层,也不会因为你忽略 err 就替你处理。
完整程序 1:解析输入并返回两个结果
先认三种显示函数:fmt.Println 打印值并换行;fmt.Printf 按格式打印,需要自己写 \n 表示换行;fmt.Sprintf 用相同格式生成字符串,不直接打印。格式里的 %d 是十进制整数,%s 是文本,%q 给文本加引号,%v 是常规显示,%t 是 true/false。
[]string{...} 先当作一组字符串,切片的存储规则下一章再讲。for index, value := range sequence 依次取出位置和值;写成 for _, value := ... 就是用空白标识符 _ 丢弃不需要的位置。strconv.Atoi 将十进制数字字符串转成整数,遇到 "x" 这样的输入就报告错误。
下面的程序同时展示变量、零值、for、条件分支、函数多返回值和 error。保存为 main.go 后运行 go run main.go。
package main
import (
"errors"
"fmt"
"strconv"
)
func classify(n int) (string, error) {
if n < 0 {
return "", errors.New("negative values are not accepted")
}
if n == 0 {
return "zero", nil
}
return "positive", nil
}
func main() {
var count int
var label string
fmt.Printf("zero: %d %q\n", count, label)
for _, text := range []string{"7", "-2", "x"} {
n, parseErr := strconv.Atoi(text)
if parseErr != nil {
fmt.Printf("%s -> invalid number\n", text)
continue
}
kind, err := classify(n)
if err != nil {
fmt.Printf("%s -> %s\n", text, err)
continue
}
fmt.Printf("%s -> %s\n", text, kind)
}
}
预期输出(已用本机 Go 执行验证):
zero: 0 ""
7 -> positive
-2 -> negative values are not accepted
x -> invalid number
术语: return 可以同时返回多个值;nil 是某些类型的空值,不是字符串 "nil"。fmt.Errorf 可以根据格式构造错误,本例用 errors.New 构造固定消息。continue 结束当前循环轮次,进入下一轮。
对应真实代码: Runner.Run 返回 (Output, error);它先编码请求、创建 Guest、执行,再把错误交给调用者。cmd/demo/main.go 的 run 逐层检查 os.ReadFile、json.Unmarshal、Runner 构造和执行结果,最后把错误写到 stderr 并以失败状态退出。
常见误解 / 自测: err == nil 不是“结果一定有业务意义”,只表示这层没有报告错误;成功的 JSON null 仍可能是合法值。若 classify 出错后仍打印 kind,会发生什么?在真实代码中,为什么 Runner.Run 出错时仍会把已有 stdout 带回 Output?
4. struct、方法和 receiver:把数据与操作放在一起
小问题: Python 对象可以有属性和方法。Go 用什么表达?
示例: struct(结构体)是一组有名字的字段,例如 Name string、Price int。方法(method)是带 receiver(接收者)的函数:
// 不完整片段(只用于对照,不能单独运行)
type Item struct {
Name string
Price int
}
func (i Item) Total(quantity int) int {
return i.Price * quantity
}
i Item 是值 receiver,调用时会复制这个结构体值作为 receiver。func (r *Runner) Run(...) 使用 *Runner,这里 r 是指向 Runner 的指针;方法可以访问同一个 Runner,而不是复制 Runner 的结构体字段。注意:值 receiver 也不会自动深复制字段引用的 runtime、map 或底层代码;不同副本仍可能引用同一份资源。指针的细节放在下一章,本节只需要看懂方法声明。
术语: 字段是结构体里保存数据的名字;receiver 是方法声明中位于函数名前的一小段参数;指针 receiver 以 *T 表示。Go 允许编译器在一些普通变量调用方法时自动取地址或解引用,但这不意味着所有 nil 指针都安全。
对应真实代码: runner.go 的 Runner 持有 runtime、code、tools、earlyReads 和 image;Run、RunPLM、Close 都是它的方法。Output 是只描述一次执行结果的结构体。boundedText 在 runner.go 中嵌入 strings.Builder,再定义自己的 Write 方法,以便加入 1 MiB 上限。嵌入是把另一个类型作为字段放进来,并允许使用它被提升的方法;外层自己定义的同名 Write 优先,不是类继承链。
接口匹配还要区分值和指针的方法集合:这里 Write 是指针 receiver,所以传入 &boundedText{} 供需要 Write 方法的接口使用。某个普通变量能自动取地址调用方法,不等于这个值类型就自动满足所有指针方法对应的接口。
常见误解 / 自测: 方法不是“自动启动的线程”,receiver 也不是类继承。看 func (r *Runner) Close(ctx context.Context) error:它关闭的是哪个对象的 runtime?如果复制一个 Runner 结构体再调用 Close,为什么不能凭字段表面相同就断定语义安全?
5. 接口和 any:按行为接收,不是泛型
小问题: Go 没有 Python 那样的动态对象系统,为什么函数可以接收不同类型?
示例: 接口(interface)是一组方法签名。一个具体类型只要拥有这些方法,就自动满足接口,不需要写 implements。下面的 Pricer 只要求 Total(int) int;Item 因为有这个方法,所以可以传给 quote。
完整程序 2:结构体方法、隐式接口和 any
package main
import "fmt"
type Item struct {
Name string
Price int
}
func (i Item) Total(quantity int) int {
return i.Price * quantity
}
type Pricer interface {
Total(quantity int) int
}
func quote(p Pricer, quantity int) string {
return fmt.Sprintf("%d", p.Total(quantity))
}
func kindOf(value any) string {
switch value.(type) {
case Item:
return "Item"
case string:
return "string"
default:
return "other"
}
}
func main() {
book := Item{Name: "book", Price: 21}
fmt.Printf("%s x 2 = %s\n", book.Name, quote(book, 2))
fmt.Println("kind=" + kindOf(book))
fmt.Println("kind=" + kindOf("plain text"))
}
预期输出(已用本机 Go 执行验证):
book x 2 = 42
kind=Item
kind=string
any 是 interface{} 的别名,表示“这里可以装入任何 Go 值”;它不是“把类型作为参数”的泛型(generics)。泛型可以用类型参数表示一类输入,并在编译时按类型约束检查操作;any 则是保存具体值及其运行时类型的接口,本项目不要求你先写泛型算法。取出其中的具体值时,常用类型断言或类型 switch。本例的 switch value.(type) 是类型分支:依据接口里实际装的类型选择一个 case,不是让所有 case 都执行;普通 Go switch 选中分支后默认也不会继续进入下一个分支。
类型断言则是取出你预期的具体类型,例如下面这个不完整片段:
// 片段:假设放在函数内。
var value any = "book"
text, ok := value.(string) // text 为 "book",ok 为 true
number, ok := value.(int) // 不匹配:number 为 0,ok 为 false
_ = text
_ = number
_ = ok
只写一个结果的 value.(string) 若类型不匹配会 panic;两个结果的形式让你检查 ok 再决定。当前内部 Host 回调读取 runState 时,类型断言依赖的是调用路径已经建立的内部约定,不是“any 自动证明数据可信”。
对应真实代码: bridge.go 定义 Tool:func(ctx context.Context, args json.RawMessage) (any, error)。这行 type 声明定义一种函数值的形状,不会启动工具;map 条目保存函数值,执行 fn(ctx, args) 时才真正调用。工具可以返回整数、字符串或可编码的结构体,encodeResponse 再把它编码成 JSON;能放进 any 不表示一定能被 json.Marshal 编码。New 的 tools map[string]Tool 是字符串到函数的注册表,不是接口继承树。
常见误解 / 自测: “参数类型是 any”不等于“函数可以对它做任意操作”;尚未确认具体类型时,不能直接使用该类型特有的字段或算术操作。比较是否有效也取决于其中的具体类型,不能认为装进 any 就一定能安全使用 ==。为什么一个返回 channel 的工具即使能装入 any,仍可能在 encodeResponse 处失败?
6. 闭包和 defer:函数可以带着外层变量走
小问题: 一个函数返回另一个函数时,返回的函数还能访问原来的局部变量吗?资源释放为什么常写在函数前面?
示例: Go 中函数也可以作为值保存在变量里或由另一个函数返回。makeCounter(start int) func() int 的末尾表示“返回一个无参数、结果为 int 的函数”;这不代表 makeCounter 自己返回一个 int。闭包(closure)是使用了外层变量的函数。外层函数返回后,若闭包仍在使用这些变量,Go 会让所需数据继续有效。defer 把一次调用登记到当前函数退出时执行;多个 defer 按后进先出执行。它覆盖正常返回和错误返回,但只负责当前函数已经登记的动作。
完整程序 3:闭包保留状态,defer 逆序执行
package main
import "fmt"
func makeCounter(start int) func() int {
current := start
return func() int {
current++
return current
}
}
func visit() {
defer fmt.Println("defer: second")
defer fmt.Println("defer: first")
fmt.Println("body")
}
func main() {
next := makeCounter(10)
fmt.Printf("counter: %d %d\n", next(), next())
visit()
fmt.Println("after")
}
预期输出(已用本机 Go 执行验证):
counter: 11 12
body
defer: first
defer: second
after
本例里两次调用共享 current;如果分别调用 makeCounter 两次,则会得到两个独立计数器。defer 的参数在登记时求值,真正的调用在函数返回时执行,因此不要把它误解为“稍后重新读取所有变量”。
对应真实代码: Runner.run 一开始登记 defer state.close();Guest 创建成功后登记 defer m.Close(context.Background());执行结果的 Guest buffer 也通过 defer release 释放。runState.prepare 在 future.go 中登记 worker 的清理动作,但 goroutine、channel 和 context 的并发关系留给并发章节,本章不展开。
panic 与 error 也不同。error 是函数按约定返回的信息;panic 会打断普通控制流并展开当前 goroutine 的调用栈,未被恢复时会使程序失败。不要用 panic 代替工具的正常业务错误。defer 能在正常返回、错误返回和通常的 panic 展开中执行,但 os.Exit 直接结束进程,不会替你跑尚未执行的 defer。CLI 先让 run 返回并完成它自己的清理,再在 main 中决定退出码。
常见误解 / 自测: defer 不是“程序退出时的全局清理”,而是当前函数返回时的清理。若 newGuest 返回错误,run 还没有登记 m.Close,为什么 helper 自己仍需要负责失败实例?读 prepared.go 的 newGuest,找出它如何处理初始化失败。
7. 把这些语法放回一次真实运行
小问题: 现在能读单个语法,怎样把它们连成 Pysolate Spine 的调用链?
示例: CLI 的 main 解析 flag,然后调用 run。run 读取 Wasm 和 Python 文件,解析输入 JSON,创建 Runner,选择 Run 或 RunPLM,打印 Output,最后 Close。这是一层普通 Go 函数调用,不是 Go 把 Python 翻译成 Go。
术语: Host 是外部 Go 程序;Guest 是 Wasm 实例中的 CPython 和用户 Python。context.Context 在工具函数签名中传递取消和截止时间,工具应尊重它;并发实现的原理见并发章节。本章只把它当作 API 参数读法。
对应真实代码: New 建立 wazero runtime、复制工具注册表、安装 spine.call/prepare/resolve 并编译 Wasm。Runner.Run 创建一次运行状态和私有 Guest;hostCall 从 Guest 内存读请求,调用注册的 Tool,再把 JSON 响应写回。RunPLM 只是在明确模式下允许 runState.prepare 提前启动只读工具,结果仍在原调用位置由 resolve 领取;这里不解释并发调度。
go run ./cmd/demo 的结果由真实 dist/spine.wasm 决定,不能拿本章的小程序输出替代它。修改 examples/lookup.py 是换输入源码;修改 runner.go 或 Host 工具才是改 Go Host;修改 Guest 的 C、bootstrap 或 PLM 文件还要重建 Wasm。
常见误解 / 自测: CompileModule 编译的是 Wasm,不是这次收到的 Python 源码。为什么 Runner 可以复用已编译的 code,却不能把同一个活的 Python Guest 给多次 Run 共用?回答时至少说出 struct 字段所有权、defer 清理和方法 receiver 分别承担什么角色。
本章小结与练习
读完后,你应该能解释:package main 与 package spine 的区别;模块路径如何进入 import;零值为什么让结构体能先被创建;(value, error) 为什么要先检查 err;方法如何通过 receiver 访问对象;接口为什么不需要显式实现声明;any 为什么不是泛型;闭包怎样保留外层变量;defer 为什么适合登记清理动作。
先不要实现练习:
- 为 demo 增加一个只读工具时,工具注册表应放在哪一侧?为什么不先改 Guest C?
- 如果工具返回一个不能 JSON 编码的 Go 值,错误应在哪个真实函数附近出现?
- 如果
Run在创建 Guest 后失败,哪些defer仍会执行?哪些资源由newGuest自己处理?
值、指针、切片别名和 JSON 复制会改变这些答案的细节,下一章继续。