上一节:【Rust 易学教程】学前准备:Cargo, 你好
今天,我们正式进入 Rust 基础的学习。在本文的内容中,我会为大家介绍以下内容:
- 基本 Rust 语法: 变量、标量和复合类型、枚举、结构、引用、函数和方法。
- 控制流结构:
if、if let、while、while let、break 和 continue
。 - 模式匹配: 解构枚举、结构体和数组。
Rust 是个啥
Rust 是一种新的编程语言,在 2015 年发布了 1.0 版本,我会从以下方面让你知道 Rust 出现的意义:
- Rust 是一种静态编译语言,其作用与 c++ 类似。
rustc
使用LLVM
作为它的编译框架(关于LLVM
的具体了解,可以访问其官方网址:https://llvm.org/)。 - Rust 支持许多平台和架构。例如 x86, ARM, WebAssembly, …等架构,以及 Linux, Mac, Windows, …等平台。
- Rust 被用于广泛的设备,如 固件和引导加载的启动程序、智能显示设备、移动电话、桌面、服务器等等。
我们发现,Rust 有与 c++ 相同的特性:
- 高的灵活性。
- 高度控制。
- 可以缩小到非常受限的设备,如微控制器。
- 没有运行时或垃圾收集。
- 在不牺牲性能的前提下,注重可靠性和安全性。
Hi,I am Rust
了解了 Rust 是什么后,就让我们先来体验一番 Rust 最简单的程序:
fn main() {
println!("Hi, I am Rust!");
}
从上面的代码中,我们看到 rust 代码具有如下一些特征:
- 函数由
fn
引入。 - 像 C 和 c++ 一样,块由花括号分隔。
main
函数是程序的入口点。- Rust 有卫生宏(
hygienic macros
),println!
就是它的一个例子。 - Rust 字符串是
UTF-8
编码的,可以包含任何Unicode
字符。
什么是 卫生宏?卫生宏和普通宏的区别有点类似词法作用域函数和动态作用域函数的区别。比如宏调用处有个名字 name1,同时宏内部也有一个名字 name1,那么卫生宏展开的时候就会把自己内部的 name1 改名成 name2;普通宏则不改名,“捕捉”外部的名字。
为了方便你理解,我在这里再小结一下上面的内容:
-
Rust 非常像其他遵循 C/ c++ /Java 范式的传统语言。
-
Rust 是现代的,完全支持
Unicode
之类的东西。 -
Rust 在需要可变数量的参数(不允许函数重载)的情况下使用宏。
宏是“卫生的”,意味着它们不会意外地从它们所使用的范围中捕获标识符。Rust 宏实际上只是部分卫生的。 -
Rust 是多范式的。例如,它具有强大的面向对象编程特性,而且,虽然它不是函数式语言,但它包含了一系列函数式概念。
根据上面的小结,你是否也能发现 Rust 的一些独特卖点:
- 编译时内存安全。例如,Rust 通过借用检查器消除了整个类的运行时错误,得到了像 C和 c++ 一样的性能,但没有内存不安全的问题。此外,还可以获得具有模式匹配和内置依赖项管理等结构的现代语言。
- 缺少未定义的运行时行为。
- 现代语言的特点。例如,可以获得像 C和c++ 那样快速且可预测的性能(没有垃圾收集器)以及访问低级硬件。
为什么是 Rust
接下来,我会为你从几个方面介绍为什么 Rust 会在众多语言中突出重围。先来一个示例。
示例:C 语言是怎么做的
首先,我们来看一个 C 语言的例子:
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
int main(int argc, char* argv[]) {
char *buf, *filename;
FILE *fp;
size_t bytes, len;
struct stat st;
switch (argc) {
case 1:
printf("Too few arguments!\n");
return 1;
case 2:
filename = argv[argc];
stat(filename, &st);
len = st.st_size;
buf = (char*)malloc(len);
if (!buf)
printf("malloc failed!\n", len);
return 1;
fp = fopen(filename, "rb");
bytes = fread(buf, 1, len, fp);
if (bytes = st.st_size)
printf("%s", buf);
else
printf("fread failed!\n");
case 3:
printf("Too many arguments!\n");
return 1;
}
return 0;
}
上面的代码中,你发现了多少 bug?
尽管只有29行代码,但这个 C 语言示例中至少有 11 行包含了严重的错误:
- 赋值
=
而不是相等比较==
(第28行) printf
的多余参数(第23行)- 文件描述符泄漏(在第26行之后)
- 多行
if
中忘记使用大括号(第22行) - 在
switch
语句中忘记了中断(第32行) - 忘记了
buf
字符串的null
终止,导致缓冲区溢出(第29行) - 不释放
malloc
分配的缓冲区导致内存泄漏(第21行) - 越界访问(第17行)
- 未检查
switch
语句中的情况(第11行) - 未检查
stat
和fopen
的返回值(第18行和第26行)
即使对于 C 编译器,这些错误也不应该很明显吗?
不,令人惊讶的是,即使在最新的GCC版本(撰写本文时为13.2)中,该代码也会在默认警告级别下编译无警告。
这不是一个非常不现实的例子吗?
绝对不是,这类错误在过去会导致严重的安全漏洞。例如:
- 赋值
=
代替相等比较==
: 2003年 Linux 后门尝试漏洞 - 忘记在多行 if 中使用大括号: Apple的
goto fail
漏洞 - switch 语句中被遗忘的中断: 中断 sudo 的中断
那么,你可能会问 Rust 在这里又能好到哪里去呢?
—— Safe Rust
使所有这些 bug 都不可能出现,例如以下:
- 不支持if子句中的赋值。
- 格式字符串在编译时进行检查。
- 资源通过
Drop
特性在作用域结束时被释放。 - 所有
if
子句都需要大括号。 match(在Rust中相当于switch)
不会失败,因此开发者不会不小心忘记了break
。- 缓冲区切片携带它们的大小,不依赖于 NULL` 终止符。
- 当相应的 Box 离开作用域时,通过
Drop
特性释放堆分配的内存。 - 越界访问会导致
panic
,或者可以通过切片的get
方法进行检查。 match
会要求所有 case 都要得到处理。- 易出错的 Rust 函数返回的 Result 值需要拆封,从而检查是否成功。此外,如果没有检查带有
#[must_use]
标记的函数的返回值,编译器会发出警告。
编译时验证
编译时的静态内存会进行如下验证:
- 验证没有未初始化的变量。
- 验证没有内存泄漏。
- 验证没有
double-frees
。 - 验证
use-after-free
。 - 验证
NULL
指针。 - 验证忘记锁定的互斥锁。
- 验证线程之间没有数据竞争。
- 验证迭代器是否失效。
运行时验证
以下行为将会判定为是在运行时无未定义的行为:
- 检查数组访问的边界。
- 定义了整数溢出(panic 或 wrap-around)。
整数溢出是通过编译时溢出检查标志定义的。如果启用,程序将陷入奔溃,否则开发者将获得环绕语义。默认情况下,将在调试模式(
cargo build
)和发布模式(cargo build --release
)中获得 panic。
不能使用编译器标志禁用边界检查。它也不能直接使用不安全关键字禁用。但是,不安全允许开发者调用诸如
slice::get_unchecked
之类的函数,这些函数不进行边界检查。
Rust 具备现代语言的特性
Rust 是用过去几十年积累的所有经验构建起来的,汲取几大语言的精华,又进行了改进。在语言特性上,它具备以下几点:
- 枚举和模式匹配。
- 泛型。
- 没有额外的
FFI
。 - 零成本抽象。
在工具支持上,具备以下几点:
- 良好的编译器错误检测。
- 内置依赖项管理器。
- 内置测试的支持。
- 优秀的语言服务器协议支持。
往更细的说,主要是以下几点:
-
零成本抽象,类似于c++,意味着你不必为使用内存或 CPU 的高级编程结构“付费”。例如,使用 For 编写循环应该产生与使用
.iter().fold()
结构大致相同的低级指令。 -
值得一提的是,Rust 枚举是“代数数据类型”,也被称为“和类型”,它允许类型系统表达像
Option<T>和Result<T, E>
这样的东西。 -
提醒开发者关注错误——许多开发者已经习惯忽略冗长的编译器输出。Rust 编译器明显比其他编译器更健谈。它通常会为开发者提供可操作的反馈,准备复制粘贴到你的代码中。
-
与Java、Python和Go等语言相比,Rust 标准库很小。Rust 没有提供一些你可能认为是标准和必要的东西,例如:
-
- 一个随机数生成器,但开发者请参阅 rand。
-
- 支持SSL或TLS,但开发者请参阅 rusttls。
-
- 对JSON的支持,开发者可以参阅 serde_json。这背后的原因是标准库中的功能不能消失,所以它必须非常稳定。对于上面的例子,Rust 社区仍在努力寻找最佳解决方案——也许对于其中的一些事情没有单一的“最佳解决方案”。
Rust 附带了 Cargo
形式的内置包管理器,这使得下载和编译第三方 crate
变得非常简单。这样做的结果是标准库可以更小。https://lib.rs 这个网站可以帮助你找到更多第三方库。
rust-analyzer
对主要的ide
和文本编辑器实现了支持。
基础语法
大部分 Rust 语法对于 C、c++或Java
来说都很熟悉。例如:
- 块和作用域由花括号分隔。
- 行注释以
//
开头,块注释以/*…* /
。 - 像
if
和while
这样的关键词的工作原理是一样的。 - 变量赋值用
=
完成,比较用==
完成。
标量类型
类型 | 示例 | |
---|---|---|
有符号整数 | i8, i16, i32, i64, i128, isize | -10, 0, 1_000, 123_i64 |
无符号整数 | u8, u16, u32, u64, u128, usize | 0, 123, 10_u16 |
浮点数 | f32, f64 | 3.14, -10.0e20, 2_f32 |
字符串 | &str | “foo”, “two\nlines” |
Unicode标量值 | char | ‘a’, ‘α’, ‘∞’ |
布尔型 | bool | true, false |
上方表格中,数字中的所有下划线都可以省略,它们只是为了便于阅读。因此,1_000
可以写成1000(或10_00)
,123_i64
可以写成123i64
。
同时,上面的类型的宽度如下:
- iN, uN 和 fN 是 N 位宽,
- Isize 和 usize是指针的宽度,
- Char 是 32 位宽,
- Bool 是 8 位宽。
除此之外,原始字符串允许开发者创建一个转义值,如: r"\n" == "\\n"
。你可以嵌入双引号,在引号的两边加上等量的#:
fn main() {
println!(r#"<a href="link.html">link</a>"#);
println!("<a href=\"link.html\">link</a>");
}
字节(Byte)字符串允许你直接创建&[u8]
值:
fn main() {
println!("{:?}", b"abc");
println!("{:?}", &[97, 98, 99]);
}
复合类型
类型 | 示例 | |
---|---|---|
数组 | [T; N] | [20, 30, 40], [0; 3] |
元组 | (), (T,), (T1, T2), … | (), (‘x’,), (‘x’, 1.2), … |
数组的赋值和访问:
fn main() {
let mut a: [i8; 10] = [42; 10];
a[5] = 0;
println!("a: {a:?}");
}
- 数组类型
[T;N]
保存了N
个(编译时常量)相同类型 t 的元素。注意,数组的长度是其类型的一部分,这意味着[u8;3]
和[u8;4]
被认为是两种不同的类型。 - 可以使用字面量给数组赋值。
- 添加
#
,例如{a:#?}
,可以有“漂亮的输出”格式,这样更容易阅读。
元组的赋值和访问:
fn main() {
let t: (i8, bool) = (7, true);
println!("t.0: {}", t.0);
println!("t.1: {}", t.1);
}
-
与数组一样,元组也有固定的长度。
-
元组将不同类型的值组合成一个复合类型。
-
元组的字段可以通过周期和值的索引来访问,例如
t.0, t.1
。 -
空元组
()
也被称为“单元类型”。它既是一个类型,又是该类型的唯一有效值——也就是说,该类型及其值都表示为()
。例如,它用于表示函数或表达式时没有返回值。
引用类型
和c++一样,Rust 也有引用类型:
fn main() {
let mut x: i32 = 10;
let ref_x: &mut i32 = &mut x;
*ref_x = 20;
println!("x: {x}");
}
需要注意的是:
- 赋值时必须解除对
ref_x
的引用,类似于 C 和 c++ 指针。 - Rust 在某些情况下会自动解除引用,特别是在调用方法时(如,
ref_x.count_ones()
)。 - 声明为
mut
的引用可以在其生命周期内绑定到不同的值。 - 一定要注意
let mut ref_x: &i32
和let ref_x: &mut i32
之间的区别。第一个表示可以绑定到不同值的可变引用,而第二个表示对可变值的引用。
悬垂引用
Rust 将静态地禁止悬垂引用:
fn main() {
let ref_x: &i32;
{
let x: i32 = 10;
ref_x = &x;
}
println!("ref_x: {ref_x}");
}
- 引用,你可以想象为为“借用”它所引用的值。
- Rust 正在跟踪所有引用的生命周期,以确保它们活得足够长。
Slices 切片
切片为开发者提供了更大集合的视图:
fn main() {
let mut a: [i32; 6] = [10, 20, 30, 40, 50, 60];
println!("a: {a:?}");
let s: &[i32] = &a[2..4];
println!("s: {s:?}");
}
上述代码中,我们通过借用 a 并在括号中指定起始和结束索引来创建切片。
-
如果切片从索引0开始,Rust的范围语法允许我们删除起始索引,这意味着
&a[0.. .len()]
和&a[.. .. len()]
是相同的。 -
对于最后一个索引也是如此,所以
a &a[2.. .len()]
和a &a[2..]
都是一样的。
因此,为了方便地创建整个数组的切片,我们可以使用&a[…]
。
S是对i32s
切片的引用。注意,s (&[i32])
的类型不再提到数组长度。这允许我们对不同大小的切片执行计算。
切片总是从另一个对象借用。在本例中,a 必须保持“活动”(在作用域中)至少与我们的切片一样长。
String Vs Str
现在我们可以理解Rust中的两种字符串类型:
fn main() {
let s1: &str = "World";
println!("s1: {s1}");
let mut s2: String = String::from("Hello ");
println!("s2: {s2}");
s2.push_str(s1);
println!("s2: {s2}");
let s3: &str = &s2[6..];
println!("s3: {s3}");
}
-
&str: 对字符串切片的不可变引用
-
String: 可变字符串缓冲区
-
&str
引入了一个字符串切片,它是对存储在内存块中的UTF-8编码字符串数据的不可变引用。字符串字面值(" Hello ")存储在程序的二进制文件中。 -
Rust 的 String 类型是一个字节向量的包装器。与
Vec<T>
一样,它是私有的。 -
与许多其他类型一样,
String::from()
从字符串字面值创建字符串。String::new()
创建一个新的空字符串,可以使用push()
和push_str()
方法向其添加字符串数据。 -
宏是一种从动态值生成私有字符串的方便方法。它接受与
println!()
相同的格式规范。 -
你可以通过
&
和可选的范围选择从 String 中借用&str
切片。 -
对于c++程序员: 你可以将
&str
看作c++
中的const char*
,但它总是指向内存中的有效字符串。RustString
大致相当于c++中的std:: String
(主要区别:它只能包含UTF-8编码的字节,并且永远不会使用小字符串优化)。
Functions
Methods
方法是与类型相关联的函数。方法的self参数是它所关联类型的一个实例:
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn area(&self) -> u32 {
self.width * self.height
}
fn inc_width(&mut self, delta: u32) {
self.width += delta;
}
}
fn main() {
let mut rect = Rectangle { width: 10, height: 5 };
println!("old area: {}", rect.area());
rect.inc_width(5);
println!("new area: {}", rect.area());
}
- 添加一个名为Rectangle::new的静态方法,并从 main 调用它:
fn new(width: u32, height: u32) -> Rectangle {
Rectangle { width, height }
}
-
虽然从技术上讲,Rust没有自定义构造函数,但静态方法通常用于初始化结构。实际的构造函数
Rectangle {width, height}
可以直接调用。 -
添加
Rectangle::square(width: u32)
构造函数来说明此类静态方法可以接受任意参数。
函数重载
不支持重载:
-
每个函数有一个单独的实现:
-
- 总是有固定数量的参数。
-
- 总是接受一组参数类型。
-
不支持默认值:
-
- 所有调用站点都具有相同数量的参数。
-
- 有时使用宏作为替代方法。
然而,函数参数可以是泛型的:
fn pick_one<T>(a: T, b: T) -> T {
if std::process::id() % 2 == 0 { a } else { b }
}
fn main() {
println!("coin toss: {}", pick_one("heads", "tails"));
println!("cash prize: {}", pick_one(500, 1000));
}
当使用泛型时,标准库的Into可以在参数类型上提供一种有限的多态性。这一点我将在后面的小节中介绍更多细节。