在实战中运用泛型和动态trait(特质)

PhantomData<T>

// 先自定义一个数据结构

js 复制代码
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Identifier<T>{
   inner:u64,
}

//然后在User和Product,各自用Identifier> 来让 Identifier 和自己的类型绑定,达到让不同类型的 id 无法比较的目的

js 复制代码
    
  #[derive(Debug, Default, PartialEq, Eq)]
pub struct User {
id: Identifier<Self>,//self其实就是User
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Product {
id: Identifier<Self>,//self其实就是Product
}  
    

搞个测试用例

js 复制代码
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn id_should_not_be_the_same() {
    let user = User::default();
     let product = Product::default();
    // 两个 id 不能比较,因为他们属于不同的类型
    // assert_ne!(user.id, product.id);
    assert_eq!(user.id.inner, product.id.inner);
  }
}

测试结论:编译直接报错

在 Rust 的类型系统中因为 Identifier 在定义时,并没有使用泛型参数 T,编译器认为 T 是多余的,所以只能把 T 删除掉才能编译通过。但是,删除掉 T,User 和 Product 的 id 就可以比较了

删掉 T 后的代码长什么样?

如果你为了编译通过,把泛型 TPhantomData 都删了,代码变成了这样:

rust 复制代码
// 1. Identifier 现在只是一个普通的结构体,没有任何泛型标记
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Identifier {
    inner: u64,
}

// 2. User 里面放的是 Identifier
#[derive(Debug, Default, PartialEq, Eq)]
pub struct User {
    id: Identifier, 
}

// 3. Product 里面放的也是 Identifier
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Product {
    id: Identifier, 
}

为什么这时候可以比较了?

当你写 assert_eq!(user.id, product.id) 时,Rust 编译器做的是类型匹配

  • 左边user.id。它的类型是什么?是 Identifier
  • 右边product.id。它的类型是什么?也是 Identifier
  • 编译器判断IdentifierIdentifier 是同一个类型吗?是!

既然类型相同,而且你实现了 PartialEq,编译器就会去比较里面的值 (inner)。因为都是默认值 0,所以它们相等。

核心误区:父结构体不会"污染"子字段

你之前的困惑点在于:

"User 和 Product 是不同的 struct,那它们里面的 id 不也应该不同吗?"

打个比方:

  • User 是一个 红色的盒子
  • Product 是一个 蓝色的盒子
  • Identifier (无泛型)一支铅笔

现在:

  1. 你在红色盒子里放了一支铅笔。
  2. 你在蓝色盒子里放了一支铅笔。
  3. 你把两支铅笔拿出来对比:它们是一样的铅笔吗?

答案是肯定的。 盒子(struct)不同,不代表盒子里的东西(field)变成了不同的类型。除非你在铅笔上刻字(这就是泛型的作用)

现在我们添加上PhantomData<T>

当我们把 PhantomData<T>Identifier<T> 加回来时,相当于我们给铅笔刻了字

js 复制代码
    
    use std::marker::PhantomData;
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Identifier<T> {
inner: u64,
_tag: PhantomData<T>,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct User {
id: Identifier<Self>,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Product {
id: Identifier<Self>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn id_should_not_be_the_same() {
let user = User::default();
let product = Product::default();
// 两个 id 不能比较,因为他们属于不同的类型
// assert_ne!(user.id, product.id);
assert_eq!(user.id.inner, product.id.inner);
}
    
    
markdown 复制代码
-   `User` 里的 `id` 类型变成了 `Identifier<User>` (刻着"用户专用"的铅笔)。
  • Product 里的 id 类型变成了 Identifier<Product> (刻着"商品专用"的铅笔)。

这时候编译器再看:

  • 左边:Identifier<User>

  • 右边:Identifier<Product>

  • 编译器满意了(认为你用了 T),结构体定义通过编译

  • 编译器判断 :这是两个完全不同的类型,就像 Stringi32 一样不同,禁止比较!

总结

  1. 没有泛型时user.idproduct.id 的类型完全一样 ,都是 Identifier。它们只是恰好被放在了不同的结构体里,但这不影响它们自身的类型身份。所以可以比较。
  2. 有泛型时 :泛型参数 <T> 变成了类型签名的一部分。Identifier<User>Identifier<Product> 是两个截然不同的类型。所以不能比较。

这就是为什么我们需要 PhantomData。它不仅是为了让编译器通过,更是为了把 User 这个身份信息,强行"注入"到 Identifier 这个类型里,实现业务逻辑上的隔离。 PhantomData :只是个创可贴 。当你仅仅想用 T 做标签,而不想真的存 T 类型的数据时,用来哄编译器开心的。

Trait Object

三个例子分别对应了 Trait Object 的三大核心用途:统一接口(多态参数)工厂模式(动态返回)异构集合(混合存储)

请按照顺序手敲,并在敲代码时思考注释里的问题。


例子 1:函数参数 (动态分发)

目的: 理解如何编写一个函数,让它能接受任何实现了特定 Trait 的类型,而不需要像泛型那样为每个类型生成一份代码(减小二进制体积)。

场景: 我们做一个 "渲染引擎" ,不管你是 Button 还是 Image,只要你会 render,我就能把你画出来。

Rust

rust 复制代码
// 1. 定义行为特征
trait Renderable {
    fn render(&self);
}

// 2. 定义具体组件
struct Button { label: String }
impl Renderable for Button {
    fn render(&self) {
        println!("绘制按钮: [ {} ]", self.label);
    }
}

struct Image { src: String }
impl Renderable for Image {
    fn render(&self) {
        println!("绘制图片: <img>{}</img>", self.src);
    }
}

// 3. 【核心】函数参数使用 &dyn Renderable
// 思考:为什么这里不用泛型 fn draw<T: Renderable>(item: &T) ?
// 答案:如果用泛型,编译器会生成 draw_for_button 和 draw_for_image 两份代码。
//      用 dyn,代码只有一份,通过 vtable 动态查找 render 方法。
fn draw_component(item: &dyn Renderable) {
    println!(">>> 开始渲染组件...");
    item.render(); // 运行时查表调用
    println!("<<< 渲染结束\n");
}

fn main() {
    let btn = Button { label: "提交".into() };
    let img = Image { src: "logo.png".into() };

    // 传入引用,自动转为 Trait Object 胖指针
    draw_component(&btn); 
    draw_component(&img);
}

例子 2:函数返回值 (工厂模式)

目的: 解决 Rust 函数只能返回确定大小类型的限制。理解为什么返回值必须包在 Box 里。

场景: 一个跨平台的 UI 工厂。根据传入的配置字符串,决定是造一个 Windows 风格的组件,还是 MacOS 风格的组件。

Rust

rust 复制代码
trait Widget {
    fn draw(&self);
}

struct WindowsWidget;
impl Widget for WindowsWidget {
    fn draw(&self) { println!("Style: Windows (方角)"); }
}

struct MacWidget;
impl Widget for MacWidget {
    fn draw(&self) { println!("Style: MacOS (圆角)"); }
}

// 【核心】函数返回值 Box<dyn Widget>
// 思考:为什么不能写 fn create_widget() -> dyn Widget ?
// 答案:dyn Widget 大小不确定(可能是 WindowsWidget 0字节,也可能是巨型结构体)。
//      函数栈帧无法预留内存。必须把数据扔到堆(Box)上,返回固定大小的胖指针。
fn widget_factory(os_type: &str) -> Box<dyn Widget> {
    if os_type == "windows" {
        Box::new(WindowsWidget)
    } else {
        Box::new(MacWidget)
    }
}

fn main() {
    // 运行时决定返回什么类型
    let w1 = widget_factory("windows");
    let w2 = widget_factory("mac");

    w1.draw();
    w2.draw();
}

对比练习: 如果你尝试把返回值改成 impl Widget(静态分发),编译器会报错,因为 if/else 分支返回了不同的类型。只有 Box<dyn Widget> 才能抹平这种差异。


例子 3:数据结构 (异构集合)

目的: 在一个 Vec 列表中存储不同类型的对象。这是泛型 Vec<T> 做不到的。

场景: 一个 事件处理器列表。你的 App 里有不同类型的监听器(点击监听、键盘监听),你想把它们放在同一个数组里统一管理。

Rust

rust 复制代码
// 1. 定义事件监听行为
trait EventListener {
    // 这里的 &self 也是动态分发的关键
    fn on_event(&self, event_name: &str);
}

struct ClickListener;
impl EventListener for ClickListener {
    fn on_event(&self, e: &str) {
        println!("鼠标点击触发: {}", e);
    }
}

struct KeyListener { key_code: u32 }
impl EventListener for KeyListener {
    fn on_event(&self, e: &str) {
        println!("键盘按键 {} 触发: {}", self.key_code, e);
    }
}

// 2. 【核心】在结构体中使用 Vec<Box<dyn Trait>>
struct App {
    // 这是一个"混合"列表,可以存任何实现了 EventListener 的东西
    listeners: Vec<Box<dyn EventListener>>,
}

impl App {
    fn new() -> Self {
        Self { listeners: Vec::new() }
    }

    // 注册监听器
    fn add_listener(&mut self, l: Box<dyn EventListener>) {
        self.listeners.push(l);
    }

    // 触发所有事件
    fn fire_event(&self, event: &str) {
        for listener in &self.listeners {
            // listener 是 Box<dyn ...>
            // Rust 会自动解引用 Box,并利用内部的 vtable 调用正确的方法
            listener.on_event(event);
        }
    }
}

fn main() {
    let mut app = App::new();

    // 塞入不同类型的结构体
    app.add_listener(Box::new(ClickListener));
    app.add_listener(Box::new(KeyListener { key_code: 13 })); // Enter键

    app.fire_event("用户登录");
}

rust 复制代码
第三个点 **数据结构 (`Vec<Box<dyn Trait>>`)**  确实是 Rust 和 JS 思维差异最大的地方。

只要搞懂了 "内存对其""鸭子类型" 的区别,你就彻底懂了。

我们用一个最经典的场景:游戏里的背包系统


1. JavaScript 的做法:魔法口袋 (Duck Typing)

在 JS 里,数组(Array)是一个魔法口袋。它可以随便装东西,不管你塞进去的是什么。

JS 场景: 你的游戏背包里有三个东西:一把剑 (Sword),一瓶药 (Potion),一张地图 (Map)。它们不一样大,属性也不一样,但它们都有一个共同点:可以被"使用" (use())

JavaScript

javascript 复制代码
// JS 代码
class Sword {
    use() { console.log("挥剑攻击!"); }
}

class Potion {
    use() { console.log("喝药回血!"); }
}

class Map {
    use() { console.log("查看地图..."); }
}

// 1. 创建背包 (Array)
// JS: 我不管你塞什么,反正我都接得住
const bag = [
    new Sword(),
    new Potion(),
    new Map(), 
    "甚至塞个字符串进去JS也不报错" 
];

// 2. 遍历使用
// JS: 只要你有 use() 方法,我就调用。如果没有,我运行时候再报错。
bag.forEach(item => item.use());

特点: JS 是 "鸭子类型" (Duck Typing)。只要它走起来像鸭子,叫起来像鸭子,我就把它当鸭子。JS 数组存的是引用(Reference) ,不在乎具体的内存大小。


2. Rust 的原生做法:强迫症的格子铺 (Vec<T>)

Rust 是静态类型 语言,而且对内存布局有强迫症。

标准的 Vec<T> 就像是一个超市货架的陈列盒

  • 如果你定义 Vec<Sword>,那这个盒子里每一个格子 都必须刚好能放下 Sword 那么大的东西。
  • Sword 可能占 10 个字节,Potion 可能占 4 个字节。

Rust 的困境: 如果你想把 SwordPotion 放在同一个 Vec 里:

Rust

rust 复制代码
let mut bag = Vec::new();
bag.push(Sword);  // 放入剑
bag.push(Potion); // ❌ 报错!类型不匹配!
// 编译器怒吼:这个 Vec 是专门放 Sword 的,你拿个 Potion 过来干什么?尺寸都不一样!

这就像你买了一箱红酒的泡沫箱(有固定孔洞),你非要往里塞个西瓜,塞不进去啊!


3. 解决方案:统一包装盒 (Vec<Box<dyn Trait>>)

既然 SwordPotion 大小不一样,那怎么把它们放进同一个格子里呢?

答案: 把它们分别装进统一大小的快递盒里!

这个"快递盒"就是 Box(指针)

  • 不管里面的东西多大,指针的大小是固定的(在 64 位系统上就是 8 字节)。
  • 我们在快递盒外面贴个标签: "可使用物品 (dyn Usable)"

这样,Vec 里存的不再是千奇百怪的物品,而是整整齐齐的快递盒

Rust

rust 复制代码
// 定义特征:所有物品必须能被"使用"
trait Usable {
    fn use_item(&self);
}

struct Sword;
impl Usable for Sword { fn use_item(&self) { println!("挥剑!"); } }

struct Potion;
impl Usable for Potion { fn use_item(&self) { println!("喝药!"); } }

fn main() {
    // 创建背包:这是一个专门装"Usable 快递盒"的列表
    // Box<dyn Usable> 的大小是固定的(胖指针大小)
    let mut bag: Vec<Box<dyn Usable>> = Vec::new();

    // 1. 装箱
    // 把 Sword 装进 Box,变成 Box<dyn Usable>
    bag.push(Box::new(Sword)); 
    // 把 Potion 装进 Box,变成 Box<dyn Usable>
    bag.push(Box::new(Potion));

    // 2. 遍历
    for item in bag {
        // item 现在是一个盒子
        // Rust 运行时会看盒子上的标签(vtable),决定怎么用里面的东西
        item.use_item();
    }
}

4. 深度对比图表

维度 JS 数组 ([]) Rust 普通泛型 (Vec<T>) Rust Trait Object (Vec<Box<dyn Trait>>)
画面感 魔法袋 红酒专用箱 集装箱货船
存储内容 啥都能混着装 只能装完全一样的东西 装着不同货物标准集装箱
原理 存的都是动态引用 存的是紧凑的原始数据 存的是统一大小的指针 (Box)
内存效率 低 (各种元数据) 极高 (无间隙) 中等 (多一次指针跳转)
灵活性 极高 (随便混用) 低 (不能混用) 高 (实现了 Trait 就能混用)

5. 为什么叫"杂物箱"?

回到那个比喻:

  • 泛型 (Vec<Apple>) :这就像水果店的**"精品苹果礼盒"**。每一个坑位都是为了苹果设计的,你敢放个梨进去,盖子都盖不上(编译不过)。

  • Trait Object (Vec<Box<dyn Fruit>>) :这就像搬家公司的**"杂物箱"**。

    • 你想把 苹果、梨、甚至西瓜 放在一起运走。
    • 你不能直接堆在一起(不好码放)。
    • 你把苹果装进一个小纸箱 (Box)。
    • 把梨装进另一个小纸箱 (Box)。
    • 把西瓜装进一个大纸箱 (Box)。
    • 关键点: 虽然里面的水果大小不同,但这些纸箱的外表看起都是规整的长方体。
    • 现在,你可以把这些纸箱整整齐齐地码放在卡车 (Vec) 里了。

这就是 Vec<Box<dyn Trait>> 的意义:通过 Box 统一尺寸,通过 Trait 统一接口,实现了在强类型语言中存储"不同种类"数据的能力。

总结与核心记忆点

当你敲完这三段代码,请在脑子里回放这三个画面:

  1. 函数参数 (&dyn Trait)

    • 画面:万能插座。
    • 目的:为了让一份函数代码兼容多种输入类型(省空间,解耦)。
  2. 函数返回值 (Box<dyn Trait>)

    • 画面:盲盒。
    • 目的:为了在运行时根据逻辑吐出不同类型的对象(因为栈上放不下未知的类型,只能放堆指针)。
  3. 数据结构 (Vec<Box<dyn Trait>>)

    • 画面:杂物箱。
    • 目的:为了把"苹果"和"梨"放在同一个篮子里(泛型篮子只能放一种水果,Trait Object 篮子能放所有"水果")。

Trait Object 到底是做什么的?

用技术语言总结,它主要为了解决 Rust 强类型系统带来的两个"痛点":

痛点 1:我想把不同的东西放在同一个数组里

在 Rust 里,数组 Vec<T>T 必须是同一种 具体类型。你不能在 Vec<String> 里放 int

  • 没有 Trait Object :你没法把 Button(按钮)和 Input(输入框)放在同一个 UI_List 里。
  • 有了 Trait Object :你可以定义一个 trait Widget,然后创建一个 Vec<Box<dyn Widget>>。这里面既能放按钮,也能放输入框,因为它们都是 dyn Widget

痛点 2:我想在运行时决定返回什么类型

有些函数,根据条件的判定,可能返回 A 类型,也可能返回 B 类型。

  • 没有 Trait Object :函数返回值 -> T 必须在编译时确定是哪一个 struct。你不能写 if x { return Button } else { return Input },编译器会报错。
  • 有了 Trait Object :你可以让函数返回 Box<dyn Widget>。编译器就不管具体是啥了,只管你返回的东西是一个"Widget 指针"。

3. 为什么说是"运行时"?

为了让你理解"运行时"和"编译时"的区别,看这个对比:

泛型 (编译时确定)

Rust

csharp 复制代码
// 泛型函数
fn run<T: Workable>(worker: T) {
    worker.work();
}

// 编译时:
// 编译器发现你对 Programmer 调用了 run,它就生成了一份 run_for_programmer 代码。
// 编译器发现你对 Designer 调用了 run,它又生成了一份 run_for_designer 代码。
// 这里的 worker.work() 是直接写死的函数地址,速度极快。

Trait Object (运行时确定)

Rust

scss 复制代码
// Trait Object 函数
fn run(worker: &dyn Workable) {
    worker.work();
}

// 编译时:
// 编译器只生成这一份代码。它不知道 worker 到底是啥。
// 编译器在代码里留了一句话:"到时候去查这个 worker 的虚表(vtable),看它对应的 work 函数在哪。"

// 运行时:
// 程序跑到了这里,拿到了 worker。
// 程序:查看 vtable -> 找到函数地址 -> 跳转执行。
// 这个"查表-跳转"的过程,就是运行时的动态分发。

总结

Trait Object (dyn Trait) 的作用就是:

  1. 忘掉身份 :把具体的 UserProduct 等类型信息忘掉,只记住它们实现了什么 Trait。

  2. 统一管理 :让不同的类型能进入同一个容器(Vec),或者通过同一个函数接口。

  3. 动态决策:把"到底执行哪个具体的函数"这个决定,推迟到程序运行的时候再做。

按"侧重点"切分:

  • 泛型 + PhantomData → 侧重 "搞数据结构" (定型、身份、内存布局)。
  • Trait Object → 侧重 "搞函数" (调用、行为、动态分发)。

这个切入点非常刁钻且有效。为了帮你达到 100% 的理解,我稍微帮你微调一下定义,你会发现豁然开朗。

我们要用 "身份 (Identity)""能力 (Capability)" 这两个词来区分它们。


1. 泛型 + PhantomData = 侧重"身份" (Identity)

------"你是谁?"

你说的"搞数据结构"完全没毛病。这套机制的核心任务就是定义身份 ,确立严格的类型边界

  • 数据结构上 :它负责生成专用的内存布局。

    • Identifier<User>Identifier<Product> 是两个物种。
    • 这就是在定义"我是 User 专用的 ID",或者"我是 Product 专用的 ID"。
    • PhantomData 是这个身份的防伪标签,防止你在编译期搞混身份。
  • 函数上 :它其实也搞函数,但是是专用的函数

    • 编译器生成了 fn save_user()fn save_product()
    • 核心逻辑 :因为你是 User(身份),所以我只准你调 User 的方法。

总结 :这一派是 "看人下菜碟" 。因为你的数据结构身份确定了,所以你的代码逻辑就定死了。


2. Trait Object = 侧重"能力" (Capability)

------"你会干什么?"

你说的"搞函数"也非常到位。这套机制的核心任务就是抹去身份,只看行为

  • 数据结构上 :它不在乎具体的内存布局。

    • 它把数据打包进黑盒子(胖指针)。
    • 它说:"我不管你本身是圆的还是扁的(是 User 还是 Product),我都把你当成一个dyn Workable"。
  • 函数上:这是它的主战场。

    • 它通过 vtable(虚表)来动态调用函数。
    • 核心逻辑 :我不管你是谁,只要你会干活(有这个函数),我就调你。

总结 :这一派是 "只要结果" 。它忽略数据结构的差异,只为了统一执行函数调用


3. 终极对比图:一张表理清"搞什么"

维度 泛型 + PhantomData Trait Object (dyn)
你的总结 搞数据结构 (✅ 侧重身份定义) 搞函数 (✅ 侧重行为调用)
核心哲学 严谨的分类学 灵活的实用主义
场景比喻 身份证系统 每个人都有唯一的身份证,不同省份的人不能乱归类。 全能工装 不管你是谁,穿上这身衣服(Trait),我就让你进工地干活。
编译器态度 "我要知道你祖宗十八代是谁,一点都不能错。" "我不管你是谁,把活干了就行。"
何时用? 当你需要类型安全,防止张冠李戴时。 (如:防止把用户ID传给商品API) 当你需要统一管理,处理一堆杂乱对象时。 (如:把按钮、图片、文字都放在一个列表里渲染)

4. 结论

你的理解在宏观架构上是完全成立的:

  • 当我们想利用 Rust 的类型系统来防止业务逻辑错误 (比如那个 ID 混用的例子)时,我们用 泛型 + PhantomData 。这确实是在精心设计数据结构之间的关系。
  • 当我们想利用 Rust 的多态性来写通用的业务代码 (比如渲染器、插件系统)时,我们用 Trait Object 。这确实是在关注函数接口的统一调用。

所以,答案是:Yes!这个二分法非常实用,完全可以作为你写 Rust 架构时的指导方针。

一句话口诀: 泛型是"千人千面"(生成多份代码),Trait Object 是"千人一面"(统一接口,查表执行)。

相关推荐
杨充20 小时前
10.可测试性实战设计
设计模式·开源·代码规范
杨充20 小时前
9.重构十二式的实战
设计模式·开源·代码规范
杨充20 小时前
6.设计原则的全景图
设计模式·开源·全栈
杨充20 小时前
2.面向对象的特性
设计模式
杨充20 小时前
7.SOLID原则案例汇
设计模式·开源·全栈
杨充20 小时前
8.反模式与坏味道
设计模式·开源·代码规范
杨充20 小时前
3.接口vs抽象类比较
设计模式
咖啡八杯1 天前
文法、BNF与AST
java·设计模式·解释器模式·ast·文法
咖啡八杯1 天前
GoF设计模式——解释器模式
java·后端·spring·设计模式