条款6:拥抱新类型模式

条款 1 描述了元组结构体,其中结构体的字段没有名称,而是通过数字 (self.0) 引用。本条款重点介绍具有某个现有类型的单个条款的元组结构,从而创建一个可以容纳与封闭类型完全相同的值范围的新类型。这种模式在 Rust 中非常普遍,值得拥有自己的条目并拥有自己的名称:新类型模式。

新类型模式最简单的用途是指示类型的附加语义,超出其正常行为。为了说明这一点,想象一个要向火星发送卫星的项目。这是一个大项目,因此不同的小组构建了项目的不同部分。一个小组负责火箭发动机的代码:

#![allow(unused)]
fn main() {
/// Fire the thrusters. Returns generated impulse in pound-force seconds.
pub fn thruster_impulse(direction: Direction) -> f64 {
    // ...
    return 42.0;
}
}

而另一个小组负责惯性制导系统:

#![allow(unused)]
fn main() {
/// Update trajectory model for impulse, provided in Newton seconds.
pub fn update_trajectory(force: f64) {
    // ...
}
}

最终需要将这些不同的部分连接在一起:

#![allow(unused)]
fn main() {
let thruster_force: f64 = thruster_impulse(direction);
let new_direction = update_trajectory(thruster_force);
}

Rust 包含一个类型别名功能,它允许不同的群体更清楚地表达他们的意图:

#![allow(unused)]
fn main() {
/// Units for force.
pub type PoundForceSeconds = f64;

/// Fire the thrusters. Returns generated impulse.
pub fn thruster_impulse(direction: Direction) -> PoundForceSeconds {
    // ...
    return 42.0;
}
}
#![allow(unused)]
fn main() {
/// Units for force.
pub type NewtonSeconds = f64;

/// Update trajectory model for impulse.
pub fn update_trajectory(force: NewtonSeconds) {
    // ...
}
}

但是,类型别名实际上只是文档;它们比以前版本的文档注释更有力,但没有什么可以阻止在需要 NewtonSeconds 值的地方使用 PoundForceSeconds 值:

#![allow(unused)]
fn main() {
let thruster_force: PoundForceSeconds = thruster_impulse(direction);
let new_direction = update_trajectory(thruster_force);
}

这是 newtype 模式有帮助的地方:

#![allow(unused)]
fn main() {
/// Units for force.
pub struct PoundForceSeconds(pub f64);

/// Fire the thrusters. Returns generated impulse.
pub fn thruster_impulse(direction: Direction) -> PoundForceSeconds {
    // ...
    return PoundForceSeconds(42.0);
}
}
#![allow(unused)]
fn main() {
/// Units for force.
pub struct NewtonSeconds(pub f64);

/// Update trajectory model for impulse.
pub fn update_trajectory(force: NewtonSeconds) {
    // ...
}
}

顾名思义,newtype 是一种新类型,因此当类型不匹配时,编译器会反对 - 这里尝试将 PoundForceSeconds 值传递给需要 NewtonSeconds 值的对象:

#![allow(unused)]
fn main() {
let thruster_force: PoundForceSeconds = thruster_impulse(direction);
let new_direction = update_trajectory(thruster_force);
}
#![allow(unused)]
fn main() {
error[E0308]: mismatched types
  --> src/main.rs:76:43
   |
76 |     let new_direction = update_trajectory(thruster_force);
   |                         ----------------- ^^^^^^^^^^^^^^ expected
   |                         |        `NewtonSeconds`, found `PoundForceSeconds`
   |                         |
   |                         arguments to this function are incorrect
   |
note: function defined here
  --> src/main.rs:66:8
   |
66 | pub fn update_trajectory(force: NewtonSeconds) {
   |        ^^^^^^^^^^^^^^^^^ --------------------
help: call `Into::into` on this expression to convert `PoundForceSeconds` into
      `NewtonSeconds`
   |
76 |     let new_direction = update_trajectory(thruster_force.into());
   |                                                         +++++++
}

如第 5 条所述,添加标准 From 特征的实现:

#![allow(unused)]
fn main() {
impl From<PoundForceSeconds> for NewtonSeconds {
    fn from(val: PoundForceSeconds) -> NewtonSeconds {
        NewtonSeconds(4.448222 * val.0)
    }
}
}

允许使用 .into() 执行必要的单位和类型转换:

#![allow(unused)]
fn main() {
let thruster_force: PoundForceSeconds = thruster_impulse(direction);
let new_direction = update_trajectory(thruster_force.into());
}

使用新类型来标记类型的附加“单元”语义的相同模式也有助于使纯布尔参数不那么含糊。重新审视第 1 条中的示例,使用新类型使参数的含义变得清晰:

#![allow(unused)]
fn main() {
struct DoubleSided(pub bool);

struct ColorOutput(pub bool);

fn print_page(sides: DoubleSided, color: ColorOutput) {
    // ...
}
}
#![allow(unused)]
fn main() {
print_page(DoubleSided(true), ColorOutput(false));
}

如果需要考虑大小效率或二进制兼容性,那么 #[repr(transparent)] 属性可确保新类型在内存中的表示形式与内部类型相同。

这是 newtype 的简单用法,也是第 1 项的一个具体示例 — 将语义编码到类型系统中,以便编译器负责监管这些语义。

绕过特征的孤儿规则

需要使用新类型模式的另一种常见但更微妙的场景围绕着 Rust 的孤儿规则展开。粗略地说,这意味着只有满足以下条件之一时,包才能为类型实现特征:

  • 包已定义特征
  • 包已定义类型

尝试为外部类型实现外部特征:

#![allow(unused)]
fn main() {
use std::fmt;

impl fmt::Display for rand::rngs::StdRng {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
        write!(f, "<StdRng instance>")
    }
}
}

导致编译器错误(这又指向新类型的方向):

#![allow(unused)]
fn main() {
error[E0117]: only traits defined in the current crate can be implemented for
              types defined outside of the crate
   --> src/main.rs:146:1
    |
146 | impl fmt::Display for rand::rngs::StdRng {
    | ^^^^^^^^^^^^^^^^^^^^^^------------------
    | |                     |
    | |                     `StdRng` is not defined in the current crate
    | impl doesn't use only types from inside the current crate
    |
    = note: define and implement a trait or new type instead
}

这种限制的原因是由于存在歧义风险:如果依赖关系图(第 25 项)中的两个不同的包都(比如说)为 rand::rngs::StdRng 实现 impl std::fmt::Display,那么编译器/链接器就无法在它们之间进行选择。

这经常会导致挫败感:例如,如果您尝试序列化包含来自另一个包的类型的数据,则孤儿规则会阻止您为 somecrate::SomeType.3 编写 impl serde::Serialize

但 newtype 模式意味着您正在定义一个新类型,它是当前包的一部分,因此孤儿特征规则的第二部分适用。现在可以实现外部特征:

#![allow(unused)]
fn main() {
struct MyRng(rand::rngs::StdRng);

impl fmt::Display for MyRng {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
        write!(f, "<MyRng instance>")
    }
}
}

Newtype 限制

newtype 模式解决了这两类问题——防止单位转换和绕过孤儿规则——但它确实带来了一些尴尬:每个涉及 newtype 的操作都需要转发到内部类型。

从简单的层面上讲,这意味着代码必须始终使用 thing.0,而不仅仅是 thing,但这很容易,编译器会告诉您在哪里需要它。

更重要的尴尬是内部类型的任何特征实现都会丢失:因为 newtype 是一种新类型,所以现有的内部实现不适用。

对于可派生特征,这仅意味着 newtype 声明最终会得到大量派生:

#![allow(unused)]
fn main() {
#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd)]
pub struct NewType(InnerType);
}

然而,对于更复杂的特征,需要一些转发样板来恢复内部类型的实现,例如:

#![allow(unused)]
fn main() {
use std::fmt;
impl fmt::Display for NewType {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
        self.0.fmt(f)
    }
}
}