Rust核心设计之Ownership

2020-02-07  本文已影响0人  moneyoverf1ow

Ownership in Rust

背景

目前主流编程语言管理内存的方式不外乎两种--gc或者手动. ownership是rust最独特的特性, 属于第三种解决方案. 它被用来管理内存以及跟踪代码使用的堆上数据, 最大化地减少堆上的重复数据. 由于这种方式在编译期间进行, 因此它的任何特性均不会拖慢程序运行时的性能.


owner的规则

  1. 每个值都有一个变量, 称其为owner
  2. 他们同一时间只有一个owner
  3. 当owner走出scope时, 值将被释放

简单的机制

在owner走出scope时, rust会调用一个特殊的drop函数, 来释放该owner.


实现该机制遇到的复杂场景

ownership受到C++的RAII机制的启发. 看上去原理简单, 但是实现起来还是相当复杂的.
以下是一些具体的场景:

  1. move, 类似浅拷贝
let x = String.from("hello");
let y = x;
// 编译错误
println!("{}", x);

这里类似浅拷贝但又有所区别, 拷贝的是变量本身, 在栈中入了一份一样的变量, 但是指向的值在堆中, 是同一份. 所以问题来了, 假设此时有2个owner, 那么在退出该scope时, 需要释放一个内存两次, 这是不行的. 所以, 回到规则的第二条, owner只能有一个, 这就是move和浅拷贝的区别, 因为它让源变量的ownership传递到新的变量, 使源变量失效. 假使在上面两行后面再加一行对x的访问, 那么会在编译时报错, 提示borrow of moved value: x. (rust永远不会自动对数据使用深拷贝, 这种情况下的拷贝被认为是没什么代价的)

  1. 使用深拷贝
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2);

既然是深拷贝, 那么值自然就是2个, 因此也就不存在违反规则的情况.
在非手动的情况下, Rust避免使用深拷贝是出于对性能的考虑.

  1. 具有Copy特征(其他语言叫接口)的情况
let x = 5;
let y = x;
println!("x = {}, y = {}", x, y);

这里x仍然可用. 所有的整形, 浮点, 布尔, 字符类型以及元组都是有Copy特征的.
为什么这么做呢? 因为这些变量size固定, 在编译期间被存入栈中, 这样做代价很低, 移动一下栈顶指针就可以了, 所以干脆copy一下值.

  1. 函数
let s = String::from("hello");
some_function(s);
let x = 5;
another_function(x);

在语义上, 等同于赋值给变量, 使用move或者copy.

  1. 函数返回值
let s1 = String::from("hello");
let s2 = some_function(s1);
let s3 = another_function(s2);

函数返回值同样可以将ownership传递到赋值的变量.

总结起来其实遵循的规律是一样的, 当值由一个变量转到另一个变量时, 使用move. 指向堆中数据的变量出scope时, 值将会被清除, 除非此值已被move.


引用&借用

当变量传入函数, 如果是move, 则该变量已失效, 那么如何获取原变量的值呢?

使用元组获取原ownership

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

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

这种方式有点麻烦, 写多了肯定会吐
于是有了下面这种:

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, 结构看上去是这样的&s1->s1->data, 因为引用并没有获取这个值的ownership, 因此在引用退出scope时, 它的值不会被drop. 这种引用作为函数参数的方式称为borrowing. 这个名字非常形象, 因为这表示这样东西的所有权并不是我们的, 并且有借就有还.

引用也是有可变和不可变的, 可变就加关键字mut. 这里有一个约束, 同一个scope中, 同一个值, 只能有一个可变引用, 这是为了规避数据竞争(它的条件: 1.有多个指针同时访问相同变量 2.其中至少有一个可以写数据 3.没有同步机制).

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

这是可以的, 因为r1已经退出scope.

let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
let r3 = &mut s;
println!("{}, {}, and {}", r1, r2, r3);

错误, s已被借为不可变量, 不能同时被借为可变量.

let r1 = &s;
let r2 = &s;
println!("{} and {}", r1, r2);
let r3 = &mut s;
println!("{}", r3);

可以, 因为r1, r2已经不再被使用, 他们的scope没有交集.


悬挂指针

编译期间会杜绝这种情况的发生,保证了引用指向的变量一定在scope内。

fn main() {
    let dangling = dangle();
}

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

因为s已经退出scope,返回s的引用是无法通过编译的。


切片

切片没有ownership,因为假设它有,那么这个ownership将被2个owner拥有,即slice与原集合,违反了owner的基本原则。编译器会保证切片引用的变量一定不会退出scope,看个例子。

fn main() {
    let mut s = String::from("hello world");
    let a_slice = slice_of(&s); // 省略函数定义
    s.clear(); // error
    println!("the slice is: {}", a_slice);
}

这里会报出一个编译错误,rustc --explain E0502看一下原因,

This error indicates that you are trying to borrow a variable as mutable when it
has already been borrowed as immutable.

哪里有mutable的借用呢?
看下clear()函数的源码:

#[inline]
#[stable(feature = "rust1", since = "1.0.0")]
pub fn clear(&mut self) {
    self.vec.clear()
}

可以看到入参是自身的可变借用。之前提到过,可变与不变引用不能同时出现的同样的scope中,或者这么说,它们的scope有交集,因为这样会满足数据竞争的条件,这是严格禁止的。因此,从编译层面保证了切片指向的值一定是有效的。

总结

说到底,其核心思想就是将内存占用与变量的生命周期绑定,当变量生命周期结束,内存也将释放。
伟人总是站在伟人的肩膀上,我们总是站在伟人的肩膀上。向伟大的前辈致敬。这种设计非常巧妙,即保证的效率,又方便了开发者。不过凡事都有两面性,编译期间搞的这么6,编译速度比起C++怕是不遑多让:)

参考文献

“The Rust Programming Language”, by Steve Klabnik and Carol Nichols

上一篇下一篇

猜你喜欢

热点阅读