前端的Rust学习之路(三):所有权系统
关于类型
各编程语言中,类型都大同小异,这部分其实看文档 + 多写就能熟悉。不过 Rust 中存在一些较为特殊的类型,并且对于一些基本类型也会有较细的划分,这一部分我们学完所有权系统和引用与借用之后再学习。我认为先了解 Rust 中保证内存安全这一部分是很有用的,这是 Rust 的灵魂。
原则
Rust 中每一个值(对象)都被一个变量所拥有,该变量被称之为值的所有者
每个值同时只能被一个变量所拥有,也就是同时只允许有一位所有者
当变量离开当前作用域之后,值被废弃(drop),占用的内存会被 Rust 自动清理
作用域
看名字就很熟悉,没错,就是那个作用域,变量在程序中有效的范围,就是作用域。概念跟 js 没有区别,这里不再赘述。
堆栈
栈
栈的特点就是 ”先进后出“,”后进先出“,按照顺序存储值并以相反顺序取出值。存入数据叫进栈
,拿取数据叫 出栈
。
每次进栈,数据都是存放在栈顶,每次出栈,都是从栈顶拿取数据。想象一下我们洗碗的时候把碗按照顺序从下往上重叠,用碗的时候从上到下拿取,不能从中间或者底部拿碗。
栈里的数据是有序的、固定大小的,并且数据都较小。用于存储局部变量、函数参数、堆地址等等。
堆
堆用于存放大小未知或者是可能会变化的数据,与栈不同,堆的数据空间是动态的,允许增长,当我们在堆上存储数据时,会申请一块一定大小的内存空间,并标记为已使用,然后返回一个表示该内存地址的指针,这个过程称之为 分配
(alocating)。
之后,该内存地址会被推入栈中,之后通过栈上的指针来获取数据在内存中的实际位置,从而访问数据。
所以堆的特点就是,数据没有特定的顺序,数据大小不固定,需要通过指针来找到存储位置并访问数据,用于存储程序运行期间动态分配的数据。并且缺乏组织,在一些语言中需要手动申请及释放。
性能区别
由上述可知,不管是内存分配还是释放,栈肯定比堆性能更高,速度更快。因为栈是有序连续的内存块,操作系统为每个线程分配了固定大小的内存空间,当需要给数据分配空间时只需要移动栈指针,这样的操作成本是很低的。同样的,释放内存时也仅仅是移动栈指针。并且栈的分配和释放都是自动的,不需要手动管理。缺点是栈的容量会比较小,远小于堆。
堆的特点是较为灵活,容量较大,理论上容量只会受到系统内存的限制。但是堆的分配和释放较为复杂,分配时需要在内存中找到足够大的内存空间来满足需求,这个过程可能会涉及到查到合适的内存块,整理内存碎片等等,增加了性能开销。释放则需要跟踪内存块的使用需要,以便在不需要时将其整合回收,也增加了额外的开销。
由此看来,在实际业务中,我们对于栈的访问频率肯定是远大于堆的,这也能让程序运行更加高效。
所有权与堆栈
当代码调用一个函数时,传递给函数的参数、指向堆上数据的指针以及函数的局部变量等会被依次压入栈中,函数调用结束时,这些值会被按照相反的顺序依次移除。
堆上的数据缺乏组织,所以跟踪这些数据何时分配和释放是非常重要的,所以Rust 中使用所有权系统来管理堆上的数据,以确保内存安全。
再回到我们刚才说的所有权原则:
Rust 中每一个值(对象)都被一个变量所拥有,该变量被称之为值的所有者
每个值同时只能被一个变量所拥有,也就是同时只允许有一位所有者
当变量离开当前作用域之后,值被废弃(drop),占用的内存会被 Rust 自动清理
Rust 中所有权、借用、生命周期这些保证内存安全的核心概念与堆栈的使用紧密相关,这一套强大的机制用来管理内存,避免数据竞争和内存泄漏,让 Rust不需要像 JS那样的垃圾回收机制(GC)或者是 C那样手动申请释放内存,而是在先天就保证了内存安全!
String对象
Rust中我们使用以下方法来创建 String
类型:
1 | let x = String::from("Hello"); |
乍一看很奇怪,为什么不直接let x = "hello"
呢,因为这是两种字符串类型。let x = "hello"
是字符串字面量 &str
,它被硬编码进程序代码中,大小是编译时就确定的,并且不可变,所以存在栈上。
我们不可能在写代码时知道所有字符串的值,例如用户填写表单,需要动态输入然后将值存储到内存上。这时候需要使用String
类型,该类型被分配到堆上,可以动态改变。
在上面一行代码中,::
是调用操作符,表示调用 String 模块的 from 方法。因为它是存储在堆上的,所以可以修改:
1 | let mut s = String:from("Hello"); |
变量绑定背后的数据交互
看一段代码:
1 | let x = 5; |
这段代码首先将 5 绑定到变量 x,然后拷贝 x 的值赋给 y,最终x 和 y 都等于 5,整数是基本类型,是固定大小的值,因此都被存储在栈中,不存在引用,是通过自动拷贝 的方式来复制的 ,所以并不存在所有权的转移。
对于存储在栈上的基本类型,Rust 会自动拷贝,在栈中拷贝是非常快的,远比在堆上分配内存快得多,所以Rust中对于基本类型是通过自动拷贝的方式来赋值的,而不是通过转移所有权来赋值。
刚才说过,String
类型是动态的,数据存储在堆上,栈上则是保存了指向真实堆内存位置的堆指针以及字符串长度和字符串容量,长度和容量很好理解:容量是堆给类型分配空间的大小,长度是目前已经使用的大小。
再看一段代码:
1 | let s1 = String::from("hello"); |
这时,s2 与 s1 之间就发生了所有权的转移。首先来看let s1 = String::from("hello");
做了哪些事。
分配内存:调用 String::from函数以创建一个新的 String实例。在堆上分配对应的内存空间。
初始化值:字符串hello将会被复制到堆上新分配的内存中。
变量绑定:将这个String实例绑定到变量 s1上,表示s1拥有这个实例的所有权,成为了这块堆内存的唯一所有者,在s1有效的区域内(作用域),可以对实例进行读写操作。s1实际上存储了字符串数据的指针、容量以及长度。
let s2 = s1;
又做了哪些事呢:
移动(move): 在这个操作中,s1 的所有权(指针、长度和容量的信息)被转移给了 s2。在 Rust 中,String 类型的变量被移动后,原变量(这里是 s1)将不再有效,因此不能被继续使用。这一点与一些其他语言中的引用或浅拷贝不同。
无内存复制:重要的是,这个过程中没有发生堆内存中的数据拷贝。只是 s1 的所有权信息(指针、长度、容量)被复制到了 s2,而原来的 s1 现在不再指向堆上的字符串数据。这种设计避免了不必要的内存复制,提高了效率。
防止双重释放:由于 s1 在移动操作后失效,Rust 确保当 s2 离开作用域并且其数据被自动释放时,不会发生双重释放的错误。Rust 的所有权机制自动处理了内存的释放,确保了内存安全。
上述提到了一个词移动
, 如果按照 JS 的概念,s2 只拷贝了指针、长度和容量而不拷贝数据应该属于浅拷贝,但是在 Rust中,变量 s1 无效了,发生了所有权的转移,所以可以更精确的称之为: 移动
,而不是浅拷贝。
另外,试想如果let s2 = s1
之后 s1
还有效会发生什么:
同一个值有了两个所有者,s1 和 s2 在离开作用域时都会被释放,这就会造成一个经典的内存安全性 BUG:二次释放 ,会造成潜在的内存安全问题。
现在回头看看我们最开始学的所有权原则:
Rust 中每一个值(对象)都被一个变量所拥有,该变量被称之为值的所有者
每个值同时只能被一个变量所拥有,也就是同时只允许有一位所有者
当变量离开当前作用域之后,值被废弃(drop),占用的内存会被 Rust 自动清理
相信会有更深的理解。
copy(浅拷贝)
浅拷贝在日常编程中无处不在,因为它只发生在栈上,所以性能很高。 在 Rust 中存在一个 copy
特征(trait),用来标记那些可以安全地进行位拷贝的类型,当这些类型被赋值给其他变量时,原始类型会被自动拷贝到新变量中,原变量保持有效并可用。
1 | let x = 5; |
跟上面说的很矛盾,为什么 x 还会有效呢,并且编译没有报错?
因为 x 是整型,像这样的基本类型在编译时是已知大小的,会被存储在栈上,而在栈上拷贝是很快速的,因此没有必要让 x 失效。所以在 x 赋值给 y时,发生了自动拷贝。像这样的类型还有很多:
所有整数类型,比如 u32布尔类型,
bool,它的值是 true 和 false
所有浮点数类型,比如 f64
字符类型,char
元组,当且仅当其包含的类型也都是 Copy 的时候。比如,(i32, i32) 是 Copy 的,但 (i32, String) 就不是
不可变引用 &T 。例如之前字符串里介绍的 &str. 注意: 可变引用 &mut T 是不可以 Copy的
总结来说就是:任何基本类型的组合是可以 Copy 的,不需要分配内存或某种形式资源的类型是可以 Copy的
这些类型的变量在被赋值给其他变量时会发生自动拷贝,原变量依旧有效。实际上,自动拷贝是通过复制栈中的内存位来实现的,也叫做 位拷贝
,也就是说从一个变量到另一个变量的复制是通过复制内存中的字节来完成的,所以性能消耗很低。
clone(深拷贝)
前面说到自动拷贝 , 需要知道一点:
Rust永远不会自动创建数据的”深拷贝“
任何自动的拷贝都不是深拷贝。深拷贝的概念是:复制对象及其所有的嵌套对象和指向的数据,确保原始对象和副本之间在逻辑上完全独立,修改一个不会影响到另一个。
Rust 实现了一个叫做 clone
的方法来深度复制堆中的数据。
1 | let s1 = String::from("hello"); |
可以看到能够编译通过,不会报所有权错误,因为 s2 完整地复制了 s1 ,堆上分配了新的内存空间。
Clone 是很消耗性能的,所以在程序开发中,应该谨慎使用。
函数传值与返回发生的所有权转移
函数传值自然也会发生 移动
和 复制
,就像 let
一样:
1 | fn main() { |
返回值也是一样的,例如:
1 | fn main() { |
当一个值被移动到函数内部时,它的所有权随之转移,原变量就不再有效。类似地,函数可以通过返回值将所有权转移回调用者。
小练习
1 | 1. |
很简单的小练习,答案可在评论区讨论🧐
总结
Rust 通过所有权、借用、生命周期三大原则来保证内存的安全性,使它无需像 JS 一样在程序运行时通过垃圾回收机制管理内存,性能得到很大的提升。下篇文章我们学习 引用与借用
。