Ferris Talk #1: Iteratoren in Rust
Startschuss der neuen Rust-Kolumne von Rainer Stropek und Stefan Baumgartner: Die erste Ausgabe des ab jetzt monatlichen Ferris Talks stellt die Iteratoren vor.
- Rainer Stropek
In der ersten Ausgabe unserer Rust-Kolumne erklären wir zunächst für Rust-Einsteiger und -Einsteigerinnen, was Iteratoren in Rust sind und wie sie funktionieren. Anschließend gehen wir der Frage nach, welche Lücken die Rust-Version 1.53 hinsichtlich Iteratoren und Feldern geschlossen hat und warum Entwicklerinnen und Entwickler so lange auf diese scheinbar einfache Sprachfunktion warten mussten. Dabei lässt sich auch über die Sprachprinzipien von Rust einiges lernen.
Vielfach heißt es, dass sich die Welt im Bereich der Softwareentwicklung schneller ändert als in anderen Bereichen. Auch wenn das stimmen mag, sind fundamentale Umbrüche mit langfristiger Wirkung auch in der Informatik selten. Nicht jedes JavaScript-Framework stellt die Art, Software zu entwickeln, komplett auf den Kopf. Rust aber gehört zu den seltenen Änderungen, die nachhaltig, langfristig und unserer Einschätzung nach positiv wirken werden. In dieser Kolumne möchten wir abwechselnd regelmäßig über Neuerungen und Hintergründe im Bereich Rust berichten. Sie soll Teams, die Rust schon verwenden, helfen, auf dem Laufenden zu bleiben. Einsteigerinnen und Einsteiger sollen durch die Kolumne tiefere Einblicke in die Funktionsweise von Rust erhalten.
Kolumnenautoren vom Rust Meetup Linz
Der Kolumnen-Titel nimmt Bezug auf Ferris, das krabbenförmige inoffizielle Maskottchen der Rust-Gemeinde. Die Ferris Talks schreiben Stropek und Baumgartner ab sofort monatlich und im Wechsel – mehr zu den Autoren steht am Ende des Artikels. Die beiden Kolumnisten sind überzeugte Rustaceans und organisieren das Rust Meetup Linz, die Fachtreffen werden in Videoform festgehalten und sind auf der Rust-Linz-Playliste bei YouTube abrufbar. Wer die beiden beruflich treffen möchte, kann sie unter anderem als Vortragende und als Workshop-Trainer bei der Rust-Konferenz 2021 von Heise erleben.
- Zum aktuellen Rust Meetup: It's time for the steel city to Rust, 26. August 2021
- Wasm 2021: Online-Konferenz zu WebAssembly, 31. August 2021
- Rust, Einstieg und Deep Dive: Online-Konferenz zu Rust, 13. Oktober 2021
Rainer Stropek
ist IT-Unternehmer, Softwareentwickler, Trainer, Autor und Vortragender im Microsoft-Umfeld. Er ist seit 2010 MVP für Microsoft Azure und entwickelt mit seinem Team die Zeiterfassung für Dienstleistungsprofis time cockpit.
Stefan Baumgartner
arbeitet bei Dynatrace im österreichischen Linz. In seiner Freizeit organisiert er Stahlstadt.js, ScriptConf, DevOne, Rust Meetup Linz und das legendäre Technologieplauscherl. Wenn noch ein wenig Freizeit bleibt, spricht er beim Working Draft Podcast, genießt italienische Pasta, belgisches Bier und britischen Rock.
Iteratoren in Rust: Einleitung
Iteratoren sind ein Konzept, das sich in vielen Programmiersprachen findet. C#, Java, JavaScript, C++ bieten Abstraktionen für das Iterator-Konzept, mit denen man im Code die Elemente einer Datenstruktur (etwa einer Liste oder eines Feldes) durchlaufen kann. Darauf aufbauend bringen die Plattformen Mengen- und Aggregationsfunktionen wie das Projizieren, Filtern und Summieren, das Berechnen des Durchschnitts sowie weitere Funktionen mit, die in der Praxis hilfreich sind.
Rust ist keine Ausnahme, im Gegenteil. Iteratoren spielen in der Sprache eine große Rolle. Die zugrundeliegenden Schnittstellen und viele auf ihnen aufbauende Funktionen sind von Haus aus eingebaut. Zusatzbibliotheken wie Itertools fügen weitere hinzu.
Mit Version 1.53 hat Rust diesen Sommer eine wesentliche Erweiterung in Sachen Iteratoren über Felder erhalten: Der IntoIterator
-Trait wurde für Felder mit konstanter Größe vollständig implementiert. Damit ziehen Iteratoren für Felder nach und bieten die gleichen Funktionen wie Iteratoren über andere Datentypen wie Vektoren. Damit kann man endlich mit einer for
-Schleife über ein Array iterieren, und zwar by Value, nicht nur by Reference. Folgendes Listing zeigt, was seit Rust 1.53 möglich ist:
fn main() {
// This worked already in Rust < 1.53
for &number in &[1, 1, 2, 3, 5, 8, 13] {
println!("The next value is {}", number);
}
// This works in Rust >= 1.53
for number in [1, 1, 2, 3, 5, 8, 13] {
println!("The next value is {}", number);
}
}
Die wichtigsten Iterator Traits
Die wichtigsten beiden Abstraktionen von Rust, die Iteratoren zugrunde liegen, sind die Traits std::iter::IntoIterator
(https://doc.rust-lang.org/std/iter/trait.IntoIterator.html) und std::iter::Iterator
(https://doc.rust-lang.org/std/iter/trait.Iterator.html). Der Der IntoIterator
-Trait legt fest, wie beispielsweise ein Vektor in einen Iterator
umgewandelt wird und sieht so aus:
pub trait IntoIterator {
type Item;
type IntoIter: Iterator;
fn into_iter(self) -> Self::IntoIter;
}
Der Trait enthält eine Methode into_iter
, die für das Erzeugen des jeweiligen Iterators zuständig ist (siehe InterIter
im Trait). Der Iterator verwaltet Elemente vom Typ Item
.
Jeder Typ, der den Trait IntoIterator
implementiert, lässt sich in Schleifen verwenden. Folgendes Beispiel steht im Rust-Playground zum Ausprobieren bereit:
fn main() {
let numbers = vec![1, 1, 2, 3, 5, 8, 13];
// std::vec::Vec implements IntoIterator,
// therefore we can use it in a for loop.
for number in numbers {
println!("The next value is {}", number);
}
}
Folgender Trait Iterator
repräsentiert den eigentlichen Iterator:
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
//...
}
Das Herzstück des Iterator
-Traits ist die Methode next
. Sie rückt den Iterator zum nächsten Element vor und gibt dieses zurück. Wenn man am Ende der Iteration angelangt ist, ist das Ergebnis None
. Vor dem ersten Aufruf steht der Iterator gedanklich vor dem ersten Element. Erst durch den Aufruf bewegt man sich zum ersten Element. Dabei ist zu bedenken, dass Iteratoren "faul" (lazy) sind. Das bedeutet, dass das Anlegen des Iterators an sich nichts bewirkt. Erst der erste next
-Aufruf aktiviert den Iterator.
Die drei Auslassungspunkte im Trait Iterator
deuten an, dass der Trait viele weitere Funktionen zum Arbeiten mit dem Iterator hat, die man in der täglichen Entwicklungsarbeit auch ständig braucht (unter anderem filter
, map
, min
, max
). Sie basieren jedoch alle auf next
und kommen mit einer fertigen Implementierung. Bei der Entwicklung eines eigenen Iterators reicht es, next
zu implementieren. Alle weiteren Methoden bekommt man quasi geschenkt, da im Gegensatz zu Interfaces in vielen anderen Sprachen die Traits in Rust Implementierungen für Funktionen mitbringen können. Genau diese Tatsache nutzt Iterator
.
Empfohlener redaktioneller Inhalt
Mit Ihrer Zustimmmung wird hier ein externes YouTube-Video (Google Ireland Limited) geladen.
Ich bin damit einverstanden, dass mir externe Inhalte angezeigt werden. Damit können personenbezogene Daten an Drittplattformen (Google Ireland Limited) übermittelt werden. Mehr dazu in unserer Datenschutzerklärung.
Consuming Iterators
Ein weiterer Punkt, der Quereinsteiger in Rust erstaunt, ist, dass into_iter
die Kontrolle über die zugrundeliegende Datenstruktur übernimmt – in Rust wird das als Ownership bezeichnet. Wenn man beispielsweise einen Vektor von Zahlen anlegt und into_iter
darauf aufruft, kann man den Vektor danach nicht mehr verwenden. Der Iterator übernimmt die Ownership über den Speicherbereich – das heißt, die variable numbers
können nicht mehr verwendet werden. Ein Beispiel dafür:
let numbers = vec![1,2,3];
let other_numbers = numbers;
Auch hier kann man danach numbers
nicht mehr verwenden, weil other_numbers
nun die Ownership über den Speicherbereich hat. Bei into_iter()
ist das Ergebnis ähnlich. Der Grund dafür sind die Ownership-Regeln von Rust. Lösen lässt sich das Problem durch den Einsatz einer Borrowed Reference, wie das folgende Beispiel zeigt. Es lässt sich im Rust-Playground ausprobieren:
fn main() {
let numbers = vec![1, 1, 2, 3, 5, 8, 13];
println!("The min is {}", numbers.into_iter().min().unwrap());
// The following line does NOT work because into_iter
// CONSUMES the vector (i.e. takes ownership of it). Once
// you call into_iter, you cannot access the underlying vector
// anymore.
println!("The sum is {}", numbers.into_iter().sum::<i32>());
// The following example DOES work because numbers is a
// borrowed reference to the vector, not the vector itself.
let numbers = &vec![1, 1, 2, 3, 5, 8, 13];
println!("The min is {}", numbers.into_iter().min().unwrap());
println!("The sum is {}", numbers.into_iter().sum::<i32>());
}
In diesem Zusammenhang sind die Funktionen iter()
und iter_mut()
erwähnenswert. Wer anstelle von into_iter()
eine dieser beiden Funktionen verwendet, bekommt spezielle Formen von Iteratoren:
iter()
gibt einen Iterator zurück, mit dem sich by Reference iterieren lässt (&T
).iter_mut()
gibt einen Iterator zurück, mit dem beim Iterieren veränderbare Referenzen (mutable Reference) erhalten bleiben (&mut T
).
Diese beiden Regeln sind nur Konventionen, sie sind nicht über Traits garantiert.
Im Gegensatz zu iter()
und iter_mut()
versucht der Rust-Compiler beim Einsatz von into_iter()
aus dem Kontext zu ermitteln, welche Form von Iterator notwendig ist. Das folgende Beispiel zeigt die verschiedenen Funktionen zum Erzeugen von Rust-Iteratoren:
struct Point {
x: f64,
y: f64,
}
fn main() {
// Iterate over items in a vector by value.
let points = vec![Point{x: 1.0, y: 1.0}, Point{x: 2.0, y: 2.0}];
let _first_point: Point = points.into_iter().next().unwrap();
// Iterate over items in a vector by reference
let points = vec![Point{x: 1.0, y: 1.0}, Point{x: 2.0, y: 2.0}];
let mut iter = points.iter(); // points.iter() is equivalent
// to (&points).into_iter()
let _first_point: &Point = iter.next().unwrap();
// Iterate over items in a vector by mutable reference
let mut points = vec![Point{x: 1.0, y: 1.0}, Point{x: 2.0, y: 2.0}];
let mut iter = points.iter_mut(); // points.iter_mut() is
// equivalent to (&mut points).into_iter()
let first_point: &mut Point = iter.next().unwrap();
first_point.x = 3.0; // As we have a mutable reference, we can change the content
first_point.y = 4.0;
}
Zero-Cost Abstractions
Die strikten Regeln für Speicherverwaltung in Rust, die bereits der Compiler erzwingt, sorgen dafür, dass der erzeugte Code sehr effizient ist. Dass Rust ohne Garbage Collector auskommt, wirkt sich positiv auf die Performance des Maschinencodes aus, den der Rust-Compiler erstellt. Man "bezahlt" Abstraktionen wie Iteratoren also nicht durch Geschwindigkeitsverlust (Zero-Cost Abstractions). Ich möchte das an einem einfachen Beispiel erklären:
fn main() {
let mut sum = 0;
for i in 0..10 {
sum += i;
}
println!("{}", sum);
}
Das Beispiel verwendet einen Iterator über die Zahlen Null bis Neun in einer Schleife. Interessant ist ein Blick auf den erzeugten Assemblercode. Denkbar wäre, dass im Hintergrund into_iter
aufgerufen wird und die Schleife das Ergebnis Schritt für Schritt durch den Aufruf von next
erzeugt. Tatsächlich ist das der Fall, wenn man den Code mit der Debug-Option kompiliert. Beim Umschalten auf den Relase-Modus hingegen bleibt vom Iterator nichts übrig. Tatsächlich erkennt der Compiler, dass das Ergebnis der Schleife schon zur Übersetzungszeit ermittelbar ist. Daher kann er die Schleife inklusive Iterator entfernen und das Ergebnis als Konstante in den Code einbauen.
Auch wenn das Beispiel extrem ist, verdeutlicht es, dass man beim Kompilieren der Release-Version nicht annehmen darf, dass die Iteratoren im Assembler-Code analog zum Rust-Code wiederzufinden sind.
Heise richtet am 13. Oktober 2021 eine Online-Konferenz zu Rust für Einsteiger und Experten aus. Die beiden Ferris-Talk-Kolumnisten sind dort mit Vorträgen und Workshops präsent:
Rainer Stropek erklärt das Speichermanagement in Rust und erläutert die Rust-Konzepte Ownership, References, Borrowing und Lifetimes. Er zeigt die Konzepte anhand praktischer Code-Beispiele. Wer teilnimmt, erlangt ein Problembewusstsein der Sicherheitsrelevanz von Speicherverwaltung, baut Verständnis auf für die Rust-Kernkonzepte und erkennt Unterschiede zwischen Rust und anderen gängigen Programmiersprachen.
Stefan Baumgartner widmet sich Serverless Rust und führt vor, wie man Serverless-Workloads mit Rust ausführt. Wer teilnimmt, gewinnt ein tieferes Verständnis von AWS Lambda und Azure Functions, lernt die passenden Rust-Crates für Serverless-Workloads und begreift den unmittelbaren Nutzen von Rust dafür.
Flankierende Workshops von Stropek & Baumgartner (9-17 Uhr)
- Rust 101 – Einführung in die Programmiersprache Rust, 20. Oktober 2021
- Wasm-Module für den Browser mit Rust, 27. Oktober 2021
- Netzwerk-Applikationen mit dem Tokio Stack, 28. Oktober 2021
Eigene Iteratoren
Um ein tieferes Verständnis für Iteratoren in Rust zu erhalten, entwickeln wir einen eigenen Iterator. Genau genommen handelt es sich um eine spezielle Form eines Iterators: einen Generator. Generatoren haben keine zugrundeliegende Datenstruktur, aus der sie Elemente bei der Iteration zurückgeben. Sie generieren den nächsten Wert erst, wenn sie mit next
danach gefragt werden.
Das Beispiel implementiert einen einfachen Passwortgenerator. Es ist nicht das Ziel, kryptografisch starke Passwörter zu generieren, sondern das Prinzip der Rust-Iteratoren zu verdeutlichen. Das Augenmerk liegt im folgenden Codebeispiel auf der Implementierung von IntoIterator
für die Struktur Passwords
sowie auf der Implementierung von Iterator
für PasswordsIterator
.
use rand::Rng;
struct Passwords {
length: usize,
}
impl Passwords {
fn new() -> Self {
Self::with_length(10)
}
fn with_length(length: usize) -> Self {
Passwords { length: length }
}
}
impl IntoIterator for Passwords {
type Item = String;
type IntoIter = PasswordsIterator;
fn into_iter(self) -> Self::IntoIter {
PasswordsIterator {
length: self.length,
}
}
}
struct PasswordsIterator {
length: usize,
}
impl Iterator for PasswordsIterator {
type Item = String;
fn next(&mut self) -> Option<Self::Item> {
let mut result = String::with_capacity(self.length);
for _ in 0..self.length {
// For demo purposes, we generate a password
// consisting only of letters a..z
result.push((b'a' + rand::thread_rng().gen_range(0..=(b'z' - b'a'))) as char);
}
Some(result)
}
}
fn main() {
// Generate three passwords and print them in a for loop
for p in Passwords::new().into_iter().take(3) {
println!("The next password is {}", p);
}
// Generate three passwords and print them with for_each
Passwords::with_length(5)
.into_iter()
.take(3)
.for_each(|p| println!("The next password is {}", p));
}
Warum erst jetzt?
Zum Abschluss wollen wir der Frage nachgehen, warum die Implementierung von IntoIterator
für Felder so lange gedauert hat.
In früheren Rust-Versionen war IntoIterator
nur für Referenzen auf Felder implementiert, daher konnte man nur by Reference über Felder iterieren. Der Grund dafür war, dass Arrays in Rust immer eine fixe Länge haben und die Länge Teil der Typdefinition ist, beispielsweise let numbers: [i32; 7]
für ein Feld mit sieben Elementen vom Typ i32
. Für eine generische Implementierung von IntoIterator
für Felder ist es daher notwendig, die konstante Länge als Parameter für den generischen Typ übergeben zu können (wie impl<T, const N: usize> IntoIterator for [T; N] {...}
). Diese Sprachfunktion wird als Const Generics bezeichnet und kam erst Anfang 2021 mit Rust 1.51.
Auf Basis der Const Generics-Funktion hätte man sofort IntoIterator
für Felder implementieren können. Jedoch konnten Entwicklerinnen und Entwickler schon früher array.into_iter()
aufrufen, was der Compiler aber implizit als (&array).into_iter()
umgesetzt hat. Sie bekamen somit einen by Reference-Iterator. Das simple Hinzufügen von IntoIterator
für Felder hätte also bestehenden Code gebrochen; und das ist nur bei Major Releases von Rust erlaubt.
Mit Rust 1.53 hat das Rust-Team zu einem Trick gegriffen: Wenn bestehender Code explizit array.into_iter()
aufruft, wird daraus weiterhin (&array).into_iter()
, allerdings mit einer Warnung, dass der Code in späteren Rust-Versionen nicht mehr funktionieren. Wenn array.into_iter()
implizit aufgerufen wird (wie in einer for
-Schleife), wird die neue by Value-Implementierung von IntoIterator
für Felder verwendet. Dass sich der into_iter()
-Aufruf für Felder anders verhält als der bei anderen Datenstrukturen (unter anderem Vektoren), ist nicht schön, bietet aber endlich die Möglichkeit, by Value über Felder zu iterieren und mit der for
-Schleife Felder zu durchlaufen.
Das folgende Codebeispiel demonstriert das Vorgehen. Zum besseren Verständnis sind die Datentypen der Variablen explizit angegeben sind, auch wenn Rust diese Typen implizit ermitteln könnte.
use std::{vec::IntoIter};
fn main() {
// Iterate over vector by value
let numbers: Vec<i32> = vec![1, 1, 2, 3, 5, 8, 13];
let mut num_iter: IntoIter<i32> = numbers.into_iter();
let item: i32 = num_iter.next().unwrap();
println!("{}", item);
// Iterate over vector by reference
let numbers: &Vec<i32> = &vec![1, 1, 2, 3, 5, 8, 13];
let mut num_iter: std::slice::Iter<i32> = numbers.into_iter();
let item: &i32 = num_iter.next().unwrap();
println!("{}", item);
// In contrast to vector, iterating over an array means
// iterating BY REFERENCE, not by value. Note that this
// code leads to a warning because this behavior will
// change in future Rust versions.
let numbers: [i32; 7] = [1, 1, 2, 3, 5, 8, 13];
let mut num_iter: std::slice::Iter<i32> = numbers.into_iter();
let item: &i32 = num_iter.next().unwrap();
println!("{}", item);
// Since Rust 1.53, we can explicitly call IntoIterator::into_iter
// as it is now implemented for arrays. It will lead to an iteration
// by value, NO LONGER by reference.
let numbers: [i32; 7] = [1, 1, 2, 3, 5, 8, 13];
let mut num_iter: std::array::IntoIter<i32, 7> = IntoIterator::into_iter(numbers);
let item: i32 = num_iter.next().unwrap();
println!("{}", item);
// As IntoIterator has been implemented in Rust 1.53, the following
// syntax is now possible. It iterates over the given array BY VALUE
// using a for loop.
for i in [1, 1, 2, 3, 5] {
println!("{}", i)
}
}
Fazit und Zusammenfassung
Iteratoren sind ein mächtiges Werkzeug in Rust, die sich nicht maßgeblich auf die Performance auswirken. Der Rust-Compiler optimiert Code mit Iteratoren, und der erzeugte Maschinencode ist performant.
In diesem Jahr wurden mit den Rust-Versionen 1.51 und 1.53 wichtige Verbesserungen eingeführt, die unangenehme Lücken im Umgang mit Iteratoren über Felder geschlossen haben.
Rainer Stropek, Autor von Ferris Talk #1
ist seit über 25 Jahren als Unternehmer in der IT-Industrie tätig.
Er gründete und führte in dieser Zeit mehrere IT-Dienstleistungsunternehmen und entwickelt neben seiner Tätigkeit als Trainer und Berater in seiner Firma software architects mit seinem Team die preisgekrönte Software time cockpit.
Rainer hat Abschlüsse der höheren technischen Schule für Informatik Leonding (AT) sowie der University of Derby (UK). Er ist Autor mehrerer Fachbücher und Artikel in Magazinen im Umfeld von Microsoft .NET und C#, Azure, Go und Rust. Seine technischen Schwerpunkte sind Cloud Computing, die Entwicklung verteilter Systeme sowie Datenbanksysteme.
Rainer tritt regelmäßig als Speaker und Trainer auf namhaften Konferenzen in Europa und den USA auf. 2010 wurde Rainer von Microsoft zu einem der ersten MVPs (Most Valuable Professionals) für die Azure-Plattform ernannt. Seit 2015 ist er Microsoft Regional Director. 2016 hat er zudem den MVP Award für Visual Studio und Developer Technologies erhalten.
Stefan Baumgartner
lebt und arbeitet als Software-Architekt und Entwickler im österreichischen Linz mit Schwerpunkt auf Webentwicklung, Serverless und Cloud-basierte Architekturen.
Für den Smashing Magazine Verlag veröffentlichte er 2020 mit “TypeScript in 50 Lessons” sein zweites Buch, seine Onlinepräsenz fettblog.eu enthält Artikel, Tutorials und Guides zum Thema TypeScript, Rust, React, und Software Engineering im Allgemeinen.
Stefan organisiert Meetups und Konferenzen, wie Stahlstadt.js, die DevOne, ScriptConf, Rust Meetup Linz, und das legendäre Technologieplauscherl. Außerdem ist er regelmäßig Gastgeber im Working Draft, dem deutschsprachigen Podcast über Webtechnologien.
(sih)