内存管理是理解低级语言(和硬件相关的)的基础概念。低级语言没有提供自动内存管理的解决方案,例如内置垃圾回收器。它要求程序员自己在程序中管理内存。理解内存何时何地被创建和释放可以使得程序员构建出一个高效、安全的软件。然而,低级语言的大量错误也正是因为程序员使用内存不当造成的。
C 和 C++ 因为容易出现大量的内存漏洞而一直被人们诟病,因此不少人选择使用 Java 这种自带垃圾回收的语言,既可以提高安全性,又省去内存管理的心智负担。不过,无论使用哪种垃圾回收方案,都无法避免世界暂停现象,垃圾回收器会使得程序中断,接着进行回收内存的操作。换句话说,这带来了性能的开销。
Rust 选择在编译阶段解决内存管理的问题,尽可能通过良好的编程实践,阻止程序员编写出糟糕的代码,通过编译器的指导程序员编写出安全、高效使用内存的软件。
内存安全
内存安全指的是程序不会碰到不该碰到的内存位置(例如越界),程序里的指针不能指向无效的内存(NULL),在整个代码中相关的内存都应该是有效的。换句话说,安全意味着程序指针始终是有效引用,进行指针操作的时候不会出现未定义的行为,例如访问未初始化的数组,编译器没有说明这种情况下会发生什么:
#include <stdio.h>
int main()
{
int values[5];
for (int i = 0; i < 5; i++)
{
printf("%d ", values[i]);
}
}
编译执行这段代码,输出的都是随机值。这种情况最好是可以立即崩溃,像这样表面看起来很正常,很可能日后就成为一颗定时炸弹,在某些情况下导致错误的输出。
还有一种很常见的内存安全问题就是迭代器失效:
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v{
1, 5, 10, 15};
for (auto it = begin(v); it != end(v); it++)
{
if ((*it) == 5)
v.push_back(-1);
}
for (auto it = begin(v); it != end(v); it++)
{
std::cout << (*it) << " ";
}
return 0;
}
能够正常编译这段代码,并且在某些情况下是能正常运行,但是某些情况下却会导致段错误。原因在于 push_back
有可能引发内存的重新分配(扩容),导致原有的迭代器失效(原本迭代器指向的内存已经被复制后删除了)。那么循环继续访问失效的内存,就会产生错误。
另外一种常见的内存安全问题就是数组越界:
int main()
{
char buf[3];
buf[0] = 'a';
buf[1] = 'b';
buf[2] = 'c';
buf[3] = 'd';
}
这种内存安全问题很容易导致缓冲区溢出攻击。现在的 gcc 版本中会使用 SIGABRT(中止)信号阻止程序。
还有另外一种内存问题就是内存泄漏。Rust 并不能完全解决这类问题。内存泄漏相对而言危害性不如上面几种,但如果需要一个长时间运行的系统,内存泄漏问题是值得关注的。内存日积月累的泄漏,长期运行下来,总会把内存占满,最终可能导致宕机。
所有权
所有者的概念不同语言有所不同。我们这里使用资源(resource)来统称堆或者栈上的任何变量,包括持有打开文件描述符、数据库连接、网络套接字等等。从他们存在到程序使用时,均占用一些内存。作为资源的所有者,一个重要指责就是在适当的时候释放掉内存。
像 Python 这样的动态语言中,可以有多个所有者或者别名指向类似 list
这样的对象。不需要关心和释放对象的问题,因为 GC 会处理这些问题。对于编译型语言,比如 C 或者 C++,由于没有清楚的界定所有权,很多时候内存该由库来释放还是由用户来释放是不清楚的,需要进一步的了解才能确定。C/C++ 语言允许多个指针指向同一个内存,这也是很多内存问题的根源,只要其中有一个释放掉内存,其余的指针就不能再使用了(当然,RAII 惯用法可以极大程度上解决这个问题)。
Rust 则希望给所有权定下明确的规则:
- 使用
let
创建资源并分配给变量后,这个变量就是该资源的所有者; - 但该变量被重新绑定到另外一个变量后,所有权会转移到新的变量上,原有变量不能再访问;
- 变量会在作用域结束时被释放;
简而言之,Rust 规定了在同一时间里只能有一个所有者:
#[derive(Debug)]
struct Foo(u32);
fn main()
{
let foo = Foo(2048);
let bar = foo;
println!("Foo is {:?}", foo);
println!("Bar is {:?}", bar);
}
使用 rustc
编译这段代码,会发现报错:
代码中,我们创建了两个变量,foo
和 bar
。一开始 foo
拥有了 Foo
对象的所有权,随后重新绑定到了 bar
上,那么原来的 foo
变量就不再具有 Foo
资源的所有权了。也就是说,Rust 不允许在这个作用域内拥有两个可以修改资源的变量。
作用域
Rust 的作用域和大部分语言一样,同样支持 {}
建立块级作用域。作用域之间可以互相嵌套,但是内部作用域可以访问父作用域的环境变量,反之则不行。
fn main()
{
let level_0_str = String::from("foo");
{
let level_1_number = 9;
{
let mut level_2_vector = vec![1, 2, 3];
level_2_vector.push(level_1_number);
} // level_2_vector 作用域结束,被释放
level_2_vector.push(4); // 不存在了!!!
} // level_1_number 作用域结束
} // level_0_str 作用域结束
进行所有权推理的时候,作用域的范围是需要记住的重要属性。作用域结束的时候,会自动隐式调用 Drop
方法来释放内存。
Move 和 Copy 语义
Rust 中默认是是移动语义。C++ 默认则是 Copy语义,直到 C++ 11 才引入 Move 移义。复制语义意味着得到是值的副本,两个变量之间其实并没有联系。至于移动语义,并不进行拷贝,而是进行了所有权的转移。Rust 由于它的类型系统是仿射类型系统,默认具有移动语义。仿射类型系统的一个突出的特点就是值或者资源只能用一次。
如果 Rust 只有移动语义,有时就有一定的局限性。因此,Rust 提供了 Copy
trait 来实现 Copy 语义。
#[derive(Copy, Clone, Debug)]
struct Dummy;
fn main() {
let a = Dummy;
let b = a;
println!("{:?}", a);
println!("{:?}", b);
}
对于简单的原生类型,例如 u32
,Rust 默认实现了 Copy
,但对于稍微复制的类型,一般都是移动语义,从而避免复制内存的开销。
Copy
Copy
通常用来实现完全由堆栈表示的类型,也就是在 C/C++ 中一般不用 malloc
和 new
的类型。换句话说,没有内存是在堆上的。如果类型是在堆上,Copy
一般就会是性能较差的操作,因为实现了 Copy
意味着从一个变量到另一个变量的赋值会隐式的复制数据,这个操作就很类似 C 语言中的 memcpy
。
Rust 中,Vec<T>
、String
,可变引用都默认没有实现 Copy
。要复制这些值,我们需要更加明确的使用 Clone
trait。
Clone
Clone
trait 是用于显式复制的,它带有一个 clone
方法。Clone
trait 的定义类似这样:
pub trait Clone {
fn clone(&self) -> Self;
}
可以看到它接收一个不可变的引用,并且返回一个相同类型的值。不像 Copy
可以隐式调用,使用 Clone
必须显式调用 clone
方法。
clone
方法是更加通用的复制机制,Copy
只是它的特例,Copy
总是按位复制。智能指针的类型也实现了 Clone
trait,但它只需要复制指针和额外的元数据,例如引用计数。
#[derive(Clone, Debug)]
struct Dummy {
items: u32
}
fn main() {