Rust所有权(ownership)

1. 前言概述

所有权可以说是Rust中最为独特的一个功能了。正是所有权概念和相关工具的引入,Rust才能够在没有垃圾回收机制的前提下保障内存安全。因此,正确地理解所有权概念及其在Rust中的实现方式,对于所有Rust开发者来说都是非常重要的。本篇的内容会详细的讨论所有权相关功能:借用,切片以及Rust在内存中布局数据的方式。

2. 什么是所有权

所有权的概念本身的含义并不复杂,但作为Rust语言的核心功能,它对语言的其他部分产生了十分深远的影响。

一般来讲,所有的程序都需要管理自己在运行时使用的计算机内存空间。某些使用垃圾回收机制的语言会在运行时定期检查并回收那些没有被继续使用的内存:比如golang,java,这些语言都是标准的带有垃圾回收机制的语言,还有一些语言是需要程序员去手动释放内存的,比如C语言,C++。但是Rust确实另辟蹊径,采用了与众不同的第三种方式:它使用包含特定规则的所有权系统来管理内存,这套规则允许编译器在编译过程中执行检查工作,而不产生任何的运行时的开销。

所有权的概念并不是那么的好理解,对于我自己而言本身也是一件非常新鲜的事物。但是已经是做好了心里准备,而且是抱着一定要学会Rust的想法去学习,一定要掌握所有权并且写出高效安全的Rust代码的。

在讲述所有权之前还是要补充一下堆与栈的知识。

2.1 堆与栈

在许多编程语言中,程序员本身就不需要频繁的考虑栈空间和堆空间的区别。但是对于Rust这种系统级的编程语言而言,一个值被存储在堆上还是栈上会极大的影响到语言的行为,进而影响到我们编写代码时的设计选择。

栈和堆都是代码在运行时可以使用的内存空间,不过它们通常以不同的结构组织而成。栈会以我们放入值的顺序来存储它们,并且以相反的顺序将值取出,熟悉数据结构的同学对这些是再熟悉不过了,也就是所谓的""先进后出"的策略。具象的你可以把栈操作想象成堆放盘子。当你需要放置盘子时,你只能将它们放置在栈的顶部,而你要取出盘子时,正常和安全的操作是从顶部取。从中间和底部去把盘子取出来很不安全,盘子可能会发生意外碎了。用计算机的术语来说就是添加数据这一操作被称为入栈,移除数据则被称为出栈。

所有存储在栈中的数据都必须有一个已知且固定大小的空间。对于那些在编译期无法确定大小的数据,你就只能将它们存储在堆中。堆的空间管理是较为松散的。当你希望将数据放入堆中时,你就可以请求特定大小的空间。操作系统会根据你的请求在堆中找到一块足够大的可用空间。将它们标记为已使用,并把指向这片空间地址的指针返回给我们。这一过程就是所谓的堆分配。它也常常被称为分配。将值压入栈中不叫分配。由于指针的大小是固定的且可以在编译期确定。所以可以将指针存储在栈中。当想要访问指针所指向的具体数据时,可以通过指针指向的地址来访问。

具象化的再举一个例子。我们可以把堆栈的分配的过程想象成是到餐厅聚餐。当你到达餐厅表明自己需要的座位数后,服务员会找到一张足够大的空桌子,并将你们领过去入座。即便这时有小伙伴来迟了,他们也可以通过询问你们就座的位置来找到你们。向栈上推入数据要比我们在堆上进行分配会更有效率些。因为操作系统省去了搜索新数据存储位置的工作;这个位置永远处于栈的顶端。除此之外,操作系统在堆上分配空间时还必须首先找到足够放下对应数据的空间,并进行某些记录工作来协调随后进行的其余分配操作。

由于多了指针跳转的环节,所以访问堆上的数据要慢于访问栈上的数据。一般来说,现代处理器在进行计算的过程中,由于缓存的缘故,指令在内存中跳转的次数越多,性能就越差。继续使用上面的餐厅的例子来做类比。假设现在同时有许多桌的顾客正在等待服务员处理。那么最高效率的处理方式自然是报完一张桌子所有的订单后再接着服务下一张桌子的顾客。而一旦服务员每次在单个桌子前只处理单个订单,那么他就不得不浪费较多的时间往返于不同的桌子之间。由于同样的原因,处理器在操作排布紧密的数据(比如在栈上)时要比操作排布稀疏的数据(比如在堆上)有效率的多。另外,分配命令本身可能也会消耗不少的时钟周期。

许多系统编程语言都需要你记录代码中分配的堆空间。最小化堆上的冗余数据,并及时清理堆上的无用数据以避免消耗空间。而所有权概念则解决了这些问题。一旦熟练的掌握了所有权及其相关工具,就可以将这些问题交给Rust处理,减轻用于思考堆和栈的心智负担。不过,知晓如何使用和管理堆内存可以帮助我们理解所有权存在的意义及其背后的工作原理。

2.2 所有权规则

现在,我们来具体看一下所有权规则。

  • Rust中每一个值都有一个对应的变量作为它的所有者。

  • 在同一时间内,值有且仅有一个所有者。

  • 当所有者离开自己的作用域时,它持有的值就会被释放掉。

2.3 变量作用域

第一个示例代码,我们先来了解下变量的作用域。简单来讲作用域是一个对象在程序中有序的范围。假设有这样一个变量

let s = "hello";

这里的变量s指向了一个字符串,它的值被硬编码到了当前的程序中。变量从声明的位置开始直到当前作用域结束都是有效的。如

{                            // 由于变量s还未被声明,所有它在这里是不可用的。
		let s = "hello";         // 从这里开始变量s变得可用。
		xxxxxxxxxx               // 执行与s相关的操作
}                            // 作用域到这里结束,变量s再次不可用

上面的这段分析有两个重点:

  • s在进入作用域后变得有效。
  • 它会保持自己的有效性直到自己离开作用域为止。

到目前为止,Rust语言变量的有效性与作用域之间的关系跟其他编程语言中的类似。现在,让我们继续在作用域的基础上学习String类型。

2.4 String类型

为了掩饰所有权的相关规则,我们需要一个特别的数据类型,它要比上一章中讲Rust基本数据类型设计的类型更加复杂。之前接触的那些类型会将数据存储在栈上,并在离开自己的作用域时将数据弹出栈空间。我们需要一个存储在堆上的数据类型来研究Rust是如何自动回收这些数据的。

我们将以String类型为例,并将注意力集中到String类型与所有权概念相关的部分。这些部分同样适用于标准库中提供的或者是你自己穿件的其他的复杂的数据类型。之后还是会继续深入的讲解String类型。

上面的代码我们已经知道了什么是字符串变量。他们是那些被硬编码进程序的字符串值。字符串字面量确实是很方便,但它们并不能满足所有需要使用文本的场景。原因之一在于字符串字面量是不可变的。而另一个原因则在于并不是所有字符串的值都能够在编写代码时就确定的,假如我们想要获取用户的输入并保存,应该怎么办呢?为了应对这种情况,Rust提供了第二种字符串类型String。这个类型会在堆上分配到自己需要的存储空间,所以它能够处理在编译时未知大小的文本。你可以调用from函数根据字符串字面量来创建一个String实例:

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

这里的双冒号(::)运算符允许我们调用置于String命名空间下面的特定from函数,而不需要使用类似于String_from这样名字,在后续讲Rust方法的时候会着重讲解这个语法,以及讨论基于模块的命名空间。

上面定义的字符串对象能够被声明为可变的:

let mut s = String::from("hello");
s.push_str(", world"); // push_str()函数向String空间的尾部添加了一段字面量
println("{}",s); //这里会输出完整的hello,world!

或许在此刻你会有一个疑问就是关于为什么String是可变的,而字符串字面量不是?这是因为采用了不同的内存处理方式。

2.5 内存与分配

对于字符串字面量而言,由于我们在编译时就知道其内容,所以这部分硬编码的文本被直接嵌入到了最终的可执行文件中。这就是访问字符串字面量异常高效的原因,而这些性质完全得益于字符串字面量的不可变性。不幸的是,我们没有办法将那些未知大小的文本在编译期统统放入二进制文件中,更何况这些文本的大小还可能随着程序的运行而发生改变。

对于String类型而言,为了支持一个可变的,可增长的文本类型,我们需要在堆上分配一块在编译时未知大小的内存来存放数据。这同时也是意味着:

  • 我们使用的内存是由操作系统在运行时动态分配出来的。
  • 当使用完String时,我们需要通过某种方式来将这些内存归还给操作系统。

这里的第一步由我们,也就是程序的编写者,在调用String::from时完成,这个函数会请求自己需要的内存空间。在大部分编程语言中都有类似的设计:由程序员来发起堆内存的分配请求。

然而,对于不同的编程语言来说,第二步实现起来就各有区别了。在某些拥有垃圾回收(Garbage Collector ,GC)机制的语言中,GC会代替程序员来负责记录并清除那些不再使用的内存。而对于那些没有GC的语言来说,识别不在使用的内存并调用代码显示释放的工作就依然需要由程序员去完成,正如我们请求分配时一样。按照以往的经验来看,正确地完成这些任务往往是十分困难的。假如我们忘记释放内存,那么就会造成内存泄漏:假如我们过早的释放内存,那么就会产生一个非法变量;假如我们重复释放同一块内存,那么就会产生无法预知的后果。为了程序的稳定运行,我们必须严格地将分配和释放操作一一对应起来。

与这些语言不同,Rust提供了另外一套解决方案:内存会自动地在拥有它的变量离开作用域后进行释放。下面的代码类似于示例中的代码,不过我们将字符串字面量换成了String类型:

{
		let s = String::from("hello"); // 从这里开始,变量s变得有效
		xxxxxxxxxxxxxxxx // 执行与s相关的操作
		// 作用域到这里结束,变量s失效
}

审视上面的代码,有一个很适合用来回收内存给操作系统的地方:变量s离开作用域的地方。Rust在变量离开作用域时,会调用一个叫drop的特殊函数。String类型的作者可以在这个函数中编写释放内存的代码。记住,Rust会在作用域结束的地方(即}处)自动调用drop函数。

注意:

在C++中,这种在对象生命周期结束时释放资源的模式有时也被称作资源获取即初始化(Resource Acquisition Is Initialization,RAII)。假如你使用过类似的模式,那么你应该对Rust中的特殊函数drop并不陌生。

这种模式极大地影响了Rust中的许多设计模块,并最终决定了我们现在编写Rust代码的方式。在上面的例子中,这套释放机制看起来也许还算简单,然而一旦把它放在默写更加复杂的环境中,代码呈现出来的行为往往会出乎你的意料,特别是当我们拥有多个指向同一处堆内存的变量时。让我们接着来看一看其中一些可能使用的场景。

2.5.1 变量和数据交互的方式:移动

Rust中的多个变量可以采用一种独特的方式与同一数据进行交互。接下来我们来看一段代码,使用了一个整型作为数据:

let x = 5;
let y = x;

示例中的代码将变量X绑定的整数值重新绑定到变量y上,你也许能够猜到这段代码的执行效果:将整数值5绑定到变量x上:然后创建一个x值的拷贝,并将它绑定到y上。结果我们有了两个变量x和y,它们的值都是5。这正是实际发生的情形,因为整数是已知固定大小的简单值,两个值5会同时被推入当前栈中。

现在让我们下上述代码的String版本:

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

以上这两段代码非常相似,你也许会假设他们的运行能力也是一致的。也就是说,第二行代码可能会生成一个s1值的拷贝,并将它绑定到s2上。不过实际上并非如此。

String的示例代码展示了String的内存布局,它实际上由3部分组成,如下图左侧所示:一个指向存放字符串内容的指针(ptr),一个长度(len)及一个容量(capacity),这部分的数据存放在了栈中。图片右侧显示了字符串存储在堆上的文本内容

image-20200815023430437

绑定到变量s1上,拥有值"hello"的String的内存布局

长度字段被用来记录当前String的文本使用了多少字节的内存。而容量字段则被用来记录String向操作系统总共获取到的内存字节数量。长度和容量之间的区别十分重要,但我们先不去讨论这个问题,简单地忽略内容字段即可。

当我们将s1赋值给s2时,便复制了一次String的数据,这意味着我们复制了它存储在栈上的指针,长度以及容量字段。但需要注意的是,我们没有复制指针指向的堆数据。换句话说,此时的内存布局应该是类似于下图

image-20200815023430437

变量s2在复制了s1的指针,长度及容量后的内存布局

由于Rust不会在复制时深度地复制堆上的数据,所以这里的布局不会像下图所以的那样。

image-20200815023430437

当Rust也复制了堆上的数据时,执行完s2=s1语句后可能产生的布局内存

假如Rust依旧这样的模式去执行赋值,那么当堆上的数据足够大时,类似于s2=s1这样的指令就会造成相当可观的运行时的性能消耗。

前面我们提到过,当一个变量离开当前的作用域时,当Rust会自动调节它的drop函数,并将变量使用的堆内存释放回收。不过,第二张图展示的内存布局里有两个指针指向了同一个地址,这就导致了一个问题:当s2和s1离开自己作用域时,它们会尝试去重复释放相同的内存。这也就是我们之前提到过的内存的错误之一,臭名昭著的二次释放。重复释放内存可能会导致某些正在使用的数据发生损坏,进而产生潜在的安全隐患。

为了确保内存安全,同时避免了复制分配的内存,Rust在这种场景下会简单的将s1废弃,不再视其为一个有效的变量。因此,Rust也不需要在s1离开作用域后清理任何东西。试图在s2创建完毕后使用s1,如下所示会导致编译错误。

let s1 = String::from("hello");
let s2 = s1;
println("{},world!",s1)

为了阻止你使用的无效的引用,Rust会产生类似于下面的错误提示

error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:4:26
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `std::string::String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 |     println!("{},world!",s1);
  |                          ^^ value borrowed here after move

error: aborting due to previous error; 1 warning emitted

假如你在其他语言中接触过浅度拷贝(shallow copy)和深度拷贝(deep copy)这两个术语,那么你也许会将这里复制指针,长度以及容量字段行为视作浅度拷贝。但由于Rust同时使第一个变量无效了,所以我们使用了新的术语移动(move)来描述这一行为,而不再使用浅度拷贝。在上面的示例中我们可以说s1被移动到了s2中。在这个过程中所发生的的操作如下图所示。

https://images-1253546493.cos.ap-shanghai.myqcloud.com/mem_layout_move.jpg

image-20200815054204580

s1变为无效之后的内存布局

这一语义完美的解决了我们的问题!既然只有s2有效,那么也就只有它会在离开自己的作用域时释放空间,所以再也没有二次释放的可能性了。另外,这里还隐含了另外一个设计原则:Rust永远不会自动地创建数据的深度拷贝。因此在Rust中,任何自动的赋值操作都可以被视为高效的。

2.5.2 变量和数据交互的方式: 克隆

都拿你确实系要去深度拷贝String堆上的数据,而不仅仅是栈数据时,就可以使用一个名为clone的方法。后面的篇章中会详细讨论类型方法的语法,但你应该在其他语言中见过类似的东西。

下面是一个实际使用clone方法的例子:

lets s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {},s2 = {}",s1,s2)

这段代码在Rust中完全合法,它显示的生成了第三张图的行为:赋值了堆上的数据。当你看到某处调用了clone时,你就应该知道某些特定的代码将会被执行,而且这些代码可能会相当的消耗资源。你可以很容易的在代码中觉察到一些不同寻常的事情正在发生。

2.5.3 栈上数据的复制

上面的讨论中遗留了一个没有提及的知识点。我们在示例中曾经使用整型编写了如下的合法代码

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

这与我们刚刚在上面说到的内容有些矛盾,即便代码没有调用clone,x在被赋值给y后也依然有效,且没有发生移动现象。

这是因为类似于整型的类型可以在编译时确定自己的大小,并且能够将自己的数据完整地存储在栈中,对于这些值的复制操作永远都是非常快速的。这也同样意味着,在创建变量y之后,我们没有任何理由去阻止变量x继续保持有效。换句话说,对于这些类型而言,深度拷贝与浅度拷贝没有任何区别,调用clone并不会与直接的浅度拷贝有任何行为上的区别。因此,我们完全不惜要在类似的场景中考虑上面的问题。

Rust提供了一个名为Copy的trait,它可以用于整数这类完全存储在栈上的数据类型,啥是trait在后续会有详细的说明。一旦某种类型拥有了Copy这种trait,那么它的变量就可以在赋值给其他变量之后保存可用性。如果一种类型本身或这种类型的任意成员实现了实现了Drop这种trait,那么Rust就不允许其实现Copy这种trait。尝试给某个需要在离开作用域时执行特殊指令的类型实现Copy这种trait会导致编译时错误。

那么究竟哪些类型是Copy的,可以去查一下文档来确定,不过一般来说,任何简单标量的组合类型都可以是Copy的,任何需要分配内存或某种资源的类型都不会是Copy的。下面是一些拥有Copy这种trait的类型:

  • 所有的整数类型,诸如u32
  • 仅拥有两种值(true 和 false)的布尔类型:bool
  • 字符类型:char
  • 所有的浮点类型,诸如f64
  • 如果元组包含的所有字段的类型都是Copy的,那么这个元组也是Copy的。诸如(i32,i32)是Copy的,但是(i32,String)则不是。

2.6 所有权与函数

将值传递给函数在语义上类似于对变量进行赋值。将变量传递给函数将会触发移动或复制,就像是赋值语句一样。

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

    takes_ownership(s); // s的值被移动进了函数,所以它从这里开始不再有效

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

    make_copy(x) //变量x同样被传递进了函数,但由于i32是Copy的,所以我们依然可以在这之后使用x
} // x首先离开作用域,随后是s。

// 但是由于s的值已经发生了移动,所有没有什么特别的事情会发生。

fn takes_ownership(some_string: String){ // some_string进入作用域
println!("{}",some_string);
} // some_string在这里离开作用域,drop函数被自动调用,some_string所占用的内存也就随之被释放了

fn make_copy(some_integer: i32){ // some_integer 进入作用域
    println!("{}",some_integer);
} // some_integer在这里离开作用域,没有发生什么特别的事情

尝试在调用takes_ownership后使用变量s会导致编译时错误。这类静态检查可以使我们免于犯错。你可以尝试在main函数中使用s和x变量,来看一下所有权规则的约束下能够在哪些地方合法的使用他们。

2.7 返回值与作用域

函数在返回值的过程中也会发生所有权的转移。

fn main() {
    let s1 = gives_ownership(); // gives_ownership将它的返回值移动至s1中


    let s2 = String::from("hello"); // s2进入作用域


    let s3 = take_and_gives_back(s2); // s2被移动进函数

    // take_and_gives_back中,而这个函数的返回值又被移动到了变量s3上
} // s3在这里离开作用域并被销毁,由于s2已经移动了,所以它不会在离开作用域时发生任何事情。s1最后离开作用域并被销毁。

fn gives_ownership() -> String{ // gives_ownership会将它的返回值移动至调用它的函数内
    let some_string = String::from("hello"); // some_string进入作用域
    some_string // some_string作为返回值移动至调用函数
}

// take_and_gives_back将取得一个String的所有权并将它作为结果返回
fn take_and_gives_back(a_String: String) -> String {
    // a_String进入作用域
    a_String // a_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针对这类场景提供了一个名为引用的功能。

2.8 引用与借用

返回参数的所有权的示例代码中,由于调用了calculate_length会导致String移动到函数体内部,而我们又希望在调用完毕之后继续使用该String,所以我们不得不使用元组将String作为元素再次返回。

下面的示例重新定义了一个新的calculate_length函数。与之前不同的是,新的函数签名使用了String的引用作为参数而没有直接转移值的所有权。

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作为参数,且在该函数的定义中,我们使用&String替代了String。

这些&代表的就是引用语义,它们允许你在不获取所有权的前提下使用值,如下图的图解

image-20200816060738083

&String s 指向String s1的图解

注意:

与使用&进行引用相反的操作被称为解引用(dereferencing),它使用*作为运算符。解应用的使用场景会在后续的章节补上。

现在,让我们观察下这个函数的调用过程

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

let len = calculate_length(&s1);

这里的&s1语法允许我们在不转移所有权的前提下,创建一个指向s1值的引用。由于引用不持有值的所有权,所以当引用离开当前的作用域时,它指向的值也不会被丢弃。

同理,函数签名中的&用来表明参数s的类型是一个引用。下面的注释给出了更详细的解释:

fn calculate_length(s: &String) -> usize{ // s是一个指向String的引用
s.len()
} // 到这里,s离开作用域。但是由于它并不持有自己所指向值的所有权,所以没有用什么特殊的事情会发生

此处,变量s的有效作用域与其他任何参数一样,唯一不同的是,它不会在离开自己的作用域时销毁其指向的数据,因为它并不拥有该数据的所有权。当一个函数使用引用而不是值本身作为参数时,我们便不需要为了归还所有权而特意去返回值,毕竟在这种情况下,我们根本没有获得所有权。

这种通过引用传递参数给函数的方法也被称为借用(borrowing)。但在现实生活中,假如一个人拥有某种东西,你可以从他那里把东西借过来,但是当你使用完毕时,就必须将东西还回去。

如果我们尝试着修改借用的值又会发生什么?尝试下修改一下代码,这里我先给出结果:无法通过编译

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

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

尝试修改借用的值

编译的结果:

error[E0596]: cannot borrow `*some_string` as mutable, as it is behind a `&` reference
 --> src/main.rs:7:5
  |
6 | fn change(some_string: &String){
  |                        ------- help: consider changing this to be a mutable reference: `&mut std::string::String`
7 |     some_string.push_str("world");
  |     ^^^^^^^^^^^ `some_string` is a `&` reference, so the data it refers to cannot be borrowed as mutable

与变量类似,应用默认也是不可变的。Rust不允许我们去修改引用指向的值。

2.9 可变引用

我们可以通过进行一个小小的调整来修复2.8中的代码示例

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

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

首先,我们需要将变量s声明为mut,即可变的。其实,我们使用&mut来给函数传入一个可变引用,并将函数签名修改为some_string: &mut String来使其可以接受一个可变引用作为参数。

单可变引用在使用上有一个很大的限制:对于特定作用域中的特定数据来说,一次只能声明一个可变引用。以下代码尝试违背这一限制,则会导致编译错误:

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

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

出现的错误如下所示:

error[E0499]: cannot borrow `s` as mutable more than once at a time
 --> src/main.rs:4:14
  |
3 |     let r1 = &mut s;
  |              ------ first mutable borrow occurs here
4 |     let r2 = &mut s;
  |              ^^^^^^ second mutable borrow occurs here
5 |     println!("{},{},{}",s,r1,r2);
  |                           -- first borrow later used here

这个规则使得引用的可变性只能以一种受到严格限制的方式来使用。刚刚接触Rust的开发者会反复的与他们进行斗争,因为大部分的语言都允许你随意修改变量。

但在另外一方面,在Rust中遵循这条限制性规则则可以帮助我们在编译中避免数据竞争。数据竞争(data race)与竞态条件十分类似,它会在指令满足以下3种情形下发生:

  • 两个或两个以上的指针同事访问同一空间
  • 其中至少有一个指针会向空间中写入数据
  • 没有同步数据访问的机制

数据竞争会导致未定义的行为,由于这些未定义的行为往往难以在运行时跟踪,也就使得出现的bug更加难以被诊断和修复。Rust则完全避免了这种情形,因为存在数据竞争的代码连编译检查都无法通过!

与大部分语言类似,我们可以通过或括号来创建一个新的作用域范围,这就使我们可以创建多个可变引用,当然,这些可变引用不会同时存在:

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

{
  let r1 = &mut s;
} // 由于r1在这里离开了作用域,所以我们可以合法地再创建一个可变引用。

  let r2 = &mut s; 

在结合使用可变引用于不可变引用时,还有另外一条类似的限制规则,它会导致下面的代码编译失败:

error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
 --> src/main.rs:5:14
  |
3 |     let r1 = &s; // 没问题
  |              -- immutable borrow occurs here
4 |     let r2 = &s; // 没问题
5 |     let r3 = &mut s; // 错误
  |              ^^^^^^ mutable borrow occurs here
6 |     println!("{},{},{}",r1,r2,r3);
  |                         -- immutable borrow later used here

不知道大家有没有发现,我们不能在拥有不可变引用的同时创建可变引用。听上去有点拗口,像是一句绕口令。不可变引用的用户可不会希望他们眼皮底下的值突然发生变化!不过,同时存在多个不可变引用是合理合法的,对数据的只读操作不会影响到其他读取数据的用户。

尽管这些编译错误会让人感到沮丧,但是要牢记一点:Rust编译器可以为我们提早(在编译时而不是在运行时)暴露那些潜在的bug,并且明确指出出现问题的地方。你不再需要去追踪调试为何数据会在运行时发生了非预期的变化。

2.10 悬垂引用

是用拥有多指针概念的语言会非常容易错误地创建悬垂指针。这类指针指向曾经存在的某处内存地址,但该内存已经被释放掉甚至是被重新分配另作他用了。而在Rust语言中,编译器会确保引用永远不会进入这种悬垂状态。假如我们当前持有某一个数据的引用,那么编译器可以保证这个数据不会在引被销毁前离开自己的作用域。

我们来创建一个悬垂引用,看看Rust是如何在编译期发现这个错误的:

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

fn dangle() -> &String {
    let s = String::from("hello");
    &s
}
rror[E0106]: missing lifetime specifier
 --> src/main.rs:5:16
  |
5 | fn dangle() -> &String {
  |                ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime
  |
5 | fn dangle() -> &'static String {
  |                ^^^^^^^^

这段错误提示信息包含了一个目前还未讲解过的新概念:生命周期。后续会出章节详细讨论。不过即使我们先将生命周期放置不管,这条错误提示信息也准确的指出了代码中的问题:

 this function's return type contains a borrowed value, but there is no value for it to be borrowed from

仔细的看下dangle函数中发生了什么

fn dangle() -> &String { // 返回一个指向String的引用
    let s = String::from("hello"); // s被绑定到新的String上
    &s // 我们将指向s的引用返回给调用者,变量s在这里离开作用域并随之被销毁,它指向的内存自然也不会再有效。一个危险的操作
}

由于变量s创建在函数dangle内,所以它会在dangle执行完毕随之释放。但是,我们的代码依旧尝试返回一个指向s的引用,这个引用指向的是一个无效的String,这可不对!Rust成功地拦截了我们的危险代码。

解决这个问题的方法也很简单,直接返回String就好:

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

这种写法没有任何问题,所有权被转移出函数,自然也就不会涉及释放操作了。

2.11 引用的规则

让我们简要的概括下对引用的讨论:

  • 在任何一段给定的时间里,你要么只能拥有一个可变的引用,要么只能拥有任意数量的不可变引用。
  • 引用总是有效的

接下来着重研究一下引用的另外一种形式:切片

2.12 切片

除了引用,Rust还有另外一种不持有所有权的数据类型:切片(slice)。

切片允许我们引用集合中某一段连续的元素序列,而不是整个集合。

考虑到这样一个小问题;编写一个搜索函数,它接收字符串作为参数,并将字符串中的首个单词作为结果返回。如果字符串中不存在空格,那么就意味着整个字符串是一个单词,直接返回整个字符串作为结果即可。

让我们来看下这个函数的签名该如何设计:

fn first_word(s: &String) -> ?

由于我们不需要获得传入的所有权,所以这个函数first_word采用了&String作为参数。但它应该返回什么?我们还没有一个获取部分字符串的方法。当然,你可以将首个单词结尾处的索引返回给调用者,如示:

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

first_word函数会返回String参数中首个单词结尾处的索引作为结果

这段代码首先使用as_byte①将String转换为字节数组,因为我们的算法需要依次检查String中的字节是否为空格。接着,我们通过iter③方法创建了一个可以遍历字节数组的迭代器。

迭代器后续再详细讨论。目前,我们只需知道iter方法会依次返回集合中的每一个元素即可。随后的enumerate则将iter的每个输出作为元素逐一封装在对应的元组中返回。元组的第一个元素是索引,第二个元素是指向集合中字节的引用。使用enumerate可以较为方便地获得迭代索引。

既然enumerate方法返回的是一个元组,那么我们就可以使用模式匹配来结构他它,就像是Rust中其他使用元组的地方一样。在for循环的遍历语句中,我们指定了一个解构模式,其中i是元组中的索引部分,而&item②则是元组中指向集合元素的引用。由于我们从.iter().enumerate()中获取的是产生引用元素的迭代器,所以我们在模式中使用了&。

在for循环的代码块中,我们使用了字节字面量语法来搜索数组中代表这空格的字节④。这段代码会在搜索到空格时返回当前的位置索引,并在搜索失败时返回传入字符串的长度s.len()。⑤

现在,我们初步实现了期望的功能,它能够成功地搜索并返回字符串中第一个单词结尾处的位置索引。但这里依然存在一个设计上的缺陷。我们将一个usize值作为索引独立地返回给调用者,但这个值脱离了传入的&String的上下文之后便毫无意义。换句话说,由于这个值独立于String而存在,所以在函数返回后,我们就再也无法保证它的有效性了。下面的示例中使用first_word函数演示了这种返回值失效的情形。

fn main() {
    let mut s = String::from("hello world");
    let _word = first_word(&s); // 索引5会被绑定到变量word上
    s.clear(); // 这里的clear方法会清空当前字符串,使之变为""
    // 虽然word依然拥有这5个值,但因为我们用于搜索的字符串发生了改变
    // 所以这个索引也就没有任何意义了,word到这里变失去了有效性。
}

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

保存first_word产生的返回值并改变其中String的内容

上面的程序编译没有任何问题,即便我们在调用s.clear()之后使用_word变量也是没有问题的。同事由于_word变量本身与s没有任何关联,所以_word的值始终是5。但当我们再次使用5去从变量s中提取单词时,一个bug就出现了:此时s中的内容早已在我们将5存入_word后发生了改变。

这种API的设计方式使我们需要随时关注_word的有效性,确保它与s中的数据是一致的,类似的工作往往相当烦琐且易于出错。这种情况对于另一个函数second_word而言更加明显。这个函数被设计来搜索字符串中的第二个单词,它的签名也许被设计为下面这样:

fn second_word(s:&String) -> (usize,usize){}

现在,我们需要同时维护起始和结束两个位置的索引,这两个值基于数据的某个特定状态计算而来,却没有跟数据产生任何程度上的联系。于是我们有了3个彼此不相关的变量需要被同步,这可不妙。

幸运的是,Rust为这个问题提供了解决方案:字符串切片。

2.13 字符串切片

字符串切片是指向String对象中某个连续部分的引用,它的使用方法如下所示

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

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

这里的语法与创建指向整个String对象的引用有些类似,但不同的是新的语法在结尾的地方多出了一段[0..5]。这段额外的声明告诉编译器我们正在创建一个String的切片引用,而不是对整个字符串本身的引用。

我们可以在一对方括号中指定切片的范围区间[starting_index,ending_index],其中starting_index是切片起始位置的索引值,ending_index是切片终止位置的下一个索引值。切片数据结构在内部存储了指向起始位置的引用和一个描述切片长度的字段,这个描述切片长度的字段等价于ending_index减去starting_index。所以在上面实例的①中,world是一个指向变量s第七个字节并且长度为5的切片。

image-20200822101453486

指向String对象中某个连续部分的字符串切片

Rust的范围语法..有一个小小的语法糖:当你希望范围从第一个元素(也就是索引值为0的元素)开始时,则可以省略两个点号之前的值。换句话说,下面两个创建切片的表达式是等价的:

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

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

同样地,假如你的切片想要包含String中的最后一个字节,你也可以省略双点号之后的值。下面的切片表达式依然是等价的:

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

let len = s.len();

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

let slice = &s[3..];

你甚至可以同时省略首位的两个值,来创建一个指向整个字符串所有字节的切片:

let s = String::from("hello");
let len = s.len();
let slice = &s[0..len];
let slice = &s[..];

字符串切片的边界必须位于有效的UTF-8字符边界内。尝试从一个多字节字符的中间位置创建字符串切片会导致运行时错误。为了将问题简化,现在使用ASCII字符集,至于UTF-8后续再做讨论。

基于上述的知识,让我们开始重构first_word函数。该函数可以返回一个切片作为结果。字符串切片的类型写作&str:

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[..]
}

这个新函数中搜索首个单词索引的方式类似于示例 first_word函数会返回String参数中首个单词结尾处的索引作为结果中的代码。一旦搜索成功,就返回一个从首字符开始带这个索引位置结束的字符串切片。

调用新的first_word函数会返回一个与底层数据紧密联系的切片作为结果,它由指向起始位置的引用和描述元素长度的字段组成。

当然我们也可以以同样的方式重构second_word函数

fn second_word(s: &String) -> &str{}

由于编译器会确保指向String的引用持续有效,所以我们新设计的接口变得更加健壮直观了。还记得在示例 保存first_word产生的返回值并改变其中String的内容中故意构造出的错误嘛?这段代码在搜索完成并保存索引后清空了字符串的内容,这使得我们存储的索引不再有效。它在逻辑上明显是有问题的,却不会触发任何编译错误,这个问题只会在我们使用第一个单词的索引去读取空字符串暴露出来。切片的引入使我们可以在开发早期快速地发现此类错误。示例保存first_word产生的返回值并改变其中String的内容中,新的first_word函数在编译时会抛出一个错误,尝试运行以下代码:

fn main() {
    let mut s = String::from("hello world");
    let _word = first_word(&s);
    s.clear();  //错误
    println!("the first word is: {}",_word);
}

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[..]
}

编译结果

error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
 --> src/main.rs:4:5
  |
3 |     let _word = first_word(&s);
  |                            -- immutable borrow occurs here
4 |     s.clear(); 
  |     ^^^^^^^^^ mutable borrow occurs here
5 |     println!("the first word is: {}",_word);
  |                                      ----- immutable borrow later used here

回忆一下借用的规则,当我们拥有了某个变量的不可变引用时,我们就无法同时取得该变量的可变引用。由于clear需要截断当前的String实例,所以调用clear需要传入一个可变引用。这就是编译失败的原因。Rust不仅使我们的API更加易用,它还在编译过程中帮助我们避免了此类错误。

2.13.1 字符串字面量就是切片

还记得Rust字符串字面量被直接存储在了二进制程序中吗?在学习了切片之后,我们现在可以更恰当地理解字符串字面量了:

let s = "Hello,World!";

在这里,变量s的类型其实就是&str,它是一个指向二进制程序特定位置的切片。正是由于&str是一个不可变的引用,所以字符串字面量自然才是不可变的。

2.13.2 将字符串切片作为参数

既然我们可以分别创建字符串字面量和String的切片,那么就能够进一步优化first_word函数的接口,下面就是它目前的签名:

fn first_word(s: &String) -> &str{}

比较有经验的Rust开发者往往会采用下面的写法,这种改进后的签名使函数可以同时处理String与&str;

fn first_word(s: &str) -> &str{}

使用字符串切片作为参数s的类型来改进first_word函数

当你持有字符串切片时,你可以直接调用这个函数。而当你持有String时,你可以创建一个完整String的切片来作为参数。在定义函数时使用字符串切片来代替字符串引用会使我们的API更加通用,且不会损失任何功能,尝试运行以下代码:

fn main() {
let my_string = String::from("hello world");
// first_word可以接收String对象的切片作为参数
let word = first_word(&my_string[..]);

let my_string_literal = "hello world";

// first_word可以接收字符串字面量的切片作为参数

let word = first_word(&my_string_literal[..]);

//由于字符串字面量本身就是切片,所以我们可以在这里直接将它传入函数
// 而不需要使用额外的切片语法
let word = first_word(my_string_literal);
}

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[..]
}

2.14 其他类型切片

从名字上可以看出来,字符串切片是专门用于字符串的。但实际上,Rust还有其他更加通用的切片类型,以下面数组为例:

let a = [1,2,3,4,5];

就像我们想要引用字符串的某个部分一样,你也可能希望引用数组的某个部分。这是,我们可以这样做

let a = [1,2,3,4,5];

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

这里的切片类型是&[i32],它在内部存储了一个指向起始元素的引用及长度,这与字符串切片的工作机制完全一样。你将在各种各样的集合中接触到此类切片,而我们会在后续的学习中再来介绍哪些常用的集合。

3. 总结

所有权,借用和切片的概念是Rust可以在编译时保证内存安全的关键所在。像其他系统语言一样,Rust语言给与了程序员完善的内存使用控制能力。除此之外,借助于本章的学习,Rust还能够自动清除那些所有者离开了作用域的数据。这极大地减轻了使用者的心智负担,也不需要专门去编写销毁代码和测试代码。所有权影响了Rust中大部分功能的运作机制,有关这些概念的深入讨论会在后续展开。下一章将会一起来学习Rust如何使用struct来组装不同的数据。

posted @ 2021-02-09 23:38  ttlv  阅读(1029)  评论(0编辑  收藏  举报