基础篇-所有权

  • 木头
  • 更新于 2023-02-17 10:59
  • 阅读 1701

所有权是Rust的重中之重,必须理解并掌握其知识点

所有权(系统)是Rust 最为与众不同的特性,对语言的其他部分有着深刻含义。它让 Rust 无需垃圾回收(garbage collector)即可保障内存安全,因此理解Rust 中所有权如何工作是十分重要的。

什么是所有权

Rust 的核心功能(之一)是 所有权(ownership)。虽然该功能很容易解释,但它对语言的其他部分有着深刻的影响。

所有程序都必须管理其运行时使用计算机内存的方式。一些语言中具有垃圾回收机制,在程序运行时有规律地寻找不再使用的内存;在另一些语言中,程序员必须亲自分配和释放内存。Rust 则选择了第三种方式:通过所有权系统管理内存,编译器在编译时会根据一系列的规则进行检查。如果违反了任何这些规则,程序都不能编译。在运行时,所有权系统的任何功能都不会减慢程序。

因为所有权对很多程序员来说都是一个新概念,需要一些时间来适应。好消息是随着你对Rust 和所有权系统的规则越来越有经验,你就越能自然地编写出安全和高效的代码。

当你理解了所有权,你将有一个坚实的基础来理解那些使 Rust 独特的功能。

所有权规则

首先,让我们看一下所有权的规则,请谨记这些规则:

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

作用域

在所有权的第一个例子中,我们看看一些变量的作用域(scope)。作用域是一个项(item)在程序中有效的范围。假设有这样一个变量:

let s = "hello";

变量s 绑定到了一个字符串字面值,这个字符串值是硬编码进程序代码中的。这个变量从声明的点开始直到当前 作用域 结束时都是有效的。下面示例中的注释标明了变量 s在何处是有效的。

fn main() {
    {
        // s 在这里无效,它尚未声明
        let s = "hello"; // 从此处起,s 是有效的

        // 使用 s
        println!("{}", s);
    } // 此作用域已结束,s 不再有效

    println!("{}", s); //报错,s 在这里无效
}

换句话说,这里有两个重要的时间点:

  • s 进入作用域时,它就是有效的。
  • 这一直持续到它 离开作用域 为止。

C语言一样,在局部变量离开作用域后,变量随即会被销毁;但不同是,Rust会连同变量绑定的内存,不管是否为常量字符串,连同所有者变量一起被销毁释放

字符串

Rust 里面有两种字符串类型。 strString

&str

str 类型基本上不怎么使用,通常使用 &str 类型,这是一种固定大小的字符串类型。 常见的的字符串字面值就是 &'static str 类型。这是一种带有 'static 生命周期的 &str 类型,如下:

// 字符串字面值
let hello = "hello, world!";

// 附带显式类型标识
let hello: &'static str = "hello, world!";

String

String类型是一个不定长的字符串,String这个类型管理被分配到堆上的数据,所以能够存储在编译时未知大小的文本。可以使用 from函数基于字符串字面值来创建 String,如下:

let s = String::from("hello");

这两个冒号:: 是运算符,允许将特定的from函数置于 String 类型的命名空间(namespace)下,而不需要使用类似 string_from这样的名字。::运算符后面会讲解。

可以 修改此类字符串:

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

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

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

为什么 String 可变而字面值却不行呢?区别在于两个类型对内存的处理上。

内存与分配

就字符串字面值来说,我们在编译时就知道其内容,所以文本被直接硬编码进最终的可执行文件中。这使得字符串字面值快速且高效。不过这些特性都只得益于字符串字面值的不可变性。不幸的是,我们不能为了每一个在编译时大小未知的文本而将一块内存放入二进制文件中,并且它的大小还可能随着程序运行而改变。

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

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

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

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

Rust 采取了一个不同的策略:内存在拥有它的变量离开作用域后就被自动释放。下面示例中作用域例子的一个使用 String而不是字符串字面值的版本:

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

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

这是一个将 String 需要的内存返回给分配器的很自然的位置:当 s 离开作用域的时候。当变量离开作用域,Rust 为我们调用一个特殊的函数。这个函数叫做 drop,在这里 String 的作者可以放置释放内存的代码。Rust 在结尾的 } 处自动调用 drop

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

Rust中,多个变量可以采取不同的方式与同一数据进行交互。示例中一个使用整型的例子将变量x的整数值赋给 y

    let x = 5;
    let y = x;

我们大致可以猜到这在干什么:“将5 绑定到 x;接着生成一个值 x的拷贝并绑定到 y”。现在有了两个变量,xy,都等于 5。这也正是事实上发生了的,因为整数是有已知固定大小的简单值,所以这两个5被放入了栈中。

现在看看这个 String 版本:

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

这看起来与上面的代码非常类似,所以我们可能会假设他们的运行方式也是类似的:也就是说,第二行可能会生成一个 s1 的拷贝并绑定到 s2 上。不过,事实上并不完全是这样。

看看下图了解 String 的底层会发生什么。String 由三部分组成,如图左侧所示:一个指向存放字符串内容内存的指针,一个长度,和一个容量。这一组数据存储在栈上。右侧则是堆上存放内容的内存部分。 image.png

长度表示 String 的内容当前使用了多少字节的内存。容量是 String 从分配器总共获取了多少字节的内存。长度与容量的区别是很重要的,不过在当前上下文中并不重要,所以现在可以忽略容量。

当我们将 s1 赋值给 s2String 的数据被复制了,这意味着我们从栈上拷贝了它的指针、长度和容量。我们并没有复制指针指向的堆上数据。换句话说,内存中数据的表现如图所示。 image.png

之前我们提到过当变量离开作用域后,Rust自动调用 drop 函数并清理变量的堆内存。不过上图展示了两个数据指针指向了同一位置。这就有了一个问题:当 s2s1 离开作用域,他们都会尝试释放相同的内存。这是一个叫做 二次释放(double free)的错误,也是之前提到过的内存安全性 bug 之一。两次释放(相同)内存会导致内存污染,它可能会导致潜在的安全漏洞。

为了确保内存安全,在let s2 = s1; 之后,Rust 认为 s1 不再有效,因此 Rust不需要在 s1 离开作用域后清理任何东西。看看在 s2被创建之后尝试使用 s1 会发生什么;这段代码不能运行:

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

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

你会得到一个类似如下的错误,因为 Rust 禁止你使用无效的引用。

$ cargo run
   Compiling variables v0.1.0 (/projects/variables)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:6:26
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
...
6 |     println!("s1 is {}", s1);
  |                          ^^ value borrowed here after move
  |
  = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let s2 = s1.clone();
  |                ++++++++

For more information about this error, try `rustc --explain E0382`.
error: could not compile `variables` due to previous error

如果你在其他语言中听说过术语 浅拷贝(shallow copy)深拷贝(deep copy),那么拷贝指针、长度和容量而不拷贝数据可能听起来像浅拷贝。不过因为 Rust 同时使第一个变量无效了,这个操作被称为移动(move),而不是叫做浅拷贝。上面的例子可以解读为 s1移动 到了 s2 中。那么具体发生了什么,如图: image.png

这样就解决了我们的问题!因为只有 s2 是有效的,当其离开作用域,它就释放自己的内存。

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

如果我们 确实 需要深度复制 String 中堆上的数据,而不仅仅是栈上的数据,可以使用一个叫做 clone 的通用函数。细心的同学其实已经发现在上面的报错Rust已经推荐我们使用clone

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

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

这段代码能正常运行,这里堆上的数据 确实 被复制了:

$ cargo run
   Compiling variables v0.1.0 (/projects/variables)
    Finished dev [unoptimized + debuginfo] target(s) in 0.47s
     Running `target/debug/variables`
s2 is hello
s1 is hello

当出现 clone 调用时,你知道一些特定的代码被执行而且这些代码可能相当消耗资源。你很容易察觉到一些不寻常的事情正在发生。

只在栈上的数据:拷贝

这里还有一个没有提到的小窍门。这些代码使用了整型并且是有效的:

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

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

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

原因是像整型这样的在编译时已知大小的类型被整个存储在栈上,所以拷贝其实际的值是快速的。这意味着没有理由在创建变量 y 后使 x 无效。换句话说,这里没有深浅拷贝的区别,所以这里调用 clone 并不会与通常的浅拷贝有什么不同,我们可以不用管它。

Rust 有一个叫做 Copy trait 的特殊注解,可以用在类似整型这样的存储在栈上的类型上(后面将会详细讲解 trait)。如果一个类型实现了 Copy trait,那么一个旧的变量在将其赋值给其他变量后仍然可用。

那么哪些类型实现了Copy trait 呢?任何一组简单标量值的组合都可以实现 Copy,任何不需要分配内存或某种形式资源的类型都可以实现 Copy 。如下是一些 Copy 的类型:

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

所有权与函数

将值传递给函数与给变量赋值的原理相似。向函数传递值可能会移动或者复制,就像赋值语句一样。使用注释展示变量何时进入和离开作用域:

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

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

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

    makes_copy(x); // x 应该移动函数里,
                   // 但 i32 是 Copy 的,
                   // 所以在后面可继续使用 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 移出作用域。没有特殊之处

返回值与作用域

返回值也可以转移所有权。示例展示了一个返回了某些值的示例:

fn main() {
    let s1 = gives_ownership(); // gives_ownership 将返回值
                                // 转移给 s1

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

} // 这里,s2 移出作用域并被丢弃。s1 也移出作用域,但已被移走,所以什么也不会发生

fn takes_and_gives_back(a_string: String) -> String {
    // a_string 进入作用域

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

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

    let some_string = String::from("张三"); // some_string 进入作用域。

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

变量的所有权总是遵循相同的模式:将值赋给另一个变量时移动它。当持有堆中数据值的变量离开作用域时,其值将通过 drop 被清理掉,除非数据被移动为另一个变量所有。

虽然这样是可以的,但是在每一个函数中都获取所有权并接着返回所有权有些啰嗦。如果我们想要函数使用一个值但不获取所有权该怎么办呢?如果我们还要接着使用它的话,每次都传进去再返回来就有点烦人了,除此之外,我们也可能想返回函数体中产生的一些数据。我们可以使用元组来返回多个值:

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(); // len() 返回字符串的长度

    (s, length)
}

但是这有点形式主义,而且这种场景很常见。Rust 对此提供了一个不用获取所有权就可以使用值的功能,叫做 引用(references)下一章详细讨论。

  • 原创
  • 学分: 4
  • 分类: Rust
  • 标签:
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。
127 订阅 31 篇文章

0 条评论

请先 登录 后评论
木头
木头
0xC020...10cf
江湖只有他的大名,没有他的介绍。