RUST 语言特性之所有权

新年开工,开启 work & study 模式,接着来学习 RUST 语言。

作为一名 C/C++ 程序员,C/C++ 语言中的指针是使用得最爽的,几乎无所不能,各种奇技淫巧也层出不穷。但 C/C++ 语言中最折磨人的也是指针,伴随着开发过程的就是和指针导致的内存问题做斗争。

也许是意识到指针的巨大杀伤力,现代语言中都不再提供指针特性,改为引用和托管指针。其实在 Java 语言中,new 一个对象后得到的是一个指向对象的东西,本质上也是一个“指针”,但这个“指针”不可以随意修改,访问受到严格控制,和 C/C++ 语言中的指针有着本质的区别。

与指针息息相关的是内存管理,在 C/C++ 中都提供了申请内存和释放内存的函数或操作符,其使用原则也相当简单,使用时申请,不使用后释放。真所谓大道至简,但并没有什么用。不知多少程序员栽在忘记释放、多次释放、释放后仍然去访问等坑中。现代程序设计语言考虑到由程序员来管理内存不靠谱,基本上都提供了 GC(Garbage Collection, 垃圾回收) 机制,但 GC 机制无论如何设计,总会带来一定的开销,会拖慢速度,增加内存占用。

RUST 语言没有提供 GC 机制,但也不用程序员来管理内存,它是如何做到的?其秘诀就是所有权

RUST 使用包含特定规则的所有权系统来管理内存,这套规则允许编译器在编译过程中执行检查工作,而不会产生任何的运行时开销。这实际上是综合了上面两种内存管理的优势,看起来似乎没有短板。那是不是以后的程序设计语言都会采用这种设计?

也不一定,一切需要交给时间,只有经过时间的检验,才能确定这是一种好的设计,毕竟 RUST 语言还是太年轻。

阻碍这一设计的广泛使用的原因之一是所有权是一个非常新鲜的事务,理解起来也非常困难。原因之二是为了让所有权能够正常运转,制定了很多规则,只有遵循这些规则,才能写出无误的代码。虽然这些规则由编译器检查,但是程序员面对编译器哗啦啦的错误,也会一脸懵圈吧。

虽然 RUST 语言中的所有权规则比较复杂,但是有编译器把关,监督程序员不犯一些低级错误,比起运行后调试是抓狂,还是要好得多。

这篇文章先聊聊所有权的一些基本规则,更多的则需要在实际场景中再展开说明。

不过在了解所有权概念之前,先了解一下栈内存和堆内存。

栈内存和堆内存

首先,栈内存和堆内存只是一种软件上的概念,主要是从软件设计的角度进行划分。其实,从硬件层面上看,内存只是一长串字节。 而在虚拟内存层面上,它被分成三个主要部分:

  • 栈区,所有局部变量都存放在哪里。

  • 全局数据,其中包含静态变量,常量和类型元数据。

  • 堆区,所有动态分配的对象都在其中。 基本上,所有具有生命周期的东西都存储在这里。

全局数据区一般存放在一个固定的区域,不存在分配和释放的问题,不在讨论之列。

栈会以我们放入值时的顺序来存储它们,并以相反的顺序将值取出,即“后进先出”策略。栈空间有一个限制,就是所有存储在栈中的数据都必须拥有一个已知且固定的大小。对于那些在编译期无法确定大小的数据(动态分配,比如根据用户的输入值决定分配多少个数组),只能将它们存储在堆中。

堆空间的管理较为松散:将数据放入堆中时,先请求特定大小的空间。操作系统会根据请求在堆中找到一块足够大的可用空间,将它标记为已使用,并把指向这片空间地址的指针返回给程序。当程序不再需要这块内存时,通过某种方式来将这些内存归还给操作系统。根据内存管理的算法,可能还存在相邻空间合并的问题,也就是将相邻区域的较小块内存合并为较大块内存,这取决于内存管理算法。

很明显,向栈上推入数据要比在堆上进行分配更有效率一些,因为:

  1. 操作系统省去了搜索新数据存储位置的工作;这个位置永远处于栈的顶端。

  2. 操作系统在堆上分配空间时还必须首先找到足够放下对应数据的空间,并进行某些记录工作来协调随后进行的其余分配操作。

  3. 堆上分配内存,得到的是指向一块内存的指针。通过指针访问内存,多了跳转的环节,所以访问堆上的数据要慢于访问栈上的数据。

  4. 分配命令本身也可能消耗不少时钟周期。

栈空间只存在进栈和出栈两种操作,不存在内存管理问题,所以,内存管理问题实际上就是堆内存管理的问题。

所有权

所有权并不是 RUST 所独有的概念,Swift 语言中就存在。在我的理解中,所有权就相当于 C++ 中的智能指针,智能指针持有对象,智能指针结束生命周期,释放所持有的对象。

但智能指针存在循环引用的问题,虽然为此又引出了强指针和弱指针来解耦循环引用,但这种解耦依赖于程序员,并没有在语言层面上保证不会循环引用。

RUST 则通过一套所有权规则来保证不会存在 C++ 智能指针那样的问题。

所有权规则其实也不复杂,主要有如下三条:

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

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

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

初次接触,可能理解上也有一些困难,下面逐条解释一下。

变量作用域

简单来讲,作用域是一个对象在程序中有效的范围。这个比较容易理解,在 Java 和 C++ 语言中都有作用域的概念,比如在一段程序块(通常使用一对大括号包括起来)中声明的变量,在程序块外面无法使用。

下面使用一段简短的 RUST 代码说明一下:

{
    ...               // 由于变量 s 还未被声明,所以它在这里是不可用的
    let s = "hello";  // 从这里开始,s 可用
    ...
}                     // 作用域到此结束,s 不再可用

但需要注意的是,上面的代码使用了字面量字符串,它的值被硬编码到了当前的程序中,也就是存在全局数据区内,所以并没有涉及到内存的分配与释放。

为了说明 当所有者离开自己的作用域时,它持有的值就会被释放掉 这条规则,将上面的程序稍微修改一下:

{
    ...
    let s = String::from("hello");
    ...
}

上面的代码中,s 为 String 类型,在调用 String::from 时,该函数会请求自己需要的内存空间。

研究上面的代码,可以发现一个很适合用来回收内存给操作系统的地方:变量 s 离开作用域的地方。Rust 在变量离开作用域时,会调用一个叫作 drop 的特殊函数。String 类型的作者可以在这个函数中编写释放内存的代码。

注:Rust会在作用域结束的地方(即 } 处)自动调用 drop 函数

熟悉 C++ 的朋友可能会说,这不就是资源获取即初始化(Resource Acquisition Is Initialization, RAII)吗?对的,许多技术就是这样相通的,假如你在 C++ 中使用过类似的模式,那么理解 当所有者离开自己的作用域时,它持有的值就会被释放掉 这条规则就容易得多。

移动

上面的规则看起来很简单,但是如果多个变量与同一数据进行交互,问题就会复杂起来。比如下面的代码:

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

此时的内存布局类似于下图:

98b444459d28ab7901c0d68fb7f2822a.png

看到这张图,熟悉 C++ 的朋友会会心的一笑,这不就是所谓的"浅拷贝"吗? 对,技术就是这样传承的。

根据前面的规则,当一个变量离开当前的作用域时,Rust 会自动调用它的 drop 函数,并将变量使用的堆内存释放回收。很明显,上面的代码存在问题: s1 和 s2 指向了同一个地址,当s2和s1离开自己的作用域时,它们会尝试去重复释放相同的内存。这就是 C++ 中经常碰到的的二次释放问题。

RUST 如何解决这一问题?

为了确保内存安全,同时也避免复制分配的内存,Rust 在这种场景下会简单地将 s1 废弃,不再视其为一个有效的变量。因此,Rust也不需要在 s1 离开作用域后清理任何东西。

也就是说,let s2 = s1; 这句赋值语句,相当于隐式调用了一个类似于 C++ 中的 std::move 函数,转移了所有权。

试图在 s2 创建完毕后使用 s1 会导致编译时错误。

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

这就是 在同一时间内,值有且仅有一个所有者 规则,同时还隐含了另外一个设计原则:Rust 永远不会自动地创建数据的深度拷贝。因此在 Rust 中,任何自动的赋值操作都可以被视为高效的。

克隆

当你确实需要去深度拷贝 String 堆上的数据时,可以使用一个名为 clone 的方法。

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

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

经过克隆,此时 s1 和 s2 的内存布局如下图所示:

271d2f668e6e597a5241f7671c4494e7.png

因为 s1 和 s2 相互独立,所以释放不存在问题。

但需要注意,堆内存复制比较耗资源。所以,当你看到某处调用了 clone 时,你就应该知道某些特定的代码将会被执行,而且这些代码可能会相当消耗资源,这时需要特别小心,要评估一下是否有必要这样做。

其实在 C++ 中,设计对象的深拷贝和浅拷贝同样存在考量。

所有权与函数

在 C++ 中,将指针问题复杂化的一个因素就是各种函数调用与返回,RUST 语言同样如此。

将值传递给函数在语义上类似于对变量进行赋值。将变量传递给函数将会触发移动或复制,就像是赋值语句一样。至于何时移动何时复制,和变量类型有关。下面的代码展示了变量在函数传递过程中作用域的变化。

fd9e7edf2b9311b613c51120e40ebba1.png

这些不用特别去记忆,RUST 可以通过静态检查使我们免于犯错。

对于返回值,同样如此。

总结起来,变量所有权的转移总是遵循相同的模式:

  • 将一个值赋值给另一个变量时就会转移所有权。

  • 当一个持有堆数据的变量离开作用域时,它的数据就会被drop清理回收,除非这些数据的所有权移动到了另一个变量上。


如果在所有的函数中都要获取所有权并返回所有权显得有些烦琐,假如你希望在调用函数时保留参数的所有权,这会涉及到 C++ 程序员非常熟悉的特性:引用。

限于篇幅原因,关于 RUST 中的引用,在下一篇文章中详细阐述,敬请关注!

猜你喜欢

转载自blog.csdn.net/mogoweb/article/details/128825583
今日推荐