Slicing in Rust ist eine zweischneidige Sache: für ASCII-Strings funktioniert &s[0..5] intuitiv und schnell, für UTF-8-Strings mit Multi-Byte-Zeichen kann derselbe Code zu einem Panic führen. Der Grund liegt im variable-length encoding: ein ü belegt 2 Bytes, ein 🦀 4 Bytes — und ein Slice-Index darf nie mitten in einem Zeichen landen. Dieser Artikel erklärt, was bei &s[i..j] wirklich passiert, wie du sicher slicest und wann der Wechsel zu as_bytes() der richtige Weg ist.
Slicing-Grundlagen
Slicing auf str und String funktioniert mit Byte-Indices, nicht Zeichen-Indices:
fn main() {
let s = "Hallo, Welt!";
let a: &str = &s[..5]; // "Hallo"
let b: &str = &s[7..]; // "Welt!"
let c: &str = &s[..]; // "Hallo, Welt!"
println!("{a} {b}");
}Bei reinem ASCII ist jeder Byte-Index gleichzeitig ein Zeichen-Index. Solange du nur ASCII verarbeitest, ist &s[i..j] unproblematisch.
UTF-8 und Multi-Byte-Zeichen
Sobald Nicht-ASCII ins Spiel kommt, kann ein naiver Index ein Problem werden:
fn main() {
let s = "Müller";
println!("Länge in Bytes: {}", s.len()); // 7 (nicht 6!)
println!("Anzahl Zeichen: {}", s.chars().count()); // 6
let h: &str = &s[..1]; // "M" — ok, M ist 1 Byte
// let bad: &str = &s[..2]; // Panic! — Grenze mitten in 'ü'
let ok: &str = &s[..3]; // "Mü" — ok, ü endet bei Byte 3
}Der String "Müller" belegt 7 Bytes:
| Byte-Index | Wert | Zeichen |
|---|---|---|
| 0 | 0x4D | M |
| 1 | 0xC3 | ü (Byte 1) |
| 2 | 0xBC | ü (Byte 2) |
| 3 | 0x6C | l |
| 4 | 0x6C | l |
| 5 | 0x65 | e |
| 6 | 0x72 | r |
&s[..2] würde nur das erste Byte von ü mitnehmen — kein gültiges UTF-8. Rust verhindert das mit einem Laufzeit-Panic.
Die Panic-Meldung
thread 'main' panicked at 'byte index 2 is not a char boundary;
it is inside 'ü' (bytes 1..3) of `Müller`'Hilfreich: die Panic-Meldung zeigt exakt, welches Zeichen an der Grenze sitzt und in welchem Byte-Bereich es liegt.
Sicheres Slicing
Drei Werkzeuge, um Slicing-Panics zu vermeiden:
1. is_char_boundary — vorher prüfen
fn sicher_slicen(s: &str, n: usize) -> &str {
if s.is_char_boundary(n) {
&s[..n]
} else {
""
}
}is_char_boundary(i) gibt true zurück, wenn i entweder 0, s.len() oder an einer Zeichen-Grenze in UTF-8 liegt.
2. get(...) — Option zurück statt Panic
let s = "Müller";
let teil: Option<&str> = s.get(0..2); // None — keine gültige Grenze
let teil: Option<&str> = s.get(0..3); // Some("Mü")s.get(range) ist die fallible Variante von &s[range]. Empfehlung: wenn dein Code mit fremdem Input arbeitet, get statt direktem Slicing.
3. floor_char_boundary / ceil_char_boundary
Seit Rust 1.79 gibt es Methoden, die zur nächsten gültigen Grenze runden:
let s = "Müller";
let runter: usize = s.floor_char_boundary(2); // 1 (eine Grenze davor)
let hoch: usize = s.ceil_char_boundary(2); // 3 (eine Grenze danach)
let teil = &s[..runter]; // "M"Ideal für „kürze einen String auf höchstens N Bytes, aber an einer gültigen Zeichen-Grenze".
Byte-Zugriff mit as_bytes
Wenn du wirklich byteweise arbeiten willst (z. B. bei Netzwerk-Protokollen oder Binary-Pattern-Suche), gibt es as_bytes():
let s = "Müller";
let bytes: &[u8] = s.as_bytes();
println!("{:?}", bytes);
// [77, 195, 188, 108, 108, 101, 114]
for &b in bytes {
print!("{b:02x} ");
}
// 4d c3 bc 6c 6c 65 72as_bytes() ist kostenlos — keine Allokation, kein Copy. Es ist nur eine andere Sicht auf dieselben Speicher-Bytes.
Zurück: from_utf8
Wenn du einen &[u8] hast und ihn als &str interpretieren willst:
let bytes = b"Hallo"; // &[u8; 5]
// Validierende Konvertierung
let s: Result<&str, _> = std::str::from_utf8(bytes);
match s {
Ok(text) => println!("{text}"),
Err(e) => eprintln!("Kein UTF-8: {e}"),
}
// Unsicher (überspringt Validierung)
let unsafe_s: &str = unsafe { std::str::from_utf8_unchecked(bytes) };Die sichere Variante prüft, ob die Bytes wohlgeformt UTF-8 sind, und gibt sonst einen Utf8Error. Die unchecked-Variante überspringt die Prüfung — schneller, aber unsafe, weil invalides UTF-8 in einem &str zu Undefined Behavior führt.
Byte-Literale und Byte-Strings
Rust hat eigene Literal-Syntaxen für rohe Bytes:
let einzel: u8 = b'A'; // 65
let array: &[u8; 5] = b"Hallo"; // [72, 97, 108, 108, 111]
let raw: &[u8; 4] = b"\xDE\xAD\xBE\xEF"; // binäre Bytes
// Bytestring kann nur ASCII (oder Hex-Escapes) enthalten
// let umlaut = b"Müller"; // Fehler — kein UTF-8 in Byte-LiteralenByte-Strings (b"...") sind &[u8; N], nicht &str. Sie sind ideal für Binär-Protokolle oder Datei-Headers.
find, position, byte-vs-char
Suche in Strings liefert immer Byte-Indices, nicht Zeichen-Indices:
let s = "Café";
let pos = s.find('f'); // Some(2) — Byte-Index nach 'C' und 'a'
let pos2 = s.find('é'); // Some(3) — Byte-Index nach 'Caf'
// Mit Slicing zusammen — funktioniert immer korrekt,
// weil find() garantiert eine gültige char-Grenze liefert
if let Some(p) = s.find('f') {
println!("Davor: {}", &s[..p]); // "Ca"
println!("Ab dort: {}", &s[p..]); // "fé"
}Wichtige Garantie: find, rfind, splitn und alle Suchmethoden liefern garantiert gültige Byte-Grenzen. Du kannst ihr Ergebnis sicher zum Slicing nutzen.
Praxis: Slicing in echtem Code
URL-Pfad und -Query trennen
Eine URL wie /users/42?include=email&fields=name in Pfad und Query-String aufteilen:
fn split_pfad_query(url: &str) -> (&str, Option<&str>) {
match url.find('?') {
Some(pos) => (&url[..pos], Some(&url[pos + 1..])),
None => (url, None),
}
}
fn main() {
let url = "/users/42?include=email&fields=name";
let (pfad, query) = split_pfad_query(url);
assert_eq!(pfad, "/users/42");
assert_eq!(query, Some("include=email&fields=name"));
}find('?') liefert garantiert eine korrekte Byte-Grenze — also ist das Slicing absolut sicher, auch wenn der Rest UTF-8 mit Umlauten enthält.
Sicheres Truncate auf max. N Bytes
In Log-Output oder DB-Spalten brauchst du oft „kürze einen Text auf höchstens 200 Bytes, ohne ein Zeichen zu zerschneiden":
fn truncate_an_grenze(s: &str, max_bytes: usize) -> &str {
if s.len() <= max_bytes {
return s;
}
let mut i = max_bytes;
while !s.is_char_boundary(i) {
i -= 1;
}
&s[..i]
}
fn main() {
let lang = "Ich bin ein längerer Text mit Umlauten äöü";
let kurz = truncate_an_grenze(lang, 12);
println!("{kurz}"); // Garantiert valides UTF-8
}Auf modernen Rust (≥ 1.79) eleganter mit floor_char_boundary(max_bytes) als Einzeiler.
HTTP-Header parsen
Ein Header wie Content-Type: text/html; charset=utf-8 in Name, Wert, Parameter zerlegen:
fn parse_header(zeile: &str) -> Option<(&str, &str)> {
let pos = zeile.find(':')?;
let name = zeile[..pos].trim();
let wert = zeile[pos + 1..].trim();
Some((name, wert))
}
fn main() {
let h = "Content-Type: text/html; charset=utf-8";
if let Some((name, wert)) = parse_header(h) {
assert_eq!(name, "Content-Type");
assert_eq!(wert, "text/html; charset=utf-8");
}
}Alle Operationen sind Slice-basiert — kein einziger neuer String wird alloziert. Das macht Header-Parsing in Rust extrem schnell.
Magic-Bytes prüfen
Dateiformate haben oft Magic-Bytes am Anfang. PNG beginnt mit \x89PNG\r\n\x1a\n, JPEG mit \xFF\xD8\xFF:
fn erkenne_format(daten: &[u8]) -> &'static str {
if daten.starts_with(b"\x89PNG\r\n\x1a\n") { return "PNG"; }
if daten.starts_with(&[0xFF, 0xD8, 0xFF]) { return "JPEG"; }
if daten.starts_with(b"GIF87a") || daten.starts_with(b"GIF89a") { return "GIF"; }
"unbekannt"
}Hier ist Byte-View (&[u8]) genau richtig — kein UTF-8 nötig. Die binären Magic-Bytes interessieren als rohe Bytes, nicht als Text.
Häufige Stolperfallen
s.len() ist die Byte-Länge, nicht die Zeichen-Anzahl.
Der häufigste Fallstrick. "Café".len() ist 5 (vier Bytes für „Caf" plus 2 für „é"... eigentlich 5). "Café".chars().count() ist 4. Wer eine UI-Wortzählung oder ein Zeichen-Limit braucht, immer chars().count() nutzen.
&s[i..j] kann zur Laufzeit panicken.
Bei Multi-Byte-Inhalten ist das ein realistisches Risiko. In produktivem Code mit user-input besser s.get(i..j) (gibt Option) oder vorher is_char_boundary prüfen.
find liefert Byte-Indices — auch bei chars.
s.find('é') gibt die Byte-Position, nicht die char-Position. Das ist Absicht: die zurückgegebene Position ist sofort zum Slicing nutzbar (&s[..pos]) — eine char-Position müsste man erst in eine Byte-Position konvertieren.
Iteration über bytes() ist NICHT Iteration über chars().
"Müller".bytes().count() ist 7, "Müller".chars().count() ist 6. bytes() liefert u8-Werte (rohe UTF-8-Bytes), chars() dekodierte 4-Byte-Code-Points. Für Text-Analyse fast immer chars().
Slice-Grenzen von find/rfind/split sind garantiert valide.
Du musst dir um is_char_boundary keine Sorgen machen, wenn der Index aus einer Suchfunktion stammt. Die Stdlib gibt nur Byte-Positionen zurück, die wirklich Zeichen-Grenzen sind. Erst bei manuell konstruierten Indices wird's gefährlich.
Strings sind UTF-8, aber OsString/PathBuf nicht zwingend.
Dateinamen auf Linux dürfen beliebige Bytes (außer 0) sein, auf Windows UTF-16-Surrogates. Deshalb gibt es OsString und OsStr als Sondertypen — sie können nicht naiv in String konvertiert werden. Genauer im Standard-Library-Kapitel.
as_bytes() ist kostenlos, into_bytes() konsumiert den String.
s.as_bytes() gibt &[u8] — Borrow, kein Move. s.into_bytes() konsumiert den String und gibt Vec<u8> — der String ist danach weg. Beide sind ohne Allokation, weil der Speicher derselbe ist.
Byte-Literale sind &[u8; N], keine &str.
b"Hallo" hat Typ &[u8; 5]. Der wichtige Unterschied: keine UTF-8-Garantie, kein direkter String-Vergleich mit &str. Praktisch für Binär-Protokolle, irreführend, wenn man irrtümlich denkt, das wäre ein „byte-String" im JavaScript-Sinne.
Weiterführende Ressourcen
Externe Quellen
- std::primitive.str – Slicing-Methoden
- std::str::from_utf8
- str::is_char_boundary
- str::floor_char_boundary (stable 1.79)
- UTF-8 (Wikipedia)