目录
1. 什么是所有权
1.1 堆与栈
1.2 变量作用域
1.3 String类型
1.4 内存和分配
1.5 变量和数据交互的方式
1.5 所有权和函数
1.6 返回值和作用域
所有权 是Rust独有的特性,该设计开创了编程语言中的先河。所有权使得Rust能保证内存的安全,且不需要类似Java、Golang类语言的垃圾回收(GC)机制,理解Rust的所有权对于Rust编程至关重要。该章节着重用介绍与所有权相关的借用(borrowing)、引用(Reference)、切片(Slices),以及Rust在内存中的数据布局。
1. 什么是所有权
所有权是Rust用于管理程序内存的一组规则。程序在运行时,必须管理它们使用计算机内存的方式。
一些编程语言具有(内存)垃圾收集功能:在程序运行时,不断查找并收集不再被使用的内存。
另外一些编程语言中,程序员必须显式地分配和释放内存(C/C++)。
Rust独创了第三种方法:通过一个拥有一组规则的所有权系统,来管理内存,编译器负责在编译时检查的该组规则。如果违反所有权系统中定义的任何规则,程序将无法编译。所有权的特性不会影响程序运行时的性能,这一点非常重要,对比GC机制具有巨大优势。
所有权,对许多程序员来说是一个新概念,所以需要一些时间来适应。 使用Rust所有权的规则的学习曲线非常高, 好消息是,对于Rust和所有权系统的规则,Rust编程经验越丰富,就越容易自然而然地开发出安全而高效的代码。
在本章节中,通过Rust中strings数据结构的一些实例来学习所有权。
1.1 堆与栈
许多编程语言不要求对栈和堆的理解。但在像Rust这样的系统编程语言中,一个值是在堆栈上还是在堆上都影响该Rust的行为,以及编程人员必做的一些选择。所有权的部分将在本章后面的部分中与堆栈和堆的关系进行描述,下面进行一个简短的说明:
栈和堆都是代码可以在运行时使用的内存,但是它们以不同的方式构造的。
-
栈按顺序获取的内存并存储值,并以相反的顺序删除,这被称为后进先出。存储在堆栈上的所有数据必须具有已知的固定大小。编译时大小未知或大小可能发生变化的数据,必须存储在堆中。
-
堆的组织性不如栈,当将数据放到堆上时,需要一定量的空间。内存分配器在堆中找到一个足够大的空点,将其标记为正在使用,并返回一个指针,这是该位置的地址。这个过程称为堆上的分配,有时简写为分配(将值压到栈上并不认为是分配)。因为指向堆的指针是已知的固定大小,所以可以将指针存储在栈上,但是当需要实际数据时,必须进行寻址(依据指针从堆上获取)。
压栈分配要比在堆上分配要快的多,因为不需要分配器来寻找存储新数据的位置,这个位置总是在栈的顶部。相比之下,在堆上分配空间需要更多的工作,因为分配器必须首先找到足够大的空间来保存数据,然后记录分配状态,为下一次分配做准备。
访问堆中的数据要比访问堆栈中的数据慢,因为必须对指针进行寻址。在堆上分配大量空间也十分耗时。
当进行函数调用时,传递给函数的值(包括指向堆上数据的指针)和函数的局部变量被推入栈中。当函数返回时,这些值从栈中弹出。
Rust所有权要解决的问题包括:跟踪代码的哪些部分使用了堆上的哪些数据;最小化堆上重复数据数量;清理堆上未使用的数据以免耗尽空间。一旦理解了所有权,就不再需要经常考虑栈和堆,但了解所有权的主要目的是管理堆数据,可以帮助解释它为什么以这种方式工作。
首先,看下所有权系统的主要规则:
1)Rust中每一个值都有一个变量关联,这个变量称作owner
2)一个值某一时刻只能有一个owner
3) 当owner的作用域结束,对应值的生命周期也结束,会即时被销毁
1.2 变量作用域
作用域(Scope)是Rust程序中变量(等)的作用范围,离开作用域就不再有效。
{ // s is not valid here, it’s not yet declared
let s = "hello"; // s is valid from this point forward
// do stuff with s
} // this scope is now over, and s is no longer valid
如上s变量有两个关键点:s进入作用域时({之后),s有效;离开作用域(}之后),s无效。
1.3 String类型
为了示例Rust所有权,需要一个略复杂点的rust数据结构,该数据结构不能是rust基本类型以至于在栈上自动进行存储和拷贝,需要使用到堆来存放该数据结构的变量。这里使用Rust标准库中提供的String类型,第8章节将详细介绍String类型。使用其他复杂类型,道理也是一样的。
String类型值,在堆上进行分配,且是可变的,大小在编译时不可知。如下:
fn main()
{
let mut s = String::from("hello");
s.push_str(", world!"); // push_str() appends a literal to a String
println!("{}", s); // This will print `hello, world!`
}
String::from中的双冒号,限定了使用String类型命名空间中的from方法。
1.4 内存和分配
字符串常量是不可变的,这是因为在编译期我们即知道其内容,因此被直接硬编码到了最终的可执行文件中。对于大小不确定的变量,无法在二进制中这样做,在运行时,其大小是可以改变的。
对于String类型,为了支持可变的、可增长的字符串,我们需要在堆上分配一定数量的内存量(在编译时未知)来保存内容。这意味着
1)必须在运行时从内存分配器请求内存
2)当String类型变量声明期结束,能够归还其内存给分配器
第(1)部分是由编程完成的:当调用String::from时,它的具体实现请求了所需的内存。这在编程语言中非常普遍。
第(2)部分则是不尽相同的:在具有垃圾收集器(GC)的语言中,GC会跟踪并清理不再使用的内存,编程时不需要考虑归还和释放问题;在大多数没有GC的语言中,编程人员负责识别何时不再使用内存并调用代码显式地释放内存,像请求内存时一致。正确无遗漏地申请和释放一直是一个困难的编程问题。如果忘记了释放,就会造成内存泄漏问题。如果释放太早,就会产生无效变量(指针)。如果我们释放两次或多次,将是个bug。必须对一次分配函数和释放函数进行配对。
Rust采用了截然不同的方法:一旦拥有值所有权的变量离开作用域,内存就被自动释放。 如下:
{
let s = String::from("hello"); // s is valid from this point forward
// do stuff with s
} // this scope is now over, and s is no
// longer valid
上述代码,存在一个很自然的内存释放(归还给内存分配器)的点,即当s离开其作用域时。Rust在这个点,插入了一个特殊函数的调用,该函数被称为drop函数,Rust在右大括号的位置,自动调用drop释放内存。
1.5 变量和数据交互的方式
不同的变量可以与相同内存的值存在不同形式的交互。主要有move和clone两种。
-
移动(move)
如下实例:
let x = 5;
let y = x;
由于x,y是简单的整型变量,具有已知、固定的大小,因此y的赋值实际上是直接拷贝生成了一个新的值(=5)并绑定到了y变量。x,y的值都被压入了栈上。
再如:
let s1 = String::from("hello");
let s2 = s1;
如上s1和s2则完全不同,s1到s2的赋值,并不会拷贝生成一个新的字符串值。如下图来展示String类型在标准库中的一些实现细节:
String类型内部由三部分组成:一个指向字符串内容内存的指针(ptr),一个整型的长度变量(len),一个整型的容量变量(capacity)。该三部分数据是直接在栈上存放的,但ptr则指向了堆上的内存。
具体释义如下:
(1)len标识当前字符串使用的内存字节数量,这里是5;
(2)capacity标识当前ptr指向内存的总大小,这里是可以大于len的,未来后续len扩展考虑。capacity实际也是内存分配器返回的内存大小。
当使用s1赋值给s2时,String内部的数据被拷贝给s2,即:拷贝了ptr指针,拷贝了len长度,拷贝了capacity容量。但ptr指向的内存区并没有进行拷贝,这段内存位于堆上。 拷贝完成,结果如下图:
假如,Rust同时也拷贝ptr指向的堆内存区,则该赋值操作将是非常耗时和性能低下的,最终结果如下图:
前面所述:当变量离开作用域时,Rust自动调用drop函数为该变量清理堆内存。但是图4-2显示了两个数据指针都指向同一个位置。这里有一个问题:当s2和s1超出作用域时,它们都会尝试释放相同的内存。这里就产生了著名的双重释放(double free)问题,这也是前面提到的内存安全错误之一。两次释放会导致内存损坏,可能会导致安全漏洞。
为了保证内存安全,在let s2 = s1的赋值完成后,Rust认为s1已经是无效的。因此Rust在s1离开作用域后,不再需要释放操作(drop)。可以知道,后续对s1的访问都会是非法的。这是区别于大多数变成语言的。 如下:
fn main()
{
let s1 = String::from("hello");
let s2 = s1;
println!("{}, world!", s1);
}
编译该代码,将产生一个非法引用的编译错误:
# cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:5:28
|
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
4 |
5 | println!("{}, world!", s1);
| ^^ value borrowed here after move
For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` due to previous error
在其它编程语言中,有浅拷贝和深拷贝的说法。String中如上的赋值只拷贝了ptr指针,看似属于一种“浅拷贝”。但Rust实际上将赋值者(s1)进行了失效操作,因此这里被称作移动(move)。 因此,可以说:“s1移动到了s2”。实质上发生的的操作如下:
赋值完成后,只有s2字符串是有效的。此外,在设计上,Rust遵循一个原则:永远不会自动进行“深拷贝”数据的赋值操作。 也因此,任何运行时的自动拷贝,都是高效不损害性能的操作。
-
克隆(clone)
如果确实需要“深拷贝”的赋值操作,而非仅仅拷贝栈上内容,Rust可以调用一个通用的clone方法来实现。如下:
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2);
上述代码的结果,恰恰如4-3图所示。调用clone方法,将执行一些会影响运行时性能的操作。
-
纯栈数据的拷贝(copy)
某些类型的赋值,虽然没有调用clone,但两个变量都不会失效。如下:
let x = 5;
let y = x;
println!("x = {}, y = {}", x, y);
原因是:诸如整型等编译时大小可知的简单类型是完全存储在栈上的,因此可以快速复制其值。在赋值创建变量y后,没有必要使x失效。换句话说,这里的深拷贝和浅拷贝是没有区别的。
Rust有一个Copy trait 的特殊注解语法,可以把该注解放在像整数一样存储在栈上的类型上(第10章节将详细讨论trait注解的内容)。
如果一个类型实现了Copy trait ,那么一个变量在赋值给另一个同类型变量后,将仍然有效。
如果一个类型或其任何部分实现了Drop trait,则Rust不允许用Copy trait来注解一个类型。这将产生编译错误,后续章节将介绍如何给自定义类型添加Copy trait注解。
那么都有哪些类型实现了Copy trait呢?
可以查看给定类型的文档来以确定。作为一般规则,任何简单的标量值以及其组成的组类型,都可以实现Copy trait。任何需要分配堆内存或某种形式的资源的类型,都不能实现Copy trait。
下面是实现Copy的一些类型:
-
所有的整数类型,例如u32
-
布尔bool类型,值为true和false
-
所有浮点类型,如f64
-
字符char类型
-
部分元组类型,如果它们只包含也实现Copy trait的类型,例如,(i32, i32)实现Copy。但(i32, String)不实现Copy trait。
1.5 所有权和函数
给函数传递参数的语义,与变量赋值的语义类似。也遵循上述变量和数据的交互方式。给函数传递一个变量,也会移动(move)或拷贝(copy)变量。如下示例:
fn main() {
let s = String::from("hello"); // s作用域开始
takes_ownership(s); // s的值移动到了函数takes_ownership中
// ... s不再有效
let x = 5; // x作用域开始
makes_copy(x); // x copy到了函数makes_copy中
// 由于x是i32简单类型,因此这里是栈Copy
// x随后使用时合法的
} // Here, x goes out of scope, then s. But because s's value was moved,
// nothing special happens.
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);
} // Here, some_integer goes out of scope. Nothing special happens.
在takes_ownership(s);调用后使用s将会产生编译错误,这种静态编译时检查可以防止编程出错。
1.6 返回值和作用域
函数返回值也会转移所有权。 如下示例:
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
// 函数中。
// takes_and_gives_back函数随后又移动其
// 返回值所有权到s3中
} // s1,s3离开作用域,自动调用drop释放内存。
// s2的所有权已经移走,因此这里无操作
fn gives_ownership() -> String { // gives_ownership将移动其返回值
// 的所有权到调用者函数中
let some_string = String::from("yours"); // some_string进入作用域
some_string // some_string返回,并移动所有权到
// 函数外的调用者
}
// 该函数拿到一个string的所有权,并返回并移动走起所有权
fn takes_and_gives_back(a_string: String) -> String { // a_string进入作用域
a_string // a_string返回,所有权移动到调用该函数的函数中
}
变量的所有权遵循相同的模式:变量赋值,并转移值的所有权到另外一个变量中。当包含堆上数据的变量离开作用域时,除非数据的所有权已移动到另一个变量,否则该值将被drop清理。
虽然调用函数获得所有权,函数返回同时返回所有权,这种模式是可以工作的。但从编程上,非常繁琐不友好。尤其,元组类型当函数参数比较多时,多个变量所有权的转移和返回,Rust可以使用元组这样操作,但在编程上非常啰嗦,如下:
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() returns the length of a String
(s, length)
}
那如何能让一个函数使用一个值,且不必要获得所有权呢?
Rust有一个使用值而不转移所有权的特性,称为引用,下一章节介绍。
关于作者:
犇叔,浙江大学计算机科学与技术专业,研究生毕业,而立有余。先后在华为、阿里巴巴和字节跳动,从事技术研发工作,资深研发专家。主要研究领域包括虚拟化、分布式技术和存储系统(包括CPU与计算、GPU异构计算、分布式块存储、分布式数据库等领域)、高性能RDMA网络协议和数据中心应用、Linux内核等方向。
专业方向爱好:数学、科学技术应用
关注犇叔,期望为您带来更多科研领域的知识和产业应用。
内容坚持原创,坚持干货有料。坚持长期创作,关注犇叔不迷路