Skip to content

4. Borrowing and strings

Full example: examples/l04_borrowing.rscargo run --example l04_borrowing.

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 words
println!("{title}"); // title is still usable

A reference borrows the value: the owner keeps ownership, and the borrow must end before the owner goes away.

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.

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 here
4 | names.push(String::from("Grace"));
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here
5 | println!("{first}");
| ----- immutable borrow later used here

This 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.

// Java
for (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 here
4 | numbers.push(n * 2);
| ^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here

The 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]

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.)

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.

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).
  • &String converts to &str automatically, 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")); // 4
println!("first word: {}", first_word(&title)); // first word: the

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: caf

Slicing 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é`
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:beta

format! works like string.Format / String.format (and like C# interpolation with {name} inside the literal).

  • &T borrows for reading, &mut T borrows 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 &str in parameters, store String in structs; strings are UTF-8, so iterate with .chars() instead of indexing.
  1. 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.

  1. 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();.

  1. Write fn initials(full_name: &str) -> String that 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.