Rust

Notizen zu Rust.

Zero-Overhead Abstractions

Rust bietet „Zero-Overhead Abstractions” an, also Abstraktionen, die keine vermeidbaren Laufzeitkosten aufweisen:

  1. die Kosten fallen nur an, wenn der Entwickler die Abstraktionen explizit verwendet.
  2. die Kosten sind nicht größer, als wenn der Entwickler die Abstraktionen selbst programmieren würde.

Leider wird dies gelegentlich auch „Zero-Cost” genannt, was häufig zum Missverständnis führt, dass überhaupt keine Laufzeitkosten entstehen.

Beispiele für Zero-Overhead Abstractions in Rust:

minimale Standard Library

Rust bietet bewusst nur eine minimale Standard Library an.

Chancen:

Risiken:

Beispiele für nicht optimalen oder ungewünschten Code in Standard Libraries:

Traits

Datentypen können mittels Traits gruppiert werden. Traits können Methoden sowohl deklarieren, als auch default-implementieren. Member können in Traits nicht definiert werden.

Implementierung

Traits werden für einen Datentyp entweder in einfachen Fällen automatisch mittels derive oder manuell mittels impl Trait for Datentyp { ... } implementiert.

Trait-Deklarationen und -Implementierungen können sich in unterschiedlichen Crates befinden. Die Orphan Rule verbietet jedoch, dass ein Trait aus einem externen Crate für einen Datentyp aus einem externen Crate implementiert wird. Dadurch wird sichergestellt, dass es für jede Kombination aus Datentyp und Trait unmissverständlich nur eine einzige Implementierung gibt.

Auto Traits werden mit dem Keyword auto markiert. Der Compiler implementiert sie für jeden Datentyp automatisch, falls er es für angebracht hält. Beispiele sind std::marker::Send und std::marker::Sync.

TODO: Blanket Implementations

Verwendung

Um eine Trait-Methode für einen Datentyp aufrufen zu können, muss neben dem Datentyp auch der Trait importiert werden.

Methoden können auch als freistehende Funktion aufgerufen werden. Ist z.B. die Methode A::f(&self, b: B) implementiert, so kann sie für eine Instanz a von A nicht nur als a.f(b), sondern auch als A::f(a, b) verwendet werden.

Polymorphismus

Trait-Methoden können nicht den Return-Wert impl Trait haben.

Statischer Polymorphismus

Von statischem Polymorphismus spricht man, falls Trait-Methoden aufgerufen werden und der Datentyp zur Kompilierzeit bekannt ist: impl Trait.

Dynamischer Polymorphismus

Von dynamischen Polymorphismus spricht man, falls Trait-Methoden aufgerufen werden und der Datentyp zur Kompilierzeit nicht bekannt ist: dyn Trait.

Da die Größe von dyn Trait zur Kompilierzeit nicht bekannt ist (!Sized), kann dyn Trait alleine nicht genutzt werden. Stattdessen muss dyn Trait in einem Wide Pointer verpackt werden (z.B. &dyn Trait, Box<dyn Trait>, Arc<dyn Trait> etc.). Diese Wide Pointer werden Trait-Objekte genannt und enthalten neben der Adresse des Datentyps auch eine Virtual Table mit den Adressen der Methoden-Implementierungen.

Ein Trait-Objekt ist möglich, falls der Trait object-safe ist:

Einschränkungen:

Returnwert-Typ-Polymorphismus

TODO

Inheritance

Traits können automatisch vereinigt werden: trait C: A+B {}. Datenstrukturen, sowie die für sie implementierten Methoden und Trait-Methoden können jedoch nicht automatisiert vereinigt werden.

Trait Bounds

TODO Bei generischen Typ-Parametern, Funktionsargumenten und -returnwerten, sowie bei Trait-Definitionen.

Mehrere Traits können nur dann als Trait Bound einer Closure oder eines Trait Objects verwendet werden, wenn sie Auto Traits sind.

Marker Traits

Marker Traits haben keine Methoden. Sie für einen Datentypen zu implementieren, bedeutet lediglich, den Compiler anzuweisen, diesen Datentyp besonders zu behandeln:

Der Compiler validiert ggf. rekursiv, dass alle Bestandteile des Datentyps den Trait bzw. die Trait Bounds erfüllen.

Ownership-Modell

Der kleinste Codebereich, in dem ein Datentyp instantiiert wird, ist der Besitzer (Owner) dieser Instanz. Nur der Besitzer hat auf sie Zugriff.

Der Besitz (Ownership) kann durch einen Move auf andere Codebereiche übergehen:

Zu jedem Zeitpunkt gibt es höchstens einen Besitzer. Der Compiler stellt sicher, dass Bindungen des vorherigen Besitzers nach einer Übertragung nicht mehr verwendet werden können.

Sobald Daten den besitzenden Codebereich verlassen, ohne dass sie von einem anderen Codebereich übernommen wurden, sind sie ungenutzt und ihr Speicherplatz kann und wird durch den Compiler freigegeben. Dies gilt auch für andere Ressourcen, wie geöffnete Dateien, Netzwerkverbindungen oder Locks.

Da immer höchstens ein Codebereich vollen Zugriff hat, sind Fehler aufgrund gleichzeitigen Zugriffs ausgeschlossen.

Clone, Copy

Die Besitzübertragung mittels Move ist außer bei primitiven Datentypen die Regel. Falls das nicht gewünscht ist, können die Daten vor der Übergabe dupliziert (geklont) werden. Somit wechselt nur das Duplikat den Besitzer. Eine weitere Möglichkeit die Abgabe des Besitzes zu vermeiden ist das Borrowing (siehe nächster Abschnitt).

Die Duplizierung erfolgt über den Aufruf der Methode clone(), falls für den Datentyp der Trait std::clone::Clone implementiert ist.

Ist für den Datentyp hingegen der Marker Trait std::marker::Copy implementiert, erfolgt die Duplizierung an den entsprechenden Stellen durch den Compiler implizit. Ein expliziter Aufruf von clone() ist dann unnötig. rust-clippy weißt auf solche und andere unnötigen Aufrufe von clone() hin.

Der Compiler implementiert Copy mittels bitweisem Kopieren. Bei Datentypen, wie String bzw. Vec, welche Pointer auf Bereiche im Heap halten, ist ein bitweises Kopieren unzulässig, da die duplizierten Pointer zu Speicherfehlern führen würden.

Borrowing, Lifetimes

Die Einschränkungen des Ownership-Modells sind in vielen Fällen zu restriktiv. Mittels Borrowing kann anderen Codebereichen temporärer Zugriff auf Daten gewährt (ausgeliehen) werden, ohne dass der Besitz abgegeben werden muss.

Mittels Referenzen kann Zugriff auf zwei unterschiedliche Arten verliehen werden:

TODO:

Eine Lifetime gibt an, wie lange eine Referenz eines bestimmten Speicherbereichs gültig ist. Sie endet spätestens mit dem Drop der Daten, aber auch bereits mit einem Move.

Der Borrowchecker des Compilers stellt all dies sicher. Kann er nicht eindeutig erkennen, dass die Lifetimes eingehalten werden, lehnt er den Code im Zweifel ab. Ein fälschliches Akzeptieren von Code würde dessen Speichersicherheit gefährden.

Asynchronous Rust

TODO Informed polling, cooperative scheduling.

Der Compiler kann nicht überprüfen, ob der Executor zur Laufzeit nicht ungewollt blockiert wird.

Wenn async Funktionen rekursiv aufgerufen werden sollen, muss der Result-Typ geboxed werden. Andernfalls hätte der Return-Wert-Typ - ähnlich wie bei rekursiven Enums oder Structs — unendliche Größe.

Einschränkungen

Implizites vs. Explizites

Implizites

Den Compiler nutzen

Mit bestimmtem Code kann man den Compiler dafür nutzen, Eigenschaften von Datentypen entweder zu bestätigen oder mittels Fehlermeldungen zu erklären.

Erklären lassen:

Sicherstellen:

Explizites

Smart Pointer

Smart Pointer erlauben, Einschränkungen des Borrow-Checkers zu umgehen.

nicht thread-safe

„Nicht thread-safe“ bedeutet in Rust, dass der Code nicht kompilieren würde, falls versucht wird, nicht thread-safe Datenstrukturen zwischen Threads auszutauschen.

thread-safe

Kombinationen

Einschränkungen

Macros

TODO

Tests

Unit Tests

TODO

Fuzzer

TODO

optimieren

die Kompilierung beschleunigen

das Kompilat performanter und kleiner machen

sicherer machen


Kontakt • made with makāmau