Rust 错误分析:多次借用问题
问题介绍
作为一个 C++ 选手,今天在写一个 AST Visitor 的时候发现了一个难办的事情。
在 C++ 中,我们常常会写这样的代码:
1for (auto& child : this->children) {
2 this->visit(child);
3}
这样的代码在 Rust 中是不允许的,因为 this
被多次借用了。
最小复现
下面的例子可以复现这个问题:
1#[cfg(test)]
2mod tests {
3 #[test]
4 fn it_works() {
5 struct Visitor {
6 items: Vec<i32>,
7 }
8 impl Visitor {
9 fn visit_items(&mut self) {
10 for item in &mut self.items {
11 self.visit_item(item);
12 }
13 }
14 fn visit_item(&mut self, item: &mut i32) {
15 *item += 1;
16 }
17 }
18
19 let mut visitor = Visitor {
20 items: vec![1, 2, 3],
21 };
22 visitor.visit_items();
23 assert_eq!(visitor.items, vec![2, 3, 4]);
24 }
25}
报错分析
报错如下:
1error[E0499]: cannot borrow `*self` as mutable more than once at a time
2 --> src/lib.rs:11:21
3 |
410 | for item in &mut self.items {
5 | ---------------
6 | |
7 | first mutable borrow occurs here
8 | first borrow later used here
911 | self.visit_item(item);
10 | ^^^^^^^^^^^^^^^^^^^^^ second mutable borrow occurs here
简而言之,我们在 in
后面使用了 self.items
,这是一次借用。而在 visit_item
这个函数的寻址时,我们又使用了 self
,这是第二次借用。
解决方案
在自己尝试以及咨询一些专家之后,找到一些方案。
方法 1:利用 clone()
在副本上操作
这种方法通过将对象一分为二,各持有一份借用,来解决问题。不过也有局限性,仅适用于不需要副作用的情况。如果我们需要修改值,那么这种方法就不适用了。
1fn visit_items(&mut self) {
2 for item in &mut self.items.clone() {
3 self.visit_item(item);
4 }
5}
上面的代码可以通过编译。不过由于我们需要副作用(修改 items 的值),所以这种方法无法通过单元测试。
方法 2:减少不必要的传参
对于下面的函数,实际上 self 是不必要的。
1fn visit_item(&mut self, item: &mut i32) {
2 *item += 1;
3}
所以完全可以改成:
1fn visit_items(&mut self) {
2 for item in &mut self.items {
3 Self::visit_item(item);
4 }
5}
6fn visit_item(item: &mut i32) {
7 *item += 1;
8}
不过,实际场景中,会更加复杂。比如我们需要依赖于 self 的某个字段的实例完成一个 visit 函数。
这种情况下,上面的方法无疑无法使用。有两种解决措施:
方法 3:利用调用栈传参
同样是沿用了上面避免将 self
作为参数的思路。
1#[cfg(test)]
2mod tests {
3
4 #[test]
5 fn it_works() {
6 struct Visitor {
7 items: Vec<i32>,
8 last_at: i32,
9 counter: i32,
10 }
11 impl Visitor {
12 fn visit_items(&mut self) {
13 for item in &mut self.items {
14 Self::visit_item(item, &mut self.last_at, &mut self.counter);
15 }
16 }
17 fn visit_item(item: &mut i32, last_at: &mut i32, counter: &mut i32) {
18 *item += 2;
19 *last_at = *item;
20 *counter += 1;
21 }
22 }
23
24 let mut visitor = Visitor {
25 items: vec![1, 2, 3],
26 last_at: 0,
27 counter: 0,
28 };
29 visitor.visit_items();
30 assert_eq!(visitor.items, vec![3, 4, 5]);
31 assert_eq!(visitor.last_at, 5);
32 assert_eq!(visitor.counter, 3);
33 }
34}
不过这种方法的缺点也很明显。不但代码的可读性变差了,而且,如果我们需要传递的参数很多,那么这种方法就会变得非常繁琐。
方法 4:将数据拆分到更小的结构中
这种方法就优雅多了。我们为什么会持有两个 self
的引用?这是因为我们要修改的其中一个字段属于 self
。那么如果我们将这个字段拆分到另一个结构中,那么就不会有这个问题了。
不过,这种方法本质上和上一种没什么不同,相当于把要传的参数批量打包为一个上下文对象。
1#[cfg(test)]
2mod tests {
3
4 #[test]
5 fn it_works() {
6 struct Visitor {}
7
8 struct Context {
9 items: Vec<i32>,
10 last_at: i32,
11 counter: i32,
12 }
13 impl Visitor {
14 fn visit_items(&mut self, ctx: &mut Context) {
15 for i in 0..ctx.items.len() {
16 self.visit_item(ctx, i);
17 }
18 }
19 fn visit_item(&mut self, ctx: &mut Context, index: usize) {
20 ctx.items[index] += 2;
21 ctx.last_at = ctx.items[index];
22 ctx.counter += 1;
23 }
24 }
25 let mut ctx = Context {
26 items: vec![1, 2, 3],
27 last_at: 0,
28 counter: 0,
29 };
30 let mut visitor = Visitor {};
31 visitor.visit_items(&mut ctx);
32 assert_eq!(ctx.items, vec![3, 4, 5]);
33 assert_eq!(ctx.last_at, 5);
34 assert_eq!(ctx.counter, 3);
35 }
36}
方法 5:不对数据分片,而是对行为分片
这个方法由 T-Dark 提出。我们可以把需要对 self 的部分结构进行访问的函数分隔出来,从而在调用时避免了对 self 的持有。
1#[cfg(test)]
2mod tests {
3
4 #[test]
5 fn it_works() {
6 struct Visitor {
7 item_visitor: ItemVisitor,
8 items: Vec<i32>,
9 last_at: i32,
10 counter: i32,
11 }
12
13 struct ItemVisitor {}
14 impl Visitor {
15 fn visit_items(&mut self) {
16 for item in &mut self.items {
17 let (last_at, counter_incr) = self.item_visitor.visit_item(item);
18 self.last_at = last_at;
19 self.counter += counter_incr;
20 }
21 }
22 }
23 impl ItemVisitor {
24 fn visit_item(&mut self, item: &mut i32) -> (i32, i32) {
25 *item += 2;
26 return (*item, 1);
27 }
28 }
29
30 let mut visitor = Visitor {
31 item_visitor: ItemVisitor {},
32 items: vec![1, 2, 3],
33 last_at: 0,
34 counter: 0,
35 };
36 visitor.visit_items();
37 assert_eq!(visitor.items, vec![3, 4, 5]);
38 assert_eq!(visitor.last_at, 5);
39 assert_eq!(visitor.counter, 3);
40 }
41}
方法 6:主客易位,使用 trait 实现 visitor 模式
我们上面的所有方法,都是围绕如何对一个漂浮在空中的 AST 进行遍历。但是,换种角度,如果我们为每个 AST 的结构实现一个 Visit 的 trait,那么我们就可以将遍历的逻辑绑定到 AST 结构中,而不是放到 visitor 中。这个思路是 maxxsoft 提供的,非常 Inspiring 且优雅。
1#[cfg(test)]
2mod tests {
3
4 #[test]
5 fn it_works() {
6 enum Expr {
7 Number(f64),
8 InfixOp(InfixOp),
9 }
10 struct InfixOp {
11 op: char,
12 lhs: Box<Expr>,
13 rhs: Box<Expr>,
14 }
15 trait Visit {
16 fn visit(&self) -> f64;
17 }
18
19 impl Visit for Expr {
20 fn visit(&self) -> f64 {
21 match self {
22 Expr::Number(n) => *n,
23 Expr::InfixOp(op) => op.visit(),
24 }
25 }
26 }
27
28 impl Visit for InfixOp {
29 fn visit(&self) -> f64 {
30 match self.op {
31 '+' => self.lhs.visit() + self.rhs.visit(),
32 '-' => self.lhs.visit() - self.rhs.visit(),
33 '*' => self.lhs.visit() * self.rhs.visit(),
34 '/' => self.lhs.visit() / self.rhs.visit(),
35 _ => panic!("unknown operator"),
36 }
37 }
38 }
39
40 let expr = Expr::InfixOp(InfixOp {
41 op: '+',
42 lhs: Box::new(Expr::Number(1.0)),
43 rhs: Box::new(Expr::Number(2.0)),
44 });
45
46 let parent_expr = Expr::InfixOp(InfixOp {
47 op: '*',
48 lhs: Box::new(Expr::Number(3.0)),
49 rhs: Box::new(expr),
50 });
51
52 assert_eq!(parent_expr.visit(), 9.0);
53 }
54}
总结
经过以上探究,我们发现,对于 visitor 模式,其实在 Rust 有很多种实现方式,完全没必要慌。至于多次借用问题,本质上是一种类似“自指”的逻辑 Bug,可以通过细分来解决。而无论是细分到 AST 各结构,或者对上下文状态进行分片,还是对函数行为进行分片,都是可以的。
最后,衷心感谢 T-Dark 和 maxxsoft 的帮助。