Rust
Notizen zu Rust.
Zero-Overhead Abstractions
Die Entwickler von Rust streben „Zero-Overhead Abstractions” an.
Das sind Abstraktionen, die ohne vermeidbare Laufzeitkosten auskommen:
- die Kosten fallen nur an, falls der Entwickler die Abstraktionen explizit nutzt.
- die Kosten sind dann auch nicht größer, als wenn der Entwickler die Abstraktionen selbst implementieren würde.
Gelegentlich wird dies auch mit „Zero-Cost” bezeichnet, was häufig zum Missverständnis führt, dass durch die
Abstraktionen überhaupt keine Laufzeitkosten anfallen.
Beispiele für Zero-Overhead Abstraktionen in Rust:
- Allokation: der Entwickler kann steuern, ob Allokation auf dem Heap oder dem Stack erfolgt
- Dispatch: der Entwickler kann steuern, ob
Dynamic Dispatch oder
Static Dispatch verwendet wird.
- Es gibt keine erzwungene Bindung an Laufzeitumgebungen für:
- Garbage Collection:
die Buchhaltung des Speichers erfolgt grundsätzlich zur Kompilierzeit.
- Green Threads: falls gewünscht, können Laufzeitumgebungen aus
optionalen, austauschbaren Crates genutzt werden.
Minimale Standard Library
Dass Rust nur eine minimale Standard Library haben sollte, war eine bewusste Entscheidung.
Chancen:
- dies erlaubt Entwicklung von Crates, frei von den Stabilitätsgarantien der Standard Library
- dies erlaubt die Implementierung konkurrierender Ansätze
- Zufallszahlengeneratoren: TODO
- dies erlaubt Entwicklung von Crates, ohne sämtliche von Rust unterstützten
Plattformen abdecken zu müssen
Risiken:
- geeignete Crates können nicht immer einfach gefunden und bewertet werden
- externen Entwicklern muss vertraut werden
- Fragmentierung des Ökosystems, z.B. bei asynchronen Runtimes (smol, Tokio)
Beispiele für nicht optimalen Code in Standard Libraries:
- C:
- Go:
- net/http: TODO Projekte wie grpc-go verwenden eigene HTTP/2-Implementierungen
- Java:
- java.util.Calendar, java.util.Date: TODO
- PHP:
- inkonsistente Namenskonventionen für Funktionsnamen
- Python (siehe auch PEP 594):
- asyncio: TODO curio, trio
- http.server: TODO
- subprocess: TODO
- urllib, urllib2: TODO
- Rust:
Beispiele von verbreiteten Libraries, die nicht Teil einer Standard Library sind:
- Java:
- Python:
- Rust:
- rustc_serialize vs Serde
- lazy_static vs once-cell
Traits
Datentypen können mittels Traits gruppiert
werden. Traits können Methoden sowohl deklarieren, als auch default-implementieren.
Member können in Traits jedoch 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.
Falls er es für angebracht hält, implementiert der Compiler diese Traits für Datentypen automatisch.
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
Statischer Polymorphismus
Von statischem Polymorphismus spricht man, falls Trait-Methoden aufgerufen werden und der Datentyp
zur Kompilierzeit bekannt ist: impl Trait.
Einschränkungen:
- Trait-Methoden können derzeit nicht den
Return-Wert impl Trait haben.
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, siehe Abschnitt „Marker Traits”), 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.). Ein solcher Wide Pointer wird
„Trait Object” genannt und enthält neben der Adresse des Datentyps auch eine
Virtual Table mit den Adressen der Methoden-Implementierungen.
Ein Trait Object ist möglich, falls der Trait „object safe” ist:
- keine Methode darf statisch sein
- keine Methode darf sich auf Self beziehen
- keine Methode darf generische Typ-Parameter verwenden
Einschränkungen:
- Upcasting von Traits eines Trait Objects ist derzeit
nicht möglich.
- Clonen ist nicht möglich, da Clone nicht object safe ist. Als Workaround kann das Crate
dyn-clone genutzt werden.
Polymorphismus bzgl. des Return-Wert-Typs
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 -Return-Werten, 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:
- std::marker::Copy: implementiert ein Datentyp Copy, so
kann er durch den Compiler implizit dupliziert werden.
- std::marker::Send: implementiert ein Datentyp Send, so
kann höchstens 1 Thread exklusiven Zugang haben.
- std::marker::Sized:
- ein Datentyp implementiert genau dann Sized, falls er eine zur Kompilierzeit
bekannte, endliche Größe hat.
Nur solche Datentypen können auf dem Stack abgelegt werden.
Für alle anderen können nur Pointer auf dem Stack abgelegt werden.
- Alle Typ-Parameter sind Sized, nicht jedoch bei Self in Traits, weil es Trait Objects
verhindert.
- Diese Einschränkung kann mit ?Sized entfernt werden.
- Generics durchlaufen Monomorphization/Reification, daher muss Self: Sized sein. TODO
- std::marker::Sync: implementiert ein Datentyp Sync, so
können mehrere Threads gleichzeitig geteilten Zugang haben.
Erfüllt ein Datentyp weder Send noch Sync, dann ist Zugang auf seine Instanz nur aus dem
Thread möglich, aus dem die Instanz stammt.
- std::marker::Unpin: TODO
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:
- durch Bindung an Variablen (mittels Gleichheitszeichen oder
Pattern Matching)
- durch Übergabe als Argument an eine Funktion
- durch Übergabe eines Return-Werts einer Funktion oder eines Blocks an den Aufrufer
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 Freigabe anderer 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 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 Arten verliehen werden:
- &T: gewährt geteilten Zugriff
- &mut T: gewährt exklusiven Zugriff
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 Borrow Checker des Compilers stellt 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.
Copy vs Borrowing
Die Wahl zwischen Copy und Borrowing ist
nicht immer trivial.
Typen von Parametern und Return-Werten
TODO:
Asynchronous Rust
TODO Informed polling, cooperative scheduling.
- Initialisierung: async-Funktionen und -Blöcke werden in eine State Machine gewrappt. Der
Returnwert-Typ ist nicht T, sondern impl Future<Output=T> + '_.
- Verwendung: die externe Runtime arbeitet die State Machine ab.
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
- async Closures stehen in Stable Rust
noch nicht zur Verfügung
- Trait-Methoden können
derzeit nicht async sein.
Als Workaround kann das Crate async-trait verwendet
werden.
- Insbesondere ist async Drop noch nicht möglich.
Explizites vs. Implizites
Explizites
- Konstruktoren:
- Jede Funktion oder Methode, welche Instanzen eines Datentyps erzeugt und liefert, ist ein
Konstruktor. Namen wie new(), from_X(), with_X() sind lediglich Konvention.
- Ein Datentyp hat den Default-Konstruktor
default(),
falls der Trait std::default::Default für diesen Datentypen
implementiert ist.
- Destruktoren:
- Ein Datentyp hat den Destruktor
drop(), falls für den
Datentyp der Trait std::ops::Drop implementiert ist.
Implizites
- async:
- Initialisierung: async-Funktionen und -Blöcke werden in eine State Machine gewrappt. Der
Returnwert-Typ ist nicht T, sondern impl Future<Output=T> + '_.
- Verwendung: die externe Runtime arbeitet die State Machine ab.
- Auto-Borrowing: TODO
- Auto Traits: Traits, die automatisch für Datentypen
definiert sind.
- Closures:
- Initialisierung: ein anonymer Struct wird erzeugt, welcher entweder die
gecapturten Werten enthält (falls mit move) oder Referenzen auf diese (falls ohne move).
- Verwendung: eine Methode eines der folgenden Traits wird aufgerufen:
std::ops::Fn,
std::ops::FnMut oder
std::ops::FnOnce aufgerufen.
- Coercion: Datentypen können implizit
in andere umgewandelt werden.
- Copy-Semantik: TODO
- Deref-Coercion:
deref()
aus dem Trait std::ops::Deref wird vom Compiler implizit aufgerufen.
- Destruktoren:
- Initialisierung: Drop-Glue wird erzeugt.
- Der Destruktor drop() wird
implizit aufgerufen, sobald der Wert out-of-scope geht.
- for-Schleifen: rufen implizit
die Methode into_iter() des
Traits std::iter::IntoIterator auf.
- Lifetime Elision: die Lifetime wird
implizit bestimmt und muss nicht definiert werden.
- Marker: Strukturen oder Traits, die alleine
durch ihre Verwendung, die Behandlung von Datentypen durch den Compiler verändern.
- Method Resolution:
TODO
- Move-Semantik: TODO
- Operatoren: TODO
- Panic: TODO
- Prelude: Definitionen, die automatisch im
Scope sind
- Reborrow: TODO
- Thread locals: TODO
Den Compiler nutzen
Mit bestimmtem Code kann der Compiler dazu genutzt werden, Eigenschaften von Datentypen entweder zu bestätigen oder
mittels Fehlermeldungen zu erklären.
Erklären lassen:
Bestätigen:
- die Object Safety des Traits T:
- fn assert_object_safety(a: Box<dyn T>) {}
- die Thread-Sicherheit des Datentyps A:
- fn assert_send<T: Send>() {}; assert_send::<A>();
- fn assert_sync<T: Sync>() {}; assert_sync::<A>();
- die Lifetime des Datentyps A:
- fn assert_contravariance<'a>(a: A<'l>) -> A<'static> { a }
- fn assert_covariance<'a>(a: A<'static>) -> A<'l> { a }
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.
- im Modul std::cell:
- für interior Mutability (Veränderung via Referenzen & anstatt unique Referenzen &mut):
- falls etwa logisch immutable ist, technisch aber Mutation erfordert:
- Caching
- Zähler hochsetzen, z.B. in clone()
- die Signatur einer Trait-Methode erlaubt keine Mutation
- std::cell::Cell:
- gibt keine Referenzen heraus, der Wert wird beim Schreiben und Lesen kopiert
- => eher für kleine Datentypen
- verwendet intern UnsafeCell
- std::cell::RefCell:
- im Modul std::rc:
thread-safe
Kombinationen
- Arc<Mutex<T>>: TODO
- Rc<RefCell<T>>: TODO
Einschränkungen
Macros
TODO
Tests
Unit Tests
TODO
Fuzzer
TODO
optimieren
die Kompilierung beschleunigen
- Abhängigkeiten:
- das eigene Projekt mittels Workspace/Crates und Features unterteilen
- nicht benötigte Abhängigkeiten komplett entfernen:
- eigene Abhängigkeiten:
- cargo tree: erleichtert zu erkennen, falls ein Crate in verschiedenen Versionen
gleichzeitig verwendet wird.
- cargo-udeps: findet unbenutzte eigene Abhängigkeiten
- Es empfiehlt sich, die eigenen Abhängigkeiten zu aktualisieren, da diese wiederum ihre
Abhängigkeiten optimiert haben könnten:
- cargo update
- cargo outdated: findet neuere Versionen der
eigenen Abhängigkeiten
- nicht benötigte Features von Abhängigkeiten nicht kompilieren
- Abhängigkeiten durch schlankere Altrnativen ersetzen:
- cargo check verwenden statt
cargo build
- in Cargo.toml im Abschnitt [profile.dev] definieren:
- split-debuginfo = \"unpacked\": TODO
- schnelleren Linker verwenden:
das Kompilat performanter und kleiner machen
- in Cargo.toml im Abschnitt [profile.release] definieren:
- codegen-units = 1: TODO
- lto = \"fat\": TODO
- -C target-cpu=native: TODO
- panic = \"abort\": TODO
- strip=\"symbols\": TODO
- Benchmarken:
sicherer machen
- die direkt und indirekt verwendeten Crates überprüfen:
- nach Sicherheitslücken:
- nach ungewünschten Lizenzen:
- nach Benutzung von unsafe:..
Alle Angaben ohne Gewähr • Home • Kontakt