DEV Community

Cover image for Rust for Gophers, Lesson 2: Ownership and Borrowing
Syam SV
Syam SV

Posted on

Rust for Gophers, Lesson 2: Ownership and Borrowing

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.

  1. Each value has exactly one owner (a variable, a struct field, a vector slot).
  2. Ownership can be moved to a new owner, and then the old one can no longer be used.
  3. 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}");
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}");
Enter fullscreen mode Exit fullscreen mode
  • Copy types 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".
  • Clone is 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
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 */ }
Enter fullscreen mode Exit fullscreen mode

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's ptr_arg lint 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.
  • mut in 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());
}
Enter fullscreen mode Exit fullscreen mode

Drill 2

fn main() {
    let mut s = String::from("a");
    let r1 = &s;
    let r2 = &mut s;
    r2.push('b');
    println!("{r1}");
}
Enter fullscreen mode Exit fullscreen mode

Drill 3 (fix it two different ways)

fn make() -> &String {
    let s = String::from("hi");
    &s
}
Enter fullscreen mode Exit fullscreen mode

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}");
}
Enter fullscreen mode Exit fullscreen mode

Drill 5 (stretch: a preview of Lesson 8)

i
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Requirements

  • [ ] fn count(text: &str) -> (usize, usize, usize, usize): lines, words, chars, bytes. Print them right-aligned in 8-wide columns like wc.
  • [ ] fn longest_word(text: &str) -> Option<&str>: the longest word measured in characters (not bytes), first one wins a tie, None for empty input. It must return a slice of text, with no allocation.
  • [ ] fn words_longer_than(text: &str, n: usize) -> Vec<&str>: all words with more than n characters, as slices of text. Print them for n = 4.
  • [ ] fn normalize(text: &mut String): lowercases ASCII letters and strips trailing whitespace in place, without creating a new String.
  • [ ] main reads the file named by the first argument, or stdin if there is none. Any I/O error goes to stderr with exit code 1. No unwrap().
  • [ ] Tests: at least one per function, including empty input, a tie in longest_word, and a non-ASCII word such as héllo where chars and bytes differ.
  • [ ] Zero .clone() calls, and cargo fmt --check && cargo clippy -- -D warnings && cargo test pass.

Hints (read only when stuck)

  1. str already has what you need: lines(), split_whitespace(), chars(), len(). Look them up in cargo doc --open under str.
  2. Reading input: std::fs::read_to_string(path) and std::io::read_to_string(std::io::stdin()) both return Result<String, io::Error>. Match on env::args().nth(1) to choose which one to call, then match the result once.
  3. For longest_word, keep an Option<&str> for the best so far. A match arm can have a guard: Some(b) if condition => ....
  4. For normalize, look for a make_ascii_* method on String, and for a method that shortens a String to a given length. trim_end() tells you what that length should be.
  5. 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)