所有权

—— 所有权(Ownership)

欢马劈雪     最近更新时间:2020-08-04 05:37:59

314

在进入正题之前,大家先回忆下一般的编程语言知识。 对于一般的编程语言,通常会先声明一个变量,然后初始化它。 例如在C语言中:

int* foo() {
    int a;          // 变量a的作用域开始
    a = 100;
    char *c = "xyz";   // 变量c的作用域开始
    return &a;
}                   // 变量a和c的作用域结束

尽管可以编译通过,但这是一段非常糟糕的代码,现实中我相信大家都不会这么去写。变量a和c都是局部变量,函数结束后将局部变量a的地址返回,但局部变量a存在栈中,在离开作用域后,局部变量所申请的栈上内存都会被系统回收,从而造成了Dangling Pointer的问题。这是一个非常典型的内存安全问题。很多编程语言都存在类似这样的内存安全问题。再来看变量cc的值是常量字符串,存储于常量区,可能这个函数我们只调用了一次,我们可能不再想使用这个字符串,但xyz只有当整个程序结束后系统才能回收这片内存,这点让程序员是不是也很无奈?

备注:对于xyz,可根据实际情况,通过堆的方式,手动管理(申请和释放)内存。

所以,内存安全和内存管理通常是程序员眼中的两大头疼问题。令人兴奋的是,Rust却不再让你担心内存安全问题,也不用再操心内存管理的麻烦,那Rust是如何做到这一点的?请往下看。

绑定(Binding)

重要:首先必须强调下,准确地说Rust中并没有变量这一概念,而应该称为标识符,目标资源(内存,存放value)绑定到这个标识符

{
    let x: i32;       // 标识符x, 没有绑定任何资源
    let y: i32 = 100; // 标识符y,绑定资源100
}

好了,我们继续看下以下一段Rust代码:

{
    let a: i32;
    println!("{}", a);
}

上面定义了一个i32类型的标识符a,如果你直接println!,你会收到一个error报错:

error: use of possibly uninitialized variable: a

这是因为Rust并不会像其他语言一样可以为变量默认初始化值,Rust明确规定变量的初始值必须由程序员自己决定

正确的做法:

{
    let a: i32;
    a = 100; //必须初始化a
    println!("{}", a);
}

其实,let关键字并不只是声明变量的意思,它还有一层特殊且重要的概念-绑定。通俗的讲,let关键字可以把一个标识符和一段内存区域做“绑定”,绑定后,这段内存就被这个标识符所拥有,这个标识符也成为这段内存的唯一所有者。 所以,a = 100发生了这么几个动作,首先在栈内存上分配一个i32的资源,并填充值100,随后,把这个资源与a做绑定,让a成为资源的所有者(Owner)。

作用域

像C语言一样,Rust通过{}大括号定义作用域:

{
    {
        let a: i32 = 100;
    }
    println!("{}", a);
}

编译后会得到如下error错误:

b.rs:3:20: 3:21 error: unresolved name a [E0425] b.rs:3 println!("{}", a);

像C语言一样,在局部变量离开作用域后,变量随即会被销毁;但不同是,Rust会连同变量绑定的内存,不管是否为常量字符串,连同所有者变量一起被销毁释放。所以上面的例子,a销毁后再次访问a就会提示无法找到变量a的错误。这些所有的一切都是在编译过程中完成的。

移动语义(move)

先看如下代码:

{
    let a: String = String::from("xyz");
    let b = a;
    println!("{}", a);
}

编译后会得到如下的报错:

c.rs:4:20: 4:21 error: use of moved value: a [E0382] c.rs:4 println!("{}", a);

错误的意思是在println中访问了被moved的变量a。那为什么会有这种报错呢?具体含义是什么? 在Rust中,和“绑定”概念相辅相成的另一个机制就是“转移move所有权”,意思是,可以把资源的所有权(ownership)从一个绑定转移(move)成另一个绑定,这个操作同样通过let关键字完成,和绑定不同的是,=两边的左值和右值均为两个标识符:

语法:
    let 标识符A = 标识符B;  // 把“B”绑定资源的所有权转移给“A”

move前后的内存示意如下:

Before move:
a <=> 内存(地址:A,内容:"xyz")
After move:
a
b <=> 内存(地址:A,内容:"xyz")

被move的变量不可以继续被使用。否则提示错误error: use of moved value

这里有些人可能会疑问,move后,如果变量A和变量B离开作用域,所对应的内存会不会造成“Double Free”的问题?答案是否定的,Rust规定,只有资源的所有者销毁后才释放内存,而无论这个资源是否被多次move,同一时刻只有一个owner,所以该资源的内存也只会被free一次。 通过这个机制,就保证了内存安全。是不是觉得很强大?

Copy特性

有读者仿照“move”小节中的例子写了下面一个例子,然后说“a被move后是可以访问的”:

    let a: i32 = 100;
    let b = a;
    println!("{}", a);

编译确实可以通过,输出为100。这是为什么呢,是不是跟move小节里的结论相悖了? 其实不然,这其实是根据变量类型是否实现Copy特性决定的。对于实现Copy特性的变量,在move时会拷贝资源到新内存区域,并把新内存区域的资源bindingb

Before move:
a <=> 内存(地址:A,内容:100)
After move:
a <=> 内存(地址:A,内容:100)
b <=> 内存(地址:B,内容:100)

move前后的ab对应资源内存的地址不同。

在Rust中,基本数据类型(Primitive Types)均实现了Copy特性,包括i8, i16, i32, i64, usize, u8, u16, u32, u64, f32, f64, (), bool, char等等。其他支持Copy的数据类型可以参考官方文档的Copy章节。

浅拷贝与深拷贝

前面例子中move String和i32用法的差异,其实和很多面向对象编程语言中“浅拷贝”和“深拷贝”的区别类似。对于基本数据类型来说,“深拷贝”和“浅拷贝“产生的效果相同。对于引用对象类型来说,”浅拷贝“更像仅仅拷贝了对象的内存地址。 如果我们想实现对String的”深拷贝“怎么办? 可以直接调用String的Clone特性实现对内存的值拷贝而不是简单的地址拷贝。

{
    let a: String = String::from("xyz");
    let b = a.clone();  // <-注意此处的clone
    println!("{}", a);
}

这个时候可以编译通过,并且成功打印"xyz"。

clone后的效果等同如下:

Before move:
a <=> 内存(地址:A,内容:"xyz")
After move:
a <=> 内存(地址:A,内容:"xyz")
b <=> 内存(地址:B,内容:"xyz")
注意,然后a和b对应的资源值相同,但是内存地址并不一样。

可变性

通过上面,我们已经已经了解了变量声明、值绑定、以及移动move语义等等相关知识,但是还没有进行过修改变量值这么简单的操作,在其他语言中看似简单到不值得一提的事却在Rust中暗藏玄机。 按照其他编程语言思维,修改一个变量的值:

let a: i32 = 100;
a = 200;

很抱歉,这么简单的操作依然还会报错:

error: re-assignment of immutable variable a [E0384]

:3 a = 200;

不能对不可变绑定赋值。如果要修改值,必须用关键字mut声明绑定为可变的:

let mut a: i32 = 100;  // 通过关键字mut声明a是可变的
a = 200;

想到“不可变”我们第一时间想到了const常量,但不可变绑定与const常量是完全不同的两种概念;首先,“不可变”准确地应该称为“不可变绑定”,是用来约束绑定行为的,“不可变绑定”后不能通过原“所有者”更改资源内容。

例如:

let a = vec![1, 2, 3];  //不可变绑定, a <=> 内存区域A(1,2,3)
let mut a = a;  //可变绑定, a <=> 内存区域A(1,2,3), 注意此a已非上句a,只是名字一样而已
a.push(4);
println!("{:?}", a);  //打印:[1, 2, 3, 4]

“可变绑定”后,目标内存还是同一块,只不过,可以通过新绑定的a去修改这片内存了。

let mut a: &str = "abc";  //可变绑定, a <=> 内存区域A("abc")
a = "xyz";    //绑定到另一内存区域, a <=> 内存区域B("xyz")
println!("{:?}", a);  //打印:"xyz"

上面这种情况不要混淆了,a = "xyz"表示a绑定目标资源发生了变化。

其实,Rust中也有const常量,常量不存在“绑定”之说,和其他语言的常量含义相同:

const PI:f32 = 3.14;

可变性的目的就是严格区分绑定的可变性,以便编译器可以更好的优化,也提高了内存安全性。

高级Copy特性

在前面的小节有简单了解Copy特性,接下来我们来深入了解下这个特性。 Copy特性定义在标准库std::marker::Copy中:

pub trait Copy: Clone { }

一旦一种类型实现了Copy特性,这就意味着这种类型可以通过的简单的位(bits)拷贝实现拷贝。从前面知识我们知道“绑定”存在move语义(所有权转移),但是,一旦这种类型实现了Copy特性,会先拷贝内容到新内存区域,然后把新内存区域和这个标识符做绑定。

哪些情况下我们自定义的类型(如某个Struct等)可以实现Copy特性? 只要这种类型的属性类型都实现了Copy特性,那么这个类型就可以实现Copy特性。 例如:

struct Foo {  //可实现Copy特性
    a: i32,
    b: bool,
}

struct Bar {  //不可实现Copy特性
    l: Vec<i32>,
}

因为Foo的属性ab的类型i32bool均实现了Copy特性,所以Foo也是可以实现Copy特性的。但对于Bar来说,它的属性lVec<T>类型,这种类型并没有实现Copy特性,所以Bar也是无法实现Copy特性的。

那么我们如何来实现Copy特性呢? 有两种方式可以实现。

  1. 通过derive让Rust编译器自动实现

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

    编译器会自动检查Foo的所有属性是否实现了Copy特性,一旦检查通过,便会为Foo自动实现Copy特性。

  2. 手动实现CloneCopy trait

      #[derive(Debug)]
      struct Foo {
          a: i32,
          b: bool,
      }
      impl Copy for Foo {}
      impl Clone for Foo {
          fn clone(&self) -> Foo {
              Foo{a: self.a, b: self.b}
          }
      }
      fn main() {
          let x = Foo{ a: 100, b: true};
          let mut y = x;
          y.b = false;
    
          println!("{:?}", x);  //打印:Foo { a: 100, b: true }
          println!("{:?}", y);  //打印:Foo { a: 100, b: false }
      }
    

    从结果我们发现let mut y = x后,x并没有因为所有权move而出现不可访问错误。 因为Foo继承了Copy特性和Clone特性,所以例子中我们实现了这两个特性。

高级move

我们从前面的小节了解到,let绑定会发生所有权转移的情况,但ownership转移却因为资源类型是否实现Copy特性而行为不同:

let x: T = something;
let y = x;
  • 类型T没有实现Copy特性:x所有权转移到y
  • 类型T实现了Copy特性:拷贝x所绑定的资源新资源,并把新资源的所有权绑定给yx依然拥有原资源的所有权。
move关键字
展开阅读全文