Rust中新型別Newtype使用注意點

banq發表於2024-06-19

Newtype 是 Rust 中型別驅動設計的原始要素,這使得無效資料幾乎不可能進入您的系統。

什麼是Newtype?
在 Rust 中,newtype 是一種設計模式,它涉及透過將現有型別包裝在具有單個欄位的元組結構中來建立新型別。此模式用於提供型別安全性和抽象,而不會產生執行時開銷。newtype 模式對於建立不可互換的不同型別特別有用,即使它們基於相同的基礎資料型別。

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

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

    <font>// 這些型別是不同的,不能互換使用<i>
   
// 這將無法編譯:<i>
   
// let combined_length = length_mm + length_m;<i>
}

在此示例中:

  • Millimeters和Meters都是包裝 u32的新型別。
  • 儘管這兩種新型別都基於u32,但它們是不同的,不能混合,從而防止了潛在的錯誤。

使用 Newtype 的好處

  1. 型別安全:新型別確保即使兩種型別由相同的底層資料表示,它們也不會被意外地互換使用。
  2. 封裝:它們可以隱藏內部表示並僅公開與型別互動的安全 API。
  3. 零成本抽象:newtype 模式沒有執行時成本,因為 Rust 編譯器可以最佳化包裝器,將其視為底層型別。


實際案例

<font>//接受兩個引數:電子郵件和密碼 注意順序<i>
pub fn create_user(email: &str, password: &str) -> Result<User, CreateUserError> {

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

    let password_hash = hash_password(password)?;  
   
// 將使用者儲存到資料庫  <i>
   
// 觸發歡迎電子郵件<i>
   
// ...<i>
    Ok(User)  
}

在 我們接受兩個引數:電子郵件和密碼時:某些時候,有人會弄錯這些引數的順序,把密碼當成電子郵件,把電子郵件地址當成密碼,

如果考慮到一般情況下記錄電子郵件地址是安全的(取決於您的資料保護制度),而記錄明文密碼則會給您的家人帶來巨大的恥辱,並在發生可預見的漏洞時給您的公司帶來鉅額罰款,那麼這就很成問題了。

由於這種責任,您的關鍵業務功能必須關注檢查它所獲得的 &strs 是否確實是電子郵件地址和密碼

這種令人不舒服的同居關係導致了複雜的錯誤型別:

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

pub enum CreateUserError {  
    #[error(<font>"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,  
    },  
   
// ...  <i>
}

當您看到 #[derive(Error)] 時:thiserror 是一個功能強大的庫,可以快速建立富有表現力的錯誤型別,我強烈推薦使用它。

而複雜的錯誤型別意味著需要大量的測試用例才能涵蓋所有實際結果:

#[cfg(test)]  
mod tests {  
    use super::*;  
  
    #[test]  
    fn test_create_user_invalid_email() {  
        let email = <font>"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)?;  
    <font>// ...  <i>
    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!(<font>"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(<font>"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、算術運算子等。


 

相關文章