Rust

Notizen zu Rust.

Zero-Overhead Abstractions

Rust bietet Abstraktionen an, deren Laufzeit-Kosten:

Dies wird als „Zero-Overhead Abstractions” bezeichnet.

Leider wird gelegentlich auch der Name „Zero-Cost Abstractions” verwendet, was häufig zu der Annahme führt, dass keinerlei Aufwände anfallen. Das ist natürlich unmöglich, da jeder ausgeführte Code Laufzeit-Kosten hat.

Beispiele für Zero-Overhead Abstractions in Rust sind:

Traits

Datentypen können mittels Traits gruppiert werden. Traits können Methoden deklarieren und definieren, aber keine Member.

Implementierung

Trait-Deklarationen und -Implementierungen können in verschiedenen Crates definiert sein. Die Orphan Rule verbietet jedoch, dass ein Trait aus einem externen Crate für einen Datentyp aus einem externen Crate implementiert wird. Hiermit wird sichergestellt, dass es für jeden Datentyp nur eine einzige Implementierung gibt.

Auto Traits sind Traits, die der Compiler für jeden Datentyp automatisch implementiert.

TODO: Blanket Implementations

Verwendung

Um eine Methode, die in einem Trait deklariert ist, aufrufen zu können, muss 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 T haben.

Trait-Objekte:

Inheritance

Traits können automatisiert vereinigt werden. Datenstrukturen und die für sie implementierten Methoden können jedoch nicht automatisiert vereinigt werden.

Trait Bounds

Bei Argumenten, bei Trait-Definitionen. TODO

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 sind Traits, die vom Compiler gesondert behandelt werden. Sie haben keine Methoden und werden als Trait-Bounds verwendet.

Ownership

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.

Weil nicht mehr als ein Codebereich gleichzeitig vollen Zugriff haben kann, sind Fehler durch konkurrierenden Zugriff nicht möglich.

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.

Es gibt zwei Arten von temporären Zugriffen:

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.

Asynchronous Rust

Informed polling, cooperative scheduling. TODO

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 bei rekursiven Enums - unendliche Größe. LocalBoxFuture und BoxFuture.

Einschränkungen

async Drop wird derzeit nicht unterstützt. async Closures stehen in Stable Rust noch nicht zur Verfügung

Trait-Methoden können nicht async sein. Als Workaround kann das Crate async-trait verwendet werden.

Implizites vs. Explizites

Implizites

Den Compiler nutzen

Mit bestimmten 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

optimieren

die Kompilierung beschleunigen

das Kompilat performanter und kleiner machen

sicherer machen

made with makāmau