4. Borrowing and strings
Full example: examples/l04_borrowing.rs — cargo run --example l04_borrowing.
Borrowing instead of moving
Section titled “Borrowing instead of moving”Lesson 3 ended with a function that “stole” its argument. Most of the time you only want to look at a value: pass a reference with &.
fn word_count(text: &str) -> usize { text.split_whitespace().count()}
let title = String::from("the rust programming language");println!("{} words", word_count(&title)); // 4 wordsprintln!("{title}"); // title is still usableA reference borrows the value: the owner keeps ownership, and the borrow must end before the owner goes away.
Two kinds of references
Section titled “Two kinds of references”| Syntax | How many at once | Can modify | |
|---|---|---|---|
| Shared reference | &T |
any number | no |
| Mutable reference | &mut T |
exactly one, and no shared ones | yes |
fn shout(text: &mut String) { text.make_ascii_uppercase(); text.push('!');}
let mut message = String::from("hello");shout(&mut message);println!("{message}"); // HELLO!The caller writes &mut at the call site — like C#’s ref keyword, the mutation is visible where it happens. Java has no equivalent: any method holding a reference can mutate the object.
The rule: shared XOR mutable
Section titled “The rule: shared XOR mutable”At any point, you can have either many readers or one writer — never both. The compiler checks this; it is called the borrow checker.
let mut names = vec![String::from("Ada")];let first = &names[0];names.push(String::from("Grace"));println!("{first}");error[E0502]: cannot borrow `names` as mutable because it is also borrowed as immutable --> e_borrow_mut.rs:4:5 |3 | let first = &names[0]; | ----- immutable borrow occurs here4 | names.push(String::from("Grace")); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here5 | println!("{first}"); | ----- immutable borrow later used hereThis is not pedantry. push may reallocate the vector’s buffer, which would leave first pointing into freed memory. In C#/Java the GC keeps the old object alive, so this particular bug does not crash — but the same rule catches a bug you know well.
You have met this bug before
Section titled “You have met this bug before”// Javafor (Integer n : numbers) { numbers.add(n * 2); // ConcurrentModificationException at runtime}// C#foreach (var n in numbers) { numbers.Add(n * 2); // InvalidOperationException: Collection was modified}In Rust, it does not get past the compiler:
let mut numbers = vec![1, 2, 3];for n in &numbers { numbers.push(n * 2);}error[E0502]: cannot borrow `numbers` as mutable because it is also borrowed as immutable --> e_iter_mutate.rs:4:9 |3 | for n in &numbers { | -------- | | | immutable borrow occurs here | immutable borrow later used here4 | numbers.push(n * 2); | ^^^^^^^^^^^^^^^^^^^ mutable borrow occurs hereThe fix is the same as in C#/Java — finish reading, then write:
let mut numbers = vec![1, 2, 3];let doubled: Vec<i32> = numbers.iter().map(|n| n * 2).collect();numbers.extend(doubled);println!("{numbers:?}"); // [1, 2, 3, 2, 4, 6]No dangling references
Section titled “No dangling references”A reference can never outlive what it points to:
fn longest_line() -> &str { let text = String::from("line one\nline two"); text.lines().next().unwrap()}error[E0106]: missing lifetime specifier --> e_dangling.rs:1:22 |1 | fn longest_line() -> &str { | ^ expected named lifetime parameter | = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from…help: instead, you are more likely to want to return an owned value |1 - fn longest_line() -> &str {1 + fn longest_line() -> String {text is dropped when the function returns, so a reference into it would dangle. Return an owned String instead. (Lifetimes — the 'a syntax the error mentions — get their own lesson, 9.)
Slices
Section titled “Slices”A slice borrows a contiguous part of a collection without copying it:
let mut scores = vec![90, 72, 85];scores.push(60);let top_two = &scores[..2]; // &[i32]println!("top two: {top_two:?}"); // top two: [90, 72]Think Span<T> / ReadOnlySpan<T> in C#, or List.subList in Java — but checked at compile time so it can never outlive the vector.
String vs &str
Section titled “String vs &str”This is the most common stumbling block, and slices explain it:
String |
&str |
|
|---|---|---|
| What it is | an owned, growable UTF-8 buffer | a borrowed slice of UTF-8 text |
| Where the bytes live | heap, owned by this value | anywhere: a String, the binary (literals), … |
| Can grow | yes (push_str, push) |
no |
| C# analogy | StringBuilder that you own |
ReadOnlySpan<char> / a string you don’t own |
| Typical use | struct fields, return values | function parameters |
- String literals like
"hello"are&str(&'static str: they live in the binary). &Stringconverts to&strautomatically, so parameters should usually be&str— they then accept both:
fn first_word(text: &str) -> &str { text.split_whitespace().next().unwrap_or("")}
println!("{}", word_count("a literal works too")); // 4println!("first word: {}", first_word(&title)); // first word: theStrings are UTF-8, not arrays of chars
Section titled “Strings are UTF-8, not arrays of chars”In C# and Java, s[0] / s.charAt(0) returns a UTF-16 unit. Rust refuses to index a string by position:
let word = String::from("cafe");let c = word[0];error[E0277]: the type `str` cannot be indexed by `{integer}` --> e_index_str.rs:3:18 |3 | let c = word[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()`Because characters take 1 to 4 bytes in UTF-8, “the n-th character” is an O(n) walk, and Rust makes that explicit:
let word = "café";println!("{} bytes, {} chars, first 3 bytes: {}", word.len(), word.chars().count(), &word[..3]);// 5 bytes, 4 chars, first 3 bytes: cafSlicing by byte range works, but it panics if you cut through a character:
thread 'main' panicked at e_slice_boundary.rs:3:25:byte index 4 is not a char boundary; it is inside 'é' (bytes 3..5) of `café`Building strings
Section titled “Building strings”let mut log = String::new();for (i, s) in ["alpha", "beta"].iter().enumerate() { log.push_str(&format!("{i}:{s} "));}println!("{}", log.trim_end()); // 0:alpha 1:betaformat! works like string.Format / String.format (and like C# interpolation with {name} inside the literal).
Key takeaways
Section titled “Key takeaways”&Tborrows for reading,&mut Tborrows for writing; the owner keeps ownership.- Many shared borrows or one mutable borrow — the rule that also catches “collection modified during iteration” at compile time.
- References can never dangle; return owned values when the data is created inside a function.
- Take
&strin parameters, storeStringin structs; strings are UTF-8, so iterate with.chars()instead of indexing.
Exercises
Section titled “Exercises”- Fix the signature so this compiles without cloning, and explain why your version is more flexible:
fn is_shouting(text: String) -> bool { text.chars().any(|c| c.is_alphabetic()) && text == text.to_uppercase()}
let msg = String::from("HELLO");if is_shouting(msg) { println!("{msg} is shouting"); }Solution
fn is_shouting(text: &str) -> bool { text.chars().any(|c| c.is_alphabetic()) && text == text.to_uppercase()}
let msg = String::from("HELLO");if is_shouting(&msg) { println!("{msg} is shouting"); }Borrowing leaves msg owned by the caller, and &str also accepts literals (is_shouting("hi")) and slices.
- This compiles in C# and runs fine. Why does Rust reject the equivalent, and how do you fix it?
var names = new List<string> { "Ada" };var first = names[0];names.Add("Grace");Console.WriteLine(first);Solution
In C#, first holds a reference to the string object, which the GC keeps alive even if the list reallocates. In Rust, &names[0] points into the vector’s buffer, which push may reallocate, so the borrow checker forbids the mutation while the borrow is alive (E0502). Fixes: use first before pushing, or take an owned copy with let first = names[0].clone();.
- Write
fn initials(full_name: &str) -> Stringthat returns"A.L."for"Ada Lovelace", correctly handling names that start with non-ASCII letters like"Émile Zola".
Solution
fn initials(full_name: &str) -> String { full_name .split_whitespace() .filter_map(|word| word.chars().next()) .map(|c| format!("{c}.")) .collect()}
assert_eq!(initials("Ada Lovelace"), "A.L.");assert_eq!(initials("Émile Zola"), "É.Z.");chars().next() takes the first character, not the first byte, so É (2 bytes in UTF-8) is handled correctly.