↑Programmieren in Rust
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() {}
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
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.
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 |
Ü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);
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.
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 }
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 } }
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.
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 a, b, c 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); }
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.
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
0
, 1
, 2
.
Daher beschreibt der Slice &a[0..3]
eine Referenz auf
[a[0], a[1], a[2]]
.
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)); }
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.
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); }
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() }
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 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];