【Rust学习之旅】认识所有权,引用与借用(四)

668 阅读18分钟

上一期我们学到了函数与控制流,理解了函数与控制在rust中的基础语法,和于我们常用的typescript的区别。这一期开始我们将进入rust中最有趣的部分,认识变量的所有权,什么是引用,什么是借用。

所有权

所有程序都必须管理其运行时使用计算机内存的方式。

  • 垃圾回收机制,在程序运行时有规律地寻找不再使用的内存;如javascript、Go、java等
  • 自主分配和释放内存,C,C++等

Rust 则选择了第三种方式:通过所有权系统管理内存,编译器在编译时会根据一系列的规则进行检查。如果违反了任何这些规则,程序都不能编译。在运行时,所有权系统的任何功能都不会减慢程序。

其实这里我们也可以理解rust 所有权模式是一种特殊的自动垃圾回收方式:内存在拥有它的变量离开作用域后就被自动释放。

这里需要提到两个重要概念 栈(Stack)与堆(Heap),栈和堆都是代码在运行时可供使用的内存,

什么是栈

栈以放入值的顺序存储值并以相反顺序取出值。这也被称作 后进先出last in, first out)。

栈中的所有数据都必须占用已知且固定的大小。

什么是堆

堆是缺乏组织的:当向堆放入数据时,你要请求一定大小的空间。内存分配器(memory allocator)在堆的某处找到一块足够大的空位,把它标记为已使用,并返回一个表示该位置地址的 指针pointer)。

入栈比在堆上分配内存要快,因为(入栈时)分配器无需为存储新数据去搜索内存空间;其位置总是在栈顶。

访问堆上的数据比访问栈上的数据慢,因为必须通过指针来访问。

所有权规则

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

变量作用域

rust的变量作用域,和javascript类似,这里有两个重要的时间点:

  • 当 s 进入作用域 时,它就是有效的。
  • 这一直持续到它 离开作用域 为止。
fn main() {
    {                      // s 在这里无效,它尚未声明
        let s = "hello";   // 从此处起,s 是有效的

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

String 类型

在前面的基础类型中你是否疑惑过,好像没有讲过String类型,只讲过char类型,前面介绍的类型都是已知大小的,可以存储在栈中,并且当离开作用域时被移出栈,

你是否疑惑过前面的可变不是通过mut,来限制的吗,现在为什么又说char不可以变?

举一个例子:

当我们使用&str类型时,其实是直接引用了内存中的一段字节序列,因此&str是不可变的,不允许对其进行修改。例如如下的代码:

let s = "hello world";

s[0] = 'H'; // 编译报错,因为&s是不可变的

编译器会报错,因为s实际上是一个指向内存中字符串”hello world”的指针,而这个字符串是不可变的。前面的mut其实相当于给&str 换了一个引用地址,而实际字符串没有变化

  • String 类型比前面的所有标量数据更复杂
  • 字符串字面量,它们是不可变的
  • String类型,在堆上分配,能够存储在编译时未知大小的文本

String类型与双引号字面量有些相似,因为它们都表示文本字符串。但是,它们之间还是有几个关键的区别:

  • String类型是动态分配的(heap-allocated),而字符串字面量是静态分配的(stack-allocated),即它们存储的位置不同。
  • 字符串字面量的类型是&'static str,而String类型的类型是String。
  • 字符串字面量是不可变的(immutable);String类型是可变的(mutable)。
  • 字符串字面量是编译时常量(compile-time constant),而String类型是运行时变量(runtime variable)。

具体来说,&str 类型通常用于对字符串的读取和处理操作,并比 String 类型更轻量级和高效。而当需要创建和修改字符串时,应该使用 String 类型。例如,当读取用户的输入时可以使用 &str 类型,而当需要在程序中拼接字符串时则可以使用 String 类型。

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

移动

下面的代码可能开起来类似,但是rust底层处理并不是一致的

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

第一种:方式有点类似于我们javascript中的值类型赋值,copy了一份数据

第二种:String是一种存放在堆上的数据,和我们javascript中引用值类型相似,只是修改了指针。

rust没有GC垃圾回收机制,而是在一个变量离开作用域时会,Rust 自动调用 drop 函数并清理变量的堆内存。

第二种方式,两个数据指针指向了同一位置。这就有了一个问题:当 s2 和 s1 离开作用域,他们都会尝试释放相同的内存。这是一个叫做 二次释放double free)的错误,也是之前提到过的内存安全性 bug 之一。两次释放(相同)内存会导致内存污染,它可能会导致潜在的安全漏洞。

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

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

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

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

Rust 永远也不会自动创建数据的 “深拷贝”。因此,任何 自动 的复制可以被认为对运行时性能影响较小。

克隆

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

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

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

拷贝

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

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

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

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

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

那么哪些类型实现了 Copy trait 呢?你可以查看给定类型的文档来确认,不过作为一个通用的规则,任何一组简单标量值的组合都可以实现 Copy,任何不需要分配内存或某种形式资源的类型都可以实现 Copy 。如下是一些 Copy 的类型:

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

所有权与函数

将值传递给函数与给变量赋值的原理相似。向函数传递值可能会移动或者复制,就像赋值语句一样。

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

    takes_ownership(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 = String::from("hello");     // s2 进入作用域

    let s3 = takes_and_gives_back(s2);  // s2 被移动到
                                        // takes_and_gives_back 中,
                                        // 它也将返回值移给 s3
} // 这里,s3 移出作用域并被丢弃。s2 也移出作用域,但已被移走,
  // 所以什么也不会发生。s1 离开作用域并被丢弃

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

    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 并移出给调用的函数
}

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

注意: 我们可以向下面一样,返回一个元祖来返回多个值,就像javascript中返回一个对象一样,和基础数据类型那一期,我们提到了元祖可以像javascript对象一样解构。

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)。

引用与借用

刚才的代码里我们必须将传递的s1返回回去,不然我们在后续的代码中就无法使用了。

引用reference)像一个指针,因为它是一个地址,我们可以由此访问储存于该地址的属于其他变量的数据。 与指针不同,引用确保指向某个特定类型的有效值。

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

看我们新的calculate_length函数不用再通过返回元祖来移动所有权了, 我们传递参数的时候使用&s1,这里的&符号,代表引用一个变量。 它允许你使用值但不获取其所有权。

注意:与使用 & 引用相反的操作是 解引用dereferencing),它使用解引用运算符,*。我们将会在后面的学习遇见

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 离开了作用域。但因为它并不拥有引用值的所有权,
  // 所以什么也不会发生

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

注意:正如变量默认是不可变的,引用也一样。(默认)不允许修改引用的值。

可变引用

当然我们说的是默认行为,就像变量默认不可以变一样,我也也可以加mut来修饰引用,使其变成可变引用。

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

    change(&mut s);
}

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

那这样我们上文的问题不是就会再次发生吗?

两个限制:

不能在同一作用域创建两个可变引用

如果你有一个对该变量的可变引用,你就不能再创建对该变量的引用。这一限制以一种非常小心谨慎的方式允许可变性,防止同一时间对同一数据存在多个可变引用。

这个限制的好处是 Rust 可以在编译时就避免数据竞争。数据竞争data race)类似于竞态条件,它可由这三个行为造成:

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

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

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

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

    let r2 = &mut s;
}
不允许同时存在可变引用与引用

不可变引用的用户可不希望在他们的眼皮底下值就被意外的改变了!然而,多个不可变引用是可以的,因为没有哪个只能读取数据的人有能力影响其他人读取到的数据。

注意一个引用的作用域从声明的地方开始一直持续到最后一次使用为止。例如,因为最后一次使用不可变引用(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);
}

悬垂引用(Dangling References)

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

下面我们看一个悬垂指针的例子:

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

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

    &s
}

这段代码会抛出错误,我们具体分析一下这段代码发生了什么

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,移交所有权

引用的规则

让我们概括一下之前对引用的讨论:

  • 在任意给定时间,要么 只能有一个可变引用,要么 只能有多个不可变引用。
  • 引用必须总是有效的。

刚才上面我们说的都是String类型的引用,我们来看看另一种不同类型的引用:slice。

slice类型

slice 允许你引用集合中一段连续的元素序列,而不用引用整个集合。slice 是一类引用,所以它没有所有权。

官网在这里提出了一个问题:

编写一个函数,该函数接收一个用空格分隔单词的字符串,并返回在该字符串中找到的第一个单词。如果函数在该字符串中并未找到空格,则整个字符串就是一个单词,所以应该返回整个字符串。

简要概括一下,就是我们获取了一个字符串的长度,但这个值无法真实反应,字符串的长度,因为获取到长度了那一刻,已经失去了联系。如下面代码发生的问题

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

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

    let word = first_word(&s); // word 的值为 5

    s.clear(); // 这清空了字符串,使其等于 ""

    // word 在此处的值仍然是 5,
    // 但是没有更多的字符串让我们可以有效地应用数值 5。word 的值现在完全无效!
}

这个程序编译时没有任何错误,而且在调用 s.clear() 之后使用 word 也不会出错。因为 word 与 s 状态完全没有联系,

Rust 为这个问题提供了一个解决方法:字符串 slice。

字符串 slice

字符串 slicestring slice)是 String 中一部分值的引用,

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

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

上面的..操作符号是不是很熟悉,我们前面中for循环中使用过,是一个左开右闭的rang值。

不同于整个 String 的引用,hello 是一个部分 String 的引用,由一个额外的 [0..5] 部分指定。可以使用一个由中括号中的 [starting_index..ending_index] 指定的 range 创建一个 slice,其中 starting_index 是 slice 的第一个位置,ending_index 则是 slice 最后一个位置的后一个值。在其内部,slice 的数据结构存储了 slice 的开始位置和长度,长度对应于 ending_index 减去 starting_index 的值。所以对于 let world = &s[6..11]; 的情况,world 将是一个包含指向 s 索引 6 的指针和长度值 5 的 slice。

注意:对于 Rust 的 .. range 语法,如果想要从索引 0 开始,可以不写两个点号之前的值。换句话说,如下两个语句是相同的:

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

let slice = &s[0..2];
let slice = &s[..2];
}

同理尾部,这样也是相同的

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

let len = s.len();

let slice = &s[3..len];
let slice = &s[3..];
}

还有这样,同时舍弃这两个值来获取整个字符串的 slice

#![allow(unused)]
fn main() {
let s = String::from("hello");

let len = s.len();

let slice = &s[0..len];
let slice = &s[..];
}

现在让我们重新改写改成的程序

fn first_word(s: &String) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

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

    let word = first_word(&s);

    s.clear(); // 错误!

    println!("the first word is: {}", word);
}

这里s.clear就会正确的抛出错误了

回忆一下借用规则,当拥有某值的不可变引用时,就不能再获取一个可变引用。因为 clear 需要清空 String,它尝试获取一个可变引用。在调用 clear 之后的 println! 使用了 word 中的引用,所以这个不可变的引用在此时必须仍然有效。Rust 不允许 clear 中的可变引用和 word 中的不可变引用同时存在,因此编译失败。

字符串字面值就是 slice

还记得我们最开始基础类型中提到的。双引号的字符串字面量了吗?

fn main() {
let s = "Hello, world!";
}

这里 s 的类型是 &str:它是一个指向二进制程序特定位置的 slice。这也就是为什么字符串字面值是不可变的;&str 是一个不可变引用。

字符串 slice 作为参数

这里我们可以再次修改一下,改成的函数

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

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

    // `first_word` 适用于 `String`(的 slice),整体或全部
    let word = first_word(&my_string[0..6]);
    let word = first_word(&my_string[..]);
    // `first_word` 也适用于 `String` 的引用,
    // 这等价于整个 `String` 的 slice
    let word = first_word(&my_string);

    let my_string_literal = "hello world";

    // `first_word` 适用于字符串字面值,整体或全部
    let word = first_word(&my_string_literal[0..6]);
    let word = first_word(&my_string_literal[..]);

    // 因为字符串字面值已经 **是** 字符串 slice 了,
    // 这也是适用的,无需 slice 语法!
    let word = first_word(my_string_literal);
}

其他类型的 slice

字符串 slice,正如你想象的那样,是针对字符串的。不过也有更通用的 slice 类型。考虑一下这个数组

fn main() {
let a = [1, 2, 3, 4, 5];

let slice = &a[1..3];

assert_eq!(slice, &[2, 3]);
}

这个 slice 的类型是 &[i32]。它跟字符串 slice 的工作方式一样,通过存储第一个集合元素的引用和一个集合总长度。你可以对其他所有集合使用这类 slice。

结语

所有权、借用和 slice 这些概念让 Rust 程序在编译时确保内存安全。但拥有数据所有者在离开作用域后自动清除其数据的功能意味着你无须额外编写和调试相关的控制代码。就相当于其他语言的自动垃圾回收机制

这一期,与javascript的区别,特别大,需要好好理解一下。在学习javascript语言时候可能我们都不需要关心这些。

本文正在参加「金石计划」