本文是对 Abstracting away correctness 的整理与翻译。
内容结构概览
- 文章核心观点:API 必须被仔细设计;一旦体验过好设计,就很难再接受"靠约定"的设计。
- Go
io.Reader的优点:只有一个方法,生态里几乎所有可读对象都实现它,组合性极强。 - 为什么
io.Reader比AltRead() []byte更好:调用者提供 buffer,可以控制最大读取量,也避免每次读取都分配。 - 内部 buffer 的陷阱:如果 reader 返回内部复用 buffer,调用者保存 slice 后,后续读取会覆盖旧数据。
- 函数签名不够表达行为 :
Read(p []byte) (n int, err error)看起来简单,但正确语义大量藏在文档里。 - Go 多返回值的问题 :
n和err可以被分别忽略,编译器不能强制你同时处理它们的状态组合。 0, nil的特殊语义:Go 文档说这不表示 EOF,只是"什么也没发生",调用方必须知道。n > 0, err == io.EOF也是合法的:读到最后一段数据时,reader 可以同时返回数据和 EOF,也可以下一次再返回 EOF。io.ReadFull也有复杂语义:少读、EOF、UnexpectedEOF、错误被丢弃等行为都要靠文档理解。- Rust
Read的对照 :read(&mut [u8]) -> Result<usize>把成功和错误压成两个分支,减少状态组合。 io.Seeker的问题 :Seek(offset int64, whence int)中whence只有 3 个有效值,却用int表达。- Rust enum 的优势:如果只有 Start / Current / End 三种情况,就应该用 enum,而不是裸整数。
- Go
bufio.Reader的问题:包装 reader 后,原 reader 仍可继续使用,二者交替读取会导致混乱。 - Rust
BufReader::new的优势:它获取 inner reader 的所有权,原 reader 被 move 后不能再误用。 io.ReaderAt的亮点:不维护当前游标,按 offset 读取,因此更容易并发安全。debug/pe案例 :NewFile接收io.ReaderAt,但内部需要ReadSeeker,于是用 9.2 exabytes 的SectionReader伪造范围。- PE string table bug:解析恶意或特殊 PE 文件时,读到荒谬长度,可能尝试分配巨大 buffer。
- 为什么 bug 难修 :公开接口只收
ReaderAt,不知道真实输入大小;因为兼容承诺,接口不能轻易改。 - Rust 不是自动保证好 API:Rust 也能写烂 API,但通常能从类型签名看出问题。
- 最终结论:好 API 不是"文档写清楚就行",而是尽量把不变量编码进类型、所有权和函数签名里。
这篇文章的标题叫 Abstracting away correctness,直译是"把正确性抽象掉"。
这个标题有点反直觉。我们通常说"抽象"是为了隐藏复杂性,让调用者更容易使用。比如文件、网络连接、HTTP 响应体都能被当成"可读对象";你不用关心底层是磁盘、socket、内存还是压缩流,只要调用同一个 Read 方法即可。
这当然是好事。
但问题在于:抽象不只会隐藏复杂性,也可能隐藏正确性。
如果一个接口的函数签名看起来很简单,但它真正的行为必须靠一大段文档解释;如果调用者只有读完文档、记住所有边界条件,才能正确使用它;如果编译器完全不知道哪些状态是合法的、哪些状态是危险的,那么这个接口就把正确性从类型系统里抽走了,交给了人脑。
而人脑不适合维护大量隐式不变量。
这篇文章以 Go 的 io.Reader 为主线。作者一开始承认,io.Reader 是一个非常成功、非常有组合性的接口。它只有一个方法,却被文件、网络连接、HTTP 响应体、压缩流、内存缓冲区等大量对象实现。整个 Go 生态几乎所有"能读"的东西都能当成 io.Reader 用。
但文章的重点不是"io.Reader 很烂"。相反,正因为它看起来很优雅,才更适合拿来讨论 API 设计。
作者一步步拆开:为什么 io.Reader 的设计比"返回一个新切片"的设计更好;为什么它仍然有大量隐藏语义;为什么 0, nil、n > 0, err == io.EOF 这类组合会让调用者写错;为什么 io.Seeker 的 whence int 是松散约束;为什么 bufio.Reader 没有消费原 reader 会留下误用空间;为什么 Go 标准库里的 debug/pe 因接口设计无法拿到输入大小,最后很难真正修复一个解析 bug。
最后,作者回到 Rust:Rust 不能保证你一定写出好 API,但它提供了更多工具,让你把正确性放进签名里。Result、enum、所有权、move、&mut [u8]、类型不同的 shadowing,都能把一部分原本只能写在文档里的约束,变成编译器能检查的东西。
这篇文章真正讲的是:API 设计不是"能用就行",而是要尽量让误用变难。
一、好设计会让你再也回不去
文章开头说,作者多年来一直在敲同一面鼓:API 必须被仔细设计。
这句话听起来像废话。谁会说 API 不需要仔细设计呢?
但"仔细设计"到底是什么意思,不是每个人都有同样感受。只有你同时体验过两个极端,才会真正理解。一个接口可以只是"能跑";也可以让很多错误根本写不出来。一个接口可以让调用者读文档、记细节、靠经验避坑;也可以把不变量直接编码进类型签名里。
好设计当然有成本。它可能增加认知负担,让类型签名变长,让编译时间增加,让招聘更困难,让初学者更容易被吓到。
但一旦你体验过好设计,就很难回到另一边。
这也是作者的警告:当你学会识别 API 设计缺陷后,就很难再"只管把活干完"。你会不断看到那些本可以避免的坑。这是一种微妙的平衡:一方面,工程确实要交付;另一方面,明知道接口在鼓励误用,还要继续用,会非常难受。
二、Go 的 io.Reader:组合性极强的接口
作者先从 Go 的 io.Reader 开始:
go
type Reader interface {
Read(p []byte) (n int, err error)
}
这个接口只有一个方法。正因为它小,所以整个生态几乎都能实现它。
文件可以是 reader:
go
f, err := os.Open("main.go")
readSome(f)
TCP 连接可以是 reader:
go
conn, err := net.Dial("tcp", "example.org:80")
readSome(conn)
HTTP 响应体也可以是 reader:
go
resp, err := http.Get("http://example.org")
readSome(resp.Body)
这就是 Go 标准库非常成功的一面。一个小接口,把文件、网络、HTTP、压缩、内存缓冲区都连起来了。
io.Reader 的核心设计是:调用者提供一个 []byte buffer,reader 把数据写进去,然后返回写了多少字节,以及是否出错。
这件事非常重要。
三、为什么不是 AltRead() ([]byte, error)
为了说明 io.Reader 的好处,文章先设计一个反例:
go
type AltReader interface {
AltRead() ([]byte, error)
}
看起来也很简单:reader 自己返回一段字节。
但它马上有几个问题。
第一,调用者不能指定想读多少。实现者如果内部写死 1024,那调用者就只能接受 1024。你只是想读 4 个字节,也会读到最多 1024。
第二,每次调用都要分配一个新 buffer:
go
func (arw *AltReadWrapper) AltRead(n int) ([]byte, error) {
buf := make([]byte, n)
n, err := arw.inner.Read(buf)
return buf[:n], err
}
这会产生大量分配,给 GC 压力。
第三,如果想复用内部 buffer,又会出大问题。
比如包装器里存一个内部 buffer:
go
type AltReadWrapper struct {
inner io.Reader
buf []byte
}
func (arw *AltReadWrapper) AltRead(n int) ([]byte, error) {
if len(arw.buf) < n {
arw.buf = make([]byte, n)
}
n, err := arw.inner.Read(arw.buf)
return arw.buf[:n], err
}
这样看起来避免了每次分配。
但如果调用者把返回的 slice 保存起来,后续读取会覆盖同一块内部 buffer。于是你读四次,保存四段结果,最后四段全部变成第四次读取的内容。
这就是返回内部 buffer 的经典问题:调用者不知道这块内存会不会被复用,也不知道能持有多久。
io.Reader 通过让调用者传入 buffer,避开了这个问题:
go
Read(p []byte) (n int, err error)
调用者想每次新分配,就每次新分配。想复用同一块 buffer,也可以复用。想读到 buffer 中间,也可以传 buf[8:20]。
所以,到这里为止,io.Reader 看起来是一个非常好的设计。
但文章马上转折:没那么简单。
四、签名不够表达行为,就是危险信号
io.Reader 的函数签名只有:
go
Read(p []byte) (n int, err error)
但这个签名不足以完整说明它的行为。
作者说,这是红旗。
任何时候,如果接口的签名本身不足以推断其行为,就有麻烦。文档当然有用,但文档不应该承担所有正确性约束。一个接口如果必须靠一大段文档告诉你"这个组合可以、那个组合不可以、这种情况不要这么理解、那种情况要继续读",那说明类型系统没有帮上足够多忙。
作者用了一个火警开关的类比:如果火警可以向上拉,也可以向下拉,但只有向上拉会响,向下拉没有反应,那你当然可以通过培训告诉员工"必须向上拉"。但这不是根治问题。新员工没培训过,或者紧急情况下记错了,就会出事。
更好的设计是:让火警只存在一个明显正确的操作方式。
API 也是一样。
如果正确用法需要靠培训和文档记忆,误用就会不断发生。
五、Go 的多返回值:n 和 err 可以被分开忽略
Go 没有异常,也没有 Result<T, E>。它的常见错误处理方式是多返回值:
go
n, err := r.Read(buf)
这让错误出现在签名里,表面上很清楚。
但问题是,Go 允许你分别忽略它们:
go
n, _ := r.Read(buf)
或者:
go
_, err := r.Read(buf)
在 Read 这种接口里,n 和 err 并不是两个独立信息。它们组合起来才表达完整状态。你不能只看其中一个,就正确理解发生了什么。
Go 编译器确实会拒绝"绑定了变量但没用"的情况:
go
f, err := os.Open("woops.go")
log.Printf("the file's name is %v", f.Name())
这里 err 声明后没用,会报错。
但这不是错误处理特有的机制。只要你写成 _,编译器就不管了。
于是很容易写出这样的 readFull:
go
func readFull(r io.Reader) []byte {
buf := make([]byte, 16)
var res []byte
for {
n, _ := r.Read(buf)
if n == 0 {
break
}
res = append(res, buf[:n]...)
}
return res
}
它看起来还行:一直读,直到读到 0 个字节为止。
对普通 *os.File,它可能确实工作。
但它不是正确的 io.Reader 使用方式。
六、0, nil 不表示 EOF
Go 的 io.Reader 文档说,实现者不鼓励在 len(p) != 0 时返回 0, nil。但这不是语言层面禁止的。文档还明确告诉调用者:
text
0, nil 表示什么也没发生,不表示 EOF。
这意味着一个合法 reader 可以前几次返回 0, nil,之后再返回真实内容。
如果你的 readFull 把 n == 0 当成结束,就会提前退出,读不到任何东西。
这就是第一个坑:0, nil 是合法状态,但不能解释成 EOF。
所以要改成检查 err == io.EOF:
go
func readFull(r io.Reader) []byte {
buf := make([]byte, 16)
var res []byte
for {
n, err := r.Read(buf)
if err == io.EOF {
break
}
res = append(res, buf[:n]...)
}
return res
}
这看起来修好了。
但还没完。
七、n > 0, err == io.EOF 也是合法的
io.Reader 还有一个重要语义:当读到最后一段数据时,实现者可以同时返回一些数据和 io.EOF。
也就是说:
go
n > 0, err == io.EOF
也是合法的。
文档允许两种方式:
text
最后一次读到数据时返回 n > 0, err == nil,下一次再返回 0, io.EOF。
或者最后一次直接返回 n > 0, err == io.EOF。
这就很麻烦了。
前面那个 readFull 看到 err == io.EOF 立刻 break,于是会丢掉最后一段数据。更极端一点,如果 buffer 大小刚好大于输入长度,第一次 Read 就可能返回全部数据和 io.EOF,结果你直接 break,读到 0 字节。
于是这个接口有很多状态组合:
text
n == 0, err == nil
n != 0, err == nil
n == 0, err != nil
n != 0, err != nil
其中有些组合很常见,有些组合很微妙,但它们都要被调用方正确处理。
这就是作者批评的核心:这些状态不是类型系统能表达的。它们藏在文档里。写错很容易,而且编译器不知道。
八、io.ReadFull:标准库帮你,但语义仍然复杂
既然自己写 readFull 容易错,那就用标准库。
Go 有 io.ReadFull:
go
func ReadFull(r Reader, buf []byte) (n int, err error)
它的语义是:尽量读满 len(buf)。如果读不满,就返回错误。没有读到任何字节时错误是 EOF;读到一部分但没读满时,错误是 ErrUnexpectedEOF。如果 reader 在至少读满 buffer 后返回错误,这个错误会被丢弃。
作者看到"错误会被丢弃"这点,觉得很可怕。
更重要的是,ReadFull 仍然返回 (n, err),所以仍然有多种组合。文档可以规定某些组合不会出现,但编译器不知道。
这不是说 Go 标准库写得差。io.Reader 要兼容太多场景,确实有历史和工程背景。
文章的重点是:这种接口非常依赖文档,误用空间很大。
九、Rust 的 Read:少一些状态组合
Rust 里对应的接口是:
rust
pub trait Read {
fn read(&mut self, buf: &mut [u8]) -> Result<usize>
}
它也不是完美的。作者甚至承认,他第一次写示例时也忘了 Ok(0) 表示 EOF,结果写出了无限循环。
但 Rust 这里至少少了一个问题:返回值是 Result<usize>,不是并排的 (usize, Error)。
也就是说,只有两个大分支:
text
Ok(n)
Err(e)
成功读取了一些数据:Ok(n)。
到达 EOF:Ok(0)。
发生错误:Err(e)。
当然,Ok(0) 表示 EOF 这件事仍然需要知道。你可以争论它是不是应该做成 ErrorKind,或者另一种 enum。文章并不说 Rust 这里 100% 完美。
但相比 Go 的 (n, err) 四种组合,Rust 的接口至少更紧一些。成功和错误不会同时出现。你不可能在类型层面得到"既有 n,又有 err"的组合。
这就是本文不断强调的点:不是说某个语言完美,而是问"这个接口能否更抗误用"。
十、io.Seeker:只有三个有效值,却用 int 表达
文章接着看 Go 的另一个接口:
go
type Seeker interface {
Seek(offset int64, whence int) (int64, error)
}
offset int64 可以理解。文件可能超过 4GB,定位偏移需要大整数。
但 whence int 就很可疑。
whence 实际上只有三个有效值:
go
const (
SeekStart = 0
SeekCurrent = 1
SeekEnd = 2
)
也就是说,在 int 巨大的取值空间里,只有 3 个值有意义。
这就是松散约束。调用者完全可以传:
go
s.Seek(0, 1024)
当然,运行时可能返回错误。但这个错误本来可以在类型层面避免。
在 Rust 里,这种参数通常会设计成 enum:
rust
enum Whence {
Start,
Current,
End,
}
Whence::Start 不是数字。你不能把它直接赋给 usize。你可以显式把它转换成数字,但不能把任意数字无条件转回 Whence,因为转换可能失败。
如果需要从数字解析成 enum,就应该用 try_into(),返回 Result。
这就是代数数据类型的意义:让状态空间变小,让非法状态难以表达。
Go 没有真正的 enum。你可以写:
go
type Whence int
但任何 int 仍然可以被强转成 Whence(1024)。这只能稍微改善可读性,不能真正约束合法状态。
十一、ReadSeeker、当前游标和缓冲
io.Reader 和 io.Seeker 很容易组合,因为它们都围绕"当前读取位置"工作。
Go 标准库里有:
go
type ReadSeeker interface {
Reader
Seeker
}
还有 ReadCloser、ReadWriteCloser、ReadWriteSeeker、ReadWriter 等组合接口。
像 *os.File 就很适合这种模型。它本质上是 Unix file descriptor 的薄封装。文件描述符有当前 offset,read 会从当前位置读并推进 offset,lseek 可以修改 offset。
但 Go 的 *os.File 本身不做缓冲。你每次读 1 字节,就可能真的触发很多小 syscall。标准库提供 bufio.Reader 来解决这个问题。
go
r := bufio.NewReader(f)
包装后,每次你从 bufio.Reader 读 1 字节,它内部可能一次从文件读 4096 字节,然后慢慢从缓冲区给你。
这很好。
但又出现一个 API 设计问题:bufio.NewReader(f) 并没有"消费"原来的 f。
也就是说,你之后仍然可以同时使用原 reader 和 buffered reader:
go
sr.Read(buf)
br.Read(buf)
sr.Read(buf)
br.Read(buf)
二者共享底层状态,会互相干扰,读出来的内容就会变得很奇怪。
这在 Go 里无法从类型层面禁止。
十二、Rust 的 BufReader::new:move 掉 inner reader
Rust 也有 BufReader。但构造函数是:
rust
impl<R: Read> BufReader<R> {
pub fn new(inner: R) -> BufReader<R> {
BufReader::with_capacity(DEFAULT_BUF_SIZE, inner)
}
}
注意,它接收的是 inner: R,不是 &mut R。
这意味着它拿走了 inner reader 的所有权。原 reader 被 move 进 BufReader 后,不能再继续使用。
如果你写:
rust
let mut f = File::open("src/main.rs").unwrap();
let mut br = BufReader::new(f);
f.read_to_string(&mut s).unwrap();
编译器会报错:f 已经被 move 了,不能再用。
这就是 Rust 所有权模型的价值之一。它不是只防内存安全问题,也防逻辑错误。
Go 里"包装后仍可误用原对象"的问题,在 Rust 里通过 move 直接变成编译错误。
十三、io.ReaderAt:Go 标准库中更安全的接口
文章接着看 io.ReaderAt:
go
type ReaderAt interface {
ReadAt(p []byte, off int64) (n int, err error)
}
它和 Read 类似,但多了一个 offset。关键区别是:它不维护当前读取位置。
这反而让它更安全。多个 goroutine 可以同时调用 ReadAt,只要 offset 不同或 buffer 独立,它们不会互相影响。因为没有共享游标,就没有"谁先读推进了位置"这种状态竞争。
作者甚至说,这可能是 Go 标准库里少数真正安全的 I/O 接口之一,因为它天然 thread-safe。
这点很有意思:并不是所有 Go 接口都不好。ReaderAt 的设计就比依赖当前 position 的接口更容易组合、更少副作用。
但接下来,文章用 Go 标准库的 debug/pe 包展示了:哪怕有一个不错的接口,如果后续抽象层设计不慎,仍然会出问题。
十四、debug/pe:一个标准库里的真实案例
Go 标准库有 debug/pe 包,用来解析 Windows PE 文件,也就是 .exe 或 .dll 这类 Portable Executable。
作者想用它找一个 Windows 可执行文件依赖哪些 DLL:
go
f, err := pe.Open(os.Args[1])
libs, err := f.ImportedLibraries()
结果某个版本里 ImportedLibraries() 直接 stub 掉了,返回 nil, nil。这本身已经让作者很意外。
于是他换成 ImportedSymbols(),这个能工作。
但真正的 bug 出现在解析某些 PE 文件时。标准库内部的 NewFile 接收:
go
func NewFile(r io.ReaderAt) (*File, error)
这个签名看起来挺好。ReaderAt 不维护游标,可以随机读取,非常适合解析文件格式。
但 NewFile 内部为了方便顺序读取,又创建了一个 SectionReader:
go
sr := io.NewSectionReader(r, 0, 1<<63-1)
为什么长度是 1<<63 - 1?
因为 ReaderAt 只有 ReadAt,它不知道输入总大小。SectionReader 需要一个长度上限,但接口没给大小。为了继续用,就传了最大 int64。
这就是设计缝隙。
十五、9.2 exabytes 的 SectionReader
1<<63 - 1 大约是 9.2 exabytes。也就是说,标准库假装这个 PE 文件最大有 9.2 EB。
接下来,代码要读取 COFF string table。它会先根据文件头算出偏移,然后 Seek 到 string table 位置,再读一个 32 位长度 l,然后分配 l 字节:
go
buf := make([]byte, l)
_, err = io.ReadFull(r, buf)
问题是,对于某些输入文件,l 会读成一个荒谬的值,比如 2274698020。于是程序可能尝试分配 2GB 多的 buffer。
解析器面对用户输入必须非常防御。文件格式解析不能相信文件里的长度字段。
作者一开始想修:既然 readStringTable 拿的是 io.ReadSeeker,那就先 seek 到末尾拿到大小,再判断 offset + l 是否超过文件大小。
但这不工作。因为这个 io.ReadSeeker 实际上是那个长度为 9.2 EB 的 SectionReader。它的 SeekEnd 得到的是假的巨大长度。SectionReader 只有在 Read 时才根据 limit 检查上界,而这个 limit 又是假的。
根本原因是最上层的接口只接收 ReaderAt,不知道真实大小。因为这是公开接口,按照 Go 的兼容承诺,不能轻易改成接收"ReaderAt + size"。
作者自己的 fork 可以改接口,所以能修。但标准库不能这样破坏兼容。最后能做的只是加一个上限,例如如果长度大于 2**30 就认为有问题。但这也是权宜之计,因为谁知道未来有没有真的很大的 PE 文件,或者 Go 编译器内部是否依赖某种行为。
这就是文章标题的现实含义:错误的抽象把正确性拿走了,后面想补很难。
十六、Rust 也能写烂 API,但签名会露馅
文章结尾强调:这不是"Go 烂、Rust 神"的简单故事。
作者明确说,他喜欢 Rust 的语言设计、编译器和标准库,但这不足以防止糟糕 API 设计。Rust 生态也有设计很差的 crate。
区别是:在 Rust 里,很多设计问题可以从函数签名看出来。
比如这个 API 很可疑:
rust
struct Template { /* ... */ }
impl Template {
fn load_from_file(path: &str) -> Template {
/* ... */
}
}
首先,为什么参数是 &str?Rust 有 Path / PathBuf,路径不应该随便当普通字符串处理。
其次,这个函数显然会打开文件、读取文件,但它无条件返回 Template,不是 Result<Template, E>。那错误怎么办?丢掉?panic?返回空模板?调用者完全看不出来。
看到这种签名,作者说他不会用这个 crate。
这是一个非常实用的判断方法:API 签名如果无法表达可能失败、无法表达路径语义、无法表达状态约束,那它很可能把错误藏到运行时去了。
十七、部分初始化状态:TemplateCompiler 的例子
文章还举了一个更隐蔽的 API:
rust
struct TemplateCompiler {}
impl TemplateCompiler {
fn new_from_folder(folder: &Path) -> Result<Self, Error> { /* ... */ }
fn new() -> Self { /* ... */ }
fn add_source(&mut self, name: &str, contents: String) { /* ... */ }
fn compile_all(&mut self) -> Result<(), Error> { /* ... */ }
}
这个 API 看起来也能用。它有两种构造方式:
text
从文件夹发现模板文件
手动逐个添加 source,然后 compile_all
但问题是:new() 之后、compile_all() 之前,TemplateCompiler 处于部分初始化状态。调用者可能忘记调用 compile_all(),或者调用顺序不对,或者在编译前调用了不该调用的方法。
更好的设计是引入另一个类型,把模板源集合建模出来:
rust
trait TemplateSourceSet {
// 枚举 sources、按名字查找等
}
impl TemplateCompiler {
fn new<S>(source_set: S) -> Result<Self, Error>
where
S: TemplateSourceSet,
{
/* ... */
}
}
这样,一旦你拿到 TemplateCompiler,它就是完整初始化好的。不存在"必须先 add,再 compile,之后才能用"的隐式阶段。
这就是 typestate 思想的影子:让不同阶段成为不同类型,而不是同一个对象里藏一个未完成状态。
十八、Read 的"不许保存 buffer"在 Rust 里写进了签名
Go 的 io.Reader 文档里写着:实现者不能保存传进来的 p。
这是一条重要不变量。实现者如果保存 p,调用方之后复用 buffer 就会出大问题。
但 Go 编译器不能检查这个不变量。
Rust 的 Read 也有同样语义:实现者不能把传进来的 buffer 保存起来,在函数返回后继续持有。
但 Rust 文档不用特别写这点,因为签名已经表达了:
rust
pub trait Read {
fn read(&mut self, buf: &mut [u8]) -> Result<usize>
}
buf 是一个临时的可变借用。它的生命周期只覆盖这次调用。函数返回时,借用必须归还。
如果你试图写一个 Read 实现,把 buf 存到结构体字段里:
rust
struct Foo<'a> {
blah: Option<&'a mut [u8]>,
}
impl<'a> Read for Foo<'a> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.blah = Some(buf);
Ok(0)
}
}
编译器会拒绝。错误信息可能很长,但本质是:buf 活得不够久,不能存进结构体里。
你不理解完整生命周期错误也没关系,灾难已经被阻止了。
这就是 Rust 类型系统最有价值的部分:它让"实现者不能保存 buffer"不再只是文档,而是可检查约束。
十九、连 copy 参数顺序都可以靠类型减少误用
文章里作者曾经写错 Go 的 copy 参数顺序:
go
copy(nr.src[:n], p[:n])
他想写的是把 nr.src 拷贝到 p,但写反了。Go 的 copy(dst, src) 两个参数都是 []byte,类型一样,编译器无法发现顺序错误。
作者一开始说:这个确实怪我,编译器不可能知道。
但后来他发现,在另一种 API 设计里,编译器其实可以知道。
比如 Rust 里写一个 copy:
rust
fn copy<T>(src: &[T], dst: &mut [T]) -> usize {
let n = std::cmp::min(src.len(), dst.len());
for i in 0..n {
dst[i] = src[i]
}
n
}
这里 src 是只读 slice,dst 是可变 slice。顺序如果写反:
rust
copy(buf, self.src)
编译器会报错,因为它期望第二个参数是 &mut [T],但你给了只读引用。
这就是"不要索取超过需要的权限"的设计原则。
如果 source 只需要读,就不要把它设计成 &mut [u8]。如果 destination 需要写,就明确用 &mut [u8]。这样类型系统才能帮你区分角色。
二十、这篇文章真正想表达什么
这篇文章不是单纯在挑 Go 的毛病,也不是说 Rust 永远正确。
它真正想表达的是:API 设计应该尽量把正确性放进类型和签名里,而不是放进文档、培训、约定和经验里。
文档当然重要。但文档不能是唯一防线。
如果一个接口需要大量说明才能不被误用,就要问:
text
这些约束能不能放进类型?
状态能不能用 enum 表达?
错误能不能用 Result 表达?
资源所有权能不能通过 move 表达?
阶段能不能拆成不同类型?
读和写能不能通过 & 和 &mut 区分?
路径能不能用 Path 而不是 &str?
长度、偏移、范围能不能明确建模?
这就是"抽象掉正确性"的反面:不要把正确性抽走,而要把正确性抽象进接口里。
二十一、对实际开发的启发
第一,小接口不一定就是好接口。
io.Reader 很小,也非常成功,但它仍然有复杂语义。接口小只是起点,不代表它足够抗误用。
第二,组合性和正确性需要同时考虑。
io.Reader 的组合性极好,但文档里隐藏了很多行为约束。API 设计要在通用性、性能、表达能力和安全性之间做平衡。
第三,多返回值会制造状态组合。
(n int, err error) 看似简单,但调用方必须理解 n 和 err 的组合。Result<T, E> 这类 sum type 能减少不合法或难理解的组合。
第四,不要用裸整数表达有限状态。
whence int 只有三个有意义值,就应该用 enum。否则非法值会流到运行时。
第五,包装资源时,考虑是否应该消费原资源。
BufReader::new(inner) 在 Rust 里拿走 inner reader,避免之后继续误用原 reader。Go 里没有 move,难以表达这种所有权转移。
第六,接口一旦公开,后续很难修。
debug/pe.NewFile 接收 ReaderAt,但缺少 size 信息。后续内部需要 size 时,只能用巨大 SectionReader 这种 workaround。公开 API 设计错误会长期存在。
第七,签名是最重要的文档。
如果函数读取文件却不返回 Result,如果路径用 &str,如果对象有部分初始化状态,调用方应该警惕。
第八,Rust 的价值不只是内存安全。
Go 有 GC,已经比 C 安全很多。但 Rust 还能用类型系统防止很多逻辑错误,比如 move 后误用、保存临时 buffer、读写参数顺序混淆、部分初始化状态等。
二十二、总结
这篇文章以 Go 的 io.Reader 开始。io.Reader 是一个非常成功的接口,只有一个方法:Read(p []byte) (n int, err error)。它被文件、网络连接、HTTP 响应体等大量类型实现,因此组合性极强。作者先构造了一个反例 AltRead() ([]byte, error),说明如果 reader 自己返回 buffer,调用者无法控制读取大小,而且每次读取可能都要分配。即使用内部 buffer 复用,也会出现调用者保存 slice 后,后续读取覆盖旧数据的问题。相比之下,io.Reader 让调用者提供 buffer,既能控制最大读取量,也能避免不必要分配,这是很好的设计。
但文章随即指出,io.Reader 的签名不足以完整表达它的语义。真正正确使用它,需要读大量文档。例如,Read 返回 0, nil 并不表示 EOF,只表示什么也没发生;调用者不能因此停止读取。再比如,reader 在读到最后一段数据时,可以返回 n > 0, err == io.EOF,也可以先返回 n > 0, err == nil,下一次再返回 0, io.EOF。这意味着 (n, err) 的组合状态很多,调用者必须理解这些细节。Go 的多返回值让 n 和 err 可以被分别忽略,编译器无法知道调用者是否正确处理了组合语义。
作者把这和 Rust 对比。Rust 的 Read::read(&mut [u8]) -> Result<usize> 也不是完美的,因为 Ok(0) 表示 EOF 仍然需要知道。但它至少把成功和错误压成两个大分支:Ok(n) 或 Err(e),不会出现"既有成功值又有错误值"的多返回组合。更重要的是,Rust 的 &mut [u8] 把"不许保存调用者 buffer"这条不变量写进了签名。如果实现者试图把传入的 buf 存到结构体里,编译器会因为生命周期不够长而拒绝。Go 的文档也说 reader 不能保存 p,但 Go 编译器无法检查。
接着文章看 io.Seeker。它的签名是 Seek(offset int64, whence int),其中 whence 实际只有 SeekStart、SeekCurrent、SeekEnd 三个合法值,却用 int 表达。这样 32 位或 64 位整数的大量无效值都可以传进来,只能运行时报错。Rust 里这种情况通常会用 enum,比如 Whence::Start、Whence::Current、Whence::End。enum 不是普通数字,不能把任意整数无条件转换成 enum;如果要从数字解析,就应该用可能失败的 try_into()。这就是代数数据类型的价值:缩小状态空间,让非法状态难以表达。
文章还讨论了 bufio.Reader。Go 里的 bufio.NewReader(f) 并不会消费原来的 f,所以你仍然可以交替使用原 reader 和 buffered reader,导致读取结果混乱。Rust 的 BufReader::new(inner) 则拿走 inner reader 的所有权。原 reader 被 move 之后,不能再使用。这不是只防内存错误,也是防逻辑错误。
随后作者分析 io.ReaderAt。它的签名是 ReadAt(p []byte, off int64),不维护当前读取位置,因此多个 goroutine 可以并发按 offset 读取,互不干扰。作者认为这是 Go 标准库里设计较安全的 I/O 接口之一。但标准库的 debug/pe 案例说明,即使有好接口,后续抽象层仍可能出问题。debug/pe.NewFile 接收 io.ReaderAt,但内部为了顺序解析又需要 ReadSeeker,于是用 io.NewSectionReader(r, 0, 1<<63-1) 构造一个长度约 9.2 exabytes 的 SectionReader。由于 ReaderAt 不提供真实输入大小,内部无法知道文件到底多大。解析某些 PE 文件时,string table 长度字段可能被读成荒谬值,比如 2274698020,导致尝试分配巨大 buffer。作者在自己的 fork 中可以通过修改接口,额外传入输入大小来修复;但标准库公开接口受兼容承诺限制,无法轻易改变,只能用上限判断之类的补丁。
文章结尾强调,Rust 也能写出差 API。比如一个 Template::load_from_file(path: &str) -> Template 就很可疑:路径应该用 Path,读取文件可能失败,为什么不返回 Result?又比如 TemplateCompiler::new()、add_source()、compile_all() 这种设计可能让对象在 compile_all() 前处于部分初始化状态。更好的设计是把模板源集合建模为单独类型,让 TemplateCompiler::new(source_set) 返回一个已经完整初始化的对象,避免调用顺序错误。
最后,作者总结:比较 Go 和 Rust,不只是比较内存安全。Go 有 GC,已经比 C 安全很多;但 Rust 的类型系统、所有权、借用、enum、Result、move 等机制还能预防大量逻辑错误。比如 BufReader 消费 inner reader,防止误用;Read::read 的 &mut [u8] 防止实现者保存调用者 buffer;copy(src: &[T], dst: &mut [T]) 通过只读/可变引用区分源和目标,甚至能在参数顺序写反时让编译器报错。
整篇文章真正传达的是:好 API 不是文档写得越详细越好,而是让正确用法尽可能自然,错误用法尽可能难写。文档应该解释设计,而不是承担所有安全边界。能放进类型系统的不变量,就不要只放在注释里;能用 enum 表达的有限状态,就不要用裸整数;能用所有权表达资源转移,就不要让调用者同时握着内外两个句柄;能让编译器检查的正确性,就不要完全交给人脑。