likes
comments
collection
share

写给前端开发者的 Rust 入门指南 Part 2 - 所有权

作者站长头像
站长
· 阅读数 20

学习一门底层语言,犹如修炼内功,帮助我们以更底层的视角看待编程,写出健壮安全的代码。修炼 Rust 绝非一朝一夕,本系列文章旨在从 JavaScript 开发者的角度,对比和厘清 Rust 中那些有趣的,难啃的,或独有的概念,帮助同胞们迈出学习 Rust 的第一步。

完整文章传送门:

写给前端开发者的 Rust 入门指南 Part 1 - 内存

写给前端开发者的 Rust 入门指南 Part 2 - 所有权 📍当前位置

写给前端开发者的 Rust 入门指南 Part 3 - 借用

写给前端开发者的 Rust 入门指南 Part 4 - 生命周期

本篇登场的是 Rust 世界中的绝对主角 -- 所有权(Ownership)

一段代码,分道扬镳

看以下这段平平无奇的 JS 代码👇:

// 定义一个对象,绑定到变量 obj1 上
let obj1 = {
  a: 1,
  b: 2
};

// 把 obj1 赋值给变量 obj2
let obj2 = obj1;

// 打印以上两个值
console.log(obj1);
console.log(obj2);

即便是 JS 新手,也能一眼看出以上代码运行完毕,会打印两次 {a: 1, b: 2} 。另外,由于 obj1 是一个对象,属于引用类型数据,所以当执行 let obj2 = obj1; 这行代码时,实际上是把 obj1引用复制一份给了 obj2。所以,代码运行完毕后,obj1obj2 指向了同一个对象 - {a: 1,b: 2},换句话说:obj1obj2 指向了同一段内存地址,且都可以访问和修改对象的数据。

同样的代码,用 Rust 来写写看:

#[derive(Debug)]
struct Obj {
  a: i32,
  b: i32
}

fn main() {
  let obj1 = Obj {a: 1, b: 2};
  let obj2 = obj1;
  println!("{:?}", obj1);
  println!("{:?}", obj2);
}

一句句分析:

首先,整段代码和前面的 JS 代码是等价的。 struct Obj {...} 声明了一个结构体(相当于 JS 中的对象),由于 Rust 是强类型的语言,定义时需要确定 Obj 中的每一个属性的类型: a 和 b 的都是 i32类型,表示都是 32 位整数;

接着,在 main 函数中:Obj {a: 1, b: 2} 创建(实例化)了这个结构体的实例 - obj1, 紧接着再把 obj1 赋值给 obj2

最后打印 obj1obj2println! 相当于 console.log{:?} 表示打印字符的占位,比如我可以在打印内容前面加一些字:这是打印内容👉 {:?}

小贴士:在 Rust 中,不是所有数据默认都能打印的,因为对于比较复杂的数据结构,Rust 并不知道你想要打印成什么样子,需要你自己实现打印方法。比如例子中的「结构体(struct)」,里面的属性可能非常复杂,所以不能直接调用 println! 。而之所以我们还是调用了,奥秘在第一行 - #[derive(Debug)]。这行奇怪的代码,紧紧贴在结构体 Obj 的上面,意思是:神龙,请给我的这个结构体默认实现一个打印功能吧。然后神龙(其实是 Rust 编译器啦😄)就会自动为这个结构体实现打印功能。这个魔法的名字叫做派生宏,后话了。

说了这么多,猜猜看,Rust 这段代码,会打印出什么呢?

答案是 -- 什么都不会打印。因为报错了,报错信息如下:

error[E0382]: borrow of moved value: `obj1`
  --> src/any.rs:11:20
   |
9  |   let obj1 = Obj {a: 1, b: 2};
   |       ---- move occurs because `obj1` has type `Obj`, which does not implement the `Copy` trait
10 |   let obj2 = obj1;
   |              ---- value moved here
11 |   println!("{:?}", obj1);
   |                    ^^^^ value borrowed here after move
   |

怎么回事?这么一段逻辑清晰且表达明确的代码,竟然会运行失败?

转移(Move)

逐步分解上例 Rust 代码到底发生了什么事情?在内存篇中我们说过,动态的,潜在占用空间大的数据,会存放在堆内存上,并且将其引用指针放在栈内存上。所以,当程序执行到 let obj1 = Obj {a: 1, b: 2}; 这一行时,内存状态是这样的:

写给前端开发者的 Rust 入门指南 Part 2 - 所有权

可以看到,结构体 {a: 1, b: 2} 在堆上,obj1 是它在栈上的引用指针。

重点来了,当执行到 let obj2 = obj1; 这行代码时,Rust 在栈上复制了一份该结构体的指针(obj1 -> obj2),并把新的指针(obj2)指向结构体在堆上的地址,随后移除了旧指针(obj1)对这个结构体的访问(引用)。在这里 obj1 就地宣告死亡(很惨真的),再也访问不到堆上的结构体了。换句话说:obj1 把它对堆上数据的所有权,转移(Move)给了 obj2

写给前端开发者的 Rust 入门指南 Part 2 - 所有权

由于旧指针 obj1 访问不到堆上的数据了,因此 println!("{:?}", obj1); 这行代码就不成立了,于是 Rust 编译器报错。试图访问一个不存在的值,是导致程序崩溃的最大元凶,作为前端开发,我们永远不会忘记被 cannot read properties of undefined 这几个红色小字支配的恐惧。

知道发生了所有权转移(Move),不禁要问,为什么 Rust 有这种「诡异的」转移(Move)机制?这要从堆内存的管理说起。

堆内存的释放

内存篇曾说到:对于编程语言来说,堆内存的释放是一大难题。栈内存的释放很简单:随着程序调用栈的结束而释放,一个函数调用完毕,其作用域内的变量就得到释放。而堆上的数据就没这么简单了,JS 和 Rust 在此分道扬镳:是否允许一个堆上数据有存在多个引用对其访问/修改,JS 允许(自由的少年),Rust 不允许(沉稳的武士)

JavaScript 的方案 - RC and GC

JS 采用的堆内存管理方案是引用计数(RC)和垃圾回收(GC)。

let obj1 = {
  a: 1,
  b: 2
};

let obj2 = obj1;

// -----至此,对象 {a: 1, b: 2} 有 2 两个引用(obj1 和 obj2)

obj2 = null
// -----至此,只剩 obj1 一个引用了 
obj1 = null
// -----至此,一个引用也没有了,准备释放内存!

以上代码中,堆内存上的 {a: 1, b: 2} 什么时候释放,取决于程序中对于它有没有引用。比如,obj1obj2 都是对这个数据的引用,所以这个数据的引用数为 2。在 JS 运行时,引用计数(RC)是一个勤恳的会计,维护着每一个堆上数据的引用数,如果引用数大于零,说明这个数据是被需要的,不能释放,如果引用数归零了,说明堆上的这个数据不再被需要了,就标记一下,等待清道夫(垃圾回收)来把这一块内存释放。

以上即垃圾回收(GC)的核心原理。这套方案开箱即用,不需要程序员操心,代价是运行时需要会计(RC)和清道夫(GC)把关,养这两个员工是要付出代价的(影响性能)。而且,他们工作可能存在「疏漏」,导致内存泄漏

let hugeObj = {...}; // 假装这是一个非常非常非常大的对象

setInterval(() => {
	console.log(hugeObj);
},1000)

以上是一个典型的 JS 内存泄漏的例子:定时器中访问了外部对象 hugeObj,而且是持续地访问(每秒一次),在这个定时器被清除之前,我们的会计(引用计数)会认为 hugeObj 永远都有用,一直被需要(真羡慕)。所以 hugeObj 自然不会被回收释放。如果我们并不需要 hugeObj 一直存在,但忘记清除定时器了,那么 hugeObj 就会一直占据着内存,占山为王,直到坐吃山空。

总结,JavaScript 允许堆上数据存在多个引用(访问/修改),在运行时靠引用计数和垃圾回收释放内存。好处是编码灵活,但牺牲了性能和内存安全。

Rust 的方案 - 所有权(Ownership)

Rust 没有垃圾回收。Rust 的内存管理基于以下两个原则:

原则1:堆上的数据,都被唯一一个栈上变量所拥有,受其控制

原则2:当栈上的变量(所有者)离开作用域,释放它拥有的堆上数据

回到这个例子中:

#[derive(Debug)]
struct Obj {
  a: i32,
  b: i32
}

fn main() {
  let obj1 = Obj {a: 1, b: 2};
  let obj2 = obj1;
  println!("{:?}", obj1);
  println!("{:?}", obj2);
}

一开始 obj1 拥有堆上结构体的数据,是这个数据的唯一拥有者,当赋值发生时(let obj2 = obj1;), 原则1生效,即堆上数据只能有唯一一个拥有者,赋值不能像 JS 那样增加一个引用(拥有者)。因此赋值的时候,obj1 只好把堆上数据的所有权(Ownership)转移(Move)给了 obj2。至此,obj2 成为结构体数据的新主人,obj1 当场宣告死亡,在这之后的代码都禁止访问它,这也就是 println!("{:?}", obj1); 触发编译错误的原因了。

把导致错误的代码(对 obj1 的访问)去掉:

...
fn main() {
  // ---函数作用域的开始,代码开始运行----
  let obj1 = Obj {a: 1, b: 2};
  let obj2 = obj1;
  // ---函数作用域的尾部,代码运行结束了----
  // !!!释放内存!!!
}

当 main 函数运行结束,变量 obj2 会被释放,由于它是堆上结构体的唯一拥有者,堆上的数据也在此时一并释放。符合了原则2:当栈上的变量(所有者)离开作用域,其拥有的堆上数据被释放。实际上,这段代码在编译后是这样的:

...
fn main() {
  let obj1 = Obj {a: 1, b: 2};
  let obj2 = obj1;
  // ---函数作用域的尾部,代码运行结束了,开始释放内存----
  // 编译器插入代码👇:
  drop(obj2)
}

Rust 编译器会在 main 函数的作用域底部,插入一个 drop 方法,传入 obj2。顾名思义,drop 方法把 obj2 及其拥有的堆上数据占用的内存释放了。

现在假设,如果没有转移(Move)机制,像 JS 一样,赋值时增加一个堆数据的引用指针,会发生什么呢?

...
fn main() {
  let obj1 = Obj {a: 1, b: 2};
  let obj2 = obj1; // 假设此时 obj1 和 obj2 都拥有数据的引用
  // ---这里是函数作用域的尾部,代码运行结束了,开始释放内存----
  // 编译器插入代码👇:
  drop(obj2) // 堆上数据被释放了
  drop(obj1) // error!重复释放同一块内存
}

由于 obj1obj2 都拥有堆上数据的所有权(假设原则1被打破),函数运行结束后,obj1obj2 都要被清除(原则2的要求),所以 Rust 编译器需要在作用域尾部插入 drop(obj2)drop(obj1) 两句代码。问题来了,当 drop(obj2) 调用后,堆上这一块内存已经被释放了,紧接着调用drop(obj1)时,此时堆上的这块内存已经不属于原先那块数据了,这可能导致严重的内存安全漏洞,比如,这块内存已经分配给其他数据了,重复释放可能造成内存污染。这就是所谓的 Double Free(内存重复释放)问题。

来一个复杂一点的数据结构:

struct Point {
    x: i32,
    y: i32
}

fn main() {
  let v = vec![
    Point {x: 50, y: 50},
    Point {x: 100, y: 100},
    Point {x: 150, y: 150}
  ];
}

以上是一个嵌套结构,变量 v 拥有动态数组,动态数组里面的元素是 Point 类型,Point 是结构体,结构体拥有字段 x, y 都是 i32 整数类型。

写给前端开发者的 Rust 入门指南 Part 2 - 所有权

我们发现,堆上的数据也可以被堆上数据拥有。比如动态数组存在堆上,它拥有的元素 Point 也存在堆上,Point 拥有的 i32 整数也在堆上。但最重要的是,这一切的源头 --「变量 v」是存在栈上的指针,它拥有动态数组的所有权,当变量 v 离开作用域时:

  1. v 释放它拥有的堆上的动态数组
  2. 动态数组释放它拥有的所有 Point 结构体
  3. Point 结构体释放了它拥有的 x,y

一旦栈上变量离开作用域,数据会沿着所有权链路,如多米诺骨牌一样坍塌释放,所有权机制运转良好。

总结一下,Rust 变量的释放依赖编译器在特定的位置插入drop 方法。为了这一机制的顺利运转,Rust 让堆上数据的生命周期(分配,转移,释放)受栈上唯一所有者的控制。这就是 Rust 最独一无二的特性 -- 所有权机制(Ownership)

没有垃圾回收机制的掣肘,Rust 具备极高的性能,只要代码符合设定好的规则,编译器就会产出内存安全、运行高效的代码。但这一切也是有代价的,基于所有权机制衍生出的各种编码规则,提高了 Rust 的理解门槛。但请相信我,迈过这个门槛,你会收获一种独一无二的编码体验。

所有权转移(Move)的场景

前面的例子中,所有权转移发生在变量的赋值传递中。其实在 Rust 中,任何使用值的场景,都可能伴随着所有权的转移。

函数的入参数和返回

fn logData(data: Vec<i32>)  {
    println!("打印成功:{:?}",data);
}

fn main() {
    let v = vec![1,2,3]; // 动态数组的所有权属于 v

    logData(v); // 所有权转移给函数的参数data,v 当场死亡
    //---------- logData 运行结束,data 内存释放,动态数组失效
    
    println!("{:?}",v); // error!访问了一个无效的引用
}

logData 函数接受参数 data -- 一个 i32 动态数组,把它打印出来。在main 函数中,一开始变量 v 拥有动态数组的所有权.调用 logData 时,传入 v,此时 v 把动态数组的所有权转移给 logData 的参数 data,变量 v 成为无效引用。随后的 println!(“{:?}”,v); 试图访问无效引用,编译器报错。

如果我希望在调用 logData 之后,能够继续访问 v 怎么办?那就把数据返回。函数的返回值也会转移数据的所有权:

// 打印 data 后,把 data 返回
fn logData(data: Vec<i32>) -> Vec<i32> {
    println!("打印成功:{:?}",data);
    data
}

fn main() {
    let v = vec![1,2,3];
    let v2 = logData(v); // 函数的return,把所有权从 data 转移给 v2
    println!("重新获取所有权 {:?}",v2); // 成功打印
}

以上代码给 logData 增加了返回值,打印后返回 data。在 Rust 中,函数的返回值不用写 return 关键字,直接把返回的值写在函数的最后,并且不加分号就好。所以最后一行的 data 相当于 return data;。另外,logData 参数申明后面的 -> Vec<i32> 表示这个函数返回值的类型是一个 i32 动态数组。函数的返回值也会发生所有权的转移,所以变量 v2 取得了动态数组的所有权,打印成功。

有没觉得这样编码很麻烦?为了避免大家对 Rust 失去耐心,有必要剧透一下,在实际编码过程中,我们其实会这样写:

fn logData(data: &Vec<i32>) {
    println!("打印成功:{:?}",data);
}

fn main() {
    let v = vec![1,2,3];
    logData(&v);
    println!("{:?}",v);
}

注意到了吗? 参数 data 的类型是 &Vec<i32>,调用入参时,传入的是 &v。这个神奇的符号 &,叫做借用(引用):变量 v 拥有的值,借过来给用用,所有权不用转移(Move)给我。借用(Borrowing)是基于所有权(Ownership)衍生出的另一机制。如果「所有权」是俞伯牙,那么「借用」就是钟子期,且听下回分解。

循环

循环一个集合是开发中常见的场景:

fn main() {
    let v = vec![1,2,3];
    for s in v { // 动态数组会的所有权被转移到 for 循环中去
	println!("{:?}",s);
    } // --- 循环结束后,v 无法继续使用了
    println!("{:?}", v); // error!!!
}

循环用到了 for ... in ... 语法,简直和 JS 一模一样。很好理解,把动态数组 v 中的每一个元素取出来,赋值s, 然后打印。既然涉及到了赋值,就会转移所有权,代码看起来像是循环把动态数组中的元素的一个个转移给 s,最后把它掏空了。实际上,只要用了这样的循环的语法,数据的所有权会被直接转移到 for 循环的语句快中,循环结束后,后续代码就无法访问 v了。值得注意的是,在 for 循环的循环体中,也是无法访问 v 的:

fn main() {
    let v = vec![1,2,3];
    for s in v { 
	println!("{:?}",s);
	println!("{:?}",v); //error! v 在循环体内无法访问
    } 
}

很合理, 动态数组正在被循环「肢解」,数据完整性未知,所以编译器阻止了访问。

对于一个这么常见的循环场景,转移所有权也太碍事了吧?不着急,大多数情况下,循环代码我们会这样写:

fn main() {
    let v = vec![1,2,3];
    for s in &v { // &v借用了数据的所有权,不发生转移
        println!("{:?}",s);
    }
    println!("{:?}", v); // 可以正常打印
}

See,神奇的借用符号 & 又出现了。&vv 借用了数据所有权,所以数据在循环结束后依然能够访问。

初始化集合数据

fn main() {
    let s = String::from("Rust"); // 创建字符串
    let v = vec![s]; // s 把所有权转移给了动态数组,s 失效
    println!("{:?}",s); // error!!!
}

String::from("Rust"); 语法创建了一个字符串 - “Rust”,这个字符串是 「String 类型」, 在 Rust 中,「String 类型」和「字符串字面量」是不一样的:

// s1 和 s2 是不同的类型
let s1 = "Hello";
let s2 = String::from("Hello");

s1字符串字面量(&str 类型),属于 Rust 基础类型值,在编译期间大小确定,固定不变,编译后直接写入程序包中,运行时存到栈内存上,快速且高效。

s2String 类型,指的是那些动态的,可变的,在编译期间无法确定大小的字符串,比如说一个表单页面,表单的值在用户输入的时候才能确定。这种情况下,我们需要一个「容器」来存放动态的字符串,这就是 String 类型。根据我们已有的知识,很容易可以推断出,String 类型的数据,储存在堆内存

本是同根生,「字符串字面量」和「String」可以相互转换👇:

let s1 = "Hello";
let s2 = String::from("Hello");

// 字符串字面量 转 String 👇
let s1_new = s1.to_string();

// String 转 字符串字面量 👇
let s2_new = s2.as_str()

不知道你是否联想到 JS 中的 String 对象。是的,对于动态的字符串数据,JS 也会把它存在堆上。从底层视角看,很多原理是相通的。

有关 String 类型的题外话结束,回到例子中:

let s = String::from("Rust"); // 创建字符串
let v = vec![s]; // s 把所有权转移给了动态数组,s 失效
println!("{:?}",s); // error!!!

把值的引用(s),放入集合类型(vector)中作为它的初始化值,也会转移其所有权。转移之后 s 的所有权属于动态数组(vector),动态数组存在堆上,被栈上变量 v 拥有。当 v 离开作用域,释放了动态数组的内存,动态数组所拥有的 String 也会被释放。

集合数据索引取值

延续上例:

let s = String::from("Rust"); // 创建字符串
let v = vec![s]; // s 把所有权转移给了动态数组,s 失效
let first = v[0]; // cannot move out of index of `Vec<String>`

通过数组索引取值是一个及其常见的场景,但 Rust 编译器会阻止我们这么做,动态数组中的元素如果是一个堆上元素(比如本例中的 Sting),取值的过程也会伴随着所有权的转移(所有权从动态数组转移给变量 first)。如果索引取值转移了所有权,动态数组的数据完整性就无法保证了,可能造成内存安全问题,所以编译器贴心地喊停🤚。

你可能猜到了,这时候,用神奇的符号 &

let s = String::from("Rust");
let v = vec![s];
let first = &v[0]; // 借用元素,不转移所有权
...

不转移的场景(Copy)

不知道你是否发现,开篇到现在,我们举的所有权转移(Move)的例子,全是基于堆上数据的。那栈上数据呢?比如数字、字符串字面量、布尔值...这些基础类型数据也遵循转移(Move)法则吗?

不是的,栈上数据不必遵循转移(Move)法则,直接按位复制(Bit-for-Bit Copy)

这并不新鲜,在无论在 JS,还是 Rust 中,基础类型值(栈上数据)在传递时,都会直接按位复制一份:

let a = 1let b = a;

很有意思,上面这两行代码从语法上看既可以是 JS,也可以是 Rust。甚至连底层发生的事情也都一模一样:

写给前端开发者的 Rust 入门指南 Part 2 - 所有权

基础类型值在赋值传递的时候,在栈上发生了按位复制。所谓按位复制,就是一个 bit 一个 bit 地完美复刻一份,新的副本和原来的毫无关系,完全独立。这从内存安全的角度看是及其安全的做法。对于静态,大小确定的值,按位复制很高效,所以他们遵循复制机制(Copy)。而诸如 vector,struct 这类复杂的动态数据,如果赋值传递也按位复制,太消耗性能,不实惠,所以他们遵循转移机制(Move),并通过「所有权机制」保障内存安全。

前文中所列举的转移场景,如果是基础类型值,就不会发生所有权转移,比如函数传参:

fn logData(data: i32)  {
    println!("打印成功:{:?}",data);
}

fn main() {
    let v = 1; 

    logData(v); // v 是基础类型,传参时自动按位复制一份给参数 data
    // -------- 函数运行结束,变量 v 依然可以访问
    
    println!("{:?}",v); // 成功打印
}

深拷贝(Clone and Copy)

有些时候,我们也需要堆上数据按位复制。比如在 JS 中,我们经常听说的「深拷贝」问题。如果想要深拷贝一个对象,可以自己实现一个方法:原理是再创建一个新的对象(新建堆内存),把原对象中的每一个属性在新对象中逐个重新创建。

在 Rust 中,某些情况可以直接调用 clone 实现深拷贝:

fn main() {
   // v 和 v1 完全独立
   let v = vec![1,2,3];
   let v1 = v.clone(); // clone 方法深拷贝了 v
   
   // s 和 s1 完全独立
   let s = String::from("Rust"); clone 方法深拷贝了 s
   Let s1 = s.clone();
   
   // 以下均可打印成功
   println!("{:?}", v);
   println!("{:?}", v1); 
   println!("{:?}", s);
   println!("{:?}", s1);
}

如上,动态数组(Vec)和 String 可以调用 clone 方法,把数据按位复制一份(在堆上),并把数据所有权交给一个栈上的变量(v1,s1)。副本和原数据相互独立,互不影响。

然而,不是所有数据类型都可以调用 clone 方法,比如结构体 struct:

struct Obj {
  a: i32,
  b: i32
}

fn main() {
   let v = Obj {a: 1, b: 2};
   let v1 = v.clone(); // 报错:no method named `clone` found for struct `Obj` in the current scope...
}

struct 默认没有 clone 方法,因为 struct 内部的数据类型可能非常复杂,如何深拷贝没有开箱即用的方法。能不能让结构体 Obj 在赋值传递的时候,也遵循 Copy 机制,自动按位复制呢?当然!Rust 是一门底层语言,我们可以精确控制数据的行为:

#[derive(Debug, Clone, Copy)] // 👈 奥秘在这里
struct Obj {
  a: i32,
  b: i32
}

fn main() {
   let v = Obj {a: 1, b: 2};
   let v1 = v;
   println!("{:?}", v); // 成功打印!!
}

请注意到第一行的 #[derive(Debug, Clone, Copy)]。在上文中,我们通过派生宏语法 -- ``#[derive(Debug)] Obj 实现了打印功能。现在多了两个词 --「Clone」 和 「Copy」。意思是:「万能的 Rust 编译器啊,请让 Obj 结构体具有 Clone 和 Copy 的特性,谢谢!」于是Obj 结构体的实例,就拥有了这两个特性:在本来该发生所有权转移的情况(let v1 = v;),执行了按位复制(Copy)!值得一提的是,我们只是为 Obj 这个自定义结构体实现了这些特性,其他的 struct 还是原来的样子。

你可能疑惑,我们只是想要为 Obj 实现 「Copy」 特性,为啥要捎上「Clone」呢?因为 Rust 要求,要实现 Copy,先要实现 Clone。可以想象 Copy 在内部依赖 Clone。因为实现了 Clone,所以现在也可以调用 Obj 的 clone 方法了(虽然没有必要):

#[derive(Debug, Clone, Copy)]
struct Obj {
  a: i32,
  b: i32
}

fn main() {
   let v = Obj {a: 1, b: 2};
   let v1 = v.clone(); // clone方法按位复制
   println!("{:?}", v);
}

我们可以据此推论:**在 Rust 中,数据的各种行为,例如赋值时是否会按位复制(Copy)?是否遵循转移(Move)机制?能否调用 clone 方法?能否被打印(println)等等,这些都是 Rust 的一个个特性。Rust 默认为某些数据类型实现了某些特性,比如为 i32 整数自带 Copy 特性,所以 i32 整数在赋值传递的时候,会自动按位复制。**我们也可以手动为数据实现特性,是的,这就是 Rust 中的 trait(特征)。trait 不是本文的重点,留待后续详细分解。

共享所有权 -- Rc and Arc

想象这样一个场景:每一篇文章都有一个作者,但一个作者可以拥有很多文章。作者和文章是「一对多」的关系。用 JS 代码来表示:

// 创建 作者 类
class Author {
	constructor(name,id) {
		this.name = name;
		this.id = id
	}
}

// 创建 文章 类
class Post {
	constructor(content, author) {
		this.content = content;
		this.author = author
	}
}

// 实例化一个作者
const a1 = new Author('Lin', 1); 

// 实例化两篇文章,并关联作者
const p1 = new Post("I love Rust", a1);
const p2 = new Post("I love JS", a1);

// p1: I love Rust -- by Lin
// p2: I love JS -- by Lin
console.log(`p1: ${p1.content} -- by ${p1.author.name}`)
console.log(`p2: ${p2.content} -- by ${p2.author.name}`)

文章 p1,p2 的 author 字段,共同指向作者 a1,或者说共同「拥有」a1。这在 JS 中再正常不过了。但在 Rust 中,由于所有权机制的限制,堆上的值不允许有多个拥有者,也就意味着 p1,p2 无法共同指向 a1,那么在 Rust 中如何实现以上代码呢?

大多数情况下,Rust 在所有权机制的保护下运行良好,但某些时候,为了应对以上的类似情形,Rust 的策略是 Rc(引用计数) 和 Arc(跨线程引用计数)。与前文中介绍的 JS 的引用计数(RC)类似,Rust 也可以使用类似的引用计数功能:允许堆上的数据存在多个所有者,多个所有者共享同一个堆上内存。

Rc 和 Arc 非常类似,区别仅在于 Arc 提供了跨线程的安全机制,如果在单线程环境使用,Rc 有更好的性能。本文仅以 Rc 为例:

use std::rc::Rc; // 引入 RC

fn main() {
  let v1 = Rc::new(String::from("Rust"));
  // --- 此刻,引用计数 = 1
  let v2 = v1.clone();
  // --- 此刻,引用计数 = 2
  let v3 = v1.clone();
  // --- 此刻,引用计数 = 3
  
  // --- 函数运行完毕,开始释放内存 ---
  // 释放 v3 成功,引用计数 = 2
  // 释放 v2 成功,引用计数 = 1
  // 释放 v1 成功,引用计数 = 0
  // 释放 v1 拥有的堆上数据(“Rust”)的内存
  // --- 成功释放,内存安全超过全国 99% 程序员,深藏功与名 ---
}

通过 use std::rc::Rc; 引入后,就可以使用 Rc 了。use 语法可以理解成 JS 中的 import,用啥就导入啥。在 main 函数块中,首先通过 Rc::new(String::from("Rust")); 创建了一个堆上数据(Rc创建的数据都在堆上),值是 String 类型。紧接着,通过 clone 语法复制了两个指针:v2,v3。clone 方法前面介绍过,不过用在 Rc 数据上,语义就不是按位复制了,而是复制一份堆数据的指针,并把该数据的引用计数加 1。至此,v1,v2,v3 共同指向堆上数据,换句话说,他们三个共同拥有同一块内存:

写给前端开发者的 Rust 入门指南 Part 2 - 所有权

Rc 虽然违背了原则1(唯一拥有者),但依然遵循原则2(堆内存随着栈上所有者离开作用域而释放),代码注解中详细标注了 main 函数生命周期全过程,Rc 数据引用计数的变化,可以看到,当函数执行完毕,每一个 Rc 引用逐个释放销毁,但引用数归零时,一切又回到所有权规则的范畴 -- 销毁那个堆内存!

虽然 v1,v2,v3 共同拥有同一块内存地址,但它们仅仅能读取数据,如果三者中任一尝试修改数据,编译器会阻止的:

use std::rc::Rc;

fn main() {
  let v1 = Rc::new(String::from("Rust"));
  let v2 = v1.clone();
  let v3 = v1.clone();

  v2.push_str("!"); // 报错:cannot borrow as mutable
}

v2 试图改变堆上的字符串,给它加上一个感叹号,但很遗憾,Rust 编译器不允许这个行为发生,因为多个指针能够修改同一个堆上数据,这个行为在 Rust 看来是极其危险的。

回到「作者与文章」的例子,我们用 Rust 实现一下(与上文中 JS 版本等价):

use std::rc::Rc;

// 创建 作者 struct
struct Author {
    name: String,
		id: i32
}

// 创建 文章 struct
struct Post {
  content: String,
  author: Rc<Author>,
}

fn main() {
    // 实例化一个作者
    let a1 = Rc::new(Author {
        name: String::from("Lin"),
        id: 1
    });

    // 实例化两篇文章,并关联作者
    let p1 = Post {
        content: String::from("I love Rust"),
        author: a1.clone(),
    };

    let p2 = Post {
        content: String::from("I love JS"),
        author: a1.clone(),
    };

    // p1: I love Rust -- by Lin
    // p2: I love JS -- by Lin
    println!("p1: {} -- by {}", p1.content, p1.author.name);
    println!("p2: {} -- by {}", p2.content, p2.author.name);
}

Rust 的 Rc,是一种受到充分限制的引用计数机制,变量的生命周期依然在离开作用域时释放,堆上数据依然受栈上所有者的控制,且拥有者们只有数据的只读权限(当然也是有办法绕过的啦)。不知道你是否嗅到了 Rust 克制的气息,每一个特性或机制背后,都要遵循一定的条件或约定。保持克制,拥抱自由。

结语

所有权(Ownership)是 Rust 的核心机制。基于所有权,Rust 构建了一套独特的编程范式。这套范式对初学者很不友好(理解门槛高),但如果你熟练掌握了所有权及其衍生出的一系列机制(Move,Borrow...),这份独特的编码技能会给你带来从未有过的高效与自由。

下一篇,我们来说说 Rust 的借用(Borrowing)机制。