在实战中运用泛型和动态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 是"千人一面"(统一接口,查表执行)。

相关推荐
lomocode6 小时前
改一个需求动 23 处代码?你可能踩进了这个坑
后端·设计模式
喷火龙8号7 小时前
JWT 认证方案深度对比:单 Token 扩展刷新 vs 双 Token 验证
后端·设计模式·架构
fakerth1 天前
【OpenHarmony】设计模式模块详解
c++·单例模式·设计模式·openharmony
alibli1 天前
一文学会设计模式之创建型模式及最佳实现
c++·设计模式
1024肥宅1 天前
前端常用模式:提升代码质量的四大核心模式
前端·javascript·设计模式
郝学胜-神的一滴1 天前
设计模式依赖于多态特性
java·开发语言·c++·python·程序人生·设计模式·软件工程
帅次1 天前
系统分析师:软件需求工程的软件需求概述、需求获取、需求分析
设计模式·重构·软件工程·团队开发·软件构建·需求分析·规格说明书
EXtreme351 天前
【数据结构】算法艺术:如何用两个栈(LIFO)优雅地模拟队列(FIFO)?
c语言·数据结构·算法·设计模式·栈与队列·摊还分析·算法艺术
1024肥宅2 天前
JavaScript常用设计模式完整指南
前端·javascript·设计模式