Blog

Funktionale Domänenmodellierung in Rust - Teil 2

Juan Pedro Moreno

Juan Pedro Moreno

Aktualisiert Oktober 15, 2025
13 Minuten

Im letzten Blogbeitrag dieser Serie haben wir einige funktionale Aspekte der Domänenmodellierung in Rust besprochen. In diesem Beitrag werden wir, wie versprochen, einige fortgeschrittenere Funktionen untersuchen, die wir bei unserer Domänenmodellierung aufgrund des Speicherverwaltungssystems von Rust berücksichtigen müssen.

Das Modell von Eigentum und Kreditvergabe

Rust hat ein starkes Eigentums- und Ausleihmodell. Es würde zwar den Rahmen dieses Blogbeitrags sprengen, darauf näher einzugehen, aber kurz gesagt stellt dieses Modell sicher, dass es immer genau einen Eigentümer eines bestimmten Speicherstücks gibt und dass dieser Eigentümer für die Kontrolle der Lebensdauer des Speichers verantwortlich ist. Dies steht im Gegensatz zu Sprachen wie Scala, Java, Go oder Python, wo die Garbage Collection den Speicher automatisch verwaltet und Probleme wie Speicherlecks vermeidet.

Die Eigentums- und Ausleihregeln werden zur Kompilierzeit durchgesetzt, was bedeutet, dass:

Wenn sich das Programm kompilieren lässt, ist es garantiert speichersicherfähig*.

(* Hinweis: Dies gilt außer für unsicheren Rost)

Diese Regeln machen deutlich, wie wichtig die Speicherverwaltung in Rust ist. Wir müssen wissen, dass wir zwei Speicherbereiche haben: den Stack und den Heap. Ohne zu sehr in die Tiefe zu gehen, ist der Stack der Bereich des Speichers, in dem Daten mit einer bekannten und festen Größe gespeichert werden, wie z.B. lokale Variablen, Funktionsargumente und Rückgabewerte. Im Gegensatz dazu ist der Heap ein Bereich des Speichers, in dem Daten mit einer unbekannten oder dynamischen Größe gespeichert werden, wie z.B. Vektoren oder Strings.

Modell der Eigentümerschaft

Jeder Wert in Rust hat einen Besitzer, der für die Verwaltung des Speichers des Wertes verantwortlich ist. Wenn der Besitzer den Geltungsbereich verlässt, wird der Wert gelöscht und sein Speicher freigegeben.

fn main() {
    let str1 = String::from("I'll be placed in the heap");
    let str2 = str1;
    println!("{}", str1); // Compile error
}

In diesem Beispiel haben wir den String str1 auf str2 gesetzt. Da es sich bei jedoch um einen Typ handelt, der dem Heap zugewiesen ist, werden bei der Zuweisung von an die Daten auf dem Heap nicht kopiert - stattdessen übernimmt den Speicher, auf den gezeigt hat. Infolgedessen ist str1 nach der Zuweisung nicht mehr gültig, und wenn wir versuchen, ihn in der folgenden Zeile zu verwenden, erhalten wir einen Kompilierfehler.

Es veranschaulicht, wie wichtig es ist, die Eigentumsverhältnisse zu verstehen und dass Rust uns nicht erlaubt, einen Wert zu verwenden, der verschoben oder gelöscht wurde(Wenn das Programm kompiliert wird, ist es garantiert speichersicherfest).

Modell der Kreditvergabe

Zusätzlich zum Besitz erlaubt uns das Leihmodell, Referenzen auf eigene Werte vorübergehend zu verleihen. Eine Referenz ist ein Zeiger auf einen Wert, mit dem Sie auf diesen Wert zugreifen können, ohne ihn zu besitzen. Borrowing-Regeln sorgen dafür, dass Referenzen sicher verwendet werden und keine Datenrennen entstehen.

fn print_message(msg: &str) {
    println!("{}", msg);
}

fn main() {
    let message = String::from("Hello, I belong to the main function!");
    print_message(&message);
}

In dem obigen Beispiel ist message eine String; wenn wir die Funktion print_message mit &message als Argument aufrufen, übergeben wir einen Verweis auf die Nachricht.

Die Funktion print_message nimmt einen Verweis auf String als Argument, was bedeutet, dass sie sich String ausleiht und Zugriff auf dessen Inhalt hat , ihn aber nicht besitzt. Die Funktion kann die geliehene String nicht verändern.

Wenn wir println!("{}", msg) aufrufen, drucken wir den geliehenen Inhalt von String, ohne ihn zu verändern.

Wandlungsfähigkeit

Durch die Kombination von Ownership und Borrowing kann Rust die Änderbarkeit feinkörnig steuern. In Bezug auf die Veränderbarkeit in Rust können wir die folgenden Eigenschaften auflisten:

  • Die Veränderbarkeit ist an das Eigentum gebunden.
  • Standardmäßig sind die Werte unveränderlich.
  • Wenn wir einen Wert ändern wollen, muss der Kontext ihn besitzen oder ausleihen (indem wir das Schlüsselwort mut hinzufügen).
  • Mutable Borrowing ist exklusiv, d.h. es kann zu jedem Zeitpunkt nur eine mutable Referenz auf einen Wert bestehen. Dies verhindert Datenwettläufe und macht es einfach, Ihren Code zu verstehen.

Intelligente Zeiger

Die vorangegangenen Abschnitte haben uns gezeigt, wo Smart Pointer ins Spiel kommen. Sie können ein leistungsfähiges Werkzeug für die funktionale Domänenmodellierung sein, das die Verwaltung des Eigentums und die gemeinsame Nutzung von komplexen Datenstrukturen ermöglicht. In der Tat können Smart Pointer wie Box, Rc und Arc dazu beitragen, die Prinzipien der Unveränderlichkeit in der funktionalen Domänenmodellierung durchzusetzen, so dass wir sicherstellen können, dass die Daten nicht unbeabsichtigt verändert werden, was hier ein grundlegendes Prinzip ist.

Darüber hinaus helfen intelligente Zeiger bei der Verwaltung von Eigentum und Ausleihen, indem sie zusätzliche Funktionen zu den normalen Zeigern bieten. Zum Beispiel bietet Box eine Heap-Allokation und eine automatische Freigabe des Speichers, wenn Box den Gültigkeitsbereich verlässt. Dies ist nützlich für die Speicherung von Daten, die den aktuellen Stack-Frame überdauern müssen. Rc und Arc bieten Shared Ownership, d.h. mehrere Eigentümer eines bestimmten Speicherbereichs, was bei Datenstrukturen, die von verschiedenen Programmteilen gemeinsam genutzt werden müssen, hilfreich sein kann. Sehen wir uns nun einige konkrete Beispiele für diese drei Arten von Zeigern an.

Box Smart Pointer

Um den Box smart pointer zu erklären, können wir uns auf das folgende Beispiel beziehen:

let candidate_id: CandidateId = CandidateId(2);

Hier wird der Wert candidate_id auf dem Stack zugewiesen. Der Stack ist der Ort, an dem Werte standardmäßig zugewiesen werden. Wenn wir diesen Wert also im Heap speichern möchten, können wir das tun:

let candidate_id: Box<CandidateId> = Box::new(CandidateId(2));

In diesem Fall wird Box die Instanz CandidateId auf dem Heap zuweisen und einen intelligenten Zeiger darauf zurückgeben. Der Zeiger wird zusammen mit der Variable candidate_id, einem Zeiger auf Box, auf dem Stack gespeichert. Die Instanz CandidateId wird also indirekt auf dem Heap alloziert, und ihr Besitz wird von Box verwaltet.

Lassen Sie uns nun eine neue Anforderung für die Verwaltung von Kandidaten in der Einstellungspipeline einführen. Das Modell HiringPipeline muss eine Liste von Kandidaten enthalten. Die erste Implementierung, die wir uns vorstellen können, wäre also:

struct HiringPipeline {
    candidates: Vec<Candidate>,
}

Der candidates Vektor wird als Feld innerhalb der HiringPipeline Struktur gespeichert. Wenn eine Instanz von erstellt wird, wird Speicher für die Struktur, einschließlich des Vektors, auf dem Stack zugewiesen. Darüber hinaus wird dem Vektor selbst Speicher auf dem Heap zugewiesen, um seine Elemente zu speichern.

Was den Besitz angeht, so ist die HiringPipeline Struktur direkt Eigentümer der Candidate Objekte:

  • Wenn wir einen Kandidaten zum Vektor hinzufügen, werden seine Daten direkt in der Speicherzuweisung des Vektors gespeichert.
  • Wenn wir einen Kandidaten aus dem Vektor entfernen, werden seine Daten aus dem Speicher entfernt.

Es ist die richtige Wahl, wenn wir eine feste Anzahl von Candidate Objekten speichern müssen, auf die immer gemeinsam zugegriffen wird und die nicht einzeln geändert oder übertragen werden müssen.

Diese Implementierung wäre jedoch nicht geeignet, wenn wir den Besitz von Candidate Objekten auf eine andere struct oder Funktion übertragen müssen. In diesem Fall sollte unser Modell wie folgt aussehen:

struct HiringPipeline {
    candidates: Vec<Box<Candidate>>,
}

In dieser zweiten Implementierung ist das Feld candidates ein Vektor von Box Objekten, wobei die Struktur HiringPipeline einen Vektor von Zeigern auf Candidate Objekte besitzt, die auf dem Heap gespeichert sind.

  • Wenn wir dem Vektor einen Kandidaten hinzufügen, wird ein neues Box erstellt, um einen Zeiger auf das Candidate Objekt auf dem Heap zu speichern, und dieses Box wird dem Vektor hinzugefügt.
  • Wenn wir einen Kandidaten aus dem Vektor entfernen, wird die Box entfernt, aber das Candidate Objekt selbst wird nicht automatisch freigegeben. Wenn jedoch keine Boxen mehr auf ein bestimmtes Candidate Objekt zeigen, wird das Speicherverwaltungssystem von Rust das Objekt automatisch freigeben.

Schauen wir uns einen konkreten Anwendungsfall dafür an:

let alice = Candidate {
    id: CandidateId(1),
    name: CandidateName(String::from("Alice")),
    email: CandidateEmail::new(String::from("alice@example.com")).unwrap(),
    experience_level: ExperienceLevel::Senior,
    interview_status: Some(InterviewStatus::Scheduled),
    application_status: ApplicationStatus::Submitted,
};
let bob = Candidate {
    id: CandidateId(2),
    name: CandidateName(String::from("Bob")),
    email: CandidateEmail::new(String::from("bob@example.com")).unwrap(),
    experience_level: ExperienceLevel::MidLevel,
    interview_status: None,
    application_status: ApplicationStatus::Rejected,
};

let pipeline = HiringPipeline {
    candidates: vec![
        Box::new(alice),
        Box::new(bob),
    ],
};

let senior_candidates: Vec<&Box<Candidate>> = pipeline
    .candidates
    .iter()
    .filter(|c| c.experience_level == ExperienceLevel::Senior)
    .collect();

println!("Senior candidates: {:?}", senior_candidates);
// Senior candidates: [Candidate { id: CandidateId(1), name: CandidateName("Alice"), email: CandidateEmail("alice@example.com"), experience_level: Senior, interview_status: Some(Scheduled), application_status: Submitted }]

Der resultierende senior_candidates Vektor enthält Verweise auf die ursprünglichen Candidate Objekte, nicht auf deren verpackte Kopien. Durch die Verwendung von Vec<Box> können wir daher unnötige Speicherzuweisungen und Kopien vermeiden, wenn wir mit Sammlungen von Objekten arbeiten, die teuer zu kopieren oder zu verschieben sind. Hätten wir stattdessen Vec verwendet, hätte die Methode filter gemeinsame Referenzen auf die Candidate Objekte im Vektor zurückgegeben. Das Problem ist jedoch, dass diese gemeinsamen Referenzen eine an den Vektor gebundene Lebensdauer haben. Wenn Sie sie an andere Funktionen weitergeben oder in anderen Datenstrukturen speichern, müssen Sie sicherstellen, dass der Vektor und sein Inhalt während der gesamten Lebensdauer dieser Referenzen gültig bleiben.

Zusammenfassend lässt sich sagen, dass wir mit Box die Candidate Objekte auf dem Heap behalten und nur Referenzen auf sie weitergeben können. Die Lebenszeiten wären an die Heap-Zuweisung gebunden und nicht an die Lebensdauer des Vektors. Auf diese Weise können wir das Eigentum an den Referenzen weitergeben, ohne das Eigentum an den Objekten selbst weiterzugeben, was wir in diesem Fall wollen.

Rc Smart Pointer

In der funktionalen Programmierung sind unveränderliche Daten ein Muss, und die gemeinsame Nutzung von Zuständen wird immer vermieden. Mit Rc können wir unveränderliche Daten gemeinsam nutzen. So können Programme einen funktionalen Stil beibehalten und gleichzeitig von der Möglichkeit profitieren, Daten effizient gemeinsam zu nutzen, ohne dass sie geklont oder kopiert werden müssen, was für die Leistung und den Speicherverbrauch teuer sein kann.

Rc steht für "reference counted" und ermöglicht es, mehrere "Besitzer" eines Wertes zu haben , ohne dass der Besitz übertragen werden muss. Zum Vergleich: Der Box Smart Pointer, den wir gesehen haben, wird verwendet, wenn wir das Eigentum an einem Wert von einem Teil Ihres Programms auf einen anderen übertragen müssen.

Daher kann jeder Teil des Programms, der die Daten verwenden muss, einen Verweis auf dieselbe Instanz halten, indem er Rc verwendet. So kann auf die Daten sicher zugegriffen werden, solange mindestens ein Verweis noch aktiv ist. Wenn die Anzahl der Referenzen auf Null sinkt, wird der Wert automatisch verworfen und sein Speicher freigegeben.

Hier ist ein Beispiel, bei dem wir den Rc Smart Pointer anstelle des Box verwenden könnten, den wir zuvor benutzt haben:

use std::rc::Rc;
struct HiringPipeline {
    candidates: Vec<Rc<Candidate>>,
}

impl HiringPipeline {
    fn new() -> HiringPipeline {
        HiringPipeline { candidates: vec![] }
    }

    fn add_candidate(&mut self, candidate: Candidate) {
        self.candidates.push(Rc::new(candidate));
    }
}

In dieser Implementierung würde die HiringPipeline die Rc Objekte besitzen, nicht die ursprünglichen Candidate Objekte. Wenn ein neuer Kandidat zu HiringPipeline hinzugefügt wird, wird er mit der Methode Rc::new in ein Rc eingewickelt und dann in den Vektor geschoben. Rc verfolgt die Anzahl der Verweise auf das zugrundeliegende Candidate Objekt und wenn der letzte Verweis gelöscht wird, wird das Candidate Objekt freigegeben.

Da Rc kein exklusives Eigentumsrecht an dem zugrunde liegenden Objekt hat, kann es das Objekt Candidate nicht direkt über eine Rc Referenz ändern.

Arc Smart Pointer

In nebenläufigen Programmen ist die gemeinsame Nutzung von Daten durch mehrere Threads entscheidend. Wie könnten wir unsere Domäne unter Berücksichtigung dieser Tatsache in Rust modellieren?

Nun, Arc ist eine Abkürzung für "atomically reference-counted smart pointer" und bietet eine thread-sichere Referenzzählung. Dadurch können sich mehrere Threads die Daten teilen, ohne dass es zu Datenrennen oder Speicherproblemen kommt. Darüber hinaus wird die Referenzzählung atomar aktualisiert, so dass sie immer genau ist, auch wenn mehrere Threads gleichzeitig darauf zugreifen.

Daher ist Arc ein wertvolles Werkzeug für die funktionale Domänenmodellierung bei der Arbeit mit gleichzeitigen und parallelen Berechnungen, die einen gemeinsamen Zugriff auf Werte über mehrere Threads hinweg erfordern. Durch die Verwendung von Arc kann der Besitz von Werten sicher zwischen Threads geteilt werden, was eine effizientere Nutzung von Ressourcen und eine bessere Leistung ermöglicht.

In einer gleichzeitigen Einstellungspipeline könnten zum Beispiel mehrere Threads gleichzeitig Kandidaten bearbeiten. Durch die Verwendung von Arc kann jeder Thread sicher auf die gemeinsam genutzten Bewerberdaten zugreifen und diese sogar ändern, ohne die Daten für jeden Thread klonen zu müssen, was ineffizient sein und zu Speicherproblemen führen kann.

use rayon::prelude::*;
use std::sync::{Arc, Mutex};

#[derive(Debug)]
struct HiringPipeline {
    candidates: Vec<Arc<Candidate>>,
}

impl HiringPipeline {
    fn new() -> HiringPipeline {
        HiringPipeline { candidates: vec![] }
    }

    fn add_candidate(&mut self, candidate: Candidate) {
        self.candidates.push(Arc::new(candidate));
    }

    fn filter_candidates<F>(&self, predicate: F) -> Vec<Arc<Candidate>>
    where
        F: Fn(&Candidate) -> bool + Send + Sync,
    {
        let filtered: Vec<Arc<Candidate>> = self
            .candidates
            .par_iter()
            .filter(|candidate| predicate(candidate.as_ref()))
            .cloned()
            .collect();

        filtered
    }
}

In dieser Illustration haben Sie vielleicht bemerkt, dass wir die Rayon-Kiste für die Filterung von Kandidaten verwenden. In der Tat kann die rayon crate eine sequenzielle Berechnung leicht in eine parallele umwandeln. Außerdem akzeptiert die Funktion filter_candidates eine Prädikatsfunktion, die die EigenschaftenSend und Sync implementiert, so dass sie sicher in mehreren Threads verwendet werden kann.

Schauen wir uns ein Beispiel an, das dieses Modell für ein Multi-Thread-Programm verwendet:

use std::sync::Mutex;
use std::thread;

fn main() {
    let alice = Candidate {
        id: CandidateId(1),
        name: CandidateName(String::from("Alice")),
        email: CandidateEmail::new(String::from("alice@example.com")).unwrap(),
        experience_level: ExperienceLevel::Senior,
        interview_status: Some(InterviewStatus::Scheduled),
        application_status: ApplicationStatus::Submitted,
    };
    let bob = Candidate {
        id: CandidateId(2),
        name: CandidateName(String::from("Bob")),
        email: CandidateEmail::new(String::from("bob@example.com")).unwrap(),
        experience_level: ExperienceLevel::MidLevel,
        interview_status: None,
        application_status: ApplicationStatus::Rejected,
    };

    let mut pipeline = HiringPipeline::new();

    pipeline.add_candidate(alice);
    pipeline.add_candidate(bob);

    let pipeline_arc = Arc::new(Mutex::new(pipeline));

    let pipeline_seniors = pipeline_arc.clone();
    let handle1 = thread::spawn(move || {
        let pipeline = pipeline_seniors.lock().unwrap();
        let filtered = pipeline
            .filter_candidates(|candidate| candidate.experience_level == ExperienceLevel::Senior);
        println!("Filtered candidates in thread 1: {:?}", filtered);
    });

    let pipeline_mids = pipeline_arc.clone();
    let handle2 = thread::spawn(move || {
        let pipeline = pipeline_mids.lock().unwrap();
        let filtered = pipeline
            .filter_candidates(|candidate| candidate.experience_level == ExperienceLevel::MidLevel);
        println!("Filtered candidates in thread 2: {:?}", filtered);
    });

    handle1.join().unwrap();
    handle2.join().unwrap();
}

Dieses Programm erstellt zwei Threads mit thread::spawn(). Jeder Thread sperrt den Mutex, um das Eigentum an der HiringPipeline Struktur zu erwerben und filtert die candidates auf der Grundlage ihrer Erfahrungsstufe mit der filter_candidates() Methode. Die gefilterten Ergebnisse werden dann auf der Konsole ausgegeben (die Reihenfolge kann variieren, wenn Sie es auf Ihrem lokalen Rechner ausprobieren):

Filtered candidates in thread 1: [Candidate { id: CandidateId(1), name: CandidateName("Alice"), email: CandidateEmail("alice@example.com"), experience_level: Senior, interview_status: Some(Scheduled), application_status: Submitted }]
Filtered candidates in thread 2: [Candidate { id: CandidateId(2), name: CandidateName("Bob"), email: CandidateEmail("bob@example.com"), experience_level: MidLevel, interview_status: None, application_status: Rejected }]

Fazit

Zusammenfassend lässt sich sagen, dass Box, Rc und Arc allesamt wertvolle Werkzeuge für die funktionale Domänenmodellierung in Rust sind. In der Tat können mehrere andere Smart Pointer hilfreich sein, aber wir haben sie in diesem Artikel nicht behandelt.

Wir haben gesehen, dass Box ideal für die Handhabung von auf dem Heap zugewiesenen Daten und die Bereitstellung von Einzelbesitz ist. Rc ist hilfreich, wenn mehrere Referenzen auf dieselben Daten benötigt werden und die gemeinsame Nutzung des Besitzes notwendig, aber nicht gleichzeitig ist. Arc ist vorteilhaft für nebenläufige Programme, bei denen der gemeinsame Besitz von Daten entscheidend ist. Durch die Verwendung dieser intelligenten Zeiger ermöglicht Rust eine sicherere und effizientere Verwaltung des Speichers, so dass wir komplexe Domänenmodelle erstellen können, die sich leicht gemeinsam nutzen lassen und in nebenläufigen Umgebungen verwendet werden können.

Das Verständnis der Unterschiede zwischen diesen intelligenten Zeigern und die Wahl des richtigen Zeigers in Kombination mit ADTs, den Typen Result und Option sowie Traits ist entscheidend für die Erstellung eleganter und effizienter Domänenmodelle, die leicht zu verstehen und zu pflegen, aber auch sicher und effizient sind.

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.

Contact

Let’s discuss how we can support your journey.