1. Why ownership exists
Ownership is how Rust frees memory without a garbage collector and without you calling free. The compiler works out, at compile time, exactly where each heap value is freed. Every rule in this lesson exists to make that analysis possible.
Stack vs heap, quickly. Values with a size known at compile time (i32, bool, [u8; 4], a struct of those) live on the stack and are popped when the function returns, which is free. Values that grow (String, Vec<T>) keep a small fixed header on the stack (pointer, length, capacity) that points to a buffer on the heap. The question every language must answer is who frees that heap buffer, and when.
| Who frees heap memory | Cost | What can go wrong | |
|---|---|---|---|
| C | You, with free
|
None | Use-after-free, double free, leaks |
| Go | The garbage collector, at some point later | GC CPU and pauses, more memory headroom | Data races, stale aliases, leaks through long-lived references |
| Rust | The compiler inserts the free at the end of the owner's scope | None at runtime | Prevented at compile time; you pay in learning effort |
Go's escape analysis already decides stack vs heap for you, and the GC handles the rest. Rust asks you to state the relationships (who owns, who borrows, for how long), and in return you get C-level performance with no use-after-free, no double free, and no data races. That last one matters for your multithreading goal: the same rules you learn today are what make concurrent Rust safe in Lesson 10.
2. The three rules, moves, and Drop
Memorize these three rules; everything else follows from them.
- Each value has exactly one owner (a variable, a struct field, a vector slot).
- Ownership can be moved to a new owner, and then the old one can no longer be used.
- When the owner goes out of scope, the value is dropped (its memory freed, its file closed, its lock released).
Moves
let s1 = String::from("hi");
let s2 = s1; // ownership MOVES to s2. Only the stack header is copied
// println!("{s1}"); // error[E0382]: borrow of moved value: `s1`
println!("{s2}");
In Go, s2 := s1 for a slice copies the header and both variables share the same backing array. That is cheap but creates two handles to one buffer. Rust copies the header too, but then invalidates s1, so there is still exactly one owner and exactly one free. No double free is possible.
Passing to a function is also a move:
fn takes(s: String) -> usize {
s.len()
} // s is dropped here; the heap buffer is freed
let name = String::from("Syam");
takes(name);
// takes(name); // error: use of moved value
Copy vs Clone
let a = 5;
let b = a; // i32 is Copy: a bitwise copy, `a` is still valid
println!("{a} {b}");
let s = String::from("hi");
let t = s.clone(); // explicit deep copy: allocates a new heap buffer
println!("{s} {t}");
-
Copytypes are plain data with no heap ownership: integers, floats,bool,char, and tuples and arrays of those. Assignment copies them implicitly, so they never "move". -
Cloneis an explicit, possibly expensive duplicate. Rust never deep-copies behind your back, so every allocation is visible in the code as.clone(),.to_string()or.to_vec().
A beginner habit to avoid: sprinkling .clone() to silence the borrow checker. It compiles, but it hides a design problem and allocates. In reviews I'll flag every clone that a borrow could replace.
Drop: defer that you can't forget
{
let f = std::fs::File::open("data.txt")?;
// use f...
} // f is dropped here: the file is closed automatically
In Go you write defer f.Close() and hope nobody forgets. In Rust the cleanup is tied to the owner's scope (this pattern is called RAII), so files, sockets, mutex locks and DB connections are always released, even on early return or panic. You'll see this pay off with MutexGuard in Lesson 10 and connection pools in Lesson 15.
3. Borrowing: references without ownership
Moving everything everywhere would be unbearable, so Rust lets you borrow: hand out a reference that uses the value without owning it. The owner keeps responsibility for dropping it.
fn len_of(s: &str) -> usize { // shared borrow: read-only
s.len()
}
fn excite(s: &mut String) { // mutable borrow: can modify
s.push('!');
}
let mut g = String::from("hello");
excite(&mut g); // the call site says &mut, so mutation is visible
println!("{} {g}", len_of(&g)); // 6 hello!
A reference is a pointer that the compiler has proven is valid. Unlike a Go pointer, it can never be nil and can never outlive the thing it points to.
The one rule of borrowing
At any moment, a value can have either any number of shared references &T, or exactly one mutable reference &mut T. Never both.
This is the readers-writer lock pattern, enforced at compile time with zero runtime cost. Think of &T as "shared, read-only" and &mut T as "exclusive". The precise names matter more than the keywords: &mut really means unique access.
The Go bug this prevents
// Go: compiles, runs, and is silently wrong
v := []int{1, 2, 3}
first := &v[0]
v = append(v, 4) // may reallocate to a new backing array
*first = 100 // writes to the OLD array; v[0] is still 1
// Rust: refuses to compile
let mut v = vec![1, 2, 3];
let first = &v[0]; // shared borrow of v starts
v.push(4); // error[E0502]: cannot borrow `v` as mutable
// because it is also borrowed as immutable
println!("{first}"); // shared borrow is still alive here
In Go the GC keeps the old array alive, so it isn't a crash, just a quiet logic bug. In C++ the same code is a use-after-free. In Rust it can't be written. The exact same rule, applied across threads, is why Rust has no data races: two threads can't both hold &mut to the same data.
Borrows end at their last use
let mut v = vec![1, 2, 3];
let first = &v[0];
println!("{first}"); // last use of `first`: the borrow ends here
v.push(4); // fine
The compiler tracks where each reference is last used, not where its variable goes out of scope (this is called non-lexical lifetimes). Reordering code like this is often the whole fix for a borrow error.
No dangling references
fn make() -> &String { // error[E0106]: missing lifetime specifier
let s = String::from("hi");
&s // s is dropped at the end of make()
}
In Go, returning &s is fine because escape analysis moves s to the heap. Rust won't silently heap-allocate. The fix is to return the owned value, fn make() -> String, which moves ownership out to the caller at no cost. "Missing lifetime specifier" is the compiler asking how long the reference should live; we cover lifetimes properly in Lesson 8.
4. Slices, and String vs &str
A slice is a borrowed view into a contiguous sequence: a pointer plus a length, with no capacity. It is Go's slice header minus cap, and it never owns its data.
fn sum(xs: &[i32]) -> i32 {
xs.iter().sum()
}
let v = vec![1, 2, 3, 4];
sum(&v); // &Vec<i32> coerces to &[i32] automatically
sum(&v[1..]); // a sub-slice: [2, 3, 4]
sum(&[5, 6]); // an array works too
You cannot append to a slice in Rust. Growth needs ownership, so only the Vec can grow. That removes Go's entire class of "did my append alias the caller's array" bugs.
Two string types
String |
&str |
|
|---|---|---|
| What it is | Owned, growable UTF-8 buffer on the heap | Borrowed view into UTF-8 bytes somewhere |
| Analogy |
Vec<u8> that is guaranteed valid UTF-8 |
&[u8] that is guaranteed valid UTF-8 |
| Go analogy |
strings.Builder / []byte you own |
Go's string (an immutable view) |
| Literals |
String::from("hi"), "hi".to_string()
|
"hi" is a &'static str baked into the binary |
| Use for | Struct fields, return values, anything you build | Function parameters that only read |
fn first_word(s: &str) -> &str {
match s.find(' ') {
Some(i) => &s[..i], // a slice of the input; no allocation
None => s,
}
}
let line = String::from("hello world");
first_word(&line); // &String coerces to &str
first_word("literal works"); // so does a literal
The returned &str borrows from the input, so the compiler won't let line be modified or dropped while the result is in use. In Go, bytes.Fields gives you zero-copy sub-slices too, but nothing stops you from mutating the underlying []byte while they are in use.
UTF-8: bytes are not characters
let s = String::from("héllo");
s.len(); // 6, bytes (é is 2 bytes), like len() in Go
s.chars().count(); // 5, Unicode scalar values, like utf8.RuneCountInString
// s[0] // error: strings can't be indexed by integer
// &s[0..2] // compiles, but PANICS: byte 2 is inside 'é'
for (i, ch) in s.char_indices() { /* like `for i, r := range s` in Go */ }
Rust forbids s[0] because it would be ambiguous (byte or character?) and O(1)-looking while really needing a scan. Slicing by byte range is allowed but panics if you cut through a character. In real code, find boundaries with find, split_whitespace, char_indices, or work on s.as_bytes() when you truly have ASCII, as you will in the base64 lesson.
5. Choosing function signatures
Every parameter answers one question: does this function need to own the value, change it, or just look at it? Pick the weakest access that works.
| Signature | Meaning | Caller writes | Use when |
|---|---|---|---|
fn f(x: &T) |
Read-only borrow | f(&x) |
Default for reading. Most parameters |
fn f(x: &mut T) |
Exclusive borrow, may modify | f(&mut x) |
Changing the caller's value in place |
fn f(x: T) |
Takes ownership |
f(x) (moved) |
Storing it, sending it to another thread, or consuming it |
fn f() -> T |
Gives ownership to the caller | let x = f(); |
Creating new values. Returning owned values is cheap: a move, not a copy |
Industry conventions clippy will push you towards:
- Take
&str, not&String, and&[T], not&Vec<T>. The slice form accepts more callers (literals, arrays, sub-slices) and gives up nothing. Clippy'sptr_arglint flags the other form. - Return owned types (
String,Vec<T>) from functions that build new data. Return&str/&[T]only when you are handing back a piece of an input. - Go's habit of passing a pointer "to avoid copying" doesn't carry over. Moves never deep-copy, so
fn f(x: BigStruct)is fine when you want ownership. -
mutin a signature is visible at every call site (&mut x). When you read Rust code, you always know which calls can change your data. Go gives you no such signal for pointers or slices.
6. Compiler error drills
Before you write the exercise, work through these five snippets. Each one is broken. For each one, predict the problem before you compile it, then paste it into src/main.rs, read the full error (cargo check), and fix it in the most idiomatic way, without using .clone(). Learning to read borrow-checker errors is the real skill here; they are some of the best error messages in any language.
Drill 1
fn main() {
let v = vec![String::from("a"), String::from("b")];
for x in v {
println!("{x}");
}
println!("{}", v.len());
}
Drill 2
fn main() {
let mut s = String::from("a");
let r1 = &s;
let r2 = &mut s;
r2.push('b');
println!("{r1}");
}
Drill 3 (fix it two different ways)
fn make() -> &String {
let s = String::from("hi");
&s
}
Drill 4 (this one compiles; what happens when it runs?)
fn main() {
let s = String::from("héllo");
let part = &s[0..2];
println!("{part}");
}
Drill 5 (stretch: a preview of Lesson 8)
i
The compiler's suggestion will add <'a>. Apply it, then explain in one sentence why the compiler couldn't figure this out alone when it could for first_word in section 4.
7. Exercise: wordy, a word counter
Build a small wc-style tool. The point is to practice choosing between &str, String and &mut String, and returning borrowed slices from your inputs. Finish fizzbuzz v2 first if you haven't yet.
cargo new wordy && cd wordy
printf 'The quick brown fox\njumps over the lazy dog\n' | cargo run
# 2 9 43 43
# longest: quick
# long words: ["quick", "brown", "jumps"]
cargo run -- notes.txt # reads a file instead of stdin
cargo run -- missing.txt # stderr: error: No such file or directory (os error 2), exit 1
Requirements
- [ ]
fn count(text: &str) -> (usize, usize, usize, usize): lines, words, chars, bytes. Print them right-aligned in 8-wide columns likewc. - [ ]
fn longest_word(text: &str) -> Option<&str>: the longest word measured in characters (not bytes), first one wins a tie,Nonefor empty input. It must return a slice oftext, with no allocation. - [ ]
fn words_longer_than(text: &str, n: usize) -> Vec<&str>: all words with more thanncharacters, as slices oftext. Print them forn = 4. - [ ]
fn normalize(text: &mut String): lowercases ASCII letters and strips trailing whitespace in place, without creating a newString. - [ ]
mainreads the file named by the first argument, or stdin if there is none. Any I/O error goes to stderr with exit code 1. Nounwrap(). - [ ] Tests: at least one per function, including empty input, a tie in
longest_word, and a non-ASCII word such ashéllowhere chars and bytes differ. - [ ] Zero
.clone()calls, andcargo fmt --check && cargo clippy -- -D warnings && cargo testpass.
Hints (read only when stuck)
-
stralready has what you need:lines(),split_whitespace(),chars(),len(). Look them up incargo doc --openunderstr. - Reading input:
std::fs::read_to_string(path)andstd::io::read_to_string(std::io::stdin())both returnResult<String, io::Error>. Match onenv::args().nth(1)to choose which one to call, then match the result once. - For
longest_word, keep anOption<&str>for the best so far. Amatcharm can have a guard:Some(b) if condition => .... - For
normalize, look for amake_ascii_*method onString, and for a method that shortens aStringto a given length.trim_end()tells you what that length should be. - When the compiler says something "does not live long enough" or "cannot borrow as mutable", draw on paper who owns each value and when each borrow is last used.
Top comments (0)