UTF-8-kodierten Text in Zeichenketten (strings) ablegen
Wir haben in Kapitel 4 über Zeichenketten (strings) gesprochen, aber wir werden uns jetzt eingehender damit befassen. Neue Rust-Entwickler haben gewöhnlich aus einer Kombination von drei Gründen Probleme mit Zeichenketten: Rusts Neigung, mögliche Fehler aufzudecken, Zeichenketten als eine kompliziertere Datenstruktur, als viele Programmierer ihnen zugestehen, und UTF-8. Diese Faktoren kombinieren sich in einer Weise, die schwierig erscheinen kann, wenn man von anderen Programmiersprachen kommt.
Wir besprechen Zeichenketten im Kontext von Kollektionen, da Zeichenketten als
Byte-Kollektion implementiert sind, sowie einige Methoden, die nützliche
Funktionalitäten bieten, wenn diese Bytes als Text interpretiert werden. In
diesem Abschnitt werden wir über String
-Operationen sprechen, die jeder
Kollektionstyp hat, wie das Erstellen, Aktualisieren und Lesen. Wir werden auch
die Art und Weise besprechen, in der sich String
von den anderen Kollektionen
unterscheidet, nämlich warum die Indexierung bei einem String
kompliziert
ist, weil Menschen und Computer String
-Daten unterschiedlich interpretieren.
Was ist eine Zeichenkette?
Zuerst werden wir definieren, was wir mit dem Begriff Zeichenkette (string)
meinen. Rust hat nur einen einzigen Zeichenkettentyp in der Kernsprache,
nämlich den Zeichenkettenanteilstyp str
, der üblicherweise in seiner
Ausleihenform &str
zu sehen ist. In Kapitel 4 sprachen wir über
Zeichenkettenanteilstypen (string slices), die Referenzen auf einige
UTF-8-kodierte Zeichenkettendaten sind, die anderswo gespeichert sind.
Zeichenkettenliterale werden beispielsweise in der Binärdatei des Programms
gespeichert und sind daher Zeichenkettenanteilstypen.
Der Typ String
, der von Rusts Standardbibliothek zur Verfügung gestellt wird
und nicht in die Kernsprache kodiert ist, ist ein größenänderbarer,
veränderbarer, aneigenbarer, UTF-8-kodierter Zeichenkettentyp. Wenn
Rust-Entwickler von Zeichenketten in Rust sprechen, meinen sie normalerweise
den Typ String
sowie den Zeichenkettenanteilstyp &str
, nicht nur einen
dieser Typen. Obwohl es in diesem Abschnitt weitgehend um String
geht, werden
beide Typen in Rusts Standardbibliothek stark verwendet, und sowohl String
als auch Zeichenkettenanteilstypen sind UTF-8-kodiert.
Erstellen einer neuen Zeichenkette
Viele der gleichen Operationen, die mit Vec<T>
verfügbar sind, sind auch mit
String
verfügbar, weil String
eigentlich als Hülle um einen Vektor von
Bytes mit einigen zusätzlichen Garantien, Einschränkungen und Fähigkeiten
implementiert ist. Ein Beispiel für eine Funktion, die auf die gleiche Weise
mit Vec<T>
und String
arbeitet, ist die Funktion new
zum Erstellen einer
Instanz, die in Codeblock 8-11 gezeigt wird.
#![allow(unused)] fn main() { let mut s = String::new(); }
Diese Zeile erzeugt eine neue, leere Zeichenkette namens s
, in die wir dann
Daten aufnehmen können. Oft werden wir einige initiale Daten haben, mit denen
wir die Zeichenkette füllen wollen. Dazu verwenden wir die Methode to_string
,
die für jeden Typ verfügbar ist, der das Merkmal Display
implementiert, wie
es bei Zeichenkettenliteralen der Fall ist. Codeblock 8-12 zeigt zwei
Beispiele.
#![allow(unused)] fn main() { let data = "initialer Inhalt"; let s = data.to_string(); // die Methode funktioniert auch direkt für ein Literal: let s = "initialer Inhalt".to_string(); }
Dieser Code erzeugt eine Zeichenkette, die initialer Inhalt
enthält.
Wir können auch die Funktion String::from
verwenden, um einen String
aus
einem Zeichenkettenliteral zu erzeugen. Der Code in Codeblock 8-13 ist
äquivalent zum Code in Codeblock 8-12, der to_string
verwendet.
#![allow(unused)] fn main() { let s = String::from("initialer Inhalt"); }
Da Zeichenketten für so viele Dinge verwendet werden, können wir viele
verschiedene generische Programmierschnittstellen (APIs) für Zeichenketten
verwenden, was uns viele Möglichkeiten bietet. Einige von ihnen können
überflüssig erscheinen, aber sie alle haben ihren Platz! In diesem Fall machen
String::from
und to_string
dasselbe, also ist die Wahl eine Frage des
Stils und der Lesbarkeit.
Denke daran, dass Zeichenketten UTF-8-kodiert sind, sodass sie alle ordnungsgemäß kodierten Daten aufnehmen können, wie in Codeblock 8-14 gezeigt.
#![allow(unused)] fn main() { let hello = String::from("السلام عليكم"); let hello = String::from("Dobrý den"); let hello = String::from("Hallo"); let hello = String::from("Hello"); let hello = String::from("שָׁלוֹם"); let hello = String::from("नमस्ते"); let hello = String::from("こんにちは"); let hello = String::from("안녕하세요"); let hello = String::from("你好"); let hello = String::from("Olá"); let hello = String::from("Здравствуйте"); let hello = String::from("Hola"); }
All dies sind gültige String
-Werte.
Aktualisieren einer Zeichenkette
Ein String
kann an Größe zunehmen und sein Inhalt kann sich ändern, genau wie
der Inhalt eines Vec<T>
, wenn du mehr Daten hineinschiebst. Darüber hinaus
kannst du bequem den Operator +
oder das Makro format!
verwenden, um
String
-Werte aneinanderzuhängen.
Anhängen an eine Zeichenkette mit push_str
und push
Wir können einen String
wachsen lassen, indem wir die Methode push_str
verwenden, um einen Zeichenkettenanteilstyp anzuhängen, wie in Codeblock 8-15
zu sehen ist.
#![allow(unused)] fn main() { let mut s = String::from("foo"); s.push_str("bar"); }
Nach diesen beiden Zeilen enthält s
den Wert foobar
. Die Methode push_str
nimmt einen Zeichenkettenanteilstyp, weil wir nicht unbedingt die
Eigentümerschaft des Parameters übernehmen wollen. Zum Beispiel wollen wir im
Code in Codeblock 8-16 in der Lage sein, s2
zu verwenden, nachdem wir seinen
Inhalt an s1
angehängt haben.
#![allow(unused)] fn main() { let mut s1 = String::from("foo"); let s2 = "bar"; s1.push_str(s2); println!("s2 ist {s2}"); }
Wenn die Methode push_str
die Eigentümerschaft von s2
übernehmen würde,
könnten wir ihren Wert nicht in der letzten Zeile ausgeben. Dieser Code
funktioniert jedoch wie erwartet!
Die Methode push
nimmt ein einzelnes Zeichen als Parameter und fügt es dem
String
hinzu. Codeblock 8-17 fügt den Buchstaben l
mit der Methode push
zu einem String
hinzu.
#![allow(unused)] fn main() { let mut s = String::from("lo"); s.push('l'); }
Als Ergebnis wird s
den Wert lol
enthalten.
Aneinanderhängen mit dem Operator +
und dem Makro format!
Häufig möchtest du zwei vorhandene Zeichenketten kombinieren. Eine Möglichkeit
das zu tun ist, den Operator +
zu verwenden, wie in Codeblock 8-18 gezeigt.
#![allow(unused)] fn main() { let s1 = String::from("Hallo "); let s2 = String::from("Welt!"); let s3 = s1 + &s2; // Beachte, s1 wurde hierher verschoben und // kann nicht mehr verwendet werden }
Die Zeichenkette s3
wird Hallo Welt!
enthalten. Der Grund, warum s1
nach
der Addition nicht mehr gültig ist und warum wir eine Referenz auf s2
verwendet haben, hat mit der Signatur der Methode zu tun, die aufgerufen wird,
wenn wir den Operator +
verwenden. Der Operator +
benutzt die Methode
add
, deren Signatur ungefähr so aussieht:
fn add(self, s: &str) -> String {
In der Standardbibliothek wird add
mittels generischer Datentypen und
assoziierter Typen definiert. Hier haben wir konkrete Typen ersetzt, was
geschieht, wenn wir diese Methode mit String
-Werten aufrufen. Wir werden
generische Datentypen in Kapitel 10 besprechen. Diese Signatur gibt uns den
entscheidenden Hinweis, um die kniffligen Stellen des Operators +
zu
verstehen.
Erstens hat s2
ein &
, was bedeutet, dass wir eine Referenz der zweiten
Zeichenkette an die erste Zeichenkette anhängen. Der Grund dafür ist der
Parameter s
in der Funktion add
: Wir können nur einen &str
zu einem
String
hinzufügen; wir können nicht zwei String
-Werte aneinanderhängen.
Aber warte – der Typ von &s2
ist &String
, nicht &str
, wie im
zweiten Parameter von add
spezifiziert. Warum kompiliert also Codeblock 8-18?
Der Grund, warum wir &s2
im Aufruf von add
verwenden können, ist, dass der
Compiler das Argument &String
in einen &str
umwandeln (coerce) kann.
Wenn wir die Methode add
aufrufen, benutzt Rust eine automatische
Umwandlung (deref coercion), die hier &s2
in &s2[...]
umwandelt. Auf die
automatische Umwandlung werden wir in Kapitel 15 tiefer eingehen. Da add
nicht die Eigentümerschaft des Parameters s
übernimmt, ist s2
auch nach
dieser Operation immer noch ein gültiger String
.
Zweitens können wir in der Signatur sehen, dass add
die Eigentümerschaft von
self
übernimmt, weil self
kein &
hat. Das bedeutet, dass s1
in
Codeblock 8-18 in den Aufruf von add
verschoben wird und danach nicht mehr
gültig ist. Obwohl also let s3 = s1 + &s2;
so aussieht, als ob beide
Zeichenketten kopiert und eine neue erzeugt wird, übernimmt diese Anweisung
tatsächlich die Eigentümerschaft von s1
, hängt eine Kopie des Inhalts von
s2
an und gibt dann die Eigentümerschaft des Ergebnisses zurück. In anderen
Worten sieht es so aus, als würde es viele Kopien erstellen, das ist aber nicht
so; die Implementierung ist effizienter als Kopieren.
Wenn wir mehrere Zeichenketten aneinanderhängen wollen, wird das Verhalten des
Operators +
unhandlich:
#![allow(unused)] fn main() { let s1 = String::from("tic"); let s2 = String::from("tac"); let s3 = String::from("toe"); let s = s1 + "-" + &s2 + "-" + &s3; }
An diesem Punkt wird s
den Wert tic-tac-toe
haben. Bei all den Zeichen +
und "
ist es schwer zu erkennen, was vor sich geht. Um Zeichenketten auf
kompliziertere Weise zu kombinieren, können wir stattdessen das Makro format!
verwenden:
#![allow(unused)] fn main() { let s1 = String::from("tic"); let s2 = String::from("tac"); let s3 = String::from("toe"); let s = format!("{s1}-{s2}-{s3}"); }
Auch bei diesem Code wird s
den Wert tic-tac-toe
haben. Das Makro format!
funktioniert wie println!
, aber anstatt das Ergebnis auf den Bildschirm
auszugeben, gibt es einen String
mit dem Inhalt zurück. Die Codevariante mit
format!
ist viel leichter lesbar, und der durch das Makro
format!
erzeugte Code verwendet Referenzen, sodass dieser Aufruf keine
Eigentümerschaft seiner Parameter übernimmt.
Indexierung von Zeichenketten
In vielen anderen Programmiersprachen ist das Zugreifen auf einzelne Zeichen in einer Zeichenkette mittels Index eine gültige und gängige Operation. Wenn du jedoch in Rust versuchst, mittels Indexierungssyntax auf Teile einer Zeichenkette zuzugreifen, wirst du einen Fehler erhalten. Betrachte den ungültigen Code in Codeblock 8-19.
#![allow(unused)] fn main() { let s1 = String::from("Hallo"); let h = s1[0]; }
Dieser Code führt zu folgendem Fehler:
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `String` cannot be indexed by `{integer}`
--> src/main.rs:3:16
|
3 | let h = s1[0];
| ^ `String` cannot be indexed by `{integer}`
|
= help: the trait `Index<{integer}>` is not implemented for `String`
= help: the following other types implement trait `Index<Idx>`:
<String as Index<RangeFull>>
<String as Index<std::ops::Range<usize>>>
<String as Index<RangeFrom<usize>>>
<String as Index<RangeTo<usize>>>
<String as Index<RangeInclusive<usize>>>
<String as Index<RangeToInclusive<usize>>>
For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` (bin "collections") due to 1 previous error
Die Fehlermeldung und der Hinweis erzählen die Geschichte: Zeichenketten in Rust unterstützen keine Indexierung. Aber warum nicht? Um diese Frage zu beantworten, müssen wir uns ansehen, wie Rust Zeichenketten im Speicher ablegt.
Interne Darstellung
Ein String
ist eine Hülle um einen Vec<u8>
. Sehen wir uns einige unserer
korrekt kodierten UTF-8-Beispielzeichenketten aus Codeblock 8-14 an. Zuerst
diese:
#![allow(unused)] fn main() { let hello = String::from("Hola"); }
In diesem Fall wird hello.len()
gleich 4 sein, was bedeutet, dass der Vektor,
der die Zeichenkette „Hola“ speichert, 4 Bytes lang ist. Jeder dieser
Buchstaben benötigt 1 Byte in UTF-8-Kodierung. Die folgende Zeile mag dich
jedoch überraschen. (Beachte, dass diese Zeichenkette mit dem kyrillischen
Großbuchstaben „Ze“ beginnt, nicht mit der Zahl 3.)
#![allow(unused)] fn main() { let hello = String::from("Здравствуйте"); }
Auf die Frage, wie lang die Zeichenkette ist, könnte man sagen: 12. Die Antwort von Rust lautet jedoch 24: Das ist die Anzahl der Bytes, die benötigt wird, um „Здравствуйте“ in UTF-8 zu kodieren, da jeder Unicode-Skalarwert in dieser Zeichenkette 2 Bytes Speicherplatz benötigt. Daher wird ein Index auf die Bytes der Zeichenkette nicht immer mit einem gültigen Unicode-Skalarwert korrelieren. Um das zu erläutern, betrachte diesen ungültigen Rust-Code:
#![allow(unused)] fn main() { let hello = "Здравствуйте"; let answer = &hello[0]; }
Du weißt bereits, dass answer
nicht З
, der erste Buchstabe, sein wird. In
der UTF-8-Kodierung von З
ist das erste Byte 208
und das zweite 151
,
sodass answer
eigentlich 208
sein müsste, aber 208
ist kein eigenständig
gültiges Zeichen. Die Rückgabe von 208
ist wahrscheinlich nicht das, was ein
Nutzer wünschen würde, wenn er nach dem ersten Buchstaben dieser Zeichenkette
fragte; das sind jedoch die einzigen Daten, die Rust beim Byte-Index 0 hat.
Nutzer wollen im Allgemeinen nicht, dass der Byte-Wert zurückgegeben wird,
selbst wenn die Zeichenkette nur lateinische Buchstaben enthält: Wenn
&"hallo"[0]
gültiger Code wäre, der den Byte-Wert zurückgibt, würde er 104
zurückgeben, nicht h
.
Um zu vermeiden, dass ein unerwarteter Wert zurückgegeben wird und dadurch Fehler entstehen, die möglicherweise nicht sofort entdeckt werden, kompiliert Rust diesen Code überhaupt nicht und verhindert so Missverständnisse in einem frühen Stadium des Entwicklungsprozesses.
Bytes, skalare Werte und Graphemgruppen (grapheme clusters)! Oje!
Ein weiterer Punkt bei UTF-8 ist, dass es eigentlich drei relevante Möglichkeiten gibt, Zeichenketten aus Rusts Perspektive zu betrachten: Als Bytes, als skalare Werte und als Graphemgruppen (das, was wir am ehesten als Buchstaben bezeichnen würden).
Wenn wir uns das in der Devanagari-Schrift geschriebene Hindi-Wort „नमस्ते“
(Namaste) ansehen, wird es als ein
Vektor von u8
-Werten gespeichert, der wie folgt aussieht:
[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]
Das sind 18 Bytes, so wie ein Computer diese Daten letztendlich speichert. Wenn
wir sie als Unicode-Skalarwerte betrachten, also als das, was der Typ char
in
Rust ist, sehen diese Bytes wie folgt aus:
['न', 'म', 'स', '्', 'त', 'े']
Es gibt hier sechs char
-Werte, aber der vierte und der sechste sind keine
Buchstaben: Sie sind diakritische Zeichen, die für sich allein genommen keinen
Sinn ergeben. Wenn wir sie schließlich als Graphemgruppen betrachten, erhalten
wir das, was eine Person die vier Buchstaben nennen würde, aus denen das
Hindi-Wort besteht:
["न", "म", "स्", "ते"]
Rust bietet verschiedene Möglichkeiten zur Interpretation von rohen Zeichenkettendaten, die von Computern gespeichert werden, sodass jedes Programm die Interpretation wählen kann, die es benötigt, unabhängig davon, in welcher menschlichen Sprache die Daten vorliegen.
Ein letzter Grund, warum Rust uns nicht erlaubt, eine Zeichenkette zu
indexieren, um ein Zeichen zu erhalten, ist, dass von Indexoperationen erwartet
wird, dass sie immer in konstanter Zeit (O(1)) erfolgen. Es ist jedoch nicht
möglich, diese Zeitgarantie bei einem String
einzuhalten, da Rust den Inhalt
von Anfang an bis zum Index durchgehen müsste, um festzustellen, wie viele
gültige Zeichen es gibt.
Anteilige Zeichenketten
Die Indexierung einer Zeichenkette ist oft eine schlechte Idee, weil nicht klar ist, was der Rückgabetyp der Zeichenketten-Indexoperation sein soll: Ein Byte-Wert, ein Zeichen, eine Graphemgruppe oder ein Zeichenkettenanteilstyp. Wenn du wirklich Indizes verwenden musst, um Zeichenkettenanteilstypen zu erstellen, bittet Rust dich daher, genauer zu sein.
Anstatt []
mit einer einzelnen Zahl zu indizieren, kannst du []
mit einem
Bereich verwenden, um ein Zeichenkettenanteilstyp zu erstellen, der bestimmte
Bytes enthält:
#![allow(unused)] fn main() { let hello = "Здравствуйте"; let s = &hello[0..4]; }
Hier wird s
ein &str
sein, das die ersten vier Bytes der Zeichenkette
enthält. Vorhin haben wir bereits erwähnt, dass jedes dieser Zeichen zwei Bytes
lang ist, was bedeutet, dass s
gleich Зд
ist.
Wenn wir versuchen würden, nur einen Teil der Bytes eines Zeichens mit etwas
wie &hello[0..1]
zu zerschneiden, würde Rust das Programm zur Laufzeit
abbrechen, genauso als wenn mit einem ungültigen Index auf einen Vektor
zugegriffen würde:
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
Finished dev [unoptimized + debuginfo] target(s) in 0.43s
Running `target/debug/collections`
thread 'main' panicked at src/main.rs:4:19:
byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Bei der Verwendung von Bereichen zum Erstellen von Zeichenkettenanteilstypen ist Vorsicht geboten, da dies zum Absturz deines Programms führen kann.
Methoden zum Iterieren über Zeichenketten
Der beste Weg, um mit Teilen von Zeichenketten zu arbeiten, besteht darin,
explizit anzugeben, ob du Zeichen oder Bytes benötigst. Für einzelne
Unicode-Skalarwerte ist die Methode chars
zu verwenden. Der Aufruf von
chars
auf „Зд“ trennt zwei Werte vom Typ char
heraus und gibt sie
zurück, und du kannst über das Ergebnis iterieren, um auf jedes Element
zuzugreifen:
#![allow(unused)] fn main() { for c in "Зд".chars() { println!("{c}"); } }
Dieser Code wird folgendes ausgeben:
З
д
Die Methode bytes
gibt jedes rohe Byte zurück, das für deinen
Verwendungszweck benötigt wird:
#![allow(unused)] fn main() { for b in "Зд".bytes() { println!("{b}"); } }
Dieser Code gibt die vier Bytes aus, aus denen diese Zeichenkette besteht:
208
151
208
180
Aber denke daran, dass gültige Unicode-Skalarwerte aus mehr als ein Byte bestehen können.
Die Ermittlung von Graphemgruppen aus Zeichenketten wie bei der Devanagari-Schrift ist komplex, sodass diese Funktionalität nicht von der Standardbibliothek bereitgestellt wird. Kisten (crates) sind unter crates.io verfügbar, falls du diese Funktionalität benötigst.
Zeichenketten sind nicht so einfach
Zusammenfassend kann man sagen, dass Zeichenketten kompliziert sind. Verschiedene Programmiersprachen treffen unterschiedliche Entscheidungen darüber, wie diese Komplexität dem Programmierer angezeigt wird. Rust hat sich dafür entschieden, den korrekten Umgang mit Zeichenkettendaten zum Standardverhalten für alle Rust-Programme zu machen, was bedeutet, dass Programmierer sich im Vorfeld mehr Gedanken über den Umgang mit UTF-8-Daten machen müssen. Dieser Zielkonflikt macht die Komplexität von Zeichenketten größer als in anderen Programmiersprachen, aber er verhindert, dass du später in deinem Entwicklungslebenszyklus mit Fehlern umgehen musst, wenn Nicht-ASCII-Zeichen vorkommen.
Die gute Nachricht ist, dass die Standardbibliothek eine Vielzahl von
Funktionen bietet, die auf den Typen String
und &str
aufbauen, um diese
komplexen Situationen korrekt zu behandeln. In der Dokumentation findest du
nützliche Methoden wie contains
zum Suchen in einer Zeichenkette und
replace
zum Ersetzen von Teilen einer Zeichenkette durch eine andere
Zeichenkette.
Lass uns zu etwas weniger Kompliziertem übergehen: Hashtabellen!