Back
Featured image of post Rust 学习笔记03 - 所有权

Rust 学习笔记03 - 所有权

导航页

所有权无疑是 Rust 的核心概念。是编写所有代码都需要注意的内存模型。

大多数 native 语言需要手动分配和释放内存(如 C、C++),性能很高,但容易出错,编写出的代码很难维护,需要程序员有较高水平;有一些语言使用垃圾收集(比如 Java、Kotlin、C#),这降低了门槛,但会损失许多性能。

Rust 的答案截然不同,Rust 使用所有权机制,在编译期就可以检查内存分配,减少程序员的心智负担,但运行时仍然很快。

堆和栈

不妨回忆一下这两个概念。

(Heap)和(Stack)都是在运行时可用的内存,但是结构不同。

栈是一个 Queue 结构,只能操作头部。增加数据被称作进栈,移出数据被称作出栈。需要以入栈相反的顺序出栈,这被称为先进后出。栈是紧密的,其中的所有数据都必须占用已知且固定的内存大小。

堆是缺乏组织的。在堆上分配内存(allocating on the heap)只需要找一块足够大的空位,并将其标记为已使用。堆上的数据通常需要在栈上保存一个指针(Pointer),使用时要指针找到对应的堆内存。

在栈上分配内存比堆上快,因为没有寻找的过程,直接操作头部即可。堆分配需要额外的工作,即需要找到能够存放数据的内存空间,并记录以供下次操作。

访问栈也比访问堆快。因为堆需要经过一层指针转发。

在函数调用时,传递给函数的值(包括指针)和函数局部变量都被压入栈。函数结束时,这些值被移出栈。

所有权

任何时候,Rust 中的变量都有且仅有一个所有者(owner)。当所有者离开作用域时,该值将会被丢弃。

{ // 作用域开始
  let s = "hello"; // s 有效
} // 作用域结束, s 被消毁

这与其他语言类似。

String 类型

为了介绍所有权,我们需要一个不能分配在栈上,只能分配到堆上的 String 类型。(详细的介绍在以后进行)

let str = String::from("Hello"); // 初始化
str.push_str(", world!")         // 添加字符
println!("{}", str);             // 将打印 `Hello, world!`

:: 是命名空间访问符号,用于访问 String 下的 from 静态方法。

这和字符串字面量的类型 &str 的区别在于。String 在运行时可修改,编译时大小不可知,存储在堆上。

内存与分配

字符串字面量在编译时可知,所以可以直接硬编码进可执行文件中。然而我们不能将可能变化的数据硬编码。

String 类型为了可变、可修改的特性,需要在堆上分配可变长的数据结构。意味着:

  1. 必须在运行时请求内存
  2. 处理后应当将内存释放

第一部分很简单:调用 String::from 时请求即可。

然而第二部分就会出现不同的路线了。在 GC 语言中,GC 会帮我们清除不再使用的内存(这部分可以参见我的 JVM 学习笔记)。在大部分无 GC 的语言中,需要程序员手动识别不再使用的内存,并调用代码显式释放。如 C:

#include <stdlib.h>
#include <stdio.h>

int main() {
  int *mem = malloc(sizeof(int) * 4);
  mem[0] = 10;
  mem[1] = 2;
  for (int i = 0; i < 4; i++) {
    printf("%d, ", mem[i]);
  }
  free(mem);
  mem = NULL;
}

手动分配内存需要很多开发精力,一次创建需要严格对应一次释放,没有释放会造成内存泄漏,重复释放更会导致不可预测的运行时错误,甚至是让程序崩溃。——JVM学习笔记02

Rust 采取不同的策略:内存在变量离开作用域后被自动释放。如:

{
  let s = String::from("hello"); // 从此处起,s 是有效的
  // 使用 s...
} // 作用域结束
  // s 不再有效

在作用域结束释放内存是很自然的。Rust 此时自动调用一个特殊的函数,名为 drop。并调用相应的,String 释放内存的代码。

这种模式虽然简单,但影响深远,下面我们将探索在复杂场景下的机制。

变量与数据交互的方式

移动

先看看分配在栈上的对象:

let x = 5;
let y = x;
println!("x: {} y: {}", x, y); // x: 5 y: 5
y = 10;
println!("x: {} y: {}", x, y); // x: 5 y: 10

嗯,很符合预期,成功输出了 xyxy 都在栈中,两个值都为 5。修改 y 不会影响 x

再来看看 String(分配在堆上的对象):

let s1 = String::from("hello");
let s2 = s1; // s1 move to s2
println!("{}", s1);

Rust 报了一个编译时错误borrow of moved value: s1

看起来和上面的代码类似,但行为并不一样。我们可能期待第二行的赋值语句生成 s1 的拷贝并绑定到 s2 上,但事实并非如此。拷贝的仅仅是指针和基本信息

String 拷贝前 String 拷贝后

可以看到,Rust 并没有拷贝堆上的数据,仅仅只拷贝了栈上的数据。如果 Rust 在此时默认拷贝堆上的数据,在数据较大时会造成非常大的开销

之前提到过,在变量离开作用域时,Rust 会自动调用 drop 并清理内存。不过,上图展示了两个指针指向了同一个位置,这就造成了一个问题:它们在离开作用域时,会尝试释放相同的内存,也叫二次释放

为了避免这个问题,保证内存安全,在 let s2 = s1 后,Rust 便令 s1 无效。后面再次使用 s1,将造成编译时异常。就是之前我们遇到的情况。

在其他语言中,有类似浅拷贝(shallow copy)、深拷贝(deep copy)的术语。上面的行为类似浅拷贝,但 Rust 同时令原变量无效了,所以称其为移动(move),而非浅拷贝。

移动方式适用于实现了 Drop trait(可以简单理解为接口)的数据结构,大多数情况不需要手动实现。

克隆

如果我们切实需要深拷贝,也就是复制一份堆上的数据。我们可以使用 clone() 函数。

let s1 = String::from("hello");
let s2 = s1.clone();
println!("{s1}, {s2}");

克隆方式适用于实现了 Clone trait 的数据结构。

拷贝

其实之前已经提及,如整数这种类型,就是实现了 Copy trait 的。它们只存储在栈上。

CopyDrop 互斥,因为 Drop 意味着在堆上储存了数据。以下数据类型实现了 Copy

  • 整数类型 iXX uXX,浮点类型 fXX
  • 布尔类型 bool
  • 字符 char
  • 所有成员都实现了 Copy 的元组,如 (i32, i32),但 (i32, String) 就没有。

对实现了 Copy 的类型调用 clone() 与直接赋值没有区别,都是在栈上分配。

let x = 32;
let y = x;
let y_clone = y.clone();

函数和作用域

将值传递给函数与给变量赋值类似,没有什么特别的。可以把函数的形参看作一种赋值(夺去所有权)。返回看作给予所有权。

fn gives_ownership() -> String {
  let some_string = String::from("hello");
  return some_string;
}

fn takes_and_gives_back(string: String) -> String {
    return string;
}

fn main() {
  let s1 = gives_ownership();

  let s2 = String::from("hello");

  let s3 = takes_and_gives_back(s2);
} // s1, s3 被 drop, s2 已经移动去了 s3

我们可以使用函数返回多个值,来达到计算的效果:

fn calculate_len(s: String) -> (String, usize) {
  let len = s.len();
  return (s, len);
}

fn main() {
  let s1 = String::from("hello");
    let (s2, len) = calculate_len(s1);
}

然而这未免有点太啰嗦了,返回 s2 仅仅是为了来回转移所有权。为何不更简单点?所以我们有了——

引用和借用

我们可以将函数的参数改为引用:

fn calculate_len(s: &String) -> usize {
  return s.len();
}

fn main() {
  let s1 = String::from("hello");
    let len = calculate_len(s1);
  println!("The length of '{}' is {}.", s1, len);
}

& 符号即为引用符号,允许你使用值但不获得其所有权,即借用(borrowing)。

与之对应的操作是解引用(dereferencing),使用 *。这在后面讲解。

正如变量默认不可变,我们在默认情况下,也无法修改借用的变量。

可变引用

fn main() {
    let mut s = String::from("hello");
    change(&mut s);
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

可以看到,我们在声明处和调用处都必须显式声明 &mut 传递可变引用。

可变引用带来一项很大的限制:在同一时间,对特定数据的可变引用至多有 1 个。

例如,以下的代码将会引发编译时错误:

let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s;

这段代码造成异常的可能性很高。为了避免数据竞争造成运行时错误,Rust 将问题提前到了编译期。造成数据竞争的 3 个条件,缺一不可,Rust 的判断机制也基于以下规则:

  • 多个指针访问同一数据
  • 至少一个指针用于写数据
  • 没有同步机制

Rust 允许在不同作用域中拥有多个可变引用,但不能同时拥有:

let mut s = String::from("hello");

{
  let r1 = &mut s;
}

let r2 = &mut s;

Rust 允许同时存在多个不变引用(同时读不会造成问题),但只要有一个可变指针,则不允许通过编译:

let mut s = String::from("hello");

let r1 = &s; // 没问题
let r2 = &s; // 没问题
let r3 = &mut s; // 大问题

println!("{}, {}, and {}", r1, r2, r3);

请注意,Rust 可以检测隐式的作用域,亦称非词法作用域生命周期(NLL, Non-Lexical Lifetimes):

let mut s = String::from("hello");

let r1 = &s; // 没问题
let r2 = &s; // 没问题
println!("{} and {}", r1, r2);
// 此位置之后 r1 和 r2 不再使用

let r3 = &mut s; // 没问题
println!("{}", r3);

相当于自动为你添加了大括号。

悬挂引用

fn dangle() -> &string {
  let s = String::from("hello");
  retunr &s;
}

将引用作为返回值是非常疯狂的一种行为。在 s 退出作用域时,它被清理了,但指向它的指针却没有,这称为悬挂引用。所以 Rust 又在编译期阶段阻止了这一行为。

复习一下,引用的规则是:

  1. 在任意时间,要么有一个可变引用,要么有多个不变引用
  2. 引用必须总是有效的

如果你不记得了,不要害怕,Rust 编译器会提醒你。

切片

切片(slice)的目的是为了引用集合中的元素序列,而不引用整个集合。相比直接使用索引的方式,提供了额外的编译时安全保障。

例如尝试找字符串的第一个单词:

fn first_word(s: &String) -> usize {
  let bytes = s.as_bytes();

  for (i, &item) in bytes.iter().enumerate() {
    if item == b' ' {
        return i;
    }
  }

  return s.len();
}

上述代码的具体实现不重要。无非就是找到第一个单词,并返回索引。

不妨查看以下用例:

fn main() {
  let mut s = String::from("hello world");
  let word = first_word(&s); // word == 5
  s.clear(); // 清空字符串, 使其为 ""
  // word 在此仍然为 5
  // 也就是说, word 的意义不再有效, 后续使用该值可能会造成意外的后果
}

如果我们要编写一个签名如这样的函数,索引问题将会更加疯狂:

fn second_word(s: &String) -> (usize, usize)

切片解决了这一问题,它的基本语法如下:

let s = String::from("Hello World");
let hello = &s[0..5];
let world = &s[6..11];
// 和之前说的一样, 也可以使用 ..= 表示闭区间
// 可以省略 0 和 length, 如:
let len = s.len()
let hello2 = &s[..5];
let world2 = &s[6..];
let whole1 = &s[0..len]
let whole2 = &s[..]

注意,如果字符串 slice range 不在有效的 UTF-8 字符边界内,程序将会崩溃。这里假设为 ASCII 字符集。

使用切片重写的方法:

fn first_word(s: &String) -> &str {
  let bytes = s.as_bytes();

  for (i, &item) in bytes.iter().enumerate() {
    if item == b' ' {
      return &s[0..i];
    }
  }

  return &s[..];
}

类似的,second_word 也可以改写为切片:

fn second_word(s: &String) -> &str

这样,我们在使用时,就无需自己思考索引的有效性了:

fn main() {
    let mut s = String::from("hello world");
    let word = first_word(&s);
    s.clear(); // 编译时错误!
    println!("the first word is: {}", word);
}

字面量类型

字符串字面量的类型就是 &str,是指向二进制程序特定位置的 slice。所以字符串字面量不可变。

let s: &str = "Hello, World!";

字符串 slice 作为参数

将字符串 slice &str 作为参数是最佳实践。

因为可以同时接收字符串字面量、&str&String

let str1 = String::from("hello, world!");
let word1 = first_word(&str1[..]);
let word2 = first_word(&str1);

let literal = "hello world!";
let word3 = first_word(literal[0..6]);
let word4 = first_word(literal[..]);
let word5 = first_word(literal);

其他类型的 slice 类似,就不再赘述了。

以上,就是 Rust 内存管理相关的概念。

comments powered by Disqus