C 里没有语言级接口,但 C 项目里一直有“接口”。
驱动表、回调表、虚函数表、opaque pointer、void *ctx 加函数指针,本质上都在表达同一件事:某个对象能执行一组操作。
Rust 的 trait 也是在表达行为。区别是,Rust 把“这个类型必须提供哪些方法”写进类型系统,而不是只靠结构体字段和调用约定维护。
这一篇只讲 trait 的第一层用法:怎样把 C 里的函数指针表,迁移成 Rust 的行为抽象。
C 里的函数指针表
看一个简单输出接口:
#include <stddef.h>
#include <stdint.h>
struct WriterOps {
int (*write)(void *ctx, const uint8_t *buf, size_t len);
int (*flush)(void *ctx);
};
struct Writer {
void *ctx;
const struct WriterOps *ops;
};
int writer_write(struct Writer *writer, const uint8_t *buf, size_t len)
{
return writer->ops->write(writer->ctx, buf, len);
}
这套写法很常见。ctx 指向具体对象,ops 提供操作表。不同实现只要填不同的函数指针,就能复用同一套调用代码。
问题也很熟悉:
ctx的真实类型要靠约定ops是否为空要靠约定- 每个函数指针是否存在要靠约定
ctx的生命周期和释放责任要靠约定- 函数签名一旦不匹配,问题可能到运行时才暴露
C 能把这套接口写得很稳,但需要纪律。
trait 表达一组行为
Rust 里可以写成 trait:
trait Writer {
fn write(&mut self, buf: &[u8]) -> std::io::Result<()>;
fn flush(&mut self) -> std::io::Result<()>;
}
这表示:任何实现 Writer 的类型,都必须提供 write 和 flush 这两个方法。
例如一个内存 writer:
struct MemoryWriter {
data: Vec<u8>,
}
impl Writer for MemoryWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<()> {
self.data.extend_from_slice(buf);
Ok(())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl Writer for MemoryWriter 的意思是:MemoryWriter 这个类型具备 Writer 这组行为。
这和 C 的 ops 表很像,但方法签名、参数类型和返回值都由编译器检查。
trait 不是数据布局
C 的接口表通常有明确内存布局:
struct WriterOps {
int (*write)(...);
int (*flush)(...);
};
Rust 的 trait 本身不是一个 C ABI 结构体,也不是可以直接跨 FFI 传递的布局。
trait 是 Rust 类型系统里的行为约束。它回答的是:
这个类型能不能调用这些方法?
这个函数要求参数具备什么行为?
如果要和 C 交互,仍然应该在 FFI 边界使用 extern "C"、明确结构体布局和函数指针。trait 可以放在 Rust 内部,把 C 边界包装成更清晰的 Rust API。
用 trait 约束泛型函数
如果一个函数只关心“能写”,不关心具体 writer 是谁,可以写:
fn write_header<W: Writer>(writer: &mut W) -> std::io::Result<()> {
writer.write(&[0x12, 0x34])?;
writer.flush()
}
W: Writer 表示:W 可以是任何实现了 Writer 的类型。
调用时:
let mut writer = MemoryWriter { data: Vec::new() };
write_header(&mut writer)?;
这和 C 里传入 struct Writer * 的目标类似:调用方提供具体实现,公共函数只依赖接口。
区别是,Rust 编译器知道 W 必须实现 Writer,也知道 write 和 flush 的精确签名。
dyn trait 表示运行时分发
有时你不想让函数针对具体类型生成代码,而是想像 C 函数指针表那样,在运行时通过接口调用。
可以用 trait object:
fn write_footer(writer: &mut dyn Writer) -> std::io::Result<()> {
writer.write(&[0xff])?;
writer.flush()
}
&mut dyn Writer 可以先理解成:
指向某个实现了 Writer 的对象 + 一张方法表
这和 C 的 ctx + ops 很接近。
第一层区别可以这样看:
W: Writer 编译期知道具体类型,静态分发
&mut dyn Writer 运行时通过方法表调用,动态分发
后面讲泛型时会再展开。现在只要知道:trait 既可以服务编译期抽象,也可以服务运行时接口对象。
trait 可以提供默认方法
C 的 ops 表里,如果某个函数可选,经常要检查空指针:
if (writer->ops->flush != NULL) {
writer->ops->flush(writer->ctx);
}
Rust trait 可以给方法默认实现:
trait Writer {
fn write(&mut self, buf: &[u8]) -> std::io::Result<()>;
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
实现者必须提供 write,但可以不实现 flush,直接使用默认行为。
这比“函数指针可以为 NULL”更明确。默认行为在 trait 定义里,不是隐藏在调用方的条件判断里。
trait 让库 API 更清楚
如果你写一个编码库,不应该把输出固定死为 Vec<u8> 或文件。可以让调用方提供 writer:
fn encode_message<W: Writer>(writer: &mut W, payload: &[u8]) -> std::io::Result<()> {
writer.write(&[payload.len() as u8])?;
writer.write(payload)?;
writer.flush()
}
这样同一套逻辑可以写到内存、文件、socket 或测试 mock。
在 C 里也能通过 ops 表做到。Rust trait 的优势是:接口约束、方法签名、错误类型、借用关系都在类型里,调用方和实现方都更难写错。
这一篇先记住什么
C 和 Rust 的第一层对应关系是:
函数指针 -> trait 方法
ops 表 / vtable -> trait / dyn trait
void *ctx -> 具体实现类型里的字段
struct Interface * -> &mut dyn Trait
要求某类型有一组操作 -> T: Trait
可选函数指针 -> trait 默认方法
trait 不是 C 结构体布局,也不是 FFI 接口本身。它是 Rust 内部表达行为约束的方式。
如果 C 里的问题是“这个对象应该支持哪些操作”,Rust 里通常先考虑 trait。
下一篇可以进入泛型。trait 经常和泛型一起出现:泛型回答“代码对哪些类型通用”,trait 回答“这些类型必须具备什么行为”。