Rust是一门现代的系统编程语言主打内存安全和高性能。

这几年Rust越来越火连续多年被Stack Overflow评为"最受喜爱的编程语言"。很多大公司,比如MozillaMicrosoftGoogleAmazon等等都开始在项目中使用Rust。

很多人对Rust感兴趣想学,但是觉得它的语法和概念比较难。特别是所有权借用生命周期这些概念和其他语言很不一样很难理解。

我最近也在学Rust花了一些时间研究它的底层机制有了一些理解。今天想分享一下我对Rust的初探和原理剖析帮大家深入理解Rust的设计思想和,底层原理。

一、Rust是什么

先简单介绍一下Rust。

Rust是由Mozilla主导开发的一门系统编程语言第一个稳定版1.0于2015年发布。

Rust的设计目标是"安全并发实用"它想要做到像C/C++一样高性能,但是又能保证内存安全和线程安全。

传统的系统编程语言,比如C/C++性能很高,但是内存安全问题很多,比如空指针野指针缓冲区溢出数据竞争等等。这些问题经常导致安全漏洞和程序崩溃。

而像JavaPython这样的语言有垃圾回收机制能保证内存安全,但是性能不如C/C++而且有GC停顿的问题不适合一些对性能要求极高的场景。

Rust想要兼顾两者的优点:既有C/C++一样的高性能又能在编译期保证内存安全不需要垃圾回收。

Rust通过独特的所有权系统借用检查生命周期等机制在编译期就能发现大部分内存安全问题不需要运行时的垃圾回收也没有GC停顿。

这就是Rust的核心优势。

二、所有权系统

所有权是Rust最核心的概念也是Rust和其他语言最不一样的地方。

理解了所有权就理解了Rust的一半。

什么是所有权

在Rust中每个值都有一个被称为所有者的变量。一个值在任意时刻有且,只有一个所有者。当所有者离开作用域的时候,这个值就会被丢弃内存被释放。

这就是Rust的所有权规则。

听起来简单,但是这个机制让Rust能在编译期确定内存的释放时机不需要垃圾回收也不会出现内存泄漏,或者重复释放的问题。

所有权的移动

在Rust中当你把一个变量赋值给另一个变量的时候,所有权会发生移动原来的变量就不能再用了。

比如:

let s1 = String::from("hello");
let s2 = s1; // 所有权从s1移动到s2
println!("{}", s1); // 编译错误!s1已经失效了

这和其他语言很不一样。在C++中这会触发拷贝构造函数两个变量都能用。在Java中两个变量指向同一个对象都能用。

但是在Rust中s1的所有权移动到了s2s1就失效了不能再用了。

这是为了避免双重释放的问题。如果s1和s2都指向同一个堆内存,那么当它们离开作用域的时候,都会尝试释放这块内存就会导致双重释放这是严重的内存安全问题。

Rust通过所有权移动保证了任意时刻,只有一个所有者,所以,只有一个变量会释放内存不会出现双重释放。

克隆

如果你真的想深拷贝一个值让两个变量都能用可以用clone方法:

let s1 = String::from("hello");
let s2 = s1.clone(); // 深拷贝
println!("s1 = {}, s2 = {}", s1, s2); // 都能用

clone会在堆上分配新的内存把数据复制过去两个变量各自有自己的内存互不影响。

当然clone的开销比较大,所以Rust默认不自动clone而是移动所有权需要你显式调用clone。

Copy trait

对于一些简单的类型,比如整数浮点数布尔值字符等等它们存储在栈上大小固定拷贝开销很小。Rust对这些类型实现了Copy trait赋值的时候,会自动拷贝而不是移动。

let x = 5;
let y = x; // 自动拷贝不是移动
println!("x = {}, y = {}", x, y); // 都能用

因为这些类型在栈上拷贝开销小,而且没有堆内存的释放问题,所以自动拷贝不会有安全问题。

三、借用和引用

所有权移动,虽然能保证内存安全,但是有时候我们不想转移所有权只是想临时用一下一个值。

比如写一个函数计算字符串的长度我们不想把字符串的所有权传进函数,因为传进去之后,外面就不能用了。

这时候就需要借用和引用。

什么是引用

引用就像一个指针指向一个值,但是不拥有这个值的所有权。

用&符号创建引用:

fn calculate_length(s: &String) -> usize {
    s.len()
} // s离开作用域,但是,因为它不拥有所有权,所以不会释放内存

let s1 = String::from("hello");
let len = calculate_length(&s1); // 传入引用不转移所有权
println!("The length of '{}' is {}.", s1, len); // s1还能用

这里&s1创建了一个指向s1的引用,但是不转移所有权。函数参数s是一个引用指向s1但是不拥有s1的所有权。所以函数结束的时候s离开作用域不会释放内存s1在外面还能用。

这就是借用我们把值临时借给函数用用完还回来所有权还是原来的变量的。

可变引用

上面的引用是不可变的只能读不能改。如果想通过引用修改值需要用可变引用&mut:

fn change(s: &mut String) {
    s.push_str(", world");
}

let mut s = String::from("hello");
change(&mut s); // 传入可变引用
println!("{}", s); // hello, world

注意变量s本身也需要是mut的才能创建可变引用。

借用规则

Rust的借用有两条重要的规则:

  1. 在任意时刻要么只能有一个可变引用要么只能有多个不可变引用两者不能,同时存在。
  2. 引用必须始终有效不能出现悬垂引用。

第一条规则是为了避免数据竞争。如果,同时有一个可变引用和多个不可变引用,那么不可变引用的持有者可能会发现值被可变引用的持有者修改了这会导致不可预测的行为。

Rust在编译期就检查这条规则违反了就编译不通过。这从根源上避免了数据竞争的问题。

第二条规则是为了避免悬垂指针。在C/C++中很容易出现悬垂指针指向已经被释放的内存。Rust的编译器会检查引用的生命周期确保引用不会比它指向的值活得更久。

四、生命周期

生命周期是Rust中另一个重要的概念也是很多人觉得难的地方。

什么是生命周期

生命周期指的是引用有效的范围。每个引用都有自己的生命周期从创建到失效。

大部分时候Rust的编译器能自动推断引用的生命周期不需要我们显式标注。但是有些情况编译器推断不出来需要我们显式标注生命周期。

比如函数返回一个引用的时候,编译器不知道这个返回的引用的生命周期和哪个参数的生命周期相关需要我们标注。

生命周期标注

生命周期标注用'a这样的语法,比如:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

这里<'a>声明了一个生命周期参数'a。然后x和y的引用都标注了&'a str意思是它们的生命周期都是'a。返回值也标注了&'a str意思是返回的引用的生命周期也是'a。

这告诉编译器返回的引用的生命周期和xy中较短的那个一样。这样编译器就能检查返回的引用是否有效。

比如:

let string1 = String::from("long string is long");
let result;
{
    let string2 = String::from("xyz");
    result = longest(string1.as_str(), string2.as_str());
}
println!("The longest string is {}", result); // 编译错误!

这里result的生命周期和string2中较短的一样也就是string2的生命周期。string2在内部作用域结束的时候,就失效了,所以result在外面也失效了不能再用。编译器会报错。

这就避免了悬垂引用的问题。

生命周期省略规则

大部分时候不需要显式标注生命周期,因为编译器有一套生命周期省略规则能自动推断。

简单来说,如果函数,只有一个输入引用,那么输出引用的生命周期自动等于输入引用的生命周期。如果有多个输入引用,但是其中一个是&self或者&mut self那么输出引用的生命周期自动等于self的生命周期。

只有当编译器用这些规则推断不出来的时候,才需要显式标注。

五、内存管理原理

理解了所有权借用生命周期之后,我们就能理解Rust的内存管理原理了。

Rust的内存管理和C/C++类似都是手动管理内存,但是Rust通过所有权系统在编译期就确定了内存的分配和释放时机不需要程序员手动调用malloc/free也不需要垃圾回收。

具体来说:

  1. 栈内存:简单的固定大小的类型存储在栈上函数调用的时候,分配函数返回的时候,自动释放。这和C/C++一样。
  1. 堆内存:动态大小的类型,比如StringVecBox等等存储在堆上。当创建这些值的时候,在堆上分配内存。当所有者离开作用域的时候,自动调用drop方法释放堆内存。

这就是RAII(Resource Acquisition Is Initialization)资源获取即初始化的思想。C++也有RAII但是Rust把它做到了极致通过所有权系统保证了资源一定会被正确释放。

而且,因为所有权规则保证了任意时刻,只有一个所有者,所以不会出现双重释放的问题。因为借用规则保证了引用不会悬垂,所以不会出现野指针的问题。

这些内存安全的保证都是在编译期完成的不需要运行时的开销,所以Rust的性能和C/C++相当。

六、Rust的其他重要特性

除了所有权借用生命周期Rust还有一些其他重要的特性。

1. 模式匹配

Rust有强大的模式匹配match表达式能优雅地处理各种情况:

match value {
    Some(x) => println!("Got: {}", x),
    None => println!("Got nothing"),
}

2. 枚举和Option/Result

Rust的枚举很强大能携带数据。Rust用Option枚举来表示可能为空的值用Result枚举来表示可能出错的操作从根源上避免了空指针和未处理错误的问题。

// Option
let some_value: Option<i32> = Some(5);
let none_value: Option<i32> = None;

// Result
fn divide(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        Err("Cannot divide by zero".to_string())
    } else {
        Ok(a / b)
    }
}

3. trait

Rust的trait类似其他语言的接口定义了一组行为的规范。但是Rust的trait更强大支持默认实现也能为外部类型实现trait。

4. 零成本抽象

Rust的很多高级抽象,比如迭代器闭包等等都是零成本抽象编译后和手写的底层代码一样高效没有额外的运行时开销。

5. 并发安全

Rust通过所有权和类型系统在编译期就能避免数据竞争的问题。Rust有Send和Sync两个trait标记哪些类型可以安全地跨线程传递和,共享。编译器会检查并发代码的安全性。

七、学习Rust的建议

最后给一些学习Rust的建议。

1. 理解所有权是关键

Rust最核心的概念就是所有权。一定要花时间理解所有权移动借用引用生命周期这些概念。理解了这些Rust就入门了。

2. 多写代码多编译

Rust的编译器很友好错误提示很详细会告诉你哪里错了怎么改。多写代码多编译看编译器的错误提示是学Rust的好方法。

3. 不要和其他语言类比

Rust的很多概念和其他语言不一样不要用其他语言的思维来学Rust。要放空自己从头理解Rust的设计思想。

4. 看官方文档和The Rust Programming Language

Rust的官方文档写得很好特别是《The Rust Programming Language》这本书免费在线阅读是最好的Rust入门资料。

5. 从小项目开始

学Rust不要一上来就做大项目先从小项目开始,比如命令行工具简单的算法实现等等慢慢熟悉Rust的语法和概念。

八、写在最后

以上就是我对Rust的初探和原理剖析。

Rust确实是一门很有特色的语言它的所有权系统借用检查生命周期等等机制让它能在编译期保证内存安全和线程安全,同时又保持了C/C++级别的高性能。

当然Rust的学习曲线确实比较陡特别是所有权和生命周期这些概念和,其他语言很不一样需要花时间理解。但是一旦理解了就会发现Rust的设计很精妙写代码的时候,很有安全感。

这几年Rust的发展很快社区也很活跃越来越多的公司和项目开始使用Rust。我觉得Rust是一门很有前途的语言值得花时间学习。

希望我的这些理解能帮大家更好地理解Rust的底层机制。如果有什么不对的地方,或者不同的看法欢迎在评论区留言我们一起交流。

最后用一句话结束这篇文章:"Rust是一门让你写代码的时候,很有安全感的语言。"

愿大家都能学好Rust享受内存安全的编程体验。