匹配表达式(模式匹配)如何在不需要运行时类型信息的情况下工作?

How does the match expression (pattern matching) not require runtime type information to work?

match expression 是如何在高层实施的?编译器知道如何将某些代码串引导到一个分支而不是另一个分支,并在编译时 搞清楚到底发生了什么?如果不存储在运行时使用的类型信息,我看不出这是怎么可能的。

类似于这个例子:

fn tree_weight_v1(t: BinaryTree) -> i32 {
    match t {
        BinaryTree::Leaf(payload) => payload,
        BinaryTree::Node(left, payload, right) => {
            tree_weight_v1(*left) + payload + tree_weight_v1(*right)
        }
    }
}

/// Returns tree that Looks like:
///
///      +----(4)---+
///      |          |
///   +-(2)-+      [5]
///   |     |   
///  [1]   [3]
///
fn sample_tree() -> BinaryTree {
    let l1 = Box::new(BinaryTree::Leaf(1));
    let l3 = Box::new(BinaryTree::Leaf(3));
    let n2 = Box::new(BinaryTree::Node(l1, 2, l3));
    let l5 = Box::new(BinaryTree::Leaf(5));

    BinaryTree::Node(n2, 4, l5)
}

#[test]
fn tree_demo_1() {
    let tree = sample_tree();
    assert_eq!(tree_weight_v1(tree), (1 + 2 + 3) + 4 + 5);
}

通过运行时类型信息,我的意思是您实际上存储了指向类型定义或类似内容的指针,因此在运行时执行到达 match 时,它只是检查 value.type == given_pattern_expression或者它的一些变体。据我了解,并非如此。也就是说,Rust 不会在运行时存储带有 structs/records 的类型。据我了解,它是在编译时计算出来的。

如果我将 .type 存储在每个 struct/record 上,我可能会实现这种功能。然后你只需查找类型并查看它是否匹配。但是,我看不到在编译器级别实现它的方法,因此它计算出匹配提前运行时。

我认为它发生在编译时,因为 post 就像 Is it possible to do a compile time type check on a generic in Rust?, or Runtime trait implementation checking?, amongst many other posts which seems to suggest everything happens at compile time, except for a tiny few cases where you can opt-into runtime checking. (this is my understanding from summarizing dozens of articles the past few days). Or another quote 是:

One does need to check whether a given BinaryTree is a Leaf or is a Node, but the compiler statically ensures such checks are done: you cannot accidentally interpret the data of a Leaf as if it were a Node, nor vice versa.

对我来说,编译器静态地计算出我在上面 post 编辑的 BinaryTree 案例中的模式匹配。不知何故。

Integrating Pattern Matching with Type Checking 是关于编程语言的随机 post 符合我的理解,您需要运行时类型信息才能完成此操作。所以我很困惑。

If types are patterns, you need to be able to determine the type of a value at runtime. In general, this requires runtime type-information (which many languages intentionally erase); but it also requires some supertype that contains the two cases (which many languages intentionally do not have).

Building a runtime reflection system for Rust ️ (Part 1) 还解释了 Rust 为什么没有运行时反射,这有助于我认为一切都发生在编译时。

https://oswalt.dev/2021/06/polymorphism-in-rust/

一个match表达式不需要需要运行时间类型信息;由于 match 仅接受单个已知类型的单个表达式,根据 定义 它可以利用编译时信息。

另请参阅:

match 在编译时 vs 运行time

编译时,每个match表达式将被验证为详尽:值的所有可能形状都是已处理。

运行时,代码将决定执行哪个具体的match arm。您可以将 match 想象成通过奇特的 if-else 链实现的。

由于我们人类在交流时往往 not-extremely-precise,因此某些资源很可能模糊了这两个方面之间的界限。

具体关注枚举

枚举变体不是独立类型。也就是说,给定枚举 FooFoo::Bar 不是类型 — 它是类型 Foo 的值。这与 false 不是类型的方式相同——它是类型 bool 的值。同样的逻辑适用于 i32(类型)和 42(值)。

在大多数情况下,枚举被实现为字节海,对应于每个枚举变体可能的值,每个变体的数据相互叠加。这被称为联合

然后在这个字节汤旁边添加一个 判别式。这是一个整数值,指定该值是哪个变体。添加判别式使其成为 tagged union.

对枚举的匹配在概念上类似于此 pseudo-Rust:

if discriminant(&enum_value) == VARIANT_1_DISCR {
    let data = mem::transmute(data(&enum_value));
} else if discriminant(&enum_value) == VARIANT_2_DISCR {
    let data = mem::transmute(data(&enum_value));
}

另请参阅:

  • Why does an enum require extra memory size?

反思

how Rust doesn't have runtime reflection

我不同意没有,但默认肯定没有。 Rust 中的运行时类型信息通常会利用 Any 特征:

fn thing(value: &dyn std::any::Any) {
    if let Some(s) = value.downcast_ref::<String>() {
        dbg!(s.len());
    } else if let Some(i) = value.downcast_ref::<i32>() {
        dbg!(i + 1);
    }
}

匹配时缺少 Any 特征是一个额外的提示,表明不存在 运行时间类型信息。

其他语言

you need to be able to determine the type of a value at runtime

只有当您允许一个值是多种类型时,这才是正确的——Rust 不允许,因为它是一种 statically-typed 语言。在要使用类型匹配值的 dynamically-typed 语言中,您确实需要一些 运行 时间类型信息。

Rust 使用的可以保存数据的枚举样式通常称为“标记联合”。一般概念是它们或多或少地通过应用区分标签以及可能值的联合来工作。

Rust 中的标记联合

例如,这里我使用匹配在 Rust 中打印一个枚举值:

pub enum Foo {
    Bar(i32),
    Baz {
        a: i32,
        b: i8,
    }
}

match foo {
    Foo::Bar(x) => println!("Bar({})", x),
    Foo::Baz { a, b } => println!("Baz {{ a: {}, b: {} }}", a, b),
}

内存中的标记联合

这里是你如何使用没有标记联合的语言来做同样的事情,比如 C:

struct Foo {
    enum {
        Bar, Baz
    } tag;

    union {
        // Data for Bar
        int32_t Bar;
        
        // Data for Baz
        struct {
            int32_t a;
            int8_t b;
        } Baz;
    } data;
}

// Check the tag to distinguish variants before accessing data
switch foo.tag {
    case Bar:
        printf("Bar(%d)", foo.data.Bar);
        break;
    case Baz:
        printf("Baz { a: %d, b: %d }", foo.data.Baz.a, foo.data.Baz.b);
}

现在,我可能在我的 C 版本中犯了一两个错误,有人可能会指出一些改进,但我希望它大体上展示了标记联合如何在获取数据时区分变体。

Rust 中的标记联合

我不太了解 tagged union 在 rust 中是如何实现的,但很容易想象 rust 可能会在这些系统上扩展以进一步提高匹配效率。例如,如果您有一个枚举类型嵌套在另一个枚举类型中,您可以将它们的标签合并在一起,这样您就不需要进行多次比较(例如:编译器可能会将 Option<Result<A, B>> 压缩为 OptionResultAB只需要一个标签)。

另一个众所周知的例子是 Option<NonNull<T>> 如何与 *mut T 具有相同的大小,因为 null 可以用作 None 值。还有一些其他类似的情况,如函数指针。

参考资料

虽然我只是在猜测 Rust 中标记联合的形式,但我确实找到了一个经过更好研究的解释,它很好地解释了这个概念: