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 出现。跳转到基本块 2,z(_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实现Ore,T可以抽象为impl Ore; - 如果返回值是
&T,实现块里约束了T实现Ore,而诡计多端的Ore声明:对所有实现了Ore的T,&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::Output 和 Try::Residual,分别代表正常和异常的返回值类型。因此,Try 用于表征该 Try-able 的类型可以容纳正常(输出)或异常(残差),且可以在三种类型间两两相互转换。它的两个关联方法里,Try::from_output 用于将 Output 类型转换回 Try-able 类型,而 Try::branch 则用于将 Try-able 类型容纳的值取出,或者转换为 Output 或为 Residual 。
Try::branch 用到了 ControlFlow,它有两个泛型参数 B 和 C:
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 来实现从 Residual 回 Try-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 写多了就会是这样子。