„Wie viele Zeichen hat dieser String?" ist in Unicode-aware-Sprachen eine überraschend tiefgehende Frage. Rust gibt dir drei Antworten: Bytes (mit .bytes()), Unicode-Scalars (mit .chars()), und für die ehrlichste Antwort — Grapheme-Cluster über das externe Crate unicode-segmentation. Dieser Artikel erklärt jede Ebene, zeigt, wann welche relevant ist, und behandelt den Sonderfall von Strings, in denen Bytes, chars und Grapheme alle unterschiedliche Anzahlen ergeben.
Die drei Iterationsebenen
| Ebene | Methode | Was du bekommst | Wann sinnvoll |
|---|---|---|---|
| Bytes | .bytes() | u8 pro UTF-8-Byte | Binär-Protokolle, Hashes, Pattern-Matching auf Bytes |
| Code-Points | .chars() | char pro Unicode-Scalar | Klassifikation, Filter, Case-Operations |
| Grapheme | .graphemes(true) (Crate) | &str pro sichtbarem Zeichen | UI-Anzeige, Cursor-Position, „sichtbare Länge" |
Beispiel zur Illustration:
fn main() {
let s = "Hé🦀!";
println!("Bytes: {}", s.bytes().count()); // 7
println!("Chars: {}", s.chars().count()); // 4
// Grapheme erfordert das unicode-segmentation-Crate:
// use unicode_segmentation::UnicodeSegmentation;
// println!("Graphemes: {}", s.graphemes(true).count()); // 4
}Bei diesem schlichten String sind chars und Grapheme noch gleich (4) — bei Emojis mit Modifikatoren oder kombinierten Diacritics wird's interessant.
.bytes() — die rohen UTF-8-Bytes
fn main() {
let s = "Café";
for b in s.bytes() {
print!("{b:02x} ");
}
// Ausgabe: 43 61 66 c3 a9
}.bytes() liefert u8 pro Byte des UTF-8-Encodings. Wann nutzen?
- Binär-Protokolle und Datei-Header, wo der Inhalt als Byte-Stream interpretiert wird.
- Pattern-Suche im Byte-Strom (z. B. nach
b"\r\n"als Zeilen-Terminator). - Hash-Funktionen, die byteweise arbeiten.
Nicht nutzen für: Text-Klassifikation, Längen-Anzeige, alles, wo „Zeichen" semantisch gemeint ist.
.chars() — Unicode-Scalars
fn main() {
let s = "Hé🦀";
for c in s.chars() {
println!("{c} = U+{:04X}", c as u32);
}
// H = U+0048
// é = U+00E9
// 🦀 = U+1F980
}.chars() dekodiert jedes UTF-8-Sequence zu einem char (Unicode-Scalar). Das ist die richtige Ebene für:
- Klassifikation (
is_alphabetic,is_numeric,is_whitespace) - Case-Konvertierung (
to_uppercase,to_lowercase) - Filter auf bestimmte Zeichen
- Pattern-Matching auf einzelnen Zeichen
.char_indices() — chars mit Byte-Position
Wenn du sowohl den char als auch seine Byte-Position brauchst (z. B. für Slicing):
fn main() {
let s = "Café";
for (i, c) in s.char_indices() {
println!("Byte {i}: {c}");
}
// Byte 0: C
// Byte 1: a
// Byte 2: f
// Byte 3: é
}char_indices() ist das Pendant zu enumerate() auf chars — aber mit Byte-Position statt einem laufenden char-Index. Genau das, was du beim Slicing brauchst.
Grapheme — die wahre „Anzahl der Zeichen"
Ein Grapheme-Cluster ist das, was ein Mensch als „ein Zeichen" wahrnimmt — auch wenn dahinter mehrere Unicode-Code-Points stecken. Beispiele:
ékann als ein Code-Point (U+00E9) oder als zwei kombiniert (e+U+0301Combining Acute) kodiert sein. Visuell identisch —.chars().count()liefert aber 1 vs. 2.- Emojis mit Hautfarben-Modifiern:
👋🏼ist ein Grapheme, aber zwei chars (Hand + Modifier). - Familien-Emojis:
👨👩👧👦(Mann + ZWJ + Frau + ZWJ + Mädchen + ZWJ + Junge) ist ein Grapheme aus sieben Code-Points.
// Cargo.toml: unicode-segmentation = "1"
use unicode_segmentation::UnicodeSegmentation;
fn main() {
let s = "Hé🦀é\u{0301}👨\u{200D}👩\u{200D}👧";
println!("Bytes: {}", s.bytes().count());
println!("Chars: {}", s.chars().count());
println!("Grapheme: {}", s.graphemes(true).count());
}Wann ist Grapheme die richtige Ebene?
- UI-Anzeige: „Du hast 280 Zeichen Limit erreicht" — Grapheme zählen.
- Cursor-Position in Textfeldern: bewege den Cursor um ein „sichtbares Zeichen".
- String-Reverse, der für den User korrekt aussieht.
Für reines String-Processing reichen chars; für Mensch-Lesbar-orientierte Operationen brauchst du Grapheme.
chars().nth — der teure Random-Access
Es gibt keinen O(1)-Zugriff auf das n-te Zeichen — chars().nth(n) ist O(n):
fn main() {
let s = "Müller";
let drittes: Option<char> = s.chars().nth(2); // Some('l')
}Wenn du häufig auf bestimmte char-Positionen zugreifst, gibt es zwei Patterns:
let s = "Müller";
let chars: Vec<char> = s.chars().collect();
let drittes = chars[2]; // 'l' — O(1)
let laenge = chars.len(); // 6Nachteil: 4 Bytes pro char (Vec<char> ist Vec<u32> mit Unicode-Validierung) statt 1-4 Bytes in UTF-8. Bei großen Strings ein Trade-off.
let s = "Müller";
let positionen: Vec<(usize, char)> = s.char_indices().collect();
// Jetzt schnelles Lookup von char-Index zu Byte-PositionIterator-Adapter auf chars
Da chars() einen normalen Iterator zurückgibt, funktionieren alle Iterator-Adapter:
fn main() {
let s = "Hello, World!";
// Zählen
let ws_count = s.chars().filter(|c| c.is_whitespace()).count();
assert_eq!(ws_count, 1);
// Mappen
let upper: String = s.chars().map(|c| c.to_ascii_uppercase()).collect();
assert_eq!(upper, "HELLO, WORLD!");
// Take/Skip
let erste5: String = s.chars().take(5).collect();
assert_eq!(erste5, "Hello");
// Position finden
let komma_pos = s.chars().position(|c| c == ',');
assert_eq!(komma_pos, Some(5)); // 5. char-Index (nicht Byte-Index!)
}Wichtig bei position: es liefert den char-Index, nicht den Byte-Index. Für Byte-Position direkt nutzen: s.find(',') (das ist eine str-Methode, kein Iterator-Adapter).
Praxis: UTF-8-Iteration im echten Code
Word-Count auf Markdown
Wörter zählen, wobei Whitespace und Punkt als Trenner gelten:
fn zaehle_woerter(text: &str) -> usize {
text.split(|c: char| c.is_whitespace() || c.is_ascii_punctuation())
.filter(|w| !w.is_empty())
.count()
}
fn main() {
let text = "Rust ist großartig — wirklich!";
assert_eq!(zaehle_woerter(text), 4);
}.split mit Closure-Predikat ist eine elegante Form von „Tokenize". Pro &str-Segment wird entschieden, ob es ein Wort ist (!is_empty()).
Slug-Generator mit Unicode-Normalisierung
Diakritische Zeichen entfernen (Müller → mueller):
fn ersetze_umlaute(c: char) -> Option<&'static str> {
match c {
'ä' => Some("ae"), 'ö' => Some("oe"), 'ü' => Some("ue"),
'Ä' => Some("Ae"), 'Ö' => Some("Oe"), 'Ü' => Some("Ue"),
'ß' => Some("ss"),
_ => None,
}
}
fn slugify_deutsch(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if let Some(rep) = ersetze_umlaute(c) {
out.push_str(rep);
} else if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
} else if c.is_whitespace() {
out.push('-');
}
}
out
}
fn main() {
assert_eq!(slugify_deutsch("Müller & Söhne"), "mueller--soehne");
}Per chars().for_each strukturiert man eine zeichenweise Transformation klar — und die Output-Allokation (String::with_capacity) reserviert Platz im Voraus.
Hex-Encoder für rohe Bytes
fn als_hex(bytes: &[u8]) -> String {
bytes.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
fn main() {
let token: [u8; 8] = [0xDE, 0xAD, 0xBE, 0xEF, 0x12, 0x34, 0x56, 0x78];
assert_eq!(als_hex(&token), "deadbeef12345678");
}Klassisches Pattern für Cryptographic-Hashes, Session-Token, UUIDs. chars() ist hier nicht im Spiel — wir arbeiten direkt auf Bytes.
Cursor in einem Text-Editor
Der Cursor bewegt sich um Grapheme, nicht um chars — sonst stoppt er in der Mitte eines Emojis:
// Cargo.toml: unicode-segmentation = "1"
use unicode_segmentation::UnicodeSegmentation;
struct Cursor<'a> {
text: &'a str,
byte_pos: usize,
}
impl<'a> Cursor<'a> {
fn bewege_rechts(&mut self) {
// Finde das nächste Grapheme nach byte_pos
if let Some((rel_offset, _)) = self.text[self.byte_pos..]
.grapheme_indices(true)
.nth(1)
{
self.byte_pos += rel_offset;
}
}
}Ohne Grapheme-Aware-Bewegung würde der Cursor im Editor zwischen dem Basis-Zeichen und dem Modifier stehen — sieht für den User aus, als wäre er „in einem Buchstaben". Mit Grapheme-Bewegung springt er immer zu „sichtbaren" Stellen.
CSV-Parser mit Quoting
Eine Zeile in Felder splitten, dabei eingebettete Kommas in Anführungszeichen respektieren:
fn split_csv(zeile: &str) -> Vec<String> {
let mut felder = Vec::new();
let mut aktuell = String::new();
let mut in_quote = false;
for c in zeile.chars() {
match (c, in_quote) {
('"', _) => in_quote = !in_quote,
(',', false) => {
felder.push(std::mem::take(&mut aktuell));
}
_ => aktuell.push(c),
}
}
felder.push(aktuell);
felder
}
fn main() {
let zeile = r#"42,"Berlin, DE",2026-05-18"#;
let f = split_csv(zeile);
assert_eq!(f, vec!["42", "Berlin, DE", "2026-05-18"]);
}Char-by-char Iteration mit State-Machine ist das Standard-Pattern für kontextabhängiges Parsing — clean, ohne Regex, ohne externe Dependencies.
Besonderheiten
Bytes, chars und Grapheme sind drei verschiedene Konzepte.
Bytes: rohe UTF-8-Bytes (u8). Chars: dekodierte Unicode-Scalars (4-Byte). Grapheme: was der User als ein Zeichen sieht (kann aus mehreren Code-Points bestehen). „Anzahl der Zeichen" muss in jedem Kontext geklärt werden.
chars().count() ist O(n).
Es gibt keinen Schnell-Cache für die Char-Anzahl — Rust muss die UTF-8-Bytes Zeichen für Zeichen dekodieren. Bei langen Strings wiederholt aufgerufen, kann das ein Performance-Issue sein. Wenn du die Anzahl mehrfach brauchst: einmal berechnen und cachen.
Grapheme-Cluster brauchen das unicode-segmentation-Crate.
Die Stdlib hat aus Gewichts-Gründen keine Grapheme-Logik. Das unicode-segmentation-Crate ist quasi-standard und folgt dem Unicode-Standard UAX #29. Bei UI-relevanten Operationen einbauen.
Iterator-Adapter wie position liefern char-Indices, nicht Byte-Indices.
s.chars().position(|c| c == ',') gibt die char-Position. Für die Byte-Position direkt: s.find(','). Beide Methoden machen unterschiedliche Dinge — beim Slicing brauchst du fast immer die Byte-Variante.
char_indices ist die enumerate-Variante mit Byte-Position.
Wer Pattern-Matching auf chars macht und gleichzeitig die Position fürs Slicing braucht, sollte direkt char_indices() nutzen. s.chars().enumerate() würde den char-Index liefern, nicht den Byte-Index — und damit beim Slicing einen falschen Wert.
to_uppercase liefert einen Iterator, kein einzelnes char.
Weil manche Zeichen beim Case-Switch ihre Länge ändern (ß → SS, türkisches i → İ). Wer einen String mappt, schreibt s.chars().flat_map(|c| c.to_uppercase()).collect::<String>() — flat_map flacht die per-char-Iteratoren zu einem einzigen ab.
Unicode-Normalisierung ist eigene Welt — Crate unicode-normalization.
Auch identisch aussehende Strings können byte-unterschiedlich sein (NFC vs. NFD). Vor String-Vergleich für User-Input (z. B. Suche) sollte normalisiert werden. Das unicode-normalization-Crate liefert NFC/NFD/NFKC/NFKD.
Reverse einen String — gar nicht trivial.
s.chars().rev().collect::<String>() reverst chars, nicht Grapheme. Wenn s = "👋🏼", würde der Hautfarben-Modifier vor die Hand kommen — unsinnig. Korrektes Reverse braucht Grapheme-Iteration: s.graphemes(true).rev().collect().
Weiterführende Ressourcen
Externe Quellen
- std::primitive.str – Iteration
- std::str::Chars
- Crate unicode-segmentation
- Crate unicode-normalization
- Unicode UAX #29 – Text Segmentation
- The Rust Book – Storing UTF-8 Encoded Text