Lesson 17 of 94
Strings in Depth: String vs &str
With ownership, references, and slices behind you, Rust's two string types finally make sense:
Stringis an owned, growable, heap-allocated buffer of UTF-8 text.&stris a borrowed view of UTF-8 text owned by something else: aString, or the program binary for literals.
| Task | Rust |
|---|---|
| Read or own text | &str for reading, String for owning |
| Build text | String (with mut) |
| Get the nth character | s.chars().nth(i) (there is no O(1) character indexing) |
| Get the length | s.len() (UTF-8 bytes) or s.chars().count() (characters) |
| Take a substring | &s[a..b] (byte range, borrowed) |
| Split text | s.split(',') returns a lazy iterator |
| Concatenate | s1 + &s2 (moves s1), or format!("{s1}{s2}") |
| Parse an integer | s.parse::<i32>() returns a Result |
The rule of thumb: functions take &str and return String. Structs store String.
1. The two types side by side
fn main() {
let borrowed: &str = "I live in the binary";
let owned: String = String::from("I live on the heap");
let view: &str = &owned; // borrow the String as &str
let also_view: &str = owned.as_str(); // same thing, spelled out
println!("{borrowed}\n{owned}\n{view}\n{also_view}");
}I live in the binary
I live on the heap
I live on the heap
I live on the heap2. Every way to make a String
fn main() {
let a = String::from("from");
let b = "to_string".to_string();
let c = "to_owned".to_owned();
let d: String = "into".into();
let e = format!("{}-{}", "format", 1);
let f = String::with_capacity(64); // empty, with room reserved
let g = 'x'.to_string();
let h = 42.to_string();
println!("{a} {b} {c} {d} {e} [{f}] {g} {h}");
}from to_string to_owned into format-1 [] x 42.into() converts a value into the type the context expects. Conversion Traits explains how it works.
3. Growing a String
fn main() {
let mut s = String::from("Hello");
s.push(',');
s.push_str(" world");
s.insert(0, '>');
s.insert_str(1, "> ");
println!("{s}");
}>> Hello, world4. Removing from a String
fn main() {
let mut s = String::from("[draft] Release notes!!!");
s.pop(); // "[draft] Release notes!!"
let removed = s.remove(0); // removes '[' -> "draft] Release notes!!"
s.replace_range(0..6, "Final:"); // "draft]" -> "Final:"
s.truncate(s.len() - 2); // keep all but the last 2 bytes
println!("{s} (removed {removed:?})");
s.clear();
println!("after clear: {:?}, capacity kept: {}", s, s.capacity() > 0);
}Final: Release notes (removed '[')
after clear: "", capacity kept: true5. Concatenation with + moves the left side
a + &b takes ownership of a, appends b to it, and returns it. No new buffer is allocated if a has room. The right side must be a &str.
fn main() {
let greeting = String::from("Hello, ");
let name = String::from("Ferris");
let message = greeting + &name + "!";
println!("{message}");
println!("name is still usable: {name}");
}Hello, Ferris!
name is still usable: FerrisUsing greeting afterwards is an error, because + moved it:
fn main() {
let greeting = String::from("Hello, ");
let name = String::from("Ferris");
let message = greeting + &name;
println!("{greeting}");
}error[E0382]: borrow of moved value: `greeting`
--> src/main.rs:5:16
|
2 | let greeting = String::from("Hello, ");
| -------- move occurs because `greeting` has type `String`, which does not implement the `Copy` trait
3 | let name = String::from("Ferris");
4 | let message = greeting + &name;
| -------- value moved here
5 | println!("{greeting}");
| ^^^^^^^^ value borrowed here after move
|
help: consider cloning the value if the performance cost is acceptable
|
4 | let message = greeting.clone() + &name;
| ++++++++6. Prefer format! for combining several pieces
format! never takes ownership of its arguments and reads much better than a chain of +.
fn main() {
let street = String::from("221B Baker Street");
let city = "London";
let postcode = String::from("NW1 6XE");
let address = format!("{street}, {city} {postcode}");
println!("{address}");
println!("parts still usable: {street} / {postcode}");
}221B Baker Street, London NW1 6XE
parts still usable: 221B Baker Street / NW1 6XE7. You cannot index a string by position
fn main() {
let s = String::from("hello");
let first = s[0];
println!("{first}");
}error[E0277]: the type `str` cannot be indexed by `{integer}`
--> src/main.rs:3:19
|
3 | let first = s[0];
| ^ string indices are ranges of `usize`
|
= help: the trait `SliceIndex<str>` is not implemented for `{integer}`
= note: you can use `.chars().nth()` or `.bytes().nth()`
for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>
help: the following other types implement trait `SliceIndex<T>`
--> /rustc/<version>/library/core/src/slice/index.rs:214:0
|
= note: `usize` implements `SliceIndex<[T]>`
--> /rustc/<version>/library/core/src/bstr/traits.rs:203:0
|
= note: `usize` implements `SliceIndex<ByteStr>`
= note: required for `String` to implement `Index<{integer}>`Why? A Rust string is UTF-8, where one character takes 1 to 4 bytes. s[0] would be ambiguous (a byte? a character?), and finding the nth character requires scanning from the start, which Rust refuses to hide behind cheap-looking syntax.
8. Bytes versus characters
fn main() {
let words = ["hello", "héllo", "日本", "🦀"];
for w in words {
println!("{w:>6}: {} bytes, {} chars", w.len(), w.chars().count());
}
} hello: 5 bytes, 5 chars
héllo: 6 bytes, 5 chars
日本: 6 bytes, 2 chars
🦀: 4 bytes, 1 charslen() is O(1) and counts bytes. chars().count() walks the string and counts characters.
9. Getting the nth character
fn main() {
let word = "naïve";
println!("{:?}", word.chars().nth(2));
println!("{:?}", word.chars().nth(10));
println!("first: {:?}, last: {:?}", word.chars().next(), word.chars().last());
}Some('ï')
None
first: Some('n'), last: Some('e')10. Iterating over characters, bytes, and positions
fn main() {
let s = "añb";
for c in s.chars() {
print!("{c} ");
}
println!();
for b in s.bytes() {
print!("{b} ");
}
println!();
for (byte_index, c) in s.char_indices() {
print!("{c}@{byte_index} ");
}
println!();
}a ñ b
97 195 177 98
a@0 ñ@1 b@3char_indices gives each character with its byte position, which is exactly what you need to slice safely.
11. Truncating text safely
Slicing by bytes can panic on multi-byte characters (see Slices). To cut text to a number of characters, find the byte position of that character first.
fn truncate_chars(s: &str, max_chars: usize) -> &str {
match s.char_indices().nth(max_chars) {
Some((byte_index, _)) => &s[..byte_index],
None => s,
}
}
fn main() {
println!("{}", truncate_chars("Zürich is lovely", 3));
println!("{}", truncate_chars("🦀🦀🦀🦀", 2));
println!("{}", truncate_chars("short", 10));
}Zür
🦀🦀
short12. Comparing strings
== compares contents. < and > compare in byte order, which for ASCII is alphabetical with all uppercase letters before lowercase. For case-insensitive comparison of ASCII, use eq_ignore_ascii_case.
fn main() {
let a = String::from("apple");
let b = "apple";
println!("{}", a == b);
println!("{}", "apple" < "banana");
println!("{}", "Zebra" < "apple");
println!("{}", "HELLO".eq_ignore_ascii_case("hello"));
println!("{}", "Straße".to_lowercase() == "straße");
}true
true
true
true
true13. Case conversion handles all of Unicode
fn main() {
println!("{}", "straße".to_uppercase());
println!("{}", "ΟΔΥΣΣΕΥΣ".to_lowercase());
println!("{}", "rust".to_ascii_uppercase());
}STRASSE
οδυσσευς
RUSTß becomes SS, so uppercasing can change the length. Greek final sigma becomes ς at the end of a word.
14. Trimming
fn main() {
let raw = "\t padded value \n";
println!("[{}]", raw.trim());
println!("[{}]", raw.trim_start());
println!("[{}]", raw.trim_end());
println!("[{}]", "xx_id_xx".trim_matches('x'));
println!("[{}]", "0004200".trim_start_matches('0'));
}[padded value]
[padded value
]
[ padded value]
[_id_]
[4200]All trim methods return a &str slice of the original. They never allocate.
15. Searching
fn main() {
let log = "2026-09-27 ERROR payment failed: card declined";
println!("{}", log.contains("ERROR"));
println!("{}", log.starts_with("2026"));
println!("{}", log.ends_with("declined"));
println!("{:?}", log.find(':'));
println!("{:?}", log.rfind(' '));
println!("{:?}", log.find("WARN"));
println!("{}", log.matches('e').count());
}true
true
true
Some(31)
Some(37)
None
4find returns the byte position as Some(position), or None when not found.
16. Removing a known prefix or suffix
strip_prefix returns Some(rest) only if the prefix was there, so you can check and cut in one step.
fn main() {
let header = "Bearer abc.def.ghi";
println!("{:?}", header.strip_prefix("Bearer "));
println!("{:?}", "Basic xyz".strip_prefix("Bearer "));
println!("{:?}", "report.csv".strip_suffix(".csv"));
}Some("abc.def.ghi")
None
Some("report")17. Splitting
split returns an iterator of &str slices. Loop over it, or collect() it into a Vec<&str> (explained in Iterators).
fn main() {
let csv = "id,name,,email";
let fields: Vec<&str> = csv.split(',').collect();
println!("{:?}", fields);
let words: Vec<&str> = " many spaces here ".split_whitespace().collect();
println!("{:?}", words);
let limited: Vec<&str> = "a=b=c".splitn(2, '=').collect();
println!("{:?}", limited);
println!("{:?}", "user@mail.example.com".split_once('@'));
println!("{:?}", "a.b.c.txt".rsplit_once('.'));
}["id", "name", "", "email"]
["many", "spaces", "here"]
["a", "b=c"]
Some(("user", "mail.example.com"))
Some(("a.b.c", "txt"))Rust's split takes a plain string or character, not a regular expression, and it keeps empty fields.
18. Splitting into lines
lines() handles both \n and \r\n line endings.
fn main() {
let text = "first line\r\nsecond line\nthird line";
for (n, line) in text.lines().enumerate() {
println!("{}: {line}", n + 1);
}
}1: first line
2: second line
3: third line19. Replacing
fn main() {
let path = "C:\\Users\\ada\\docs";
println!("{}", path.replace('\\', "/"));
println!("{}", "aaa".replacen("a", "b", 2));
println!("{}", "-".repeat(20));
}C:/Users/ada/docs
bba
--------------------20. Parsing numbers from strings
parse can fail, so it returns a Result: Ok(value) or Err(error). Result & the ? Operator covers Result properly. Here, unwrap_or supplies a fallback value.
fn main() {
let port: u16 = "8080".parse().unwrap_or(80);
let bad: u16 = "eighty".parse().unwrap_or(80);
let too_big: u16 = "70000".parse().unwrap_or(80);
let ratio: f64 = " 0.75 ".trim().parse().unwrap_or(0.0);
println!("{port} {bad} {too_big} {ratio}");
println!("{:?}", "12x".parse::<i32>());
}8080 80 80 0.75
Err(ParseIntError { kind: InvalidDigit })parse::<i32>() names the target type with the turbofish ::<> instead of an annotation on the variable.
21. Bytes to text: from_utf8 can fail
Not every sequence of bytes is valid UTF-8, so converting bytes to a String returns a Result.
fn main() {
let valid = vec![82, 117, 115, 116];
let invalid = vec![0xFF, 0xFE];
println!("{:?}", String::from_utf8(valid));
println!("{}", String::from_utf8(invalid).is_err());
println!("{}", String::from_utf8_lossy(&[72, 105, 0xFF]));
}Ok("Rust")
true
Hi�from_utf8_lossy replaces invalid bytes with � instead of failing.
22. Reversing a string
Reverse by characters, not bytes, or multi-byte characters break.
fn main() {
let word = "stressed";
let reversed: String = word.chars().rev().collect();
println!("{reversed}");
let accented: String = "café".chars().rev().collect();
println!("{accented}");
}desserts
éfac23. Building strings efficiently
Each + or format! in a loop may reallocate. Reserving capacity and using push_str into one String avoids that.
fn main() {
let items = ["alpha", "beta", "gamma", "delta"];
let mut out = String::with_capacity(64);
for (i, item) in items.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
out.push_str(item);
}
println!("{out} (len {}, capacity {})", out.len(), out.capacity());
}alpha, beta, gamma, delta (len 25, capacity 64)24. &str parameters, String returns
A function that only reads text should take &str, which accepts every kind of text. A function that creates text should return String, handing ownership to the caller.
fn initials(full_name: &str) -> String {
let mut result = String::new();
for word in full_name.split_whitespace() {
if let Some(c) = word.chars().next() {
result.push(c.to_ascii_uppercase());
result.push('.');
}
}
result
}
fn main() {
let owned = String::from("grace brewster hopper");
println!("{}", initials(&owned));
println!("{}", initials("alan turing"));
println!("{}", initials(&owned[6..]));
}G.B.H.
A.T.
B.H.25. Realistic example: URL slugs
fn slugify(title: &str) -> String {
let mut slug = String::new();
let mut last_was_dash = true; // avoids a leading dash
for c in title.chars() {
if c.is_ascii_alphanumeric() {
slug.push(c.to_ascii_lowercase());
last_was_dash = false;
} else if !last_was_dash {
slug.push('-');
last_was_dash = true;
}
}
if slug.ends_with('-') {
slug.pop();
}
slug
}
fn main() {
println!("{}", slugify("Rust for Java Developers: Part 1!"));
println!("{}", slugify(" --Hello, World-- "));
}rust-for-java-developers-part-1
hello-world26. Realistic example: masking a card number
Keep the last four digits and replace the rest, ignoring spaces.
fn mask_card(number: &str) -> String {
let digits: String = number.chars().filter(|c| c.is_ascii_digit()).collect();
let visible = &digits[digits.len() - 4..];
format!("{}{}", "*".repeat(digits.len() - 4), visible)
}
fn main() {
println!("{}", mask_card("4111 1111 1111 1234"));
println!("{}", mask_card("5500-0000-0000-0004"));
}************1234
************000427. Realistic example: parsing a CSV line with quoted fields
Real CSV fields may contain commas inside quotes. A small state machine walks the characters once.
fn parse_csv_line(line: &str) -> Vec<String> {
let mut fields = Vec::new();
let mut current = String::new();
let mut in_quotes = false;
for c in line.chars() {
if c == '"' {
in_quotes = !in_quotes;
} else if c == ',' && !in_quotes {
fields.push(current);
current = String::new();
} else {
current.push(c);
}
}
fields.push(current);
fields
}
fn main() {
let line = r#"1042,"Hopper, Grace",grace@navy.mil,"Arlington, VA""#;
for (i, field) in parse_csv_line(line).iter().enumerate() {
println!("field {i}: {field}");
}
}field 0: 1042
field 1: Hopper, Grace
field 2: grace@navy.mil
field 3: Arlington, VANotice fields.push(current) moves the finished field into the vector, and current = String::new() gives the variable a fresh value, the pattern from Ownership.
28. Realistic example: a template renderer
Replace {{name}} placeholders with values. The template and values are borrowed, and the result is a new owned String.
fn render(template: &str, values: &[(&str, &str)]) -> String {
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some(start) = rest.find("{{") {
out.push_str(&rest[..start]);
let after = &rest[start + 2..];
match after.find("}}") {
Some(end) => {
let key = after[..end].trim();
let mut replaced = false;
for (k, v) in values {
if *k == key {
out.push_str(v);
replaced = true;
}
}
if !replaced {
out.push_str("<missing>");
}
rest = &after[end + 2..];
}
None => {
out.push_str(&rest[start..]);
rest = "";
}
}
}
out.push_str(rest);
out
}
fn main() {
let template = "Hi {{ name }}, your order {{order}} ships {{when}}.";
let values = [("name", "Ada"), ("order", "#8812")];
println!("{}", render(template, &values));
}Hi Ada, your order #8812 ships <missing>.while let repeats as long as the pattern matches. It is covered with if let in Pattern Matching.
29. Realistic example: word wrapping
Wrap text to a maximum line width without breaking words.
fn wrap(text: &str, width: usize) -> Vec<String> {
let mut lines = Vec::new();
let mut line = String::new();
for word in text.split_whitespace() {
let needed = if line.is_empty() { word.len() } else { line.len() + 1 + word.len() };
if needed > width && !line.is_empty() {
lines.push(std::mem::take(&mut line));
}
if !line.is_empty() {
line.push(' ');
}
line.push_str(word);
}
if !line.is_empty() {
lines.push(line);
}
lines
}
fn main() {
let text = "Ownership rules are checked at compile time, so they cost nothing at runtime.";
for line in wrap(text, 24) {
println!("|{line:<24}|");
}
}|Ownership rules are |
|checked at compile time,|
|so they cost nothing at |
|runtime. |std::mem::take(&mut line) moves the finished line out and leaves an empty String behind, as shown in Mutable References.
30. Realistic example: a simple tokenizer
Splitting an arithmetic expression into numbers and operators. Each token is a slice of the input, so no text is copied.
fn tokenize(expr: &str) -> Vec<&str> {
let mut tokens = Vec::new();
let mut start: Option<usize> = None;
for (i, c) in expr.char_indices() {
if c.is_ascii_digit() || c == '.' {
if start.is_none() {
start = Some(i);
}
continue;
}
if let Some(s) = start {
tokens.push(&expr[s..i]);
start = None;
}
if "+-*/()".contains(c) {
tokens.push(&expr[i..i + 1]);
}
}
if let Some(s) = start {
tokens.push(&expr[s..]);
}
tokens
}
fn main() {
println!("{:?}", tokenize("12.5 * (3 + 40) / 2"));
}["12.5", "*", "(", "3", "+", "40", ")", "/", "2"]Summary
Stringowns its text and can grow.&strborrows text owned elsewhere. Literals are&str.- Take
&strparameters, returnString, storeStringin structs. - Strings are UTF-8.
len()counts bytes, and you cannot index by position. Usechars(),char_indices(), andbytes(). a + &bmovesa. Preferformat!for several parts, andpush_strin loops.- Search with
contains,find,starts_with,strip_prefix; split withsplit,split_once,split_whitespace,lines; clean withtrimandreplace. parseandString::from_utf8returnResult, because they can fail.
Next: Ownership & Borrowing with Collections, which applies everything so far to Vec, HashMap, and nested data.
