[注]:本篇文章包含内容较多,请做好准备。
我们在学习编程语言时或者在直接开发过程中,常常会遇到这种编译器报错,“内存错误” 或者 “内存泄漏” 等使用内存不当时导致的编译器报错或运行时出错。这是常见的错误,但也是会导致严重后果的错误。
所有的编程语言都会拥有自己的一套内存管理方式用来管理程序运行时的计算机内存。常见的编程语言会以以下两种方式来管理内存。
(1) 提供了垃圾回收机制 (Garbage Collection
),在程序运行时不断的检测不再被使用的内存。
(2) 提供内存操作的 API
,由程序员手动再合适的位置分配和释放内存,尽可能的去保证自己开发的代码不会存在内存泄露等问题。
Rust
则选择了另外一种方式,通过所有权系统 (Ownership
) 来管理内存,编译器也会根据所有权制定的一系列规则来检查代码。若不符合任何一个规则条件,则程序在编译阶段就不会通过。并且更重要的一点是,所有权系统的任何一个功能都不会拖慢程序的运行速度。
Ownership
是 Rust
的核心功能之一,虽然我们学习和理解起来应该会很容易,但这仍是一个很重要的功能,对于以后的开发过程将会产生重要的影响。
在正式介绍所有权之前,首先让我们先回顾一下栈和堆这两个存储结构。(图示为 X86
架构下程序的 4G
内存空间)
在使用大多数编程语言中,开发者不必特意的去考虑变量到底是存储在栈中还是堆里。但是在 Rust
中变量存储在栈中还是堆中将会很大程度上影响着代码的逻辑行为,因此开发者需要非常清楚的了解存储在这两种结构中的区别。
首先清楚的一点是,我们的代码中的所有变量都会被存储在栈或者堆上。存储在栈或者堆上的变量为的是让程序在运行过程中可以随时的使用。栈和堆的存储结构不同,而被保存的变量也根据其特性的不同分别被保存在这两种不同的存储结构中。
静态分配的变量和一些常量会被保存在栈中,而在程序运行中动态分配的变量则会保存在堆中。
存储在栈结构的数据遵循着后进先出 (last in, first out
) 的规则有序的被保存进栈或从栈中弹出。向栈中增加数据的过程称为进栈 (pushing onto the stack
),从栈中移出数据的过程则成为出栈 (popping off the stack
)。栈中保存的所有数据都是事先清楚其所占的内存大小。
在 Linux
环境下,我们可以通过 ulimit -s
这个命令查看当前系统的栈空间大小(单位:KB
)。
imaginemiracle:~$ ulimit -s
8192
可以看到 Linux
系统的默认设置为 8192
,即默认会给每个用户程序分配 8MB
大小的栈空间。
[注]:当程序尝试定义一个超出 8MB 大小的变量或者定义的所有变量大小总和超过 8MB 时,将会得到操作系统反馈的错误(栈溢出),程序将无法正常执行。(可以编译通过,当然也可通过此命令设置栈大小)
下图演示了的是一个满栈的入栈过程,当新数据需要入栈时,栈指针 (SP
) 减一(递增栈加一),然后让栈指针指向入栈元素,同时栈顶指针也指向栈顶元素,完成入栈。出栈则于入栈循序相反。(下图所示为 X86
架构下的栈空间,使用 ESP
寄存器保存栈顶指针,使用 EBP
寄存器保存栈底指针)
本文以满递减栈作为示例呈现入栈的图示过程,从上图过程可以看出程序中栈空间的内存分配是由系统自动完成,栈还有其它三种类型满递增栈、空递减栈、空递增栈。
满栈和空栈的区别:
满栈:栈顶指针永远指向栈顶元素,即栈顶指针不为空;
空栈:栈顶指针永远指向栈顶元素的下一个元素,即栈顶指针为空。
递增栈和递减栈的区别:
递增栈:栈底元素的地址最小,栈顶元素的地址最大,栈的增长方向是向上的(递增的);
递减栈:栈底元素的地址最大,栈顶元素的地址最小,栈的增长方向是向下的(递减的)。
堆的存储结构相对于栈来讲则要复杂的多,光听名字就可以感觉得到,堆是一种组织性较差的存储结构。在程序中存在编译时大小未知或大小可能改变的数据时,则要将其存储在堆上。
当需要在堆上存放数据时,首先程序需要向操作系统请求指定大小的空间。接着内存分配器 (Memory Allocator
) 在堆中找到一块足够大的内存空间,将其标记为已使用(未使用的内存被标记为空闲),同时将指向该内存空间地址的指针返回给程序。这个过程被称作为堆上的内存分配(Allocating on the heap
),也可简称为 “分配”(Allocating
)。因此存放在堆上的内存空间需要手动申请,再由内存分配器分配给程序使用。下图呈现了常见的一种堆表结构——空闲双向链表(FreeList
),能够直观的感受到堆上的内存结构。
存放数据时堆的耗时更长:
通过上述内容,不难看出,数据在存入栈中(入栈)和数据要存放在堆上时(内存分配)所用时间要快的多。因为入栈操作不需要分配器为了找到合适大小的内存块在内存空间中查找,数据入栈的位置总是在栈顶。相比而言,在堆上存放数据时,则需要做大量的工作,首先内存分配器需要找到一块足够大的内存空间提供给程序使用,紧接着需要标记本次行为所占用的内存块,以便下一次分配内存时的查找操作,避免分配正在使用的内存。
访问数据时堆的耗时更长:
由于栈的结构是一片连续的内存空间,在访问数据时,系统只需要根据专门的寄存器执行出栈的指令即可。在访问堆内存时则必须通过指针访问,程序首先需要获取存放指针的地址,再根据指针指向地址去访问需要使用的内存空间。随着处理器性能的提升,在内存中的跳转的次数越少访存的速度越快(由于增加 Cache
的原因,当然是要在 Cache Hit
的情况下)。简单来讲可以这样分析,访问栈空间只需要一次访存操作,而访问堆内存则至少需要两次访存操作(页表和实际内存)。
当程序掉用一个函数时,向函数传入的参数(可能包含存在堆上数据的指针)和函数的局部变量会被压入栈,当函数执行结束后,这些值会被移出栈。栈空间的数据会在其作用域结束后自动被移出栈空间。
存放在堆上的数据则需要在不使用的时候手动释放,以免造成内存空间的浪费以及内存泄漏的问题,并且要时刻关注哪些数据在堆上,要最大限度的减少存放在堆上的重复数据的数量。这是在其它语言中需要牢牢记住的铁律。而在 Rust
中这些问题将正是所有权系统的工作。因此我们需要清楚的了解所有权所作的工作有哪些,以及所有权系统为什么要这样做,可以帮助我们更好的管理堆上的数据,加强对内存的安全。
我们首先需要牢记以下这些所有权的规则:
Rust
中每一个值都有一个被称为其所有者 (Owner
) 的变量。一个变量的作用域 (Scope
) 表示着该变量在程序中的有效范围。
{ // val 在这里无效,因为它并未被声明
// val 在这里无效,因为它并未被声明
{ // val 在这里无效,因为它并未被声明
let val = 27149; // 从现在开始,val 有效
// 可正常使用 val
} // val 作用域结束,之后的将不能使用此处的 val
// 无法使用 val,超出有效范围
}
此处的示例代码展示了 val
变量在代码中的每一行是否有效,从而看出 val
的作用域范围。大多时候在声明了一个变量,该变量到下一个右大括号 }
之前都是有效的。
了解变量的做由于是很重要的,这将意味着我们在程序中何处可以使用它。在其作用域范围可有效的访问。当离开作用域后将不能正常使用。
为了更加清楚的认识所有权的工作,我们将使用比起之前介绍的数据类型较为复杂的一个数据类型——String
类型。而事实上我们也已经用过该变量,之前在获取用户输入时创建过这样的变量还记得吗?
let str = String::new();
当时是这样创建的 String
类型变量,目的是为了保存用户输入的不确定长度的字符串(编译过程中无法知道具体的大小)。该变量会被分配一段堆上的内存空间。
在此处我们可以直接通过一个已知的字符串(常量)来实例化 String
类型:
let str = String::from("hello");
当我们需要修改 String
类型:
fn main() {
let mut str = String::from("hello");
str.push_str(", world!"); // push_str() 在字符串后追加字面值
println!("{}", str); // 将打印 `hello, world!`
}
我们都清楚的是程序中的 “hello”
是一个常量,是一个不可变的值,但是 String
根据它实例化后,却成了可变的字符串。那么这其中的原因就是对于这两种类型在内存中的操作的不同所导致的。
字符串常量值,在编译时就已经清楚的知道其大小和内容,因此编译器可直接将字符串对应的编码简单的写入可执行文件的只读数据段中(.rodata
)。这将会使得程序在执行期间使用该字符串的值变得非常高效。但高效的访问是有代价的,其代价即为该字符串的值无法被改变。遗憾的是,我们并不能为了高效的访问每一个在编译时大小不确定的文本,而将整块内存写入二进制文件中,并且其变量大小可能会随时改变(二进制文件本身大小无法动态改变)。
那么对于 String
类型而言,为了支持可变性,即改变文本的长度,则需要在堆上分配一块编译时未知大小的内存来存放数据。这将意味着,1: 在程序运行过程中,需要向内存分配器请求内存;2: 在使用结束后或程序结束时需要将内存归还给内存分配器(释放内存)。
这里的第一部分则由程序员手动完成,即当调用 String::new()
或者 String::from()
的时候,这两个函数内部都会向内存分配器 (Memory Allocator
) 请求内存。
那么第二部分,在不同的语言中完成起来就各有不同了。在有垃圾回收器 (Garbage Collertor
, GC
) 的语言中,GC
会在程序运行过程中记录着不再使用的内存并回收,这里将不需要开发人员过多的考虑了。但,在大多数语言中是没有垃圾回收器的,控制和释放不再使用的内存将落在了我们肩上,与请求内存一样。当忘记释放内存,则会导致内存浪费;若过早的释放内存,又会导致之后无法正常使用(访问错误);若重复释放内存,这也会导致内存出错。 因此,在编写代码中,我们需要为每一个分配 (Allocate
) 的内存对应一个释放 (free
) 内存的操作。
Rust
针对这样的问题会这样做:当拥有这段内存的变量 (所有者) 离开其作用域后便会自动释放所占有的这段内存空间。
fn main() {
{
let str = String::from("hello"); // 从此处起,str 是有效的
// 使用 str
} // 此作用域已结束,
// str 不再有效
}
当变量离开作用域时,Rust
会自动调用一个特殊的函数 drop
函数,用来释放变量所对应的内存。这里则在第一个 }
处自动调用了 drop
函数,将 str
所分配的内存空间释放。
目前看起来这种模式下写起代码来会非常简单,但是如果是在更富在的代码场景下,那么则需要我们小心的应对这样的机制(如在多个变量同时使用同一块堆上分配的内存时)。
首先来看一个例子:
let x = 5;
let y = x;
我们可以很清楚的知道这段程序想表达什么。首先声明了一个变量 x
并为其绑定一个值 5
,接着拷贝 x
所对应的值并绑定到新声明的变量 y
上。那么此时将会有两个变量 x
、y
,他们的值都是 5
,但是这两个 5
都会被放入栈中(如下图所示,栈中存储了两个值 5
,分别对应的变量 x
和 y
)。
那么再看 String
类型会是什么样的。
fn main() {
let s1 = String::from("hello");
let s2 = s1;
}
参考之前的 x
和 y
分析此段代码,这里的 s2
是将 s1
变量所对应的内存空间拷贝一份绑定到 s2
上。但遗憾的是,并不是我们想象中简单的这样,只能说分析的终点是相同的,但这里 s2
所用的方法并非如此。
首先我们需要了解一个 String
类型的存储结构,String
类型由三部分组成,第一部分为指向存放字符串具体位置的内存空间的指针;第二部分为字符串的长度;第三部分为该类型的内存大小(单位:Byte
)。(长度和容量大小是有着确确实实的差别的,只不过在此处 String
中单个单元所占用的大小为 1 Byte
,所以导致长度和容量大小恰好相同。)
接下来看看 let s2 = s1;
这条语句具体发生了什么:
当使用 s1
赋值给 s2
时,String
中的数据内容时确确实实的被拷贝了一份,即拷贝了 s1
的指针、长度和容量部分。但并没有拷贝所指向的具体内存空间上的内容。
该图则表示了 s2
连同堆上的内存空间一起拷贝过来。但 Rust
默认不会采取这种方式,而是使用之前的不拷贝堆上内存的方式。想象一下,当堆上的内存空间非常大的时候,拷贝这么一次将会对程序的性能带来非常大的影响,这时候你就会觉得 Rust
的考虑是合理的。
我们已经知道,当变量离开作用域后,Rust
会自动调用 drop
函数来清理不被使用的堆内存空间。那么正如上面的 let s2 = s1;
赋值操作后,s1
和 s2
中的指针同时指向了同一块内存空间,当 s1
和 s2
离开自己的作用域后,都会对这段内存空间做释放操作。即该段内存空间被释放了两次,这将会产生一个叫做二次释放 (Double Free
) 的内存错误,这将会导致存在潜在的内存安全漏洞。
Rust
为了确保内存安全,在 let s2 = s1;
之后,Rust
认为这个时候 s1
将不再有效,因此当 s1
离开作用域后将不做任何内存释放的操作。
来看这段代码示例:
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("{}, world!", s1);
}
我们尝试运行一下这段代码,将会得到这样的错误:
imaginemiracle:ownership$ cargo run
Compiling ownership v0.1.0 (/home/imaginemiracle/Miracle/Code/rust_projects/ownership)
warning: unused variable: `s2`
--> src/main.rs:4:9
|
4 | let s2 = s1;
| ^^ help: if this is intentional, prefix it with an underscore: `_s2`
|
= note: `#[warn(unused_variables)]` on by default
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:6:28
|
2 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |
4 | let s2 = s1;
| -- value moved here
5 |
6 | println!("{}, world!", s1);
| ^^ value borrowed here after move
|
= note: this error originates in the macro `$crate::format_args_nl` (in Nightly builds, run with -Z macro-backtrace for more info)
For more information about this error, try `rustc --explain E0382`.
warning: `ownership` (bin "ownership") generated 1 warning
error: could not compile `ownership` due to previous error; 1 warning emitted
可以看到错误 error[E0382]: borrow of moved value: s1
,尝试使用移动后的变量则会看到这样的错误。Rust
会告诉你,使用一个所有权已经被移动了的变量,这样的行为,だめ♂️!
这种赋值在 Rust
中被称为移动 (Move
)。在其它的编程语言中也许接触过类似的操作浅拷贝 (Shallow Copy
) 和深拷贝 (Deep Copy
)。
C/C++
中浅拷贝,类似下图,p2
只拷贝了 p1
所保存的内存地址,但并未拷贝所指向的具体内存空间。
C/C++
中深拷贝,类似下图,这时 p2
直接将 p1
所指向的内存空间内容拷贝到自己所指向的内存空间中。
而 Rust
中类似于浅拷贝这样的操作则看起来像这样:
浅拷贝后,两个变量在后续都可以被正常的使用,而在 Rust
中在经过这样的类似于浅拷贝赋值后,之前的变量 s1
将视为无效。因此不能简单的讲其视为浅拷贝这样的动作,Rust
将这个过程称为移动 (Move
),移动会将变量的所有权转移给另一个变量(还记得吗?之前提到关于所有权的三个规则中的第二条:这些值,在任一时刻有且只有一个所有者)。
[注]:Rust 永远不会自动创建数据的 “深拷贝”。
如果我们的确需要对变量的内存空间(存放在堆上的数据)做深拷贝这样的操作,那么 Rust
也做好了准备。我们可以使用一个 clone
函数来达到我们的目的。
示例:
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2);
}
运行该代码:
imaginemiracle:ownership$ cargo run
Compiling ownership v0.1.0 (/home/imaginemiracle/Miracle/Code/rust_projects/ownership)
Finished dev [unoptimized + debuginfo] target(s) in 0.47s
Running `target/debug/ownership`
s1 = hello, s2 = hello
我们可以看到,使用过 clone
函数后,s1
也是可以正常使用的。但我们需要知道的一点是,这个函数的确发生了深拷贝,即拷贝了堆上的内存空间,使得两个变量在之后都有效,但同样带来的是资源的消耗和代码执行效率的影响。
[注]:存储在栈上的数据发生的拷贝会调用各自类型的 Copy 函数,都可被视为深拷贝。——请注意,这是重点,要考的!!!
在调用函数时,向函数传递参数的过程也会发生移动或拷贝。
来看一段代码
fn main() {
let s = String::from("hello"); // 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 移出作用域。没有特殊之处
这段代码只有一点需要特别注意,当执行过 take_ownership(s);
后,s
变量将被视为无效变量,即再使用 s
将会报错。由于调用 take_ownership
函数时,将 s
变量的值移动 (Move
) 给了参数 some_string
,因此 s
将不再有效。与之不同的 x
在调用 make_copy
函数时,传入参数过程中调用的是 i32
类型的 Copy
函数,该过程发生的是深拷贝,因此在函数调用之后,也可以正常的使用 x
。
[注]:这段代码虽然简单,但请各位仔细分析,务必完全掌握这里 s 和 x 的所有权问题,即什么时候可以用什么时候不能使用。这很重要!
返回值同样可以转移所有权。顺便反问一下,还有哪些操作可以转移所有权??(可别犯懵哦!)答案是:移动 (Move
)、函数传参的方式以及函数返回值的方式。
来看下面代码:
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 并移出给调用的函数
}
变量的所有权会遵循不变的规则,持有堆中数据值的变量离开其作用域时,Rust
将会调用 drop
函数将这段不再被使用的内存释放掉(Rust
认为的不再使用,非主观)。若希望这段内存不被释放,可以选择使用移动的方式将这段内存的所有权移交给另一个变量所有。
这样做的目的为了在使用过函数后,我们传入变量所对应在堆上的内存不被释放,而可以正常使用。每次都要使用返回值来转移所有权给另一个变量,以这样的方式来保留我们的数据,这样的方式显然有些麻烦。但这样子的忧虑 Rust
当然也是考虑到了,使用一个不用转移所有权就可以访问其指向的内存空间的功能——引用 (References
)。
本篇文章写到这里显然是内容已经很丰腴了,再写过多的内容只会让读者 “消化不良”,因此关于引用的内容将放在下一篇来介绍,还请各位读者能够彻底的理解所有权这一概念。
Boys and Girls!!!
准备好了吗?下一节我们要还有一个小练习要做哦!
不!我还没准备好,让我先回顾一下之前的。
上一篇《Rust 实现摄氏度与华氏度相互转换——Rust语言基础10》
我准备好了,掛かって来い(放马过来)!
下一篇《引用和借用,字符串切片 (slice) 类型 (&str)——Rust语言基础12》
觉得这篇文章对你有帮助的话,就留下一个赞吧v*
请尊重作者,转载还请注明出处!感谢配合~
[作者]: Imagine Miracle
[版权]: 本作品采用知识共享署名-非商业性-相同方式共享 4.0 国际许可协议进行许可。
[本文链接]: https://blog.csdn.net/qq_36393978/article/details/125855001