把正确性藏进类型里:从 Go 的 io.Reader 到 Rust 的 API 设计

本文是对 Abstracting away correctness 的整理与翻译。

内容结构概览

  1. 文章核心观点:API 必须被仔细设计;一旦体验过好设计,就很难再接受"靠约定"的设计。
  2. Go io.Reader 的优点:只有一个方法,生态里几乎所有可读对象都实现它,组合性极强。
  3. 为什么 io.ReaderAltRead() []byte 更好:调用者提供 buffer,可以控制最大读取量,也避免每次读取都分配。
  4. 内部 buffer 的陷阱:如果 reader 返回内部复用 buffer,调用者保存 slice 后,后续读取会覆盖旧数据。
  5. 函数签名不够表达行为Read(p []byte) (n int, err error) 看起来简单,但正确语义大量藏在文档里。
  6. Go 多返回值的问题nerr 可以被分别忽略,编译器不能强制你同时处理它们的状态组合。
  7. 0, nil 的特殊语义:Go 文档说这不表示 EOF,只是"什么也没发生",调用方必须知道。
  8. n > 0, err == io.EOF 也是合法的:读到最后一段数据时,reader 可以同时返回数据和 EOF,也可以下一次再返回 EOF。
  9. io.ReadFull 也有复杂语义:少读、EOF、UnexpectedEOF、错误被丢弃等行为都要靠文档理解。
  10. Rust Read 的对照read(&mut [u8]) -> Result<usize> 把成功和错误压成两个分支,减少状态组合。
  11. io.Seeker 的问题Seek(offset int64, whence int)whence 只有 3 个有效值,却用 int 表达。
  12. Rust enum 的优势:如果只有 Start / Current / End 三种情况,就应该用 enum,而不是裸整数。
  13. Go bufio.Reader 的问题:包装 reader 后,原 reader 仍可继续使用,二者交替读取会导致混乱。
  14. Rust BufReader::new 的优势:它获取 inner reader 的所有权,原 reader 被 move 后不能再误用。
  15. io.ReaderAt 的亮点:不维护当前游标,按 offset 读取,因此更容易并发安全。
  16. debug/pe 案例NewFile 接收 io.ReaderAt,但内部需要 ReadSeeker,于是用 9.2 exabytes 的 SectionReader 伪造范围。
  17. PE string table bug:解析恶意或特殊 PE 文件时,读到荒谬长度,可能尝试分配巨大 buffer。
  18. 为什么 bug 难修 :公开接口只收 ReaderAt,不知道真实输入大小;因为兼容承诺,接口不能轻易改。
  19. Rust 不是自动保证好 API:Rust 也能写烂 API,但通常能从类型签名看出问题。
  20. 最终结论:好 API 不是"文档写清楚就行",而是尽量把不变量编码进类型、所有权和函数签名里。

这篇文章的标题叫 Abstracting away correctness,直译是"把正确性抽象掉"。

这个标题有点反直觉。我们通常说"抽象"是为了隐藏复杂性,让调用者更容易使用。比如文件、网络连接、HTTP 响应体都能被当成"可读对象";你不用关心底层是磁盘、socket、内存还是压缩流,只要调用同一个 Read 方法即可。

这当然是好事。

但问题在于:抽象不只会隐藏复杂性,也可能隐藏正确性。

如果一个接口的函数签名看起来很简单,但它真正的行为必须靠一大段文档解释;如果调用者只有读完文档、记住所有边界条件,才能正确使用它;如果编译器完全不知道哪些状态是合法的、哪些状态是危险的,那么这个接口就把正确性从类型系统里抽走了,交给了人脑。

而人脑不适合维护大量隐式不变量。

这篇文章以 Go 的 io.Reader 为主线。作者一开始承认,io.Reader 是一个非常成功、非常有组合性的接口。它只有一个方法,却被文件、网络连接、HTTP 响应体、压缩流、内存缓冲区等大量对象实现。整个 Go 生态几乎所有"能读"的东西都能当成 io.Reader 用。

但文章的重点不是"io.Reader 很烂"。相反,正因为它看起来很优雅,才更适合拿来讨论 API 设计。

作者一步步拆开:为什么 io.Reader 的设计比"返回一个新切片"的设计更好;为什么它仍然有大量隐藏语义;为什么 0, niln > 0, err == io.EOF 这类组合会让调用者写错;为什么 io.Seekerwhence 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 的多返回值:nerr 可以被分开忽略

Go 没有异常,也没有 Result<T, E>。它的常见错误处理方式是多返回值:

go 复制代码
n, err := r.Read(buf)

这让错误出现在签名里,表面上很清楚。

但问题是,Go 允许你分别忽略它们:

go 复制代码
n, _ := r.Read(buf)

或者:

go 复制代码
_, err := r.Read(buf)

Read 这种接口里,nerr 并不是两个独立信息。它们组合起来才表达完整状态。你不能只看其中一个,就正确理解发生了什么。

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,之后再返回真实内容。

如果你的 readFulln == 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.Readerio.Seeker 很容易组合,因为它们都围绕"当前读取位置"工作。

Go 标准库里有:

go 复制代码
type ReadSeeker interface {
    Reader
    Seeker
}

还有 ReadCloserReadWriteCloserReadWriteSeekerReadWriter 等组合接口。

*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) 看似简单,但调用方必须理解 nerr 的组合。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 的多返回值让 nerr 可以被分别忽略,编译器无法知道调用者是否正确处理了组合语义。

作者把这和 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 实际只有 SeekStartSeekCurrentSeekEnd 三个合法值,却用 int 表达。这样 32 位或 64 位整数的大量无效值都可以传进来,只能运行时报错。Rust 里这种情况通常会用 enum,比如 Whence::StartWhence::CurrentWhence::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 表达的有限状态,就不要用裸整数;能用所有权表达资源转移,就不要让调用者同时握着内外两个句柄;能让编译器检查的正确性,就不要完全交给人脑。

相关推荐
必胜刻2 小时前
从零搭建全栈博客系统:Go + Vue 3 + Docker 全流程实战
vue.js·docker·golang
AI-好学者2 小时前
MCP企业运用全面知识点-基础篇
服务器·开发语言·网络·人工智能·python·架构
happyprince2 小时前
18-vLLM 结构化输出约束分析文档
网络·vllm
前端之虎陈随易2 小时前
Rust、Golang、MoonBit 编译成 WASM,体积和速度差距有多大?
golang·rust·wasm
雨师@2 小时前
go语言项目--实例化(图书管理)--006
开发语言·后端·golang
网络攻城狮_2 小时前
网络协议大全
运维·网络·网络协议·http
有浔则灵2 小时前
网络安全核心知识梳理:从OSI模型到密码技术
网络·安全·web安全
Lorin 洛林3 小时前
一文读懂 Agent Skills
前端·网络
andxe12 小时前
安科士AndXe 400G QSFP-DD LR8光模块芯片架构与品控体系解析
网络·光模块·光通信