Rust 101 入门
最近,我有机会在一个项目中试用 Rust,这是一次令人惊喜的体验。Rust 是一门现代语言,几天下来我就觉得非常熟悉了。它的设计简单而有效,正因为如此,它经常被称为 C 语言等老式低级语言的继承者。Rust 运行速度很快,在很多基准测试中,编译后的代码性能可与 C 和 C++ 相媲美。我还没有使用过其他类似低级语言(如 Nim 或 Zig)的经验,但我相信 Rust 在过去几年中大受欢迎是有原因的。
在本文中,我将简要概述 Rust 最重要的概念,我发现这些概念让 Rust 成为了一种与前辈语言不同的、更先进的语言。Rust 文档写得很好,并附有大量示例,可以让你在很短的时间内使用这门语言。
内存安全
Rust 是一种内存安全语言,这意味着它的设计可以防止程序员进行可能引发内存故障和程序崩溃的操作。如果你写过 C 或 C++,你就知道我说的是哪种错误。这类问题很难发现和调试,而且在复杂的程序中,很容易使用已经释放的内存或忘记释放内存。
管理程序内存主要有两种方法。一种是将内存管理权交给程序员(如 C、C++),另一种是使用垃圾回收器(Garbage Collector),它将为你清理未使用的已分配内存部分(如 Lisp、Java、Python、JS)。手动释放内存会让大型复杂程序变得复杂,而自动垃圾收集器则会随意降低执行速度,而且也不容易控制。
Rust 带来了一种不同的方法:编写的程序在设计上实际上是内存安全的。它不公开内存管理函数,也不使用外部工具来完成这项工作。它引入了 “所有权”(ownership)的概念,这是一套在编译时检查代码的规则,以防止你进行破坏变量所有权的操作。
所有权
这是所有权规则:
- Rust 中的每个值都有一个所有者。
- 一次只能有一个所有者。
- 当所有者离开作用域时,该值将被丢弃。
let s1 = String::from("hello"); // s1 is the owner of the String
let s2 = s1; // s2 becomes the new owner, and s1 is invalidated
println!("{}, world!", s1); // This will cause a compile-time error because s1 is no longer valid
之前的代码无法编译。编译器甚至会提示错误原因:error[E0382]: borrow of moved value: s1.
(错误[E0382]:移动值的借用:s1)。我们可能会认为字符串 s1 应该还在,但实际上 Rust 已经在字符串结构上调用了 drop
,去分配了内存段,使其不再有效。
Rust 妨碍程序员的工作是有原因的:遵循这些规则可以防止内存去分配时出现问题,例如忘记去分配某些东西、访问已被去分配的资源或重复释放变量。作为交换,Rust 正在强制执行一些模式,并提供一些数据结构来解决所有的所有权问题。下面是我在 reddit 上找到的一个有用的模式,它能快速为你指出在每种情况下使用哪种数据结构更好(T
是通用数据类型)。
source: https://www.reddit.com/r/rust/comments/mgh9n9/ownership_concept_diagram/
Borrowing 借用
Borrowing 借用是一种机制,它允许你创建对另一个变量所拥有数据的引用,从而使代码的多个部分都能访问并可能修改相同的数据,而无需转移所有权。
fn main() {
let mut s = String::from("hello");
change(&mut s);
}
fn change(some_string: &mut String) {
some_string.push_str(", world");
}
在上面的代码中,我创建了一个String
对象的可变引用,并将其传递给一个修改字符串的函数。这种行为称为 “借用(borrowing)”,因为我们借用了该值,并在使用完毕后将其还给了调用者。
在 Rust 中,我们不能多次借用可变变量。这种防止对同一数据进行多次可变引用的限制有助于在编译时防止数据竞赛。
结构和对象
Rust 允许使用 struct
和 enum
创建自定义数据类型。结构体介于 C 结构体和 C++ 类之间。它可以作为对象使用,并能访问self
,但缺少对继承的全面支持。
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn new(w: u32, h: u32) -> Rectangle {
Rectangle {
width: w,
height: h,
}
}
fn area(&self) -> f64 {
(self.width * self.height) as f64
}
}
fn main() {
let mut rect = Rectangle::new(3, 4);
println!("Area: {}", rect.area());
}
我们定义了一个新的 struct Rectangle
,它有一个简单的 area
方法,可以使用对自身的引用来访问属性。
在文件中定义新结构体时,只需避免在方法中使用 pub
即可对其进行封装。这样,这些方法从外部看不到,但从内部仍然可以访问。
Traits
多态性可以通过定义一个特质(Trait
)来实现,该特质增加了结构间共享行为的可能性。
trait Area {
fn area(&self) -> f64;
}
impl Area for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
}
在这里,我定义了一个 trait Area
,每个形状结构体都可以用自己的方式实现它。该 trait 只是函数签名、参数和预期返回类型。
Enum
枚举(Enum)只是定义该类型变量可以取值的一组值的一种方法。在接受使用 impl
和自定义方法和 trait 的意义上,它与 struct
相似。
enum Animal {
Bird,
Insect,
Fish,
Mammal,
}
模式匹配
模式匹配是 Rust 设计中必不可少的一种结构。它允许使用变量的可能状态自动匹配变量值,类似于其他语言中的语句 case
。我使用的操作符 match
可以接收变量,并在值匹配时执行代码。
impl Animal {
fn can_fly(&self) -> bool {
match self {
Animal::Bird => true,
Animal::Insect => true,
_ => false, // Covers Fish and Mammal
}
}
}
例如,这里匹配的是 Animal
类型变量的所有可能状态。选项 _
是一个包罗万象的模式,代表变量可能具有的所有其他状态。
Result 和 Option
Rust 标准库中的两个枚举类型是 Result
和 Option
。这些值用于处理需要管理的错误和可能不存在的值。要访问这些值,需要使用上面所示的模式匹配方法。
Result
用于函数可能失败,需要捕获异常并进行处理的情况。Rust 允许函数抛出错误,然后通过对函数返回值进行match
,从调用者那里捕获错误。
use std::fs::File;
use std::io;
fn read_file(path: &str) -> Result<String, io::Error> {
let file = File::open(path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}
fn main() {
match read_file("example.txt") {
Ok(contents) => println!("File contents: {}", contents),
Err(error) => println!("Error reading file: {}", error),
}
}
?
是 Rust 中的一个特殊运算符,可以直接返回错误信息,省去了编写另一个匹配的模板。
Option
则用于返回可选值,因为 Rust 没有 null
的概念(也没有 Null 指针异常)。
fn find_first_even(numbers: &[i32]) -> Option<i32> {
for num in numbers {
if num % 2 == 0 {
return Some(*num);
}
}
None
}
fn main() {
let numbers = vec![1, 3, 5, 7, 8];
match find_first_even(&numbers) {
Some(even) => println!("First even number: {}", even),
None => println!("No even numbers found"),
}
}
函数式编程
Rust 从函数式编程中引入了闭包和迭代器。利用函数式编程的函数集,编写内联函数和遍历数组都很容易。
let numbers = vec![1, 2, 3, 4, 5];
let doubled: Vec<_> = numbers.iter().map(|x| { x * 2 }).collect();
在上面的代码中,我在数字列表上使用了 map
,并在其上运行了 lambda 函数 |x| x * 2
,事实上创建了一个闭包。如果需要在结构体中添加迭代支持,则需要实现Iterator
trait,如文档所示。
Analyzer
rust-analyzer 可以帮助你理解 Rust 以及该语言引导你使用的模式。当它集成到你最喜欢的编辑器(我现在用的是 Zed)中时,你会看到保存文件时直接生成的警告和错误。
让我们试着分析一些有问题的代码
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("{}", s1);
}
代码有什么问题?
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:4:20
|
65 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
66 | let s2 = s1;
| -- value moved here
67 | println!("{}", s1);
| ^^ value borrowed here after move
|
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
|
66 | let s2 = s1.clone();
| ++++++++
Rust 的意思是,当代码执行 s2 = s1
时,会移动 s1
的值,因此在 println
调用中不能再使用它。解决方法是将 s1
克隆到 s2
中,这样就有了两个独立的字符串实例。找到快速解决方案非常方便,尤其是对于初学者。Rust 团队在创建高质量的错误信息以引导用户找到解决方案方面做得非常出色。
宏
Rust 支持不同类型的宏:声明式宏和过程式宏。宏可以帮助扩展语言,删除模板代码,甚至使用外部代码(如插件)扩展程序。
声明式宏的调用方式与函数类似,只是在名称后附加 !
#[macro_export]
macro_rules! vec {
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
}
let v = vec![1, 2, 3, 4]; // Creates a new vec inline
我的建议是避免使用宏,尽可能使用函数。宏一旦变得复杂,就很难理解、维护和记录。
Cargo 和 packages
cargo
是 Rust 的官方软件包管理器。它会下载依赖包,并跟踪项目支持的 Rust 版本。它可用于与项目相关的所有任务,如运行、构建、生成文档等。软件包注册中心是 crates.io,目前已拥有超过 140K 个软件包。
测试
Rust 在语言中直接提供了一个测试框架,支持单元测试和集成测试。cargo test
就是如何运行项目的所有测试。
这是一个单元测试示例,包含在函数实现的同一文件中。
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
let result = 2 + 2;
assert_eq!(result, 4);
}
}
何时使用 Rust
Rust 目前非常流行,但我认为它并不能解决所有类型的问题。当对性能有要求时,与其使用 C 或 C++,不如使用 Rust,因为 Rust 可以大大提高性能。Rust 的速度几乎和 C 一样快,但它绝对更安全,选择它将会得到回报…
Rust 每年都会出现新的用户界面框架,尽管它不是面向对象的,但我相信它将成为创建下一代桌面应用程序的首选语言。看看 Tauri,它是比 Electron 更快的替代品,或者看看新的 GPUI,它使用本地库,看起来很有前途。
本文文字及图片出自 Rust 101