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 的帮助。