1. 1. 第1章 入门安装
    1. 1.1. 1. Cargo.toml配置文件
    2. 1.2. 2. Cargo.lock 文件
    3. 1.3. 3. Cargo常用命令
    4. 1.4. 4. 总结
  2. 2. 第2章 猜数字游戏
    1. 2.1. 1. read_line 入参
    2. 2.2. 2. read_line 返回值
    3. 2.3. 3. crate 和外部依赖
    4. 2.4. 4. Cargo.lock 文件
    5. 2.5. 5. match 表达式
  3. 3. 第3章 常见编程概念
    1. 3.1. 3.1 变量和可变性
    2. 3.2. 3.2 数据类型
    3. 3.3. 3.3 函数
    4. 3.4. 3.4 注释
    5. 3.5. 3.5 控制流
  4. 4. 第4章 所有权
    1. 4.1. 4.1 什么是所有权
    2. 4.2. 4.2 引用与借用
    3. 4.3. 4.3 Slice类型
  5. 5. 第5章 结构体
    1. 5.1. 5.1结构体的定义和实例化
    2. 5.2. 5.3 方法语法
  6. 6. 第6章 枚举和模式匹配
    1. 6.1. 6.1 枚举的定义
      1. 6.1.1. Option 枚举和其相对于空值的优势
    2. 6.2. 6.2 match 控制流结构
      1. 6.2.1. 匹配Option<T>
      2. 6.2.2. 匹配是穷尽的
      3. 6.2.3. 使用_通配符
    3. 6.3. 6.3 if let简洁控制流
  7. 7. 第7章 使用包、Crate 和模块管理不断增长的项目
    1. 7.1. 7.1 包和 Crate
    2. 7.2. 7.2 定义模块来控制作用域与私有性
    3. 7.3. 7.3 引用模块项目的路径
    4. 7.4. 7.4 使用use关键字将路径引入作用域
  8. 8. 第8章 常见集合
    1. 8.1. 8.1 使用Vector存储多个值
      1. 8.1.1. 使用枚举来储存多种类型
    2. 8.2. 8.2 使用String存储文本
  9. 9. 第9章 错误处理
    1. 9.1. 9,1 用 panic! 处理不可恢复的错误
    2. 9.2. 9.2 用 Result 处理可恢复的错误
    3. 9.3. 9.3 要不要panic!
  10. 10. 第10章 泛型、Trait和生命周期
    1. 10.1. 10.1 泛型数据类型
      1. 10.1.1. 泛型代码的性能
    2. 10.2. 10.2 Trait:定义共同行为
      1. 10.2.1. trait 定义
      2. 10.2.2. trait 作为参数
      3. 10.2.3. trait作为返回结果
    3. 10.3. 10.3 生命周期确保引用有效
      1. 10.3.1. 生命周期注解语法
      2. 10.3.2. 结构体的生命周期注解
      3. 10.3.3. 生命周期省略
      4. 10.3.4. 方法定义中的生命周期注解
      5. 10.3.5. 静态生命周期
  11. 11. 第11章 编写自动化测试
    1. 11.1. 11.1 如何编写测试
    2. 11.2. 11.2 控制测试如何运行
    3. 11.3. 11.3 测试的组织结构
      1. 11.3.1. 单元测试
      2. 11.3.2. 集成测试
      3. 11.3.3. 二进制 crate 的集成测试
  12. 12. 第13章 迭代器与闭包
    1. 12.1. 13.1 闭包:可以捕获环境的匿名函数
      1. 12.1.1. 捕获引用或者移动所有权
      2. 12.1.2. 将被捕获的值移出闭包和Fn trait
    2. 12.2. 13.2 使用迭代器处理元素序列
      1. 12.2.1. 产生其他迭代器的方法
  13. 13. 第14章 更多关于 Cargo 和 Crates.io
    1. 13.1. 14.1 采用发布配置自定义构建
    2. 13.2. 14.2 将crate发布到crates.io
      1. 13.2.1. 文档注释
      2. 13.2.2. 使用 pub use 导出合适的公有 API
      3. 13.2.3. 发布到crates.io
    3. 13.3. 14.4 使用 cargo install 安装二进制文件
    4. 13.4. 14.5 Cargo 自定义扩展命令
  14. 14. 第15章 智能指针
    1. 14.1. 15.1 Box<T> 指针指向堆上的数据
      1. 14.1.1. 定义
      2. 14.1.2. Box 允许创建递归类型
    2. 14.2. 15.2 智能指针 trait
      1. 14.2.1. Deref
      2. 14.2.2. Drop
    3. 14.3. 15.3 Rc<T> 引用计数智能指针
    4. 14.4. 15.4 RefCell<T> 和内部可变性模式
      1. 14.4.1. 结合 Rc<T> 和 RefCell<T> 来拥有多个可变数据所有者
    5. 14.5. 15.5 弱引用和循环引用
      1. 14.5.1. 内存泄露
      2. 14.5.2. 避免引用循环:将 Rc 变为 Weak
  15. 15. 第16章 无畏并发
    1. 15.1. 16.1 使用线程同时运行代码
      1. 15.1.1. 使用 spawn 创建新线程
      2. 15.1.2. 使用 join 等待所有线程结束
      3. 15.1.3. 将 move 闭包与线程一同使用
    2. 15.2. 16.2 消息传递并发
      1. 15.2.1. 信道与所有权转移
    3. 15.3. 16.3 共享状态的并发
      1. 15.3.1. 使用互斥器,实现同一时刻只允许一个线程访问数据
      2. 15.3.2. 原子引用计数 Arc<T>
    4. 15.4. 16.4 使用 Sync 和 Send trait 的可扩展并发
  16. 16. 第17章 Async 和 await
    1. 16.1. 17.1 Futures 和 async 语法
    2. 16.2. 17.2 并发与async
    3. 16.3. 17.3 使用任意数量的 futures
      1. 16.3.1. future 竞争 与 Yield
    4. 16.4. 17.3 流 Streams
  17. 17. 第18章 Rust的面向对象特性
    1. 17.1. 18.1 面向对象语言的特征
      1. 17.1.1. 1. 对象包含数据和行为
      2. 17.1.2. 2. 封装:隐藏实现细节
      3. 17.1.3. 3. 继承:类型系统与代码共享
      4. 17.1.4. 4. 多态(Polymorphism)
    2. 17.2. 18.2 顾及不同类型值的 trait 对象
      1. 17.2.1. 1. 问题背景
      2. 17.2.2. 2. trait 对象的引入
      3. 17.2.3. 3. 定义通用行为的 trait
      4. 17.2.4. 4. 实现 trait 对象
      5. 17.2.5. 5. 使用 trait 对象的好处
      6. 17.2.6. 6. trait 对象与鸭子类型
      7. 17.2.7. 7. 动态分发与性能
      8. 17.2.8. 8. 编译时检查
    3. 17.3. 18.3 面向对象设计模式的实现
      1. 17.3.1. 1. 状态模式(State Pattern)概述
      2. 17.3.2. 2. 博文发布工作流的实现
      3. 17.3.3. 3. 状态模式的Rust实现
      4. 17.3.4. 4. 状态模式的优点
      5. 17.3.5. 5. 避免枚举的使用
      6. 17.3.6. 6. 状态模式的权衡取舍
      7. 17.3.7. 7. 将状态和行为编码为类型
      8. 17.3.8. 8. 状态模式与Rust的优势
    4. 17.4. 第19章 模式与模式匹配
    5. 17.5. 19.1 所有可能会用到模式的位置
      1. 17.5.1. 1. match 分支
      2. 17.5.2. 2. if let 条件表达式
      3. 17.5.3. 3. while let 条件循环
      4. 17.5.4. 4. for 循环
      5. 17.5.5. 5. let 语句
      6. 17.5.6. 6. 函数参数中的模式
    6. 17.6. 19.2 Refutability(可反驳性): 模式是否会匹配失效
      1. 17.6.1. 1. 不可反驳模式(Irrefutable Patterns)
      2. 17.6.2. 2. 可反驳模式(Refutable Patterns)
      3. 17.6.3. 3. 在Rust中使用模式的规则
      4. 17.6.4. 4. 示例 18-8:不可反驳模式错误
      5. 17.6.5. 5. 修复示例:使用 if let
      6. 17.6.6. 6. 示例 18-10:不可反驳模式用于 if let
      7. 17.6.7. 7. match表达式中的模式
    7. 17.7. 19.3 所有的模式语法
      1. 17.7.1. 1. 匹配字面值
      2. 17.7.2. 2. 匹配命名变量
      3. 17.7.3. 3. 多个模式
      4. 17.7.4. 4. 通过 ..= 匹配值的范围
      5. 17.7.5. 5. 解构并分解值
      6. 17.7.6. 6. 解构嵌套的结构体和枚举
      7. 17.7.7. 7. 忽略模式中的值
      8. 17.7.8. 8. 匹配守卫提供的额外条件
      9. 17.7.9. 9. @ 绑定
  18. 18. 第20章 高级特征
    1. 18.1. 20.1 不安全 Rust
      1. 18.1.1. 不安全 Rust 的目的
      2. 18.1.2. 不安全 Rust 的五个超能力
      3. 18.1.3. 解引用裸指针
      4. 18.1.4. 调用不安全函数或方法
      5. 18.1.5. 创建不安全代码的安全抽象
      6. 18.1.6. 访问或修改可变静态变量
      7. 18.1.7. 实现不安全 trait
      8. 18.1.8. 访问 union 的字段
      9. 18.1.9. 何时使用不安全代码
    2. 18.2. 20.2 高级trait
      1. 18.2.1. 关联类型(Associated Types)
      2. 18.2.2. 默认泛型类型参数与运算符重载
      3. 18.2.3. 完全限定语法与消歧义
      4. 18.2.4. 父 trait(Supertrait)
      5. 18.2.5. newtype 模式
    3. 18.3. 20.3 高级类型
      1. 18.3.1. 为了类型安全和抽象而使用 newtype 模式
      2. 18.3.2. 类型别名(Type Aliases)
      3. 18.3.3. ! 类型(Never Type)
      4. 18.3.4. 动态大小类型(DST)和 Sized trait
      5. 18.3.5. 总结
    4. 18.4. 20.4 高级函数与闭包
      1. 18.4.1. 函数指针
      2. 18.4.2. 返回闭包
    5. 18.5. 总结
    6. 18.6. 20.5 宏
      1. 18.6.1. 宏与函数的区别
      2. 18.6.2. 使用 macro_rules! 的声明宏
      3. 18.6.3. 过程宏
      4. 18.6.4. 总结

Rust 程序设计语言

第1章 入门安装

1. Cargo.toml配置文件

Cargo.toml是Rust项目的配置文件,使用TOML格式(Tom’s Obvious, Minimal Language)。其中包含两个重要的部分:

  • **[package]**:配置项,定义项目名称、版本和Rust版本等信息。

    1
    2
    3
    4
    [package]
    name = "hello_cargo"
    version = "0.1.0"
    edition = "2021"

    这些配置帮助Cargo了解项目的基本信息和编译要求。

  • **[dependencies]**:列出项目依赖的库(crates)。例如:

    1
    2
    [dependencies]
    anyhow = "1.0"

    这表示项目依赖了anyhow库,版本为1.0。

2. Cargo.lock 文件

Cargo.lock 文件记录了所有依赖项的精确版本和相关元数据,确保每次构建时使用相同的依赖版本。其主要内容包括:

  • 依赖项的名称和版本:列出所有库及其具体版本号。
  • 来源:标明依赖项的来源(如crates.io或git仓库)。
  • 依赖关系:描述库之间的依赖关系,确保构建的一致性。

Cargo.lock文件帮助保持依赖关系的稳定性,保证项目在不同环境下构建的一致性。

3. Cargo常用命令

  • cargo new <project_name>:创建新项目。
  • cargo build:构建项目,生成可执行文件。
  • cargo run:构建并运行项目的可执行文件,简化了手动构建和运行的过程。
  • cargo check:检查代码是否可以编译,但不生成二进制文件,适合快速检测错误。
  • cargo build --release:构建优化版项目,适合发布,生成高性能的可执行文件。

4. 总结

  • 使用rustup安装Rust并更新到最新版本。
  • 通过rustc编译并运行简单的Rust程序(如Hello, world!)。
  • 使用Cargo来管理Rust项目,自动处理构建、依赖关系和版本控制。

第2章 猜数字游戏

1. read_line 入参

read_line 方法用于从用户输入中获取数据,并将其附加到传入的可变字符串(String)中。这里的 &mut guess 是一个可变引用,允许对原数据进行修改,而不需要拷贝数据到新的内存位置。&mut 关键字表示引用是可变的,Rust会对引用的数据进行修改而不会创建新的副本。

2. read_line 返回值

  • read_line返回一个 Result 类型,用于表示操作的成功或失败。Result枚举有两个成员:

    • Ok:表示成功,包含操作成功时的值(如读取的字节数)。
    • Err:表示失败,包含错误信息。
  • expect方法是 Result上的一种错误处理方法:

    • 如果 ResultErrexpect 会导致程序崩溃并打印错误信息。
    • 如果 ResultOk,则返回内部的值(如用户输入)。
  • 未处理的 Result 会触发编译时警告,提示可能存在的错误。

  • expect 是一种简便的错误处理方式,适用于开发过程中希望直接崩溃的场景。

3. crate 和外部依赖

  • crate 是一组 Rust 源代码文件,可以是一个库或一个二进制程序。rand crate 是一个库 crate,提供随机数生成功能。
  • 在使用外部 crate 时,必须在 Cargo.toml 文件中添加依赖。例如,添加 rand = "0.8.5"
  • 版本号使用语义化版本(SemVer)。例如,0.8.5 表示使用 0.8.x 版本,但不包括 0.9.0。

4. Cargo.lock 文件

  • Cargo.lock 确保每次构建使用相同的依赖版本,避免因版本变更引发潜在问题。
  • 第一次运行 cargo build 时,Cargo 会计算依赖版本并写入 Cargo.lock 文件。后续构建将使用该文件中记录的版本,避免重复计算。
  • cargo update 命令会更新依赖,选择与 Cargo.toml 中声明的版本兼容的最新版本。
  • Cargo.lock 文件通常加入版本控制,以确保团队成员使用相同的依赖版本。

5. match 表达式

  • match 是 Rust 的一种强大模式匹配工具,它可以根据传入的值选择并执行对应分支的代码。match 的分支由模式(pattern)和相应的代码块组成。
  • Result 类型常与 match 配合使用,处理不同的状态(OkErr)。如果 parse 失败,返回 Err,可以使用 match 继续执行其他操作。
  • match 确保对每种可能的情况都做了处理,避免遗漏。

第3章 常见编程概念

3.1 变量和可变性

  1. 变量的默认不可变性:Rust 中变量默认是不可变的。通过 let 关键字声明的变量值一旦赋予后不可更改。
  2. 使用 mut 声明可变变量:如果需要修改变量的值,可以在声明时使用 mut 关键字,使其可变。
  3. 常量的定义:常量使用 const 声明,必须显式指定类型,且不能使用 mut,并且只能绑定常量表达式。
  4. 隐藏(shadowing):Rust 支持变量名重复使用,通过重新声明变量来“隐藏”之前的值,方便对值进行多步转换或处理。

3.2 数据类型

  1. 数值类型:包括整数类型和浮点类型,整数有 i8, i16, i32, i64, i128, isize(有符号)及对应的无符号类型。浮点数有 f32f64
  2. 字符类型:Rust 使用 char 表示 Unicode 字符,占 4 个字节,可以表示更多字符而不仅限于 ASCII。
  3. 复合类型:Rust 提供两种复合类型,即元组和数组。元组可以包含不同类型元素,而数组长度固定且类型一致。
  4. 整型溢出:
    1. 整型溢出定义:当一个整型变量的值超出其数据类型所能表示的范围时,就会发生整型溢出。例如,u8 类型的范围是 0 到 255,超出此范围的值会导致溢出。
    2. 调试模式下的行为:在 Rust 的调试模式下,当整型溢出时程序会 panic,即终止运行并提示错误,以便开发者发现问题。
    3. 发布模式下的行为:在发布模式下(使用 --release 编译),Rust 不会 panic,而是采用 “环绕” 行为。例如,将 u8 的 256 设置为 0,将 257 设置为 1。这种行为称为 “wrap around”。
    4. 避免溢出的方法:可以使用标准库提供的溢出处理方法,如:
      • wrapping_* 系列方法:执行环绕计算。
      • checked_* 系列方法:返回 None 来标识溢出。
      • overflowing_* 系列方法:返回结果和一个布尔值标识是否溢出。
      • saturating_* 系列方法:遇到溢出时使用数据类型的最大或最小值。

3.3 函数

  1. Rust函数基本结构
    • 函数的定义格式:fn关键字+函数名+参数+函数体。
    • 函数名称和参数需要显式声明类型。
  2. 函数的参数
    • 可以通过在小括号内定义参数名称和类型。
    • 多个参数以逗号分隔,Rust要求参数类型显式声明。
  3. 函数体与表达式
    • 函数体是一个由大括号包裹的代码块。
    • Rust中的函数返回值使用表达式(省略return语句)来表示,不需要分号。
  4. 语句与表达式的区别
    • 语句执行操作但不返回值,常见如变量绑定和函数调用。
    • 表达式返回一个值,例如字面值、操作符操作、函数调用、以及代码块内最后一个不带分号的表达式。
  5. 函数的返回值
    • 使用“-> 类型”语法来声明函数的返回值类型。
    • 函数默认返回代码块中最后一个表达式的值,显式return可以中途返回。

3.4 注释

  1. 注释的作用:注释用于解释代码,帮助人类理解代码的目的或功能,但不会影响代码的运行。
  2. 单行注释:在 Rust 中,用 // 开始的行是单行注释。可以放在代码的上方或旁边。
  3. 多行注释:虽然 Rust 没有多行注释的专门语法,多个 // 可用于编写多行注释,每行都需以 // 开头。
  4. 文档注释:Rust 支持使用 /// 的文档注释,通常用于说明模块、结构体、枚举或函数等的用途,会出现在生成的文档中。支持markdown语法。

3.5 控制流

  1. 条件判断
    使用 if 表达式实现条件判断,并可以嵌套使用。if 表达式会根据条件的真假值选择执行代码块。

  2. if 表达式的值
    if 是一个表达式,可以在 let 语句中用于绑定值。条件表达式的各分支需要返回相同类型的值。

  3. loop 循环
    loop 创建无限循环。使用 break 可以提前退出循环,并可以在 break 语句后跟上一个值,从而将循环的结果返回给变量。

  4. while 循环
    while 循环在条件为真时执行循环,适合在循环次数不确定的情况下使用。

  5. for 循环
    for 循环适合遍历集合中的每一个元素,比 while 更为安全。使用 for 循环可避免越界等风险。

  6. 使用范围 (Range)
    for 循环可以搭配 1..51..=5 范围使用,表示从 1 到 4 或从 1 到 5 的范围。

  7. 循环标签(Loop Labels)用于帮助区分多层嵌套循环的 breakcontinue 语句,以便清晰指定要退出或继续的具体循环。

    1. 标签的定义

      1. 标签使用单引号 ' 开头,后跟标签名。标签紧跟在 loopwhilefor 关键字之前。
      2. 例如:'outer: loop { ... }
    2. breakcontinue 指定标签

      • 在嵌套循环中,如果要退出或继续外层循环,可以使用 break 'outercontinue 'outer,通过标签指定目标循环。

      • 例如:

        1
        2
        3
        4
        5
        6
        7
        8
        'outer: loop {
        println!("进入外层循环");
        loop {
        println!("进入内层循环");
        break 'outer; // 退出外层循环
        }
        }
        println!("已退出外层循环");

第4章 所有权

4.1 什么是所有权

  1. 所有权ownership)是 Rust 用于如何管理内存的一组规则。通过所有权系统管理内存,编译器在编译时会根据一系列的规则进行检查。如果违反了任何这些规则,程序都不能编译。在运行时,所有权系统的任何功能都不会减慢程序。

  2. 所有权的规则:

    1. Rust 中的每一个值都有一个 所有者owner)。
    2. 值在任一时刻有且只有一个所有者。
    3. 当所有者(变量)离开作用域,这个值将被丢弃。
  3. 移动:

    1. 为了确保内存安全,在 let s2 = s1; 之后,Rust 认为 s1 不再有效,因此 Rust 不需要在 s1 离开作用域后清理任何东西。

      1
      2
      let s1 = String::from("hello");
      let s2 = s1
    2. Rust 永远也不会自动创建数据的 “深拷贝”。因此,任何 自动 的复制都可以被认为是对运行时性能影响较小的。

      我的理解:

      1. Rust不会自动深拷贝,隐藏的深拷贝会造成性能影响,比如函数参数、拷贝构造、等号重载等;
      2. Rust的每个变量都类似于一个Guard,析构时释放内存,这就导致了如果有两个变量指向了同一个内存,就会出现double free了。所以使用移动的语义,使变量只释放一次,另一个就无效了。
  4. 克隆:如果我们 确实 需要深度复制 String 中堆上的数据,而不仅仅是栈上的数据,可以使用一个叫做 clone 的通用函数。

  5. 只在栈上的数据会出现拷贝。像整型这样的在编译时已知大小的类型被整个存储在栈上,所以拷贝其实际的值是快速的。Rust 有一个叫做 Copy trait 的特殊注解,可以用在类似整型这样的存储在栈上的类型上(第十章将会详细讲解 trait)。如果一个类型实现了 Copy trait,那么一个旧的变量在将其赋值给其他变量后仍然可用。

4.2 引用与借用

  1. 引用(reference)像一个指针,因为它是一个地址,我们可以由此访问储存于该地址的属于其他变量的数据。 与指针不同,引用确保指向某个特定类型的有效值。这些 & 符号就是 引用,它们允许你使用值但不获取其所有权。

    1
    2
    3
    4
    5
    6
    7
    8
    fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1);
    println!("The length of '{s1}' is {len}.");
    }
    fn calculate_length(s: &String) -> usize {
    s.len()
    }
  2. 我们将创建一个引用的行为称为 借用borrowing)。正如现实生活中,如果一个人拥有某样东西,你可以从他那里借来。当你使用完毕,必须还回去。我们并不拥有它。引用同变量也一样。默认不允许修改引用的值。

    1
    2
    3
    4
    5
    6
    7
    fn main() {
    let s = String::from("hello");
    change(&s); //报错
    }
    fn change(some_string: &String) {
    some_string.push_str(", world");
    }
  3. 增加mut描述可以实现可变引用。可变引用有一个很大的限制:如果你有一个对该变量的可变引用,你就不能再创建对该变量的引用。

    1
    2
    3
    4
    5
    6
    7
    fn main() {
    let mut s = String::from("hello");
    change(&mut s);
    }
    fn change(some_string: &mut String) {
    some_string.push_str(", world");
    }

    这个限制的好处是 Rust 可以在编译时就避免数据竞争。数据竞争data race)类似于竞态条件,它可由这三个行为造成:

    • 两个或更多指针同时访问同一数据。
    • 至少有一个指针被用来写入数据。
    • 没有同步数据访问的机制。

    数据竞争会导致未定义行为,难以在运行时追踪,并且难以诊断和修复;Rust 避免了这种情况的发生,因为它甚至不会编译存在数据竞争的代码!

  4. 也不能在拥有不可变引用的同时拥有可变引用

    不可变引用的用户可不希望在他们的眼皮底下值就被意外的改变了!然而,多个不可变引用是可以的,因为没有哪个只能读取数据的人有能力影响其他人读取到的数据。注意一个引用的作用域从声明的地方开始一直持续到最后一次使用为止。

    1
    2
    3
    4
    5
    6
    7
    8
    let mut s = String::from("hello");
    let r1 = &s; // 没问题
    let r2 = &s; // 没问题
    println!("{r1} and {r2}");
    // 此位置之后 r1 和 r2 不再使用
    let r3 = &mut s; // 没问题
    println!("{r3}");

  5. 悬垂引用(野指针):因为 s 是在 dangle 函数内创建的,当 dangle 的代码执行完毕后,s 将被释放。不过我们尝试返回它的引用。这意味着这个引用会指向一个无效的 String

1
2
3
4
5
6
7
fn main() {
let reference_to_nothing = dangle();
}
fn dangle() -> &String {
let s = String::from("hello");
&s
}

4.3 Slice类型

Slice 类型定义

  • Slice 是对集合中连续元素序列的引用,提供一种安全、轻量的方式来引用数据。
  • 使用切片时并不会拥有数据的所有权,仅仅是借用数据的一部分。

字符串切片

  • 字符串切片(string slice)是一种常见的切片类型,表示对字符串一部分的引用。
  • 通过 [start..end] 语法来定义字符串切片,其中 start 是起始索引,end 是结束索引。

切片在函数中的应用

  • 切片常用于函数参数,使得函数能够灵活接受不同长度的数据集合。
  • 例如,在一个函数中传递 &str 切片可以接受字符串字面量和 String 类型。

数组切片

  • Rust 同样支持对数组进行切片,生成对数组元素的连续引用。
  • 数组切片的用法与字符串切片类似,也采用 [start..end] 语法。

切片的长度与不可变性

  • 切片会记录长度信息,因此可以通过 len 方法获取切片的长度。
  • 默认情况下,切片是不可变的,无法更改其引用的数据。

常见错误:超出边界访问

  • Rust 在编译期和运行时提供边界检查,避免切片引用超出边界的数据,提高程序安全性。

切片与所有权和生命周期

  • 切片的生命周期遵循借用规则,其生命周期不能超出被引用的数据,从而防止悬挂引用。

第5章 结构体

5.1结构体的定义和实例化

创建一个实例需要以结构体的名字开头,接着在大括号中使用 key: value 键 - 值对的形式提供字段,其中 key 是字段的名字,value 是需要存储在字段中的数据值。实例中字段的顺序不需要和它们在结构体中声明的顺序一致。
如果需要一个可变的实例,则需要整体使用mut 关键字,Rust不支持单个字段是可变的。

1
2
3
4
5
6
7
8
9
10
fn main() {
let mut user1 = User {
active: true,
username: String::from("someusername123"),
email: String::from("someone@example.com"),
sign_in_count: 1,
};

user1.email = String::from("anotheremail@example.com");
}

使用字段初始化简写语法

1
2
3
4
5
6
7
8
fn build_user(email: String, username: String) -> User {
User {
active: true, // 普通语法
username, // 相同名称,则可以简写
email,
sign_in_count: 1,
}
}

如上,build_user 函数使用了字段初始化简写语法,因为 username 和 email 参数与结构体字段同名

使用结构体更新语法从其他实例创建实例

1
2
3
4
5
6
7
8
fn main() {
// --snip--

let user2 = User {
email: String::from("another@example.com"),
..user1
};
}

使用结构体更新语法为一个 User 实例设置一个新的 email 值,不过其余值来自 user1 变量中实例的字段。..user1 必须放在最后,以指定其余的字段应从 user1 的相应字段中获取其值,但我们可以选择以任何顺序为任意字段指定值,而不用考虑结构体定义中字段的顺序。

请注意,结构更新语法就像带有 = 的赋值,因为它移动了数据,就像我们在“变量与数据交互的方式(一):移动”部分讲到的一样。在这个例子中,总体上说我们在创建 user2 后不能就再使用 user1 了,因为 user1 的 username 字段中的 String 被移到 user2 中。如果我们给 user2 的 email 和 username 都赋予新的 String 值,从而只使用 user1 的 active 和 sign_in_count 值,那么 user1 在创建 user2 后仍然有效。active 和 sign_in_count 的类型是实现 Copy trait 的类型,所以我们在“变量与数据交互的方式(二):克隆” 部分讨论的行为同样适用。

使用没有命名字段的元组结构体来创建不同的类型

元组结构体有着结构体名称提供的含义,但没有具体的字段名,只有字段的类型。
当你想给整个元组取一个名字,并使元组成为与其他元组不同的类型时,这时像常规结构体那样为每个字段命名就显得多余和形式化了。

1
2
3
4
5
6
struct Color(i32, i32, i32);

fn main() {
let black = Color(0, 0, 0);
println!("{}, {}, {}", black.0, black.1, black.2);
}

没有任何字段的类单元结构体
我们也可以定义一个没有任何字段的结构体!它们被称为 类单元结构体(unit-like structs)。
类单元结构体常常在你想要在某个类型上实现 trait 但不需要在类型中存储数据的时候发挥作用。

1
2
3
4
5
struct AlwaysEqual;

fn main() {
let subject = AlwaysEqual;
}

5.3 方法语法

  1. 方法(method)与函数类似:它们使用 fn 关键字和名称声明,可以拥有参数和返回值,同时包含在某处调用该方法时会执行的代码。不过方法与函数是不同的,因为它们在结构体的上下文中被定义(或者是枚举或 trait 对象的上下文,将分别在第六章和第十八章讲解),并且它们第一个参数总是 self,它代表调用该方法的结构体实例。
  2. 在方法的签名中,使用 &self 来替代 结构体,&self 实际上是 self: &Self 的缩写。在一个 impl 块中,Self 类型是 impl 块的类型的别名。方法的第一个参数必须有一个名为 self 的Self 类型的参数,所以 Rust 让你在第一个参数位置上只用 self 这个名字来简化。
  3. 如果我们并不想获取所有权,只希望能够读取结构体中的数据,参数类型使用借用&Self。如果想要在方法中改变调用方法的实例,需要将第一个参数改为 &mut self。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}

impl Rectangle {
fn area(&self) -> u32 {
self.width * self.height
}
}

fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};

println!(
"The area of the rectangle is {} square pixels.",
rect1.area()
);
}

第6章 枚举和模式匹配

6.1 枚举的定义

在 Rust 中,枚举(enumerations)是一种类型,它允许你列举可能的值。枚举的优势在于它可以帮助你在编译时防止 bug,因为 Rust 可以确保你的代码涵盖了每一个可能的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
num IpAddrKind {
V4,
V6,
}

struct IpAddr {
kind: IpAddrKind,
address: String,
}

let home = IpAddr {
kind: IpAddrKind::V4,
address: String::from("127.0.0.1"),
};

let loopback = IpAddr {
kind: IpAddrKind::V6,
address: String::from("::1"),
};

我们可以使用一种更简洁的方式来表达相同的概念,仅仅使用枚举并将数据直接放进每一个枚举成员而不是将枚举作为结构体的一部分。IpAddr 枚举的新定义表明了 V4 和 V6 成员都关联了 String 值:

1
2
3
4
5
6
7
8
enum IpAddr {
V4(String),
V6(String),
}

let home = IpAddr::V4(String::from("127.0.0.1"));

let loopback = IpAddr::V6(String::from("::1"));

我们直接将数据附加到枚举的每个成员上,这样就不需要一个额外的结构体了。这里也很容易看出枚举工作的另一个细节:每一个我们定义的枚举成员的名字也变成了一个构建枚举的实例的函数。也就是说,IpAddr::V4() 是一个获取 String 参数并返回 IpAddr 类型实例的函数调用。作为定义枚举的结果,这些构造函数会自动被定义。

用枚举替代结构体还有另一个优势:每个成员可以处理不同类型和数量的数据。IPv4 版本的 IP 地址总是含有四个值在 0 和 255 之间的数字部分。如果我们想要将 V4 地址存储为四个 u8 值而 V6 地址仍然表现为一个 String,这就不能使用结构体了。枚举则可以轻易的处理这个情况:

1
2
3
4
5
6
7
8
enum IpAddr {
V4(u8, u8, u8, u8),
V6(String),
}

let home = IpAddr::V4(127, 0, 0, 1);

let loopback = IpAddr::V6(String::from("::1"));

Option 枚举和其相对于空值的优势

空值是一个因为某种原因目前无效或缺失的值。问题不在于概念而在于具体的实现。为此,Rust 并没有空值,不过它确实拥有一个可以编码存在或不存在概念的枚举。这个枚举是 Option<T>,而且它定义于标准库中,如下:

1
2
3
4
enum Option<T> {
None,
Some(T),
}

当有一个 Some 值时,我们就知道存在一个值,而这个值保存在 Some 中。当有个 None 值时,在某种意义上,它跟空值具有相同的意义:并没有一个有效的值。那么,Option<T> 为什么就比空值要好呢?

简而言之,因为 Option<T> 和 T(这里 T 可以是任何类型)是不同的类型,编译器不允许像一个肯定有效的值那样使用 Option<T>。例如,这段代码不能编译,因为它尝试将 Option<i8> 与 i8 相加:

1
2
3
4
let x: i8 = 5;
let y: Option<i8> = Some(5);

let sum = x + y;

如果运行这些代码,将得到类似这样的错误信息:

1
2
3
4
5
6
7
$ cargo run
Compiling enums v0.1.0 (file:///projects/enums)
error[E0277]: cannot add `Option<i8>` to `i8`
--> src/main.rs:5:17
|
5 | let sum = x + y;
| ^ no implementation for `i8 + Option<i8>`

错误信息意味着 Rust 不知道该如何将 Option<i8> 与 i8 相加,因为它们的类型不同。当在 Rust 中拥有一个像 i8 这样类型的值时,编译器确保它总是有一个有效的值。我们可以自信使用而无需做空值检查。只有当使用 Option<i8>(或者任何用到的类型)的时候需要担心可能没有值,而编译器会确保我们在使用值之前处理了为空的情况。

6.2 match 控制流结构

Rust 有一个叫做 match 的极为强大的控制流运算符,它允许我们将一个值与一系列的模式相比较,并根据相匹配的模式执行相应代码。

如下是一个使用 match 的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#[derive(Debug)] // 这样可以立刻看到州的名称
enum UsState {
Alabama,
Alaska,
// --snip--
}

enum Coin {
Penny,
Nickel,
Dime,
Quarter(UsState),
}

fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter(state) => {
println!("State quarter from {state:?}!");
25
}
}
}

匹配Option<T>

我们在之前的部分中使用 Option<T> 时,是为了从 Some 中取出其内部的 T 值;我们还可以使用 match 处理 Option<T>

1
2
3
4
5
6
7
8
9
10
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}

let five = Some(5);
let six = plus_one(five);
let none = plus_one(None);

匹配是穷尽的

match 还有另一方面需要讨论:这些分支必须覆盖了所有的可能性。考虑一下 plus_one 函数的这个版本,它有一个 bug 并不能编译:

1
2
3
4
5
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
Some(i) => Some(i + 1),
}
}

我们没有处理 None 的情况,所以这些代码会造成一个 bug。这是一个 Rust 知道如何处理的 bug。如果尝试编译这段代码,会得到这个错误:pattern \None` not covered`

Rust 知道我们没有覆盖所有可能的情况甚至知道哪些模式被忘记了!Rust 中的匹配是 穷尽的(exhaustive):必须穷举到最后的可能性来使代码有效。特别的在这个 Option<T> 的例子中,Rust 防止我们忘记明确的处理 None 的情况,这让我们免于假设拥有一个实际上为空的值。

使用_通配符

我们希望对一些特定的值采取特殊操作,而对其他的值采取默认操作。这个时候,我们可以使用 _ 通配符来匹配所有其他的值。这个例子也满足穷举性要求,因为我们在最后一个分支中明确地忽略了其他的值。我们没有忘记处理任何东西。

1
2
3
4
5
match dice_roll {
3 => add_fancy_hat(),
7 => remove_fancy_hat(),
_ => reroll(),
}

6.3 if let简洁控制流

if let 语法让我们以一种不那么冗长的方式结合 if 和 let,来处理只匹配一个模式的值而忽略其他模式的情况。考虑示例 中的程序,它匹配一个 config_max 变量中的 Option<u8> 值并只希望当值为 Some 成员时执行代码.不过我们可以使用 if let 这种更短的方式编写。换句话说,可以认为 if let 是 match 的一个语法糖,它当值匹配某一模式时执行代码而忽略所有其他值。如下代码与示例中的 match 行为一致:

1
2
3
4
5
6
7
8
9
10
11
12
let mut count = 0;
match coin {
Coin::Quarter(state) => println!("State quarter from {state:?}!"),
_ => count += 1,
}

let mut count = 0;
if let Coin::Quarter(state) = coin {
println!("State quarter from {state:?}!");
} else {
count += 1;
}

第7章 使用包、Crate 和模块管理不断增长的项目

  • 包(Packages):Cargo 的一个功能,它允许你构建、测试和分享 crate。
  • Crates :一个模块的树形结构,它形成了库或二进制项目。
  • 模块(Modules)和 use:允许你控制作用域和路径的私有性。
  • 路径(path):一个命名例如结构体、函数或模块等项的方式。

7.1 包和 Crate

  1. crate 是 Rust 在编译时最小的代码单位。crate 有两种形式:二进制项和库。
  2. 包(package)是提供一系列功能的一个或者多个 crate。一个包会包含一个 Cargo.toml 文件,阐述如何去构建这些 crate。
  3. 如果一个包同时含有 src/main.rs 和 src/lib.rs,则它有两个 crate:一个二进制的和一个库的,且名字都与包相同。

7.2 定义模块来控制作用域与私有性

  • 从 crate 根节点开始: 当编译一个 crate, 编译器首先在 crate 根文件(通常,对于一个库 crate 而言是src/lib.rs,对于一个二进制 crate 而言是src/main.rs)中寻找需要被编译的代码。
  • 声明模块: 在 crate 根文件中,你可以声明一个新模块;比如,你用mod garden;声明了一个叫做garden的模块。编译器会在下列路径中寻找模块代码:
    • 内联,在大括号中,当mod garden后方不是一个分号而是一个大括号
    • 在文件 src/garden.rs
    • 在文件 src/garden/mod.rs
  • 声明子模块: 在除了 crate 根节点以外的其他文件中,你可以定义子模块。比如,你可能在src/garden.rs中定义了mod vegetables;。编译器会在以父模块命名的目录中寻找子模块代码:
    • 内联,在大括号中,当mod vegetables后方不是一个分号而是一个大括号
    • 在文件 src/garden/vegetables.rs
    • 在文件 src/garden/vegetables/mod.rs
  • 模块中的代码路径: 一旦一个模块是你 crate 的一部分,你可以在隐私规则允许的前提下,从同一个 crate 内的任意地方,通过代码路径引用该模块的代码。举例而言,一个 garden vegetables 模块下的Asparagus类型可以在crate::garden::vegetables::Asparagus被找到。
  • 私有 vs 公用: 一个模块里的代码默认对其父模块私有。为了使一个模块公用,应当在声明时使用pub mod替代mod。为了使一个公用模块内部的成员公用,应当在声明前使用pub。
  • use 关键字: 在一个作用域内,use关键字创建了一个成员的快捷方式,用来减少长路径的重复。在任何可以引用crate::garden::vegetables::Asparagus的作用域,你可以通过 use crate::garden::vegetables::Asparagus;创建一个快捷方式,然后你就可以在作用域中只写Asparagus来使用该类型。

在模块中可以对相关的代码进行分组,通过使用模块,我们可以将相关的定义分组到一起,并指出它们为什么相关。程序员可以通过使用这段代码,更加容易地找到他们想要的定义,因为他们可以基于分组来对代码进行导航,而不需要阅读所有的定义。程序员向这段代码中添加一个新的功能时,他们也会知道代码应该放置在何处,可以保持程序的组织性。

1
2
3
4
5
6
7
8
9
10
11
mod front_of_house {
mod hosting {
fn add_to_waitlist() {}
fn seat_at_table() {}
}
mod serving {
fn take_order() {}
fn serve_order() {}
fn take_payment() {}
}
}

7.3 引用模块项目的路径

路径有两种形式,绝对路径和相对路径都后跟一个或多个由双冒号(::)分割的标识符。

  • 绝对路径(absolute path)是以 crate 根(root)开头的全路径;对于外部 crate 的代码,是以 crate 名开头的绝对路径,对于当前 crate 的代码,则以字面值 crate 开头。
  • 相对路径(relative path)从当前模块开始,以 self、super 或定义在当前模块中的标识符开头。

父模块中的项不能使用子模块中的私有项,但是子模块中的项可以使用它们父模块中的项。这是因为子模块封装并隐藏了它们的实现详情,但是子模块可以看到它们定义的上下文。

我们可以通过在路径的开头使用 super ,从父模块开始构建相对路径,而不是从当前模块或者 crate 根开始。这类似以 .. 语法开始一个文件系统路径。使用 super 允许我们引用父模块中的已知项。

我们还可以使用 pub 来设计公有的结构体和枚举,不过关于在结构体和枚举上使用 pub 还有一些额外的细节需要注意。如果我们在一个结构体定义的前面使用了 pub ,这个结构体会变成公有的,但是这个结构体的字段仍然是私有的。我们可以根据情况决定每个字段是否公有。

与之相反,如果我们将枚举设为公有,则它的所有成员都将变为公有。我们只需要在 enum 关键字前面加上 pub。如果枚举成员不是公有的,那么枚举会显得用处不大;给枚举的所有成员挨个添加 pub 是很令人恼火的,因此枚举成员默认就是公有的。结构体通常使用时,不必将它们的字段公有化,因此结构体遵循常规,内容全部是私有的,除非使用 pub 关键字。

7.4 使用use关键字将路径引入作用域

我们可以使用 use 关键字创建一个短路径,然后就可以在作用域中的任何地方使用这个更短的名字。在作用域中增加 use 和路径类似于在文件系统中创建软连接(符号连接,symbolic link)。通过在 crate 根增加 use crate::front_of_house::hosting,现在 hosting 在作用域中就是有效的名称了

使用 use 将两个同名类型引入同一作用域这个问题还有另一个解决办法:在这个类型的路径后面,我们使用 as 指定一个新的本地名称或者别名。

1
2
3
4
5
6
7
8
use std::fmt::Result;
use std::io::Result as IoResult;
fn function1() -> Result {
// --snip--
}
fn function2() -> IoResult<()> {
// --snip--
}

使用 use 关键字,将某个名称导入当前作用域后,这个名称在此作用域中就可以使用了,但它对此作用域之外还是私有的。如果想让其他人调用我们的代码时,也能够正常使用这个名称,就好像它本来就在当前作用域一样,那我们可以将 pub 和 use 合起来使用。这种技术被称为 “重导出(re-exporting)”:我们不仅将一个名称导入了当前作用域,还允许别人把它导入他们自己的作用域。

第8章 常见集合

8.1 使用Vector存储多个值

vector 允许我们在一个单独的数据结构中储存多于一个的值,它在内存中彼此相邻地排列所有的值。vector 只能储存相同类型的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 新建向量
let v: Vec<i32> = Vec::new();
let v2: Vec<i32> = vec![1, 2, 3];

// 插入向量
let mut v3 = Vec::new();
v3.push(5);
v3.push(6);

// 读取向量
let third: &i32 = &v[2];
println!("The third element is {third}");

let third: Option<&i32> = v.get(2);
match third {
Some(third) => println!("The third element is {third}"),
None => println!("There is no third element."),
}

// 遍历向量
let v = vec![100, 32, 57];
for i in &v {
println!("{i}");
}

// 遍历可变向量
let mut v = vec![100, 32, 57];
for i in &mut v {
*i += 50;
}

//丢弃 vector 时也会丢弃其所有元素
{
let v = vec![1, 2, 3, 4];
} // 当 vector 被丢弃时,所有其内容也会被丢弃,这意味着这里它包含的整数将被清理。借用检查器确保了任何 vector 中内容的引用仅在 vector 本身有效时才可用

使用枚举来储存多种类型

vector 只能储存相同类型的值。这是很不方便的;绝对会有需要储存一系列不同类型的值的用例。幸运的是,枚举的成员都被定义为相同的枚举类型,所以当需要在 vector 中储存不同类型值时,我们可以定义并使用一个枚举!

1
2
3
4
5
6
7
8
9
10
11
enum SpreadsheetCell {
Int(i32),
Float(f64),
Text(String),
}

let row = vec![
SpreadsheetCell::Int(3),
SpreadsheetCell::Text(String::from("blue")),
SpreadsheetCell::Float(10.12),
];

8.2 使用String存储文本

字符串(String)类型由 Rust 标准库提供,而不是编入核心语言,它是一种可增长、可变、可拥有、UTF-8 编码的字符串类型。当 Rustaceans 提及 Rust 中的 “字符串 “时,他们可能指的是 String 或 string slice &str 类型,而不仅仅是其中一种类型。虽然本节主要讨论 String,但这两种类型在 Rust 的标准库中都有大量使用,而且 String 和 字符串 slices 都是 UTF-8 编码的。

由于UTF8编码的问题,按照从字节、标量值、字形簇的方式,一个字符可能有很多种解释方法,所以在Rust中,字符串的索引操作是不允许的。另一个 Rust 不允许使用索引获取 String 字符的原因是,索引操作预期总是需要常数时间(O(1))。但是对于 String 不可能保证这样的性能,因为 Rust 必须从开头到索引位置遍历来确定有多少有效的字符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66

// 新建字符串
let mut s1: String = String::new();
let data: &str = "initial contents";
let s2: String= data.to_string();
let s3: String = String::from("initial contents");

// 更新字符串
let mut s4 = String::from("foo");
s4.push_str("bar");
s4.push('l');

//拼接字符串
let s5 = s4 + &String::from("baz"); // 注意 s4 被移动了,不能继续使用
let s6 = format!("{}-{}", s5, s4); // format! 宏不会获取任何参数的所有权

// 字符串切片
let hello = "Здравствуйте";
let s = &hello[0..4]; // Зд
let s2 = &hello[0..1]; // panic

// 遍历字符串
for c in "Зд".chars() {
println!("{c}"); // 3 д
}
for b in "Зд".bytes() {
println!("{b}"); // 208 151 208 180
}
```rust
### 8.3 使用 Hash Map 储存键值对

常用集合类型是 哈希 map(hash map)。HashMap<K, V> 类型储存了一个键类型 K 对应一个值类型 V 的映射。它通过一个 哈希函数(hashing function)来实现映射,决定如何将键和值放入内存中。

对于像 i32 这样的实现了 Copy trait 的类型,其值可以拷贝进哈希 map。对于像 String 这样拥有所有权的值,其值将被移动而哈希 map 会成为这些值的所有者。

```rust
// 创建一个HashMap
let mut scores = HashMap::new();
let blue = String::from("Blue");
scores.insert(blue, 10);
// blue的所有权已经转移给了HashMap,这里不能再使用blue
scores.insert(String::from("Yellow"), 50);

// 访问HashMap中的值
let team_name = String::from("Blue");
let score = scores.get(&team_name).copied().unwrap_or(0);

// 遍历HashMap
for (key, value) in &scores {
println!("{}: {}", key, value);
}

// 覆盖Hashmap中的一个值
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Blue"), 25);

// 只在键没有对应值时插入
scores.entry(String::from("Blue")).or_insert(50);

// 根据旧值更新一个值
let text = "hello world wonderful world";
let mut map = HashMap::new();
for word in text.split_whitespace() {
let count = map.entry(word).or_insert(0);
*count += 1;
}

HashMap 默认使用一种叫做 SipHash 的哈希函数,它可以抵御涉及哈希表(hash table)1 的拒绝服务(Denial of Service, DoS)攻击。然而这并不是可用的最快的算法,不过为了更高的安全性值得付出一些性能的代价。如果性能监测显示此哈希函数非常慢,以致于你无法接受,你可以指定一个不同的 hasher 来切换为其它函数。

第9章 错误处理

Rust 将错误分为两大类:可恢复的(recoverable)和 不可恢复的(unrecoverable)错误。对于一个可恢复的错误,比如文件未找到的错误,我们很可能只想向用户报告问题并重试操作。不可恢复的错误总是 bug 出现的征兆,比如试图访问一个超过数组末端的位置,因此我们要立即停止程序。

9,1 用 panic! 处理不可恢复的错误

在实践中有两种方法造成 panic:执行会造成代码 panic 的操作(比如访问超过数组结尾的内容)或者显式调用 panic! 宏。这两种情况都会使程序 panic。通常情况下这些 panic 会打印出一个错误信息,展开并清理栈数据,然后退出。

通过RUST_BACKTRACE=1环境变量,你也可以让 Rust 在 panic 发生时打印调用堆栈(call stack)以便于定位 panic 的原因。

当出现 panic 时,程序默认会开始 展开(unwinding),这意味着 Rust 会回溯栈并清理它遇到的每一个函数的数据,不过这个回溯并清理的过程有很多工作。另一种选择是直接 终止(abort),这会不清理数据就退出程序, 那么程序所使用的内存需要由操作系统来清理。 panic 时通过在 Cargo.toml 的 [profile] 部分增加 panic = ‘abort’,可以由展开切换为终止。

9.2 用 Result 处理可恢复的错误

大部分错误并没有严重到需要程序完全停止执行, 使用Result进行处理:

1
2
3
4
5
6
enum Result<T, E> {
Ok(T),
Err(E),
}

let file = File::Open("hello.txt");

File::open 的返回值是 Result<T, E>。泛型参数 T 会被 File::open 的实现放入成功返回值的类型 std::fs::File,这是一个文件句柄。错误返回值使用的 E 的类型是 std::io::Error。这些返回类型意味着 File::open 调用可能成功并返回一个可以读写的文件句柄。这个函数调用也可能会失败:例如,也许文件不存在,或者可能没有权限访问这个文件。File::open 函数需要一个方法在告诉我们成功与否的同时返回文件句柄或者错误信息。

因此,我们就可以对不同的错误原因采取不同的行为:如果 File::open 因为文件不存在而失败,我们希望创建这个文件并返回新文件的句柄。如果 File::open 因为任何其他原因失败,例如没有打开文件的权限,我们仍然希望panic!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
use std::fs::File;
use std::io::ErrorKind;

fn main() {
let greeting_file_result = File::open("hello.txt");

let greeting_file = match greeting_file_result {
Ok(file) => file,
Err(error) => match error.kind() {
ErrorKind::NotFound => match File::create("hello.txt") {
Ok(fc) => fc,
Err(e) => panic!("Problem creating the file: {e:?}"),
},
other_error => {
panic!("Problem opening the file: {other_error:?}");
}
},
};
}

// 使用闭包的方式处理:
fn main() {
let greeting_file = File::open("hello.txt").unwrap_or_else(|error| {
if error.kind() == ErrorKind::NotFound {
File::create("hello.txt").unwrap_or_else(|error| {
panic!("Problem creating the file: {:?}", error);
})
} else {
panic!("Problem opening the file: {:?}", error);
}
});
}

9.3 要不要panic!

返回 Result 是定义可能会失败的函数的一个好的默认选择。

  • 选择对任何错误场景都调用 panic!,不管是否有可能恢复,不过这样就是你代替调用者决定了这是不可恢复的。
  • 选择返回 Result 值的话,就将选择权交给了调用者,而不是代替他们做出决定。调用者可能会选择以符合他们场景的方式尝试恢复,或者也可能干脆就认为 Err 是不可恢复的,所以他们也可能会调用 panic! 并将可恢复的错误变成了不可恢复的错误。
  1. 示例、代码原型和测试都非常适合 panic;如果方法调用在测试中失败了,我们希望这个测试都失败,即便这个方法并不是需要测试的功能。因为 panic! 会将测试标记为失败,此时调用 unwrap 或 expect 是恰当的。
  2. 当我们比编译器知道更多的情况;比如确定永远不会出现Err值的情况,使用unwrap或expect是合适的。
    1
    2
    3
    let home: IpAddr = "127.0.0.1"
    .parse()
    .expect("Hardcoded IP address should be valid");

第10章 泛型、Trait和生命周期

  • 泛型允许我们使用一个可以代表多种类型的占位符来替换特定类型,以此来减少代码冗余。
  • trait,这是一个定义泛型行为的方法。trait 可以与泛型结合来将泛型限制为只接受拥有特定行为的类型,而不是任意类型。
  • 生命周期(lifetimes),它是一类允许我们向编译器提供引用如何相互关联的泛型。Rust 的生命周期功能允许在很多场景下借用值的同时仍然使编译器能够检查这些引用的有效性。

10.1 泛型数据类型

泛型版本的函数:

1
2
3
4
5
6
7
8
9
10
11
12
fn largest<T>(list: &[T]) -> &T {
// 函数中直接 > 比较会编译错误,需要指定trait

fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
let mut largest = list[0];
for &item in list.iter() {
if item > largest {
largest = item;
}
}
largest
}

泛型版本的结构体

1
2
3
4
5
6
7
8
9
10
11
12
struct Point<T> {
x: T,
y: T,
}
let integer = Point { x: 5, y: 10 };
let float = Point { x: 1, y: 4.0 }; // 编译错误, x和y类型不一致,y不是整形

struct Point<T, U> {
x: T,
y: U,
}
let integer_and_float = Point { x: 5, y: 4.0 };

泛型版本的枚举类型:

1
2
3
4
5
6
7
8
9
enum Option<T> {
Some(T),
None,
}

enum Result<T, E> {
Ok(T),
Err(E),
}

泛型版本的类的方法定义:

1
2
3
4
5
6
7
8
9
10
11
struct Point<T> {
x: T,
y: T,
}

impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}

泛型代码的性能

Rust 通过在编译时进行泛型代码的 单态化(monomorphization)来保证效率。单态化是一个通过填充编译时使用的具体类型,将通用代码转换为特定代码的过程。

在这个过程中,编译器寻找所有泛型代码被调用的位置并使用泛型代码针对具体类型生成代码。

让我们看看这如何用于标准库中的 Option 枚举:

1
2
let integer = Some(5);
let float = Some(5.0);

当 Rust 编译这些代码的时候,它会进行单态化。编译器会读取传递给 Option<T> 的值并发现有两种 Option<T>:一个对应 i32 另一个对应 f64。为此,它会将泛型定义 Option<T> 展开为两个针对 i32 和 f64 的定义,接着将泛型定义替换为这两个具体的定义。

编译器生成的单态化版本的代码看起来像这样(编译器会使用不同于如下假想的名字):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
enum Option_i32 {
Some(i32),
None,
}

enum Option_f64 {
Some(f64),
None,
}

fn main() {
let integer = Option_i32::Some(5);
let float = Option_f64::Some(5.0);
}

泛型 Option<T> 被编译器替换为了具体的定义。因为 Rust 会将每种情况下的泛型代码编译为具体类型,使用泛型没有运行时开销。当代码运行时,它的执行效率就跟好像手写每个具体定义的重复代码一样。这个单态化过程正是 Rust 泛型在运行时极其高效的原因。

10.2 Trait:定义共同行为

trait 定义

trait 定义了某个特定类型拥有可能与其他类型共享的功能。可以通过 trait 以一种抽象的方式定义共同行为。可以使用 trait bounds 指定泛型是任何拥有特定行为的类型。

注意:trait 类似于其他语言中的常被称为 接口(interfaces)的功能,但是有一些不同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 定义trait
pub trait Summary {
fn summarize(&self) -> String;
}

// 实现trait
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, by {} ({})", self.headline, self.author, self.location)
}
}

//默认实现
pub trait Summary {
fn summarize(&self) -> String {
String::from("(Read more...)")
}
}
impl Summary for NewsArticle {} // 就可以使用默认的实现
//默认实现允许调用相同 trait 中的其他方法,哪怕这些方法没有默认实现。
pub trait Summary {
fn summarize_author(&self) -> String;

fn summarize(&self) -> String {
format!("(Read more from {}...)", self.summarize_author())
}
}

trait 作为参数

1
2
3
4
5
6
7
8
9
10
11
12
pub fn notify(item: &impl Summary) {
println!("Breaking news! {}", item.summarize());
}
```rust
对于 item 参数,我们指定了 impl 关键字和 trait 名称,而不是具体的类型。该参数支持任何实现了指定 trait 的类型。

impl Trait 语法更直观,但它实际上是更长形式的 trait bound 语法的语法糖。它看起来像:

```rust
pub fn notify<T: Summary>(item: &T) {
println!("Breaking news! {}", item.summarize());
}

如果有两个参数,并且想让两个参数都是相同的类型的话,可以使用trait bound语法:

1
pub fn notify<T: Summary>(item1: &T, item2: &T) {

如果需要指定多个trait, 可以使用+ 或者 where关键字

1
2
3
4
5
6
7
8
9
pub fn notify(item: &(impl Summary + Display)) {

pub fn notify<T: Summary + Display>(item: &T) {

fn some_function<T, U>(t: &T, u: &U) -> i32
where
T: Display + Clone,
U: Clone + Debug,
{

trait作为返回结果

可以在返回值中使用 impl Trait 语法,来返回实现了某个 trait 的类型,在闭包和迭代器场景十分的有用

1
fn returns_summarizable() -> impl Summary {

10.3 生命周期确保引用有效

Rust 中的每一个引用都有其 生命周期(lifetime),也就是引用保持有效的作用域。大部分时候生命周期是隐含并可以推断的。

生命周期的主要目标是避免悬垂引用(dangling references),后者会导致程序引用了非预期引用的数据。Rust 编译器有一个 借用检查器(borrow checker),它比较作用域来确保所有的借用都是有效的。

1
2
3
4
5
6
7
8
fn main() {
let r; // ---------+-- 'a
{ // |
let x = 5; // -+-- 'b |
r = &x; // | |
} // -+ |
println!("r: {r}"); // |
} // ---------+

生命周期注解语法

生命周期注解并不改变任何引用的生命周期的长短。相反它们描述了多个引用生命周期相互的关系,而不影响其生命周期。
生命周期参数名称必须以撇号(’)开头,其名称通常全是小写。

1
2
3
&i32        // 引用
&'a i32 // 带有显式生命周期的引用
&'a mut i32 // 带有显式生命周期的可变引用

单个的生命周期注解本身没有多少意义,因为生命周期注解告诉 Rust 多个引用的泛型生命周期参数如何相互联系的。如果函数有一个生命周期 ‘a 的 i32 的引用的参数 first。还有另一个同样是生命周期 ‘a 的 i32 的引用的参数 second。这两个生命周期注解意味着引用 first 和 second 必须与这泛型生命周期存在得一样久。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}

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

记住通过在函数签名中指定生命周期参数时,我们并没有改变任何传入值或返回值的生命周期,而是指出任何不满足这个约束条件的值都将被借用检查器拒绝。当具体的引用被传递给 longest 时,被 ‘a 所替代的具体生命周期是 x 的作用域与 y 的作用域相重叠的那一部分。泛型生命周期 ‘a 的具体生命周期等同于 x 和 y 的生命周期中较小的那一个。

结构体的生命周期注解

包含引用的结构体,需要为定义中的每一个引用增加生命周期注解;

1
2
3
4
5
6
7
8
struct ImportantExcerpt<'a> { // 这个注解意味着 ImportantExcerpt 的实例不能比其 part 字段中的引用存在的更久。
part: &'a str,
}
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().unwrap();
let i = ImportantExcerpt {
part: first_sentence,
};

生命周期省略

函数或方法的参数的生命周期被称为 输入生命周期(input lifetimes),而返回值的生命周期被称为 输出生命周期(output lifetimes)。

编译器采用三条规则来判断引用何时不需要明确的注解。第一条规则适用于输入生命周期,后两条规则适用于输出生命周期。

第一条规则是编译器为每一个引用参数都分配一个生命周期参数。换句话说就是,函数有一个引用参数的就有一个生命周期参数:fn foo<’a>(x: &’a i32),有两个引用参数的函数就有两个不同的生命周期参数,fn foo<’a, ‘b>(x: &’a i32, y: &’b i32),依此类推。

第二条规则是如果只有一个输入生命周期参数,那么它被赋予所有输出生命周期参数:fn foo<’a>(x: &’a i32) -> &’a i32。

第三条规则是如果方法有多个输入生命周期参数并且其中一个参数是 &self 或 &mut self,说明是个对象的方法 (method),那么所有输出生命周期参数被赋予 self 的生命周期。第三条规则使得方法更容易读写,因为只需更少的符号。

方法定义中的生命周期注解

当为带有生命周期的结构体实现方法时,需要在 impl 后面的尖括号中声明生命周期参数,以便 Rust 知道这些引用可能与结构体实例有不同的生命周期。

1
2
3
4
5
impl<'a> ImportantExcerpt<'a> {
fn level(&self) -> i32 {
3
}
}

静态生命周期

这里有一种特殊的生命周期值得讨论:’static,其生命周期能够存活于整个程序期间。所有的字符串字面值都拥有 ‘static 生命周期,我们也可以选择像下面这样标注出来:

1
let s: &'static str = "I have a static lifetime.";

第11章 编写自动化测试

11.1 如何编写测试

Rust 提供的专门用来编写测试的功能:test 属性、一些宏和 should_panic 属性。

1
2
3
4
5
6
7
8
9
mod tests {
use super::*; // 因为 tests 模块是一个内部模块,要测试外部模块中的代码,需要将其引入到内部模块的作用域中。这里选择使用 glob 全局导入,以便在 tests 模块中使用所有在外部模块定义的内容。

#[test] // #[test]:这个属性表明这是一个测试函数,
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}

测试常用的宏包括:

  • assert!宏,它检查其参数是否为 true。如果值是 true,assert! 什么也不做,同时测试会通过。如果值为 false,assert! 调用 panic! 宏,这会导致测试失败。
  • assert_eq!assert_ne! 宏,它们测试是否相等或不等,分别用于测试相等和不等的情况。 如果测试失败,会打印出实际值和期望值。

也可以向 assert!、assert_eq! 和 assert_ne! 宏传递一个可选的失败信息参数,可以在测试失败时将自定义失败信息一同打印出来。例如 assert_eq!(2,3, "not equal");.

#[should_panic],它用于测试是否会产生 panic。如果测试通过,说明代码中的 panic 是我们期望的。如果测试失败,说明代码中的 panic 不是我们期望的,或者代码中没有 panic。

1
2
3
4
5
6
#[test]
#[should_panic]
// 或者 #[should_panic(expected = "Make this test fail")] 更精确制定了错误类型
fn another() {
panic!("Make this test fail");
}

也可以使用Result<T, E>来测试

1
2
3
4
5
6
7
8
9
10
#[test]
fn it_works() -> Result<(), String> {
let result = add(2, 2);

if result == 4 {
Ok(())
} else {
Err(String::from("two plus two does not equal four"))
}
}

11.2 控制测试如何运行

cargo test 在测试模式下编译代码并运行生成的测试二进制文件。cargo test 产生的二进制文件的默认行为是并发运行所有的测试,并截获测试运行过程中产生的输出,阻止它们被显示出来,使得阅读测试结果相关的内容变得更容易。

cargo test有如下的参数:

  • cargo test -- --test-threads=1 可以指定并行执行测试的线程数量
  • cargo test -- --show-output 可以显示测试函数的println!输出
  • cargo test test_name 可以指定运行某个测试函数,可以使用通配符
  • cargo test -- --ignored 可以运行被忽略的测试函数, #[ignore]标记的测试函数默认不会被运行

11.3 测试的组织结构

  • 单元测试倾向于更小而更集中,在隔离的环境中一次测试一个模块,或者是测试私有接口。
  • 集成测试对于你的库来说则完全是外部的。它们与其他外部代码一样,通过相同的方式使用你的代码,只测试公有接口而且每个测试都有可能会测试多个模块。

单元测试

测试模块的 #[cfg(test)] 注解告诉 Rust 只在执行 cargo test 时才编译和运行测试代码,而在运行 cargo build 时不这么做。
Rust 的私有性规则确实允许你测试私有函数。通过 use super::* 将 test 模块的父模块的所有项引入了作用域来测试私有函数。

集成测试

为了编写集成测试,需要在项目根目录创建一个 tests 目录,与 src 同级。Cargo 知道如何去寻找这个目录中的集成测试文件。接着可以随意在这个目录中创建任意多的测试文件,Cargo 会将每一个文件当作单独的 crate 来编译。

1
2
3
4
5
6
7
adder
├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
└── integration_test.rs

二进制 crate 的集成测试

如果项目是二进制 crate 并且只包含 src/main.rs 而没有 src/lib.rs,这样就不可能在 tests 目录创建集成测试并使用 extern crate 导入 src/main.rs 中定义的函数。只有库 crate 才会向其他 crate 暴露了可供调用和使用的函数;二进制 crate 只意在单独运行。

这就是许多 Rust 二进制项目使用一个简单的 src/main.rs 调用 src/lib.rs 中的逻辑的原因之一。因为通过这种结构,集成测试 就可以 通过 extern crate 测试库 crate 中的主要功能了。

第13章 迭代器与闭包

13.1 闭包:可以捕获环境的匿名函数

Rust 的 闭包(closures)是可以保存在变量中或作为参数传递给其他函数的匿名函数。你可以在一个地方创建闭包,然后在不同的上下文中执行闭包运算。不同于函数,闭包允许捕获其被定义时所在作用域中的值。

1
2
3
4
let plus_one = |x| x + 1;
fn giveaway(&self, user_preference: Option<ShirtColor>) -> ShirtColor {
user_preference.unwrap_or_else(|| self.most_stocked()) // 无参数闭包
}

函数与闭包还有更多区别。闭包通常不要求像 fn 函数那样对参数和返回值进行类型注解。函数需要类型注解是因为这些类型是暴露给用户的显式接口的一部分。严格定义这些接口对于确保所有人对函数使用和返回值的类型达成一致理解非常重要。与此相比,闭包并不用于这样暴露在外的接口:它们储存在变量中并被使用,不用命名它们或暴露给库的用户调用。

1
2
3
4
5
6
7
8
9
10
let expensive_closure = |num: u32| -> u32 {
println!("calculating slowly...");
thread::sleep(Duration::from_secs(2));
num
};

fn add_one_v1 (x: u32) -> u32 { x + 1 } //函数定义
let add_one_v2 = |x: u32| -> u32 { x + 1 }; //完整标注的闭包定义
let add_one_v3 = |x| { x + 1 }; // 省略了类型注解
let add_one_v4 = |x| x + 1 ; // 去掉了可选的大括号

捕获引用或者移动所有权

闭包可以通过三种方式捕获其环境中的值,它们直接对应到函数获取参数的三种方式:不可变借用、可变借用和获取所有权。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
fn main() {
let list = vec![1, 2, 3];
println!("Before defining closure: {list:?}");

let only_borrows = || println!("From closure: {list:?}"); // 捕获了不可变引用

println!("Before calling closure: {list:?}");
only_borrows();
println!("After calling closure: {list:?}");
}

fn main() {
let mut list = vec![1, 2, 3];
println!("Before defining closure: {list:?}");

let mut borrows_mutably = || list.push(7); // 它捕获了对 list 的可变引用。闭包在被调用后就不再被使用,这时可变借用结束。因为当可变借用存在时不允许有其它的借用,所以在闭包定义和调用之间不能有不可变引用来进行打印。

borrows_mutably();
println!("After calling closure: {list:?}");
}

use std::thread;

fn main() {
let list = vec![1, 2, 3];
println!("Before defining closure: {list:?}");

thread::spawn(move || println!("From thread: {list:?}")) //闭包仅通过不可变引用捕获了 list,因为这是打印列表所需的最少的访问权限。这个例子中,尽管闭包体依然只需要不可变引用,我们还是在闭包定义前写上 move 关键字,以确保 list 被移动到闭包中。新线程可能在主线程剩余部分执行完前执行完,也可能在主线程执行完之后执行完。如果主线程维护了 list 的所有权但却在新线程之前结束并且丢弃了 list,则在线程中的不可变引用将失效。因此,编译器要求 list 被移动到在新线程中运行的闭包中,这样引用就是有效的。
.join()
.unwrap();
}

将被捕获的值移出闭包和Fn trait

  • 闭包捕获:闭包可按引用、值或可变引用捕获外部环境中的变量,捕获方式决定了闭包如何处理变量。
  • Fn Traits:闭包根据如何处理捕获的值自动实现不同的 Fn trait:
    • FnOnce:捕获值并可能移出闭包,只能调用一次。
    • FnMut:可能修改捕获的值,闭包可多次调用。
    • Fn:既不移出值,也不修改值,闭包可多次调用且不会改变环境。
  • FnOnce 示例:unwrap_or_else 接受 FnOnce 闭包,最多调用一次。
  • FnMut 示例:sort_by_key 接受 FnMut 闭包,允许多次调用并修改环境。

闭包如何捕获和处理变量影响其实现的 trait,进而影响如何使用闭包。

13.2 使用迭代器处理元素序列

迭代器(iterator)负责遍历序列中的每一项并确定序列何时结束的逻辑。在 Rust 中,迭代器是 惰性的(lazy),这意味着在调用消费迭代器的方法之前不会执行任何操作。

1
2
3
4
5
6
7
8
9
10
let v1 = vec![1, 2, 3];
let v1_iter = v1.iter(); // 创建迭代器
for val in v1_iter {
println!("Got: {val}");
}

let v1 = vec![1, 2, 3];
let v1_iter = v1.iter();
let total: i32 = v1_iter.sum();
assert_eq!(total, 6);

如果我们需要一个获取 v1 所有权并返回拥有所有权的迭代器,则可以调用 into_iter 而不是 iter。类似地,如果我们希望迭代可变引用,可以调用 iter_mut 而不是 iter.

Iterator trait 有一系列不同的由标准库提供默认实现的方法;你可以在 Iterator trait 的标准库 API 文档中找到所有这些方法。一些方法在其定义中调用了 next 方法,这也就是为什么在实现 Iterator trait 时要求实现 next 方法的原因。

这些调用 next 方法的方法被称为 消费适配器(consuming adaptors),因为调用它们会消耗迭代器。一个消费适配器的例子是 sum 方法。调用 sum 之后不再允许使用 v1_iter 因为调用 sum 时它会获取迭代器的所有权。

产生其他迭代器的方法

Iterator trait 中定义了另一类方法,被称为 迭代器适配器(iterator adaptors),它们不会消耗当前的迭代器,而是通过改变原始迭代器的某些方面来生成不同的迭代器。

1
2
3
let v1: Vec<i32> = vec![1, 2, 3];
let v2: Vec<_> = v1.iter().map(|x| x + 1).collect();
assert_eq!(v2, vec![2, 3, 4]);

第14章 更多关于 Cargo 和 Crates.io

14.1 采用发布配置自定义构建

Cargo 有两个主要的配置:运行 cargo build 时采用的 dev 配置和运行 cargo build --release 的 release 配置。

dev 配置为开发定义了良好的默认配置,release 配置则为发布构建定义了良好的默认配置。

Cargo 允许你通过 Cargo.toml 文件的 [profile] 部分来配置这些设置。opt-level 设置控制 Rust 会对代码进行何种程度的优化。这个配置的值从 0 到 3。越高的优化级别需要更多的时间编译

1
2
3
4
5
[profile.dev]
opt-level = 0

[profile.release]
opt-level = 3

14.2 将crate发布到crates.io

文档注释

Rust使用///可以为crate添加文档注释,使用cargo doc --open可以生成文档并打开浏览器查看。

1
2
3
4
5
6
7
8
9
10
11
12
13
/// Adds one to the number given.
///
/// # Examples
///
/// ```rust
/// let arg = 5;
/// let answer = my_crate::add_one(arg);
///
/// assert_eq!(6, answer);
/// ```rust
pub fn add_one(x: i32) -> i32 {
x + 1
}

crate

# Examples 部分是一个文档注释的特殫部分,它会被 rustdoc 工具识别并渲染为 crate 文档的一部分。这个部分通常包含一个或多个代码示例,展示如何使用 crate 中的函数。 其中的测试用例也可以会被cargo test当做单测来运行;
# Panics :这个函数可能会 panic! 的场景。并不希望程序崩溃的函数调用者应该确保他们不会在这些情况下调用此函数。
# Errors:如果这个函数返回 Result,此部分描述可能会出现何种错误以及什么情况会造成这些错误,这有助于调用者编写代码来采用不同的方式处理不同的错误。
# Safety:如果这个函数使用 unsafe 代码,这一部分应该会涉及到期望函数调用者支持的确保 unsafe 块中代码正常工作的不变条件(invariants)。

文档注释风格 //! 为包含注释的项,而不是位于注释之后的项增加文档。这通常用于 crate 根文件(通常是 src/lib.rs)或模块的根文件为 crate 或模块整体提供文档。

使用 pub use 导出合适的公有 API

crate用户不熟悉包的内部结构,所以需要提供一个简单的公有 API。可以使用 pub use 重导出定义在私有模块中的公有 API。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// crate定义如下
pub mod kinds {
/// The primary colors according to the RYB color model.
pub enum PrimaryColor {
Red,
Yellow,
Blue,
}
}

pub mod utils {
use crate::kinds::*;

/// Combines two primary colors in equal amounts to create
/// a secondary color.
pub fn mix(c1: PrimaryColor, c2: PrimaryColor) -> SecondaryColor {
// --snip--
}
}

// 用户使用
use art::kinds::PrimaryColor;
use art::utils::mix;

fn main() {
let red = PrimaryColor::Red;
let yellow = PrimaryColor::Yellow;
mix(red, yellow);
}

上述的问题是,create用户必须侵入到内部结构,知道kinds结构和PrimaryColor结构; 使用pub use就可以将kinds和utils模块的公有API导出到crate的根模块中,这样用户就可以直接使用了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pub use self::kinds::PrimaryColor; // 导出
pub use self::utils::mix;

pub mod kinds {
// --snip--
}

pub mod utils {
// --snip--
}

// 用户使用
use art::mix;
use art::PrimaryColor;

fn main() {
// --snip--
}

发布到crates.io

  1. 注册crates.io账号, 获取一个API Token, 使用 cargo login 登录
  2. Cargo.toml中增加项目的元信息,不能与其他人重名, 增加协议、版本等描述
  3. 调用cargo publish命令,发布crate, 发布是永久性的
  4. 调用cargo yank命令,撤销发布的crate, 注意撤回不能删除任何代码,撤回功能无法删除不小心发布的秘密信息;

14.4 使用 cargo install 安装二进制文件

cargo install 命令用于在本地安装和使用二进制 crate。它并不打算替换系统中的包;它意在作为一个方便 Rust 开发者们安装其他人已经在 crates.io 上共享的工具的手段。只有拥有二进制目标文件的包能够被安装。

14.5 Cargo 自定义扩展命令

Cargo 的设计使得开发者可以通过新的子命令来对 Cargo 进行扩展,而无需修改 Cargo 本身。如果 $PATH 中有类似 cargo-something 的二进制文件,就可以通过 cargo something 来像 Cargo 子命令一样运行它。像这样的自定义命令也可以运行 cargo –list 来展示出来。

第15章 智能指针

指针 (pointer)是一个包含内存地址的变量的通用概念。这个地址引用,或 “指向”(points at)一些其他数据。

Rust 中最常见的指针是第四章介绍的 引用(reference)。引用以 & 符号为标志并借用了它们所指向的值。除了引用数据没有任何其他特殊功能,也没有额外开销。

智能指针通常使用结构体实现。智能指针不同于结构体的地方在于其实现了 Deref 和 Drop trait。Deref trait 允许智能指针结构体实例表现的像引用一样,这样就可以编写既用于引用、又用于智能指针的代码。Drop trait 允许我们自定义当智能指针离开作用域时运行的代码。

常用的一些:

  • Box<T>,用于在堆上分配值
  • Rc<T>,一个引用计数类型,其数据可以有多个所有者
  • Ref<T>RefMut<T>,通过 RefCell<T> 访问。( RefCell 是一个在运行时而不是在编译时执行借用规则的类型)。

15.1 Box<T> 指针指向堆上的数据

定义

Box<T>允许你将一个值放在堆上而不是栈上。留在栈上的则是指向堆数据的指针。除了数据被储存在堆上而不是栈上之外,box 没有性能损失。Box<T> 类型是一个智能指针,因为它实现了 Deref trait,它允许 Box<T> 值被当作引用对待。当 Box<T> 值离开作用域时,由于 Box<T> 类型 Drop trait 的实现,box 所指向的堆数据也会被清除。多用于如下场景:

  • 当有一个在编译时未知大小的类型,而又想要在需要确切大小的上下文中使用这个类型值的时候
  • 当有大量数据并希望在确保数据不被拷贝的情况下转移所有权的时候.(转移大量数据的所有权可能会花费很长的时间,因为数据在栈上进行了拷贝。)
  • 当希望拥有一个值并只关心它的类型是否实现了特定 trait 而不是其具体类型的时候(也叫trait 对象)

Box 允许创建递归类型

递归类型(recursive type)的值可以拥有另一个同类型的值作为其自身的一部分。

cons list 是一个来源于 Lisp 编程语言及其方言的数据结构,它由嵌套的列表组成。如(1, (2, (3, Nil)));

1
2
3
4
5
enum List {
Cons(i32, Box<List>), // Box指针的大小是usize,所以可以计算其大小
//Cons(i32, List), // 递归计算其大小,所以会出现失败
Nil,
}

15.2 智能指针 trait

Deref

实现 Deref trait 允许我们重载 解引用运算符(dereference operator)* 。通过这种方式实现 Deref trait 的智能指针可以被当作常规引用来对待,可以编写操作引用的代码并用于智能指针。

Deref 强制转换(deref coercions)将实现了 Deref trait 的类型的引用转换为另一种类型的引用。

Rust 提供了 DerefMut trait 用于重载可变引用的 * 运算符。

Rust 在发现类型和 trait 实现满足三种情况时会进行 Deref 强制转换:

  • T: Deref<Target=U> 时从 &T 到 &U。
  • T: DerefMut<Target=U> 时从 &mut T 到 &mut U。
  • T: Deref<Target=U> 时从 &mut T 到 &U。

Drop

Drop,其允许我们在值要离开作用域时执行一些代码。可以用来释放类似文件或网络连接的资源。当实例离开作用域 Rust 会自动调用 drop,并调用我们指定的代码。变量以被创建时相反的顺序被丢弃。

整个 Drop trait 存在的意义在于其是自动处理的。然而,有时你可能需要提早清理某个值。比如想要强制运行 drop 方法来释放锁以便作用域中的其他代码可以获取锁。Rust 并不允许我们主动调用 Drop trait 的 drop 方法;当我们希望在作用域结束之前就强制释放变量的话,我们应该使用的是由标准库提供的 std::mem::drop

1
2
3
4
5
6
7
8
fn main() {
let c = CustomSmartPointer {
data: String::from("some data"),
};
println!("CustomSmartPointer created.");
drop(c);
println!("CustomSmartPointer dropped before the end of main.");
}

15.3 Rc<T> 引用计数智能指针

为了启用多所有权需要显式地使用 Rust 类型 Rc,其为 引用计数(reference counting)的缩写。引用计数意味着记录一个值的引用数量来知晓这个值是否仍在被使用。如果某个值有零个引用,就代表没有任何有效引用并可以被清理。注意 Rc 只能用于单线程场景;

如15.1中的List案例,就不允许如下的操作:

1
2
3
let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
let b = Cons(3, Box::new(a));
let c = Cons(4, Box::new(a));

Cons 成员拥有其储存的数据,所以当创建 b 列表时,a 被移动进了 b 这样 b 就拥有了 a。接着当再次尝试使用 a 创建 c 时,这不被允许,因为 a 的所有权已经被移动。

修改 List 的定义为使用 Rc 代替 Box。当创建 b 时,不同于获取 a 的所有权,这里会克隆 a 所包含的 Rc<List>,这会将引用计数从 1 增加到 2 并允许 a 和 b 共享 Rc<List> 中数据的所有权。创建 c 时也会克隆 a,这会将引用计数从 2 增加为 3。每次调用 Rc::clone,Rc<List> 中数据的引用计数都会增加,直到有零个引用之前其数据都不会被清理。

1
2
3
4
5
6
7
8
9
10
11
12
13
enum List {
Cons(i32, Rc<List>),
Nil,
}

use crate::List::{Cons, Nil};
use std::rc::Rc;

fn main() {
let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
let b = Cons(3, Rc::clone(&a));
let c = Cons(4, Rc::clone(&a));
}

Rc::clone 的实现并不像大部分类型的 clone 实现那样对所有数据进行深拷贝。Rc::clone 只会增加引用计数,这并不会花费多少时间。

引用计数,其值可以通过调用 Rc::strong_count 函数获得。这个函数叫做 strong_count 而不是 count 是因为 Rc<T> 也有 weak_count;在 “避免引用循环:将 Rc<T> 变为 Weak<T>” 部分会讲解 weak_count 的用途。

15.4 RefCell<T> 和内部可变性模式

内部可变性(Interior mutability)是 Rust 中的一个设计模式,它允许你即使在有不可变引用时也可以改变数据,这通常是借用规则所不允许的。为了改变数据,该模式在数据结构中使用 unsafe 代码来模糊 Rust 通常的可变性和借用规则。不安全代码表明我们在手动检查这些规则而不是让编译器替我们检查。

类似于 Rc<T>RefCell<T> 只能用于单线程场景。如果尝试在多线程上下文中使用RefCell<T>,会得到一个编译错误。

当创建不可变和可变引用时,我们分别使用 &&mut 语法。对于 RefCell<T> 来说,则是 borrowborrow_mut 方法,这属于 RefCell<T> 安全 API 的一部分。borrow 方法返回 Ref<T> 类型的智能指针,borrow_mut 方法返回 RefMut<T> 类型的智能指针。这两个类型都实现了 Deref,所以可以当作常规引用对待。

RefCell<T> 记录当前有多少个活动的 Ref<T>RefMut<T> 智能指针。每次调用 borrowRefCell<T> 将活动的不可变借用计数加一。当 Ref<T> 值离开作用域时,不可变借用计数减一。就像编译时借用规则一样,RefCell<T> 在任何时候只允许有多个不可变借用或一个可变借用。

如果我们尝试违反这些规则,相比引用时的编译时错误,RefCell<T> 的实现会在运行时出现 panic。

结合 Rc<T> 和 RefCell<T> 来拥有多个可变数据所有者

回忆一下 Rc<T> 允许对相同数据有多个所有者,不过只能提供数据的不可变访问。如果有一个储存了 RefCell<T>Rc<T> 的话,就可以得到有多个所有者并且可以修改的值了!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#[derive(Debug)]
enum List {
Cons(Rc<RefCell<i32>>, Rc<List>),
Nil,
}

use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

fn main() {
let value = Rc::new(RefCell::new(5));

let a = Rc::new(Cons(Rc::clone(&value), Rc::new(Nil)));

let b = Cons(Rc::new(RefCell::new(3)), Rc::clone(&a));
let c = Cons(Rc::new(RefCell::new(4)), Rc::clone(&a));

*value.borrow_mut() += 10;

println!("a after = {a:?}");
println!("b after = {b:?}");
println!("c after = {c:?}");
}

15.5 弱引用和循环引用

内存泄露

如果有包含 Rc<T>RefCell<T> 值或类似的嵌套结合了内部可变性和引用计数的类型,请务必小心确保你没有形成一个引用循环.否则 Rust无法捕获他们之间的引用循环,最终造成一个内存泄漏;

避免引用循环:将 Rc 变为 Weak

可以通过调用 Rc::downgrade 并传递 Rc<T> 实例的引用来创建其值的 弱引用(weak reference)。强引用代表如何共享 Rc<T> 实例的所有权。弱引用并不属于所有权关系,当 Rc<T> 实例被清理时其计数没有影响。它们不会造成引用循环,因为任何涉及弱引用的循环会在其相关的值的强引用计数为 0 时被打断。

调用 Rc::downgrade 时会得到 Weak<T> 类型的智能指针。不同于将 Rc<T> 实例的 strong_count 加 1,调用 Rc::downgrade 会将 weak_count 加 1。Rc<T> 类型使用 weak_count 来记录其存在多少个 Weak<T> 引用,类似于 strong_count。其区别在于 weak_count 无需计数为 0 就能使 Rc<T> 实例被清理。

强引用代表如何共享 Rc<T> 实例的所有权,但弱引用并不属于所有权关系。它们不会造成引用循环,因为任何弱引用的循环会在其相关的强引用计数为 0 时被打断。

因为 Weak<T> 引用的值可能已经被丢弃了,为了使用 Weak<T> 所指向的值,我们必须确保其值仍然有效。为此可以调用 Weak<T> 实例的 upgrade 方法,这会返回 Option<Rc<T>>。如果 Rc<T> 值还未被丢弃,则结果是 Some;如果 Rc<T> 已被丢弃,则结果是 None。因为 upgrade 返回一个 Option<Rc<T>>,Rust 会确保处理 Some 和 None 的情况,所以它不会返回非法指针。

第16章 无畏并发

安全且高效地处理并发编程是 Rust 的另一个主要目标。并发编程(Concurrent programming),代表程序的不同部分相互独立地执行,而 并行编程(parallel programming)代表程序不同部分同时执行。

通过利用所有权和类型检查,在 Rust 中很多并发错误都是 编译时 错误,而非运行时错误。

16.1 使用线程同时运行代码

Rust 标准库使用 1:1 线程实现,这代表程序的每一个语言级线程使用一个系统线程。有一些 crate 实现了其他有着不同于 1:1 模型取舍的线程模型。

使用 spawn 创建新线程

为了创建一个新线程,需要调用 thread::spawn 函数并传递一个闭包,并在其中包含希望在新线程运行的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use std::thread;
use std::time::Duration;

fn main() {
thread::spawn(|| {
for i in 1..10 {
println!("hi number {i} from the spawned thread!");
thread::sleep(Duration::from_millis(1));
}
});

for i in 1..5 {
println!("hi number {i} from the main thread!");
thread::sleep(Duration::from_millis(1));
}
}

使用 join 等待所有线程结束

可以通过将 thread::spawn 的返回值储存在变量中来修复新建线程部分没有执行或者完全没有执行的问题。thread::spawn 的返回值类型是 JoinHandle。JoinHandle 是一个拥有所有权的值,当对其调用 join 方法时,它会等待其线程结束。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use std::thread;
use std::time::Duration;

fn main() {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("hi number {i} from the spawned thread!");
thread::sleep(Duration::from_millis(1));
}
});

for i in 1..5 {
println!("hi number {i} from the main thread!");
thread::sleep(Duration::from_millis(1));
}

handle.join().unwrap();
}

将 move 闭包与线程一同使用

move 关键字经常用于传递给 thread::spawn 的闭包,因为闭包会获取从环境中取得的值的所有权,因此会将这些值的所有权从一个线程传送到另一个线程。通过在闭包之前增加 move 关键字,我们强制闭包获取其使用的值的所有权,而不是任由 Rust 推断它应该借用值。

1
2
3
4
5
6
7
8
9
10
11
use std::thread;

fn main() {
let v = vec![1, 2, 3];

let handle = thread::spawn(move || {
println!("Here's a vector: {v:?}");
});

handle.join().unwrap();
}

16.2 消息传递并发

为了实现消息传递并发,Rust 标准库提供了一个 信道(channel)实现。信道是一个通用编程概念,表示数据从一个线程发送到另一个线程。

使用 mpsc::channel 函数创建一个新的信道;mpsc 是 多个生产者,单个消费者(multiple producer, single consumer)的缩写。简而言之,Rust 标准库实现信道的方式意味着一个信道可以有多个产生值的 发送(sending)端,但只能有一个消费这些值的 接收(receiving)端。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use std::sync::mpsc;
use std::thread;

fn main() {
let (tx, rx) = mpsc::channel();

thread::spawn(move || {
let val = String::from("hi");
tx.send(val).unwrap();
});

let received = rx.recv().unwrap();
// recv 会阻塞主线程执行直到从信道中接收一个值 当信道发送端关闭,recv 会返回一个错误表明不会再有新的值到来了
// try_recv 不会阻塞,相反它立刻返回一个 Result<T, E>:Ok 值包含可用的信息,而 Err 值代表此时没有任何消息。
println!("Got: {received}");
}

信道与所有权转移

一旦将值发送到另一个线程后,那个线程可能会在我们再次使用它之前就将其修改或者丢弃。其他线程对值可能的修改会由于不一致或不存在的数据而导致错误或意外的结果。

16.3 共享状态的并发

在某种程度上,任何编程语言中的信道都类似于单所有权,因为一旦将一个值传送到信道中,将无法再使用这个值。共享内存类似于多所有权:多个线程可以同时访问相同的内存位置。

使用互斥器,实现同一时刻只允许一个线程访问数据

互斥器(mutex)是 互相排斥(mutual exclusion)的缩写。在同一时刻,其只允许一个线程对数据拥有访问权。为了访问互斥器中的数据,线程首先需要通过获取互斥器的 锁(lock)来表明其希望访问数据。锁是一个数据结构,作为互斥器的一部分,它记录谁有数据的专属访问权。因此我们讲,互斥器通过锁系统 保护(guarding)其数据。

1
2
3
4
5
6
7
8
9
use std::sync::Mutex;
fn main() {
let m = Mutex::new(5);
{
let mut num = m.lock().unwrap();
*num = 6;
}
println!("m = {m:?}");
}

Mutex<T> 是一个智能指针。更准确的说,lock 调用 返回 一个叫做 MutexGuard 的智能指针。这个智能指针实现了 Deref 来指向其内部数据;它也实现了 Drop,当 MutexGuard 离开作用域时,自动释放锁(发生在示例内部作用域的结尾)。有了这个特性,就不会有忘记释放锁的潜在风险(忘记释放锁会使互斥器无法再被其它线程使用).

如果在线程间直接使用Mutex<T>, 使用闭包和move捕获相同的mutex,编译会报错。原因是第一次循环中的闭包获取了锁,第二次循环中的闭包也尝试获取锁,但第一个闭包还在作用域中,锁还未释放。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use std::sync::Mutex;
use std::thread;

fn main() {
let counter = Mutex::new(0);
let mut handles = vec![];

for _ in 0..10 {
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();

*num += 1;
});
handles.push(handle);
}

for handle in handles {
handle.join().unwrap();
}

println!("Result: {}", *counter.lock().unwrap());
}

如果使用Rc<Mutex<T>>,可以在多个线程中共享所有权,但是不能保证线程安全,因为Rc不是线程安全的,可能存在多个线程并发地同时增删引用计数,导致引用计数出错。

原子引用计数 Arc<T>

幸 Arc 正是这么一个类似 Rc 并可以安全的用于并发环境的类型。字母 “a” 代表 原子性(atomic),所以这是一个 原子引用计数(atomically reference counted)类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];

for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();

*num += 1;
});
handles.push(handle);
}

for handle in handles {
handle.join().unwrap();
}

println!("Result: {}", *counter.lock().unwrap());
}

16.4 使用 Sync 和 Send trait 的可扩展并发

  1. 通过 Send 允许在线程间转移所有权: Send 标记 trait 表明实现了 Send 的类型值的所有权可以在线程间传送。几乎所有的 Rust 类型都是Send 的,不过有一些例外,包括 Rc<T>:这是不能 Send 的,因为如果克隆了 Rc<T> 的值并尝试将克隆的所有权转移到另一个线程,这两个线程都可能同时更新引用计数。为此,Rc<T> 被实现为用于单线程场景,这时不需要为拥有线程安全的引用计数而付出性能代价。
  2. Sync 允许多线程访问: Sync 标记 trait 表明一个实现了 Sync 的类型可以安全的在多个线程中拥有其值的引用。换一种方式来说,对于任意类型 T,如果 &T(T 的不可变引用)是 Send 的话 T 就是 Sync 的,这意味着其引用就可以安全的发送到另一个线程。类似于 Send 的情况,基本类型是 Sync 的,完全由 Sync 的类型组成的类型也是 Sync 的。 例如,Mutex<T>是 Sync 的,因为其允许多个线程访问相同的 Mutex<T>
  3. 通常并不需要手动实现 Send 和 Sync trait,因为由 Send 和 Sync 的类型组成的类型,自动就是 Send 和 Sync 的。因为它们是标记 trait,甚至都不需要实现任何方法。它们只是用来加强并发相关的不可变性的。

第17章 Async 和 await

操作系统的隐式中断提供了一种形式的并发。不过这种并发仅限于整个程序的级别:操作系统中断一个程序并让其它程序得以执行。在很多场景中,由于我们能比操作系统在更细粒度上理解我们的程序,因此我们可以观察到很多操作系统无法察觉的并发机会。

17.1 Futures 和 async 语法

future 是一个现在可能还没有准备好但将在未来某个时刻准备好的值。Rust 提供了 Future trait 作为基础组件,这样不同的异步操作就可以在不同的数据结构上实现。在 Rust 中,我们称实现了 Future trait 的类型为 futures。

async 关键字可以用于代码块和函数,表明它们可以被中断并恢复。在一个 async 块或 async 函数中,可以使用 await 关键字来等待一个 future 准备就绪,这一过程称为 等待一个 future。async 块或 async 函数中每一个等待 future 的地方都可能是一个 async 块或 async 函数中断并随后恢复的点。检查一个 future 并查看其值是否已经准备就绪的过程被称为 轮询(polling)。

Rust 中的 futures 是 惰性(lazy)的:在你使用 await 请求之前它们不会执行任何操作。惰性使得 Rust 可以避免提前运行异步代码,直到真正需要时才执行。注意 Rust 的 await 关键字出现在需要等待的表达式之后而不是之前。也就是说,这是一个 后缀关键字(postfix keyword)。Rust 如此选择是因为这使得方法的链式调用更加简洁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
use trpl::Html;

async fn page_title(url: &str) -> Option<String> {
let resp = trpl::get(url).await;
let resp_text = resp.text().await;
Html::parse(&resp_text)
.select_first("title")
.map(|title_elem| title_elem.inner_html())
}

fn main() {
let args: Vec<String> = std::env::args().collect();

trpl::run(async {
let url = &args[1];
match page_title(url).await {
Some(title) => println!("The title for {url} was {title}"),
None => println!("{url} had no title"),
}
})
}

唯一可以使用 await 关键字的地方是异步函数或者代码块中,同时 Rust 不允许将特殊的 main 函数标记为 async。main 不能标记为 async 的原因是异步代码需要一个 运行时:即一个管理执行异步代码细节的 Rust crate。一个程序的 main 函数可以 初始化 一个运行时,但是其 自身 并不是一个运行时。如果 main 是一个异步函数,需要有其它组件来管理 main futrue 返回的状态机,但是 main 是程序的入口点.

每一个 await point,也就是代码使用 await 关键字的地方,代表将控制权交还给运行时的地方。为此 Rust 需要记录异步代码块中涉及的状态,这样运行时可以去执行其他工作,并在准备好时回来继续推进当前的任务。Rust 编译器自动创建并管理异步代码的状态机数据结构。

17.2 并发与async

join和await都可以等待线程结束, 但是join是阻塞的,而await是非阻塞的。

trpl::join 函数是 公平的(fair),这意味着它以相同的频率检查每一个 future,使它们交替执行,绝不会让一个任务在另一个任务准备好时抢先执行。对于线程来说,操作系统会决定该检查哪个线程和会让它运行多长时间。对于异步 Rust 来说,运行时决定检查哪一个任务。

17.3 使用任意数量的 futures

  1. 可以使用宏版本的join函数,自动处理不同数量的future:

    1
    trpl::join!(tx1_fut, tx_fut, rx_fut);
  2. 使用futures::future::join_all函数,可以传递一个future的迭代器,返回一个future,当所有future都完成时,返回一个包含所有结果的Vec。

    1
    2
    3
        
    let futures: Vec<Pin<Box<dyn Future<Output = ()>>>> =
    vec![Box::pin(tx1_fut), Box::pin(rx_fut), Box::pin(tx_fut)];

在 Rust 中,Pin 和 UnPin 是用于处理指针和内存地址的特殊类型,主要用于确保某些数据在内存中的位置不会被移动。这在处理自引用(self-referential)结构或异步编程中的 Future 时尤为重要。

Pin 类型:

Pin 是一个标记类型,表示指向的值被固定在内存中的特定位置,不能被移动。这对于那些在内存中不能安全移动的类型(如包含指向自身指针的结构)非常重要。通过将数据放入 Pin,可以防止它们被意外地移动,从而确保内存安全。

UnPin 类型:

UnPin 是一个标记 trait,表示类型的值可以安全地在内存中移动。默认情况下,大多数类型都是 UnPin 的,这意味着它们可以在内存中自由移动。然而,对于那些包含自引用的类型,需要显式地标记为不可移动,即不实现 UnPin

在异步编程中的应用:

在异步编程中,Future 是一个核心概念。当你创建一个异步块(async block)时,编译器会生成一个实现了 Future trait 的匿名类型。这些匿名类型可能包含对自身的引用,因此在某些情况下,它们不能被安全地移动。为了确保这些 Future 在内存中的位置固定不变,可以将它们放入 Pin 中。

例如,当你想将多个异步操作存储在一个集合(如 Vec)中并同时运行它们时,需要确保这些异步操作在内存中的位置不变。这时,可以使用 Pin 来固定它们的位置。同时,为了避免不必要的堆分配,可以使用 pin! 宏直接对每个 Future 进行固定。

future 竞争 与 Yield

在 Rust 的异步编程中,Future 是一个核心概念,表示一个可能在未来某个时间点完成的计算。当我们使用 join 系列函数或宏时,通常是等待所有传入的 Future 全部完成后再继续执行后续代码。然而,在某些情况下,我们只关心部分 Future 的完成情况,这类似于让多个 Future 彼此竞争,谁先完成就先处理谁的结果。

Future 竞争:

为了实现上述需求,可以使用类似 race 的函数。该函数接受多个 Future,并在其中任意一个完成时立即返回,忽略其他尚未完成的 Future。例如,假设有两个 Futureslowfast,它们分别在不同的时间后完成。通过将它们传递给 race 函数,可以让它们相互竞争,先完成的那个会被优先处理。

需要注意的是,某些 race 函数的实现并非公平调度,它们可能按照传入参数的顺序依次轮询 Future。这意味着,即使某个 Future 本身更快完成,但由于轮询顺序的原因,可能会出现先启动的 Future 先被处理的情况。

让出(Yielding):

在异步代码中,如果一个 Future 在没有 await 的情况下执行了大量同步操作,可能会阻塞其他 Future 的执行。为了防止这种情况,需要在适当的位置插入 await,以便将控制权交还给运行时,让其他任务有机会执行。

然而,有时我们并不需要真正的延迟操作,只是希望主动让出执行权。此时,可以使用 yield_now 函数。它会立即让出当前任务的执行权,允许运行时调度其他任务,然后再恢复当前任务的执行。这是一种协作式多任务处理方式,每个 Future 通过在适当的位置调用 yield_now,确保不会长时间占用执行权,从而实现更高效的任务调度。

需要注意的是,频繁地插入 awaityield_now 会带来一定的性能开销,因此应根据具体情况权衡使用,以实现最佳的性能和平衡。

通过合理地使用 Future 竞争和让出机制,可以编写出高效且响应迅速的异步代码,确保各个任务能够公平地获取执行机会,避免出现某个任务长时间占用资源的情况。

17.3 流 Streams

流(stream)是异步编程中的一个重要概念,它表示一系列异步产生的值。与 Future 类似,Stream 也是一个 trait,表示异步产生的一系列值。在 Rust 中,Stream 通常用于处理异步数据流,例如从网络接收数据、处理文件流等。

我们以一组数字作为开始,将其转换为一个迭代器并接着调用 map 将其所有值翻倍。然后使用 trpl::stream_from_iter 函数将迭代器转换为流。再然后在 while let 循环中到达时循环处理流中的项。

1
2
3
4
5
6
7
8
9
use trpl::StreamExt;

let values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
let iter = values.iter().map(|n| n * 2);
let mut stream = trpl::stream_from_iter(iter);

while let Some(value) = stream.next().await {
println!("The value was: {value}");
}

第18章 Rust的面向对象特性

18.1 面向对象语言的特征

1. 对象包含数据和行为

  • 面向对象编程(OOP)中的对象是包含数据和操作这些数据的过程(即方法)的组合。Rust 的结构体和枚举通过 impl 块提供了方法,符合面向对象的“对象”定义。尽管 Rust 中的结构体和枚举不被称为对象,但它们确实具备类似对象的特征。

2. 封装:隐藏实现细节

  • 封装是面向对象编程中的一个关键特征,指的是对象的实现细节对外部代码不可见。外部代码只能通过对象的公共 API 与其交互。Rust 使用 pub 关键字来控制哪些字段和方法是公开的,从而实现封装。
  • 示例:定义了一个 AveragedCollection 结构体,包含 listaverage 字段,其中字段是私有的。通过 addremoveaverage 方法来修改和访问数据。通过封装,外部代码无法直接访问和修改内部数据,从而保证了数据的一致性。
  • 封装的好处:通过封装,程序员可以更改对象的内部实现,而不需要改变外部与之交互的代码。例如,可以轻松将 Vec<i32> 替换为 HashSet<i32>,只要保持公共方法签名不变,外部代码无需更改。

3. 继承:类型系统与代码共享

  • 继承是面向对象编程中的一个重要特性,允许子类继承父类的字段和方法。Rust 不支持传统的继承机制,即不能直接在结构体定义中继承父结构体的字段和方法。
  • 然而,Rust 提供了其他方式来实现类似继承的功能,例如:
    • 代码复用:可以通过默认的 trait 方法实现来复用代码。实现了 Summary trait 的类型可以使用 summarize 方法的默认实现,无需再次实现。这与继承的代码复用类似。
    • 多态性:通过 trait 和泛型,Rust 提供了类似多态的机制。不同类型可以通过 trait bounds 被抽象化处理,从而实现类似子类替代父类的功能。

4. 多态(Polymorphism)

  • 多态是指能够处理多种类型数据的能力。Rust 使用 泛型trait bounds 来实现多态性。与继承不同,Rust 的多态性基于 trait,而非继承结构。
  • bounded parametric polymorphism:Rust 使用 trait 来约束不同类型的数据,实现泛型函数对多种类型的支持。这种方法比传统的继承更灵活、更具可扩展性。

18.2 顾及不同类型值的 trait 对象

1. 问题背景

  • 在第八章中,我们讨论了 Vec 只能存储相同类型的元素的局限。为了存储不同类型的元素,我们可以使用枚举类型(如 SpreadsheetCell),该类型可以存储整数、浮点数和文本等不同类型的数据,但这需要在编译时已知所有可能的类型。
  • 然而,有时我们希望能支持库用户在运行时扩展类型集合,这时就需要更加灵活的方式来处理不同类型的数据。

2. trait 对象的引入

  • trait 对象是一种抽象机制,它允许我们在运行时使用不同类型的数据,而不需要在编译时知晓所有的具体类型。通过使用 Box<dyn Trait>&dyn Trait,我们可以存储实现了某个 trait 的不同类型的数据,并且能够在运行时对这些数据调用方法。
  • 示例:我们创建了一个 GUI 库 gui,它包含了 ButtonTextField 等组件,而库用户可以增加自定义的类型(如 ImageSelectBox)。在这个例子中,trait 对象提供了一种存储不同类型并调用相同方法(如 draw)的方式。

3. 定义通用行为的 trait

  • 我们定义了一个 Draw trait,它包含一个 draw 方法。不同的组件(如 ButtonTextField)可以实现 Draw trait,从而定义各自的绘制行为。
  • 使用 trait 对象的一个关键点是,可以将存储不同类型的组件放入一个 Vec<Box<dyn Draw>> 中,这样 Screen 结构体就可以包含多种类型的组件,并在运行时对它们调用 draw 方法。
1
2
3
pub trait Draw {
fn draw(&self);
}

4. 实现 trait 对象

  • Screen 结构体Screen 结构体包含一个 Vec<Box<dyn Draw>> 字段,存储所有实现了 Draw trait 的组件。通过这种方式,Screen 不需要知道具体的组件类型,只要这些组件实现了 Draw trait 就可以进行操作。
1
2
3
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
  • run 方法:在 Screen 结构体中,我们实现了一个 run 方法,它遍历 components 列表,并调用每个组件的 draw 方法。
1
2
3
4
5
6
7
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}

5. 使用 trait 对象的好处

  • 通过使用 trait 对象,Screen 可以存储不同类型的组件,并且无需知道每个组件的具体类型,只要它们实现了 Draw trait。
  • 示例:我们在 main 函数中创建了一个 Screen 实例,并将不同类型的组件(如 ButtonSelectBox)存储在其中。Screenrun 方法会调用每个组件的 draw 方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let screen = Screen {
components: vec![
Box::new(SelectBox {
width: 75,
height: 10,
options: vec![
String::from("Yes"),
String::from("Maybe"),
String::from("No"),
],
}),
Box::new(Button {
width: 50,
height: 10,
label: String::from("OK"),
}),
],
};

screen.run();
  • 在这个例子中,SelectBoxButton 类型的组件被存储为 trait 对象,并且在 run 方法中调用它们的 draw 方法。

6. trait 对象与鸭子类型

  • 使用 trait 对象的概念与动态语言中的鸭子类型(duck typing)类似:“如果它走起来像鸭子,叫起来像鸭子,那么它就是鸭子”。在 Rust 中,Screenrun 方法不需要知道每个组件的具体类型,它只关心组件是否实现了 Draw trait。
  • 与动态语言不同,Rust 会在编译时确保每个类型是否实现了 trait。如果某个类型没有实现 Draw trait,编译器会报错,而不会等到运行时才发现错误。

7. 动态分发与性能

  • 当使用 trait 对象时,Rust 需要进行 动态分发,这意味着编译器无法在编译时确定调用哪个方法,而是在运行时查找方法并进行调用。动态分发相比于静态分发会带来一定的性能开销,因为编译器无法进行优化(例如内联)。
  • 静态分发发生在使用泛型时,编译器能够为每个具体类型生成特定的代码实现,因此能够进行优化。相比之下,使用 trait 对象时,编译器在编译时无法确定调用的方法,必须在运行时进行分发,导致性能损失。

8. 编译时检查

  • Rust 的类型系统确保了 trait 对象的类型安全。例如,当我们尝试在 Screen 中存储不实现 Draw trait 的类型(如 String)时,编译器会报错,提示该类型没有实现 Draw trait。
1
2
3
4
let screen = Screen {
components: vec![Box::new(String::from("Hi"))],
};
screen.run();

这段代码会在编译时出现错误,因为 String 类型没有实现 Draw trait。

18.3 面向对象设计模式的实现

1. 状态模式(State Pattern)概述

  • 状态模式的核心思想是:通过定义一系列的状态对象来表示不同的内部状态,值的行为根据其当前状态而改变。
  • 在Rust中,我们通过结构体和trait而非对象和继承来实现状态模式。每个状态对象负责自己的行为和状态转换,状态的变化由持有状态对象的值管理。
  • 状态模式的优点:业务需求改变时,无需修改值或使用值的代码,只需要调整状态对象的内部逻辑或增加新的状态对象。

2. 博文发布工作流的实现

  • 博文从草案开始,经过审核后发布。未发布的博文不应打印内容,且不能在未审核通过前发布。
  • 通过实现Post结构体,使用DraftPendingReviewPublished状态来控制博文的不同状态。
  • 状态的转换request_reviewapprove等方法管理,保证了博文在不同状态下的行为。

3. 状态模式的Rust实现

  • Post结构体:包含一个Option<Box<dyn State>>字段来存储当前状态。
  • 使用State trait定义所有状态共享的行为,DraftPendingReviewPublished实现了该trait。
  • Post中定义方法来操作博文内容、请求审核和批准,并通过状态对象管理状态转换。

4. 状态模式的优点

  • 封装行为:每个状态只负责自己状态下的行为,不会影响其他状态的行为。
  • 灵活性:只需修改状态对象中的逻辑或增加新状态,而不必修改整个系统的代码。
  • 代码维护性:状态相关的行为都集中在状态对象中,易于维护和扩展。

5. 避免枚举的使用

  • 使用traittrait对象代替枚举来表示状态。相比使用枚举,trait对象的优点是避免了在多个地方使用match表达式,减少了代码重复。

6. 状态模式的权衡取舍

  • 扩展性:增加新状态只需创建一个新结构体并实现State trait,保证了代码的简洁性。
  • 避免冗余:状态之间的转换逻辑通过State trait封装,避免了在Post方法中使用match语句检查状态。
  • 代码复用:如果有多个状态需要共享相同的行为,可以通过trait对象复用代码,而不必为每个状态编写重复的逻辑。

7. 将状态和行为编码为类型

  • 通过将状态编码为不同的类型,Rust的类型系统可以在编译时确保状态的正确性。例如,草案博文不能有content方法,草案博文不能在未发布前获取内容,避免了运行时错误。
  • 使用类型系统确保状态转换的正确性,转换为不同状态时,旧状态将被消费,保证了状态之间的转换逻辑正确性。

8. 状态模式与Rust的优势

  • 类型系统:Rust的类型系统可以在编译时捕捉潜在错误,使得某些不合法的状态转换成为编译错误。
  • 所有权和生命周期:Rust的所有权和生命周期管理使得我们可以在编译时确保资源的正确管理,避免了面向对象语言中的一些常见错误。

第19章 模式与模式匹配

19.1 所有可能会用到模式的位置

Rust中的模式使用非常广泛,以下是模式可能出现的所有位置:

1. match 分支

  • match表达式通过比较值和模式来决定执行哪个分支。

  • 每个分支的左边是一个模式,右边是相应的表达式。

  • match表达式必须是穷尽的,即所有可能的值必须被涵盖,通常使用 _ 来捕获所有未列出的情况。

  • 示例:匹配

    1
    Option<i32>

    类型的值:

    1
    2
    3
    4
    match x {
    None => None,
    Some(i) => Some(i + 1),
    }
  • _模式:可以用来忽略不需要的值。

2. if let 条件表达式

  • if let用于简化只关心一个情况的match表达式。

  • 语法允许处理匹配成功时的代码,并可以使用else处理未匹配的情况。

  • if let可以组合多个条件,如if letelse ifelse,且每个分支的条件不需要互相关联。

  • 示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    if let Some(color) = favorite_color {
    println!("Using your favorite color, {color}, as the background");
    } else if is_tuesday {
    println!("Tuesday is green day!");
    } else if let Ok(age) = age {
    if age > 30 {
    println!("Using purple as the background color");
    } else {
    println!("Using orange as the background color");
    }
    } else {
    println!("Using blue as the background color");
    }

3. while let 条件循环

  • while let用于条件循环,只要模式匹配成功就继续循环。

  • 示例:跨线程接收消息:

    1
    2
    3
    while let Ok(value) = rx.recv() {
    println!("{value}");
    }
  • 在消息传递过程中,while let会一直循环,直到接收到Err

4. for 循环

  • for循环中,模式可以用于解构值。例如,通过解构元组。

  • 示例:

    1
    2
    3
    for (index, value) in v.iter().enumerate() {
    println!("{value} is at index {index}");
    }
  • 这里通过enumerate将迭代的每个元素与其索引一起返回,并通过模式(index, value)解构。

5. let 语句

  • let语句本质上是使用模式来绑定值。变量名即为最简单的模式。

  • 示例:解构元组并绑定多个变量:

    1
    2
    3
    4
    5
    rust


    复制
    let (x, y, z) = (1, 2, 3);
  • 如果模式不匹配,则会导致编译错误,如尝试用两个变量解构三元素的元组:

    1
    2
    3
    4
    5
    rust


    复制
    let (x, y) = (1, 2, 3); // 会报错

6. 函数参数中的模式

  • 函数参数也可以使用模式。通过解构传入的值。

  • 示例:解构元组作为参数:

    1
    2
    3
    fn print_coordinates(&(x, y): &(i32, i32)) {
    println!("Current location: ({x}, {y})");
    }
  • 这里通过模式&(x, y)解构传入的元组。

19.2 Refutability(可反驳性): 模式是否会匹配失效

模式可以分为两种类型:可反驳模式(refutable)不可反驳模式(irrefutable)。了解这两种模式的区别可以帮助我们更好地掌握Rust中的模式匹配及其用法。

1. 不可反驳模式(Irrefutable Patterns)

  • 定义:不可反驳模式能匹配任何传递给它的值,不会发生匹配失败的情况。
  • 例子let x = 5; 中的 x,因为它能匹配任何值,不会出错。
  • 用途let语句、函数参数、for循环中都要求使用不可反驳模式,因为这些地方需要一个始终能匹配的模式。

2. 可反驳模式(Refutable Patterns)

  • 定义:可反驳模式可能匹配失败,意味着它只能匹配特定类型的值,其他值则不能匹配。
  • 例子if let Some(x) = a_value,如果 a_valueNone,该模式匹配失败。

3. 在Rust中使用模式的规则

  • 不可反驳模式的限制let语句、函数参数和for循环只能接受不可反驳模式,因为它们期望永远能成功匹配。
  • if letwhile let的灵活性:这两个表达式可以接受可反驳模式和不可反驳模式,但在使用不可反驳模式时,Rust会发出警告,因为这些结构是用来处理可能失败的模式匹配的。

4. 示例 18-8:不可反驳模式错误

  • 代码:

    1
    2
    3
    4
    5
    rust


    复制
    let Some(x) = some_option_value;
  • 如果 some_option_valueNoneSome(x) 无法匹配,编译器会报错:“refutable pattern in local binding”(可反驳模式出现在局部绑定中)。

  • 错误信息说明let语句要求使用不可反驳模式。

5. 修复示例:使用 if let

  • 修正方法:使用 if let 来代替 let,因为 if let 可以接受可反驳模式。

  • 代码:

    1
    2
    3
    if let Some(x) = some_option_value {
    println!("{x}");
    }
  • 这种方式允许如果匹配失败则忽略代码块中的内容,而不会导致错误。

6. 示例 18-10:不可反驳模式用于 if let

  • 错误用法:将不可反驳模式应用于 if let 会导致不必要的警告。

  • 代码:

    1
    2
    3
    if let x = 5 {
    println!("{x}");
    }
  • 警告:编译器会提示“此模式永远匹配,if let 无用”,因为模式 x = 5 始终会匹配。

7. match表达式中的模式

  • match 分支通常需要使用可反驳模式,只有最后一个分支可以使用不可反驳模式来匹配剩余的所有情况。

19.3 所有的模式语法

本节总结了Rust中所有有效的模式语法,并讨论了在不同情境下使用这些语法的原因和时机。

1. 匹配字面值

  • 使用字面值模式直接匹配特定的值。

  • 例子:

    1
    2
    3
    4
    5
    6
    7
    let x = 1;
    match x {
    1 => println!("one"),
    2 => println!("two"),
    3 => println!("three"),
    _ => println!("anything"),
    }
  • 结果:匹配 1,打印 “one”。

2. 匹配命名变量

  • match表达式中使用的命名变量会在match内部创建新作用域,可能会覆盖外部同名变量。

  • 例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    let x = Some(5);
    let y = 10;

    match x {
    Some(50) => println!("Got 50"),
    Some(y) => println!("Matched, y = {y}"), // 匹配值并创建新变量
    _ => println!("Default case, x = {x:?}"),
    }

    println!("at the end: x = {x:?}, y = {y}");
  • 结果:

    • 打印 “Matched, y = 5”(新变量y覆盖了外部y)。
    • 最后打印 “at the end: x = Some(5), y = 10”(外部变量未被覆盖)。

3. 多个模式

  • | 运算符用于匹配多个模式(“或”运算符)。

  • 例子:

    1
    2
    3
    4
    5
    6
    let x = 1;
    match x {
    1 | 2 => println!("one or two"),
    3 => println!("three"),
    _ => println!("anything"),
    }
  • 结果:匹配 “one or two”。

4. 通过 ..= 匹配值的范围

  • 使用 ..= 语法来匹配闭区间范围内的值。

  • 例子:

    1
    2
    3
    4
    5
    let x = 5;
    match x {
    1..=5 => println!("one through five"),
    _ => println!("something else"),
    }
  • 结果:匹配范围并打印 “one through five”。

5. 解构并分解值

  • 解构结构体

    • 可以解构结构体字段并将其分配给新的变量。

    • 例子:

      1
      2
      3
      4
      5
      6
      7
      struct Point {
      x: i32,
      y: i32,
      }

      let p = Point { x: 0, y: 7 };
      let Point { x, y } = p;
    • 结果:x = 0, y = 7

  • 解构枚举

    • 枚举的成员可以包含数据,解构时可以提取这些数据。

    • 例子:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      enum Message {
      Quit,
      Move { x: i32, y: i32 },
      Write(String),
      }

      let msg = Message::Move { x: 10, y: 20 };

      match msg {
      Message::Move { x, y } => println!("Moving to ({x}, {y})"),
      _ => println!("Other message"),
      }
    • 结果:匹配并打印 “Moving to (10, 20)”。

6. 解构嵌套的结构体和枚举

  • 可以解构嵌套的结构体或枚举。

  • 例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    enum Color {
    Rgb(i32, i32, i32),
    Hsv(i32, i32, i32),
    }

    enum Message {
    ChangeColor(Color),
    }

    let msg = Message::ChangeColor(Color::Hsv(0, 160, 255));

    match msg {
    Message::ChangeColor(Color::Rgb(r, g, b)) => println!("RGB: {r}, {g}, {b}"),
    Message::ChangeColor(Color::Hsv(h, s, v)) => println!("HSV: {h}, {s}, {v}"),
    _ => (),
    }
  • 结果:打印 “HSV: 0, 160, 255”。

7. 忽略模式中的值

  • 使用下划线 _ 来忽略模式中的某些值。

  • 忽略整个值:用于忽略匹配模式中的某个值。

    • 例子:

      1
      2
      3
      4
      fn foo(_: i32, y: i32) {
      println!("This code only uses the y parameter: {y}");
      }
      foo(3, 4);
    • 结果:忽略第一个参数,打印 “This code only uses the y parameter: 4”。

  • 忽略部分值:使用 _ 在模式内忽略部分值。

    • 例子:

      1
      2
      3
      4
      let numbers = (2, 4, 8, 16, 32);
      match numbers {
      (first, _, third, _, fifth) => println!("Some numbers: {first}, {third}, {fifth}"),
      }
    • 结果:打印 “Some numbers: 2, 8, 32”。

  • 使用 .. 忽略剩余值:适用于多个值的结构体或元组。

    • 例子:

      1
      2
      3
      4
      let origin = Point { x: 0, y: 0, z: 0 };
      match origin {
      Point { x, .. } => println!("x is {x}"),
      }
    • 结果:打印 “x is 0”。

8. 匹配守卫提供的额外条件

  • match 分支中可以加入条件语句(匹配守卫)来进一步限制何时匹配。

  • 例子:

    1
    2
    3
    4
    5
    6
    let num = Some(4);
    match num {
    Some(x) if x % 2 == 0 => println!("The number {x} is even"),
    Some(x) => println!("The number {x} is odd"),
    None => (),
    }
  • 结果:打印 “The number 4 is even”。

9. @ 绑定

  • 使用 @ 运算符在匹配时同时绑定值并测试其模式。

  • 例子:

    1
    2
    3
    4
    5
    let msg = Message::Hello { id: 5 };
    match msg {
    Message::Hello { id: id_variable @ 3..=7 } => println!("Found id: {id_variable}"),
    _ => (),
    }
  • 结果:打印 “Found id: 5”。

第20章 高级特征

20.1 不安全 Rust

不安全 Rust(unsafe Rust)是一种特殊的 Rust 语言模式,它允许在编译器的内存安全检查中绕过某些规则,提供额外的超能力。虽然 Rust 的常规代码强制执行内存安全保证,但不安全 Rust 允许程序员在某些情况下直接控制内存,这也意味着需要自行承担潜在的错误风险。

不安全 Rust 的目的

  • 静态分析的保守性:Rust 编译器会保守地拒绝那些可能是合法但无法确定的代码。使用不安全 Rust 就是告诉编译器,“相信我,我知道自己在做什么”。
  • 底层系统编程需求:不安全 Rust 允许进行低级操作,如直接与操作系统交互或编写操作系统内核,这在安全 Rust 中是无法完成的任务。

不安全 Rust 的五个超能力

通过 unsafe 关键字,Rust 可以提供五种不被内存安全检查器验证的操作。它们分别是:

  1. 解引用裸指针
  2. 调用不安全的函数或方法
  3. 访问或修改可变静态变量
  4. 实现不安全 trait
  5. 访问 union 的字段

这些超能力仍然受到 Rust 的借用检查等规则约束,unsafe 关键字只是禁用了某些内存安全检查,允许程序员在代码中确保这些操作的正确性。

解引用裸指针

  • 裸指针(raw pointers)是与引用类似的指针,但它们不受 Rust 的安全保证,允许多个指向同一内存位置的指针,并且没有有效性保证。

  • 可以创建不可变 (*const T) 和可变裸指针 (*mut T)。

  • 示例:创建并解引用裸指针

    1
    2
    3
    4
    5
    6
    7
    8
    let mut num = 5;
    let r1 = &num as *const i32;
    let r2 = &mut num as *mut i32;

    unsafe {
    println!("r1 is: {}", *r1);
    println!("r2 is: {}", *r2);
    }
    • 结果:输出裸指针指向的值。
  • 裸指针的使用场景:通常用于与 C 语言接口(FFI)交互,或构建 Rust 无法验证的安全抽象。

调用不安全函数或方法

  • 不安全函数使用 unsafe 关键字标识,表示该函数可能违反 Rust 的内存安全规则,因此编译器无法保证其安全性。程序员需要自行确保正确使用这些函数。

  • 示例:

    1
    2
    3
    4
    5
    unsafe fn dangerous() {}

    unsafe {
    dangerous();
    }
    • 结果:需要在 unsafe 块中调用不安全函数。

创建不安全代码的安全抽象

  • 将不安全的代码封装到安全的抽象中是防止不安全代码泄漏到应用中的一种常见方法。通过这种方式,外部代码只与安全接口交互,避免了直接暴露不安全代码。
  • 示例:实现 split_at_mut 安全函数
    • 通过 unsafe 使用裸指针,创建不安全代码的安全接口,允许在安全 Rust 中调用。

访问或修改可变静态变量

  • 静态变量是程序中全局可访问的变量,允许在不同地方共享数据。Rust 默认不允许多线程同时访问可变静态变量,因为这可能导致数据竞争。

  • 访问和修改可变静态变量需要使用 unsafe 代码。

  • 示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    static mut COUNTER: u32 = 0;

    fn add_to_count(inc: u32) {
    unsafe {
    COUNTER += inc;
    }
    }

    fn main() {
    add_to_count(3);

    unsafe {
    println!("COUNTER: {COUNTER}");
    }
    }
    • 结果:输出 COUNTER: 3

实现不安全 trait

  • 不安全 trait 是当 trait 的方法包含不能由编译器验证的约束时使用的。实现这些 trait 的类型必须自行保证不违反 Rust 的规则。

  • 示例:实现

    1
    unsafe trait
    1
    2
    3
    4
    5
    6
    7
    unsafe trait Foo {
    // methods
    }

    unsafe impl Foo for i32 {
    // methods implementation
    }

访问 union 的字段

  • 联合体(union)与结构体类似,但它的每个字段都共享相同的内存位置。访问 union 的字段是危险的,因为 Rust 无法保证当前存储的类型。
  • 示例:访问 union 字段需要 unsafe

何时使用不安全代码

  • 当需要执行那些 Rust 无法检查内存安全性的操作时,可以使用不安全代码。尽管如此,使用不安全代码时,程序员必须非常小心,确保所有操作都是有效的。
  • 通过 unsafe 块,可以隔离不安全代码,确保错误只会发生在明确的区域,从而更容易追踪和修复内存安全问题。

总结:不安全 Rust 允许程序员绕过 Rust 的一些内存安全检查,执行低级操作,但它也带来了更高的风险。因此,使用不安全代码时,务必确保代码的正确性,保持不安全块尽可能小,并通过安全接口封装不安全操作。

20.2 高级trait

本节介绍了 Rust 中一些较为高级的 trait 概念,进一步深入了解 trait 的用法,特别是关联类型、运算符重载、父 trait、newtype 模式等。

关联类型(Associated Types)

关联类型允许在 trait 中定义占位符类型,等待在具体实现时提供。这种机制让 trait 更具灵活性,同时避免了每次实现时都要指定类型参数的麻烦。

例子:Iterator trait

1
2
3
4
5
pub trait Iterator {
type Item;

fn next(&mut self) -> Option<Self::Item>;
}
  • Item 是关联类型,占位了返回值的类型。具体的类型在实现 Iterator trait 时指定。比如:
1
2
3
4
5
6
7
impl Iterator for Counter {
type Item = u32;

fn next(&mut self) -> Option<Self::Item> {
// 实现...
}
}
  • 使用关联类型的好处是实现时只需要一次指定类型,而不需要每次都加上类型参数。

默认泛型类型参数与运算符重载

当使用泛型时,可以为泛型类型参数指定默认值,从而避免在大多数情况下重复指定类型。

运算符重载: 通过实现 std::ops 中的相关 trait,Rust 可以自定义常见运算符(如 +)。例如,重载 + 运算符来相加两个 Point 实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use std::ops::Add;

#[derive(Debug, Copy, Clone, PartialEq)]
struct Point {
x: i32,
y: i32,
}

impl Add for Point {
type Output = Point;

fn add(self, other: Point) -> Point {
Point {
x: self.x + other.x,
y: self.y + other.y,
}
}
}

fn main() {
assert_eq!(
Point { x: 1, y: 0 } + Point { x: 2, y: 3 },
Point { x: 3, y: 3 }
);
}
  • 通过 Add trait 实现了 + 运算符。这里的 Output 类型表示 + 运算的结果类型。

默认类型参数: Add trait 提供了一个默认的类型参数 Rhs=Self,表示默认操作两个相同类型的对象。当需要对不同类型进行操作时,可以覆盖默认值,如将 MillimetersMeters 结构体相加:

1
2
3
4
5
6
7
impl Add<Meters> for Millimeters {
type Output = Millimeters;

fn add(self, other: Meters) -> Millimeters {
Millimeters(self.0 + (other.0 * 1000))
}
}
  • 默认泛型类型参数使得在多数情况下不需要重复指定类型,使代码更加简洁。

完全限定语法与消歧义

当多个 trait 或类型中有相同名称的方法时,需要使用完全限定语法来指定调用的是哪个方法。例如,PilotWizard 两个 trait 都定义了 fly 方法,并且 Human 类型实现了这两个 trait:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
trait Pilot {
fn fly(&self);
}

trait Wizard {
fn fly(&self);
}

impl Pilot for Human {
fn fly(&self) {
println!("This is your captain speaking.");
}
}

impl Wizard for Human {
fn fly(&self) {
println!("Up!");
}
}
  • 调用时使用完全限定语法来消歧义:
1
2
3
4
5
6
fn main() {
let person = Human;
Pilot::fly(&person); // 调用 Pilot trait 的 fly
Wizard::fly(&person); // 调用 Wizard trait 的 fly
person.fly(); // 调用 Human 类型自定义的 fly
}

父 trait(Supertrait)

父 trait 用于在一个 trait 中使用另一个 trait 的功能,即要求实现某个 trait 的类型也实现了另一个 trait。通过父 trait 可以增加功能的扩展。

例子:OutlinePrint

1
2
3
4
5
6
7
8
9
10
11
12
13
use std::fmt;

trait OutlinePrint: fmt::Display {
fn outline_print(&self) {
let output = self.to_string();
let len = output.len();
println!("{}", "*".repeat(len + 4));
println!("*{}*", " ".repeat(len + 2));
println!("* {output} *");
println!("*{}*", " ".repeat(len + 2));
println!("{}", "*".repeat(len + 4));
}
}
  • OutlinePrint trait 依赖 fmt::Display trait,表示只有实现了 Display 的类型才能实现 OutlinePrint

父 trait 的应用:

1
2
3
4
5
6
7
8
9
10
11
12
struct Point {
x: i32,
y: i32,
}

impl fmt::Display for Point {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}

impl OutlinePrint for Point {}
  • Point 结构体实现了 Display,因此可以实现 OutlinePrint

newtype 模式

newtype 模式是通过封装一个已有类型(如 Vec<T>)来绕过 Rust 的孤儿规则(orphan rule),从而为该类型实现新的 trait。

例子:封装 Vec<T> 类型

1
2
3
4
5
6
7
8
9
use std::fmt;

struct Wrapper(Vec<String>);

impl fmt::Display for Wrapper {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "[{}]", self.0.join(", "))
}
}
  • Wrapper 结构体封装了 Vec<String>,并实现了 Display trait。

优点与局限性:

  • 优点:不影响原始类型的行为,避免了直接修改外部类型的限制。
  • 局限性:必须为每个方法实现代理代码,如果想让封装类型拥有内部类型的所有方法,可以实现 Deref trait。

20.3 高级类型

本节深入探讨了 Rust 类型系统中的一些高级特性,包括 newtype 模式、类型别名、! 类型(never type)、动态大小类型(DST)和 Sized trait。这些特性帮助我们更好地理解 Rust 类型系统的强大和灵活性。

为了类型安全和抽象而使用 newtype 模式

newtype 模式通过封装现有类型来创建一个新类型,通常用于类型安全、抽象和隐藏实现细节。例如,封装一个 u32 值为 MillimetersMeters 类型,确保在编写函数时不会混淆这两种不同的类型:

1
2
struct Millimeters(u32);
struct Meters(u32);

这种封装方法能够确保类型安全,避免混用类型。例如,函数接受 Millimeters 类型参数时,不能用 Meters 或普通的 u32 调用该函数,从而减少错误发生的可能性。

newtype 模式还可以用来封装类型的实现细节,提供一个公有 API 来与内部类型进行交互,避免外部直接访问内部数据。

类型别名(Type Aliases)

类型别名使用 type 关键字为现有类型创建同义词,但不同于 newtype 模式,类型别名并不会创建新的类型,而是直接为已有类型提供一个新的名字。例如:

1
2
3
4
5
rust


复制
type Kilometers = i32;

Kilometersi32 是同一个类型,使用类型别名不会改变它们的行为。因此,Kilometersi32 可以互换使用:

1
2
3
4
let x: i32 = 5;
let y: Kilometers = 5;

println!("x + y = {}", x + y);

类型别名的主要作用是减少重复代码,特别是在处理复杂类型时。例如,长类型签名:

1
2
3
4
5
rust


复制
let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("hi"));

可以通过类型别名简化:

1
2
3
type Thunk = Box<dyn Fn() + Send + 'static>;

let f: Thunk = Box::new(|| println!("hi"));

类型别名还常用于 Result<T, E> 类型,特别是在如 std::io::Result<T> 这样的模块中,减少了重复代码并提供了统一的接口。

! 类型(Never Type)

! 类型被称为 never type,表示没有值的类型。它用于表示永远不会返回的函数类型。最常见的使用场景是 panic!loop

1
2
3
fn bar() -> ! {
panic!("This function never returns");
}
  • ! 类型的另一个常见用途是 continueloop,它们不返回值,但允许程序继续执行或永远循环。

动态大小类型(DST)和 Sized trait

动态大小类型(DST)是那些在编译时无法确定大小的类型。str 类型就是一个例子,它的大小在编译时无法确定,因为字符串的长度是运行时决定的。由于 DST 的大小无法在编译时知道,不能直接创建 str 类型的变量。

例子:

1
2
3
4
5
rust


复制
let s1: str = "Hello"; // 错误

要使用 str,我们通常会使用引用,如 &strBox<str>,这些类型在运行时保存了动态信息(如字符串的长度),并通过指针来间接引用实际数据。

Rust 使用 Sized trait 来标记那些在编译时已知大小的类型。对于大多数类型,Rust 会自动为其实现 Sized,但对于动态大小类型(如 str 或 trait 对象),我们需要明确地指定它们可能不具有已知大小。

例子:

1
2
3
fn generic<T: ?Sized>(t: &T) {
// 处理动态大小类型
}

?Sized 允许某些类型没有已知大小,例如在处理引用时可以处理动态大小类型。

总结

本节介绍了 Rust 中一些高级类型的使用:

  • newtype 模式:通过封装现有类型来提供类型安全和抽象。
  • 类型别名:为现有类型创建同义词,简化类型签名,减少重复代码。
  • ! 类型:用于表示从不返回的函数,常用于 panic!loop
  • 动态大小类型(DST):用于处理大小在运行时确定的类型,如 str 和 trait 对象,配合 Sized trait 来处理。

这些高级类型帮助开发者在 Rust 中实现更灵活的类型系统,并增强了语言的表达能力。

20.4 高级函数与闭包

本节讨论了 Rust 中的一些高级函数与闭包功能,包括函数指针的使用以及返回闭包的技巧。

函数指针

函数指针允许我们将常规函数作为参数传递给其他函数。这与我们向函数传递闭包的方式类似,但函数指针适用于已经定义的函数,而不是每次都定义一个新的闭包。函数指针通过 fn 关键字表示,代表的是函数类型,而非闭包的 trait(FnFnMutFnOnce)。通过函数指针,我们可以更加明确地指定函数作为参数,而不需要依赖泛型和 trait bounds。

例如,示例 19-27 中的 do_twice 函数接收一个函数指针和一个 i32 值:

1
2
3
4
5
6
7
8
9
10
11
12
fn add_one(x: i32) -> i32 {
x + 1
}

fn do_twice(f: fn(i32) -> i32, arg: i32) -> i32 {
f(arg) + f(arg)
}

fn main() {
let answer = do_twice(add_one, 5);
println!("The answer is: {answer}");
}

这段代码会打印 The answer is: 12。在这里,do_twice 接受一个 fn(i32) -> i32 类型的函数指针 f,并且我们将 add_one 函数作为参数传递给它。与闭包不同,函数指针是一个具体的类型,而非一个 trait,因此更容易理解和使用。

函数指针也能实现闭包的所有 trait(FnFnMutFnOnce),因此可以在闭包的场合传递函数指针,尤其在与 C 语言等外部代码交互时,函数指针尤其有用。

返回闭包

虽然闭包是一个 trait,Rust 无法直接返回闭包,因为闭包没有一个具体的可返回类型。例如,尝试直接返回一个闭包时会报错:

1
2
3
fn returns_closure() -> dyn Fn(i32) -> i32 {
|x| x + 1
}

编译器提示返回类型不能有未包装的 trait 对象。解决方案有两种:

  1. 使用

    1
    impl Trait

    返回类型:

    1
    2
    3
    fn returns_closure() -> impl Fn(i32) -> i32 {
    |x| x + 1
    }
  2. 使用

    1
    Box

    包装返回类型:

    1
    2
    3
    fn returns_closure() -> Box<dyn Fn(i32) -> i32> {
    Box::new(|x| x + 1)
    }

这两种方式都能解决问题,第二种方式通过 Box 将闭包包装成一个动态大小类型(DST),使得 Rust 可以在运行时为闭包分配空间。

总结

  • 函数指针:函数指针是传递已定义函数作为参数的一种方式,它使用 fn 类型,并能够避免每次都定义闭包的开销。
  • 返回闭包:由于闭包没有固定大小,无法直接返回闭包,但可以通过 impl TraitBox<dyn Trait> 来解决这个问题。

20.5 宏

宏与函数的区别

宏和函数的根本区别在于,宏是一种“写代码的代码”,即元编程(metaprogramming)。函数是运行时执行的,而宏在编译时展开。宏可以接受不同数量和类型的参数,而函数的参数数量和类型必须在编译时确定。此外,宏可以在编译时实现某些功能,如在某个类型上实现 trait,这是函数无法做到的。

宏的定义通常比函数复杂,因为它们涉及到生成 Rust 代码的 Rust 代码。而且,在文件中使用宏之前,必须先定义或引入它,而函数则可以随时定义和调用。

使用 macro_rules! 的声明宏

macro_rules! 是最常用的宏定义方式,它允许我们定义通用的元编程规则。其核心是通过模式匹配来生成代码。宏的基本构建块是匹配模式和相应的代码替换。

例如,vec! 宏的简化定义:

1
2
3
4
5
6
7
8
9
10
#[macro_export]
macro_rules! vec {
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$( temp_vec.push($x); )*
temp_vec
}
};
}
  • $( $x:expr ),* 是一个模式,它匹配零个或多个表达式,每个表达式后面跟着一个逗号。
  • $( temp_vec.push($x); )* 表示生成 push 操作的代码。

当调用 vec![1, 2, 3] 时,宏会展开为:

1
2
3
4
5
6
7
{
let mut temp_vec = Vec::new();
temp_vec.push(1);
temp_vec.push(2);
temp_vec.push(3);
temp_vec
}

这样,宏能够根据不同数量和类型的参数生成代码。

过程宏

过程宏(procedural macros)与 macro_rules! 不同,它们接收 Rust 代码作为输入,经过处理后返回新的代码。过程宏有三种形式:自定义派生宏、类属性宏和类函数宏。

  1. 自定义派生宏(Custom Derive): 用于通过 #[derive] 属性为结构体或枚举自动实现 trait。自定义派生宏通过过程宏 crate 来实现。

    示例代码:

    1
    2
    #[derive(HelloMacro)]
    struct Pancakes;

    这段代码会自动为 Pancakes 生成 HelloMacro trait 的实现。

  2. 类属性宏(Attribute-like Macros): 类属性宏用于为任何项(如函数、结构体等)添加自定义属性。这些宏可以更灵活地应用于不同类型的代码元素。

    例如,#[route] 属性可能用于 Web 框架的路由定义:

    1
    2
    3
    4
    #[route(GET, "/")]
    fn index() {
    // 处理请求
    }
  3. 类函数宏(Function-like Macros): 类函数宏的定义类似于函数调用,接受 TokenStream 作为参数,并返回生成的代码。它们允许更复杂的处理,例如 SQL 查询解析。

    示例:

    1
    2
    3
    4
    #[proc_macro]
    pub fn sql(input: TokenStream) -> TokenStream {
    // 解析 SQL 并返回处理后的代码
    }

总结

  • 宏与函数:宏在编译时展开,并能够处理不同数量和类型的参数,适用于更复杂的元编程场景。相比之下,函数在运行时执行。
  • 声明宏:通过 macro_rules! 可以定义用于代码生成的模式匹配宏,例如 vec! 宏。
  • 过程宏:包括自定义派生、类属性和类函数宏,能够为类型或其他代码元素生成和修改代码。