Programmiersprache: Rust 2024 ist die bisher umfangreichste Edition
Die jüngste Rust-Edition bringt große Neuerungen unter anderem für das Ownership-Modell, Unsafe Rust und Slices.
(Bild: iX)
- Stefan Baumgartner
Ende Februar 2025 kam Rust 1.85 und damit gleichzeitig die Rust Edition 2024. Das ist nicht ganz leicht zu verstehen, denn warum gibt es zusätzlich zu einer Version noch eine Edition und wozu braucht man die? Zunächst bedarf es einer einführenden Erläuterung: Edition, Version? Worum geht's da eigentlich?
Als Rust 1.0 vor mittlerweile zehn Jahren erschien, ging das mit einem Versprechen einher: Jeden mit Version 1.0 geschriebenen Code soll der Compiler auch Jahrzehnte später noch mit aktuellen Rust-Versionen übersetzen können. Es darf also keine Änderungen geben, die die Abwärtskompatibilität gefährden oder brechen. Da sich die Sprache in den vergangenen Jahren deutlich weiterentwickelt hat, lassen sich Breaking Changes jedoch kaum vermeiden.
Editionen für große Änderungen
Rust umgeht diese selbst auferlegte Limitierung durch Editionen. Während Sprache und Standardbibliothek sich von Version zu Version weiterentwickeln und neue Features im Sinne der Abwärtskompatibilität hinzufügen, erlauben Editionen Kompatibilitätsbrüche nach Zustimmung. Entwicklerinnen und Entwickler können durch die Wahl der Edition entweder ein Projekt nach dem aktuell gültigen Verständnis des Rust-Ökosystems starten oder ein bestehendes Projekt halbautomatisch migrieren.
Die Änderungen zwischen Versionen können tiefgreifend sein. Ein Beispiel sind die Änderungen des zentralen Borrow Checker mit der Einführung von Non-Lexical Lifetimes in Edition 2015. Heute ist dieses Feature nicht mehr wegzudenken. In Edition 2018 gibt es subtilere Ergänzungen wie die Schlüsselwörter async und await. Im Interesse der Abwärtskompatibilität muss sichergestellt sein, dass bestehender Code, der diese Schlüsselwörter als Bezeichner für Variablen oder Funktionen verwendet, weiterhin funktioniert. Des Weiteren wurde das Modulsystem gestrafft und modernisiert, um den Anforderungen im realen Einsatz zu entsprechen. Rust 2021 enthält Fehler, die vorher Warnungen waren, neue Traits und Typen im globalen Geltungsbereich, Erweiterungen der Makrosyntax und andere Quality-of-Life-Verbesserungen.
Das Konzept der Editionen ist ein Segen für die Entwicklung mit Rust. Wer damals den Sprung von Python 2 auf Python 3 mitgemacht hat, erinnert sich meist nur ungern an den Split des Ökosystems.
Das Beste der Edition 2024
Wie der Edition Guide zeigt, sind die Änderungen in der Edition 2024 umfangreich und gehen von Sprachänderungen über Quality-of-Life-Features im Tooling bis hin zu Updates der Standardbibliothek.
Da die Änderungsliste kleinere Anpassungen wie reservierte Syntax und Schlüsselwörter enthält, aber auch Features, die einen ganzen Artikel rechtfertigen würden, folgt eine persönlich ausgewählte Best-of-Liste, die sich vor allem darauf konzentriert, was sich direkt auf das Entwickeln auswirkt.
Temporäre Geltungsbereiche
Der Borrow Checker auf Basis der Non-Lexical Lifetimes war mit Version 1.0 in Edition 2015 schon eine Offenbarung in Entwicklungsergonomie, schützt aber nicht vor kleinen Logikfehlern, bei denen der Code das Modell zu Ownership und Borrowing zwar befolgt, aber andere Umstände für unerwartetes Verhalten sorgen.
Folgende Funktion führt einen Lesezugriff auf ein RwLock durch. Das damit entstandene ReadLock enthält eine Option, die die if let-Anweisung auspackt und mit dem darin befindlichen Wert arbeitet. Sollte dieser Wert nicht existieren, möchte man im else-Zweig der Bedingung einen Wert in das RwLock legen, führt also einen Schreibzugriff durch.
fn f(value: &RwLock<Option<bool>>) {
if let Some(x) = *value.read().unwrap() {
println!("value is {x}");
} else {
let mut v = value.write().unwrap();
if v.is_none() {
*v = Some(true);
}
}
// <--- Read lock is dropped here in 2021
}
In Edition 2021 würde das RwLock erst am Ende der gesamten If-Else- Anweisung beendet und freigemacht werden. Da das Lock noch existiert, wenn sich das Programm im Else-Zweig befindet, ist der Schreibzugriff nicht gestattet. Bis Rust 2021 würde dieser Code einen Deadlock erzeugen und zur Laufzeit unendlich warten.
Rust 2024 verkürzt die Lifetime dieser Elemente auf den temporären Geltungsbereich der If-Anweisung. Das bedeutet, dass der Drop-Call nun vor dem Else-Zweig erfolgt:
fn f(value: &RwLock<Option<bool>>) {
if let Some(x) = *value.read().unwrap() {
println!("value is {x}");
}
// <--- Read lock is dropped here in 2024
else {
let mut s = value.write().unwrap();
if s.is_none() {
*s = Some(true);
}
}
}
Für Ausdrücke, die am Ende eines Blocks oder des Bereichs einer Funktion oder einer Closure ausgewertet werden – die Tail Expressions – verkürzt sich der temporäre Geltungsbereich ebenfalls. Der RFC 3606 (Request for Comments) zeigt das in der folgenden Grafik ganz anschaulich.
(Bild: Rust Foundation)
Das löst problematische Situationen, in denen der Besitzer eines Wertes noch erforderlich ist, um ein Resultat zu erzeugen:
// Before 2024
fn f() -> usize {
let c = RefCell::new("..");
c.borrow().len() // error[E0597]: `c` does not live long enough
}
c.borrow() erzeugt einen temporären Wert, der Zugriff auf c hat. Da c vor diesem temporären Wert gedropped wird, ist in Rust 2021 auch kein Zugriff auf Elemente wie len erlaubt, in Rust 2024 dagegen schon.
Änderungen für unsafe
In Rusts wohl am häufigsten missverstandenen Sicherheitsfeature unsafe, gibt es ebenfalls ein paar sehr willkommene Änderungen.
Das Schlüsselwort unsafe existiert für zwei Aufgaben: Als Präfix einer Funktion signalisiert es eine Operation, die ohne weitere Checks zu undefiniertem Verhalten führen kann. Ein mit unsafe gekennzeichneter Block darf eine solche Funktion ausführen.
Bislang war allerdings alles innerhalb eines gekennzeichneten Blocks automatisch unsafe: Man konnte mit unsafe deklarierte Operationen ohne weiteres aufrufen.
unsafe fn get_unchecked<T>(x: &[T], i: usize) -> &T {
x.get_unchecked(i)
}
Dieses Verhalten stand schon länger in der Kritik, da es einerseits schwierig ist, die kritischen Aufrufe herauszufinden und andererseits unsafe als Präfix noch lange nicht impliziert, dass im Block der Aufruf einer unsafe-Funktion erfolgt. So kann beispielsweise die Änderung der Vektorenlänge komplett sicher sein, aber Seiteneffekte verursachen.
Rust 2024 beseitigt dieses Verhalten nicht vollständig, aber erzeugt zumindest eine Warnung, falls eine Anwendung unsafe-Funktionen innerhalb von unsafe-Funktionen ohne unsafe-Block ausführt. Einmal in den Block eingepackt, verschwindet die Warnung auch schon wieder:
unsafe fn get_unchecked<T>(x: &[T], i: usize) -> &T {
unsafe { x.get_unchecked(i) }
}
Ferner erwartet Rust 2024, dass alle extern-Blöcke, die beispielsweise als Schnittstellen zu C dienen, ebenfalls mit unsafe deklariert sind. Hier zeigt sich hervorragend die eigentliche Bedeutung von unsafe: Es markiert Dinge, deren Gültigkeit nicht der Compiler überprüft, sondern die man beim Schreiben des Codes im Blick haben muss.
Aus denselben Gründen müssen Attribute wie link_section, export_name und no_mangle als unsafe markiert werden. Funktionen aus der Standardbibliothek wie env::set_var und env::get_var zum Lesen und Schreiben von Umgebungsvariablen behandelt Rust 2024 ebenfalls als unsafe.
Dass gerade dieser Bereich so viele Updates erhält, zeigt, wie wichtig unsafe als Sicherheitsfeature ist. Genaueres zu dem Thema und den vielen Missverständnissen habe ich auf meiner privaten Website zusammengestellt.
Iteratoren für Box<[T]>
Slices stellen in Rust eine Sicht auf eine Sequenz von Elementen dar. Damit bilden sie einen gemeinsamen Nenner zwischen Arrays und Vektoren und erlauben, Methoden, Funktionen und Traits für unterschiedliche Datentypen zu implementieren. Meist hantiert man mit Slices in der Referenzform: Wo es ein Vec<T> als Owned Type gibt, ist &[T] die Slice dazu.
Es gibt allerdings in manchen Szenarien noch eine weitere Art von Slices: Boxed Slices (Box<[T]>), die nichts anderes sagen als: Hier ist ein Zeiger auf eine Sequenz. Dieser Typ ist ein Owned Type, der anders als bei Referenzen erlaubt, Ownership an Variablen zu binden.
Um bei Sequenzen auch auf die darunterliegenden Elemente zuzugreifen, ist der Trait IntoIterator erforderlich.
In Rust-Versionen vor 1.80 war IntoIterator nicht für Box<[T]> implementiert. Der Aufruf .into_iter() löste Box<[T]> zu &[T] auf. Alle Elemente sind dadurch Referenzen, also &T, was für einen Owned Type nicht wünschenswert ist.
// Vor Rust 2021
let my_boxed_slice: Box<[u32]> = vec![1, 2, 3].into_boxed_slice();
// .into_iter() war notwendig vor Rust 1.80
for x in my_boxed_slice.into_iter() {
// x ist vom Typ &u32
}
Rust 1.80 brachte eine Implementierung von IntoIterator für Box<[T]>, die allerdings nur dann griff, wenn man den Iterator über Syntaxzucker bekommen hat. For-Schleifen dereferenzieren anders als der explizite Aufruf von into_iter() nicht.
// Rust 1.80, alle Editionen
let my_boxed_slice: Box<[u32]> = vec![1, 2, 3].into_boxed_slice();
for x in my_boxed_slice {
// x ist vom Typ u32
}
In Rust 2024 zieht das Verhalten gleich: Mit into_iter() bekommt man nun die richtige Implementierung und damit auch T für das Element – und nicht mehr &T.
// Rust 2024
let my_boxed_slice: Box<[u32]> = vec![1, 2, 3].into_boxed_slice();
for x in my_boxed_slice.into_iter() {
// x ist vom Typ u32
}