„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

EbeneMethodeWas du bekommstWann sinnvoll
Bytes.bytes()u8 pro UTF-8-ByteBinär-Protokolle, Hashes, Pattern-Matching auf Bytes
Code-Points.chars()char pro Unicode-ScalarKlassifikation, Filter, Case-Operations
Grapheme.graphemes(true) (Crate)&str pro sichtbarem ZeichenUI-Anzeige, Cursor-Position, „sichtbare Länge"

Beispiel zur Illustration:

Rust Drei Ebenen, drei Zahlen
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

Rust Byte-Iteration
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

Rust char-Iteration
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):

Rust char + Position
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+0301 Combining 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.
Rust Bytes vs. chars vs. Grapheme
// 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):

Rust nth — 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:

Rust Vec<char> für Random-Access
let s = "Müller";
let chars: Vec<char> = s.chars().collect();
let drittes = chars[2];           // 'l' — O(1)
let laenge = chars.len();         // 6

Nachteil: 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.

Rust char_indices für Map
let s = "Müller";
let positionen: Vec<(usize, char)> = s.char_indices().collect();
// Jetzt schnelles Lookup von char-Index zu Byte-Position

Iterator-Adapter auf chars

Da chars() einen normalen Iterator zurückgibt, funktionieren alle Iterator-Adapter:

Rust 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:

Rust Wortzähler
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üllermueller):

Rust Slug ohne Umlaute
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

Rust Hex-String aus 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:

Rust Cursor-Movement
// 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:

Rust CSV mit Quotes
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

/ Weiter

Zurück zu Strings & Text

Zur Übersicht