2024-04-06 17:36:23 +08:00

25 KiB
Raw Blame History

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] 下的字段 nameversionedition 表示项目的名称、项目的版本和使用的 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 中,使用 letconst 声明的变量默认都是不可变的,通过给 let 声明的变量加上 mut 标记,来让 guess 这个变量可变mutatiable

::new 中的 :: 表明:newString 类型的一个关联函数在一些语言中它被称之为静态方法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 实例的值是 Errexpect 会导致程序崩溃,并显示错误信息。

如果 read_line 返回 Err,则可能是来源于底层操作系统错误的结果。如果 Result 实例的值是 Okexpect 会获取 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 也是一个枚举,其成员包含 LessGreaterEqual,这是在两个值进行比较时可能出现的三种结果。

cmp 方法用于比较两个值,并且可以在任何可比较的值上调用。它获取一个被比较值的引用:将 guesssecret_number 作比较。然后返回一个通过 use 引入作用域的 Ordering 枚举的成员。

使用 match 表达式,根据对 guesssecret_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 不能从字符串生成一个数字,返回一个 ResultErr 成员时,expect 方法会使程序结束并打印附加的信息。
  • 如果 parse 成功执行,那么它会返回 ResultOk 成员,然后 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语言编译时必须知道所有变量的类型当多种类型均有可能时例如使用 parseString 转换为数字时,必须增加类型注解,像这样:

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 可以存储 02^8-1 的数字,也就是 0 到 255。

另外,isizeusize 类型依赖运行程序的计算机架构:在 64 位架构上,它们是 64 位的,在 32 位架构上,它们是 32 位的。分别等价于 i64 i32u64u32

除了通过类型指定变量的整型类型,还可以以后缀形式使用类型,例如 let x = 57u8;。还可以通过数字字面值来指定类型:

10001_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'; 语句将直接退出外层循环。

除了 loopRust 还支持通过 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!!!");
}

所有权

什么是所有权

引用与借用

Slice 类型

结构体

结构体的定义与初始化

示例

方法语法

枚举和模式匹配

枚举的定义

match 控制流结构

if let 简洁控制流