rust 快速入门——9 所有权

81 阅读19分钟

[!|center] 普若哥们儿

github.com/wu-hongbing…

gitee.com/wuhongbing/…

所有权机制

内存安全

C/C++ 语言最受诟病的是内存的不正确访问引发的内存安全问题,一般有 5 个典型情况:

  • 使用未初始化的内存
  • 对空指针解引用
  • 悬垂指针(使用已经被释放的内存)
  • 缓冲区溢出
  • 非法释放内存(释放未分配的指针或重复释放指针)

这些问题在 C/C++ 中需要开发者非常小心地处理。比如下面这段 C++ 代码,把这五个内存安全错误全部犯一遍:

#include <iostream>

struct Point
{
   int x;
   int y;
};

Point *newPoint (int x, int y)
{
   Point p{x, y};
   return &p; // 悬垂指针,返回p的指针,但是p在返回时已失效
}

int main ()
{
   int values[3] = {1, 2, 3};
   std:: cout << values[3] << std:: endl; // 缓冲区溢出

   Point *p1 = (Point *) malloc (sizeof (Point)); // 使用堆内存
   std:: cout << p1->x << "," << p1->y << std:: endl; // 使用未初始化内存

   Point *p2 = newPoint (10, 10); // 悬垂指针
   delete p2;                    // 非法释放内存

   p1 = NULL;
   std:: cout << p1->x << std:: endl; // 对空指针解引用
   return 0;
}

这个例子能够正常编译,在运行时才会出现问题。

Rust 所有权机制就是为了避免上述问题,以保证内存安全。所有权机制是 Rust 最为与众不同的特性,对 Rust 语言各个部分有着全面而深刻的影响。

所有权概念

计算机程序处理的数据位于某个内存区域,这个内存区域,可以在堆上,可以在栈上,也可以在代码段,还有些内存地址是直接用于 I/O 地址映射的,这些都是内存区域可能存在的位置。

在高级语言中,内存位置要在程序中能被访问,必然就会与一个或多个变量建立关联关系(低级语言如汇编语言,可以直接访问内存地址),也就是说,通过变量就能访问这个内存地址。

Rust 中的所有权就是指变量拥有其所关联的内存区域,或者说变量拥有被其绑定的

所有权规则

Rust 所有权规则:

  • 每一个值在任一时刻有且只有一个 称为其所有者的变量
  • 当变量离开作用域,这个值将被丢弃

Rust 的思路是,编译器在编译时根据所有权规则进行检查,确保只有唯一的变量对某个内存拥有所有权,如果违反了任何这些规则,程序都不能编译。在程序运行时,所有权系统能够通过跟踪变量作用域和生命周期来管理变量所拥有的内存,在变量生命周期结束时自动释放内存,这样,既能保障内存安全,同时不会有性能的损耗。

String 类型

为了演示所有权的规则,我们使用一个复杂的数据类型 String,这是标准库中定义的类型,该类型的变量是一个胖指针,胖指针是一个包含指向堆内存的指针以及堆内存当前容量等信息的数据结构,胖指针大小固定,存储在栈上,指针指向的堆内存存储实际的数据,并且数据长度可变。

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

    s.push_str(", world!"); // push_str() 在字符串后追加字面值

    println!("{}", s); // 将打印 `hello, world!`
}

变量 s 是一个 String 类型的胖指针,从图 1 可以看到,String 类型变量 s 由三部分组成:一个指向存放字符串内容内存的指针 ptr,一个长度 len,和一个容量 capacity,这一组数据存储在栈上。堆上则是存放内容的内存部分。

fb8543d35dfbfc824a3f864ee6a3d0ac.svg

图 1:String 类型的胖指针结构

对于 String 类型,为了支持一个可变、可增长的文本片段,需要在堆上分配一块在编译时未知大小的内存来存放内容。这意味着:

  • 必须在运行时向内存分配器(memory allocator)请求内存。
  • 需要一个当我们处理完 String 时将内存返回给分配器的方法。

第一部分由程序本身完成:当调用 String::from 时,它的实现 (implementation) 请求其所需的内存,这在编程语言中是非常通用的。

然而,第二部分实现起来就各有区别了。在有 垃圾回收garbage collectorGC)的语言中,GC 记录并清除不再使用的内存,而我们并不需要关心它。在大部分没有 GC 的语言中,识别出不再使用的内存并调用代码显式释放就是程序自身的责任了,跟请求内存的时候一样。如果忘记回收了会浪费内存。如果过早回收了,将会出现无效变量,如果重复回收,这也是个 bug,我们需要精确的为一个 allocate 配对一个 free

Rust 采取了一个不同的策略:内存在拥有它的变量离开作用域后就被自动释放。

fn main() {
    {
        let s = String::from("hello"); // 从此处起,s 是有效的

        // 使用 s
    }                                  // 此作用域已结束,
                                       // s 不再有效
}

这是一个将 String 需要的内存返回给分配器的很自然的位置:当 s 离开作用域的时候。当变量离开作用域,Rust 为我们调用 s 对象的一个特殊的方法,这个方法叫做 drop,在这里 String 的标准库作者可以编写释放内存的代码。

C++中析构函数也是类似的思路:在对象离开作用域时,对象的析构函数被自动调用。

变量赋值与所有权

首先要明确:

  • 不论是使用堆内存的变量(比如 String 类型的变量)还是不使用堆内存的变量,任何变量绑定的值都存储栈上,只不过使用堆内存的变量绑定的值还关联着堆内存中的内存区域。
  • let B = A 的含义为将 A 变量的值复制一份再绑定(赋值)给 B。

使用堆内存的变量的所有权转移

根据所有权规则,下面的代码编译错误:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
    println!("s1 = {}, s2 = {}", s1, s2); //编译错误,s1失去所有权,不能使用
}

第 3 行,let s2 = s1s2 的值为 s1 的值的一份拷贝。为了确保内存安全,在 let s2 = s1; 之后,Rust 认为 s1 不再有效,不能再使用,因此 Rust 不需要在 s1 离开作用域后清理任何东西。只有 s2 是有效的,当其离开作用域,它就释放自己的内存。由于只有 s2 是有效的,不会发生堆内存重复释放的问题。

这个过程可以认为堆内存的所有权s1 移动(转移) 到了 s2 中,其过程如图 2 所示。

5ff78944152fb3512d8a7ce134d20281.svg

图 2:String 类型对象所有权转移

如果你在其他语言中听说过术语 浅拷贝shallow copy)和 深拷贝deep copy),那么拷贝指针、长度和容量而不拷贝数据可能听起来像浅拷贝。不过因为 Rust 同时使第一个变量无效了,这个操作被称为 移动move),而不是叫做浅拷贝。

另外,这里还隐含了一个 Rust 的设计选择:Rust 永远也不会自动创建数据的 “深拷贝”。因此,任何 自动 的复制都可以被认为是对运行时性能影响较小的。

使用堆内存的变量克隆

如果我们确实需要深度复制 String 中堆上的数据,而不仅仅是栈上的数据,可以使用一个叫做 clone 的通用函数:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();

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

这段代码能正常运行,并且明确产生图 2 中行为,这里堆上的数据确实被复制了,s1s2 都是有效的。

3daca39caf42696bd032a61c5934fa19.svg

图 3:通过克隆操作 s1s2 都是有效的

只使用栈内存的变量

下例是有效的:

fn main() {
    let x = 5;
    let y = x;

    println!("x = {}, y = {}", x, y);
}

但这段代码似乎与刚刚讲到的内容相矛盾:没有调用 clone,不过 x 依然有效且没有被移动到 y 中。

原因是Rust 有一个 Copy trait ,用在了类似整型这样的存储在栈上的类型上。如果一个类型实现了 Copy trait,那么该类型赋值、传参、函数返回就会使用Copy语义,对应的值会在栈上被按位浅拷贝,产生新的值。所以这里调用 clone 并不会与通常的浅拷贝有什么不同。

Rust 不允许为类型同时实现 Drop trait 和 Copy trait。因为实现了 Copy trait 的数据类型其数据内容全部在栈上,它不需要有针对堆内存的 drop 操作。而实现了 Drop trait 的数据类型的变量在栈上,而实际的数据内容在堆上,若同时实现了 Copy trait,会存在 2 份栈上的数据都指向同一个堆数据,drop 操作时会造成堆内存重复释放的问题。

任何不需要分配堆内存的类型都可以实现 Copy trait,如下是一些 Copy 的类型:

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

函数与所有权

函数参数与所有权转移

Rust 函数调用规则与大多语言一样,采用值传递规则:将实参赋值一份给虚参。因此,函数调用时所有权转移规则与前面讲到的给变量赋值的规则一样,可能会移动或者复制。下例使用注释展示变量何时进入和离开作用域,以及所有权转移情况:

fn main() {
    let s = String::from("hello");  // s 进入作用域

    takes_ownership(s); // s 是String类型,使用堆内存,因此堆内存的所有权移动到函数里 ...
    
	// ... 所以到这里 s 不再有效

    let x = 5; // x 进入作用域

    makes_copy(x); // x 类型是i32,实现了 Copy trait,不发生所有权移动
    
    // 所以在后面可继续使用 x
    
} // 这里,x 先移出了作用域,被销毁;然后是 s ,但因为 s 的所有权已被移走,什么也不需要做

fn takes_ownership(some_string: String) { // some_string 进入作用域
    println!("{}", some_string);
} // 这里,some_string 移出作用域并调用 `drop` 方法,占用的内存被释放,实际上就是 实参 s 被销毁

fn makes_copy(some_integer: i32) { // some_integer 进入作用域
    println!("{}", some_integer);
} // 这里,some_integer 移出作用域并被销毁

返回值所有权转移

返回值也可以转移所有权:

fn main() {
    let s1 = gives_ownership(); // gives_ownership 将返回值所有权转移给 s1
    
    // 这里 s1 有效,可以使用
    
    let s2 = String::from("hello");     // s2 进入作用域
    let s3 = takes_and_gives_back(s2);  // s2 被移动到 takes_and_gives_back 中,它也将返回值移给 s3

	// 这里 s2 失效; s3 有效

} // 这里,s3 移出作用域并被丢弃;s2 也移出作用域,但所有权已被移走,不需要做什么;s1 离开作用域并被丢弃

// gives_ownership 会将返回值所有权移动给调用它的函数
fn gives_ownership() -> String {
    let some_string = String::from("yours"); // 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 并将所有权移出给调用的函数
}

引用与所有权

引用作为函数参数

通过变量的引用访问变量的值不需要获得所有权,不会发生所有权转移。下例 calculate_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 是 String 的引用
    s.len()
} // 这里,s 离开了作用域。但因为它并不拥有引用值的所有权,
  // 所以什么也不会发生

注意我们传递 &s1calculate_length,同时在函数定义中,我们获取 &String 而不是 String,因此允许使用值但不获取其所有权。图 3 展示了一张示意图。

c67eb3d76faefbfeed7e72abc845cf29.svg

图 3:&String s 指向 String s1 示意图

注意:与使用 & 引用相反的操作是 解引用dereferencing),它使用解引用运算符*&s1 语法让我们创建一个 指向s1 的引用,但是并不拥有它。因为并不拥有这个值,所以当引用停止使用时,它所指向的值也不会被丢弃。

同理,函数签名使用 & 来表明参数 s 的类型是一个引用。变量 s 有效的作用域与函数参数的作用域一样,不过当 s 停止使用时并不丢弃引用指向的数据,因为 s 并没有所有权。当函数使用引用而不是实际值作为参数,无需返回值来交还所有权,因为就不曾拥有所有权。

我们将创建一个引用的行为称为 借用borrowing)。正如现实生活中,如果一个人拥有某样东西,你可以从他那里借来。当你使用完毕,必须还回去,我们并不拥有它。

可变引用

正如变量默认是不可变的,引用也一样。(默认)不允许修改引用的值。下例编译错误:

fn main() {
    let s = String::from("hello");

    change(&s); // &s 为 s 的不可变引用
}

fn change(some_string: &String) {
    some_string.push_str(", world"); // 编译错误,不允许通过变量不可变引用修改变量的值
}

修改为 可变引用,编译成功:

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

    change(&mut s);
}

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

可变引用有一个很大的限制:如果有一个对该变量的可变引用,就不能再创建对该变量的不可变引用。这些尝试创建两个 s 的可变引用的代码会编译失败:

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

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

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

这一限制防止同一时间对同一数据存在多个可变引用。新 Rustacean 们经常难以适应这一点,因为大部分语言中变量任何时候都是可变的。这个限制的好处是 Rust 可以在编译时就避免数据竞争。数据竞争data race)类似于竞态条件,它可由这三个行为造成:

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

数据竞争会导致未定义行为,难以在运行时追踪,并且难以诊断和修复;Rust 避免了这种情况的发生,因为它甚至不会编译存在数据竞争的代码!

可以使用大括号来创建一个新的作用域,以允许拥有多个可变引用,只是不能 同时 拥有:

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

    {
        let r1 = &mut s;
    } // r1 在这里离开了作用域,所以我们完全可以创建一个新的引用

    let r2 = &mut s;
}

Rust 不允许在拥有不可变引用的同时拥有可变引用。这些代码会导致编译错误:

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

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

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

不可变引用的用户不希望值被意外的改变!然而,多个不可变引用是可以的,因为没有哪个只能读取数据代码能够影响其他代码读取的数据。

注意一个引用的作用域从声明的地方开始一直持续到最后一次使用为止。例如,因为最后一次使用不可变引用(println!),发生在声明可变引用之前,所以如下代码是可以编译的:

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

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

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

不可变引用 r1r2 的作用域在 println! 最后一次使用之后结束,这也是创建可变引用 r3 的地方。它们的作用域没有重叠,所以代码是可以编译的。编译器可以在作用域结束之前判断不再使用的引用。

[!attention] 在新版编译器中(1.31以后),引用的作用域范围变成了从定义开始,到不再使用结束。因此,下面的代码是可以正确的:

fn main() {
    let mut a = 5u32;
    let b = &mut a; // b是可变引用
    *b = 6u32;
    // 新版编译器中,b的作用域到这里就结束了

    let c = &a; // c是可变引用,因为b的作用域已经结束,所以这里可以使用不可变引用
    println!("c === {:?}", c);
}

悬垂引用(Dangling References)

在具有指针的语言中,很容易通过释放内存时保留指向它的指针而错误地生成一个 悬垂指针dangling pointer),所谓悬垂指针是其指向的内存可能已经被分配给其它持有者。相比之下,在 Rust 中编译器确保引用永远也不会变成悬垂状态:当你拥有一些数据的引用,编译器确保数据不会在其引用之前离开作用域。

下例尝试创建一个悬垂引用,Rust 编译器会发现这个错误:

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

fn dangle() -> &String { // dangle 返回一个字符串的引用

    let s = String::from("hello"); // s 是一个新字符串

    &s // 返回字符串 s 的引用
} // 这里 s 离开作用域并被丢弃。其内存被释放。
  // 危险!

因为 s 是在 dangle 函数内创建的,当 dangle 的代码执行完毕后,s 将被释放。不过我们尝试返回它的引用。这意味着这个引用会指向一个无效的 String,Rust 不会允许我们这么做。

这里的解决方法是直接返回 String

fn main() {
    let string = no_dangle();
}

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

    s
}

这样就没有任何错误了,所有权被移动出去,所以没有值被释放。

print! 宏对参数的引用

但下面的代码中,变量 ab 均被多次使用:

fn main () {
    let a = String:: from ("Hello");
    let b = String:: from ("Hello");

	test (a);    //合法
    test (a);   //非法
    
    print! ("{}", b);   //合法
    print! ("{}", b);   //合法
}

fn test (x:String){
}

第 6 行是非法的,因为变量 a 在第 5 行已经发生了所有权转移,不能再次使用。

从形式上看,第 8 行变量 b 也发生了所有权转移,第 9 行应该不合法,但是程序时正确的,原因是 print! 是宏,宏在编译时展开,展开时自动在参数中添加引用符号&,因此 print! 宏展开后,上述代码类似于:

fn main () {
    let a = String:: from ("Hello");

    my_print (&a);    //合法
    my_print (&a);   //合法
}

因此是合法的。

结构体更新语法中的所有权转移

再前面结构体更新语法中我们提到可以用一个结构体实例初始化另一个实例:

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    let user1 = User {
        email: String::from("someone@example.com"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };

    let user2 = User {
        email: String::from("another@example.com"),
        ..user1
    };

    print!("{:?}", user1.sign_in_count); //正确
    print!("{:?}", user1.email); //正确
    // print!("{:?}", user1.username); //错误
}

结构更新语法就像带有 = 的赋值,它会移动数据的所有权,因此创建 user2 后不能就再使用 user1.username 了,因为 user1username 字段中的 String 被移到 user2 中。

我们给 user2email 赋予新的 String 值,因此 user1.email 可以使用。

另外 user1activesign_in_count 可以使用,因为 activesign_in_count 的类型是实现 Copy trait 的类型。

小结

只使用栈的数据类型不存在所有权转移,因此,所有权的问题只需要关注使用堆内存的数据类型,也可以认为所有权的概念只是针对使用堆内存的变量。如果这样认为,“所有权是变量对其绑定的值的所有权“ 就不准确了,对于 let s = String::from("hello") ,所有权应表述为:变量 s 对绑定的值所引用的堆内存拥有所有权。

所有权似乎也不能说只是针对的是堆中的数据,比如后面讲到闭包时有这样的例子:

fn main () {
    fn test ()-> impl Fn (){
        let color = 5;
        println!("`color`: {:p}",&color);

        return  move|| println! ("`color`: {:p}", &color);
    } // color 离开了作用域,被销毁,但是闭包已经复制了一份

    let x=test ();
    x ();
}

color 是栈类型数据,但是闭包使用了 color,例中必须使用 move 关键字移动 color 的所有权给闭包。如果删除 move 关键字,编译报错:

to force the closure to take ownership of `color` (and any other referenced variables), use the `move` keyword: `move ` rustc [E0373](https://doc.rust-lang.org/error-index.html#E0373)

从编译器给出的说明中的术语可以看出,这个使用闭包的例子中,闭包捕获的栈类型数据发生了所有权移动。但使用 move 关键字的闭包捕获栈类型数据实际上是复制操作,即闭包获取了环境变量的一份拷贝,不存在所有权转移的问题。这从示例的输出的地址不同可以看出:

`color`: 0x1881d1fa94
`color`: 0x1881d1fb44

另外,下例在闭包被调用后,环境中的 color 仍然可以使用,说明并未失去所有权。

fn main() {  
    let color = 5;  
    println!("`color`: {}-{:p}", color, &color);  
  
    let s=move || println!("`color`: {}-{:p}", color, &color);  
    s();  
  
    println!("`color`: {}-{:p}", color, &color);  // color 仍然可以使用!
  
}

看来,准确而严谨地描述所有权概念和规则不容易😓。