类型系统

6 阅读10分钟

类型系统其实就是,对类型进行定义、检查和处理的系统

  • 定义后类型是否可以隐式转换,可以分为强类型和弱类型

  • 按类型检查的时机,在编译时检查还是运行时检查,可以分为静态类型系统动态类型系 统

     -对于静态类型系统,根据类型是否能够被推导出来进一步分为显式静态和隐式静态
    

所以rust是强显示静态语言

对于静态类型系统,多态可以通过,参数多态特设多态子类型多态

  • 参数多态 eg:一个函数参数类型不一样,可以有不一样的实现

  • 特设多态 eg:允许一个相同的函数名或操作符(比如 +)在操作不同类型时,有各自特定的、不同的实现 Trait (特征)

  • 子类型多态 eg: Rust 没有传统 OOP 的“类继承”,因此没有“子类型”。 但是,Rust 通过 Trait 对象 (dyn Trait)  完美地实现了“子类型多态”的效果。

       “父类型”/“接口” -> **Trait** (例如 `trait Drive`)
       “子类型”/“实现类” -> **Struct** (例如 `struct TruckDriver` `impl Drive`)
       使用父类型的地方 ->  **`&dyn Drive`** 或 **`Box<dyn Drive>`**
    

在 Rust 中:

  • 当你想写一个对所有类型逻辑都一样的代码(比如 Vec<T>),请使用参数多态 (泛型)
  • 当你想让不同类型共享一个名字(如 + 或 speak),但实现各自不同时,请定义 Trait (特设多态)
  • 当你想把实现了同一 Trait 的不同类型DogCat)混在一起处理(比如放进一个 Vec)时,请使用子类型多态 (Trait 对象 dyn Trait)

理解

  • 参数多态感觉就是同样一套东西,都是死流程,只是类型不一样,执行了相同的操作,
  • 特设多态就好比预先有个模版,但具体实现要谁用谁根据模版自己去设置但必须和模版里约定的必须一样的相同,可选的不用管

1. 参数多态(Parametric Polymorphism)

你的理解:“死流程,只是类型不一样,操作完全相同。” 我的补充:这就像是  “快递盒子”

  • 场景:如果你写了一个“打包”的函数。

  • 逻辑:拿个箱子 -> 把东西放进去 -> 封胶带。

  • 特点:不管你放进去的是金条,还是石头,打包的动作(流程)是一模一样的。我根本不需要知道里面是什么,我只负责“打包”这个动作。

  • 代码体现:这就是泛型(Generics)。

    Rust

    // 这是一个“死流程”,T 是啥无所谓,逻辑永远是把 x 放到列表里
    fn push_to_list<T>(list: &mut Vec<T>, item: T) {
        list.push(item); 
    }
    

2. 特设多态(Ad-hoc Polymorphism)

你的理解:“预先有个模板,具体怎么做谁用谁自己定,必须遵守约定,可选的不管。” 我的补充:这就像是  “岗位说明书(Job Description)”

  • 场景:老板(Trait)发了一个模板,要求:“每个人都必须会 工作(work) ”。

  • 逻辑

    • 程序员看到模板:他的实现是“写代码”。
    • 厨师看到模板:他的实现是“炒菜”。
    • 司机看到模板:他的实现是“开车”。
  • 特点:虽然都叫“工作”,但每个人具体的干法千差万别,完全取决于你是谁。

  • 关于“可选” :Rust 的 Trait 确实允许定义“默认方法”(Default Methods)。如果你的类型不特殊写实现,就用模板里默认的(可选不用管);如果你觉得默认的不行,就自己覆盖重写。

// 1. 定义 Trait(定义行为标准)
// 这相当于规定:谁想被“播放”,谁就得实现 play 这个方法
trait Playable {
    fn play(&self);
}

// 2. 定义类型 A:CD 机
struct CDPlayer;

// 3. 为 CD 机实现 Trait(特设多态的体现:定制 CD 的行为)
impl Playable for CDPlayer {
    fn play(&self) {
        println!("🔊 CD 机正在旋转光盘... 滋滋滋...");
    }
}

// 4. 定义类型 B:MP3
struct MP3Player;

// 5. 为 MP3 实现 Trait(特设多态的体现:定制 MP3 的行为)
impl Playable for MP3Player {
    fn play(&self) {
        println!("🎵 MP3 正在解码数字信号... 动次打次...");
    }
}

// 6. 一个统一的函数,接受任何实现了 Playable 的东西
fn run_music<T: Playable>(device: T) {
    // 这里调用的是同一个 .play(),但实际执行的代码取决于 device 是什么
    device.play(); 
}

fn main() {
    let cd = CDPlayer;
    let mp3 = MP3Player;

    // 见证特设多态:
    run_music(cd);  // 输出:CD 机正在旋转...
    run_music(mp3); // 输出:MP3 正在解码...
}

总结对比(一句话定论)

特性你的话(通俗版)核心区别
参数多态一套死流程,换汤不换药一份代码,服务所有类型 (One logic for all)
特设多态大家拿模板,各干各的事多份代码,每个类型都有自己的逻辑 (Specific logic for each)

其中特设多态,子类型多态 都与 Trait有关

trait 作为对不同数据结构中相同行为的一种抽象,它可以让我们在开发时,通过用户需 求,先敲定系统的行为,把这些行为抽象成 trait,之后再慢慢确定要使用的数据结构,以 及如何为数据结构实现这些 trait。

理解

我理解如果特设多态是抽象的方法,那子类型多态就是抽象了实现特设多态的具体实例,只不过只有在运行时,才能看出来是什么实例

1. 你的理解复盘

  • 特设多态(Ad-hoc)

    • 你理解为“抽象了方法”。
    • 更准确地说:它是**“编译期的绑定”**。编译器在编译代码时,就已经把“哪个类型”和“哪个代码块”对应上了。
    • 虽然写起来是个模版,但编译出来的结果是实实在在的针对每个类型的代码(单态化)。
  • 子类型多态(Subtype / dyn

    • 你理解为“抽象了具体实例,只有运行时才知道是谁”。
    • 完全正确!这叫**“类型擦除”**。编译器在编译时两眼一抹黑,只知道手里拿的是个“指针”,至于指针指向的是猫还是狗,它不知道,只有程序跑起来(运行时)去查表才知道。

2. 深度解析:从“透明”到“黑盒”

为了把你的理解“视觉化”,我们可以把这两种多态看作是编译器对类型的认知程度不同。

场景一:特设多态(泛型 T: Trait)—— 透明盒子

  • 代码写的时候fn bark<T: Animal>(x: T)

  • 编译器眼中:编译器有“透视眼”。

    • 当你传入 Dog 时,编译器心里想:“哈,我知道这是 Dog,我直接把 Dog 的 bark 代码拷贝过来放在这。”
    • 当你传入 Cat 时,编译器:“哈,这是 Cat,我再拷贝一份 Cat 的代码。”
  • 结果没有秘密。所有东西在运行之前(编译时)就已经确定了。

  • 你的话:这里抽象的是“方法约束”,但实例是具体的、透明的。

场景二:子类型多态(特征对象 Box<dyn Animal>)—— 黑盒子

  • 代码写的时候fn bark(x: &dyn Animal)

  • 编译器眼中:编译器“瞎了”。

    • 它只看到一个黑盒子(指针),上面贴着个标签叫 Animal
    • 编译器想:“我不知道这里面装的是啥,但我相信标签。等程序跑起来,让 CPU 去把盒子打开看看里面是谁。”
  • 结果全是秘密。具体的实例被“抽象”成了一个通用的黑盒,只有运行时(Runtime)打开盒子(查虚表 vtable)才能找到对应的代码。

  • 你的话:这里确实抽象了“具体的实例”,实例变成了匿名的,直到运行时才“原形毕露”。


3. 终极比喻:点外卖

为了让你彻底记住这个**“运行时”**的区别:

  • 特设多态(Ad-hoc)是“预定专属套餐”

    • 你在订单备注写了:“如果是张三(类型A),就做不辣的;如果是李四(类型B),就做特辣的。”
    • 编译时:厨师(编译器)在做菜前看了一眼名字,直接炒了两份不一样的菜。张三拿到的就是不辣的,李四拿到的就是特辣的。
    • 特点:精准,效率高,不需要现场问。



  • 子类型多态(Subtype)是“发通用盒饭”

    • 你做了一堆一模一样的铝箔饭盒,里面装的可能是米饭,也可能是面条,但外面统一印着“午餐”。
    • 运行时:领饭的人(函数)拿到饭盒时,根本不知道里面是啥。只有当他打开盖子吃的那一刻(运行时) ,他才意识到:“哦!原来我是吃面条的实例。”
    • 特点:灵活(大家都能领),但吃之前多了一个“揭盖子(查表)”的动作,稍微慢一点点。

1. 制造处:编译器的“上帝视角”

在赋值的那一行(let a1 = &dog),编译器是全知全能的。

Rust

let dog = Dog::new(); 
// ▼ 就在这一行!
let a1: &dyn Animal = &dog; 

2. 使用处:编译器的“故意失忆”

那为什么说“编译时不知道”呢?这通常指的是把胖指针传给别的函数的时候。

看下面这个函数:

Rust

// 这是一个通用的函数
// 编译器在编译这段代码时,根本不知道将来谁会传进来
fn make_it_speak(animal: &dyn Animal) {
    // ▼ 在这里,编译器“瞎”了
    animal.speak(); 
}

当编译器编译 make_it_speak 这个函数时:

  • 它只看到参数类型是 &dyn Animal
  • 完全不知道将来传进来的是 a1 (狗) 还是 a2 (猫)。
  • 所以它只能生成通用的“查表指令” :它生成的机器码是“不管你是谁,去查你的 Vtable,然后跳过去”。

这就是“编译时不知道”的真正含义:指在使用该对象的通用代码块里,无法确定具体类型。

3. 运行时:真相大白

现在,程序跑起来了(运行时):

  1. 主函数(Main) :拿着早就做好的 a1(里面藏着 Dog 的 Vtable)。

  2. 调用:把 a1 扔给了 make_it_speak 函数。

  3. 函数内部

    • make_it_speak 拿到指针,虽然它不知道是狗,但它按照指令去查 Vtable。
    • Vtable 指向了“汪汪叫”的代码。
    • CPU 跳过去执行。
    • 此时(运行时),具体的“狗叫”行为发生了。

4. 还有一种情况:真正的“不可预测”

如果你的程序包含用户输入,那就在任何阶段(无论是编译时还是启动前)都不知道是谁,只有到了那一秒才知道。

Rust

fn main() {
    let input = read_user_input(); // 假设用户输入了 "1" 或 "2"
    
    let animal: Box<dyn Animal>;

    // 只有运行到这行,根据用户的心情,才决定造个胖指针给谁
    if input == "1" {
        animal = Box::new(Dog::new()); // 此时才把 Dog 的表塞进去
    } else {
        animal = Box::new(Cat::new()); // 此时才把 Cat 的表塞进去
    }

    // 后面调用时,依然是查表
    animal.speak();
}

在这个例子里:

  • 赋值时:代码逻辑是编译好的,但真正执行哪条赋值语句,是运行时决定的。
  • 调用时:依然是查表。

总结你的理解

你的理解可以修正为:

  1. 赋值/制造时编译器知道。它根据你的代码(= &dog),把具体的身份证明(Vtable 地址)封印在了胖指针里。
  2. 传递/使用时编译器不知道(或者说不关心)。通用函数只负责“运行那个指针指向的任何代码”。
  3. 运行时既定事实的执行。虽然代码写的是通用的,但因为手里拿的是具体的胖指针,所以跑出来的结果是具体的。