Iterating over a string in Rust: how it works under the hood
Have you ever wondered what actually happens when you write for c in "Привет".chars()? If you come from Python or JavaScript, where strings are just arrays of characters, Rust may seem like a strict teacher who makes you think before every action. And that's great!
The question "How do you iterate over a string by character" on Stack Overflow has gathered thousands of views. And for good reason. In Rust, there is no simple string[i] — and there are good reasons for that. Let's figure out how to iterate over a string correctly, efficiently, and without surprises.
Why doesn't Rust just let you get a character by index?
It's all about UTF-8 encoding. Unlike many languages, Rust stores strings in UTF-8, not ASCII or UCS-2. This means that one character can take from 1 to 4 bytes. For example:
'a'— 1 byte'é'— 2 bytes'中'— 3 bytes'😊'— 4 bytes
If Rust allowed string[0], then "😊"[0] would return not the emoji, but half of its byte representation — garbage. Therefore, Rust protects you from such errors at the compiler level.
Main ways to iterate over a string
Rust offers three main iteration methods, each with its own purpose. The choice depends on what exactly you need: characters, bytes, or graphemes.
1. Iteration by characters (char)
The most common way is the .chars() method. It returns an iterator over Unicode characters (type char).
fn main() { let text = "Привет, мир! 🌍"; for c in text.chars() { println!("{}", c); } // Output: // П // р // и // в // е // т // , // (space) // м // и // р // ! // (space) // 🌍}Under the hood, .chars() parses the UTF-8 byte sequence and returns each valid character. If the string contains invalid bytes, the iterator may return std::char::REPLACEMENT_CHARACTER ('�').
Important: .chars() is O(n). The iterator goes through all the bytes, decoding them on the fly. For short strings this is instantaneous, but for long ones (megabytes of text) it can be noticeable.
2. Iteration by bytes (bytes)
If you need raw bytes, use .bytes(). This is a fast way, but you get bytes, not characters.
fn main() { let text = "Hello"; for byte in text.bytes() { println!("{}", byte); } // Output: // 72 (H) // 101 (e) // 108 (l) // 108 (l) // 111 (o)}For English text, this is equivalent to iterating by characters. But for Russian or emoji — it is not. For example, "Привет" in bytes will be 12 bytes (2 per character), and "🌍" — 4 bytes.
When to use: when you are working with binary data, protocols, or want maximum performance without UTF-8 decoding.
3. Iteration by grapheme clusters
Now this is more interesting. Sometimes one visual "character" consists of several Unicode characters. For example, a country flag or a letter with diacritical marks: "é" can be represented as a single character é (U+00E9) or as a combination of e + ◌́ (U+0065 + U+0301).
To work with graphemes, you need to add the unicode-segmentation crate:
// Cargo.toml// [dependencies]// unicode-segmentation = "1.10"
use unicode_segmentation::UnicodeSegmentation;
fn main() { let text = "é"; // chars() will return 1 ch