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 写多了就会是这样子。