Rust中新类型Newtype使用注意点

Newtype 是 Rust 中类型驱动设计的原始要素,这使得无效数据几乎不可能进入您的系统。

什么是Newtype?
在 Rust 中,newtype 是一种设计模式,它涉及通过将现有类型包装在具有单个字段的元组结构中来创建新类型。此模式用于提供类型安全性和抽象,而不会产生运行时开销。newtype 模式对于创建不可互换的不同类型特别有用,即使它们基于相同的基础数据类型。

struct Millimeters(u32);
struct Meters(u32);

fn main() {
    let length_mm = Millimeters(1000);
    let length_m = Meters(1);

    // 这些类型是不同的,不能互换使用
   
// 这将无法编译:
   
// let combined_length = length_mm + length_m;
}

在此示例中:

  • Millimeters和Meters都是包装 u32的新类型。
  • 尽管这两种新类型都基于u32,但它们是不同的,不能混合,从而防止了潜在的错误。

使用 Newtype 的好处

  1. 类型安全:新类型确保即使两种类型由相同的底层数据表示,它们也不会被意外地互换使用。
  2. 封装:它们可以隐藏内部表示并仅公开与类型交互的安全 API。
  3. 零成本抽象:newtype 模式没有运行时成本,因为 Rust 编译器可以优化包装器,将其视为底层类型。


实际案例

//接受两个参数:电子邮件和密码 注意顺序
pub fn create_user(email: &str, password: &str) -> Result<User, CreateUserError> {

    validate_email(email)?;  
    validate_password(password)?;

    let password_hash = hash_password(password)?;  
   
// 将用户保存到数据库  
   
// 触发欢迎电子邮件
   
// ...
    Ok(User)  
}

在 我们接受两个参数:电子邮件和密码时:某些时候,有人会弄错这些参数的顺序,把密码当成电子邮件,把电子邮件地址当成密码,

如果考虑到一般情况下记录电子邮件地址是安全的(取决于您的数据保护制度),而记录明文密码则会给您的家人带来巨大的耻辱,并在发生可预见的漏洞时给您的公司带来巨额罚款,那么这就很成问题了。

由于这种责任,您的关键业务功能必须关注检查它所获得的 &strs 是否确实是电子邮件地址和密码

这种令人不舒服的同居关系导致了复杂的错误类型:

#[derive(Error, Clone, Debug, PartialEq)]

pub enum CreateUserError {  
    #[error("invalid email address: {0}")]  
    InvalidEmail(String),  
    #[error(
"invalid password: {reason}")]  
    InvalidPassword {  
        reason: String,  
    },  
    #[error(
"failed to hash password: {0}")]  
    PasswordHashError(#[from] BcryptError),  
    #[error(
"user with email address {email} already exists")]  
    UserAlreadyExists {  
        email: String,  
    },  
   
// ...  
}

当您看到 #[derive(Error)] 时:thiserror 是一个功能强大的库,可以快速创建富有表现力的错误类型,我强烈推荐使用它。

而复杂的错误类型意味着需要大量的测试用例才能涵盖所有实际结果:

#[cfg(test)]  
mod tests {  
    use super::*;  
  
    #[test]  
    fn test_create_user_invalid_email() {  
        let email = "invalid-email";  
        let password =
"password";  
        let result = create_user(email, password);  
        let expected = Err(CreateUserError::InvalidEmail(email.to_string()));  
        assert_eq!(result, expected);  
    }  
  
    #[test]  
    fn test_create_user_invalid_password() { unimplemented!() }  
  
    #[test]  
    fn test_create_user_password_hash_error() { unimplemented!() }  
  
    #[test]  
    fn test_create_user_user_already_exists() { unimplemented!() }  
  
    #[test]  
    fn test_create_user_success() { unimplemented!() }  
}

如果这看起来很合理,也很容易理解,请记住,我是一个命名和文档方面的狂人。你也应该如此。但我们都必须接受一个事实,那就是你的标准队友不会一致地命名这些函数。

当被问及他们的测试函数测试什么时,这位队友可能会告诉你 "读读代码就知道了"。这种人是危险的,应该像对待 C++ 一样,对其充满恐惧和怀疑。

有这么多分支返回值的函数是不合理的。

试想一下,如果 create_user 内部的验证是并行进行的,或者函数的成功取决于部分验证成功,而不是全部验证成功。突然间,你会发现自己正在测试各种失败情况的排列组合--这种情况应该会让人心惊肉跳、冷汗直流。

这就是许多实际生产函数的表现,让我告诉你,我可不想测试这些代码。

Newtyping来简化拯救
Newtyping 是一种前期投入额外时间来设计始终有效的数据类型的做法。从长远来看,这样可以避免人为错误,保持代码的可读性,并使单元测试变得微不足道。

#[derive(Debug, Clone, PartialEq)]
pub struct EmailAddress(String);  
 
#[derive(Debug, Clone, PartialEq)]
pub struct Password(String);
 
pub fn create_user(email: EmailAddress, password: Password) -> Result<User, CreateUserError> {
    validate_email(&email)?;  
    validate_password(&password)?;  
    let password_hash = hash_password(&password)?;  
    // ...  
    Ok(User)  
}

我们可以使用struct EmailAddress(String)合 struct Password(String)定义元组结构体:作为表示电子邮件地址和密码的字符串的封装器。

现在,我们的函数参数的输入类型从String更改为这两种类型:(DDD值对象

  • 这就不可能将密码作为 EmailAddress 类型的参数传递,反之亦然。

我们已经消除了一个人为错误的来源,但相信我,还有很多。永远不要忘记,只要软件工程师能搞砸,他们就一定会搞砸。

实现 Newtypes 的特征trait
你可以为你的 newtype 实现特征trait来提供特定的功能。例如,如果你想Add为 newtype 实现特征,你可以这样做:

use std::ops::Add;

struct Millimeters(u32);

impl Add for Millimeters {
    type Output = Millimeters;

    fn add(self, other: Millimeters) -> Millimeters {
        Millimeters(self.0 + other.0)
    }
}

fn main() {
    let length1 = Millimeters(500);
    let length2 = Millimeters(600);
    let total_length = length1 + length2;
    println!("Total length in millimeters: {}", total_length.0);
}

新型 Deref 模式
有时,你可能希望你的新类型表现得像底层类型。在这种情况下,你可以实现Deref和DerefMut特征:

use std::ops::{Deref, DerefMut};

struct MyString(String);

impl Deref for MyString {
    type Target = String;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

impl DerefMut for MyString {
    fn deref_mut(&mut self) -> &mut Self::Target {
        &mut self.0
    }
}

fn main() {
    let mut s = MyString(String::from("Hello"));
    s.push_str(
", world!");
    println!(
"{}", *s);
}

在此示例中:

  • MyString是 的新类型String。
  • 通过实现Deref和DerefMut,MyString可以像 一样使用String。

那么我们为什么必须谨慎Deref使用呢?
通过将封装类型的每个 &self 方法添加到 newtype 中,我们极大地扩展了 newtype 的公共接口。这与我们通常选择发布方法的方式恰恰相反,我们通常会公开最小可行的操作集,并随着新用例的出现逐步扩展类型。

让新类型拥有所有这些方法有意义吗?这需要判断。EmailAddress 没有理由拥有 is_empty 方法,因为 EmailAddress 永远不会是空的,但实现 Deref 意味着 str::is_empty 也会随之而来。

如果您的 newtype 对用户控制的类型进行了泛型包装,那么这个决定就至关重要。在这种情况下,你不知道用户的底层类型中会有哪些方法可用。如果你的 newtype 定义的方法恰好与用户提供的类型上的方法具有相同的签名,会发生什么情况?新类型的方法优先,所以如果用户依赖你的新类型的 Deref 实现来调用他们的底层类型,那他们就倒霉了。

关于这个问题我见过的最好的建议来自《Rust for Rustaceans》(必读):在通用包装类型上,优先考虑关联函数而不是固有方法。

Borrow
如果您正在寻找一种使用安全 Rust 来搬起石头砸自己的脚的方法,那么这个Borrow特性是一个很好的选择。

Borrow看似简单。Borrow<T>可以为您提供&T 的类型。

impl Borrow<str> for EmailAddress {
    fn borrow(&self) -> &str {
        &self.0
    }
}

如果使用得当,实现了借用的新类型会说:"就所有实际目的而言,我与我的底层类型是相同的"。我们希望 Eq、Ord 和 Hash 的输出对于拥有的 newtype 和借用、包装的类型都是相同的,但这并不是静态强制的。

例如,如果我们手动为 EmailAddress 实现 PartialEq,使其大小写不敏感(事实上电子邮件地址也是如此),EmailAddress 就无法实现 Borrow<str>.

使用 derive_more......获得更多
derive_more 是一个旨在减轻在新类型上实现特质的负担的板块。通过它,你可以派生实现 From、IntoIterator、AsRef、Deref、算术运算符等。