Appearance
19.4 分割借用
“alias+mutation”规则非常有用。然而,alias 分析在碰到复合数据类型的时候也会非常无奈。我们看看下面的示例:
rust
struct Foo {
a: i32,
b: i32,
c: i32,
}
fn main() {
let mut x = Foo {a: 0, b: 0, c: 0};
let pa = &mut x.a;
let pb = &mut x.b;
let pc = &x.c;
*pb += 1;
let pc2 = &x.c;
*pa += 10;
println!("{} {} {} {}", pa, pb, pc, pc2);
}
这种情况,Rust 编译器可以愉快地通过编译,因为它知道,指针 pa、pb、pc 分别指向的是不同的内存区域,它们之间不是 alias 关系,所以这些指针完全可以共存。但是,我们把数据类型从结构体转为数组之后,情况就变了:
rust
fn main() {
let mut x = [1_i32, 2, 3];
let pa = &mut x[0];
let pb = &mut x[1];
let pc = &x[2];
*pb += 1;
let pc2 = &x[2];
*pa += 10;
println!("{} {} {} {}", pa, pb, pc, pc2);
}
这段代码所做的事情其实跟上面一段代码基本一样。编译,发生错误,错误信息为:
rust
error: cannot borrow `x[..]` as mutable more than once at a time
这时候,Rust 编译器判定 pa、pb、pc 存在 alias 关系。它没办法搞清楚&x[A]、&x[B]、&x[C] 之间是否有可能有重叠。为什么编译器没办法搞清楚它们之间是否有重叠呢?
首先,要考虑到实际程序中作为索引的变量很可能不是编译期常量,而是根据运行期的值决定,A、B、C 可以是任意表达式。
其次,索引操作运算符其实是在标准库中实现的,除了数组,还有许多类型也能支持索引操作,比如 HashMap。即便编译器知道了 A、B、C 三个索引没有重叠,它也无法直接推理出&x[A]、&x[B]、&x[C] 之间是否有重叠。两个不同字符串做索引实际上指向了同一个值也有可能。
所以,对于结构体类型,编译器可以很轻松地知道,指向不同成员的指针一定不重叠,而对于数组切片,编译器的推理结果是将 x[_] 视为一个整体,&x[A]、&x[B]、&x[C] 之间都算重叠。虽然读者可以看出来,&mut x[0..2] 和&mut x[3..4] 根本就是指向两块独立的内存区域,它们同时存在是完全安全的。但是编译器却觉得,&mut x[A] 和&mut x[B] 一定不能同时存在,否则就违反了 alias+mutation 的设计原则。
那么面对这样的情况,Rust 该如何解决呢?如果说我们可以确定两块数组切片确实不存在重叠,我们应该怎么告诉编译器呢?
这就需要用到标准库中的 split_at 以及 split_at_mut 方法。首先,我们看看它的使用方式:
rust
fn main() {
let mut x = [1_i32, 2, 3];
{
let (first, rest) : (&mut [i32], &mut [i32]) = x.split_at_mut(1);
let (second, third): (&mut [i32], &mut [i32]) = rest.split_at_mut(1);
first[0] += 2;
second[0] += 4;
third[0] += 8;
println!("{:?} {:?} {:?}", first, second, third);
}
println!("{:?}", &x);
}
执行结果为:
rust
[3] [6] [11]
[3, 6, 11]
使用 split_at_mut 方法,可以将一个 Slice 切分为两个部分返回,返回值中包括的两个值分别都是指向原 Slice 的&mut[T] 型切片。这样可以保证这两个数组切片一定不会发生重叠,因此它们可以同时存在两个&mut 型指针,同时修改原来的数组,而不会制造内存不安全。
那么 split_at_mut 方法内部实现是怎么做的呢?它的源码如下所示:
rust
#[inline]
fn split_at_mut(&mut self, mid: usize) -> (&mut [T], &mut [T]) {
let len = self.len();
let ptr = self.as_mut_ptr();
unsafe {
assert!(mid <= len);
(from_raw_parts_mut(ptr, mid),
from_raw_parts_mut(ptr.offset(mid as isize), len - mid))
}
}
整体的逻辑并不复杂,只是需要一点 unsafe 代码来辅助。这说明,Rust 的这套 alias+mutation 规则,虽然威力巨大,但也误伤了一些“好人”。存在一些情况是,实际上内存安全但是无法被编译器接受。面对这样的情况,我们可以利用 Rust 的 unsafe 代码,case by case 地解决问题。
另外需要强调的是,unsafe 一定不能滥用。应该尽量把 unsafe 代码封装在一个较小的范围,对外公开的是完全 safe 的 API。如果不懂得如何抽象,把 unsafe 散落在业务逻辑的各个角落中,那么这就相当于退化成了 C 语言,甚至更糟糕。
还需要特别强调的一点是,用户更加不要做自欺欺人的事情,把一个明明是 unsafe 的函数声明成普通函数,仅仅为了调用的时候稍微方便一点。请大家一定要注意:如果一个函数有可能在某些场景下制造出内存不安全,那么它必须用 unsafe 标记,不能偷懒。哪怕这个可能性微乎其微也不行。在调用 unsafe 函数的地方,调用者必须反复确认被调用的这个 unsafe 函数的前置条件和后置条件是否满足,不能简单地用 unsafe 代码块框起来。