25 KiB
Rust
Rust 环境搭建
rustup
是一个用于管理 Rust 版本和相关工具的命令行工具。
Unix 系统:
$ curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh
Windows 系统:
下载并安装 rustup-init.exe.
安装完毕后,在命令行执行:
rustc --version
可以看到输出的版本号信息,则 rust 已安装完毕。
Hello, Rust!
mkdir hello_rust
创建并编辑第一个 Rust 程序:
// main.rs
fn main() {
println!("Hello, Rust!");
}
执行 rustc ./hello_rust/main.rs
可以看到在代码同目录下输出了二进制文件 main
,在命令行中执行:
./main
可以看到输出:
> Hello, Rust!
下面我们分析一下这个程序:
在 Rust 中,函数名为 main
的函数是一个特殊的函数,它总是会被最先执行:
fn main() {
}
在函数体中的代码:
println!("Hello, Rust!")
println!
是一个 Rust 宏(macro),它与函数调用的区别是它以 !
结尾。
"Hello, Rust!"
是一个字符串,传递给了 println!
宏。
Rust 程序的编译和运行是独立进行的,这意味着你可以将编译产物直接发送给别人,别人不需要安装 Rust 也可以运行
这与 Ruby Python JavaScript 这类动态语言不同,Rust 是一门预编译静态语言(ahead-of-time compiled)
简单项目可以使用 rustc,但随着项目复杂度增长,我们可以使用 cargo 来管理项目中的三方依赖、管理真实世界中 Rust 程序开发的方方面面。
Cargo
# 初始化一个 Cargo 项目
cargo new hello_cargo
执行 cargo new
后会自动帮你初始化一个 Git 仓库,如果你是在一个现存的 Git 仓库中执行的初始化,那么就不会执行此操作。
除了帮你创建了一个 HelloWorld 代码,cargo
还创建了一个 cargo.toml
文件:
[package]
name = "hello_cargo"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
[package]
、[dependencies]
分别代表是一个片段:
- 其中
[package]
下的字段name
、version
和edition
表示项目的名称、项目的版本和使用的 Rust 版本。 [dependenciese]
中记录着项目的第三方依赖,这些依赖被称为 crates
Cargo 期望所有的源代码都存放在 src/
目录下,项目根目录中保存如 README、LICENSE 这类的文件。
在 Cargo 中构建和运行项目
执行 cargo build
可以构建项目:
cargo build
构建产物将输出在 target/debug/
目录下,这是因为 cargo build
是调试构建(debug build)
执行:
cargo run
即可运行刚刚 build 输出的产物。如果你在 cargo run
之前未构建或修改了代码,cargo
会自动帮你完成 re-build 并执行代码。
cargo check
这个命令可以帮你完成代码的静态检查且不输出任何文件,由于它不需要准备输出构建产物,所以它比 cargo build
要快得多。
发布构建
与调试构建不同,可以执行:
cargo build --release
来构建一个用于生产环境的产物,这会在 target/release/
下输出产物而不是 target/debug/
下。
发布构建的产物往往有针对生产的更多优化,同时构建需要花费的时间也更长,这也是为什么要有调试构建与发布构建的区分:调试构建用于开发时更快的看到最终效果,需要经常快速地执行构建,而发布构建则是为了最终用户使用时构建的。
Gussing Game
写一个猜数游戏:
使用 use
标识符来从标准库中引入 io
库,之后就可以在当前作用域中通过 io:stdin()
读取到用户输入:
use std::io;
fn main() {
println!("Guess the number!");
println!("Please input your guess.");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Failed to read line");
println!("You guessed: {guess}");
}
执行 cargo run
后测试一下:
Guess the number!
Please input your guess.
6
You guessed: 6
使用变量保存数据
在 Rust 中,使用 let
与 const
声明的变量默认都是不可变的,通过给 let
声明的变量加上 mut
标记,来让 guess 这个变量可变(mutatiable)
::new
中的 ::
表明:new
是 String
类型的一个关联函数,在一些语言中它被称之为静态方法(static function)
总的来说,let mut guess = String::new();
这一行创建了一个可变变量,当前它绑定到一个新的 String
空实例上。
读取用户输入
如果在程序开头我们没有使用 use
来引入 io
库,在代码中我们也可以这样写:
std::io::stdin()
来动态地引入 io
库,通过 read_line
来从标准输入句柄获取用户输入。
将 &mut guess
传递给 read_line
,其中 &
表明传递的是一个变量的引用,同时由于变量是不可变的,&mut
表示这个引用可以修改。
使用 Result 类型处理潜在错误
前文中我们说 read_line
会持续地将用户输入附加到传递给它的字符串中,它也会返回一个 Result
类型的值。
Result
类型是一个枚举类型,包含两种成员类型:
Ok
: 表示操作成功,内部包含成功时产生的值;Err
: 表示操作失败,包含失败的前因后果。
这些 Result
类型的作用是处理错误信息,Result
的实例具有 expect
方法,如果 io:Result
实例的值是 Err
,expect
会导致程序崩溃,并显示错误信息。
如果 read_line
返回 Err
,则可能是来源于底层操作系统错误的结果。如果 Result
实例的值是 Ok
,expect
会获取 Ok
中的值并原样返回。
在此例子中,这个值是用户输入到标准输入中的字节数。
使用 println! 占位符打印值
下面这两种 println!
是等价的,他们都可以将变量打印到指定位置:
println!("You guessed: {guess}");
println!("You guessed: {}", guess);
生成一个随机数
在 Rust 标准库中不包含随机数功能,我们可以使用 rand crate:
cargo add rand
安装后,我们到 Cargo.toml 中可以看到:
[dependencies]
rand = "0.8.5"
这里的 "0.8.5"
实际上是 "^0.8.5"
的简写,它表示至少是 0.8.5
但小于 0.9.0
的版本。
具体可以参看语义化版本(Semantic Versioning)
Cargo 通过 Cargo.lock 文件来保证每一次构建都是可以被重现的,任何人在任何时候重新构建代码都会产生相同的结果,Cargo 只会使用你指定的依赖版本。
如果 rand 库下周发布了
0.8.6
版本,新版本中修复了一个 BUG 但存在破坏性变更,如果你没有显式地在 Cargo.toml 中升级 rand 库,那 Cargo 会按照上一次构建成功时生成的 Cargo.lock 记录的第三方库版本来构建。
如果你确实要升级 crate,可以使用:
cargo update
来忽略 Cargo.lock 文件,并计算所有符合 Cargo.toml 声明的最新版本。
安装完了 rand crate,我们下面来生成一个随机数:
use std::io;
use rand::Rng;
fn main() {
println!("Guess the number!");
let secret_number = rand::thread_rng().gen_range(1..=100);
println!("The secret number is: {secret_number}");
println!("Please input your guess.");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Failed to read line");
println!("You guessed: {guess}");
}
use rand:Rng;
其中,Rng
是一个 trait,它定义了随机数生成器实现的方法的话,此 trait 必须在作用域中。
我们调用 rand::thread_rng
函数提供实际使用的随机数生成器:它位于当前执行线程的本地环境中,从操作系统获取 seed。
随后调用随机数生成器的 gen_range
方法,它由 Rng
trait 定义,获取一个范围表达式(Range expression)作为参数,并生成一个在此范围之间的随机数。
范围表达式使用 start..=end
这样的形式,如 1..=100
就代表 1 到 100 之间。
::: info
你不可能凭空知道应当 use 哪个 trait,以及应当从 crate 中调用哪个方法,因此每个 crate 都有说明文档。
通过调用 cargo doc --open
来构建所有本地依赖提供的文档并在浏览器中打开。
:::
对比两个数字
// 此代码不可运行
use rand::Rng;
use std::cmp::Ordering;
use std::io;
fn main() {
// --snip--
println!("You guessed: {guess}");
match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => println!("You win!"),
}
}
我们引入 std::cmp::Ordering
到作用域中。Ordering
也是一个枚举,其成员包含 Less
、Greater
和 Equal
,这是在两个值进行比较时可能出现的三种结果。
cmp
方法用于比较两个值,并且可以在任何可比较的值上调用。它获取一个被比较值的引用:将 guess
与 secret_number
作比较。然后返回一个通过 use
引入作用域的 Ordering
枚举的成员。
使用 match
表达式,根据对 guess
和 secret_number
调用 cmp
返回的 Ordering
成员,来决定下一步应该要做什么。
match
表达式由众多的分支(arms)构成,每个分支都包含一个 pattern 以及 pattern 被匹配时要执行的代码。
尝试执行此代码,编译器会抛出错误:不匹配的类型(mismatched types)。Rust 有一个静态强类型系统,同时也有类型推断。
当我们写出 let guess = String::new();
时,Rust 会帮我们推断出 guess
变量应当是 String
类型。
而 secret_number
是 1 - 100 之间的数字类型,而符合这个要求(1~100之间)的数字,在 Rust 中有下面几种:
i32
32位数字;u32
32位无符号数字;i64
64位数字- 等等 ...
Rust 默认使用 i32
,所以 Rust 默认为 secret_number
推断出的类型是 i32
,导致了字符串与数字作对比的情况。
要将 String
转化为数字类型才能与 secret_number
作比较:
let guess: u32 = guess.trim().parse().expect("Please type a number!");
上面的代码将重新声明 guess
变量,这个特性叫隐藏(Shadowing),通过 guess.trim()
去除字符串头尾的空白字符(如用户输入 5 并按下空格后,在 Unix 系统中 guess
的值为 5\n
,在 Windows 系统中 guess
的值为 5\r\n
)
guess.parse()
方法会将字符串转换为其他类型,通过给 guess
显式指定类型来告诉 guess
方法转化的目标类型,这里的目标类型是 u32
。
同时,为了防止字符串中包含特殊字符等原因导致 parse
执行失败,这里用 expect
来对转化是否成功进行提示:
- 如果
parse
不能从字符串生成一个数字,返回一个Result
的Err
成员时,expect
方法会使程序结束并打印附加的信息。 - 如果
parse
成功执行,那么它会返回Result
的Ok
成员,然后expect
会返回Ok
值中的数字。
使用循环来允许多次猜测
可以使用 loop
关键字来创建一个无限循环,给用户更多机会来猜数。
println!("The secret number is: {secret_number}");
loop {
println!("Please input your guess.");
// --snip--
match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => {
println!("You win!");
break;
},
}
}
当用户成功猜对后,会执行 break;
退出程序。
忽略非数字的猜测并继续游戏
目前的代码如果用户输入了非数字,会导致 parse
失败,进而导致程序退出,因此我们需要改写这部分的逻辑,将 expect
调用 改为 match
:
// --snip--
io::stdin()
.read_line(&mut guess)
.expect("Failed to read line");
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
println!("You guessed: {guess}");
// --snip--
这样当遇到错误时程序不再崩溃,而是进入到 match
的错误分支中处理错误:调用 continue;
继续循环。
总结
这一章里我们学习了使用 let
声明变量、变量隐藏、类型转化、match
处理多分支任务、loop
循环。
还学习了外部 crate 的使用、如何指定数据类型等
常见编程概念
变量和可变性
变量默认是不可变的(Immutable),这是 Rust 提供的众多特性之一:
下面的代码由于修改了 x
导致编译不通过:
fn main() {
let x = 5;
println!("x is {}", x);
x = 6;
println!("x is {}", x);
}
编译器抛出的错误信息:cannot assign twice to immutable variable x
要让 x
变得可变,可以在声明 let
后添加 mut
:
let mut x = 5;
这样就可以修改 x
的值了。
除了 let
,还可以通过 const
声明一个常量:
- 常量总是不可变,且不允许对常量使用
mut
- 必须在声明时注明值的类型
- 常量只能被设置为常量表达式,而不是任何只能在运行时计算出的值
const THREE_HOURS_IN_SECONDS: u32 = 60 * 60 * 3;
这里的 60 * 60 * 3
会在编译器编译时执行运算,这使我们可以选择更容易理解和验证的方式来写出这个值,而不是直接将常量设置为 10,800
。
在前文中我们介绍了变量隐藏,后声明的同名变量将会屏蔽掉之前声明的变量,直到新的变量也被隐藏或作用域结束:
fn main() {
let x = 5; // 5
let x = x + 1; // 6
{
let x = x * 2;
println!("x is {}", x); // 12
}
println!("x is {}", x); // 6
}
需要注意的是,隐藏与将变量标记为 mut
是有区别的,当对变量进行重新赋值时,如果没有使用 mut
那么会导致编译时错误。通过变量隐藏,我们可以用新的变量进行一些计算,但计算完之后变量依然是不可变的。
mut
与隐藏的另一个区别是:当再次使用 let
声明变量时,隐藏实际上创建了一个新的变量,我们可以改变值的类型,只不过复用这个名字:
如果没有变量隐藏,代码可能会像这样:
let spaces_str = " ";
let spaces_num = spaces_str.len();
利用变量隐藏,我们可以简单地复用相同变量名:
let spaces = " "; // 文本之间的空格数量
let spaces = spaces.len(); // 多少个空格
然而,如果使用 mut
,他不允许修改变量的类型:
let spaces = " ";
spaces = spaces.len(); // 错误:不能改变变量的类型
数据类型
在 Rust 中,每一个值都属于某一种数据类型(data type),这告诉 Rust 它被指定为何种数据。
Rust 是静态类型(statically typed)语言编译时必须知道所有变量的类型,当多种类型均有可能时,例如使用 parse
将 String
转换为数字时,必须增加类型注解,像这样:
let guess: u32 = "42".parse().expect("Not a number!")
在 Rust 中有两种数据类型子集:标量和复合
标量类型(scalar)
整型
整型是一个没有小数部分的数字,例如 u32
代表一个占据 32 bit 的无符号整数。其中有符号和无符号代表数字能否为负值。
长度 | 有符号 | 无符号 |
---|---|---|
8-bit | i8 | u8 |
16-bit | i16 | u16 |
32-bit | i32 | u32 |
64-bit | i64 | u64 |
128-bit | i128 | u128 |
arch | isize | usize |
有符号的整型可以存储从 -(2^{n-1})
到 2^{n-1}-1
在内的数字,这里的 n
代表位数。
例如 i8
可以存储 -(2^7)
到 2^7-1
在内的数字,也就是从 -128 到 127。
无符号的整型可以存储从 0
到 $2^n-1$的数字。
所以 u8
可以存储 0
到 2^8-1
的数字,也就是 0 到 255。
另外,isize
与 usize
类型依赖运行程序的计算机架构:在 64 位架构上,它们是 64 位的,在 32 位架构上,它们是 32 位的。分别等价于 i64
i32
与 u64
和 u32
。
除了通过类型指定变量的整型类型,还可以以后缀形式使用类型,例如 let x = 57u8;
。还可以通过数字字面值来指定类型:
1000
与 1_000
等价,但后者更易读。
数字字面值 | 例子 |
---|---|
Decimal (十进制) | 98_222 |
Hex (十六进制) | 0xff |
Octal (八进制) | 0o77 |
Binary (二进制) | 0b1111_0000 |
Byte (单字节字符)(仅限于u8) | b'A' |
浮点型
在 Rust 中有两个原生浮点数类型:
f32
单精度浮点数,占 32 位f64
双精度浮点数,占 64 位,现代 CPU 中,它与f32
速度几乎一样,不过精度更高
浮点数都是有符号的。
fn main() {
let x = 2.0; // f64
let y: f32 = 3.0; // f32
}
Rust 中的所有数字类型都支持基本数学运算:加法、减法、乘法、除法和取余。整数除法会向零舍入到最接近的整数:
fn main() {
// addition
let sum = 5 + 10;
// subtraction
let difference = 95.5 - 4.3;
// multiplication
let product = 4 * 30;
// division
let quotient = 56.7 / 32.2;
let truncated = -5 / 3; // 结果为 -1
// remainder
let remainder = 43 % 5;
}
布尔型
let t = true;
let f: bool = false;
字符类型
Rust 的 char
类型是语言最原生的字母类型,大小为四个字节:
fn main() {
let c = 'z';
let z: char = 'ℤ'; // 显式类型声明
let heart_eye_cat = '😻';
}
复合类型(compound)
复合类型(Compound types)可以将多个值组合成一个类型。Rust 有两个原生的复合类型:元组(tuple)和数组(array)。
元组类型
元组是一个将多个其他类型的值组合进一个复合类型的主要方式。元组长度固定:一旦声明,其长度不会增大或缩小。
fn main() {
let tup: (i32, f64, u8) = (500, 6.4, 1);
}
可以使用模式匹配(pattern matching)来解构(destructure)元组值:
fn main() {
let tup = (500, 6.4, 1);
let (x, y, z) = tup;
println!("{}", y); // 6.4
}
也可以直接用 .
跟随值的索引来访问元组中的元素:
fn main() {
let tup = (500, 6.4, 1);
let x = tup.0;
let y = tup.1;
let z = tup.2;
println!("{}", x); // 500
}
不带任何值的元组有个特殊的名称,叫做 单元(unit) 元组。这种值以及对应的类型都写作 ()
,表示空值或空的返回类型。如果表达式不返回任何其他值,则会隐式返回单元值。
数组类型
与元组不同,数组中每个元素的类型必须相同。
Rust 中的数组长度是固定的。
fn main() {
let a = [1, 2, 3, 4, 5];
}
当你想要在栈(stack)而不是在堆(heap)上为数据分配空间(第四章将讨论栈与堆的更多内容),或者是想要确保总是有固定数量的元素时,数组非常有用。
但是数组并不如 vector
类型灵活。
vector
类型是标准库提供的一个 允许 增长和缩小长度的类似数组的集合类型。当不确定是应该使用数组还是 vector 的时候,那么很可能应该使用 vector
。
然而,当你确定元素个数不会改变时,数组会更有用。
例如,当你在一个程序中使用月份名字时,你更应趋向于使用数组而不是 vector
,因为你确定只会有 12 个元素。
let months = ["January", "February", "March", "April", "May", "June", "July",
"August", "September", "October", "November", "December"];
声明数组时,可以像这样编写数组的类型,既能约束数组中元素的类型,还能限制数组的长度:
let a: [i32; 5] = [1, 2, 3, 4, 5];
这里的 i32
代表每个元素的类型,分号之后的 5
代表数组的长度为 5,包含五个元素。
还可以在类型声明中指定初始值:
let b: [3, 5];
这样变量 b
就是一个长度为 5,初始值全为 3 的数组。
数组是可以在栈 (stack) 上分配的已知固定大小的单个内存块。可以使用索引来访问数组的元素,像这样:
let c = [1, 2, 3, 4, 5];
let x = c[0]; // 1
let y = c[1]; // 2
通过索引从数组中取值的操作如果是在运行时进行的,那么代码可以顺利通过编译,但在运行时会出错:
下面这段代码可以正常通过编译,当你输入 0 1 2 3 4 访问数组时工作正常,但一旦输入了超过数组长度的索引如 10,就会抛出错误。
use std::io;
fn main() {
let a = [1, 2, 3, 4, 5];
println!("Please enter an array index.");
let mut index = String::new();
io::stdin()
.read_line(&mut index)
.expect("Failed to read line");
let index: usize = index
.trim()
.parse()
.expect("Index entered was not a number");
let element = a[index];
println!("The value of the element at index {index} is: {element}");
}
thread 'main' panicked at 'index out of bounds: the len is 5 but the index is 10', src/main.rs:19:19
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
函数
- 必须显式指定参数的类型
- 通过
->
指定返回值的类型 - 函数结尾不包含分号时,隐式返回表达式
fn main() {
print_labeled_measurement(188, 's');
let f: i32 = five();
println!("five: {}", f);
}
fn print_labeled_measurement(value: i32, unit_label: char) {
println!("The measurement is: {value}{unit_label}");
}
fn five() -> i32 {
// 结尾不包含分号 隐式返回表达式
5
}
控制流
if-else & else-if
- 可以省略 if 与条件之间的空格
- 不允许隐式转换
fn main() {
let number = 3;
if number < 5 {
println!("Yes.");
} else {
println!("No.");
}
}
// 不允许隐式转换 条件表达式必须返回一个布尔值
if number {
println!("Yes.");
}
if number != 0 {
println!("Yes.");
}
if
可以返回一个值,因此可以在 let
语句中使用 if
:
fn main() {
let condition = true;
// if 与 else 分支的结果都为 i32
let number = if condition { 5 } else { 6 };
println!("number: {}", number);
}
由于类型必须在编译时被确定,编译器会自动识别出不符合这一原则的 if-in-let 声明:
fn main() {
// 编译报错 因为 if 与 else 分支的结果类型不同
let number = if condition { 5 } else { "six" }
}
循环
break;
用于中止循环continue;
用于跳过当次循环break;
可以从循环返回表达式
fn main() {
let mut count = 0;
let result = loop {
count += 1;
if (count == 10) {
break count * 2;
}
}
println!("result: {}", result); // 20
}
循环标签:在多个循环之间消除歧义
如果你存在一个嵌套的循环,而 break;
与 continue;
只会应用于此时最内层的循环,可以通过循环标签来让这些关键字应用于已标记的循环:
fn main() {
let mut count = 0;
'counting_up: loop {
println!("count = {count}");
let mut remaining = 10;
loop {
println!("remaining = {remaining}");
if remaining == 9 {
break;
}
if count == 2 {
break 'counting_up;
}
remaining -= 1;
}
count += 1;
}
println!("End count = {count}");
}
上面的代码中,第一个 break;
语句只会退出内层循环,而 break 'counting_up';
语句将直接退出外层循环。
除了 loop
,Rust 还支持通过 while
来控制循环:
fn main() {
let mut number = 3;
while number != 0 {
println!("{number}!");
number -= 1;
}
println!("LIFTOFF!!!");
}
当我们要实现遍历集合中的元素时,用 for
会更方便:
fn main() {
let a = [10, 20, 30, 40, 50];
for element in a {
println!("value is {}", a);
}
}
for
亦可用于计时:
这段代码中用到了 .rev()
方法来将 range 反转
fn main() {
for number in (1..4).rev() {
println!("{number}!");
}
println!("LIFTOFF!!!");
}