Speichern von UTF-8-kodiertem Text mit Strings

RustRustBeginner
Jetzt üben

This tutorial is from open-source community. Access the source code

💡 Dieser Artikel wurde von AI-Assistenten übersetzt. Um die englische Version anzuzeigen, können Sie hier klicken

Einführung

Willkommen zu Speichern von UTF-8-kodiertem Text mit Strings. Dieser Lab ist ein Teil des Rust Buchs. Du kannst deine Rust-Fähigkeiten in LabEx üben.

In diesem Lab werden wir die Komplexitäten von Strings in Rust besprechen, insbesondere im Zusammenhang mit der UTF-8-Kodierung, sowie die Operationen und Unterschiede des String-Typs im Vergleich zu anderen Sammlungen.


Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL rust(("Rust")) -.-> rust/BasicConceptsGroup(["Basic Concepts"]) rust(("Rust")) -.-> rust/DataTypesGroup(["Data Types"]) rust(("Rust")) -.-> rust/FunctionsandClosuresGroup(["Functions and Closures"]) rust(("Rust")) -.-> rust/MemorySafetyandManagementGroup(["Memory Safety and Management"]) rust(("Rust")) -.-> rust/DataStructuresandEnumsGroup(["Data Structures and Enums"]) rust(("Rust")) -.-> rust/AdvancedTopicsGroup(["Advanced Topics"]) rust/BasicConceptsGroup -.-> rust/variable_declarations("Variable Declarations") rust/BasicConceptsGroup -.-> rust/mutable_variables("Mutable Variables") rust/DataTypesGroup -.-> rust/string_type("String Type") rust/FunctionsandClosuresGroup -.-> rust/function_syntax("Function Syntax") rust/FunctionsandClosuresGroup -.-> rust/expressions_statements("Expressions and Statements") rust/MemorySafetyandManagementGroup -.-> rust/lifetime_specifiers("Lifetime Specifiers") rust/DataStructuresandEnumsGroup -.-> rust/method_syntax("Method Syntax") rust/AdvancedTopicsGroup -.-> rust/operator_overloading("Traits for Operator Overloading") subgraph Lab Skills rust/variable_declarations -.-> lab-100407{{"Speichern von UTF-8-kodiertem Text mit Strings"}} rust/mutable_variables -.-> lab-100407{{"Speichern von UTF-8-kodiertem Text mit Strings"}} rust/string_type -.-> lab-100407{{"Speichern von UTF-8-kodiertem Text mit Strings"}} rust/function_syntax -.-> lab-100407{{"Speichern von UTF-8-kodiertem Text mit Strings"}} rust/expressions_statements -.-> lab-100407{{"Speichern von UTF-8-kodiertem Text mit Strings"}} rust/lifetime_specifiers -.-> lab-100407{{"Speichern von UTF-8-kodiertem Text mit Strings"}} rust/method_syntax -.-> lab-100407{{"Speichern von UTF-8-kodiertem Text mit Strings"}} rust/operator_overloading -.-> lab-100407{{"Speichern von UTF-8-kodiertem Text mit Strings"}} end

Speichern von UTF-8-kodiertem Text mit Strings

Wir haben uns in Kapitel 4 über Strings unterhalten, werden sie jetzt jedoch genauer betrachten. Neue Rustaceans geraten normalerweise wegen eines Kombinations von drei Gründen in Schwierigkeiten bei Strings: Rusts Tendenz, mögliche Fehler zu offenbaren, Strings als eine komplexere Datenstruktur anzusehen als viele Programmierer es glauben, und UTF-8. Diese Faktoren kombinieren sich auf eine Weise, die schwierig erscheinen kann, wenn man aus anderen Programmiersprachen kommt.

Wir diskutieren Strings im Zusammenhang mit Sammlungen, da Strings als eine Sammlung von Bytes implementiert sind, plus einige Methoden, um nützliche Funktionalität bereitzustellen, wenn diese Bytes als Text interpretiert werden. In diesem Abschnitt werden wir über die Operationen auf String sprechen, die jede Sammlungsart hat, wie das Erstellen, Aktualisieren und Lesen. Wir werden auch die Unterschiede zwischen String und den anderen Sammlungen besprechen, nämlich wie das Indizieren in einen String durch die Unterschiede zwischen der menschlichen und der computerischen Interpretation von String-Daten kompliziert wird.

Was ist ein String?

Wir werden zunächst definieren, was wir unter dem Begriff String verstehen. Rust hat im Kern der Sprache nur einen String-Typ, nämlich die String-Slice str, die normalerweise in ihrer entlehnten Form &str gesehen wird. Im Kapitel 4 haben wir über String-Slices gesprochen, die Verweise auf einige UTF-8-kodierte String-Daten, die an einem anderen Ort gespeichert sind. String-Literale werden beispielsweise im Binärprogramm gespeichert und sind daher String-Slices.

Der String-Typ, der von Rusts Standardbibliothek bereitgestellt wird, statt in die Kernsprache codiert zu sein, ist ein wachsender, veränderbarer, eigener, UTF-8-kodierter String-Typ. Wenn Rustaceans in Rust von "Strings" sprechen, können sie sich entweder auf den String- oder den String-Slice &str-Typ beziehen, nicht nur auf einen dieser Typen. Obwohl dieser Abschnitt größtenteils über String geht, werden beide Typen in Rusts Standardbibliothek stark verwendet, und sowohl String als auch String-Slices sind UTF-8-kodiert.

Erstellen eines neuen Strings

Viele der gleichen Operationen, die mit Vec<T> möglich sind, sind auch mit String verfügbar, da String tatsächlich als Wrapper um einen Byte-Vektor mit einigen zusätzlichen Garantien, Einschränkungen und Funktionen implementiert ist. Ein Beispiel für eine Funktion, die auf die gleiche Weise mit Vec<T> und String funktioniert, ist die new-Funktion, um eine Instanz zu erstellen, wie in Listing 8-11 gezeigt.

let mut s = String::new();

Listing 8-11: Erstellen eines neuen, leeren Strings

Dieser Code erstellt einen neuen, leeren String namens s, in den wir dann Daten laden können. Oft haben wir einige Anfangsdaten, mit denen wir den String beginnen möchten. Dazu verwenden wir die to_string-Methode, die für jede Art verfügbar ist, die das Display-Attribut implementiert, wie dies bei String-Literalen der Fall ist. Listing 8-12 zeigt zwei Beispiele.

let data = "initial contents";

let s = data.to_string();

// die Methode funktioniert auch direkt auf einem Literal:
let s = "initial contents".to_string();

Listing 8-12: Verwenden der to_string-Methode, um einen String aus einem String-Literal zu erstellen

Dieser Code erstellt einen String, der initial contents enthält.

Wir können auch die Funktion String::from verwenden, um einen String aus einem String-Literal zu erstellen. Der Code in Listing 8-13 ist dem Code in Listing 8-12, der to_string verwendet, äquivalent.

let s = String::from("initial contents");

Listing 8-13: Verwenden der String::from-Funktion, um einen String aus einem String-Literal zu erstellen

Da Strings für so viele Dinge verwendet werden, können wir viele verschiedene generische APIs für Strings verwenden, was uns viele Optionen bietet. Einige von ihnen können redundant erscheinen, aber sie alle haben ihren Platz! In diesem Fall machen String::from und to_string das gleiche, also ist die Wahl, welche Sie verwenden, eine Frage der Stil- und Lesbarkeit.

Denken Sie daran, dass Strings UTF-8-kodiert sind, sodass wir in ihnen beliebige richtig codierte Daten einschließen können, wie in Listing 8-14 gezeigt.

let hello = String::from("السلام عليكم");
let hello = String::from("Dobrý den");
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");

Listing 8-14: Speichern von Begrüßungen in verschiedenen Sprachen in Strings

Alle diese sind gültige String-Werte.

Aktualisieren eines Strings

Ein String kann in der Größe wachsen und seine Inhalte können sich ändern, genau wie die Inhalte eines Vec<T>, wenn Sie mehr Daten hineinpushen. Darüber hinaus können Sie den +-Operator oder die format!-Makro bequem verwenden, um String-Werte zu konkatenieren.

Anhängen an einen String mit push_str und push

Wir können einen String erweitern, indem wir die push_str-Methode verwenden, um einen String-Slice anzuhängen, wie in Listing 8-15 gezeigt.

let mut s = String::from("foo");
s.push_str("bar");

Listing 8-15: Anhängen eines String-Slices an einen String mit der push_str-Methode

Nach diesen beiden Zeilen wird s den Wert foobar enthalten. Die push_str-Methode nimmt einen String-Slice entgegen, da wir nicht unbedingt die Eigentumsgewalt über den Parameter übernehmen möchten. Beispielsweise möchten wir in dem Code in Listing 8-16 nach dem Anhängen des Inhalts von s2 an s1 weiterhin s2 verwenden können.

let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(s2);
println!("s2 ist {s2}");

Listing 8-16: Verwenden eines String-Slices nach dem Anhängen seines Inhalts an einen String

Wenn die push_str-Methode die Eigentumsgewalt über s2 übernehmen würde, könnten wir seinen Wert nicht in der letzten Zeile ausgeben. Dieser Code funktioniert jedoch wie erwartet!

Die push-Methode nimmt ein einzelnes Zeichen als Parameter und fügt es zum String hinzu. Listing 8-17 fügt den Buchstaben l zu einem String mit der push-Methode hinzu.

let mut s = String::from("lo");
s.push('l');

Listing 8-17: Hinzufügen eines Zeichens zu einem String-Wert mit push

Dadurch wird s den Wert lol enthalten.

Verkettung mit dem +-Operator oder der format!-Makro

Oft möchten Sie zwei vorhandene Strings kombinieren. Ein Möglichkeit dazu ist, den +-Operator zu verwenden, wie in Listing 8-18 gezeigt.

let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // beachten Sie, dass s1 hier verschoben wurde und nicht mehr verwendet werden kann

Listing 8-18: Verwenden des +-Operators, um zwei String-Werte zu einer neuen String-Wert zu kombinieren

Der String s3 wird den Wert Hello, world! enthalten. Der Grund, warum s1 nach der Addition nicht mehr gültig ist, und der Grund, warum wir auf s2 eine Referenz verwendet haben, hat mit der Signatur der Methode zu tun, die aufgerufen wird, wenn wir den +-Operator verwenden. Der +-Operator verwendet die add-Methode, deren Signatur ungefähr so aussieht:

fn add(self, s: &str) -> String {

In der Standardbibliothek werden Sie add mit Generics und assoziierten Typen definiert sehen. Hier haben wir konkrete Typen eingesetzt, was passiert, wenn wir diese Methode mit String-Werten aufrufen. Wir werden Generics im Kapitel 10 besprechen. Diese Signatur gibt uns die Hinweise, die wir benötigen, um die komplizierten Teile des +-Operators zu verstehen.

Zunächst hat s2 ein &, was bedeutet, dass wir eine Referenz des zweiten Strings zum ersten String hinzufügen. Dies liegt an dem s-Parameter in der add-Funktion: Wir können nur einen &str zu einem String hinzufügen; wir können zwei String-Werte nicht zusammenfügen. Aber warte mal - der Typ von &s2 ist &String, nicht &str, wie im zweiten Parameter von add angegeben. Warum kompiliert also Listing 8-18?

Der Grund, warum wir &s2 im Aufruf von add verwenden können, ist, dass der Compiler die &String-Argument in einen &str umwandeln kann. Wenn wir die add-Methode aufrufen, verwendet Rust eine Deref-Umwandlung, die hier &s2 in &s2[..] umwandelt. Wir werden Deref-Umwandlungen im Kapitel 15 im Detail besprechen. Da add die Eigentumsgewalt über den s-Parameter nicht übernimmt, wird s2 nach dieser Operation immer noch ein gültiger String sein.

Zweitens können wir in der Signatur sehen, dass add die Eigentumsgewalt über self übernimmt, weil self kein & hat. Dies bedeutet, dass s1 in Listing 8-18 in den add-Aufruf verschoben wird und danach nicht mehr gültig ist. Also, obwohl let s3 = s1 + &s2; so aussieht, als würde es beide Strings kopieren und einen neuen erstellen, nimmt dieser Ausdruck tatsächlich die Eigentumsgewalt über s1, fügt eine Kopie des Inhalts von s2 hinzu und gibt dann die Eigentumsgewalt über das Ergebnis zurück. Mit anderen Worten, es sieht so aus, als würde es viele Kopien machen, aber es tut es nicht; die Implementierung ist effizienter als das Kopieren.

Wenn wir mehrere Strings konkatenieren müssen, wird das Verhalten des +-Operators unhandlich:

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 tic-tac-toe sein. Mit all den +- und "-"-Zeichen ist es schwierig, zu sehen, was passiert. Um Strings auf komplexere Weise zu kombinieren, können wir stattdessen das format!-Makro verwenden:

let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");

let s = format!("{s1}-{s2}-{s3}");

Dieser Code setzt auch s auf tic-tac-toe. Das format!-Makro funktioniert wie println!, aber anstatt die Ausgabe auf den Bildschirm zu drucken, gibt es einen String mit dem Inhalt zurück. Die Version des Codes mit format! ist viel lesbarer, und der von dem format!-Makro generierte Code verwendet Referenzen, sodass dieser Aufruf keine der Parameter in die Eigentumsgewalt nimmt.

Indizieren von Strings

In vielen anderen Programmiersprachen ist das Abrufen einzelner Zeichen in einem String, indem man sie anhand eines Indexes referenziert, eine gültige und häufige Operation. In Rust jedoch wird ein Fehler auftreten, wenn Sie versuchen, Teile eines Strings mit der Indizierungssyntax zuzugreifen. Betrachten Sie den ungültigen Code in Listing 8-19.

let s1 = String::from("hello");
let h = s1[0];

Listing 8-19: Versuch, die Indizierungssyntax mit einem String zu verwenden

Dieser Code führt zu dem folgenden Fehler:

error[E0277]: der Typ `String` kann nicht mit `{integer}` indiziert werden
 --> src/main.rs:3:13
  |
3 |     let h = s1[0];
  |             ^^^^^ `String` kann nicht mit `{integer}` indiziert werden
  |
  = help: das Attribut `Index<{integer}>` ist für `String` nicht implementiert

Der Fehler und die Anmerkung erzählen die Geschichte: Rust-Strings unterstützen keine Indizierung. Aber warum nicht? Um diese Frage zu beantworten, müssen wir diskutieren, wie Rust Strings im Speicher speichert.

Interne Darstellung

Ein String ist eine Umhüllung über ein Vec<u8>. Schauen wir uns einige unserer korrekt codierten UTF-8-Beispielstrings aus Listing 8-14 an. Zunächst diesen:

let hello = String::from("Hola");

In diesem Fall wird len 4 sein, was bedeutet, dass der Vektor, der den String "Hola" speichert, 4 Bytes lang ist. Jeder dieser Buchstaben nimmt ein Byte bei der UTF-8-Codierung ein. Die folgende Zeile mag Sie jedoch überraschen (achten Sie darauf, dass dieser String mit dem großen kyrillischen Buchstaben Ze, nicht der arabischen Zahl 3 beginnt):

let hello = String::from("Здравствуйте");

Wenn Sie gefragt würden, wie lang der String ist, würden Sie vielleicht 12 sagen. Tatsächlich ist Rust's Antwort 24: das ist die Anzahl der Bytes, die es braucht, um "Здравствуйте" in UTF-8 zu codieren, weil jeder Unicode-Skalarwert in diesem String 2 Bytes Speicherplatz nimmt. Daher wird ein Index in die Bytes des Strings nicht immer mit einem gültigen Unicode-Skalarwert korrelieren. Um dies zu demonstrieren, betrachten Sie diesen ungültigen Rust-Code:

let hello = "Здравствуйте";
let answer = &hello[0];

Sie wissen bereits, dass answer nicht З, der erste Buchstabe, sein wird. Wenn in UTF-8 codiert, ist der erste Byte von З 208 und das zweite 151, so dass es so aussehen würde, dass answer tatsächlich 208 sein sollte, aber 208 ist kein gültiges Zeichen für sich allein. Das Zurückgeben von 208 ist wahrscheinlich nicht das, was ein Benutzer möchte, wenn er den ersten Buchstaben dieses Strings fragt; jedoch ist das die einzige Daten, die Rust an Byteindex 0 hat. Benutzer möchten im Allgemeinen nicht den Bytewert zurückgegeben bekommen, auch wenn der String nur lateinische Buchstaben enthält: Wenn &"hello"[0] gültiger Code wäre, der den Bytewert zurückgibt, würde er 104 zurückgeben, nicht h.

Die Antwort ist daher, dass Rust diesen Code überhaupt nicht compiliert, um unerwartete Werte zurückzugeben und Bugs zu vermeiden, die möglicherweise nicht sofort entdeckt werden, und um Missverständnisse im frühen Entwicklungsprozess zu vermeiden.

Bytes, Skalarwerte und Grapheme-Clustern! Oh mein!

Ein weiterer Aspekt von UTF-8 ist, dass es tatsächlich drei relevante Wege gibt, Strings aus der Sicht von Rust zu betrachten: als Bytes, Skalarwerte und Grapheme-Clustern (das Ähnlichste zu dem, was wir Buchstaben nennen würden).

Wenn wir das hindi Wort "नमस्ते" in der Devanagari-Schrift betrachten, wird es als Vektor von u8-Werten gespeichert, der so aussieht:

[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224,
164, 164, 224, 165, 135]

Das sind 18 Bytes und so speichern Computer letztendlich diese Daten. Wenn wir sie als Unicode-Skalarwerte betrachten, was der char-Typ von Rust ist, sehen diese Bytes so aus:

['न', 'म', 'स', '्', 'त', 'े']

Es gibt hier sechs char-Werte, aber der vierte und der sechste sind keine Buchstaben: Sie sind Diakritika, die alleine keinen Sinn ergeben. Schließlich, wenn wir sie als Grapheme-Clustern betrachten, erhalten wir das, was eine Person die vier Buchstaben nennen würde, die das hindi Wort bilden:

["न", "म", "स्", "ते"]

Rust bietet verschiedene Möglichkeiten, die ursprünglichen Stringdaten, die Computer speichern, zu interpretieren, sodass jedes Programm die Interpretation wählen kann, die es benötigt, unabhängig davon, welche menschliche Sprache die Daten in sind.

Ein letzter Grund, warum Rust uns nicht erlaubt, in einen String einzusteigen, um ein Zeichen zu erhalten, ist, dass Indexoperationen immer in konstanter Zeit (O(1)) erfolgen sollten. Dies ist jedoch nicht möglich, um die Leistung eines Strings zu gewährleisten, da Rust die Inhalte von Anfang bis zum Index durchlaufen müsste, um zu bestimmen, wie viele gültige Zeichen vorhanden sind.

Slicen von Strings

Das Indizieren in einen String ist oft eine schlechte Idee, weil nicht klar ist, welchen Rückgabetyp die String-Indizierungsoperation haben sollte: einen Byte-Wert, ein Zeichen, einen Grapheme-Cluster oder einen String-Slice. Wenn Sie daher tatsächlich Indizes verwenden müssen, um String-Slices zu erstellen, fordert Rust Sie auf, sich genauer zu erklären.

Anstatt mit einer einzigen Zahl mit [] zu indizieren, können Sie mit einem Bereich [] verwenden, um einen String-Slice zu erstellen, der bestimmte Bytes enthält:

let hello = "Здравствуйте";

let s = &hello[0..4];

Hier wird s ein &str sein, der die ersten vier Bytes des Strings enthält. Früher haben wir erwähnt, dass jedes dieser Zeichen zwei Bytes lang war, was bedeutet, dass s Зд sein wird.

Wenn wir versuchen würden, nur einen Teil der Bytes eines Zeichens mit etwas wie &hello[0..1] zu slicen, würde Rust zur Laufzeit abstürzen, genauso wie wenn ein ungültiger Index in einem Vektor abgerufen wird:

thread 'main' panicked at 'byte index 1 is not a char boundary;
it is inside 'З' (bytes 0..2) of `Здравствуйте`', src/main.rs:4:14

Sie sollten Vorsicht walten lassen, wenn Sie String-Slices mit Bereichen erstellen, da dies Ihren Programm abstürzen kann.

Methoden zum Iterieren über Strings

Der beste Weg, um mit Teilen von Strings zu arbeiten, ist es, klar zu machen, ob Sie Zeichen oder Bytes möchten. Für einzelne Unicode-Skalarwerte verwenden Sie die chars-Methode. Wenn Sie chars auf "Зд" aufrufen, werden die beiden Werte vom Typ char getrennt und zurückgegeben, und Sie können über das Ergebnis iterieren, um jedes Element zuzugreifen:

for c in "Зд".chars() {
    println!("{c}");
}

Dieser Code wird Folgendes ausgeben:

З
д

Alternativ gibt die bytes-Methode jedes ursprüngliche Byte zurück, was für Ihren Anwendungsbereich geeignet sein könnte:

for b in "Зд".bytes() {
    println!("{b}");
}

Dieser Code wird die vier Bytes ausgeben, die diesen String bilden:

208
151
208
180

Denken Sie jedoch daran, dass gültige Unicode-Skalarwerte aus mehr als einem Byte bestehen können.

Das Extrahieren von Grapheme-Clustern aus Strings, wie bei der Devanagari-Schrift, ist komplex, daher wird diese Funktionalität von der Standardbibliothek nicht bereitgestellt. Wenn Sie diese Funktionalität benötigen, finden Sie Crates unter https://crates.io.

Strings sind nicht so einfach

Zusammenfassend lässt sich sagen, dass Strings kompliziert sind. Verschiedene Programmiersprachen treffen unterschiedliche Entscheidungen darüber, wie diese Komplexität an den Programmierer weitergegeben wird. Rust hat entschieden, das korrekte Handling von String-Daten als Standardverhalten für alle Rust-Programme zu machen, was bedeutet, dass Programmierer im Voraus mehr Überlegung in die Behandlung von UTF-8-Daten stecken müssen. Dieser Kompromiss bringt die Komplexität von Strings stärker in den Vordergrund als in anderen Programmiersprachen, verhindert jedoch, dass Sie Fehler bei der Behandlung von nicht-ASCII-Zeichen im späteren Entwicklungsprozess haben müssen.

Die gute Nachricht ist, dass die Standardbibliothek eine Vielzahl von Funktionen bietet, die auf den String- und &str-Typen aufbauen, um diese komplexen Situationen richtig zu behandeln. Stellen Sie sicher, dass Sie sich die Dokumentation zu hilfreichen Methoden wie contains für das Suchen in einem String und replace für das Ersetzen von Teilen eines Strings durch einen anderen String ansehen.

Lassen Sie uns zu etwas weniger Komplexem wechseln: Hash-Maps!

Zusammenfassung

Herzlichen Glückwunsch! Sie haben das Lab "Speichern von UTF-8-kodiertem Text mit Strings" abgeschlossen. Sie können in LabEx weitere Labs absolvieren, um Ihre Fähigkeiten zu verbessern.