Strings and Text Processing
Character Count vs. Byte Count
Unicode text has three distinct levels of representation—UTF-8 bytes, Unicode scalar values (char), and extended grapheme clusters—each yielding a different count for non-ASCII or composite text.
The Three Levels of Text Granularity
| Level | Rust API | Time Complexity | What It Counts |
|---|---|---|---|
| 1. UTF-8 Bytes | s.len() or s.bytes().count() |
$O(1)$ | Raw encoded bytes (1..=4 bytes per char) |
2. Unicode Scalar Values (char) |
s.chars().count() |
$O(n)$ | Code points (U+0000..=U+D7FF and U+E000..=U+10FFFF), including standalone combining marks and zero-width joiners |
| 3. Extended Grapheme Clusters | External unicode-segmentation crate (s.graphemes(true).count()) |
$O(n)$ | User-perceived visual characters (a base character plus any combining accents or emoji sequences) |
How Combining Marks and Emoji Sequences Diverge
A single visual character on screen (a grapheme cluster) can be composed of multiple Unicode scalar values (chars):
- Decomposed accents:
"e\u{0301}"(visualé) consists of twochars—'e'(U+0065, 1 byte) followed by the combining acute accentU+0301(2 bytes)—totaling 3 bytes and 2chars for 1 visual character. - Indic consonant + vowel signs:
"কি"consists of'ক'(U+0995, 3 bytes) + vowel sign'ি'(U+09BF, 3 bytes)—totaling 6 bytes and 2chars. - Zero-Width Joiner (ZWJ) emoji sequences:
"👩💻"(woman technologist) is'👩'(U+1F469, 4 bytes) + ZWJ (U+200D, 3 bytes) +'💻'(U+1F4BB, 4 bytes)—totaling 11 bytes and 3chars for 1 visual emoji.
fn analyze(label: &str, s: &str) {
let chars: Vec<char> = s.chars().collect();
println!(
"{label:<22} -> bytes: {:>2}, chars: {} {:?}",
s.len(),
chars.len(),
chars
);
}
fn main() {
analyze("Precomposed 'é'", "\u{00E9}");
analyze("Decomposed 'e' + '◌́'", "e\u{0301}");
analyze("Bengali 'কি'", "কি");
analyze("Emoji ZWJ '👩💻'", "👩\u{200D}💻");
}
Precomposed 'é' -> bytes: 2, chars: 1 ['é']
Decomposed 'e' + '◌́' -> bytes: 3, chars: 2 ['e', '\u{301}']
Bengali 'কি' -> bytes: 6, chars: 2 ['ক', 'ি']
Emoji ZWJ '👩💻' -> bytes: 11, chars: 3 ['👩', '\u{200d}', '💻']
Fast Path When a String Is Pure ASCII (s.is_ascii())
When s.is_ascii() returns true (every byte is in 0x00..=0x7F), each char is guaranteed to be exactly 1 byte, so s.len() equals s.chars().count():
fn char_count_fast(s: &str) -> usize {
if s.is_ascii() {
s.len() // O(1) when ASCII
} else {
s.chars().count() // O(n) UTF-8 scan when non-ASCII bytes are present
}
}
fn main() {
println!("ASCII count: {}", char_count_fast("hello_world"));
println!("Unicode count: {}", char_count_fast("হ্যালো"));
}
ASCII count: 11
Unicode count: 6