Rust 内存管理
Rust 内存管理
Rust 与其他编程语言相比,最大的亮点就是引入了一套在编译期间,通过静态分析的方式,确定所有对象的作用域与生命周期,从而可以精确的在某个对象不再被使用时,将其销毁,并且不引入任何运行时复杂度。
现代编程语言,对于堆上分配的内存(可以理解为 malloc 出来的内存)进行管理,不外乎两种方式:使用者在代码中显示调用函数,回收这部分内存;或者引入自动的垃圾回收机制,在运行时由程序自动管理。
前者的问题是给代码编写者引入了额外的工作,并且很难避免出 bug。后者的问题是会降低程序性能,尤其是对实时性要求比较高的程序。
值类型与引用类型
现代编程语言,大部分都会把类型分成两种:值类型与引用类型。
值类型一般类似 Java 中的 int / byte / bool 这种大小固定,分配在栈上的数据类型。在 Rust 中,这类类型都会实现 Copy 这个 trait,来标记它是一个值类型。
另外一种是大小不固定/可变的引用类型,比如 Java 中的 String,这种数据类型在内存中实际上是两部分:一部分在堆上,内容是其实际数据,另外一部分分配在栈上,内容实际上是个内存地址,指向栈上的实际数据。
对于值类型,因为它们保存在函数调用栈上,在函数调用结束,这个栈会被整体销毁,因此不存在「内存管理」这个问题。真正需要管理的,是引用类型的变量,因为在函数调用结束时,即使销毁了栈上保存的数据的地址,堆上的数据依然存在,这时不再做处理的话,就会发生内存泄漏。
RAII
RAII 全称为 Resource Acquisition Is Initialization,是 C++ 中的一种常见编程范式。RAII 也可以用作内存管理,参考如下代码:
class C {
public:
  int *value;
  C() {
    value = new int();
  }
  ~C() {
    delete value;
  }
};
void f() {
  auto c = C();
}
int main() {
  c();
  return 0;
}
在上述代码中,C 这个类的构造函数进行内存分配,析构函数进行内存回收,这样这个类对应的堆上的内存(这里是 value)就和某个变量的生命周期绑定在了一起。在变量的作用域结束时,堆上的内存也被回收,因此我们就不需要在代码中来手动回收 C 中 value 字段的内存了。在例子中,只要出了函数 f,c.value 就会自动被回收。
这种方式代码编写者不需要手动回收内存,并且代码运行时也没有额外的负担。
Rust 的引用类型,都相当于已经应用了上面提到的 RAII 技术,在离开变量的生命周期作用域时,会自动将本身对应堆上的内存清空。
不过 RAII 也有一些缺陷,比如将 c 赋值给另外一个变量上,会导致类的析构函数被调用两次,以及多线程等复杂的情况下的正确性。
move 语义
Rust 的赋值(= 语句)、函数传参、返回结果这三个操作,如果针对的目标是一个值类型的话,相当于把这个值的内容复制到目标上,原来的值上的修改不会应用到新的值上。这一点和其他常见编程语言相同。举个例子:
fn main() {
    let a = 1;
    let mut b = a;
    b += 1;
    println("a: {}, b: {}", a, b);  // 输出为 "a: 1, b: 2",并且此时两个变量都可以被使用。
}
那在一个引用类型上,执行上述操作会如何呢?我们以 String 为例:
fn main() {
    let a = String::from("hello");
    let b = a;
    println!("{}", a);
}
此时我们会遇到一个编译错误:
error[E0382]: use of moved value: `a`
 --> a.rs:4:20
  |
3 |     let b = a;
  |         - value moved here
4 |     println!("{}", a);
  |                    ^ value used here after move
  |
  = note: move occurs because `a` has type `std::string::String`, which does not implement the `Copy` trait
原因是,这类引用类型,在进行赋值、函数传参、返回结果操作时,并不是把内存内容复制一份过去,而是将数据「移动」到了新的变量上,原来的变量会不能使用。
这样就能确保堆上分配的一段内存,都只有唯一的拥有者。这样就解决了上面提到的 RAII 将一个引用类型变量赋值给另外一个类型,内存被回收两次的问题了。
引用
不过在 Rust 中,move 语义虽然保证了每个引用类型数据都有唯一的拥有者,但是这样也给编写代码造成了不便。比如我们想写一个计算 String 长度的函数:
fn get_string_length(the_s: String) -> usize {
    return the_s.len();
}
fn main() {
    let s = String::from("Hello!");
    get_string_length(s);
    println!("{}'s length is {}", s, length);
}
编译时会得到一个错误:
error[E0382]: use of moved value: `s`
 --> a.rs:8:35
  |
7 |     let length = get_string_length(s);
  |                                    - value moved here
8 |     println!("{}'s length is {}", s, length);
  |                                   ^ value used here after move
  |
  = note: move occurs because `s` has type `std::string::String`, which does not implement the `Copy` trait
error: aborting due to previous error
原因就是在调用 get_string_length 的时候,实际字符串的拥有权,已经从变量 s 转移到了 get_string_length 函数的参数 the_s 上,后续再使用 s 当然会失败。
当然我们可以修改一下函数,让它在最后不仅返回字符串的长度,同时也返回作为参数的字符串,这样所有权又可以转移回调用者上。不过显然这种做法会很啰嗦并且不优雅。
为此 Rust 又引入了 引用 这个概念。引用有些类似 C++ 中的引用,并且都是只需要在变量以及类型的前面加上 & 前缀即可。我们用引用来对上面的代码进行改写:
fn get_string_length(s: &String) -> usize {
    return s.len();
}
fn main() {
    let s = String::from("Hello!");
    let length = get_string_length(&s);
    println!("{}'s length is {}", s, length);
}
这样代码就可以正确编译和运行了。
在 Rust 中,通过引用,之前需要进行 move 语义的操作,就会变成 borrow 语义的操作,对象的生命周期并不会转移,只是暂时「借出」到了新的地方。
引用的可变性
如果学过 Rust,都应该知道在声明一个变量的时候,可以加上 mut 前缀,来表明这个变量是可以改变的。
在声明一个引用的类型时,也可以加上 mut 前缀。它的意思是,借出的这个引用,是可以被借用者修改的。
不过值得注意的是,一个变量只能借出一个可变引用,此时不能再借出任何引用(包括非可变引用)。这个限制是为了防止多线程情况下,数据的一致性出现问题。
生命周期
除了上述概念之外,关于 Rust 内存管理,还有一个生命周期(lifetime)的概念。
生命周期指的是一个变量的作用域范围。理论上来说,不光 Rust,其他大部分常见编程语言都有生命周期这一概念,只不过只有在 Rust 中,生命周期才可以显示的声明。
现有如下代码:
fn lifetime_showcase() {
    let a = 1;
    let b = 2;
    {
        let c = 3;
    }
    // Other codes here ...
}
这段代码中,变量 a 就拥有一个生命周期,是从声明开始到这个函数体结束。b 的生命周期类似,也是从声明开始到函数体结束,不过 b 比 a 晚一些声明,因此它的生命周期开始的也就晚一些。c 是在一个单独的变量作用域范围内的变量,因此它的生命周期更短,只能在这个作用域范围内。
然后假设我们有这么一个函数,接受两个 &str 作为参数,然后比价其长度,然后返回长度最大的那一个:
fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        return x;
    }
    return y;
}
看上去没什么问题,然而我们实际运行的时候, Rust 编译器会给出如下错误:
error[E0106]: missing lifetime specifier
 --> a.rs:1:33
  |
1 | fn longest(x: &str, y: &str) -> &str {
  |                                 ^ expected lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
error: aborting due to previous error
这个错误是告诉我们,函数返回了一个引用类型的结果,然而我们不知道这个引用类型是参数 x 还是 y,因此就不能确定返回结果的生命周期。
根本原因是,Rust 独特的「引用借出」概念,编译器要明确知道每个引用的所被使用的位置。然而这个函数中,我们接受了两个引用类型作为参数,然后在运行时决定将其中的一个引用类型返回,因此编译器在编译(这个函数)时并不能得知哪个引用被返回了。这样如果调用函数的两个参数拥有不同的生命周期,那么返回结果的生命周期也就不能确定了。
我们设想如下调用 longest 函数的场景:
fn main() {
    let s1 = String::from("foo");
    let result;
    {
        let s2 = String::from("barbaz");
        result = longest(s1.as_str(), s2.as_str());
    }
    println!("result is: {}", result);
}
这种情况下,result 这个函数返回结果显然比 s2 这个函数传入参数要长,因此这个函数调用完成后,除了函数体中的那个作用域范围,到达 println! 语句的时候,result是否还指向一个还存在的内存地址,完全取决于 s1 和 s2 对应的长度了。在这个例子中,我们是写死的字符串,但真实世界中,这两个字符串可能来自于用户输入,所以只能在运行时才能确定是哪个比较长。这就是上面说的生命周期二意性。
在这个例子中,显然 longest 的函数需要一个隐含前提:「返回结果的生命周期,需要是输入参数的两个生命周期中,最大的那个」。这样就可以保证不管返回哪个参数,结果的生命周期条件都是可以满足的。
然而遗憾的是,Rust 编译器不能根据上下文推断出这个隐含前提,所以就需要我们手动进行指定了。只要把 longest 的函数签名改为:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { ... }
代码就可以正确编译通过。这里引入了一个新的引用生命周期标记 'a,其实只要是有 ' 前缀的标记,都可以作为引用生命周期标记,后面的 a 是一种惯用法。
这里指的注意的是,函数的两个输入参数,以及返回结果都有相同的引用生命周期标记('a),但这里并不是需要这三个引用的生命周期都完全相同,而是只需标记相同的情况下,输入的生命周期要大于等于输出的生命周期即可。
Rust 内存管理的更多相关文章
- .NET基础拾遗(1)类型语法基础和内存管理基础
		
Index : (1)类型语法.内存管理和垃圾回收基础 (2)面向对象的实现和异常的处理 (3)字符串.集合与流 (4)委托.事件.反射与特性 (5)多线程开发基础 (6)ADO.NET与数据库开发基 ...
 - PHP扩展-生命周期和内存管理
		
1. PHP源码结构 PHP的内核子系统有两个,ZE(Zend Engine)和PHP Core.ZE负责将PHP脚本解析成机器码(也成为token符)后,在进程空间执行这些机器码:ZE还负责内存管理 ...
 - linux2.6 内存管理——逻辑地址转换为线性地址(逻辑地址、线性地址、物理地址、虚拟地址)
		
Linux系统中的物理存储空间和虚拟存储空间的地址范围分别都是从0x00000000到0xFFFFFFFF,共4GB,但物理存储空间与虚拟存储空间布局完全不同.Linux运行在虚拟存储空间,并负责把系 ...
 - linux2.6 内存管理——概述
		
在紧接着相当长的篇幅中,都是围绕着Linux如何管理内存进行阐述,在内核中分配内存并不是一件非常容易的事情,因为在此过程中必须遵从内核特定的状态约束.linux内存管理建立在基本的分页机制基础上,在l ...
 - Objective-C内存管理之引用计数
		
初学者在学习Objective-c的时候,很容易在内存管理这一部分陷入混乱状态,很大一部分原因是没有弄清楚引用计数的原理,搞不明白对象的引用数量,这样就当然无法彻底释放对象的内存了,苹果官方文档在内存 ...
 - Quartz2D内存管理
		
p.p1 { margin: 0.0px 0.0px 0.0px 0.0px; font: 14.0px "PingFang SC"; color: #239619 } p.p2 ...
 - 浅谈Linux内存管理机制
		
经常遇到一些刚接触Linux的新手会问内存占用怎么那么多?在Linux中经常发现空闲内存很少,似乎所有的内存都被系统占用了,表面感觉是内存不够用了,其实不然.这是Linux内存管理的一个优秀特性,在这 ...
 - linux内存管理
		
一.Linux 进程在内存中的数据结构 一个可执行程序在存储(没有调入内存)时分为代码段,数据段,未初始化数据段三部分: 1) 代码段:存放CPU执行的机器指令.通常代码区是共享的,即其它执行程 ...
 - cocos2d-x内存管理
		
Cocos2d-x内存管理 老师让我给班上同学讲讲cocos2d-x的内存管理,时间也不多,于是看了看源码,写了个提纲和大概思想 一. 为什么需要内存管理 1. new和delete 2. 堆上申 ...
 
随机推荐
- 跑edgebox
			
这是edge的作者的代码:https://github.com/pdollar/edges 这是matlab写的,还需要装Matlab Image Processing Toolbox和Piotr's ...
 - OO第三次电梯作业优化
			
目录 第三次电梯作业个人优化 前言 优化思路 一.调度器 二.电梯 第三次电梯作业个人优化 前言 由于个人能力有限,第二次电梯作业只能完成正确性设计,没能进行优化,也因此损失了强测分数,于是第三次电梯 ...
 - 实验1  c语言最基本内容
			
part 1 验证性内容 总结:经受了数组和结构体的双重折磨后,发现这部分好简单...现在没啥问题了... part 2 补全程序 1.判断奇偶 // 程序功能: // 要求用户从键盘输入一个整数 ...
 - iOS 后台传输服务
			
后台传输服务 — 我们用水壶来比喻 (0:14) 后天传输服务是 iOS 7 引进的 API,它准许应用暂停或者中止之后,在后台继续执行网络服务(比如下载或者上传).举个例子,这正是 Dropbox ...
 - NOIP2016——大家一起实现の物语
			
由于最近硬盘挂了,换了个固态硬盘,比赛结束后四天一直在装Linux,所以最近一直没怎么更新 看起来挺漂亮的 比赛前一个月申请停了一个月晚自习,在我们这座城市里能做到这种事情已经可以被称为奇迹了,并且在 ...
 - redis学习笔记(2)
			
redis学习笔记第二部分 --配置文件介绍 二,解析redis的配置文件redis.conf常见配置参数说明redis.conf 配置项说明如下:1. Redis默认不是以守护进程的方式运行,可以通 ...
 - MySQL数据库---索引
			
索引的作用就是快速找出在一个列上用一特定值的行.如果没有索引,MySQL不得不首先以第一条记录开始并然后读完整个表直到它找出相关的行. 索引的类型: 先写一个建表语句: CREATE TABLE `t ...
 - 201621123080《Java程序设计》第1周学习总结
			
作业01-Java基本概念 1. 本周学习总结 关键词: JDK.JAVA.编程.基础语法 概念之间的关系: JDK是JAVA的开发工具,学习JAVA的主要方法是大量编程,语法是JAVA的基础 2. ...
 - Python分布式爬虫开发搜索引擎 Scrapy实战视频教程
			
点击了解更多Python课程>>> Python分布式爬虫开发搜索引擎 Scrapy实战视频教程 课程目录 |--第01集 教程推介 98.23MB |--第02集 windows下 ...
 - 【jenkins】jenkins执行nohup java报错
			
nohup:failed to run command 'java':No such file or directory 这是因为jenkins只认绝对路径.在shell里面有涉及到文件的都应该写成绝 ...