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 字段的内存了。在例子中,只要出了函数 fc.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 内存管理的更多相关文章

  1. .NET基础拾遗(1)类型语法基础和内存管理基础

    Index : (1)类型语法.内存管理和垃圾回收基础 (2)面向对象的实现和异常的处理 (3)字符串.集合与流 (4)委托.事件.反射与特性 (5)多线程开发基础 (6)ADO.NET与数据库开发基 ...

  2. PHP扩展-生命周期和内存管理

    1. PHP源码结构 PHP的内核子系统有两个,ZE(Zend Engine)和PHP Core.ZE负责将PHP脚本解析成机器码(也成为token符)后,在进程空间执行这些机器码:ZE还负责内存管理 ...

  3. linux2.6 内存管理——逻辑地址转换为线性地址(逻辑地址、线性地址、物理地址、虚拟地址)

    Linux系统中的物理存储空间和虚拟存储空间的地址范围分别都是从0x00000000到0xFFFFFFFF,共4GB,但物理存储空间与虚拟存储空间布局完全不同.Linux运行在虚拟存储空间,并负责把系 ...

  4. linux2.6 内存管理——概述

    在紧接着相当长的篇幅中,都是围绕着Linux如何管理内存进行阐述,在内核中分配内存并不是一件非常容易的事情,因为在此过程中必须遵从内核特定的状态约束.linux内存管理建立在基本的分页机制基础上,在l ...

  5. Objective-C内存管理之引用计数

    初学者在学习Objective-c的时候,很容易在内存管理这一部分陷入混乱状态,很大一部分原因是没有弄清楚引用计数的原理,搞不明白对象的引用数量,这样就当然无法彻底释放对象的内存了,苹果官方文档在内存 ...

  6. Quartz2D内存管理

    p.p1 { margin: 0.0px 0.0px 0.0px 0.0px; font: 14.0px "PingFang SC"; color: #239619 } p.p2 ...

  7. 浅谈Linux内存管理机制

    经常遇到一些刚接触Linux的新手会问内存占用怎么那么多?在Linux中经常发现空闲内存很少,似乎所有的内存都被系统占用了,表面感觉是内存不够用了,其实不然.这是Linux内存管理的一个优秀特性,在这 ...

  8. linux内存管理

    一.Linux 进程在内存中的数据结构 一个可执行程序在存储(没有调入内存)时分为代码段,数据段,未初始化数据段三部分:    1) 代码段:存放CPU执行的机器指令.通常代码区是共享的,即其它执行程 ...

  9. cocos2d-x内存管理

    Cocos2d-x内存管理 老师让我给班上同学讲讲cocos2d-x的内存管理,时间也不多,于是看了看源码,写了个提纲和大概思想 一.   为什么需要内存管理 1. new和delete 2. 堆上申 ...

随机推荐

  1. UVA12904 Load Balancing(中途相遇法)

    虽然这题可以用暴力n^3过,但是还有有种n^2的方法的,枚举b,对于b,分别枚举a和c,得到对于这个b的最优解,然后从所以b中选一个最优的. 要保证字典序最小,只要从小往大枚举就好了 感谢moonfl ...

  2. ubuntu 18.04下 配置qt opencv的坑

    问题和过程描述: 我按照网上的教程装了qt5.8版本,然后去配置opencv,感觉一切顺利,然后随便写了个 Mat src = imread("xxx") 然后imshow发现编译 ...

  3. 一些常用的HTML标签

    由于本人目前没有系统学习,日常碰见哪个有用就记下来. pre标签 可定义预格式化的文本,在pre元素中的文本会保留空格和换行符.比如我们展示源代码的时候,只要放一个pre标签,然后把代码直接复制.粘贴 ...

  4. 点击按钮在表格的某一行下,在添加一行(HTML+JS)

    使用js在指定的tr下添加一个新的一行newTr html代码: <table> <tr> <td>用户名:</td> <td><in ...

  5. Java制作桌面弹球下载版 使用如鹏游戏引擎制作 包含2个精灵球同时弹动

    package com.swift; import com.rupeng.game.GameCore; public class DesktopBouncingBall implements Runn ...

  6. 转 救命的教程 anaconda下载安装包网络错误的解决办法

    折腾了一天,终于找到了这个解决办法 https://blog.csdn.net/sinat_29315697/article/details/80516498

  7. redis学习笔记(2)

    redis学习笔记第二部分 --配置文件介绍 二,解析redis的配置文件redis.conf常见配置参数说明redis.conf 配置项说明如下:1. Redis默认不是以守护进程的方式运行,可以通 ...

  8. centos 安装 python3 分类链接

    上一篇文章描述了如何安装python3,但是在后续安装pip便不断报出缺少各类模块,安装一个又需要依赖另一个,导致安装过程非常繁琐.究其原因,我是安装centos-minimal版本,有许多功能不是完 ...

  9. 让Web站点崩溃最常见的七大原因

    转载自: https://blog.csdn.net/u012981511/article/details/53503242 1. 磁盘已满 导致系统无法正常运行的最可能的原因是磁盘已满.一个好的网络 ...

  10. Relu的缺点

    Relu不适合梯度过大的的输入 Relu是我们在训练网络时常用的激活函数之一(对我而言没有之一).然而最近发现Relu太脆弱了,经常由于输入的函数梯度过大导致网络参数更新后,神经元不再有激活功能.特别 ...