Skip to content

Latest commit

 

History

History
147 lines (111 loc) · 9.69 KB

File metadata and controls

147 lines (111 loc) · 9.69 KB

阅读英文原版

展开操作(二):匹配和借用

展开操作涉及借用时,会出现一些意外情况。如果你对借用引用的理解透彻了,应该就不会觉得意外了,不过还是值得探讨下(我是真的花了不少时间厘清这段的内容。由于文章的第一版没写好,因此实际花的时间比我想的还多。)

假设有 &Enumx,其中 Enum 是枚举类型。有两种选择:可以匹配 *x 并列举所有的变体(Variant1 => ... 等),也可以匹配 x 并列出变体模式的引用(&Variant1 => ... 等)(就代码风格而言,尽可能优选第一种,因为语法噪音(syntactic noise)更小)。x 是借用引用,而且对借用引用进行解引用的操作有着严格的规定,这会以令人惊讶的方式(至少我是惊了)与匹配表达式交互,尤其是以看似无伤大雅的方式修改已有枚举时,编译器会在某个匹配处报错。

深入匹配表达式的细节前,先复习一下 Rust 传值的规定。C++ 中,有两种方式将值赋给变量或传给函数:按值传递,以及按引用传递。前者是默认选项,代表值会通过复制构造函数或逐位(bitwise)复制的方式复制。如果在赋值目标和形参处加 &,则值按引用传递:只复制了指向值得指针,操纵新变量也会操纵旧变量。

Rust 可以按引用传递,不过源和目标都需要标 &。至于 Rust 的按值传递,还有两种选择:复制和移动。复制和 C++ 的语义一样(不同在于,Rust 中没有复制构造函数)。移动会复制值,还会销毁原值:Rust 的类型系统保证无法再访问旧值。(译者注:这和 C++ 的移动构造不太一样。C++ 中,移动后也可以使用旧值。)例如,i32 带有复制语义(copy semantics),Box<i32> 带有移动语义:

fn foo() {
    let x = 7i32;
    let y = x;                 // x 被复制
    println!("x is {}", x);    // 正确

    let x = Box::new(7i32);
    let y = x;                 // x 被移动
    // println!("x is {}", x); // 错误:使用了被移走的值 `x`
}

也可以通过实现 Copy 特征为用户定义的类型添加复制语义。其中一种直接的方式是,在结构体定义前加 #[derive(Copy)]。不是所有用户定义类型都可以实现 Copy 特征。类型中的每个字段都必须实现 Copy,且类型不得带有析构函数。或许需要单独介绍下析构函数,不过先简单解释下,Rust 中的对象实现了 Drop 特征,就会有析构函数。和 C++ 一样,析构函数在对象被销毁前执行。

重点在于,借出的对象并未被移走,否则只会有个不再有效的,对旧对象的引用。这与拥有离开作用域后被销毁的对象的引用一样,算是种悬垂指针。如果有指针指向某个对象,就不能再有对象的引用了。因此,如果对象带有移动语义,而你有个指向此对象的指针,和解引用指针不安全。(如果对象带有复制语义,则解引用会创建副本,旧对象仍存在,因此其他引用仍可正常使用。)

好,回到匹配表达式。之前提到,要匹配 &T 类型的 x,可以在匹配从句处解引用一次,或者在匹配表达式的每个分支处匹配引用。例如:

enum Enum1 {
    Var1,
    Var2,
    Var3
}

fn foo(x: &Enum1) {
    match *x { // 写法 1:此处解引用
        Enum1::Var1 => {}
        Enum1::Var2 => {}
        Enum1::Var3 => {}
    }

    match x {
        // 写法 2:每个分支都解引用一次
        &Enum1::Var1 => {}
        &Enum1::Var2 => {}
        &Enum1::Var3 => {}
    }
}

上例中,两种写法都行,因为 Enum1 带有复制语义。仔细观察这两种写法:第一种写法将 x 解引用为类型为 Enum1 的新值(复制了 x 中的值),然后与 Enum1 的三种变体匹配。第二种写法没有解引用操作,我们将 &Enum1 类型的值与每种变体的引用匹配。要匹配指涉的类型(即 Enum1),这种匹配会深入两层操作,既要匹配类型(总是引用),又要观察类型内部的内容。

无论哪种方式,必须确保我们(指编译器)尊重 Rust 移动和引用的不变因素:如果对象被引用,则不能移走对象的一部分。如果要匹配的对象带有复制语义,则操作是平凡(trivial)的。如果对象带有移动语义,则须确保每个分支都不会出现移动操作。这一点要么通过忽略会移动的对象实现,要么通过引用对象实现(因此按引用传递,而非按值传递)。

enum Enum2 {
    // Box 类型带有析构函数,因此 Enum2 带有移动语义。
    Var1(Box<i32>),
    Var2,
    Var3
}

fn foo(x: &Enum2) {
    match *x {
        // 我们忽略嵌套的值,因此以下操作正确
        Enum2::Var1(..) => {}
        // 其他分支不作更改
        Enum2::Var2 => {}
        Enum2::Var3 => {}
    }

    match x {
        // 我们忽略嵌套的值,因此以下操作正确
        &Enum2::Var1(..) => {}
        // 其他分支不作更改
        &Enum2::Var2 => {}
        &Enum2::Var3 => {}
    }
}

两种方式都不会指涉嵌套数据,因此没有出现移动操作。第一种写法中,尽管引用了 x,我们在解引用的作用域内(即匹配表达式)没有动内部数据,因此没有能逃逸(escape,译者注:Golang 和 Java 中常见概念,可以理解为对象从创建对象的作用域传到不相干的作用域)的内容。我们也没有绑定整个值(将 *x 绑定到某个值),因此也不能修改整个对象。

可以在第二种写法的匹配中取任意变体的引用,但第一次写法的匹配不行。因此,将第二种写法的第二个分支改为 a @ &Var2 => {} 没问题(a 为引用),但是第一种写法不能写出 a @ &Var2 => {},因为这意味着将 *x 移动到 a。可以写 ref a @ Var2 {}a 仍为引用),不过这种写法不太常见。

如果想使用 Var1 中嵌套的数据呢?不能写成:

match *x {
    Enum2::Var1(y) => {}
    _ => {}
}

也不能写成:

match x {
    &Enum2::Var1(y) => {}
    _ => {}
}

因为两种写法都意味着将 x 的部分内容移至 y。可以使用关键字 ref 获取 Var1 中数据的引用:&Var1(ref y) => {}。这么写没问题,因为这样不会发生解引用操作,因此不会将 x 的部分内容移走,而是创建了指向 x 内部的指针。

或者,可以将 Box 展开(这就要匹配三层深了):&Var1(box y) => {}(注意, box 模式语法在 rustc 1.58 中为实验性功能,仅在 rustc 的 Nightly 版中可用)。这么写没问题,因为 i32 带有复制语义,且 y 是(借用引用中的) Var1Boxi32 的副本。由于 i32 带有复制语义,因此不用移动 x 中的内容。也可以不复制值,创建 i32 的引用:&Var1(box ref y) => {}。还是没问题,因为不想解引用,所以无需移动 x 中的内容。如果 Box 中的内容带有移动语义,则不能写 &Var1(box y) => {},只能使用引用版(译者注:即 &Var1(box ref y))。也可以写得和第一种匹配方式大体上一致,只是不用写第一个 &。例如,Var1(box ref y) => {}

接下来上点难度。假设要匹配两个枚举的引用,第一种方式完全不能用:

fn bar(x: &Enum2, y: &Enum2) {
    // 错误:x 和 y 会被移走
    // match(*x, *y) {
    //     (Enum2::Var2, _) => {}
    //     _ => {}
    // }

    // 正确
    match(x, y) {
        (&Enum2::Var2, _) => {}
        _ => {}
    }
}

第一种写法非法,因为要匹配的值通过解引用 xy 并将二者移至新的元组对象创建。因此这种情况下,只有第二种写法能用。当然了,避免移走 xy 中内容的规则还是要遵守的。

如果你只能获得数据的引用,且需要其自身的值,则只能复制数据。通常这意味着使用 clone()。如果数据没有实现 clone(),则需要进一步展开数据,以进行手动复制,或者自己实现一个 clone()

如果我们持有的不是带移动语义的值的引用,而是值本身,那么可以移动,因为我们知道,其他代码没有此值的引用(编译器会确保的是,如果其他代码有引用,我们是不能使用值的)。例如:

fn baz(x: Enum2) {
    match x {
        Enum2::Var1(y) => {}
        _ => {}
    }
}

还有一些需要注意的事项。首先,只能将对象移到一处。上例中我们将 x 中的部分内容移动到了 y,忽略了其他内容。如果写成 a @ Var1(y) => {},我们会尝试将 x 的所有内容移至 a,并将 x 的部分内容移至 y。不允许进行这种操作,形如此类的分支是非法的。(利用 ref a 之类的代码)将 ay 中的某一个改为引用也是不行的,这样会出现上文提到的持有引用时移动的问题。将 ay 都改为引用就可以了,因为什么都没移动,x 保持原样,我们也获得了指向整个对象以及对象一部分内容的指针。

类似的是,如果我们有个内含多个嵌套数据的变体,不能在持有一个数据的引用时移动另一个数据。例如,有个 Var4,其声明为 Var4(Box<int>, Box<int>),可以写个匹配分支,同时包含所有内嵌数据的引用(Var4(ref y, ref z) => {},或者同时移动所有内嵌数据(Var4(y, z) => {}),但不能移动一个并引用另一个(Var4(ref y, z) => {}。因为部分移动会销毁整个对象,因此引用行为无效。