Programmieren in Rust

Grundbegriffe

Inhaltsverzeichnis

  1. Das erste Programm
  2. Ausdrücke
    1. Arithmetik
    2. Variablen
    3. Typen
    4. Blöcke
    5. Überschattung
  3. Kontrollfluss
    1. Funktionen
    2. Verzweigungen
    3. Schleifen
  4. Felder
    1. Felder fester Länge
    2. Ausschnitte von Feldern
    3. Dynamische Felder
    4. Alleinige Referenzen auf ein Feld
    5. Nochmals Ausschnitte
    6. Stapelspeicher
  5. Eingaben

Das erste Programm

Es ist üblich, als erstes ein Programm zu schreiben, welches nichts weiter tut als »Hallo Welt!« auf dem Terminal auszugeben. Es folgt das obligatorische Hallo-Welt-Programm:

fn main() {
    println!("Hallo Welt!");
}

Wie aus diesem Quelltext ein ausführbares Programm gemacht wird, steht in ›Compiler und Erstellungswerkzeuge‹ erläutert.

Der Befehl println (print line) sorgt für die Ausgabe der Zeichenkette in den Anführungszeichen.

Das Schlüsselwort fn steht für function und leitet die Definition einer Funktion ein. In Rust versteht man unter einer Funktion jegliche Art von Prozedur (Unterprogramm), – das betrifft auch solche Prozeduren, die keine Funktionen im mathematischen Sinn darstellen. Der Bezeichner main deutet an, dass die Hauptprozedur des Programms definiert wird. Jedes ausführbare Programm muss diese Hauptprozedur enthalten. Das kürzeste ausführbare Programm ist demnach:

fn main() {}

Ausdrücke

Arithmetik

Das Wesen von Computern liegt in gewisser Hinsicht in ihrer Fähigkeit, Berechnungen schnell ausführen zu können. Aus diesem Grund erscheint es erwartungsgemäß, wenn die Programmiersprache in bequemer Weise die Angabe einer Berechnung ermöglicht. Arithmetische Ausdrücke schreibt man wie gewohnt. Das Programm »Gib den Wert des Ausdrucks 1+2 aus« geht so:

fn main() {
    println!("{}", 1 + 2);
}

Der Befehl println nimmt hier die Zeichenkette "{}" als Schablone und ersetzt das geschweifte Klammerpaar gegen den Wert des Ausdrucks. Die resultierende Zeichenkette wird dann ausgegeben, woraufhin ein Zeilenumbruch folgt.

Zu den Grundrechenarten liegen Operatoren vor:

Operation Erklärung
x+y Addition
x-y Subtraktion
x*y Multiplikation
x/y Division
x%y Rest-Bildung
-x Negation

Die Division von ganzen Zahlen hat ein ganzzahliges Ergebnis. Ist der Zähler nichtnegativ und der Nenner positiv, ist der Quotient der abgerundete Wert. Das folgende Programm zeigt eine Division mit Rest.

fn main() {
    println!("{}/{} = {} Rest {}", 17, 5, 17/5, 17%5);
}

Die Ausgabe:

17/5 = 3 Rest 2

Variablen

Im Programm zur Division mit Rest wurden mehrmals die gleichen Zahlen wiederholt. Wollte man den Nenner oder den Zähler ändern, müsste man dies an mehreren Stellen tun. Es wäre doch vorteilhaft, wenn wir einen Wert an eine Variable binden könnten, womit man den Wert nur noch an einer Stelle ändern bräuchte.

Das Schlüsselwort let leitet eine solche Bindung einer Variablen an einen Wert ein. Wir dürfen schreiben:

fn main() {
    let x = 17;
    let y = 5;
    println!("{}/{} = {} Rest {}", x, y, x/y, x%y);
}

Die Bindungen sind auch in der kompakten Form

let (x, y) = (17, 5);

möglich. Man darf dies als ein Abgleich von Tupeln verstehen. In der Mathematik ist definiert:

(x, y) = (a, b) gelte genau dann, wenn x = a und y = b.

Typen

In der Sprache Rust besitzt jeder Wert und jede Variable einen Datentyp, kurz Typ. Um auszudrücken dass die Variable v vom Typ T ist, schreibt man v: T. Beispielsweise sagt x: i32 aus, dass x eine Ganzzahl sein soll, welche binär in einer 32 Bit langen Speicherzelle gespeichert werden kann. Alle Berechnungen müssen den Regeln des Typsystems genügen. So ist es nicht erlaubt, Zahlen mit Farben zu verrechnen. Dies ergibt keinen Sinn, und der Compiler verweigert sich auch, ein solches Programm zu übersetzen.

Der Typ einer Variablen muss nicht unbedingt angegeben sein, sofern sich der Compiler in der Lage sieht, diesen Typ aus dem Zusammenhang abzuleiten. Der Vorgang des Ableitens wird als Typinferenz bezeichnet, und davon wurde im Programm zur Division mit Rest auch bereits Gebrauch gemacht. Vollständig typisiert schaut das Programm so aus:

fn main() {
    let x: i32 = 17;
    let y: i32 = 5;
    println!("{}/{} = {} Rest {}", x, y, x/y, x%y);
}

Typen werden in Rust eigentlich mit einem großen Anfangsbuchstaben geschrieben. Bei den primitiven Datentypen wurde da jedoch eine Ausnahme gemacht. Von diesen primitiven Datentypen gibt es allerdings nur eine Hand voll. Sie sind in der folgenden Tabelle aufgeführt.

Typ Englisch Deutsch
u8, u16, u32, u64, u128 unsigned integer of size 8, 16, 32, 64, 128 bit vorzeichenlose Ganzzahl der Größe 8, 16, 64, 128 Bit
i8, i16, i32, i64, i128 integer of size 8, 16, 32, 64, 128 bit Ganzzahl der Größe 8, 16, 32, 64, 128 Bit
usize unsigned integer of address size (32 bit or 64 bit) vorzeichenlose Ganzzahl der Adressgröße (32 Bit oder 64 Bit)
isize integer of address size (32 bit or 64 bit) Ganzzahl der Adressgröße (32 Bit oder 64 Bit)
f32, f64 floating point number of size 32 bit, 64 bit Fließkommazahl der Größe 32 Bit, 64 Bit
bool boolean, i.e. logical values boolesch, d. h. logische Werte
str string Zeichenkette

Blöcke

Überall dort wo ein Ausdruck oder eine Anweisung stehen darf, darf auch ein von geschweiften Klammern umfasster Block vorkommen. Ein Block schränkt das Vorhandensein von lokal definierten Variablen auf einen kleineren Bereich ein. Formal gesprochen ist mit dem Block ein lexikalischer Bereich, engl. lexical scope, verbunden.

Betrachten wir folgendes Programm:

fn main() {
    {
        let x = 1;
        println!("{}", x);
    }
    println!("{}", x);
}

Dieses Programm ist nicht korrekt, denn außerhalb des Blocks kann auf die Variable x nicht mehr zugegriffen werden.

Die Sprache Rust ist, wie die Sprachen der ML-Familie, eine ausdrucksorientierte. So stellt bestimmte Syntax einen Ausdruck dar, die in anderen Sprachen lediglich eine Anweisung bildet. Dies trifft auch auf Blöcke zu.

Nun ist dem Wert eines Ausdrucks aber immer ein Typ zugeordnet. Welchen Typ besitzt denn ein Block dort, wo kein Wert erwartet wird? Dies wurde auf mathematisch besonders reine Art gelöst, indem ein wertloser Ausdruck den Einheitstyp, engl. unit type, bekommt. In Rust ist dieser Typ als leeres Tupel () kodiert. Dieser Typ enthält einen einzigen informationslosen Wert, den leeren Tupelwert ().

Beispielsweise erzeugt das Programm

fn main() {
    println!("{:?}", {
        println!("Die Ordnung");
        println!("der Dinge");
    });
}

die Ausgabe:

Die Ordnung
der Dinge
()

Einen Wert besitzt ein Block dann, wenn der letzte Teil des Blocks ein Ausdruck ist. Der Wert des Blocks ist dann erwartungsgemäß der Wert dieses letzten Ausdrucks. Zu beachten ist hierbei, den letzten Ausdruck nicht mit einem Semikolon abzuschließen.

Betrachten wir dazu die Auswertung eines Polynoms vierten Grades:

let (a0, a1, a2, a3, a4) = (5, 4, 3, 2, 1);
let x = 10;
let y = a0 + a1*x + a2*x*x + a3*x*x*x + a4*x*x*x*x;
println!("{}", y);

In der Berechnung stecken vier Additionen und zehn Multiplikationen drin. In kritischen Situationen kostet jede Berechnung Zeit und Energie. Es wäre daher schön, wenn wir irgendwie Operationen einsparen könnten.

Einführung von Hilfsvariablen reduziert die Anzahl der Multiplikationen auf sieben:

let (a0, a1, a2, a3, a4) = (5, 4, 3, 2, 1);
let x = 10;
let y = {
    let x2 = x*x;
    let x3 = x2*x;
    let x4 = x2*x2;
    a0 + a1*x + a2*x2 + a3*x3 + a4*x4
};
println!("{}", y);

Weil die Hilfsvariablen in einem Block definiert wurden, sind sie auf diesen beschränkt. Außerhalb des Blocks sind die Variablen nicht mehr sichtbar.

Manchmal gewährt die Mathematik allerdings noch wesentlich umfangreichere Einsparungen. Die Überführung ins Horner-Schema reduziert hier die Anzahl der Multiplikationen auf lediglich vier:

let (a0, a1, a2, a3, a4) = (5, 4, 3, 2, 1);
let x = 10;
let y = a0 + x*(a1 + x*(a2 + x*(a3 + a4*x)));
println!("{}", y);

Überschattung

Befindet sich innerhalb eines Blocks die Definition einer Variable, die denselben Namen trägt wie eine bereits außerhalb des Blocks vorkommende Variable, dann überschattet die innere die äußere Variable. Die Überschattung bleibt auf den Block beschränkt.

Probieren wir es aus. Das Programm

let x = 1;
{
    let x = 2;
    println!("{}", x);
}
println!("{}", x);

liefert die Ausgabe:

2
1

Überschattung kann auch ohne explizite Blöcke stattfinden. Man muss das so verstehen, dass dabei implizit ein innerer Block gebildet wird, der ganz bis zum Ende des äußeren Blocks verläuft. Das heißt, man kann das Programm

fn main() {
    let x = 1;
    println!("{}", x);
    let x = 2;
    println!("{}", x);
}

als

fn main() {
    let x = 1;
    println!("{}", x);
    {
        let x = 2;
        println!("{}", x);
    }
}

lesen.

Kontrollfluss

Funktionen

Trifft der Kontrollfluss auf den Aufruf eines Unterprogramms, dann kommt es zu einem Sprung in das Unterprogramm, woraufhin der darin vorhandene Code ausgeführt wird. Mit einer Rücksprunganweisung return kehrt der Kontrollfluss später wieder an die Stelle des Aufrufs zurück. Wie bei einer mathematischen Funktion kann das Unterprogramm Argumente und einen Wert haben.

Das folgende Beispiel zeigt die Berechnung des Umfangs zu einem gegebenen Radius mittels der Funktion circumference.

use std::f64::consts::PI;

fn circumference(radius: f64) -> f64 {
    return 2.0*PI*radius;
}

fn main() {
    print!("{}", circumference(1.0));
}

Steht die Rücksprunganweisung ganz am Ende der Funktion, darf diese auch entfallen:

fn circumference(radius: f64) -> f64 {
    2.0*PI*radius
}

Verzweigungen

Bei vielen Prozeduren kommen im Kontrollfluss Verzweigungen vor. Das Programm ändert dabei sein Verhalten aufgrund des Wertes von Variablen. Das folgende Programm macht bspw. unterschiedliche Ausgaben, je nachdem welcher Wert für die Temperatur theta angegeben wird.

fn main() {
    let theta = 4.0;
    print!("Draußen ist es ");
    if theta < 0.0 {
        println!("eisig.");
    } else if theta < 10.0 {
        println!("kalt.");
    } else if theta < 16.0 {
        println!("mild.");
    } else {
        println!("warm.");
    }
}

Bei der if-Anweisung handelt es sich in Rust eigentlich um einen Ausdruck. Demnach lässt sich ein if-Ausdruck auch dort angeben, wo ein Ausdruck bzw. Term erwartet wird. Dies erlaubt eine etwas elegantere Formulierung des letzten Programms:

fn main() {
    let theta = 4.0;
    let text = if theta <  0.0 {"eisig"}
          else if theta < 10.0 {"kalt"}
          else if theta < 16.0 {"mild"}
          else {"warm"};
    print!("Draußen ist es {}.", text);
}

In der Mathematik kommen im Zusammenhang mit ganzen Zahlen oft rekursive Definitionen vor. Die rekursive Definition der Potenzierung ist:

x0 := 1,
xn := xn−1 ⋅ x.

Diese Definition lässt sich in Rust direkt als rekursive Funktion angeben:

fn pow(x: f64, n: u32) -> f64 {
    if n == 0 {1.0} else {pow(x, n-1)*x}
}

fn main() {
    print!("{}", pow(2.0, 10));
}

Für Fallunterscheidungen gibt es auch den match-Ausdruck. Dieser Ausdruck vergleicht einen Wert mit einer Liste von Mustern und wählt den Ausdruck zum ersten passenden Muster. Für die Potenzierung ergibt sich damit eine äquivalente Formulierung:

fn pow(x: f64, n: u32) -> f64 {
    match n {
        0 => 1.0,
        n => pow(x, n-1)*x
    }
}

Schleifen

While-Schleifen

Anstelle von rekursiver Programmierung ist in Rust eher die iterative Programmierung üblich, was unterschiedliche Gründe hat. Zum einen lassen sich iterative Programme leichter vom Compiler optimieren. Zum anderen ist aus Gründen der Effizienz die Größe des Aufrufstapels und damit die maximale Rekursionstiefe beschränkt. Man bräuchte Tail-call-Optimierungen, die aber wieder zu Einschränkungen beim Programmierstil führen. Das heißt nicht, dass rekursive Programmierung nicht in Rust vorkommt, jedoch ist sie meistens auf solche baumartige Strukturen und gerichtete Graphen beschränkt, welche nicht besonders tief sind und demnach nur geringe Rekursionstiefe benötigen.

Aus der rekursiven Definition der Potenzierung lässt sich ein iterativer Algorithmus zur Berechnung gewinnen. Unter Benutzung einer while-Schleife ist der Algorithmus von folgender Gestalt:

fn pow(x: f64, n: u32) -> f64 {
    let mut acc = 1.0;
    let mut i = 0;
    while i < n {
        acc = acc*x;
        i = i + 1;
    }
    acc
}

Die Variable i ist eine sogenannte Laufvariable. Die Laufvariable speichert hier lediglich die Anzahl der Iterationen. Die Anweisung i = i+1 bedeutet »berechne den Wert des Ausdrucks i+1 und überschreibe die Variable i mit diesem Wert«. Entsprechend ist die Anweisung acc = acc*x zu verstehen. Für beide Anweisungen gibt es auch eine Kurzschreibweise:

acc *= x; i += 1;

Die Variable acc nimmt die Stellung eines Akkumulators ein, da sie in der Schleife schrittweise Multiplikationen ansammelt. Algorithmen mit Akkumulatoren sind oft von einer besonderen Form, dergestalt dass sie sich im Rahmen der funktionalen Programmierung direkt als eine sogenannte Faltung beschreiben lassen. So ist

fn pow(x: f64, n: u32) -> f64 {
    (0..n).fold(1.0, |acc, _| acc*x)
}

zum obigen Algorithmus äquivalent. Eine genauere Erläuterung der Faltung kommt später im Kapitel über funktionale Programmierung.

For-Schleifen

Oft günstiger ist die Benutzung von for-Schleifen anstelle von while-Schleifen. Zur Verdeutlichung hier nochmals die Berechnung der Potenzierung:

fn pow(x: f64, n: u32) -> f64 {
    let mut acc = 1.0;
    for _ in 0..n {acc *= x;}
    acc
}

Hinter dem Schlüsselwort for steht eigentlich die Laufvariable, die hier entfallen darf, da in der Schleife kein Zugriff darauf vorkommt.

Im nächsten Programm kommen for-Schleifen zur Suche von pythagoreischen Tripeln zur Anwendung. Pythagoreische Tripel sind solche Tripel aus natürlichen Zahlen, zu denen es ein rechtwinkliges Dreieck mit diesen Zahlen als Seitenlängen gibt. Demnach sind natürliche Zahlen abc gesucht, die die im Satz des Pythagoras enthaltene Gleichung

a2 + b2 = c2

erfüllen. Das Programm findet alle Tripel, bei denen jede der Seitenlängen maximal 20 beträgt.

fn main() {
    let n = 20;
    for a in 1..=n {
        for b in 1..=n {
            for c in 1..=n {
                if a*a + b*b == c*c {
                    println!("{:2}, {:2}, {:2}", a, b, c);
                }
            }
        }
    }
}

Die Ausgabe:

 3,  4,  5
 4,  3,  5
 5, 12, 13
 6,  8, 10
 8,  6, 10
 8, 15, 17
 9, 12, 15
12,  5, 13
12,  9, 15
12, 16, 20
15,  8, 17
16, 12, 20

Das Programm ist ein einfaches Beispiel für eine erschöpfende Suche, auch Brute-Force-Methode genannt. Hierbei wird der gesamte Suchraum auf naive Weise durchsucht, ohne weitergehende mathematische Einsichten zur Anwendung zu bringen. Typisch für diese Methode ist die hohe Inanspruchnahme des Computers. So kann sich der Suchraum mit steigenden Parametern beträchtlich vergrößern. Der Aufwand kann dabei quadratisch, kubisch oder sogar exponentiell steigen. Irgendwann ist eine Grenze erreicht, wo der Aufwand die Rechenkapazitäten des Computers übersteigt, so dass die Berechnung nicht mehr in überschaubarer Zeit zum Ende kommt. Man spricht auch von einer sogenannten kombinatorischen Explosion.

Im obigen Programm steigt die Zahl der Prüfungen kubisch in Abhängigkeit der maximalen Seitenlänge. Sind es bei einer Seitenlänge von 20 noch 8000 Prüfungen, so sind es bei einer Seitenlänge von 100 schon 1 Mio. Prüfungen

Von den meisten mathematischen Funktionen weiß der Computer nichts. Ein paar wenige liegen als Schaltkreise in der Hardware vor, für alle anderen bedarf es eines Programms. Ein effizientes Verfahren zur Berechnung der Quadratwurzel einer Zahl a ist das Heron-Verfahren, das ist die Iteration xn+1 = φ(xn) mit Startwert x0=a/2 und Abbildung

φ(x) = (x + a/x)/2.

Zur iterativen Formulierung benutzen wir wieder einfach eine for-Schleife:

fn sqrt(a: f64) -> f64 {
    let mut x = 0.5*a;
    for _ in 0..20 {
        x = 0.5*(x + a/x);
    }
    x
}

fn main() {
    println!("{}", sqrt(2.0))
}

Das Programm sollte 1.414213562373095 ausgeben. Wie viele Iterationen benötigt man? Sofern die Zahlen nicht gigantisch groß oder winzig klein sind, reichen 20 Iterationen mit Sicherheit aus. Man kann durch Experimente herausfinden oder durch mathematische Analyse abschätzen, wie viele Iterationen für eine bestimmte Zahl a notwendig sind. Eine elegantere Formulierung verzichtet aber auf dieses Wissen und benutzt stattdessen eine Abbruchbedingung. Der Abbruch der Schleife wird durch den Befehl break ausgelöst:

fn sqrt(a: f64) -> f64 {
    let mut x = 0.5*a;
    for _ in 0..1000 {
        if (x*x - a).abs() < 1E-14 {break;}
        x = 0.5*(x + a/x);
    }
    x
}

Die folgende Modifikation des Programms gibt auch die Anzahl der benötigten Iterationen an. Dazu gibt die Funktion ein aus Wert und Anzahl bestehendes Paar zurück:

fn sqrt_count(a: f64) -> (f64, u32) {
    let mut x = 0.5*a;
    for k in 0..1000 {
        if (x*x - a).abs() < 1E-14 {return (x, k);}
        x = 0.5*(x + a/x);
    }
    (x, 1000)
}

fn main() {
    let (x, n) = sqrt_count(2.0);
    println!("Wert: {};", x);
    println!("Iterationen: {}", n); 
}

Felder

Felder fester Länge

Ein Feld, auch Array genannt, ist eine zusammenhängende Folge von Elementen desselben Datentyps. Die Elemente werden dabei in Speicherzellen gespeichert, welche auch zusammenhängend im Speicher liegen. Jedes Feld hat eine Länge, das ist die Anzahl der Elemente. Betrachten wir ein einfaches Programm, welches ein Feld mit vier Ganzzahlen ausgibt:

fn main() {
    let a: [i32; 4] = [1, 2, 3, 4];
    println!("{:?}", a);
}

Hier liegt ein Feld a vom Typ [i32; 4] vor, also vier Speicherzellen mit jeweils einer Zahl vom Typ i32. Jedes Element lässt sich über eine Indizierung a[i] ansprechen. Der Index i startet immer ab null. Im genannten Beispiel gilt daher

a[0]==1, a[1]==2, a[2]==3, a[3]==4.

Nun wird eine Funktion gezeigt, welche ein Feld nimmt und die Summe der Elemente zurückgibt. Das lässt sich wie folgt bewerkstelligen:

fn sum(a: &[i32; 4]) -> i32 {
    let mut acc = 0;
    for x in a {acc += x;}
    acc
}

fn main() {
    let a: [i32; 4] = [1, 2, 3, 4];
    println!("{}", sum(&a));
}

Das Feld braucht für diese Aufgabe lediglich ausgeborgt werden, wofür eine Referenz auf das Feld genügt. Aus disem Grund befindet sich in der Typsignatur des Arguments ein Et-Zeichen (&). Das Feld selbst besitzt den Typ [i32; 4]. Der Typ &[i32; 4] beschreibt eine Leihgabe des Feldes, engl. borrow. Unter einer Leihgabe versteht man einen Zeiger auf das Feld, der durch das Typsystem um eine sogenannte Lebenszeit angereichert ist.

Ein Zeiger enthält lediglich eine darüber Auskunft gebende Adresse, wo sich das Feld im Speicher befindet. Der Sinn dahinter besteht darin, dass ein Unterprogramm auf diese Weise effizient auf das Feld zugreifen kann. Andernfalls müsste das Feld in den Speicher des Unterprogramms verschoben oder kopiert werden. Je nach Größe des Feldes bedeutet das einen erhöhten bis außerordentlichen Aufwand.

Das Konzept der Lebenszeiten gewährleistet bereits zur Kompilierzeit, dass für Zeiger kritische Probleme wie Use after free niemals auftreten. Bei Verzicht auf Lebenszeiten ergibt sich ein einfacher Zeiger, dessen Dereferenzierung eine unsichere Operation darstellt. Beispielsweise nimmt sum bei Benutzung eines einfachen Zeigers die Form

fn sum(a: *const [i32; 4]) -> i32 {
    let mut acc = 0;
    for x in unsafe{*a}.iter() {acc += x;}
    acc
}

an.

Ausschnitte von Feldern

Dem Leser mag nun auffallen, dass die Funktion sum lediglich Felder der Länge vier verarbeiten kann. Zur Lösung dieses Problems existiert ein eigener Datentyp, der Slice eines Feldes, geschrieben &[i32]. Die Signatur der Funktion sum braucht lediglich auf a: &[i32] abgeändert werden, und schon lassen sich Summen von Feldern beliebiger Länge bilden.

Ein Slice besteht intern aus zwei Bestandteilen: erstens einem Zeiger auf den Anfang des Feldes, und zweitens der Länge des Feldes. Slices können auch zusammenhängende Teilbereiche eines Feldes beschreiben. Wenn nur die ersten drei Zahlen des Feldes aufsummiert werden sollen, sieht das Programm wie folgt aus:

fn main() {
    let a: [i32; 4] = [1, 2, 3, 4];
    println!("{}", sum(&a[0..3]));
}

Der Bereich 0..3 enthält die Indizes 012. Daher beschreibt der Slice &a[0..3] eine Referenz auf [a[0], a[1], a[2]].

Dynamische Felder

Felder vom Typ [T; N] besitzen immer eine feste, bereits zur Kompilierzeit bekannte Länge N. Da diese Einschränkung für viele Problemstellungen zu hinderlich ist, gibt es noch den Datentyp Vec<T> zur Beschreibung von Feldern, die ihre Länge automatisch vergrößern. Dynamische Felder werden ganz analog zu den Feldern fester Länge gehandhabt:

fn main() {
    let a: Vec<i32> = vec![1, 2, 3, 4];
    println!("{}", sum(&a));
}

Der Aufruf a.push(x) fügt ein Element x zum Ende von a hinzu. Es folgt eine Funktion reverse, die ein Feld a umdreht, indem sie rückwärts durch das Feld iteriert und dabei die Elemente zu einem neuen Feld acc hinzufügt.

fn reverse(a: &[i32]) -> Vec<i32> {
    let mut acc: Vec<i32> = vec![];
    for x in a.iter().rev() {
        acc.push(*x);
    }
    acc
}

fn main() {
    let a: Vec<i32> = vec![1, 2, 3, 4];
    println!("{:?}", reverse(&a));
}

Alleinige Referenzen auf ein Feld

Zum Umdrehen eines Feldes muss man eigentlich kein neues erzeugen. Das Feld lässt sich auch selbst umdrehen; man nennt diese Vorgehensweise in place. Der folgende Algorithmus vertauscht das erste Element mit dem letzten, das zweite mit dem vorletzten usw. bis das Verfahren in der Mitte des Feldes angekommen ist.

fn reverse(a: &mut [i32]) {
    let n = a.len();
    for i in 0..n/2 {
        a.swap(i, n-1-i);
    }
}

fn main() {
    let mut a: Vec<i32> = vec![1, 2, 3, 4];
    reverse(&mut a);
    println!("{:?}", a);
}

Da dem Feld selbst Veränderung widerfährt, ist hier eine alleinige Referenz (unique reference) auf den Gesamtausschnitt des Feldes notwendig. Eine ausführliche Erläuterung, warum das so ist, folgt später. Zunächst genügt es zu wissen, dass diese Art von Referenz die Signatur &mut anstelle von & besitzt. Das mut (mutable) weist daraufhin, dass die Daten, auf die die Referenz zeigt, verändert werden dürfen.

Nochmals Ausschnitte von Feldern

Ausschnitte bestehen nur aus Zeiger und Länge. Intuitiv lässt sich von einem Ausschnitt ein Teilausschnitt bilden und von diesem wieder ein Teilausschnitt usw. Solange bis man den leeren Ausschnitt erhält. Ein Beispiel dazu ist diese Implementation der Summierung:

fn sum(a: &[i32]) -> i32 {
    if a.is_empty() {0} else {a[0] + sum(&a[1..])}
}

Eine solche rekursive Formulierung ist zwar mathematisch elegant, in der Praxis jedoch leider ineffizient.

Der Ausschnitt &a[i..] ist eine Abkürzung für &a[i..a.len()]. Außerdem gibt es noch &a[..j], das ist &a[0..j]. Demnach hat &a[..] die Bedeutung &a[0..a.len()].

Ausschnitte sind recht praktisch, weshalb für diese eine ganze Reihe von Methoden definiert sind. Beispielsweise ist eine Methode zur Sortierung eines veränderbaren Ausschnittes vorhanden:

fn main() {
    let mut a: Vec<i32> = vec![4, 2, 1, 3];
    a[..].sort();
    println!("{:?}", a);
}

Stapelspeicher

Ein wichtiges Hilfsmittel in der Programmierung sind Stapelspeicher. Diese lassen sich mit Feldern modellieren. Als Anwendung will ich ein Programm zeigen, das herausfindet, ob die Klammersetzung in einer Zeichenkette korrekt verschachtelt ist.

Das machen wir so: Die Zeichenkette wird von vorne bis hinten durchgegangen. Trifft das Programm auf eine öffnende Klammer, packet es diese auf einen Stapel. Trifft es auf eine schließende, nimmt es die oberste Klammer vom Stapel und schaut, ob diese zur schließenden passt. Wir müssen außerdem bedenken: Ist der Stapel vorzeitig leer oder verbleiben am Ende noch Klammern auf dem Stapel, liegt ebenfalls ein Mismatch vor.

Die erste Variante benutzt ein Feld stack von fester Länge und zur Indizierung einen sogenannten Stapelzeiger sp, engl. stack pointer. Der Stapelzeiger zeigt immer auf bzw. indiziert immer die leerstehende Stapelzelle oberhalb des Stapels. Weil die maximale Stapelhöhe fest ist, sagen wir zehn Zeichen, arbeitet das Programm nur dann korrekt, falls die Verschachtelung der Klammerung nicht mehr als zehn Klammern tief ist.

fn close(c: char) -> char {
    match c {
        '(' => ')', '[' => ']', '{' => '}', _ => '?'
    }
}

fn is_valid(s: &str) -> bool {
    let mut stack: [char; 10] = ['-'; 10];
    let mut sp = 0;
    for c in s.chars() {
        if c == '(' || c == '[' || c == '{' {
            stack[sp] = c;
            sp += 1;
        } else if c == ')' || c == ']' || c == '}' {
            if sp == 0 || c != close(stack[sp-1]) {
                return false;
            } else {
                sp -= 1;
            }
        }
    }
    sp == 0
}

Bei der zweiten Variante kommt nun ein dynamisches Feld zur Anwendung, das die Beschränkung der maximalen Verschachtelungstiefe aufhebt. Ein Stapelzeiger ist hier nicht notwendig, da die Methoden push und pop vorliegen, die gerade die Funktionalität eines Stapelspeichers auszeichnen.

fn is_valid(s: &str) -> bool {
    let mut stack: Vec<char> = Vec::new();
    for c in s.chars() {
        if c == '(' || c == '[' || c == '{' {
            stack.push(c);
        } else if c == ')' || c == ']' || c == '}' {
            match stack.pop() {
                Some(x) => if c != close(x) {return false;},
                None => {return false;}
            }
        }
    }
    stack.is_empty()
}

Eingaben

Eingaben von der Konsole

In Rust gibt es Funktionen für die Eingaben/Ausgaben im Modul io. Diese erlauben sehr kontrolliertes Ansprechen der Schnittstelle zum Betriebssystem, die sowohl Ein- und Ausgaben über die Konsole als auch über Dateien ermöglicht. Aus diesem Grund sind die Funktionen etwas unergonomisch. Zur Vereinfachung der Eingabe gebe ich hier eine Funktion input vor und packe die technischen Details da rein.

Als Beispiel erweitern wir das Programm für die gefühlte Temperatur um Nutzereingaben.

fn input(prompt: &str) -> String {
    use std::{io, io::Write};
    let mut buffer = String::new();
    print!("{}", prompt);
    io::stdout().flush().unwrap();
    io::stdin().read_line(&mut buffer).unwrap();
    if buffer.ends_with('\n') {
        buffer.pop();
        if buffer.ends_with('\r') {buffer.pop();}
    }
    buffer
}

fn message(theta: f64) -> String {
    let text = if theta <  0.0 {"eisig"}
          else if theta < 10.0 {"kalt"}
          else if theta < 16.0 {"mild"}
          else {"warm"};
    format!("Draußen ist es {}.", text)
}

fn main() {
    loop {
        let s = input("Gib eine Temperatur ein: ");
        match s.trim().parse::<f64>() {
            Ok(theta) => {
                println!("{}", message(theta));
            },
            _ => {
                println!("Eine Zahl bitte.");
            }
        };
    }
}

Eingaben aus Dateien

Eingaben aus Dateien ermöglicht die Funktion read_to_string. Die Funktion gibt ein Resultat zurück, bei welchem zwischen Erfolg (Ok) und Fehler (Err) unterschieden wird.

fn main() {
    let path = "Datei.txt";
    let s = match std::fs::read_to_string(path) {
        Ok(value) => value,
        _ => panic!("Fehler: Konnte Datei '{}' nicht finden/lesen.", path)
    };
    println!("Inhalt von Datei '{}':\n{}", path, s);
}

Die Datei muss in dem Verzeichnis liegen, in welchem das Programm geöffnet wird. Der Dateipfad kann auch als Kommandozeilenargument gelesen werden:

let argv: Vec<String> = std::env::args().collect();
let path = &argv[1];