Der Slice-Typ
Mit Slices kannst du auf eine zusammenhängende Folge von Elementen in einer Kollektion referenzieren. Ein Slice ist eine Art Referenz und hat daher kein Eigentum.
Hier ist eine kleine Programmieraufgabe: Schreibe eine Funktion, die einen String aus Wörtern entgegennimmt, die durch Leerzeichen getrennt sind, und das erste Wort in diesem String zurückgibt. Wenn die Funktion im String kein Leerzeichen findet, muss der gesamte String ein einziges Wort sein; in diesem Fall soll der komplette String zurückgegeben werden.
Hinweis: Zur Einführung in Slices gehen wir in diesem Abschnitt nur von ASCII aus. Eine ausführlichere Erörterung der UTF-8-Verarbeitung findest du im Abschnitt „UTF-8-kodierten Text in Strings ablegen“ in Kapitel 8.
Gehen wir einmal durch, wie wir die Signatur dieser Funktion ohne Verwendung von Slices schreiben würden, um das Problem zu verstehen, das durch Slices gelöst wird:
fn first_word(s: &String) -> ?
Die Funktion first_word hat einen Parameter vom Typ &String. Wir benötigen
kein Eigentum, also ist das in Ordnung. (In idiomatischem Rust übernehmen
Funktionen nicht das Eigentum an ihren Argumenten, es sei denn, sie müssen es,
und die Gründe dafür werden im weiteren Verlauf klar werden.) Aber was sollen
wir zurückgeben? Wir haben nicht wirklich die Mittel, einen Teil eines Strings
zu referenzieren. Wir könnten jedoch den Index des Wortendes zurückgeben.
Versuchen wir das, wie in Listing 4-7 gezeigt.
Dateiname: src/main.rs
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
fn main() {}
Listing 4-7: Die Funktion first_word, die einen
Byte-Indexwert zum Parameter String zurückgibt
Da wir den String Zeichen für Zeichen durchgehen und prüfen müssen, ob ein
Wert ein Leerzeichen ist, wandeln wir unseren String mit der Methode
as_bytes in ein Byte-Array um.
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
fn main() {}
Als nächstes erstellen wir einen Iterator über das Byte-Array, indem wir die
Methode iter verwenden:
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
fn main() {}
Auf Iteratoren werden wir in Kapitel 13 näher eingehen. Fürs Erste
solltest du wissen, dass iter eine Methode ist, die jedes Element in einer
Kollektion zurückgibt und dass enumerate das Ergebnis von iter umhüllt und
stattdessen jedes Element als Teil eines Tupels zurückgibt. Das erste Element
des Tupels, das von enumerate zurückgegeben wird, ist der Index, und das
zweite Element ist eine Referenz auf das Element. Das ist etwas bequemer, als
den Index selbst zu berechnen.
Da die Methode enumerate ein Tupel zurückgibt, können wir Muster verwenden,
um dieses Tupel zu zerlegen. Wir werden uns in Kapitel 6 eingehender mit
Mustern befassen. In der for-Schleife spezifizieren wir also ein Muster, das
i für den Index im Tupel und &item für das einzelne Byte im Tupel hat. Da
wir eine Referenz auf das Element aus .iter().enumerate() erhalten, verwenden
wir & im Muster.
Innerhalb der for-Schleife suchen wir mit Hilfe der Byte-Literal-Syntax b' '
nach dem Byte, das das Leerzeichen repräsentiert. Wenn wir ein Leerzeichen
finden, geben wir die Position zurück. Andernfalls geben wir die Länge des
Strings zurück, indem wir s.len() verwenden.
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
fn main() {}
Wir haben jetzt eine Möglichkeit, den Index des ersten Wortendes im String
herauszufinden, aber es gibt ein Problem. Wir geben ein usize für sich allein
zurück, aber die Zahl ist nur aussagekräftig im Kontext des &String. Mit
anderen Worten: Da es sich um einen vom String getrennten Wert handelt, gibt
es keine Garantie, dass er auch in Zukunft noch gültig ist. Betrachte das
Programm in Listing 4-8, das die Funktion first_word aus Listing 4-7
verwendet.
Dateiname: src/main.rs
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
fn main() {
let mut s = String::from("Hallo Welt");
let word = first_word(&s); // word erhält den Wert 5
s.clear(); // leert den String und macht ihn gleich ""
// word hat noch immer den Wert 5, aber es gibt keinen String mehr,
// mit dem wir den Wert 5 sinnvoll verwenden könnten.
// word ist jetzt völlig ungültig!
}
Listing 4-8: Speichern des Ergebnisses des
Funktionsaufrufs first_word und anschließendes Ändern des Inhalts des
Strings
Dieses Programm kompiliert fehlerfrei und würde dies auch tun, wenn wir word
nach dem Aufruf von s.clear() benutzen würden. Da word überhaupt nicht mit
dem Zustand von s verbunden ist, enthält word immer noch den Wert 5. Wir
könnten den Wert 5 mit der Variable s verwenden, um zu versuchen, das erste
Wort zu extrahieren, aber das wäre ein Fehler, weil sich der Inhalt von s
geändert hat, nachdem wir 5 in word gespeichert haben.
Sich darum kümmern zu müssen, dass der Index in word mit den Daten in s
konform ist, ist mühsam und fehleranfällig! Das Verwalten dieser Indizes ist
noch fehleranfälliger, wenn wir eine Funktion second_word schreiben. Ihre
Signatur müsste dann so aussehen:
fn second_word(s: &String) -> (usize, usize) {
Jetzt verfolgen wir einen Anfangs- und einen Endindex, und wir haben noch mehr Werte, die aus Daten in einem bestimmten Zustand berechnet wurden, aber überhaupt nicht an diesen Zustand gebunden sind. Wir haben drei unverbundene Variablen, die synchron gehalten werden müssen.
Glücklicherweise hat Rust eine Lösung für dieses Problem: String Slices
String Slices
Ein String Slice ist eine Referenz auf einen Teil eines String, und er sieht
so aus:
#![allow(unused)]
fn main() {
let s = String::from("Hallo Welt");
let hello = &s[0..5];
let world = &s[6..10];
}
Anstelle einer Referenz auf den gesamten String ist hello eine Referenz auf
einen Teil des String, der mit dem zusätzlichen [0..5] spezifiziert ist. Wir
erstellen Slices unter Angabe eines Bereichs innerhalb von Klammern, indem wir
[starting_index..ending_index] angeben, wobei starting_index die erste
Position im Slice und ending_index eine Position mehr als die letzte
Position im Slice ist. Intern speichert die Slice-Datenstruktur die
Anfangsposition und die Länge des Slices, was ending_index minus
starting_index entspricht. Im Fall von let world = &s[6..10]; wäre world
also ein Slice, der einen Zeiger auf das Byte bei Index 6 von s mit dem
Längenwert 4 enthält.
Abbildung 4-7 stellt dies dar.
Abbildung 4-7: Ein String Slice, der auf einen Teil eines
String referenziert
Wenn du mit der Bereichssyntax .. in Rust beim Index 0 beginnen willst, kannst
du den Wert vor den zwei Punkten weglassen. Mit anderen Worten sind diese
Ausdrücke gleich:
#![allow(unused)]
fn main() {
let s = String::from("Hallo");
let slice = &s[0..2];
let slice = &s[..2];
}
Ebenso kannst du den Endindex weglassen, wenn dein Slice das letzte Byte des
String enthält. Das bedeutet, dass diese gleich sind:
#![allow(unused)]
fn main() {
let s = String::from("Hallo");
let len = s.len();
let slice = &s[3..len];
let slice = &s[3..];
}
Du kannst auch beide Werte weglassen, um einen Ausschnitt des gesamten Strings zu beschreiben. Diese sind also gleichwertig:
#![allow(unused)]
fn main() {
let s = String::from("Hallo");
let len = s.len();
let slice = &s[0..len];
let slice = &s[..];
}
Hinweis: Bereichsindizes bei String Slices müssen sich nach gültigen UTF-8-Zeichengrenzen richten. Wenn du versuchst, einen String Slice in der Mitte eines Mehrbyte-Zeichens zu erstellen, wird dein Programm mit einem Fehler abbrechen.
Mit all diesen Informationen im Hinterkopf schreiben wir first_word so um,
dass es einen Slice zurückgibt. Der Typ mit der Bedeutung „String Slice“ wird
&str geschrieben:
Dateiname: src/main.rs
fn first_word(s: &String) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
fn main() {}
Den Index für das Wortende erhalten wir auf die gleiche Weise wie in Listing 4-7, indem wir nach dem ersten Vorkommen eines Leerzeichens suchen. Wenn wir ein Leerzeichen finden, geben wir einen String Slice zurück, wobei wir den Anfang des Strings und den Index des Leerzeichens als Anfangs- bzw. Endindex verwenden.
Wenn wir nun first_word aufrufen, erhalten wir einen einzelnen Wert zurück,
der an die zugrundeliegenden Daten gebunden ist. Der Wert setzt sich aus einer
Referenz auf den Startpunkt des Slices und der Anzahl der Elemente im Slice
zusammen.
Die Rückgabe eines Slices würde auch für eine Funktion second_word
funktionieren:
fn second_word(s: &String) -> &str {
Wir haben jetzt eine einfache API, die viel schwieriger durcheinanderzubringen
ist, weil der Compiler sicherstellt, dass die Referenzen auf den String gültig
bleiben. Erinnere dich an den Fehler im Programm in Listing 4-8, als wir den
Index bis zum Ende des ersten Wortes erhielten, dann aber den String löschten,
sodass unser Index ungültig wurde. Dieser Code war logisch falsch, zeigte aber
keine unmittelbaren Fehler. Die Probleme würden sich später zeigen, wenn wir
weiterhin versuchen würden, den ersten Wortindex mit einem leeren String zu
verwenden. Slices machen diesen Fehler unmöglich und lassen uns viel früher
wissen, dass wir ein Problem mit unserem Code haben. Die Slice-Variante von
first_word führt zu einem Compilerfehler:
Dateiname: src/main.rs
fn first_word(s: &String) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
fn main() {
let mut s = String::from("Hallo Welt");
let word = first_word(&s);
s.clear(); // Fehler!
println!("Das erste Wort ist: {word}");
}
Hier ist der Compilerfehler:
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> src/main.rs:18:5
|
16 | let word = first_word(&s);
| -- immutable borrow occurs here
17 |
18 | s.clear(); // Fehler!
| ^^^^^^^^^ mutable borrow occurs here
19 |
20 | println!("Das erste Wort ist: {word}");
| ------ immutable borrow later used here
For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error
Erinnere dich an die Borrowing-Regeln, durch die wir, wenn wir eine
unveränderbare Referenz auf etwas haben, nicht noch eine veränderbare Referenz
anlegen können. Da clear den String abschneiden muss, muss es eine
veränderbare Referenz erhalten. Das println! nach dem Aufruf von clear
verwendet die Referenz in word, sodass die unveränderbare Referenz zu diesem
Zeitpunkt noch aktiv sein muss. Rust verbietet, dass die veränderbare Referenz
in clear und die unveränderbare Referenz in word nicht gleichzeitig
existieren, und die Kompilierung schlägt fehl. Rust hat nicht nur die Benutzung
unserer API vereinfacht, sondern auch eine ganze Klasse von Fehlern zur
Kompilierzeit beseitigt!
String-Literale als Slices
Erinnere dich, dass wir darüber sprachen, dass String-Literale in der Binärdatei gespeichert werden. Jetzt, da wir über Slices Bescheid wissen, können wir String-Literale richtig verstehen:
#![allow(unused)]
fn main() {
let s = "Hallo Welt!";
}
Der Typ von s hier ist &str: Es ist ein Slice, der auf diesen speziellen
Punkt der Binärdatei zeigt. Das ist auch der Grund, warum String-Literale
unveränderbar sind; &str ist eine unveränderbare Referenz.
String Slices als Parameter
Das Wissen, dass man Slices von Literalen und String-Werten erstellen kann,
führt uns zu einer weiteren Verbesserung von first_word, und das ist ihre
Signatur:
fn first_word(s: &String) -> &str {
Ein erfahrenerer Rust-Entwickler würde stattdessen die in Listing 4-9
gezeigte Signatur schreiben, da sie es uns erlaubt, dieselbe Funktion sowohl
auf &String-Werte als auch auf &str-Werte anzuwenden.
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
fn main() {
let my_string = String::from("Hallo Welt");
// `first_word` funktioniert mit Slices von `String`, ob teilweise oder ganz
let word = first_word(&my_string[0..6]);
let word = first_word(&my_string[..]);
// `first_word` funktioniert auch bei Referenzen auf `String`, die
// äquivalent zu ganzen Slices von `String` sind
let word = first_word(&my_string);
let my_string_literal = "Hallo Welt";
// `first_word` funktioniert mit Slices von String-Literalen, ob teilweise oder ganz
let word = first_word(&my_string_literal[0..6]);
let word = first_word(&my_string_literal[..]);
// Da String-Literale bereits String Slices sind,
// funktioniert dies auch ohne Slice-Syntax!
let word = first_word(my_string_literal);
}
Listing 4-9: Verbessern der Funktion first_word durch
Verwenden eines String Slices für den Typ des Parameters s
Wenn wir einen String Slice haben, können wir diesen direkt übergeben. Wenn wir
einen String haben, können wir einen Slice des String oder eine Referenz auf
den String übergeben. Diese Flexibilität nutzt die Vorteile der automatischen
Umwandlung, eine Funktionalität, die wir im Abschnitt „Automatische Umwandlung
in Funktionen und Methoden verwenden“ in Kapitel 15 behandeln.
Das Definieren einer Funktion, die einen String Slice statt einer Referenz auf
einen String entgegennimmt, macht unsere API allgemeiner und nützlicher, ohne
an Funktionalität einzubüßen:
Dateiname: src/main.rs
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
fn main() {
let my_string = String::from("Hallo Welt");
// `first_word` funktioniert mit Slices von `String`, ob teilweise oder ganz
let word = first_word(&my_string[0..6]);
let word = first_word(&my_string[..]);
// `first_word` funktioniert auch bei Referenzen auf `String`, die
// äquivalent zu ganzen Slices von `String` sind
let word = first_word(&my_string);
let my_string_literal = "Hallo Welt";
// `first_word` funktioniert mit Slices von String-Literalen, ob teilweise oder ganz
let word = first_word(&my_string_literal[0..6]);
let word = first_word(&my_string_literal[..]);
// Da String-Literale bereits String Slices sind,
// funktioniert dies auch ohne Slice-Syntax!
let word = first_word(my_string_literal);
}
Andere Slices
String Slices sind, wie du dir vorstellen kannst, spezifisch für Strings. Es gibt aber auch einen allgemeineren Slice-Typ. Betrachte dieses Array:
#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5];
}
Genauso wie wir vielleicht auf einen Teil eines Strings verweisen möchten, möchten wir vielleicht auf einen Teil eines Arrays verweisen. Wir würden das so machen:
#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5];
let slice = &a[1..3];
assert_eq!(slice, &[2, 3]);
}
Dieser Slice hat den Typ &[i32]. Es funktioniert auf die gleiche Weise wie bei
String Slices, indem es eine Referenz auf das erste Element und eine Länge
speichert. Du wirst diese Art von Slices für alle möglichen anderen Kollektionen
verwenden. Wir werden diese Kollektionen im Detail besprechen, wenn wir in
Kapitel 8 über Vektoren sprechen.
Zusammenfassung
Die Konzepte von Eigentümerschaft, Borrowing und Slices gewährleisten Speichersicherheit zur Kompilierzeit in Rust-Programmen. Die Sprache Rust gibt dir Kontrolle über die Speicherverwendung auf die gleiche Weise wie andere Systemprogrammiersprachen, aber dadurch, dass der Eigentümer der Daten diese automatisch aufräumt, wenn der Eigentümer den Gültigkeitsbereich verlässt, bedeutet dies, dass du keinen zusätzlichen Code schreiben und debuggen musst, um diese Kontrolle zu erhalten.
Die Eigentümerschaft wirkt sich auf die Funktionsweise vieler anderer Teile von
Rust aus, deshalb werden wir im weiteren Verlauf des Buches weiter über diese
Konzepte sprechen. Lass uns zu Kapitel 5 übergehen und uns das Gruppieren von
Datenteilen zu einer struct ansehen.