rust语言特性之所有权

云水木石 云水木石     2023-02-02     201

关键词:

新年开工,开启 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;

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

看到这张图,熟悉 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 的内存布局如下图所示:

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

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

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

所有权与函数

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

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

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

对于返回值,同样如此。

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

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

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


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

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

rust语言特性之变量

在学习一门新的语言中,我写下2023年的新目标:学习RUST语言。这几天我把RUST语法过了一遍。有了其它编程语言的基础,RUST语法学起来不难。但RUST毕竟是一门全新设计的语言,如果和现有语言完全一样,那就... 查看详情

rust编程语言入门之rust的面向对象编程特性(代码片段)

Rust的面向对象编程特性一、面向对象语言的特性Rust是面向对象编程语言吗?Rust受到多种编程范式的影响,包括面向对象面向对象通常包含以下特性:命名对象、封装、继承对象包含数据和行为“设计模式四人帮”在《设计模型... 查看详情

rust编程语言入门之函数式语言特性:-迭代器和闭包(代码片段)

函数式语言特性:-迭代器和闭包本章内容闭包(closures)迭代器(iterators)优化改善12章的实例项目讨论闭包和迭代器的运行时性能一、闭包(1)-使用闭包创建抽象行为什么是闭包(closure)闭包:可以捕获其所在环境的匿名函数... 查看详情

rust编程语言入门之高级特性(代码片段)

...函数和闭包宏一、不安全Rust匹配命名变量隐藏着第二个语言,它没有强制内存安全保证:UnsafeRust(不安全的Rust)和普通的Rust一样,但提供了额外的“超能力”UnsafeRust存在的原因:静态分析是保守的。使用UnsafeRust:我知道自己... 查看详情

rust所有权

所有权规则Rust中的每一个值都有一个被称为其所有者(owner)的变量。值在任一时刻有且只有一个所有者。当所有者(变量)离开作用域,这个值将被丢弃。引用和Copy特性赋值过程:包括变量赋值,函数传参,函数返回如果类... 查看详情

rust编程语言入门之编写自动化测试(代码片段)

编写自动化测试一、编写和运行测试测试(函数)测试:函数验证非测试代码的功能是否和预期一致测试函数体(通常)执行的3个操作:准备数据/状态运行被测试的代码断言(Assert)结果解剖测试函数测试函数需要使用test属性... 查看详情

rust学习内存安全探秘:变量的所有权引用与借用

...能轻松和其他语言集成。•可靠性-Rust丰富的类型系统和所有权模型保证了内存安全和线程安全,让您在编译期就能够消除各种各样的错误。•生产力-Rust拥有出色的文档、友好的编译器和清晰的错误提示信息,还集成了一流的... 查看详情

「rust进阶笔记」rust之derive特性总结(代码片段)

前言编译器可以通过#[derive]为一些trait提供基础的实现。如果需要更复杂的逻辑,这些trait也可以被手动实现。这些可导入的实现:比较:Eq、PartialEq、Ord、PartialOrdClone:从&T的一个拷贝创建TCopy:把一个类型的move转换为copyHash:... 查看详情

rust编程语言入门之智能指针(代码片段)

...能指针Box<T>:在heap内存上分配值Rc<T>:启用多重所有权的引用计数类型Ref<T>和RefMut<T>,通过RefCell<T>访问:在运行时而不是编译时强制借用规则的类型此外:内部可变模型(interiormutab 查看详情

rust编程语言入门之项目实例:-命令行程序(代码片段)

项目实例:-命令行程序一、实例:接收命令行参数本章内容12.1接收命令行参数12.2读取文件12.3重构:改进模块和错误处理12.4使用TDD(测试驱动开发)开发库功能12.5使用环境变量12.6将错误消息写入标准错误而不是标准输出创建... 查看详情

rust学习笔记1.基础语法(代码片段)

...体的语句和表达式函数返回值7、条件语句8、循环语句9、所有权Rust的所有权内存的分配变量与数据交互方式移动克隆垂悬引用10、切片11、结构体12、枚举类match语法Option枚举类iflet语法13、生命周期生命周期注释函数的生命周期... 查看详情

rust入坑指南之ownership

...ust编译器会捕获很多C++难以发现的错误”。BrianKernighan:C语言的创始人之一,对Rust的评价是:“Rust是一门非常强大的程序语言,在资源管理、内存安全、多线程等方面具有很强的能力”。RobPike:Go语言的创始人之一,对Rust的评... 查看详情

rust学习--变量(代码片段)

0x0每种编程语言都有变量的概念,我们可以把变量理解为最简单的存储方式,它是编码过程中是必不可少的。Rust的变量很有特色。变量不可变的特性让人想起了Erlang。以及后面的模式匹配,我觉得作者应该受Erlang影响很大。下... 查看详情

rust编程语言入门(代码片段)

Rust编程语言入门Rust简介为什么要用Rust?Rust是一种令人兴奋的新编程语言,它可以让每个人编写可靠且高效的软件。它可以用来替换C/C++,Rust和他们具有同样的性能,但是很多常见的bug在编译时就可以被消灭。Rust是一种通用的... 查看详情

rust编程语言入门之模式匹配(代码片段)

模式匹配模式模式是Rust中的一种特殊语法,用于匹配复杂和简单类型的结构将模式与匹配表达式和其他构造结合使用,可以更好地控制程序的控制流模式由以下元素(的一些组合)组成:字面值解构的数组、enum、struct和tuple变... 查看详情

rust学习教程02-rust语言简介

本文节选自<<Rust语言圣经>>一书欢迎大家加入Rust编程学院,一起学习交流:QQ群:1009730433进入Rust编程世界一、Rust发展历程Rust最早是Mozilla雇员GraydonHoare的一个个人项目,从2009年开始,得到了Mozilla研究院的... 查看详情

rust极简教程(代码片段)

...句循环输出&输入输出输出花括号输出非基础类型输入所有权切片结构体枚举MatchIf-letOption集合vector容器String代码组织命名空间访问其他mod和crate访问权限和关键字访问其他文件中的对象使用第三方库异常处理泛型泛型概念特性... 查看详情

rust所有权语义模型

首发于知乎专栏本文试图从语义角度来解释Rust所有权的概念,以便帮助降低Rust的学习曲线。编程语言的内存管理,大概可以分为自动和手动两种。自动管理就是用GC(垃圾回收)来自动管理内存,像Java、Ruby、Golang、Elixir等语言... 查看详情