rust 2024年12月28日

Effective Rust

35个提升Rust代码质量的途径
书籍封面

Effective Rust

作者:Mara Bos 出版日期:2024-05-07 出版社:O'Reilly Media, Inc.
书中以35个具体的例子,详细讲解如何改进Rust代码。内容涵盖类型系统(例如枚举、Result、类型转换)、特性(trait)、生命周期、借用检查器、并发编程、错误处理、依赖管理以及工具的使用等方面,旨在帮助读者编写更有效、更安全、更易于维护的Rust代码。书中还强调了Rust的内存安全特性,并讨论了如何处理与其他语言(例如C和C++)的互操作性。 最后,还介绍了一些提高开发效率的工具和方法,例如持续集成系统。

第一章:类型(Types)

本章主要讨论 Rust 的类型系统及其在表达数据结构和行为方面的应用。

  • 使用类型系统表达数据结构
    • 强调 Rust 的类型系统能够帮助开发者清晰地表达数据结构,提高代码的可读性和可维护性。
    • 介绍了 元组结构体 (tuple structs),其字段通过数字索引访问,例如 m.0
    • 深入探讨了 枚举 (enums),它是 Rust 类型系统的核心。枚举可以定义一组互斥的值,并可以附带数值。
      enum HttpResultCode {
          Ok = 200,
          NotFound = 404,
          Teapot = 418,
      }
    • 使用枚举可以增强代码的可读性和类型安全性,避免使用布尔值参数时可能出现的混淆。例如,使用 Sides::BothOutput::BlackAndWhite 比使用 truefalse 更清晰易懂。
    • match 表达式 用于处理枚举的不同变体,Rust 编译器会强制检查所有变体是否都被覆盖,从而避免遗漏情况。
    • 如果枚举仅仅是一个 C 风格的数值列表,可以使用 non_exhaustive 属性来避免添加新变体时的破坏性变更。
  • 使用类型系统表达通用行为
    • 函数 (functions) 可以通过 fn 关键字定义,并可以指定返回值类型。函数也可以仅用于副作用,没有返回值。
      fn div(x: f64, y: f64) -> f64 {
          if y == 0.0 {
              return f64::NAN;
          }
          x / y
      }
      fn show(x: f64) {
          println!("x = {x}");
      }
    • 方法 (methods) 与特定的数据结构关联,通过 self 引用该数据结构。方法可以修改、读取或消耗数据结构. 方法可以被添加到 enum 类型和 struct 类型。
      enum Shape {
          Rectangle { width: f64, height: f64 },
          Circle { radius: f64 },
      }
      
      impl Shape {
          pub fn area(&self) -> f64 {
              match self {
                  Shape::Rectangle { width, height } => width * height,
                  Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
              }
          }
      }
    • 函数指针 (function pointers) 可以作为参数传递,允许代码在运行时改变行为。函数指针类型实现了 CopyEq 等 trait. 函数的名称需要显式转换为 fn 类型.
    • 闭包 (closures) 是一种匿名函数,可以捕获其所在环境中的变量。闭包通过 FnOnceFnMutFn 等 trait 来表达不同的捕获行为. 使用 最通用的 Fn* trait 可以为调用者提供最大的灵活性.
  • 优先使用 OptionResult 的转换方法
    • Option<T> 用于表示可能存在或不存在的值,而 Result<T, E> 用于表示可能成功或失败的操作。
    • 标准库为 OptionResult 提供了多种转换方法,以避免显式使用 match 表达式,从而使代码更简洁。
  • 优先使用惯用的错误类型
    • 当函数可能产生多种不同类型的错误时,可以使用枚举 (enum) 来统一表示。
    • From trait 可以实现不同错误类型之间的自动转换,配合 ? 操作符,可以简化错误处理.
    • 可以使用 thiserror crate 来简化错误类型定义。
  • 理解类型转换
    • Rust 中的类型转换通过 FromInto trait 实现。如果类型 U 实现了 From<T>,那么类型 T 会自动实现 Into<U>
    • Coercion (强制转换) 是一种自动类型转换机制,可以发生在某些情况下,例如:数组到切片、具体类型到 trait 对象、较长的生命周期到较短的生命周期。用户定义的类型可以通过实现 DerefDerefMut trait 来影响 coercion 行为。
  • 拥抱 newtype 模式
    • newtype 模式 是指创建一个包含单个字段的元组结构体,可以为现有类型赋予新的语义,并解决一些类型安全问题。
    • 例如,可以使用 newtype 模式来避免单位转换错误。
    • newtype 模式也可以用于绕过孤儿规则 (orphan rule),为外部类型实现外部 trait。
  • 使用 Builder 模式处理复杂类型
    • Builder 模式 可以简化复杂类型的构造过程,允许用户链式调用 setter 方法来设置字段值。
    • builder 可以用于创建多个实例,并且可以克隆模板。
    • 可以使用宏 (macros) 或现有的 crate(如 derive_builder)来减少 builder 模式的样板代码。
  • 熟悉引用和指针类型
    • 引用 (references) 是一种不拥有数据的指针,分为可变引用 (&mut T) 和不可变引用 (&T)。
    • 切片 (slices) 可以引用数组的一部分。
    • Vec 是一种动态数组,可以增长和缩小。
    • Trait 对象 是指向实现了特定 trait 的具体类型的指针,通过虚表 (vtable) 实现动态分发。
    • DerefDerefMut trait 用于智能指针类型,允许像普通引用一样访问它们指向的数据。
    • AsRefAsMut trait 用于类型之间的引用转换。
    • BorrowBorrowMut trait 用于处理引用和被移动的值。
    • Cow 类型可以持有拥有的数据或借用的数据的引用。
    • Rc<T> 用于在单线程环境中共享所有权。Weak<T>Rc<T> 的弱引用。
    • Arc<T>Rc<T> 的线程安全版本,使用原子计数器。
    • Mutex<T> 用于在多线程环境中保护可变数据。
  • 考虑使用迭代器转换而不是显式循环
    • 迭代器 (iterators) 提供了一种处理集合的便捷方式,避免显式循环。
    • 迭代器转换方法可以链式调用,实现复杂的数据处理操作。例如 filter, take, map, sum.
    • iter() 方法用于创建不可变迭代器,iter_mut() 方法用于创建可变迭代器.

第二章:Trait

本章主要介绍 Rust 中的 trait 以及一些常用的标准 trait。

  • 理解标准 trait:

    • Trait 描述了类型的行为,允许对不同类型进行抽象操作。
    • 标准库定义了很多有用的 trait,如 CloneCopyDefaultPartialEqEqPartialOrdOrdHashDebugDisplay
    • Clone 用于创建类型的副本,Copy 用于指示类型可以通过按位复制来创建副本。
    • Default trait 可以提供类型的默认值, 方便初始化结构体.
    • PartialEq trait 定义了类型的相等性比较,Eq trait 表示类型满足等价关系,即x == x
    • PartialOrd trait 用于类型的部分排序,Ord trait 用于类型的完全排序。
    • Hash trait 用于创建类型的哈希值,Debug trait 用于为程序员显示类型,Display trait 用于为用户显示类型。
    • 当赋值时,带有 Copy 的类型执行位复制,而没有 Copy 的类型会移动(move).
    • 对于没有浮点数相关特性的用户定义类型, 应该尽可能实现 Eq trait。
    • 标准 trait 总结:
      • Clone: 克隆自身. clone 方法
      • Copy: 标记 trait. 编译器处理按位复制
      • Default: 默认值. default 方法
      • PartialEq: 部分相等性. eq 方法
      • Eq: 完全相等性,标记trait
      • PartialOrd: 部分排序. partial_cmp 方法
      • Ord: 完全排序. cmp 方法
      • Hash: 哈希. hash 方法
      • Debug: 程序员输出. fmt 方法
      • Display: 用户输出. fmt 方法
      • Add, Sub, Mul, Div, Rem: 加减乘除, 求余, add, sub, mul, div, rem 方法
      • AddAssign, SubAssign, MulAssign, DivAssign, RemAssign: 加减乘除赋值, 求余赋值, add_assign, sub_assign, mul_assign, div_assign, rem_assign 方法
      • BitAnd, BitOr, BitXor: 按位与或异或, bitand, bitor, bitxor 方法
      • BitAndAssign, BitOrAssign, BitXorAssign: 按位与或异或赋值, bitand_assign, bitor_assign, bitxor_assign 方法
      • Neg: 取负, neg 方法
      • Not: 取反, not 方法
      • Shl, Shr: 左移, 右移, shl, shr 方法
      • ShlAssign, ShrAssign: 左移赋值, 右移赋值, shl_assign, shr_assign 方法
      • Fn, FnMut, FnOnce: 函数调用, call, call_mut, call_once 方法
      • Error: 错误类型. Display + Debug 方法
      • From, TryFrom: 类型转换, from, try_from 方法
      • Into, TryInto: 类型转换, into, try_into 方法
      • AsRef, AsMut: 引用转换, as_ref, as_mut 方法
      • Borrow, BorrowMut: 借用转换, borrow, borrow_mut 方法
      • ToOwned: 克隆, to_owned 方法
      • Deref, DerefMut: 解引用, deref, deref_mut 方法
      • Index, IndexMut: 索引访问, index, index_mut 方法
      • Drop: 析构. drop 方法
  • 理解泛型和 trait 对象之间的权衡:

    • 泛型 (generics) 允许编写可以处理多种类型的代码,使用 trait bounds 来约束类型参数。泛型函数在编译时会被单态化,为每种类型生成不同的代码。
      pub trait Draw {
          fn bounds(&self) -> Bounds;
      }
      pub fn on_screen<T: Draw>(draw: &T) -> bool {
           overlap(SCREEN_BOUNDS, draw.bounds()).is_some()
      }
    • Trait 对象 是指向实现了特定 trait 的类型的指针,允许在运行时进行动态分发。trait 对象的大小不固定 (fat pointers), 包含指向数据和虚表的指针。
    • 泛型可能会导致代码膨胀,因为编译器为每种类型生成一个副本,而 trait 对象只有一个实例。
    • 泛型在编译时检查类型,并且可以访问类型的所有方法,trait 对象只能访问 trait 中定义的方法。
    • 为 trait 实现 blanket implementations 可以简化类型操作。

第三章:概念(Concepts)

本章深入探讨 Rust 的一些核心概念,包括生命周期、借用检查器、并发以及错误处理。

  • 理解生命周期:
    • Rust 中的每个引用都有一个关联的生命周期 (lifetime),用于确保引用在有效的时间段内使用。
    • 生命周期标签 (lifetime labels) 表示为 'a,并遵循特定规则。
    • 生命周期标签可以省略,通过生命周期省略规则来推断.
    • 'static 生命周期 用于表示永远不会失效的引用,例如全局数据或堆上泄漏的内存。
    • 生命周期参数的传递遵循 协变 (covariance) 规则,即输出的生命周期必须包含在输入的生命周期之内。
    • 非词法生命周期 (non-lexical lifetimes) 允许借用检查器更精确地确定引用的生命周期.
    • 所有 Rust 引用都有生命周期,它影响了数据结构及其引用的生命周期.
  • 理解借用检查器:
    • 借用检查器 (borrow checker) 是 Rust 编译器的重要组成部分,用于确保内存安全和避免数据竞争。
    • 借用规则包括:可变借用 (mutable borrow) 和不可变借用 (immutable borrow), 一个资源在同一时间只能有一个可变借用或多个不可变借用。
    • std::mem::replace 可以用于替换可变引用的值。
    • 使用局部变量和显式类型注释可以帮助理解复杂的类型转换和借用问题。
    • 解决借用检查器错误的方法包括:使用数据结构设计, 使用局部代码重构, 使用智能指针。
    • 树形结构可以使用 Rc<RefCell<T>>Weak<T> 实现。
  • 避免编写不安全的代码:
    • unsafe 关键字用于表示一段不安全的代码,需要开发者自行保证内存安全。
    • 应该尽可能避免使用 unsafe 代码,优先使用标准库或第三方 crate 提供的安全抽象。
    • once_cellrandbyteordercxx 等 crate 封装了不安全代码,提供了常用的功能。
  • 警惕共享状态的并行:
    • 数据竞争 (data race) 指的是多个线程同时访问同一个可变数据,并且至少有一个线程在进行写入操作。
    • 多线程环境下的数据竞争会导致不确定的行为。
    • C++ 中的多线程示例展示了未加锁的代码可能出现的问题。
    • 可以使用互斥锁 (mutex) 或原子操作来避免数据竞争, 保证多线程安全。
    • Rust 中使用 MutexArc 类型实现线程安全的数据共享。
    • 多线程编程时,应该优先考虑简单且明显正确的代码,而不是复杂且不明显错误的代码。
  • 不要 panic!:
    • panic! 宏会导致程序崩溃,应该尽可能避免使用,而应该优先返回 Result 类型,让调用者处理错误。
    • Result 类型可以传递错误信息,使错误处理更加明确和可控。
  • 避免反射:
    • Rust 中没有像 Java 那样的运行时反射机制,但可以使用 std::any::type_namestd::any::Any trait 来获取类型信息。
    • type_name 函数在编译时访问类型信息, 没有运行时代码确定类型.
    • Any trait 对象本质上是指向具体项的原始指针和类型标识符的组合,可以通过 downcast_refdowncast_mut 方法进行类型转换.
    • Trait 对象只能访问 trait 中定义的方法,不能动态转换到其他 trait 对象.
  • 避免过度优化:
    • 过早优化可能会导致代码难以理解和维护,应该优先编写清晰易懂的代码。
    • 应该优先编写简单, 容易理解的代码, 而不是那些很难理解的非配零拷贝算法
    • 一个过早优化的示例是使用生命周期标注的结构体,虽然它避免了内存拷贝,但难以使用。
    • 拥有数据结构可以改善代码的人体工程学。

第四章:依赖(Dependencies)

本章讨论 Rust 项目中的依赖管理,包括语义化版本控制、模块和依赖冲突。

  • 理解语义化版本控制的承诺:
    • 语义化版本控制 (SemVer) 用于描述库的版本号,并表达不同版本之间的兼容性。
    • 版本号通常由三个部分组成:主版本号、次版本号和修订号 (major.minor.patch)。
    • 更改主版本号表示不兼容的 API 更改,更改次版本号表示添加了新的兼容功能,更改修订号表示修复了 bug。
    • 作为库的作者,应该避免在补丁版本中破坏兼容性,并通过 Git 标签来管理版本。
    • 避免使用通配符(如 *0.*)指定依赖的版本,这样会导致依赖的版本可能与您的库不兼容。
  • 模块的可见性:
    • pub 关键字表示模块项是公开的,可以被其他模块访问。
    • pub(crate) 表示该项只能在当前 crate 中访问。
    • pub(in path) 表示该项只能在指定的路径中访问.
    • pub(super)pub(self) 表示在父模块和当前模块中访问。
    • 应该优先选择私有代码,而不是公共代码
  • 依赖冲突:
    • 依赖冲突可能发生在多个 crate 使用相同名称的类型或 trait 时,可以使用 as 关键字来重命名导入的项.
    • 当多个 crate 使用相同名称的 trait,并且实现了相同的方法时,可能会出现冲突.
    • 当直接依赖和传递依赖使用了同一个 crate,但不同版本时,可能会出现依赖冲突。
    • Cargo 会尽可能地选择兼容的版本,并生成依赖关系图。
    • 对于同名的 crate,可以使用别名来消除歧义 .
  • Cargo 功能:
    • Cargo features 允许可选地启用或禁用某些功能,使得代码可以有条件编译。
    • Features 可以在 Cargo.toml 中定义,并在代码中使用 #[cfg(feature = "name")] 进行条件编译.
    • 避免在 public 结构体中使用 feature-gated 的字段,因为用户可能会不知道是否应该使用它们.
    • 功能名称应该避免与 crate 的名称冲突, 因为他们共享命名空间.

第五章:工具(Tooling)

本章介绍 Rust 开发中常用的一些工具,包括文档生成、宏、Clippy 和测试。

  • 文档化公共接口:
    • 应该为公共接口编写文档注释,以提高代码的可读性和可维护性。
    • 避免在文档中重复代码中已有的信息,重点描述代码的目的和使用方式。
    • 使用 /// 来编写文档,使用 //! 来描述整个模块.
    • 文档注释应当关注 Why,而不是 What 和 How, 这样能够更好的抵抗代码变化.
  • 明智地使用宏:
    • 宏 (macros) 是一种元编程工具,可以生成代码,从而避免代码重复。
    • 声明宏 (declarative macros) 使用 macro_rules! 声明,通过模式匹配来生成代码。
      macro_rules! inc_item {
          { $x:ident } => {
              $x.contents += 1;
          }
      }
    • 宏可以修改或使用其参数,所以可能会产生意想不到的副作用。
    • 宏扩展后的代码可以包含控制流操作。
    • 使用 format_args! 宏可以构建格式化的字符串。
    • 过程宏 (procedural macros) 使用函数来处理 TokenStream,分为函数式过程宏、属性过程宏和派生过程宏.
    • syn crate 可以帮助解析过程宏的输入。
    • 尽可能的使用声明宏而不是函数式的过程宏, 因为前者更容易编写
    • 应该优先选择泛型,然后是宏来避免重复.
    • 使用宏保持相关信息同步.
    • 宏定义中的$( ... )+语法可以为宏的每一个参数生成多行代码.
  • 听取 Clippy 的建议:
    • Clippy 是一个 Rust 代码的静态分析工具,可以提供代码质量、性能和可读性方面的建议。
    • Clippy 可以指出代码中不必要的操作,潜在的错误,以及可以改进的地方。
    • Clippy 也可以用来查找重复依赖和使用通配符导入.
    • 应该把 Clippy 当作一个有用的助手而不是一个需要对抗的敌人.
  • 编写超越单元测试的测试:
    • 应该编写多种类型的测试,包括单元测试、集成测试、行为测试和模糊测试等,以确保代码的质量和健壮性。
    • 单元测试使用 #[test] 属性标记,并使用 assert_eq! 等宏进行断言。
    • 可以使用 #[should_panic] 属性来测试会 panic 的代码.
    • 还可以使用 cargo-fuzz 来进行模糊测试, 以及 criterion crate 进行基准测试.
    • cargo-expand 可以用来查看宏展开后的代码.
  • 充分利用工具生态系统:
    • Rust 的工具生态系统非常丰富,包括代码格式化工具(rustfmt)、代码分析工具(Clippy)、测试工具和文档生成工具等.
    • bindgen 可以用来为 C 库自动生成 Rust FFI 绑定.

第六章:超越标准 Rust(Beyond Standard Rust)

本章讨论了超越标准 Rust 的一些主题,包括 no_std 环境、FFI 和 Rust 与 C 的互操作性。

  • 考虑使库代码与 no_std 兼容:
    • no_std 属性表示代码不依赖于标准库,可以在没有操作系统支持的环境下运行。
    • no_std 环境需要使用 extern crate alloc; 来引入分配器,并手动处理内存分配。
    • no_std 环境下可以使用 alloc crate 中的类型,如 Box<T>Vec<T>String
    • try_reserve 方法可以用来进行失败的内存分配.
  • 控制跨越 FFI 边界的内容:
    • FFI (Foreign Function Interface) 用于调用其他语言的代码,如 C 代码。
    • 在 FFI 边界,应该小心处理内存管理,避免内存泄漏或不安全的访问。
    • 可以使用封装结构体 (wrapper struct) 来管理 FFI 资源.
    • C 语言的原始指针需要进行空检查.
    • 可以使用 repr(C) 属性来确保结构体在 C 中的内存布局与 Rust 中的相同.
  • 优先选择 bindgen 而不是手动 FFI 映射:
    • bindgen 是一个用于自动生成 Rust FFI 绑定的工具,可以简化 C 代码与 Rust 代码的交互。
    • bindgen 可以生成类似于手工编写的 FFI 声明代码.
    • bindgen 可以处理 C 结构体、函数和枚举,避免手动编写 FFI 绑定的繁琐工作.
    • bindgen 也能处理 C++ 代码中的一些结构 .
  • 其他主题:
    • 本章还提到了 bare-metal Rust 和 crates.io 生态系统,鼓励读者探索更多 Rust 的可能性.

总结

《Effective Rust》这本书深入探讨了 Rust 编程语言中的各种最佳实践和重要概念。通过阅读本书,可以学习如何有效地利用 Rust 的类型系统、内存安全机制、并发模型以及丰富的工具生态系统,编写高质量的 Rust 代码。 这本书为 Rust 程序员提供了非常实用的建议,帮助他们编写更加清晰、安全、高效的代码。