Rust strings in practice¶
Level: 201 → 301 · for anyone starting from zero
One line: Take &str, return String, never invent a byte index, ask for the length you actually meant, and let &[u8] mean "not checked yet" — Rust's strings are not hard, they are the UTF-8 everywhere rules with the compiler holding them for you.
Why Rust feels strict here, and why it is not¶
Rust's string types answer a question other languages leave open: have these bytes been checked?
&[u8]/Vec<u8>— bytes. Could be anything. A PNG, a Latin-1 file, half a packet.&str/String— bytes that are guaranteed valid UTF-8. Not "probably", not "we hope": there is no safe way to construct one that is not.char— one Unicode scalar value, four bytes wide in memory, and not what a person calls a character.OsStr/Path— the operating system's idea of a string, which is not guaranteed to be UTF-8 at all.
Every complaint about Rust strings is really one of two things: reaching for a length without saying which length, or trying to index into text by a number you made up. Both are questions the other languages let you get wrong quietly. The rules below are short because the type system is doing the remembering.
The checklist¶
Take &str, return String. A &str can be borrowed from a String, a literal, or a slice of either, so one signature serves every caller with no allocation. &String narrows your function for nothing. (impl AsRef<str> when you want to be generous; Cow<str> when you might not need to allocate.)
len() is bytes. It is the shortest name and the one you mean least often. .chars().count() is code points. Grapheme clusters — what a cursor moves over — need the unicode-segmentation crate; there is no std substitute and there is not going to be one, because the tables move with every Unicode release.
Never invent a byte index. Indices that came out of std — find, char_indices, split, match_indices — are always character boundaries. An index you computed by counting letters is not, and &s[0..1] panics rather than handing you half a letter. Use s.get(range), which returns Option<&str>, anywhere the range is not obviously yours.
The ascii_ methods are a different tool, not a faster one. to_ascii_lowercase leaves every non-ASCII byte alone — correct and branch-free for a protocol token, an HTTP header name, a hex digit; wrong for anything a person typed. to_lowercase is the Unicode one. eq_ignore_ascii_case has the same split. And note that case conversion can change the length: "ß".to_uppercase() is "SS".
Decode once, at the boundary. std::str::from_utf8(bytes) returns Result and its error carries valid_up_to(), which is how a tool reports where a file stopped making sense. from_utf8_lossy replaces bad bytes with U+FFFD — fine for a message a human will read, never for data you will write back out, because the original bytes are gone. unsafe { from_utf8_unchecked } is for a hot path where something else already checked, and nowhere else: a &str that is not valid UTF-8 is undefined behaviour, not just a wrong answer.
Path is not a str. A Unix filename is any bytes except NUL and /; a Windows one is UTF-16 units that may include unpaired surrogates. So Path::to_str() returns Option<&str> and to_string_lossy() returns a Cow. Handle the None rather than unwrapping it, in anything that will one day be pointed at somebody else's disk. (std::os::unix::ffi::OsStrExt is the escape hatch when you truly need the bytes.) The third door, into_string(), is the one to reach for when you need a String and might not get one: its Err hands the name back.
When it is not text, say so. Binary is &[u8] and Vec<u8>; a byte-string literal is b"…". Do not launder binary through String on its way past. The type is the documentation: &[u8] says "unknown bytes", &str says "already checked", and a function signature that says the right one is a comment that cannot go stale.
Two crates worth knowing about, because their absence from std is deliberate: unicode-segmentation (grapheme clusters, word boundaries) and unicode-normalization (NFC/NFD/NFKC). bstr is the third, for when you want string-ish operations on bytes that are mostly UTF-8 — log processing, grep-like tools.
In Rust¶
Verified output of rust_strings_in_practice_rs.rs — regenerated by tools/run_examples.py, never hand-typed.
1. TAKE &str, RETURN String, AND STOP THINKING ABOUT IT
------------------------------------------------------------------------
shout("literal") = "LITERAL"
shout(&owned) = "PŁYTA"
shout(&owned[0..3])= "PŁ"
One function, three callers, no allocation to call it. A &str
is a borrowed window onto bytes that are already valid UTF-8;
String owns them. That is the whole distinction.
2. THREE LENGTHS. THE COMPILER WILL NOT PICK ONE FOR YOU.
------------------------------------------------------------------------
bytes 5 chars 5 "hello"
bytes 7 chars 4 "Łódź"
bytes 5 chars 4 "café"
bytes 6 chars 5 "cafe\u{301}"
bytes 9 chars 3 "日本語"
Rows 3 and 4 look identical and are not: 'café' composed is 4
chars, decomposed is 5. `len()` is BYTES — it is the one people
mean least often and reach for most, because it is the short name.
3. NEVER INVENT A BYTE INDEX. ASK FOR ONE.
------------------------------------------------------------------------
s.find('d') = Some(4) <- a real boundary, from std
s.get(0..2) = Some("Ł")
s.get(0..1) = None <- inside a character: None
s.char_indices() = [(0, 'Ł'), (2, 'ó'), (4, 'd'), (5, 'ź')]
s.chars().next() = Some('Ł')
Indices that came from std (`find`, `char_indices`, `split`) are
always boundaries. An index you computed by counting letters is
not, and `&s[0..1]` panics rather than returning half a letter.
4. ASCII METHODS ARE FAST AND WRONG; UNICODE ONES ARE RIGHT AND SLOWER
------------------------------------------------------------------------
"Straße"
to_lowercase() "straße"
to_ascii_lowercase() "straße" <- non-ASCII left alone
"ŁÓDŹ"
to_lowercase() "łódź"
to_ascii_lowercase() "ŁÓdŹ" <- non-ASCII left alone
Use the ascii_ methods when the data really is ASCII (a protocol
token, a hex digit, an HTTP header name) — they are branch-free.
Use the Unicode ones for anything a person typed.
And note the length change, which is why case is not per-char:
"ß".to_uppercase() = "SS", 2 chars from 1
5. REVERSING BY chars() IS NOT REVERSING A WORD
------------------------------------------------------------------------
composed "café" reversed -> "éfac"
decomposed "cafe\u{301}" reversed -> "\u{301}efac"
The accent came off the 'e' and landed on the front. `chars()`
yields Unicode SCALAR VALUES, and a user-perceived character can
be several of them — that is what the `unicode-segmentation`
crate is for, and there is no std substitute. Same reason emoji
families and flags cannot be counted with `chars().count()`.
6. AT THE BOUNDARY: BYTES IN, CHECKED TEXT OUT
------------------------------------------------------------------------
from_utf8(good) -> Ok("order,café,2")
from_utf8(latin-1) -> Err: invalid utf-8 sequence of 1 bytes from index 9
lossy -> "order,caf�,2"
Decode once, at the edge, and carry &str inwards. `from_utf8` for
data you will act on; `from_utf8_lossy` only for something a human
reads, because it destroys the bytes it could not understand.
`unsafe { from_utf8_unchecked }` is for a checked-elsewhere hot
path and nothing else: a &str that lies is undefined behaviour.
7. PATHS AND ARGUMENTS ARE NOT &str, AND THAT IS NOT PEDANTRY
------------------------------------------------------------------------
OsStr::to_str() = Some("report.csv")
OsStr::to_string_lossy() = "report.csv"
A Unix filename is any bytes except NUL and '/', and a Windows
one is any UTF-16 units including unpaired surrogates. Neither
is guaranteed to be UTF-8, so `Path` is not a `str` — handle the
`None` from `to_str()` rather than unwrapping it in a tool that
will one day be pointed at somebody else's disk.
8. WHEN THE DATA IS NOT TEXT, SAY SO IN THE TYPE
------------------------------------------------------------------------
b"\x89PNG..." = [89, 50, 4e, 47, 0d, 0a, 1a, 0a]
is it UTF-8? false
Binary is `&[u8]`/`Vec<u8>`, and it should never be forced through
String on the way past. The type IS the documentation: `&[u8]`
says 'unknown bytes', `&str` says 'already checked'.
If you are coming from Python or ABAP¶
Python. String is a bytearray that has promised to stay valid UTF-8; &str is a memoryview of one. The mapping is almost exact: str ↔ &str/String, bytes ↔ &[u8]/Vec<u8>, and bytes.decode('utf-8') ↔ str::from_utf8. Two real differences to hold on to. Python's s[0] is a character and Rust has no such operator at all — because Python pays for it with a variable-width internal representation (PEP 393) that Rust will not, so chars().nth(0) is a walk and Rust makes the cost visible. And UnicodeDecodeError becomes a Result you must handle: the same information, moved from runtime to the type signature, exactly as sy-subrc becomes Result.
ABAP. The three-way split is one you already live with: string ↔ &str/String, xstring ↔ Vec<u8>, and cl_abap_codepage=>convert_from ↔ str::from_utf8. What Rust adds is that the conversion cannot be skipped — there is no path from xstring to string that does not go through a check, so the class of bug where raw bytes end up in a character field is not expressible. The strlen / xstrlen distinction you already make by hand is chars().count() / len(), with the difference that Rust's short name is the byte one, which is the opposite of ABAP's instinct and worth a second look in review. (Not machine-checked — CI cannot run ABAP.)
Try it¶
cd 10_Best_Practices/rust_strings_in_practice/examples
rustc --edition 2024 rust_strings_in_practice_rs.rs -o /tmp/rsprac && /tmp/rsprac
Without the machine: you are writing a function that truncates a name to fit a 20-byte database column without cutting a character in half. Which of the three lengths do you check, which std method finds the cut point, and what does it return when the string is already short enough?
Practice¶
Five signatures. Write the parameter and return types for a function that counts characters and one that uppercases — and say what taking String instead of &str costs every caller.
Then: give the correct way to take the first three characters without inventing a byte index; say which length method answers which question; explain what &[u8] means in a signature that &str does not; and name the one-line function the compiler will simply not let you write.
Answers
Verified output of rust_strings_in_practice_kata_rs.rs — regenerated by tools/run_examples.py, never hand-typed.
1. TAKE &str, RETURN String
count_chars(&owned) 4
count_chars("café") 4
shout(&owned) "CAFÉ"
A &str parameter accepts a String, a literal and a slice, so
it costs the caller nothing. Taking String forces every caller
to give up ownership or clone -- the single most common
avoidable allocation in a Rust codebase.
2. NEVER INVENT A BYTE INDEX
forbidden: &s[0..4] -- 4 may be inside a character
correct: chars().take(3) -> "caf"
char_indices() gives you real boundaries when you need offsets:
(0, 'c') (1, 'a') (2, 'f') (3, 'é')
Note the gap: the last index is 3 and the string is 5 bytes,
because é occupies 3 and 4. There is no boundary at 4.
3. ASK FOR THE LENGTH YOU MEANT
len() 5 bytes -- for buffers and field limits
chars().count() 4 code points -- for most 'how long is this'
Neither is 'characters' as a person means it; that needs a
grapheme crate, and needing one is the honest answer.
4. LET &[u8] MEAN 'NOT CHECKED YET'
from_utf8 err at byte 3 -- and the type says so
"caf�"
A function taking &[u8] is saying 'these may not be text'. A
function taking &str is saying 'somebody already checked'. The
boundary between them is the only place a decode belongs.
5. THE SIGNATURE THE COMPILER REFUSES
fn first(s: &str) -> char { s[0] } // does not compile
String has no Index<usize>, so the O(1) indexing every other
language offers is simply absent -- not slow, absent. That is
the UTF-8-everywhere rules with the compiler holding them:
the operation that would silently split a character is not
available to be written by accident.
See also¶
- UTF-8 everywhere — the same rules, language-independent
- 05_Rust — the mechanics:
String,char,from_utf8, byte slicing - Why UTF-8 won — why
from_utf8can refuse at all - Anatomy of a String ↗ — the sibling library's page on the layout
- Meet the
char↗ — and its page onchar - The Rust Book, ch. 8.2 ↗ — the official chapter, which is short