Box 里到底装了什么:从 Go interface 到 Rust trait object

本文是对 What's in the box? 的整理与翻译。

内容结构概览

  1. 开场金句:Rust 里经常有人说"just box it",但这句话背后其实藏着很多类型系统和内存模型知识。
  2. 从读文件开始std::fs::read_to_string 返回 Result<String, E>,因为读文件可能失败。
  3. JavaScript 错误处理 :可以随便 throw,甚至 throw 字符串,但最好 throw Error 对象,否则堆栈信息很差。
  4. Go 错误处理 :函数返回 (value, error),但错误很容易被忽略。
  5. Go 的 error interfaceerror 是接口,只要求实现 Error() string
  6. Go 的 nil 陷阱 :一个 *naughtyError 为 nil,但装进 error interface 后,err != nil
  7. Go interface 的内存结构:interface value 通常是两个指针:一个指向值,一个指向类型信息。
  8. Rust 选择错误类型Result<String, E> 必须明确 E 是什么。
  9. 为什么 dyn Error 不能直接返回:trait object 是动态大小类型,函数返回值必须在编译期知道大小。
  10. 裸指针 vs 引用:Rust 的原始指针可以为空,解引用是 unsafe;引用保证有效,安全代码中不能构造悬垂引用。
  11. Go string 不是"纯字符串" :Go string 实际类似 Data + Len 的 header,复制 string 会复制 header,不一定复制底层数据。
  12. GC 与内存可见性 :GC 回收不一定清零内存,GODEBUG=clobberfree=1 可以帮助观察已释放内存被破坏的效果。
  13. Rust 的所有权与生命周期 :不能返回指向局部 String&str,因为局部变量函数结束就会被释放。
  14. 堆、栈与静态区:局部变量、堆分配和字符串字面量所在区域不同,生命周期也不同。
  15. Sized?Sized :Rust 默认泛型参数是 Sized,但 dyn Traitstr[T] 这类类型大小未知。
  16. 为什么 Box 有用Box<T> 是拥有所有权的智能指针,指向堆上的值;指针大小固定,因此可以间接持有 unsized value。
  17. Box<dyn Error> 如何统一多种错误类型 :关键在 dyn Error 的 vtable,而不只是 Box 本身。
  18. Boxed trait object 是 fat pointerBox<std::io::Error> 通常 8 字节,Box<dyn Error> 通常 16 字节。
  19. 不用堆分配也能统一错误类型 :如果错误集合有限,可以用自定义 enum,把 std::io::ErrorFromUtf8Error 放进同一个 enum。
  20. thiserror 的意义 :手写 enum、Display、Error、From 很繁琐,实际项目常用 thiserror 减少样板。
  21. impl Trait 真正有用的地方 :返回闭包、async block 这类无法命名的具体类型时,impl Trait 可以隐藏具体类型且避免 heap allocation。
  22. 最终结论:Box 不是"把数字变成对象",也不是魔法;它是所有权、堆分配、动态分发、类型大小和 trait object 共同作用下的一种工程工具。

Rust 开发里有一句很常见的话:

text 复制代码
......或者我们可以直接 box 一下。

这句话听起来很简单。很多 Rust 初学者甚至会把它当成一种"编译器不高兴时的魔法咒语":编译器说类型太大、大小未知、生命周期太烦、trait object 不能直接返回,那就 Box::new(...),或者 Box<dyn Trait>,问题突然消失。

这当然能解决很多问题。

但如果停下来问一句:Box 里到底装了什么?为什么 box 之后就能工作?Box 和普通指针有什么区别?Box<dyn Error> 为什么有时候是 16 字节?dyn Error 又为什么不能直接作为返回值?

事情就变得没那么简单了。

这篇文章就是在拆这个问题。它从一个非常普通的 Rust 函数开始:读取 /etc/issue 文件。然后比较 JavaScript、Go 和 Rust 的错误处理,顺手解释 Go 的 interface 内存模型、nil interface 陷阱、Go string 的底层结构、GC 和堆栈;再回到 Rust,解释引用、生命周期、Sized?Sized、trait object、fat pointer、Box<dyn Error>、enum 和 impl Trait

最后你会发现,"just box it" 不是一句咒语,而是一整套模型的压缩表达。


一、从一个纯洁的 Hello World 开始

每次运行 cargo new,Rust 都会生成一个非常纯洁的程序:

rust 复制代码
fn main() {
    println!("Hello, world!");
}

它什么都不做错,也没有什么会失败。编译、运行、输出,世界很美好。

但真实程序总会做一些可能失败的事情。比如读文件:

rust 复制代码
fn main() {
    println!("{}", std::fs::read_to_string("/etc/issue").unwrap())
}

std::fs::read_to_string 可以失败。文件可能不存在,权限可能不足,路径可能不对。所以它不是返回 String,而是返回:

rust 复制代码
Result<String, std::io::Error>

这里 .unwrap() 的意思是:如果是 Ok(String),拿出里面的字符串;如果是 Err(error),直接 panic。

main 里这么写还能接受。小程序、示例、临时脚本,panic 也许没问题。但如果我们把读文件逻辑封装成一个库函数,情况就不一样了。

rust 复制代码
fn main() {
    println!("{}", read_issue().unwrap())
}

fn read_issue() -> String {
    std::fs::read_to_string("/etc/issue").unwrap()
}

这个 read_issue 看起来像库函数。库函数里直接 panic,就不是很礼貌。更好的方式是把错误继续返回给调用者:

rust 复制代码
fn read_issue() -> Result<String, E> {
    std::fs::read_to_string("/etc/issue")
}

问题来了:这里的 E 到底是什么?

Rust 要求你明确说出错误类型。这就是后面一切麻烦的开始。


二、JavaScript:想 throw 什么都行

在 JavaScript 里,这个问题没那么明显。因为 JavaScript 函数不在签名里声明自己会不会 throw。

js 复制代码
import { readFileSync } from "fs";

function main() {
    let issue = readIssue();
    console.log(`${issue}`);
}

function readIssue() {
    return readFileSync("/etc/i-do-not-exist");
}

main();

文件不存在时,Node.js 会抛异常,控制台打印错误和调用栈。

而且 JavaScript 不仅能 throw Error,它几乎可以 throw 任意东西:

js 复制代码
function readIssue() {
    throw "woops";
}

这不是好主意。因为 throw 字符串时,你通常拿不到正常的堆栈信息。更合理的是:

js 复制代码
throw new Error("woops");

JavaScript 的好处是写起来快。你不需要在函数签名里写 throws,也不需要调用方强制处理错误。坏处也在这里:函数到底会不会 throw,要看文档、看实现、看运行时。

程序可以一路走 happy path,直到某个地方炸掉。


三、Go:错误在签名里,但你可以忘记处理

Go 没有异常,常见做法是多返回值:

go 复制代码
func readIssue() (string, error) {
    bs, err := os.ReadFile("/etc/issue")
    if err != nil {
        return "", err
    }

    return string(bs), nil
}

调用方也要处理:

go 复制代码
issue, err := readIssue()
if err != nil {
    log.Fatalf("fatal error: %+v", err)
}

log.Printf("issue = %v", issue)

这至少从签名上表达了:这个函数可能失败。

但 Go 的错误处理有一个非常老生常谈的问题:太容易忽略错误。

go 复制代码
func readIssue() (string, error) {
    bs, err := os.ReadFile("/etc/issue")

    err = os.WriteFile("/tmp/issue-copy", bs, 0o644)
    if err != nil {
        return "", err
    }

    return string(bs), nil
}

这里 os.ReadFile 返回的 err 被直接覆盖了。如果读文件失败,错误就永远丢了。Go 编译器不会警告你。

问题在于 Go 的函数返回的是"成功值"和"错误值"两个东西。它们是并排的。语言本身并没有强制你先检查 err,再使用成功值。所有人只能靠习惯、代码审查、lint、约定。

Rust 的 Result<T, E> 不一样。它是一个 enum:

rust 复制代码
enum Result<T, E> {
    Ok(T),
    Err(E),
}

一个 Result 要么是 Ok(T),要么是 Err(E),不能同时拥有成功值和错误值。你必须通过 match?unwrap 等方式明确处理。

不过,Go 还有一个更有趣、更隐蔽的问题。


四、Go 的 error interface 和 nil 陷阱

Go 的 error 是一个 interface:

go 复制代码
type error interface {
    Error() string
}

只要某个类型有 Error() string 方法,它就实现了 error

比如:

go 复制代码
type naughtyError struct{}

func (ne *naughtyError) Error() string {
    return "oh no"
}

于是可以返回:

go 复制代码
func readIssue() (string, error) {
    return "", &naughtyError{}
}

调用方看到 err != nil,打印错误,一切正常。

但如果写成这样:

go 复制代码
func readIssue() (string, error) {
    var err *naughtyError
    log.Printf("(in readIssue) is err nil? %v", err == nil)
    return "", err
}

err 是一个 *naughtyError,它的值是 nil。函数内部打印 err == nil,结果是 true。

可它被返回成 error interface 后,调用方再判断:

go 复制代码
issue, err := readIssue()
log.Printf("(in main) is err nil? %v", err == nil)

结果可能是 false。

也就是说,一个 nil 的具体指针,装进 interface 后,interface 本身不再是 nil。

这不是 Go 初学者的问题,很多有经验的人也会被它咬。因为它暴露了 interface 的底层结构。


五、Go interface 里有两个指针

为什么 nil 指针装进 error 后不等于 nil?

因为 interface value 不是单个指针。粗略理解,它有两部分:

text 复制代码
一个指向具体值
一个指向具体类型信息

当你有一个 *naughtyError(nil) 时,值指针确实是 nil。但当它被装进 error interface 后,interface 还记录了"这里面装的是 *naughtyError 类型"。所以 interface 的"类型指针"不是 nil。

因此,这个 interface 不等于 nil。

这也解释了为什么两个 nil 指针装进 interface 后能有不同行为:

go 复制代码
var err error

err = (*naughtyError)(nil)
log.Printf("%v", err)

err = (*niceError)(nil)
log.Printf("%v", err)

虽然两个具体值都是 nil,但一个动态类型是 *naughtyError,另一个是 *niceError。调用 Error() 时,会走不同类型的方法。

这就是 Go interface 的"魔术"。表面上它很简单:实现方法就满足接口。但当你遇到 nil、类型断言、unsafe、大小时,就必须知道它大概是两个指针。

作者用 unsafe.Sizeof 观察:一个普通指针在 64 位机器上是 8 字节,而 interface value 通常是 16 字节。

这和 Rust 后面的 Box<dyn Error> 很像。


六、Rust:我到底该返回什么错误类型

回到 Rust。我们想写:

rust 复制代码
fn read_issue() -> Result<String, E> {
    std::fs::read_to_string("/etc/issue")
}

E 该选什么?

read_to_string 返回的错误类型是 std::io::Error。所以最直接写法是:

rust 复制代码
fn read_issue() -> Result<String, std::io::Error> {
    std::fs::read_to_string("/etc/issue")
}

这很好。如果这个函数只会产生 I/O 错误,就应该这么写。

但很多时候,函数内部会有多种可能失败点。比如我们不用 read_to_string,而是先读 bytes,再做 UTF-8 转换:

rust 复制代码
fn read_issue() -> Result<String, E> {
    let buf = std::fs::read("/etc/issue")?;
    let s = String::from_utf8(buf)?;
    Ok(s)
}

这里可能出现两种错误:

text 复制代码
std::io::Error
std::string::FromUtf8Error

一个 Result<String, E> 只能有一个 E。那怎么办?

直觉上可能想写:

rust 复制代码
use std::error::Error;

fn read_issue() -> Result<String, Error> {
    ...
}

Rust 会提醒你:trait object 要写 dyn Error

于是改成:

rust 复制代码
fn read_issue() -> Result<String, dyn Error> {
    ...
}

然后编译器又报错:dyn Error 的大小在编译期未知。

这就是本文的核心之一。


七、为什么 dyn Error 不能直接作为返回值

std::error::Error 是一个 trait。很多类型都可以实现它:

text 复制代码
std::io::Error
std::string::FromUtf8Error
自定义 MyError
第三方库错误类型

dyn Error 表示"某个实现了 Error 的具体类型,但现在我们不说它到底是什么"。

问题是,不同具体类型大小不同。std::io::Error 可能是一个大小,FromUtf8Error 可能是另一个大小,自定义错误类型又可能更大。函数返回值必须在编译期知道大小,否则调用方不知道要在栈上为返回值预留多少空间。

所以裸的 dyn Error 不能直接被持有、作为局部变量、作为函数参数按值传递、作为返回值返回。

这类类型叫动态大小类型,DST。典型例子包括:

text 复制代码
dyn Trait
str
[T]

我们平时不是直接拿 str,而是拿 &strString。不是直接拿 [T],而是拿 &[T]Vec<T>。同理,不能直接拿 dyn Error,但可以拿:

rust 复制代码
&dyn Error
Box<dyn Error>
Arc<dyn Error>

这些指针本身大小是已知的。

这就是 "just box it" 的第一层含义:不要直接返回大小未知的 dyn Error,返回一个大小已知的智能指针。


八、裸指针、引用和 Box 的区别

在 Rust 里,可以写裸指针:

rust 复制代码
let e = MyError { value: 32 };
let e_ptr: *const MyError = &e;

裸指针大小固定,通常 8 字节。创建裸指针是安全的,但解引用是 unsafe:

rust 复制代码
unsafe {
    println!("{}", (*e_ptr).value);
}

为什么?因为裸指针可能为空,可能指向无效地址,可能指向已经释放的内存,可能违反别名规则。编译器无法保证它有效,所以解引用时你必须进入 unsafe,表示"我自己负责"。

Rust 更常用引用:

rust 复制代码
let e_ref: &MyError = &e;

引用通常也是一个指针大小,但它有更强保证:在安全 Rust 中,引用一定指向有效值,不能是 null,不能悬垂。你可以安全地解引用它,甚至通常不需要写 *,因为 Rust 有自动解引用。

但引用不拥有数据。它只是借用。

Box<T> 则拥有数据。它把值放到堆上,自己保存一个指向堆上值的指针。当 Box 离开作用域时,它会释放堆上的值。

可以粗略理解:

text 复制代码
*const T       裸指针,不保证有效,解引用 unsafe
&T             安全引用,不拥有数据,保证有效
Box<T>         拥有指针,堆分配,离开作用域释放
Box<dyn T>     拥有一个实现 T 的值,但通过 trait object 动态分发

所以 Box 不是普通指针。它带所有权,负责释放。


九、Go string 其实也有"盒子味"

文章中间花了很长篇幅解释 Go 的 string。因为很多 Go 开发者平时不会想 string 到底长什么样。

Go string 可以粗略看成一个 header:

text 复制代码
Data pointer
Len

当你写:

go 复制代码
s2 := s1

复制的不是底层字符串数据,而是这个 header。两个 string header 指向同一块底层字节数据。

reflect.StringHeaderunsafe.Pointer 可以看到这一点。字符串字面量 "hello" 的底层数据可能在可执行文件映射区域;如果通过 string([]byte{'h','e','l','l','o'}) 创建,底层数据可能在 Go heap。

这里文章还实验了 GC。即使对象已经可以被垃圾回收,内存内容也不一定立刻消失,因为 GC 通常只是把内存块标记为可复用,不一定清零。用 GODEBUG=clobberfree=1 后,Go GC 会在释放时用坏内容覆盖内存,这时你就能更明显看到 unsafe 代码读到了已释放内存的后果。

这一段的目的不是鼓励写 unsafe Go,而是说明:即使在 GC 语言里,内存仍然真实存在。string、slice、interface 都有底层表示。平时你可以不关心,但一旦遇到 nil interface、unsafe、性能、逃逸分析、GC 行为,就不得不关心。

Go 的"简单"在这里也有边界。


十、Rust 为什么不能返回指向局部变量的引用

Rust 里如果写:

rust 复制代码
fn lol() -> &str {
    let data: String = "hello".into();
    &data
}

编译器会拒绝。

原因很直接:data 是局部变量。函数返回时,data 会被 drop,它拥有的堆上字符串数据也会被释放。如果允许返回 &data,调用方就拿到了悬垂引用。

如果返回的是字符串字面量,就可以:

rust 复制代码
fn lol() -> &'static str {
    let s: &'static str = "hello";
    s
}

因为字符串字面量存储在可执行文件的静态区域,生命周期是 'static

这就引出堆、栈、静态区的区别。

局部变量通常在栈上。函数调用时压栈,函数返回时栈帧失效。堆由内存分配器管理,BoxStringVec 等会在堆上分配。字符串字面量等静态数据放在程序映像里,生命周期可以贯穿整个程序。

Rust 的所有权和生命周期就是在静态检查这些关系:你不能返回指向已经释放数据的引用,不能在引用还活着时释放所有者,也不能让引用比被引用数据活得更久。

C 里这些错误是运行时灾难。Rust 把它们变成编译错误。


十一、Go 也有栈和逃逸分析

文章也提醒:Go 不是"所有东西都在堆上"。Go 当然有栈。函数调用也需要栈帧。Go 编译器会尽力把局部变量放在栈上,只有当值逃逸到函数外,或者编译器无法保证生命周期时,才放到堆上。

可以用:

bash 复制代码
go run -gcflags=-m ./main.go

观察逃逸分析。

有时一个值只是算长度,不会逃逸。可一旦传给 log.Printf 这类可变参数函数,它可能被装进 interface{},然后逃逸到堆上。

这也说明:GC 语言不是没有堆栈区别,而是很多决策由编译器和运行时替你做。你平时可以不关心,但性能和底层调试时仍然会遇到它。

Rust 则更显式。Box::new 就是堆分配,引用就是借用,move 就是所有权转移。你需要知道更多,但也能更明确地表达成本。


十二、Sized:Rust 默认要求类型大小已知

现在回到 dyn Error 的问题。

Rust 中,函数局部变量、参数、返回值通常都必须在编译期知道大小。泛型参数默认也有一个隐式约束:

rust 复制代码
T: Sized

也就是说:

rust 复制代码
fn f<T>(t: T) {}

实际上近似于:

rust 复制代码
fn f<T>(t: T)
where
    T: Sized,
{}

如果你放宽这个约束:

rust 复制代码
fn f<T>(t: T)
where
    T: ?Sized,
{}

编译器会拒绝,因为按值传参必须知道大小。

但你可以拿引用:

rust 复制代码
fn f<T>(t: &T)
where
    T: ?Sized,
{}

引用本身大小固定。它可以指向一个大小已知的 T,也可以指向大小未知的 dyn Traitstr[T]

std::mem::size_of_val 就是类似签名:

rust 复制代码
pub const fn size_of_val<T: ?Sized>(val: &T) -> usize

它不能直接拿一个 unsized value,但可以拿对它的引用,然后在运行时知道实际大小。

这解释了为什么 Result<String, dyn Error> 不行:Result 需要把 E 当成一个值存进去,而 dyn Error 大小未知。


十三、返回 Result 的几种方式

我们想写一个可能返回多种错误的函数。有哪些选择?

第一,返回具体错误类型:

rust 复制代码
fn read_issue() -> Result<String, std::io::Error> {
    std::fs::read_to_string("/etc/issue")
}

优点:无堆分配,类型精确。缺点:只能返回这一种错误。

第二,用泛型或 impl Error 在参数位置很有用:

rust 复制代码
fn print_error<E: Error>(e: E) {}
fn print_error(e: impl Error) {}

但返回位置的 impl Error 不是"任意错误类型"。它表示某一个隐藏但固定的具体类型。也就是说,函数所有返回路径必须返回同一种具体错误。

如果函数里有:

rust 复制代码
let buf = std::fs::read("/etc/issue")?;
let s = String::from_utf8(buf)?;

这里有两种错误:std::io::ErrorFromUtf8Errorimpl Error 不能自动变成"二者之一"。

第三,用 Box<dyn Error>

rust 复制代码
fn read_issue() -> Result<String, Box<dyn std::error::Error>> {
    let buf = std::fs::read("/etc/issue")?;
    let s = String::from_utf8(buf)?;
    Ok(s)
}

这里可以工作。因为 Box<dyn Error> 是一个统一的具体类型,大小已知。无论底层错误是 std::io::Error 还是 FromUtf8Error,都能被装进 Box,变成 boxed trait object。

缺点是:需要堆分配和动态分发。

第四,用自定义 enum:

rust 复制代码
enum MyError {
    Io(std::io::Error),
    Utf8(std::string::FromUtf8Error),
}

它可以统一两种错误,而且不需要 heap allocation。代价是要写一些样板代码:DebugDisplayErrorFrom

实际项目中常用 thiserror

rust 复制代码
#[derive(Debug, thiserror::Error)]
enum MyError {
    #[error("i/o error: {0}")]
    Io(#[from] std::io::Error),

    #[error("utf-8 error: {0}")]
    Utf8(#[from] std::string::FromUtf8Error),
}

这样 ? 就能自动把两种错误转换成 MyError

可以总结成:

text 复制代码
具体错误类型:精确、无堆分配,但只能一种错误。
impl Error:隐藏具体类型,但返回位置仍必须是同一种具体类型。
Box<dyn Error>:统一多种错误,简单灵活,但有堆分配和动态分发。
自定义 enum:统一多种错误,无堆分配,但需要样板代码。

十四、Box<dyn Error> 为什么是 16 字节

现在终于回到问题核心:Box 里到底有什么?

看这个函数:

rust 复制代码
fn get_error() -> Box<dyn std::error::Error> {
    let e: std::io::Error = std::io::ErrorKind::Other.into();
    let e = Box::new(e);
    e
}

Box<std::io::Error> 通常只是一个指针,8 字节。它指向堆上的 std::io::Error

但返回后,类型变成 Box<dyn Error>。这时它通常是 16 字节:两个指针。

第一个指针指向堆上的具体值。第二个指针指向 vtable,也就是虚表。

vtable 里有"这个具体类型如何实现 Error trait"的函数指针。例如 Display 怎么格式化,source() 怎么调用,drop 怎么做,大小和对齐信息是什么等。

这和 Go interface 有点相似:Go interface value 里有值指针和类型信息。Rust 的 boxed trait object 里有值指针和 vtable 指针。

但也有差异。Rust 的 Box<dyn Error> 安全 API 并不让你随便 downcast 成具体类型,除非用专门设计的 Any。如果你用 unsafe 强行把 Box<dyn Error> 当成 Box<std::io::Error>,那就必须自己保证类型真的匹配。错了就是未定义行为。

所以 Box<dyn Error> 的重点不是"Box 神奇地统一了类型"。真正统一类型的是 dyn Error 的动态分发机制。Box 提供的是拥有所有权的堆指针,并让这个大小未知的 trait object 能被一个大小已知的指针持有。


十五、不想堆分配怎么办:enum

如果错误类型集合是有限的,而且你愿意把它们列出来,enum 是更好的零堆分配方案。

rust 复制代码
#[derive(Debug)]
enum MyError {
    Io(std::io::Error),
    Utf8(std::string::FromUtf8Error),
}

enum 的内存布局可以粗略理解为:

text 复制代码
一个 discriminant,记录当前是哪种变体
一块足够大的存储,能容纳最大那个变体

这有点像安全版 union。它不用 vtable,也不用堆分配。当前是哪种错误,直接存在值里。

然后实现 Display

rust 复制代码
impl std::fmt::Display for MyError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            MyError::Io(e) => write!(f, "i/o error: {}", e),
            MyError::Utf8(e) => write!(f, "utf-8 error: {}", e),
        }
    }
}

实现 Error

rust 复制代码
impl std::error::Error for MyError {}

再实现 From,让 ? 能自动转换:

rust 复制代码
impl From<std::io::Error> for MyError {
    fn from(e: std::io::Error) -> Self {
        Self::Io(e)
    }
}

impl From<std::string::FromUtf8Error> for MyError {
    fn from(e: std::string::FromUtf8Error) -> Self {
        Self::Utf8(e)
    }
}

最后:

rust 复制代码
fn read_issue() -> Result<String, MyError> {
    let buf = std::fs::read("/etc/issue")?;
    let s = String::from_utf8(buf)?;
    Ok(s)
}

它就能工作。

这就是 Rust 里很常见的错误建模方式。库代码通常用精确 enum;应用代码或脚本场景有时用 Box<dyn Error>anyhow::Error 追求方便。

Box 不是唯一答案。它只是某些场景下最省事的答案。


十六、impl Trait 到底有什么用

文章最后又问了一个问题:如果 impl Error 在错误处理里没那么好用,那 impl Trait 到底有什么意义?

答案是:它特别适合返回"具体类型存在,但我们没法写出它名字"的东西。

闭包就是典型例子。

rust 复制代码
let f = || {
    println!("hello from the closure side");
};

这个闭包实现了 Fn(),但它的具体类型没有一个你能写出来的名字。编译器内部知道它是什么,但源码里不能写出那个类型。

如果想从函数返回它,有两种方式。

第一,用 Box:

rust 复制代码
fn get_closure() -> Box<dyn Fn()> {
    Box::new(|| {
        println!("hello from the closure side");
    })
}

这会堆分配,并用动态分发。

第二,用 impl Fn()

rust 复制代码
fn get_closure() -> impl Fn() {
    || {
        println!("hello from the closure side");
    }
}

这不需要堆分配,也不需要动态分发。返回的仍然是某个具体闭包类型,只是调用者不知道名字。

如果闭包不捕获任何变量,它甚至可能是零大小类型。std::mem::size_of_val 打印出来可能是 0。

如果闭包捕获一个 u64

rust 复制代码
fn get_closure() -> impl Fn() {
    let val = 27_u64;
    move || {
        println!("val is {}", val);
    }
}

它的大小可能就是 8 字节,因为闭包结构体里要存下捕获的 val

这说明闭包不是纯函数指针。它是一个匿名结构体,里面可能有捕获环境,并实现了 Fn / FnMut / FnOnce

impl Trait 的价值就在这里:既保留具体类型和静态分发,又不用写出无法命名的类型。

同样道理,async block 和 async fn 返回的 future 类型也通常无法手写出来,所以 impl Future 很重要。


十七、这篇文章真正讲清楚了什么

整篇文章表面上是在讲 Box,实际上是在讲"大小、所有权和动态分发"。

Rust 要求很多值在编译期大小已知。因为局部变量、参数、返回值都需要布局。dyn Traitstr[T] 这种动态大小类型不能直接按值持有。

但我们可以通过指针间接使用它们:

text 复制代码
&dyn Trait
Box<dyn Trait>
Rc<dyn Trait>
Arc<dyn Trait>

其中:

text 复制代码
&dyn Trait 不拥有值,只是借用。
Box<dyn Trait> 独占拥有堆上的值。
Rc<dyn Trait> 单线程共享拥有。
Arc<dyn Trait> 多线程共享拥有。

Box<dyn Trait> 是 fat pointer,携带数据指针和 vtable 指针。它让我们能在一个统一类型里持有不同具体类型,但代价是堆分配和动态分发。

如果类型集合有限,enum 可以在不堆分配的情况下统一类型,但需要列出所有变体。

如果具体类型只有一个,只是名字写不出来,比如闭包或 async block,就用 impl Trait

所以 "just box it" 背后其实是一个选择表:

text 复制代码
我是否需要拥有这个值?
我是否需要统一多种具体类型?
我是否能列出所有类型?
我是否能接受堆分配?
我是否需要动态分发?
这个具体类型能不能被命名?

Box 是一个答案,但不是所有问题的答案。


十八、对实际 Rust 开发的建议

第一,如果你只是写应用层代码,想快速把多种错误往上传,Box<dyn Error>anyhow::Result<T> 很实用。

它牺牲了一些类型精度,但能减少很多样板代码。应用层很多时候只需要"把错误带上下文并打印出来",不一定需要调用方精确匹配每种错误。

第二,如果你在写库,优先考虑自定义错误 enum。

库的调用方通常希望知道可能失败的原因,能 match 错误类型,能做不同恢复策略。用 enum 更清楚,也避免不必要的堆分配。

第三,看到 dyn Trait 就要想:这是动态大小类型,不能直接持有,通常要通过引用或智能指针。

第四,看到 Box<dyn Trait> 就要知道:这里有堆分配,也有 vtable 动态分发。不是坏事,但不是零成本。

第五,看到 impl Trait,要分清参数位置和返回位置。

参数位置的 impl Trait 类似泛型简写;返回位置的 impl Trait 是"某个隐藏但固定的具体类型",不是"任意实现该 trait 的类型"。

第六,闭包和 async future 的具体类型通常无法命名。返回它们时,impl Trait 往往比 Box<dyn Trait> 更好,因为它能避免堆分配和动态分发。


十九、Go 和 Rust 的对照意义

文章花很多时间讲 Go,不是为了说 Go 一无是处,而是为了展示两种语言如何处理"值、接口、指针、内存、错误"的复杂性。

Go 用 GC 让很多生命周期问题在使用层面消失。你可以把指针放进 map,可以把局部创建的对象返回出去,只要还有引用,GC 就不会回收。Go interface 也让动态分发很自然。

但这不代表底层复杂性不存在。interface 有两指针结构,所以 nil 指针装进 interface 会出现 err != nil 的陷阱。string 有 header 和底层数据。逃逸分析会决定栈还是堆。unsafe 和 reflect 可以绕过抽象,看到内存真实样子。

Rust 则把更多复杂性暴露在类型系统里。你要明确所有权、借用、大小、动态分发、堆分配。开始更难,但很多事情更清楚:

text 复制代码
Result 是 Ok 或 Err,不会同时有成功值和错误值。
引用保证有效,不是普通裸指针。
dyn Trait 大小未知,必须通过引用或 Box 使用。
Box 拥有堆上数据,离开作用域自动释放。
enum 可以显式统一有限集合的类型。
impl Trait 可以隐藏无法命名的具体类型。

所以,这不是"谁绝对更好"的简单比较,而是两种抽象边界的对照。


二十、总结

这篇文章从一句 Rust 开发者常说的"那就 box 一下吧"开始。很多人知道 Box<dyn Error> 能让代码通过,却不一定知道为什么。作者于是从一个读 /etc/issue 文件的小函数出发,逐层拆开:读文件会失败,所以 Rust 返回 Result<String, std::io::Error>;如果函数内部可能产生多种错误,就必须决定返回什么错误类型。

JavaScript 的做法是 throw,甚至可以 throw 字符串,但那会丢失正常堆栈。Go 的做法是返回 (value, error),错误在签名里,但很容易忘记检查。Go 的 error 是 interface,只要求有 Error() string 方法;于是一个 nil 的 *naughtyError 装进 error interface 后,interface 本身不再是 nil,因为 interface value 通常包含值指针和类型信息两个部分。这个例子揭示了 Go interface 的底层结构,也为后面理解 Rust trait object 做铺垫。

回到 Rust,Result<String, dyn Error> 不成立,因为 dyn Error 是动态大小类型。实现 Error 的具体类型可以有不同大小,而函数返回值和局部变量必须在编译期知道大小。Rust 默认泛型参数有 Sized 约束;如果使用 ?Sized 放宽约束,也只能通过引用或指针间接使用。裸指针可以为空,解引用需要 unsafe;引用保证有效但不拥有数据;Box<T> 则是拥有所有权的堆指针,离开作用域时会释放堆上的值。

Box<dyn Error> 能工作,是因为 Box 本身大小固定,而 dyn Error 通过 trait object 做动态分发。Box<std::io::Error> 通常是一个指针,8 字节;Box<dyn Error> 通常是 fat pointer,16 字节,一个指向堆上的具体值,另一个指向 vtable。vtable 里有该具体类型对 Error trait 的实现信息。真正统一多种错误类型的是 dyn Error 的动态分发,Box 负责拥有和间接存储。

如果不想为了统一错误类型而堆分配,可以使用 enum。比如 MyError::Io(std::io::Error)MyError::Utf8(std::string::FromUtf8Error),再实现 DisplayErrorFrom,就能让 ? 自动把两种错误转换成同一个 MyError。实际项目中通常用 thiserror 减少样板。这样可以统一多种错误类型,又避免 heap allocation。

最后,文章解释 impl Trait 的真正用途。返回位置的 impl Error 不是"任意错误类型",而是"某个隐藏但固定的具体类型",所以不能用来返回两种不同错误。impl Trait 更适合返回闭包或 async block 这种具体类型存在但无法命名的值。闭包实现 Fn,但它的具体类型没有可写名字;如果用 Box<dyn Fn()>,要堆分配和动态分发;如果用 impl Fn(),就能保留具体类型,避免分配。没有捕获变量的闭包甚至可能是零大小类型;捕获一个 u64 后,闭包大小可能变成 8 字节。

整篇文章最后总结出几个核心点:Rust 里只有 Sized 的值能按值持有、传递和返回;trait object 是 unsized,必须通过引用或智能指针操作;Box 是拥有所有权的堆指针,因此能间接容纳 unsized value;trait object 不是唯一统一类型的方法,enum 也可以;impl Trait 可以隐藏无法命名的具体类型,比如闭包和 async future。

所以,"just box it" 不是魔法。它的意思是:把一个具体值放到堆上,用一个大小已知、拥有所有权的指针来持有它;如果再配合 dyn Trait,就能在运行时通过 vtable 把不同具体类型统一到同一个接口下。Box 里装的不只是一个值,还装着 Rust 对大小、所有权、动态分发和内存布局的整套设计。

相关推荐
Java内核笔记1 小时前
Spring Security 源码解析(六)无状态 JWT 实践:Session 共享与自定义过滤器
java·后端
乘云数字DATABUFF1 小时前
5分钟部署开源APM Databuff:OpenTelemetry全链路追踪入门实战
运维·后端
杨利杰YJlio1 小时前
OpenClaw / clawdbot 是什么?看懂 Agent 体系
前端·后端
SamDeepThinking2 小时前
一条UPDATE语句在MySQL 8.0中到底加了几把锁?
后端·mysql·程序员
CodeSheep2 小时前
他俩只靠写代码,登上了胡润财富榜!
前端·后端·程序员
IT_陈寒2 小时前
React状态更新总是慢半拍?你可能忘了这个默认行为
前端·人工智能·后端
candyTong2 小时前
阿里开源 AI Code Review 工具:ocr review 的执行链路解析
javascript·后端·架构
铁皮饭盒3 小时前
TypeBox 比 Zod.js 校验 快10倍, 还兼容AI 工具调用, 他做对了什么?
前端·javascript·后端
倔强的石头_11 小时前
WorkBuddy 上手实战:打造一个可用的本地 AI 工作台
后端