Blog
Funktionale Domänenmodellierung in Rust - Teil 1

Wenn ich anfange, neue Programmiersprachen zu lernen, überlege ich oft, wie ich Modelle für bestimmte Bereiche erstellen kann. Ich weiß nicht, ob mein Beratungshintergrund dies beeinflusst, aber es fällt mir immer wieder ein. Ich erzähle Ihnen von meinen ersten Versuchen mit Rust, bei denen ich einen funktionalen Domänenmodellierungsansatz verfolgte. Es kann also sein, dass einige der Implementierungen in diesem Blogpost noch verbesserungswürdig sind.
Ich habe vor, in zwei separaten Artikeln über die funktionale Domänenmodellierung zu sprechen. In diesem ersten Teil werde ich mich auf grundlegendere Konzepte in Rust konzentrieren, während ich in diesem zweiten Teil das Rust-Speicherverwaltungssystem betrachten werde und wie es sich auf das Design unserer Domänenmodelle auswirkt.
Die Domänenmodellierung, die von den Prinzipien der funktionalen Programmierung beeinflusst wird, zielt darauf ab, die Geschäftsdomäne im Code genau darzustellen. Rust ist dank seiner Spracheigenschaften und seines Typensystems, die Korrektheit erzwingen und die Wahrscheinlichkeit von Fehlern verringern, ideal. Indem wir die Domäne genau modellieren, wollen wir den Rust-Compiler nutzen, um Fehler frühzeitig zu erkennen und zu verhindern, dass sie sich zur Laufzeit ausbreiten. Dadurch können wir die Notwendigkeit umfangreicher Unit-Tests verringern und die Zuverlässigkeit und Wartbarkeit der Codebasis verbessern.
Lassen Sie uns einige der Prinzipien, Typen und Techniken der funktionalen Programmierung näher betrachten:
- Algebraische Datentypen (ADTs), wobei die Typen
enumundstructvon Rust zur Definition von ADTs verwendet werden können. - Reine Funktionen, die die Verwendung von Funktionen fördern, die keine Seiteneffekte haben und bei gleicher Eingabe immer die gleiche Ausgabe zurückgeben. Reine Funktionen können dazu beitragen, dass das Domänenmodell korrekt und vorhersehbar ist.
- Die Typen
ResultundOptionstehen für Fehler bzw. optionale Werte. Sie ermöglichen es, Modelle zu validieren, um sicherzustellen, dass das Modell konsistent und vollständig ist und alle Invarianten oder Einschränkungen erfüllt, die von der Geschäftsdomäne verlangt werden. - Mit
Traitskönnen wir ein aussagekräftigeres und flexibleres Domänenmodell erstellen, indem wir Eigenschaften definieren, die den Domänenkonzepten entsprechen. - Schließlich, und damit zusammenhängend mit dem vorherigen Punkt, ist es wichtig, veränderbare Zustände und Seiteneffekte zu vermeiden. Rust erzwingt dieses Prinzip durch sein Eigentums- und Ausleihsystem, das sicherstellt, dass der Speicher sicher und effizient verwaltet wird. Intelligente Zeiger, wie z.B.
Box,RcundArc, sind für diesen Zweck ebenfalls von Bedeutung, da sie das Schreiben von funktionalerem Code ermöglichen.
Das letzte Thema werden wir im nächsten Blogbeitrag behandeln. Heute werden wir die verbleibenden Punkte am Beispiel einer Hiring Pipeline mit ihren Kandidaten untersuchen. Wir werden also die Domänenlogik implementieren, Beziehungen zwischen Entitäten definieren, Modelle validieren und Verhaltensweisen erfassen.
Algebraische Datentypen (ADTs)
In Rust können wir ADTs verwenden, um die Domänenentitäten und Beziehungen unserer Anwendung auf funktionale Weise zu modellieren und die Menge der möglichen Werte und Zustände klar zu definieren. In Rust gibt es zwei Haupttypen von ADTs: enum und struct. enum wird verwendet, um einen Typ zu definieren, der eine von mehreren möglichen Varianten annehmen kann, während struct hier verwendet wird, um einen Typ auszudrücken, der benannte Felder hat.
Lassen Sie uns mit dem oben erwähnten Beispiel beginnen:
struct Candidate {
id: u64,
name: String,
email: String,
experience_level: String,
interview_status: String,
application_status: String,
}
Die Struktur Candidate repräsentiert einen Kandidaten in einer Einstellungspipeline mit einer eindeutigen ID, einem Namen, einer E-Mail-Adresse, einem Erfahrungsniveau sowie dem Bewerbungs- und Interviewstatus. Dies ist in der Tat recht einfach, da unser Modell grundlegende Typen (Ganzzahlen ohne Vorzeichen und Strings) verwendet, bei denen wir keine "Einschränkungen" bezüglich der verschiedenen Werte, die jedes Feld annehmen kann, hinzufügen können.
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"),
};
Nun, der Compiler ist nicht schlau genug, um zu erkennen, dass ich name und email gemischt habe.
Wie können wir das beheben? newtype
Das newtype-Muster ist typisch für die funktionale Programmierung. In Haskell wird dieses Muster durch die Deklaration newtype unterstützt, die es dem Programmierer ermöglicht, einen neuen Typ zu definieren, der bis auf den Namen identisch mit einem bestehenden Typ ist. Dies ist nützlich, um typsichere Abstraktionen zu erstellen, die es dem Programmierer ermöglichen, stärkere Typbeschränkungen bei der Verwendung bestimmter Werte durchzusetzen.
In ähnlicher Weise bietet das newtype Idiom in Rust zur Kompilierzeit die Garantie, dass der richtige Wertetyp geliefert wird. Der newtype ist eine Struktur, die einen einzelnen Wert umhüllt und einen neuen Typ für diesen Wert bereitstellt. Ein newtype ist zur Laufzeit derselbe wie der zugrundeliegende Typ, so dass er keinen Leistungs-Overhead verursacht. Der erzeugte Code ist genauso effizient, wie wenn der zugrundeliegende Typ direkt verwendet wird, da der Rust-Compiler den newtype zur Kompilierzeit eliminiert.
Das ist genau das, was wir brauchen, um unser Modell zu verbessern:
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,
}
Der Compiler meldet also einen Fehler, wenn die Werte gemischt sind. Die folgende Candidate Instanz würde besser aussehen:
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")),
};
Tiefer in ADTs eindringen
In der funktionalen Programmierung sind ADTs eine Möglichkeit, strukturierte Daten mit Hilfe von Produkttypen und Summentypen darzustellen.
- Ein Produkttyp wird durch die Kombination von zwei oder mehr Datentypen zu einem neuen Typ erstellt (siehe den
Candidatestruct-Typ oben). Zusätzlich zustructsind auch Tupel Produkttypen in Rust. - Summentypen, auch bekannt als Enums oder Tagged Unions, repräsentieren jedoch Daten, die einen von mehreren möglichen Werten annehmen können. In Rust werden Summentypen mit dem Schlüsselwort
enumdefiniert.
In Anlehnung an unsere Beispiel-Domäne könnten wir die folgenden enum Typen hinzufügen:
enum ExperienceLevel {
Junior,
MidLevel,
Senior,
}
enum InterviewStatus {
Scheduled,
Completed,
Cancelled,
}
enum ApplicationStatus {
Submitted,
UnderReview,
Rejected,
Hired,
}
Auf diese Weise können wir diese neuen Summentypen mit unserem bestehenden Candidate Modell "komponieren". Mit anderen Worten, wir beginnen zu sehen, wie diese Datenkomposition zwischen verschiedenen Typen die Erstellung komplexerer Typen unterstützt, die die Daten, mit denen wir arbeiten, genau darstellen.
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,
};
Die Aufzählung ExperienceLevel steht für die möglichen Erfahrungsstufen eines Bewerbers, während die Aufzählungen InterviewStatus und ApplicationStatus die möglichen Zustände eines Vorstellungsgesprächs bzw. einer Bewerbung darstellen.
Reine Funktionen in Rust
Reine Funktionen gelten für jede funktionale Sprache, in der wir Seiteneffekte und den veränderlichen Zustand so weit wie möglich vermeiden sollten. Dementsprechend können wir in Rust reine Funktionen erstellen. Wir könnten zum Beispiel new zugehörige Funktionen für CandidateId oder CandidateName hinzufügen:
struct CandidateId(u64);
impl CandidateId {
fn new(id: u64) -> Self {
CandidateId(id)
}
}
struct CandidateName(String);
impl CandidateName {
fn new(name: String) -> Self {
CandidateName(name)
}
}
Diese Funktionen sind rein, da sie keine Nebeneffekte haben und nur das erstellte Objekt zurückgeben (Self im Kontext).
Datenüberprüfung mit den Typen Result und Option
Was wäre geeignet, wenn wir modellieren möchten, dass ein Bewerber möglicherweise noch einen Termin für ein Vorstellungsgespräch benötigt? Der Typ Option könnte hier eine gute Wahl sein.
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,
};
Vielleicht haben wir eine zusätzliche Anforderung: Wir müssen die E-Mail-Adresse des Kandidaten überprüfen. Wir können dem Konstruktor des Typs CandidateEmail eine Überprüfungslogik hinzufügen, um sicherzustellen, dass die E-Mail-Adresse gültig ist. Wir werden den Typ Result verwenden, der die Möglichkeit darstellt, dass eine Operation fehlschlägt oder erfolgreich ist, was für Validierungszwecke wie den hier vorgestellten Fall ideal ist. Hier ist eine mögliche Implementierung (übrigens recht einfach):
impl CandidateEmail {
fn new(email: String) -> Result<Self, String> {
if email.contains('@') {
Ok(CandidateEmail(email))
} else {
Err(String::from("Invalid email address"))
}
}
}
Die Funktion prüft zunächst, ob die E-Mail-Adresse das Symbol '@' enthält, indem sie die Methode contains des Typs String verwendet. Natürlich ist dies bei weitem kein exzellentes Muster, um eine E-Mail zu validieren, aber ich wollte eine vereinfachte Version für didaktische Zwecke zeigen. Wenn also die email Adresse ungültig ist, gibt die Methode eine Err Variante zurück, die eine Fehlermeldung als String enthält. Ist die E-Mail-Adresse hingegen gültig, erstellt die Methode eine neue CandidateEmail Instanz der Ok Variante.
Alternativ dazu bietet Rust die Eigenschaften From und TryFrom, die für die Konvertierung zwischen Typen nützlich sind:
// 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>;
}
Insbesondere die Eigenschaft TryFrom ist praktisch, wenn die Konvertierung zwischen Typen fehlschlagen kann, wie im Fall von 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"))
}
}
}
In dieser Situation könnten wir auch die Erstellung von Candidate übernehmen, wo wiederum Daten und Validierungen wie diese gestapelt werden könnten.
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,
})
}
}
Auch hier hätten wir das gleiche Muster für den Typ Candidate anwenden können, aber ich überlasse das der Praxis zu Hause.
Mit der oben beschriebenen Implementierung können wir eine neue Candidate Instanz wie folgt erstellen:
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,
);
Wenn die E-Mail-Adresse also gültig ist, erstellen wir eine neue CandidateEmail Instanz und fügen sie in das Candidate Objekt ein. Wenn CandidateEmail::new/CandidateEmail::try_from jedoch einen Err Wert zurückgibt, wird der ? Operator diesen Fehler an den Aufrufer von Candidate::new zurückgeben.
Verhaltensweisen mit Eigenschaften ausdrücken
Stellen wir uns vor, wir führen ein neues Verhalten oder eine Anforderung ein, um die Vorstellungsgespräche der Bewerber zu verwalten, bei denen Personalverantwortliche und Recruiter Vorstellungsgespräche planen können. Auf diese Weise könnten wir das Verhalten wie folgt definieren:
trait Interviewer {
fn schedule_interview(&self, candidate: &Candidate) -> Result<InterviewStatus, String>;
}
Dann könnten wir die Entitäten HiringManager und Recruiter erstellen, um diese Eigenschaft zu implementieren:
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)
}
}
Mit diesem Ansatz könnten wir Funktionen schreiben, die ein Argument des Typs impl Interviewer annehmen und die Funktion schedule_interview darauf aufrufen, ohne sich um den konkreten Typ des Arguments zu kümmern.
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),
};
Wie Sie sehen können, haben wir die Funktion schedule_interview so definiert, dass sie jeden Typ I annimmt, der die Eigenschaft Interviewer implementiert. Dadurch können wir jeden konkreten Typ, der die Eigenschaft implementiert, übergeben, ohne uns um den konkreten Typ selbst zu kümmern. Um das Beispiel zu vervollständigen, ruft die Anweisung match die Funktion schedule_interview mit den Variablen interviewer und candidate als Argumente auf und führt dann einen Mustervergleich mit dem Ergebnis Result durch. Wenn das Ergebnis Ok ist, wird der Status des Interviews gedruckt, andernfalls wird eine Fehlermeldung ausgegeben.
Zusammenfassung
In diesem Beitrag haben wir untersucht, wie Algebraische Datentypen, reine Funktionen, die Typen Result und Option sowie Traits leistungsstarke Konzepte sind, die bei der Entwicklung von Domänenmodellen mit funktionaler Programmierung in Rust helfen können. ADTs können Domänenkonzepte modellieren und bieten Typsicherheit, während reine Funktionen für referenzielle Transparenz sorgen und es einfacher machen, über das Programm nachzudenken. Die Typen Result und Option ermöglichen eine aussagekräftigere Fehlerbehandlung, und Traits können von konkreten Typen abstrahieren und die Flexibilität des Programmdesigns fördern. Mit diesen Konzepten können Rust-Entwickler robuste, skalierbare und wartbare Domänenmodelle erstellen, die einfacher zu testen und im Laufe der Zeit zu erweitern sind.
Im nächsten Artikel werde ich untersuchen, wie Smart Pointer wie Box, Rc und Arc unsere funktionale Domänenmodellierung in Rust weiter verbessern können. Sie ermöglichen uns eine effiziente Speicherverwaltung und die gemeinsame Nutzung von Daten zwischen mehreren Teilen unseres Programms.
Verfasst von
Juan Pedro Moreno
Juan Pedro boasts over 15 years of experience in the IT sector, with a deep specialization in software engineering, functional programming, data engineering, microservices architecture, and cloud technologies. He likes to balance his professional career with a passion for endurance sports. Residing in sunny Cadiz, Spain, with his family, he actively participates in running, biking, and triathlons, embodying the discipline and resilience he applies to his personal and professional endeavors.
Unsere Ideen
Weitere Blogs
Contact



