文章

Rust学习笔记(2) - 认识所有权

所有权机制是Rust最与众不同的特性,它让Rust无需垃圾回收(garbage collector)就能保障内存安全

什么是所有权(ownership)?

所有运行的程序都必须管理其使用计算机内存的方式,有一些语言具有GC机制,在程序运行时不断寻找不再使用的内存;还有一些语言,需要程序员亲自分配和释放内存。而在Rust中,是通过所有权机制来管理内存的,编译器在编译时会根据一系列规则进行检查,在运行时,所有权系统内部的所有功能都不会减慢程序

在了解所有权之前,我们要先了解栈(Stack)与堆(Heap)的概念

栈(Stack)与堆(Heap)

在很多语言中,你并不需要经常考虑到栈与堆。不过在像 Rust 这样的系统编程语言中,值是位于栈上还是堆上在更大程度上影响了语言的行为以及为何必须做出这样的抉择。我们会在本章的稍后部分描述所有权与栈和堆相关的内容,所以这里只是一个用来预热的简要解释。

栈和堆都是代码在运行时可供使用的内存,但是它们的结构不同。栈以放入值的顺序存储值并以相反顺序取出值。这也被称作 后进先出last in, first out)。想象一下一叠盘子:当增加更多盘子时,把它们放在盘子堆的顶部,当需要盘子时,也从顶部拿走。不能从中间也不能从底部增加或拿走盘子!增加数据叫做 进栈pushing onto the stack),而移出数据叫做 出栈popping off the stack)。

栈中的所有数据都必须占用已知且固定的大小。在编译时大小未知或大小可能变化的数据,要改为存储在堆上。堆是缺乏组织的:当向堆放入数据时,你要请求一定大小的空间。操作系统在堆的某处找到一块足够大的空位,把它标记为已使用,并返回一个表示该位置地址的 指针pointer)。这个过程称作 在堆上分配内存allocating on the heap),有时简称为 “分配”(allocating)。将数据推入栈中并不被认为是分配。因为指针的大小是已知并且固定的,你可以将指针存储在栈上,不过当需要实际数据时,必须访问指针。

想象一下去餐馆就座吃饭。当进入时,你说明有几个人,餐馆员工会找到一个够大的空桌子并领你们过去。如果有人来迟了,他们也可以通过询问来找到你们坐在哪。

入栈比在堆上分配内存要快,因为(入栈时)操作系统无需为存储新数据去搜索内存空间;其位置总是在栈顶。相比之下,在堆上分配内存则需要更多的工作,这是因为操作系统必须首先找到一块足够存放数据的内存空间,并接着做一些记录为下一次分配做准备。

访问堆上的数据比访问栈上的数据慢,因为必须通过指针来访问。现代处理器在内存中跳转越少就越快(缓存)。继续类比,假设有一个服务员在餐厅里处理多个桌子的点菜。在一个桌子报完所有菜后再移动到下一个桌子是最有效率的。从桌子 A 听一个菜,接着桌子 B 听一个菜,然后再桌子 A,然后再桌子 B 这样的流程会更加缓慢。出于同样原因,处理器在处理的数据彼此较近的时候(比如在栈上)比较远的时候(比如可能在堆上)能更好的工作。在堆上分配大量的空间也可能消耗时间。

当你的代码调用一个函数时,传递给函数的值(包括可能指向堆上数据的指针)和函数的局部变量被压入栈中。当函数结束时,这些值被移出栈。

跟踪哪部分代码正在使用堆上的哪些数据,最大限度的减少堆上的重复数据的数量,以及清理堆上不再使用的数据确保不会耗尽空间,这些问题正是所有权系统要处理的。一旦理解了所有权,你就不需要经常考虑栈和堆了,不过明白了所有权的存在就是为了管理堆数据,能够帮助解释为什么所有权要以这种方式工作。

记住Rust所有权的规则:

  1. Rust 中的每一个值都有一个被称为其 所有者owner)的变量。
  2. 值在任一时刻有且只有一个所有者。
  3. 当所有者(变量)离开作用域,这个值将被丢弃。

通过String类型来认识所有权

首先,我们来看简单情况,我们都知道,所有编程语言都有变量作用域(scope)的概念,Rust也不例外,我们来看一段代码:

1
2
3
4
5
6
7
8
9
10
fn main() {
    {
        let mut num = 2;
    }

    // not found in this scope
    num = 4;

    println!("{}", num);
}

可以看到,注释部分表示了上面代码在编译的时候会注明的error,提示在当前作用域中不存在num这个变量,到这里为止,Rust的作用域相关特征都还和其他编程语言类似,num是一个基本类型的变量,其储存在栈上,当离开作用域时被移出栈

在介绍更复杂的所有权规则之前,我们先来介绍一种比之前都要复杂的数据类型,String类型,它和之前我们用let s = "hello, world!"定义的字符串字面值不一样,字符串字面值是不可变的,而String类型是可变的

来看两段代码:

1
2
3
4
5
fn main() {
    let mut s = "hello";
    // cannot add `&str` to `&str`
    s = s + ", world! ";
}
1
2
3
4
5
fn main() {
    let mut s = String::from("hello");
    s = s + ", world!";
    println!("{}", s);
}

在第一段代码中,我们定义了一个字符串字面量,当我们试图在其后面添加新的内容时,出现了编译错误,而在第二段代码中,我们定义了一个String类型的变量,就可以在后面添加新的内容了,那么,第二段代码是什么意思呢

对于字符串字面量,我们在编译时就知道其内容,它被直接硬编码进最后的可执行文件中。而我们在第二段代码中用了String::from("hello")来定义了一个字符串变量(String::from这样的写法会在以后学习到),可以存储在编译时未知大小的字符串内容,它可能会随着程序的运行而改变,所以不能硬编码进二进制文件,需要在对上分配一块在编译时未知大小的内容来存放

这个变量被分配到堆上,意味着:

  • 必须在运行时向操作系统请求内存
  • 当处理完这个变量时,我们要将内存返回给操作系统

第一点当我们在调用String::from时就已经完成了,在许多编程语言中,都是这么做的,对于第二点,每种编程语言就有区别了,在有GC的语言中,GC记录并清除不再使用的内存,在没有GC的语言中,手动释放不再使用的内存就是我们的责任。正确处理内存回收是一个困难的编程问题,如果忘记回收了会浪费内存。如果过早回收了,将会出现无效变量。如果重复回收,这也是个 bug。为此,Rust的设计者为每一个内存的分配配对了一个释放规则,这个规则由所有权来保证

1
2
3
4
5
fn main() {
    {
        let s = String::from("hello, world!");
    }
}

上面的代码中,当s离开其作用域,Rust为我们调用一个特殊的函数drop,在这里会执行String的释放内存的代码

变量与数据的交互方式(一):移动

1
2
let x = 5;
let y = x;

上面的代码将x的值赋值给了y,因为整数是已知固定大小的简单值,所以这两个5被放入了栈

再来看看String版本

1
2
let s1 = String::from("hello");
let s2 = s1;

让我们观察在String的底层会发生什么,String由左侧的三部分组成,分别是一个指向存放字符串内容内存的指针,一个长度,以及一个容量,这一组数据存储在栈上。右侧则是在堆上存放内容的内存部分

当我们将s1赋值给s2String的数据被复制了,我们复制了其在栈上的指针,长度和容量,并没有复制指针指向的堆上的数据,所以内存中数据的表现如图:

如果其同时也拷贝了其在堆上的数据,如图所示,可以看出如果堆上数据比较大,其会对性能造成较大的影响

之前提过,当变量离开作用域时,Rust会自动调用drop函数清理内存,可以想象到,如果两个变量的指针指向同一个位置,当其离开作用域时,会尝试释放相同的内存,这可能会导致潜在的安全漏洞

为了确保内存安全,Rust做的处理是这样的:当s1被赋值给s2时,Rust认为s1不再有效,所以如下代码会出现问题:

1
2
3
4
5
6
fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
    // borrow of moved value: `s1`
    println!("{}", s1);
}

这里当let s2 = s1时,s1已经是一个moved value了,在其它语言里面,你可能听说过浅拷贝shallow copy)和 深拷贝deep copy),在Rust里,当把s1的指针,长度和容量拷贝到s2时,Rust还使s1无效了,这个操作被称为移动(move),而不是浅拷贝

所以这里只有s2依然有效,当其离开作用域,就释放自己的内存

变量与数据交互的方式(二):克隆

当确实需要深拷贝String位于堆上的数据时,可以用一个叫做clone的通用函数

1
2
3
4
5
fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();
    println!("s1 = {}, s2 = {}", s1, s2);
}

像整形这样实现了Copytrait的类型(关于trait会在之后的学习中学到),在被赋值给其它变量之后仍然可用,Rust不允许其自身或其任何部分实现了Droptrait的类型使用Copytrait,Copy类型包括但不限于:

  • 所有整数类型,比如 u32
  • 布尔类型,bool,它的值是 truefalse
  • 所有浮点数类型,比如 f64
  • 字符类型,char
  • 元组,当且仅当其包含的类型也都是 Copy 的时候。比如,(i32, i32)Copy 的,但 (i32, String) 就不是。

所有权与函数

将值传递给函数在语义上与给变量赋值相似。向函数传递值可能会移动或者复制,就像赋值语句一样,比如如下代码会出现问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fn main() {
    let s = String::from("hello"); // s 进入作用域

    takes_ownership(s); // s 的值移动到函数里 ...
                        // ... 所以到这里不再有效

    // borrow of moved value: `s`
    println!("{}", s);
} // 这里, x 先移出了作用域,然后是 s。但因为 s 的值已被移走,
  // 所以不会有特殊操作

fn takes_ownership(some_string: String) {
    // some_string 进入作用域
    println!("{}", some_string);
} // 这里,some_string 移出作

返回值与作用域

如下代码在第二次使用s2时出现错误,因为那时s2的值已经被移动到takes_and_gives_back,然后将返回指移动到s3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
fn main() {
    let s1 = gives_ownership(); // gives_ownership 将返回值
                                // 移给 s1

    let s2 = String::from("hello"); // s2 进入作用域

    let s3 = takes_and_gives_back(s2); // s2 被移动到
                                       // takes_and_gives_back 中,
                                       // 它也将返回值移给 s3

    // borrow of moved value: `s2`
    println!("{}", s2);
} // 这里, s3 移出作用域并被丢弃。s2 也移出作用域,但已被移走,
  // 所以什么也不会发生。s1 移出作用域并被丢弃

fn gives_ownership() -> String {
    // gives_ownership 将返回值移动给
    // 调用它的函数

    let some_string = String::from("hello"); // some_string 进入作用域.

    some_string // 返回 some_string 并移出给调用的函数
}

// takes_and_gives_back 将传入字符串并返回该值
fn takes_and_gives_back(a_string: String) -> String {
    // a_string 进入作用域

    a_string // 返回 a_string 并移出给调用的函数
}

当想要函数使用一个值但不获取所有权,可以通过元组的方式或者使用引用(references)

通过元组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fn main() {
    let s1 = String::from("hello"); // s1 进入作用域

    let (s1, s2) = takes_and_gives_back(s1); // s1 被移动到
                                             // takes_and_gives_back 中,
                                             // 它也将返回值移给 s2

    println!("{} length is {}", s1, s2);
} // 这里, s3 移出作用域并被丢弃。s2 也移出作用域,但已被移走,
  // 所以什么也不会发生。s1 移出作用域并被丢弃

// takes_and_gives_back 将传入字符串并返回该值
fn takes_and_gives_back(a_string: String) -> (String, usize) {
    // a_string 进入作用域
    let a_length = a_string.len();
    (a_string, a_length) // 返回 a_string 并移出给调用的函数
}

可以看到,通过元组的方法过于形式主义,所以我们需要使用引用来做这件事

引用与借用

使用&符号可以使用引用,允许使用值但不获取其所有权(对应的,使用*符号可以进行解引用)

1
2
3
4
5
6
7
8
9
10
11
fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

&String s指向String s1示意图

当引用离开作用域时,其指向的值也不会被丢弃,此时我们不再需要返回指来交换所有权,因为所有权没有发生过转移

获取引用的过程被称为 借用(borrowing),和变量默认是不可变的一样,引用的值也默认不可变

比如上面的代码,因为我们没有获得s1的所有权,所以当引用离开作用域时其指向的值也不会被丢弃

可变引用

将引用调整成mut可以使其可变,比如,这段代码最后会输出hello, the world!

1
2
3
4
5
6
7
fn main() {
  let mut s = String::from("hello, world!");
  let s1 = &mut s;
  s1.clear();
  s1.push_str("hello, the world!");
  println!("{}", s);
}

不过可变引用是有限制的:在特定作用域的特定数据只能有一个可变引用,所以,下列代码不能编译通过:

1
2
3
4
5
6
 fn main() {
  let mut s = String::from("hello");
  let s1 = &mut s;
  let s2 = &mut s;
  println!("{}, {}", s1, s2);
}

这个限制可以有效的防止数据竞争data race),数据竞争会导致未定义行为,难以在运行时追踪,其可由这三个行为造成:

  • 两个或更多指针同时访问同一数据。
  • 至少有一个指针被用来写入数据。
  • 没有同步数据访问的机制。

Rust不会让存在数据竞争的代码编译通过,也就不会发生数据竞争

也不能在拥有不可变引用的同时拥有可变引用,如:

1
2
3
4
5
6
7
fn main() {
  let mut s = String::from("hello, world!");
  let s1 = &s;
  // cannot borrow `s` as mutable because it is also borrowed as immutable
  let s2 = &mut s;
  println!("{}", s1);
}

这个“同时拥有”的概念,或者说是引用的作用域的概念,用下面的代码可以很好理解:

1
2
3
4
5
6
fn main() {
  let mut s = String::from("hello");
  let s1 = &mut s;
  let s2 = &mut s;
  println!("{}", s2);
}
1
2
3
4
5
6
7
8
fn main() {
  let mut s = String::from("hello");
  let s1 = &s;
  let s2 = &s;
  println!("{},{}", s1, s2);
  let s3 = &mut s;
  println!("{}", s3);
}

一个引用的作用域,从其被创建的时候开始,到其最后一次被使用为之,所以上面的代码在编译时不会产生错误,因为这些引用的作用域没有重叠

悬垂引用

在具有指针的语言中,很容易因为释放内存时还保留了指向它的指针而错误地生成一个悬垂指针dangling pointer),即其指向的内存可能已经被分配给其它持有者,在Rust中,永远也不会出现这种情况,编译器会保证数据不会在其引用之前离开作用域

1
2
3
4
5
// this function's return type contains a borrowed value, but there is no value for it to be borrowed from
fn dangle() -> &String {
  let s = String::from("hello");
  return &s;
}

上面的代码除了一个和生命周期(lifetimes)有关的错误外(生命周期之后会学到),还会提示由于被借用的值已经没了,所以没办法返回这个引用

引用的规则

总结上面引用的规则,可以做一个概括:

  • 在任意给定时间,要么只能有一个可变引用,要么只能有多个不可变引用。
  • 引用必须总是有效的。

Slice类型

可以用slice类型引用集合中一段连续的元素序列,而不引用整个集合,写法是..语法,引用集合中从..左边的索引开始,到右边的索引之前一个位置的内容(类似Python里面range()函数的范围)

字符串slice

1
2
3
4
5
6
7
8
9
fn main() {
    let s = String::from("abcdefghijklmn");
    println!("{}", &s[0..3]); // abc
    println!("{}", &s[0..6]); // abcdef
    println!("{}", &s[2..6]); // cdef
    println!("{}", &s[..]); // abcdefghijklmn
    println!("{}", &s[2..]); // cdefghijklmn
    println!("{}", &s[..s.len()]); // abcdefghijklmn
}

之前提到过的字符串字面值就是slice,其类型是&str,它是一个指向二进制程序特定位置的slice,是一个不可变引用,所以字符串字面量也不可变

其他类型的slice

其他类型比如数组,也有slice,比如这段代码,把一个数组分成两个可变的数组slice,并分别调整它们的值,其中split_at_mut()函数可以把一个数组从指定mid位置分成两部分,分出来的两部分分别是[0,mid)[mid,len)

1
2
3
4
5
6
7
8
9
10
11
12
fn main() {
    let mut a = [1, 2, 3];
    let (part1, part2) = a.split_at_mut(2);
    part1[1] = 222;
    part2[0] = 333;
    for elem in a.iter() {
        println!("{}", elem);
    }
    // 1
    // 222
    // 333
}
本文由作者按照 CC BY 4.0 进行授权