Richard's Blog

Rust中的所有权规则(Ownership)

字数统计: 2.6k阅读时长: 9 min
2019/08/13 Share

一直听说Rust性能牛逼,无GC但可以自动内存管理。一直听说Rust有特殊的内存管理规则,而且是一款系统语言,一直想学一款低级语言,所以就选了Rust。

看到Rust的Ownership相关内容,都说这部分不容易搞懂,记录一下。

三个原则

  • 在Rust中的所有数据都有一个变量叫owner(所有者)
  • 同一时间只存在一个owner
  • 当执行的代码超出了owner的作用范围数据就会被drop掉

移动(move)

在需要内存管理的语言中,申请(alloc)一次内存就必须对应的释放(free)一次,如果这两个操作不是一一对应的情况就会出现问题。
在Rust中释放的动作是通过代码执行超出数据的作用域时对数据进行drop操作实现的,但是会出来以下情况,s1,s2共同持有同一个指针,当作用域结束时,如果没有其他规则,s1和s2的指针都需要向数据执行一次drop,这样会对同一块数据释放两次。

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

println!("{}, world!", s1);

以上代码是无法编译成功的。

在Rust中规定,像这样的操作会使s1无效,被称为s1的指针引用被移动到了s2上。这样在作用域中只有s2是有效变量,在作用域结束后也只有s2会执行drop操作。

复制

在Rust中移动相当于别的语言中的‘浅拷贝’,当需要进行别的语言中的‘深拷贝’的行为时,可以调用一个通用的方法clone

1
2
3
4
let s1 = String::from("hello");
let s2 = s1.clone();

println!("s1 = {}, s2 = {}", s1, s2);

这个方法是确实的将堆上的数据复制为两份,所以此时s1和s2都可以使用。

原始数据类型因为有具体的空间和值,且被存储在栈上,每次都是执行复制操作。

方法中的所有权

当方法存在非原始数据类型时,数据传入方法的时候会进行移动复制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
fn main() {
let s = String::from("hello"); // s 在作用域中

takes_ownership(s); // s 的值移动到了方法中
// ... 然后就失效了

let x = 5; // x 在作用域中

makes_copy(x); // x 被移动到方法中
// 但是 i32 执行的是复制, 所以它依然能用
// x 可以继续使用

} // 在此,x和s离开了作用域
// 但是s的值已经被移动了, 所以这里没发生什么事情

fn takes_ownership(some_string: String) { // some_string 进入了作用域
println!("{}", some_string);
} // 在此, some_string 离开了作用域并调用了`drop`操作`,释放了内存

fn makes_copy(some_integer: i32) { // some_integer 进入了作用域
println!("{}", some_integer);
} // 在此, some_integer 离开了作用域. 没发生什么特别的事情

如果在调用takes_ownership方法后尝试使用s变量,会无法编译。因为在Rust的规则中s已经被移动到方法参数上了,s已经失效,在方法中some_string会在方法结束后释放内存,所以原s的指针指向的内存会在方法结束后释放。

所以如果需要在方法调用后想继续在方法外部使用传入的参数数据,其中一个方法是可以把数据再返回到方法外

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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中
} // 在此, s3 离开了作用域并执行了drpp.
// s2 也离开的作用域但是什么都没发生,因为已经‘移动’了
// s1 离开了作用域并执行了drop

fn gives_ownership() -> String { // gives_ownership 将会把它的返回值‘移动’给调用它的作用域中

let some_string = String::from("hello"); // some_string 在作用域中被创建

some_string // some_string 被返回并‘移动‘到调用此方法的作用域中
}

// takes_and_gives_back 将接收一个String并返回一个String
fn takes_and_gives_back(a_string: String) -> String { // a_string 来到作用域中

a_string // a_string 被返回并且’移动‘到调用者的作用域中
}

借取

像上面那样如果想在方法外继续使用参数数据必须把数据再返回出去的作法会让代码变得非常繁琐,所以在Rust中有另一种传参的方式:借取

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()
}

像上面的代码,可以看到s1使用了一种类似C语言中的语法似乎将一个引用传入了calculate_length方法,然后方法返回了s1的长度,并能在calculate_length后继续正常使用s1。

‘&’符号修饰的变量就是这个变量的引用,在Rust中对象的变量是堆地址在栈中所存放的引用,包含对象的指针,容量,长度等数据,变量的‘&’引用只包含栈变量的指针,它不会获得数据的所有权。这样的参数传递方式叫做借取。就像现实中如果某个人有个东西,你可以向他借取,当你使用完后必须还给他。

1
2
3
fn calculate_length(s: &String) -> usize { // s 是一个字符串的引用
s.len()
} // 在此, s 离开的作用域. 但是因为引用没有所有权,所以什么都没发生

向上面这样虽然将数据给了方法内的作用域,但是在方法中,不可以改变s的值。s在方法体内是不可变的。

可变的借取

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

change(&mut s);
}

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

声明一个可变变量,参数传递时加上&mut,方法参数列表接收时使用&mut声明,这样就可以在方法体中修改参数的值,并作用于方法外部。

借取的约束

可变的借取不能赋值给多个变量,以下的代码是错误的

1
2
3
4
5
6
let mut s = String::from("hello");

let r1 = &mut s;
let r2 = &mut s;

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

Rust语言约束借取操作只能在作用域中执行一次,这种约束能在编译时就防止了数据竞争。

以下三种情况会出现数据竞争:

  • 两个或以上的指针同时访问同一块数据
  • 至少一个指针被用于写数据
  • 没有使用数据同步访问机制

当然我们可以使用一些方式绕过这个约束创建多个借取变量

1
2
3
4
5
6
7
8
let mut s = String::from("hello");

{
let r1 = &mut s;

} // r1 离开了作用域, 所以我们可以放心的创建新的引用

let r2 = &mut s;

不可变的引用(借取),和可变的引用(借取)不能同时出现,但是不可变的引用(借取)可以有多个

1
2
3
4
5
6
7
let mut s = String::from("hello");

let r1 = &s; // 没问题
let r2 = &s; // 没问题
let r3 = &mut s; // 编译错误

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

如果理解呢,因为不可变引用(借取)不会改变数据内容,所以无论有多少个同时出现都没问题。

引用(借取)的作用范围是从声明开始到此变量的最后一次使用,所以以下代码是正确的:

1
2
3
4
5
6
7
8
9
let mut s = String::from("hello");

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

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

不可变引用r1和r2的最后一次使用是在可变引用r3的声明之前,所以这段代码是没有问题的。

悬空引用(借取)

在一些带有指针的编程语言中,不注意的话会很容易出现悬空引用,即一个指针变量指向的内存空间实际上已经在其他地方被释放了,这个变量就变成了悬空引用。在Rust中,编译器能保证代码不会出现悬空引用。当你引用了某些数据,编译器会确认引用是在数据的作用域内使用。

以下是会出现悬空引用的代码,无法编译通过

1
2
3
4
5
6
7
8
9
fn main() {
let reference_to_nothing = dangle();
}

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

&s
}

在dangle方法中创建了s数据,然后返回s的引用(借取),当方法完成后,s已经离开作用域,所以s会被执行drop操作,main方法中的reference_to_nothing变量实际获得的是经过drop操作的引用,即悬空引用。这段代码编译的错误帮助提示涉及Rust的另一特性lifetime的内容,以后再做记录。

以下给出能正确编译的代码

1
2
3
4
5
fn no_dangle() -> String {
let s = String::from("hello");

s
}

以上代码no_dangle方法将s的所有权转移到了方法外,所以不会出错。

概述引用(借取)的规则

  • 在任何时候,你可以拥有一个可变引用(借取),或多个不可变引用(借取)
  • 引用(借取)必须是有效的

切片

切片是一组数据的子集,在Rust中,切片是没有所有权的。

1
2
3
4
let s = String::from("hello world");

let hello = &s[0..5];
let world = &s[6..11];

以上代码为创建切片的方式,返回的切片不包含尾下标数据。

切片的行为与引用(借取)类似,编译器会保证切片在最后一次使用之间的过程包含在源数据的作用域中,否则会编译失败。

字符串字面量实际上是字符串切片,是一个不可变引用(借取)。

在不需要数据使用权的方法参数中使用切片会使方法更通用,因为我们能很容易的从数据里创建切片

切片参数及返回值如下表示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fn main() {
let my_string = String::from("hello world");

let word = first_word(&my_string[..]);

let my_string_literal = "hello world";

let word = first_word(&my_string_literal[..]);

let word = first_word(my_string_literal);
}

fn first_word(s: &str) -> &str {
s
}
CATALOG
  1. 1. 三个原则
  2. 2. 移动(move)
  3. 3. 复制
  4. 4. 方法中的所有权
  5. 5. 借取
    1. 5.1. 可变的借取
    2. 5.2. 借取的约束
    3. 5.3. 悬空引用(借取)
    4. 5.4. 概述引用(借取)的规则
  6. 6. 切片