跳转到主内容trait:从函数指针表到行为抽象 | 物联网民工

trait:从函数指针表到行为抽象

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 回答“这些类型必须具备什么行为”。