这是用户在 2024-4-24 24:17 为 https://xebia.com/blog/functional-domain-modeling-in-rust-part-1/ 保存的双语快照页面,由 沉浸式翻译 提供双语支持。了解如何保存?
 博客


功能Rust域建模 – 第 1 部分

 07 3月, 2023
Xebia Background Header Wave


当我开始学习新的编程语言时,我经常考虑如何为特定领域创建模型。我不知道我的咨询背景是否影响了这一点,但它总是浮现在脑海中。我正在分享我最初尝试使用Rust函数式领域建模方法,因此这篇博文中的某些实现可能还有改进的余地。


我计划在两篇单独的文章中讨论函数域建模。在第一部分中,我将重点介绍 中Rust更基本的概念,而在第二部分中,我将考虑Rust内存管理系统以及它如何影响领域模型的设计。


受函数式编程原则影响的领域建模旨在准确地在代码中表示业务领域。Rust由于其语言功能和类型系统,它是理想的选择,可以强制执行正确性并减少错误的可能性。通过对域进行精确建模,我们的目标是使用Rust编译器及早捕获错误并防止它们传播到运行时。这将有助于我们减少对大量单元测试的需求,并提高代码库的可靠性和可维护性。


让我们强调一下函数式编程带来的一些原则、类型和技术:


  • 代数数据类型 (ADT),其中 Rust的 enumstruct 类型可用于定义 ADT。

  • 纯函数,鼓励使用没有副作用的函数,并且在给定相同输入的情况下始终返回相同的输出。纯函数可以帮助确保域模型的正确性和可预测性。

  • ResultOption 类型分别表示错误和可选值,允许验证模型以确保模型一致、完整并满足业务域所需的任何不变量或约束。

  • 有了 Traits ,我们可以通过定义与领域概念相对应的特征来创建一个更具表现力和灵活性的领域模型。

  • 最后,与上一点相关,考虑到避免可变状态和副作用的重要性,Rust通过其所有权和借用系统来执行这一原则,从而确保安全有效地管理内存。智能指针(如 BoxRcArc )也与此目的相关,因为它们允许编写更多功能代码。


我们将在下一篇文章中回顾最后一个主题。今天,我们将以招聘管道为例,以候选人为例,检查剩余的要点。因此,我们将实现域逻辑,定义实体之间的关系,验证模型并捕获行为。


代数数据类型 (ADT)


在 中Rust,我们可以使用 ADT 以函数式方式对应用程序的域实体和关系进行建模,从而明确定义可能的值和状态集。Rust有两种主要类型的 ADT: enumstructenum 用于定义可以采用多种可能变体之一的类型,而 struct 此处用于表示具有命名字段的类型。


让我们开始研究我们上面提到的示例:

struct Candidate {
    id: u64,
    name: String,
    email: String,
    experience_level: String,
    interview_status: String,
    application_status: String,
}
Rust


Candidate 结构表示招聘管道中具有唯一 ID、姓名、电子邮件地址、经验级别以及申请和面试状态的候选人。事实上,这很简单,因为我们的模型使用基本类型(无符号整数和字符串),我们不能对每个字段可以采用的不同值添加“限制”。

let candidate = Candidate {
    id: 1,
    name: String::from("jane.brown@example.com"),
    email: String::from("Jane Brown"),
    experience_level: String::from("Senior"),
    interview_status: String::from("Scheduled"),
    application_status: String::from("In Review"),
};
Rust


好吧,编译器不够聪明,无法检测到我已经混合 nameemail 和 .


我们该如何解决这个问题?新类型


newtype 模式在函数式编程中很常见。在 Haskell 中,这种模式通过 newtype 声明得到支持,它允许程序员定义一个与现有类型相同的新类型,但其名称除外。这对于创建类型安全抽象非常有用,使程序员能够在使用特定值时强制实施更强的类型约束。


类似地,在 中Rust,newtype 惯用语带来了编译时保证提供正确的值类型。newtype 是一个结构,用于包装单个值并为该值提供新类型。newtype 在运行时与基础类型相同,因此它不会引入任何性能开销。事实上,生成的代码将与直接使用底层类型一样高效,因为Rust编译器将在编译时消除 newtype。


这正是我们需要改进模型的地方:

struct CandidateId(u64);
struct CandidateName(String);
struct CandidateEmail(String);
struct CandidateExperienceLevel(String);
struct CandidateInterviewStatus(String);
struct CandidateApplicationStatus(String);

struct Candidate {
    id: CandidateId,
    name: CandidateName,
    email: CandidateEmail,
    experience_level: CandidateExperienceLevel,
    interview_status: CandidateInterviewStatus,
    application_status: CandidateApplicationStatus,
}
Rust


因此,如果值混合,编译器将报告错误。以下 Candidate 实例看起来会更好:

let candidate = Candidate {
    id: CandidateId(1),
    name: CandidateName(String::from("Jane Brown")),
    email: CandidateEmail(String::from("jane.brown@example.com")),
    experience_level: CandidateExperienceLevel(String::from("Senior")),
    interview_status: CandidateInterviewStatus(String::from("Scheduled")),
    application_status: CandidateApplicationStatus(String::from("In Review")),
};
Rust

 深入了解 ADT


在函数式编程中,ADT 是一种使用产品类型和总和类型表示结构化数据的方法。


  • 产品类型是通过将两个或多个数据类型组合成一个新类型来创建的(请参阅上面的 Candidate 结构类型)。除了 struct 之外,元组也是 中Rust的产品类型。

  • 但是,求和类型(也称为枚举或标记并集)表示可以采用多个可能值之一的数据。在 中Rust,使用 enum 关键字定义求和类型。


按照我们的示例域,我们可以添加以下 enum 类型:

enum ExperienceLevel {
    Junior,
    MidLevel,
    Senior,
}

enum InterviewStatus {
    Scheduled,
    Completed,
    Cancelled,
}

enum ApplicationStatus {
    Submitted,
    UnderReview,
    Rejected,
    Hired,
}
Rust


这样,我们就可以用现有的 Candidate 模型“组合”这些新的总和类型。换句话说,我们开始看到不同类型之间的这种数据组合如何支持创建更复杂的类型,以准确表示我们正在处理的数据。

struct Candidate {
    id: CandidateId,
    name: CandidateName,
    email: CandidateEmail,
    experience_level: ExperienceLevel,
    interview_status: InterviewStatus,
    application_status: ApplicationStatus,
}

let candidate = Candidate {
    id: CandidateId(1),
    name: CandidateName(String::from("Jane Brown")),
    email: CandidateEmail(String::from("jane.brown@example.com")),
    experience_level: ExperienceLevel::Senior,
    interview_status: InterviewStatus::Scheduled,
    application_status: ApplicationStatus::UnderReview,
};
Rust


ExperienceLevel 枚举代表候选人可能的经验水平,而 InterviewStatusApplicationStatus 举分别代表面试和申请的可能状态。

 纯Rust函数


纯函数适用于每一种函数式语言,我们应该尽可能避免副作用和可变状态。因此,在 中Rust,我们可以做纯函数。例如,我们可以为 CandidateIdCandidateName 添加 new 关联函数:

struct CandidateId(u64);

impl CandidateId {
    fn new(id: u64) -> Self {
        CandidateId(id)
    }
}

struct CandidateName(String);

impl CandidateName {
    fn new(name: String) -> Self {
        CandidateName(name)
    }
}
Rust


这些函数是纯函数,因为它们没有任何副作用,并且只返回创建的对象( Self 在上下文中)。


数据 Result 验证与类型 Option


如果我们想模拟候选人可能仍然需要安排面试,那么什么才合适?该 Option 类型在这里可能是一个不错的选择。

struct Candidate {
    id: CandidateId,
    name: CandidateName,
    email: CandidateEmail,
    experience_level: ExperienceLevel,
    interview_status: Option<InterviewStatus>,
    application_status: ApplicationStatus,
}

let candidate = Candidate {
    id: CandidateId(1),
    name: CandidateName(String::from("Jane Brown")),
    email: CandidateEmail(String::from("jane.brown@example.com")),
    experience_level: ExperienceLevel::Senior,
    interview_status: None, // no status yet
    application_status: ApplicationStatus::UnderReview,
};
Rust


我们可能有额外的要求;我们需要验证候选人的电子邮件地址。我们可以将验证逻辑添加到类型构造函数中 CandidateEmail ,以确保电子邮件地址有效。我们将使用 Result 类型,该类型表示操作失败或成功的可能性,这非常适合验证目的,如此处介绍的情况。这是一个潜在的实现(顺便说一句,非常简单):

impl CandidateEmail {
    fn new(email: String) -> Result<Self, String> {
        if email.contains('@') {
            Ok(CandidateEmail(email))
        } else {
            Err(String::from("Invalid email address"))
        } 
    }
}
Rust


该函数首先使用 String 类型的 contains 方法检查电子邮件地址是否包含 '@' 符号。当然,这远不是验证电子邮件的绝佳模式,但我想展示一个用于教学目的的简化版本。因此, email 如果地址无效,该方法将返回一个 Err 包含错误消息的变体。 String 另一方面,如果电子邮件地址有效,则该方法会在 Ok 变体上创建一个新 CandidateEmail 实例。


或者,Rust提供可用于在类型之间转换的 FromTryFrom 特征:

// From<T> definition
pub trait From<T> {
    fn from(T) -> Self;
}

// TryFrom<T> definition
pub trait TryFrom<T>: Sized {
    type Error;
    fn try_from(T) -> Result<Self, Self::Error>;
}
Rust


值得注意的是,如果类型之间的转换可能失败,则该 TryFrom 特征非常方便,例如在这种情况下 CandidateEmail

use std::convert::TryFrom;

struct CandidateEmail(String);

impl TryFrom<String> for CandidateEmail {
    type Error = String;

    fn try_from(email: String) -> Result<Self, Self::Error> {
        if email.contains('@') {
            Ok(CandidateEmail(email))
        } else {
            Err(String::from("Invalid email address"))
        }
    }
}
Rust


在这种情况下,我们也可以处理 Candidate 创建,同样,像这样的数据和验证可以堆叠在一起。

struct Candidate {
    id: CandidateId,
    name: CandidateName,
    email: CandidateEmail,
    experience_level: ExperienceLevel,
    interview_status: Option<InterviewStatus>,
    application_status: ApplicationStatus,
}

impl Candidate {
    fn new(
        id: CandidateId,
        name: CandidateName,
        email: String,
        experience_level: ExperienceLevel,
        interview_status: Option<InterviewStatus>,
        application_status: ApplicationStatus,
    ) -> Result<Self, String> {
        let candidate_email = CandidateEmail::try_from(email)?;
        Ok(Candidate {
            id,
            name,
            email: candidate_email,
            experience_level,
            interview_status,
            application_status,
        })
    }
}
Rust


同样,我们可以对 Candidate 类型应用相同的模式,但我将其留给家庭练习。


通过上面给出的实现,我们可以创建一个这样的新 Candidate 实例:

let candidate: Result<Candidate, String> =
    Candidate::new(
        CandidateId(2),
        CandidateName(String::from("John Doe")),
        String::from("johndoe@example.com"), // passing directly the String
        ExperienceLevel::Junior,
        Some(InterviewStatus::Scheduled),
        ApplicationStatus::UnderReview,
    );
Rust


因此,如果电子邮件地址有效,我们将构造一个新 CandidateEmail 实例并将其包含在对象中 Candidate 。但是,如果 CandidateEmail::new / CandidateEmail::try_from 返回一个 Err 值, ? 则运算符会将该错误返回给 的 Candidate::new 调用方。


表达具有特质的行为


让我们想象一下,我们引入了一种新的行为或要求来管理候选人的面试,招聘经理和招聘人员可以在其中安排面试。这样,我们可以按以下方式定义行为:

trait Interviewer {
    fn schedule_interview(&self, candidate: &Candidate) -> Result<InterviewStatus, String>;
}
Rust


然后,我们可以制作 HiringManagerRecruiter 实体来实现这个特征:

struct HiringManager {
    name: String,
}

impl Interviewer for HiringManager {
    fn schedule_interview(&self, candidate: &Candidate) -> Result<InterviewStatus, String> {
        // Biz and validation logic to schedule an interview with given candidate
        // ...
        Ok(InterviewStatus::Scheduled)
    }
}

struct Recruiter {
    name: String,
}

impl Interviewer for Recruiter {
    fn schedule_interview(&self, candidate: &Candidate) -> Result<InterviewStatus, String> {
        // Biz and validation logic to schedule an interview with given candidate
        // ...
        Ok(InterviewStatus::Scheduled)
    }
}
Rust


使用这种方法,我们可以编写采用类型 impl Interviewer 参数并在其上调用 schedule_interview 函数的函数,而无需担心参数的具体类型。

fn schedule_interview<I: Interviewer>(
    interviewer: &I,
    candidate: &Candidate,
) -> Result<InterviewStatus, String> {
    interviewer.schedule_interview(candidate)
}

let candidate: Candidate = unimplemented!();
let interviewer: HiringManager = unimplemented!();

match schedule_interview(&interviewer, &candidate) {
    Ok(status) => println!("Interview status: {:?}", status),
    Err(e) => println!("The interview scheduling failed: {:?}", e),
};
Rust


正如你所看到的,我们已经定义了 schedule_interview 函数来采用实现该 Interviewer 特征的任何类型 I 。这使我们能够传入任何实现该特征的具体类型,而不必担心具体类型本身。然后,为了完成示例,该 match 语句调用了 interviewercandidate 变量作为参数的 schedule_interview 函数,然后在得到 Result 的 .如果结果为 Ok ,则打印面试状态;否则,它将打印错误消息。

 总结


在这篇文章中,我们探讨了代数数据类型、纯函数、 ResultOption 类型以及特征如何成为强大的概念,可以帮助使用函数式编程来设计领域Rust模型。ADT 可以对领域概念进行建模并提供类型安全,而纯函数可确保引用透明度,并使其更容易对程序进行推理。 ResultOption 类型允许更具表现力的错误处理,特征可以抽象于具体类型并提高程序设计的灵活性。使用这些概念,Rust开发人员可以创建可靠、可扩展且可维护的领域模型,这些模型更易于测试和扩展。


在下一篇文章中,我将研究智能指针如何像 BoxRcArc 进一步增强我们的功能域建模Rust,使我们能够有效地管理内存并在程序的多个部分之间共享数据。

Juan Pedro Moreno

胡安·佩德罗·莫雷诺(Juan Pedro Moreno)

Juan Pedro 在 IT 领域拥有超过 15 年的经验,在软件工程、函数式编程、数据工程、微服务架构和云技术方面拥有深厚的专业知识。他喜欢在职业生涯和对耐力运动的热情之间取得平衡。他与家人居住在阳光明媚的西班牙加的斯,积极参与跑步、骑自行车和铁人三项,体现了他在个人和职业努力中的纪律和韧性。
Questions?

Get in touch with us to learn more about the subject and related solutions

Explore related posts