第7章 类型安全

类型安全 — Rust API Guidelines

译文 · 基于 Rust API Guidelines

原文链接: https://rust-lang.github.io/api-guidelines/type-safety.html

类型安全

Newtype 提供静态区分 (C-NEWTYPE)

Newtype 可以在静态层面区分同一底层类型的不同解释。

例如,一个 f64 值既可能表示英里数,也可能表示千米数。使用 newtype,我们可以跟踪其预期解释:

1
2
3
4
5
6
7
8
9
struct Miles(pub f64);
struct Kilometers(pub f64);

impl Miles {
    fn to_kilometers(self) -> Kilometers { /* ... */ }
}
impl Kilometers {
    fn to_miles(self) -> Miles { /* ... */ }
}

一旦将这两种类型分开,就可以静态确保不会把它们弄混。例如,函数

1
fn are_we_there_yet(distance_travelled: Miles) -> bool { /* ... */ }

不会被意外地用 Kilometers 值调用。编译器会提醒我们进行转换,从而避免某些 catastrophic bugs。

参数通过类型传达含义,而非 bool 或 Option (C-CUSTOM-TYPE)

优先

1
let w = Widget::new(Small, Round)

而不是

1
let w = Widget::new(true, false)

像 bool、u8 和 Option 这样的核心类型可以有许多种解释。

应使用特意设计的类型(无论是枚举、结构体还是元组)来传达解释和不变量。在上面的例子中,若不查看参数名,很难立刻明白 true 和 false 在传达什么,而 Small 和 Round 则更有提示性。

使用自定义类型也更容易在日后扩展选项,例如增加一个 ExtraLarge 变体。

关于用零成本方式为已有类型包上一层可区分名称,见 newtype 模式(C-NEWTYPE)。

一组标志使用 bitflags 而非枚举 (C-BITFLAG)

Rust 支持带有显式指定判别值的 enum 类型:

1
2
3
4
5
enum Color {
    Red = 0xff0000,
    Green = 0x00ff00,
    Blue = 0x0000ff,
}

当 enum 类型需要被序列化为与其他系统/语言兼容的整数值时,自定义判别值很有用。它们支持「类型安全」的 API:函数接受 Color 而非整数,即可保证得到格式正确的输入,即便随后把这些输入当作整数使用。

enum 允许 API 从多个选项中恰好请求一个。有时 API 的输入反而是一组标志的存在与否。在 C 代码中,这通常通过让每个标志对应特定的位来完成,从而用单个整数表示例如 32 或 64 个标志。Rust 的 bitflags crate 为这种模式提供了类型安全的表示。

 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 bitflags::bitflags;

bitflags! {
    struct Flags: u32 {
        const FLAG_A = 0b00000001;
        const FLAG_B = 0b00000010;
        const FLAG_C = 0b00000100;
    }
}

fn f(settings: Flags) {
    if settings.contains(Flags::FLAG_A) {
        println!("doing thing A");
    }
    if settings.contains(Flags::FLAG_B) {
        println!("doing thing B");
    }
    if settings.contains(Flags::FLAG_C) {
        println!("doing thing C");
    }
}

fn main() {
    f(Flags::FLAG_A | Flags::FLAG_C);
}

Builder 使复杂值的构造成为可能 (C-BUILDER)

某些数据结构构造起来很复杂,因为构造过程需要:

  • 大量输入
  • 复合数据(例如切片)
  • 可选的配置数据
  • 在若干种风格之间选择

这很容易导致大量彼此不同、且各自带有许多参数的构造函数。

如果 T 是这样的数据结构,考虑引入一个 T 的 builder:

  1. 引入一个单独的数据类型 TBuilder,用于逐步配置 T 值。在可能的情况下选择更好的名称:例如 Command 是 child process 的 builder,Url 可由 ParseOptions 创建。
  2. builder 的构造函数应当只把构造 T 所必需的数据作为参数。
  3. builder 应当提供一套便于配置的方法,包括逐步设置复合输入(如切片)。这些方法应当返回 self 以支持链式调用。
  4. builder 应当提供一个或多个「终结」方法,用于实际构建 T。

当构建 T 涉及副作用(例如生成任务或启动进程)时,builder 模式尤其合适。

在 Rust 中,builder 模式有两种变体,区别在于对所有权的处理,如下所述。

非消耗式 builder(首选)

在某些情况下,构造最终的 T 并不需要消耗 builder 本身。下面这个 std::process::Command 的变体就是一个例子:

 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
// 注意:实际的 Command API 并不使用拥有所有权的 String;
// 这是一个简化版本。

pub struct Command {
    program: String,
    args: Vec<String>,
    cwd: Option<String>,
    // 等等
}

impl Command {
    pub fn new(program: String) -> Command {
        Command {
            program: program,
            args: Vec::new(),
            cwd: None,
        }
    }

    /// 添加一个传给程序的参数。
    pub fn arg(&mut self, arg: String) -> &mut Command {
        self.args.push(arg);
        self
    }

    /// 添加多个传给程序的参数。
    pub fn args(&mut self, args: &[String]) -> &mut Command {
        self.args.extend_from_slice(args);
        self
    }

    /// 设置子进程的工作目录。
    pub fn current_dir(&mut self, dir: String) -> &mut Command {
        self.cwd = Some(dir);
        self
    }

    /// 将命令作为子进程执行,并返回该子进程。
    pub fn spawn(&self) -> io::Result<Child> {
        /* ... */
    }
}

注意,真正使用 builder 配置来生成进程的 spawn 方法,以共享引用接受 builder。这之所以可行,是因为生成进程并不需要配置数据的所有权。

因为终结方法 spawn 只需要引用,配置方法接受并返回 self 的可变借用。

好处

由于全程使用借用,Command 可以方便地用于单行写法以及更复杂的构造:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 单行写法
Command::new("/bin/cat").arg("file.txt").spawn();

// 复杂配置
let mut cmd = Command::new("/bin/ls");
if size_sorted {
    cmd.arg("-S");
}
cmd.arg(".");
cmd.spawn();

消耗式 builder

有时 builder 在构造最终类型 T 时必须转移所有权,这意味着终结方法必须接受 self 而非 &self。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
impl TaskBuilder {
    /// 为即将创建的任务命名。
    pub fn named(mut self, name: String) -> TaskBuilder {
        self.name = Some(name);
        self
    }

    /// 重定向任务本地的 stdout。
    pub fn stdout(mut self, stdout: Box<io::Write + Send>) -> TaskBuilder {
        self.stdout = Some(stdout);
        self
    }

    /// 创建并执行一个新的子任务。
    pub fn spawn<F>(self, f: F) where F: FnOnce() + Send {
        /* ... */
    }
}

这里,stdout 配置涉及传递 io::Write 的所有权,该所有权必须在构造时(在 spawn 中)转移给任务。

当 builder 的终结方法需要所有权时,存在一个基本权衡:

  • 如果其他 builder 方法接受/返回可变借用,复杂配置会很顺畅,但单行配置将变得不可能。

  • 如果其他 builder 方法接受/返回拥有所有权的 self,单行写法仍然好用,但复杂配置会不太方便。

在「让简单的事情简单、让困难的事情成为可能」这一原则下,消耗式 builder 的所有方法都应当接受并返回拥有所有权的 self。于是客户端代码如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 单行写法
TaskBuilder::new("my_task").spawn(|| { /* ... */ });

// 复杂配置
let mut task = TaskBuilder::new();
task = task.named("my_task_2"); // 必须重新赋值以保留所有权
if reroute {
    task = task.stdout(mywriter);
}
task.spawn(|| { /* ... */ });

单行写法与之前一样有效,因为所有权会穿过每一个 builder 方法,直到被 spawn 消耗。然而,复杂配置会更啰嗦:每一步都需要重新赋值 builder。

最后修改 August 21, 2026: 更新 (76fc81a2e)