In Rust We Bust

Rust 语言的踩坑记录。

rustc 施用 Silencio 可不能解决问题

RefCell 和作用域

Rust 通过 std::cell 提供的“内部可变性”看起来很爽——可以把一个不可变的数据的一部分搞成可变的。其中的 RefCell 可以把美名远扬的编译期 borrowck 搞到运行期去,实现往不可变数据内部指一个可写的指针 RefMut 的功能🤗。

那么能不能把原数据用 RefCell 一包,然后直接把 &x, &mut x 替换成 x.borrow(), x.borrow_mut() ?编译器会答应,但作用域问题可能会导致运行时 panic🙃。

考虑下面一段代码:

#[allow(unused)]
fn main() {
    let mut x: Option<i32> = None;
    let y = &x;
    if y.is_none() {
        let z = &mut x;
    }
}

问题全然无,编译通过。y 是不可变引用,但是在 if 的表达式里只用过一次就没再用过,因此 z 可以高高兴兴地借出可变引用。看看 MIR:

$ rustc src/main.rs --emit mir
scope 1 {
    debug x => _1;                   // in scope 1
    let _2: &std::option::Option<i32>; // in scope 1
    scope 2 {
        debug y => _2;               // in scope 2
        let _5: &mut std::option::Option<i32>; // in scope 2
        scope 3 {
            debug z => _5;           // in scope 3
        }
    }
}

bb0: {
    discriminant(_1) = 0;            // scope 0
    _2 = &_1;                        // scope 1
    _4 = _2;                         // scope 2
    _3 = Option::<i32>::is_none(move _4) -> bb1; // scope 2
}

bb1: {
    switchInt(move _3) -> [false: bb3, otherwise: bb2]; // scope 2
}

bb2: {
    _5 = &mut _1;                    // scope 2
    goto -> bb3;                     // scope 2
}

bb3: {
    return;                          // scope 0
}

y(_2) 只在基本块 0 出现。跳转到基本块 2z(_5) 借可变引用的时候没有 x 的不可变引用了,borrowck 放行。

如果替换成 RefCell 呢?

#[allow(unused)]
fn main() {
    let x: RefCell<Option<i32>> = RefCell::new(None);
    let y = x.borrow();
    if y.is_none() {
        let z = x.borrow_mut(); // <- thread 'main' panicked at 'already borrowed: BorrowMutError'
    }
}

panic 了。为什么呢?

scope 1 {
    debug x => _1;                   // in scope 1
    let _3: std::cell::Ref<std::option::Option<i32>>; // in scope 1
    scope 2 {
        debug y => _3;               // in scope 2
        let _9: std::cell::RefMut<std::option::Option<i32>>; // in scope 2
        scope 3 {
            debug z => _9;           // in scope 3
        }
    }
}

bb0: {
    discriminant(_2) = 0;            // scope 0
    _1 = RefCell::<Option<i32>>::new(move _2) -> bb1; // scope 0
}

bb1: {
    _4 = &_1;                        // scope 1
    _3 = RefCell::<Option<i32>>::borrow(move _4) -> bb2; // scope 1
}

bb2: {
    _8 = &_3;                        // scope 2
    _7 = <Ref<Option<i32>> as Deref>::deref(move _8) -> [return: bb3, unwind: bb9]; // scope 2
}

bb3: {
    _6 = _7;                         // scope 2
    _5 = Option::<i32>::is_none(move _6) -> [return: bb4, unwind: bb9]; // scope 2
}

bb4: {
    switchInt(move _5) -> [false: bb7, otherwise: bb5]; // scope 2
}

bb5: {
    _10 = &_1;                       // scope 2
    _9 = RefCell::<Option<i32>>::borrow_mut(move _10) -> [return: bb6, unwind: bb9]; // scope 2
}

/* z 在这里析构 */
bb6: {
    drop(_9) -> [return: bb7, unwind: bb9]; // scope 2
}

/* y 在这里才析构 */
bb7: {
    drop(_3) -> bb8;                 // scope 1
}

bb8: {
    return;                          // scope 0
}

// ...

作用域是一样的啊?但是注意,y(_3)RefCell 借出的 Ref 是智能指针,需要析构这一步,它一直保持到 if 作用域的尾部,也就是基本块 7 才析构!在此之前基本块 5 要求借出 RefMut,于是当场 panic😱。

怎么解决呢?手动 drop()🤪!事实上,mem::drop 的文档已经给出了类似的例子

#[allow(unused)]
fn main() {
    let x: RefCell<Option<i32>> = RefCell::new(None);
    let y = x.borrow();
    if { let b = y.is_none(); drop(y); b } {
        let z = x.borrow_mut();
    }
}

更新:把引用保存在临时变量里其实不太 Rust 风味,特别是保存 RefCell 或者 Mutex 的守卫(guard)。所以,不提取临时中间变量反而是更符合 Rust 的:

let x: RefCell<Option<i32>> = RefCell::new(None);
if x.borrow().is_none() {
  let z = x.borrow_mut(); // Fine!
}

初始化器也是表达式

这不是显然的吗

元组(tuple)和数组(array)都是 Rust 的基本类型(primitive type)。元组的初始化器用 (),数组用 [],分隔符是 ,。虽然初始化器显然是表达式,但有时候逗号一加,就难免想这样写:

let cell = RefCell::new(vec![1, 2, 3]);
let values = (
  cell.borrow_mut().iter().max().cloned(),
  cell.borrow_mut().iter().min().cloned(), 
  /*   ^^^^^^^^^^ 
       thread 'main' panicked at 'already borrowed: BorrowMutError'
  */
);

于是就吃了一个 panic。尽管用逗号隔开了,但表达式里面的临时变量仍然会生存到表达式的结束,函数调用的实参同理。考虑下面一个简单的例子就知道为什么要这样了:

let mut i = 42;
let tuple = (&i, &mut i); // 显然不行吧

大家最喜欢的 C++ 也是一样的:

#include <iostream>
#include <tuple>

struct A {
    int v;
    A(int i): v(i) {
        std::cout << "A(" << i << ")" << std::endl;
    }
    ~A() {
        std::cout << "~A(" << v << ")" << std::endl;
    }
};

int main() {
    std::tuple<A, A> t = {A(1), A(2)};
  	/*
  	                      A(1)
  	                            A(2)
  	                           ~A(2)
  	                     ~A(1)
  	*/
    std::cout << "---" << std::endl;
}

所以只好乖乖地加一个中间变量:

let r = cell.borrow_mut();
let values = (r.iter().max().cloned(), r.iter().min().cloned());

或者,写一个宏rust-analyzer:礼貌你吗🤬?):

macro_rules! with {
    ($i:ident
      .$($h:ident ( $($e0:expr),*) ).* // 要丢弃的可变引用
      #
      .$($f:ident ( $($e1:expr),*) ).* // 调用链
      $(: $ty:ty)? // 可选的类型标注
    ) => {
        {
            let h = $i$(.$h($($e0),*))*;
            let v$(: $ty)? = h$(.$f($($e1),*))*;
            drop(h);
            v
        }
    };
}

let cell = RefCell::new(vec![1, 2, 3]);
let values = (
  with!(cell
    .borrow_mut()
    # // 以上是可变的引用,以下是引用上的调用链
    .iter()
    .map(|a| a + 1)
    .sum()
    :i32 // 可选的类型标注
  ),
  with!(cell.borrow_mut()#.iter().max().cloned()),
  with!(cell.borrow_mut()#.iter().min().cloned()),
);
println!("{:?}", values); // -> (9, Some(3), Some(1))

返回值位置的 impl Trait

来自 This Week in Rust 443 推荐文章 A new impl Trait 1/4

众所周知impl Trait 在形参位置和返回值位置是完全不同的语法糖🤯。在返回值位置,可以理解为它表示一个“静态的抽象类型🐘”,即:

  • 编译器在编译期为它确定了一个具体类型,所以仍然是静态派发
  • 但它是一个抽象类型abstract type)。在语义上,它实现且仅实现Trait 暴露出来的接口。

impl Trait 一般用于带有关联类型(associate type)的特征,比如 Iterator 或是 Future,它们的关联类型可能变得非常复杂,所以用 impl 抽象它省略掉这些类型会比较方便。隐式的推断固然方便,但最终推断出了什么类型?这里面就有可能……😈

比如博文中举出的这个例子:

trait Ore: Clone {
    fn print(&self);
}

impl<T: Ore> Ore for &T {
    fn print(&self) { println!("Ore"); } // <- print#1
}

#[derive(Clone, Copy)]
struct Bauxite; // 铝土矿(话说这个博主怎么这么喜欢挖矿啊)

impl Ore for Bauxite { // <- Bauxite 上实现了 Clone,OK
    fn print(&self) { println!("Bauxite"); } // <- print#2
}

struct Quarry<T> {
    ore: T,
}

impl<T: Ore> Quarry<T> {
    fn ore(&self) -> &T {
        &self.ore
    }

    fn mine(&self) -> impl Ore + '_ { // <- 这里有一个**很可疑**的隐式生命周期
        self.ore().clone()
    }
}

#[test]
fn test_impl() {
    let quarry = Quarry { ore: Bauxite };
    quarry.mine().print(); // >> Bauxite
}

输出了 Bauxite,说明 impl 推断出来的具体类型是 Bauxite,派发到了 print#2,一切正常🤔。

但,当你删除了第一行 Clone 的约束后,打印出来的就变成了 Ore,派发到了 print#1😲。为什么?注意 impl 后面那个可疑的隐式生命周期……

mine() 中,self.ore() 的类型是 &T,而 T 满足 Ore……

  • 如果 Ore 同时满足 Clone,那么根据 Deref 语法糖,&T.clone() 调用的就是 <T as Clone>::clone,得到一个 T
  • 如果 Ore 不满足 Clone,那么调用的就是 <&T as Clone>::clone,得到一个 &T

注意,在这个实现块里,并没有在 T 上约束 Clone,即使 Bauxite 实现了 Clone 也会被忽略。mine() 的返回值是一个 impl Ore,所以……

  • 如果返回值是 T,而在实现块里约束了 T 实现 OreT 可以抽象为 impl Ore
  • 如果返回值是 &T,实现块里约束了 T 实现 Ore,而诡计多端的 Ore 声明:对所有实现了 OreT&T 也实现 Ore!所以 &T 也可以抽象为 impl Ore

如果返回值不用 impl,那么返回 &T 其实符合生命周期擦除条件,不用标注生命周期。但用了 impl,所以必须在 impl 后面加一个 '_。而拥有所有权的 T 当然也符合 '_,所以……

两种情况都编译通过,但派发到了完全不同的实际类型😵。事实上,如果 Ore 同时为 () 自动实现,而你手抖多加了一个分号把返回值变成了 (),但签名里又用了 impl Ore,编译完美通过,运行时发生什么,都不敢想了🙃!

try, ?yeet

Rust 的错误处理机制依赖两个特征 Try, FromResidual 和一个枚举 ControlFlow

首先是 Try

pub trait Try: FromResidual<Self::Residual> {
    type Output;
    type Residual;

    fn from_output(output: Self::Output) -> Self;
    fn branch(self) -> ControlFlow<Self::Residual, Self::Output>;
}

它有两个关联类型 Try::OutputTry::Residual,分别代表正常和异常的返回值类型。因此,Try 用于表征该 Try-able 的类型可以容纳正常(输出)或异常(残差),且可以在三种类型间两两相互转换。它的两个关联方法里,Try::from_output 用于将 Output 类型转换回 Try-able 类型,而 Try::branch 则用于将 Try-able 类型容纳的值取出,或者转换为 Output 或为 Residual

Try::branch 用到了 ControlFlow,它有两个泛型参数 BC

pub enum ControlFlow<B, C = ()> {
    Continue(C),
    Break(B),
}

它用于表示一个可能有两种状态的控制流,ControlFlow::Break 分量表示跳出控制流,而 ControlFlow::Continue 代表控制流继续进行(因此 ControlFlow::Continue 默认为 ())。其实 ControlFlow<B, C>Result<C, B> 几乎完全一样,只是泛型参数 C 在语义(或者说语用)上并不一定是一个“错误”。Residual 刚好是一种“提前跳出”,而 Output 则是继续进行控制流得到的结果。

FromResidual 则是 Try 依赖的特征,它有一个泛型参数 R

pub trait FromResidual<R = <Self as Try>::Residual> {
    fn from_residual(residual: R) -> Self;
}

一个 Try-able 的类型必须实现 FromResidual<Try::Residual>,如我们所见,Try 需要通过 FromResidual 来实现从 ResidualTry-able 类型的转换。因此,泛型参数 R 可以有一个默认值:一个 FromResidual-able 的类型如果同时也实现了 Try,那么它关联的 Residual 就可以是这个 R

那么为什么 FromResidual 要单独设立一个特征?这是为了**允许一个 Try-able 类型接受其他类型的 Residual **(所以这个 R 是泛型参数,而不是关联类型),也就是在收集残差的时候进行类型转换。

? 就可以理解这一点。

match input.branch() { // <- 一个 `Try`-able 类型
  ControlFlow::Break(residual) => return {ReturnType}::from_residual(residual), 
  // 这个分支类型是 !
  ControlFlow::Continue(output) => output, 
  // 这个分支类型是 Try::Output
} // 最后返回类型是 Try::Output

这里的 {ReturnType} 是跳出的块(函数体或 try 块)的返回类型,它一定是 Try-able 的,但不一定和 input 类型相同。因此,FromResidual 的泛型在这里起到了作用。

try 块则负责收集作用域里丢出的 Residual 或者最终得到的 Output。如果是 Residual,就调用 FromResidual::from_residual 把各种类型的 Residual 收入自己(感觉有点像葫芦娃里面的七娃),或者调用 Try::from_output 收集自己预期的输出。? 的作用是把一个 Try-able 类型裂解掉,而 try 块则用于收集出一个 Try-able。

还有一个家伙,yeet。它关联一个结构体 Yeet,这个结构就代表这个 yeet 表达式(截至 2023/3 写作 do yeet,也就是 return {ReturnType}::from_residual(Yeet(...)))。正如它的名字暗示的那样,它类似于其他语言中的 raise 或是 throw,用于直接抛出一个可以转换为 {ReturnType} 的残差,而不像 ? 需要从 Try-able 类型抛出。

抛出错误可以使用语法糖(?yeet)的处理机制被称作 Err-wrapping(不用手写 return Err),而 try 块则代表着相反的方向,Ok-wrapping(不用手写 return Ok)。可见,Try 特征对 Err-wrapping 提供了第一等支持(函数内可以直接使用 ? 进行 Err-wrapping),而要想实现 Ok-wrapping 只能多套一层 try

fn err_wrapping() -> Result<T, E> {
  let result = can_fail()?;
  Ok(result)
}

fn ok_wrapping() -> Result<T, E> {
  try {
    let result = can_fail()?;
    result
  } // <- 不必 Ok!
  
  // 但提前返回还得写:
  return Ok(result);
  // 当然也可以……
  return try { result };
  // 但还多一个字母!
}

下划线表达式和 RAII

TL;DR | Rust 用下划线表达式 _ 绑定变量会造成值在原地析构!

调试程序的时候写了这样一段代码。

let (_, rx) = channel::<usize>();
  // ^ 注意这里

spawn(move || {
    loop {
        rx.recv() {
            Ok(i) => println!("received: {i}"),
            Err(err) => {
                eprintln!("error: {err}");
                break
            }
        }
    }
}).join().unwrap();

由于是调试输出端,发送端还没实现,预期代码会死循环。但结果是直接 Err

error: receiving on a closed channel

发送端被提前析构了?原来是 _ 的锅。下划线 _ 本身自成一种表达式类型,表示在解构过程中忽略这个值。那么在绑定语句的 _ 是什么意思呢?The Book 提了一嘴

However, using the underscore by itself doesn’t ever bind to the value.

既然绑定未发生,根据 The Reference 的规定,绑定语句中的临时变量的作用域到语句末尾结束,没有绑定意味着没有移动,因此相当于立即析构;对于依赖 RAII 逻辑的值,这个析构可能会带来意想不到的结果。

检查上一个程序的 MIR:

bb1: {
    _7 = const true;              
    _1 = move (_2.1: std::sync::mpsc::Receiver<usize>);
    drop((_2.0: std::sync::mpsc::Sender<usize>)) -> [return: bb6, unwind: bb8];
}

确实析构了。解决方案也有一些迷惑性:

let (_tx, rx) = channel::<usize>();

没错,_tx 是合法的标识符名,且被格式检查器视作“被忽略的变量”,而 _ 不是标识符!所以 _tx 会一直保留到上层作用域的末尾才被析构。

隔壁的叔叔 Python 和 Go 写多了就会是这样子