IS 4010: Application Development with Artificial Intelligence

Week 10: The soul of Rust, ownership and borrowing

Brandon M. Greenwell

Session 1: the ownership model

Why ownership matters: memory-safety defects

Memory-management trade-offs

Languages make different trade-offs among:

  1. Runtime overhead and predictability
  2. Compile-time guarantees
  3. Programmer control and implementation complexity

Examples: - Python automates memory management and accepts runtime overhead - C exposes manual memory operations and few compile-time protections - Modern C++ often uses RAII and smart pointers, while still permitting low-level operations - Rust uses ownership and borrowing to enforce many memory-safety rules in safe code without a tracing garbage collector

How Python handles memory (garbage collection)

  • Python implementations manage memory automatically
  • CPython primarily uses reference counting and supplements it with a cyclic garbage collector
  • Most objects are reclaimed when their reference count reaches zero; the cyclic collector detects unreachable reference cycles
  • Benefit: Application code rarely allocates or frees memory directly
  • Trade-off: Reference counting and cycle detection add runtime and memory overhead

How C/C++ handles memory (manual management)

  • C exposes manual allocation with malloc() and free()
  • C++ also provides RAII, containers, and smart pointers; low-level new and delete remain available
  • Benefit: Direct control over representation, allocation, and lifetime
  • Risk: Low-level operations can introduce mistakes:
    • Use-after-free: Using memory after it’s been freed (security vulnerability)
    • Double-free: Freeing the same memory twice (crashes)
    • Memory leaks: Forgetting to free memory (resource exhaustion)

Rust’s approach: ownership

  • Rust introduces a third approach: the ownership system
  • The compiler enforces ownership rules at compile time
  • If your code violates these rules, it won’t compile
  • Ownership checks themselves occur at compile time
  • No garbage collector needed
  • Safe Rust prevents many memory-safety errors through the type system

The three ownership rules

Use these three rules as the course model for owned values in safe Rust:

  1. Each value has an owner
    • A binding, collection, or other value may own its data
  2. There can only be one owner at a time
    • When you assign a value to a new variable, ownership transfers (it “moves”)
  3. When the owner goes out of scope, the value is dropped

Ownership in action: the stack and the heap

  • Fixed-size values can be stored directly in a stack frame or inside another allocation
  • A String value contains a pointer, length, and capacity; its text buffer is normally allocated on the heap
  • Moving a String transfers that fixed-size header; the heap buffer is not duplicated
  • Storage location and ownership are related concepts, but they are not the same rule

The stack and heap: a cooking analogy

Think of a chef setting up their station before service (mise en place):

  • The stack → your mise en place bowls: ingredients pre-measured into small glass bowls on the counter
    • Fixed, known sizes: measured precisely before cooking begins
    • Right at hand: instant access, no searching required
    • Automatically cleared when the dish is finished
  • The heapthe walk-in pantry: large, flexible storage for bigger and variable quantities
    • Items vary in size: a whole bag of flour vs. a pinch of salt
    • Farther away: more effort to access and manage
    • Must be tracked carefully: leave things unlabeled and they get “forgotten” (memory leak!)

From the kitchen to Rust

The analogy maps directly to Rust types:

  • Mise en place bowl (fixed size, cheap to duplicate) → values such as i32, f64, and bool that implement Copy
  • Pantry claim ticket plus stored item → the fixed-size header and heap buffer used by String or Vec<T>
  • Handing a bowl across the counter → cheap Copy (both keep their data)
  • Handing off the pantry itemmove: you no longer have it, they do

Example: ownership transfer (move)

fn main() {
    let s1 = String::from("hello");
    let s2 = s1; // Ownership moves from s1 to s2

    // println!("{}", s1); // ❌ ERROR: value borrowed after move
    println!("{}", s2); // ✅ OK: s2 owns the string now
}

Try it in Rust Playground

Why moves matter: preventing double-free

  • If Rust allowed both s1 and s2 to be valid, both would try to free the same memory when they go out of scope
  • This is a double-free error - a serious security vulnerability
  • Rust prevents this by invalidating s1 after the move to s2
  • The borrow checker enforces this at compile time

The Copy trait: the exception to moves

  • Simple types like i32, f64, bool, and char implement the Copy trait
  • Types with Copy are copied instead of moved
  • Their values have copy semantics and do not own resources that require custom cleanup
  • After copying, both variables remain valid
let x = 5;
let y = x; // Copy happens (not a move)
println!("x = {}, y = {}", x, y); // ✅ Both valid!

AI copilot technique: understanding moves

When you encounter move errors, ask your AI assistant for help:

Effective prompts: - “Explain this Rust move error in simple terms: [paste error message]” - “Why does Rust move String but copy i32?” - “How do I use a value after it’s been moved?” - “What’s the difference between Copy and Clone in Rust?”

First step: Read the compiler diagnostic, identify the values and borrows it names, and then ask an AI assistant about the specific rule that remains unclear.

Scope and automatic cleanup

{
    let s = String::from("hello"); // s is valid here
    // use s
} // s goes out of scope, drop() is called automatically

Try it in Rust Playground

Python permits mutation during iteration

# Goal: remove all even numbers from a list
items = [1, 2, 3, 4, 6, 7, 8]

for item in items:
    if item % 2 == 0:
        items.remove(item)  # Modifying the list while iterating!

print(items)  # Expected: [1, 3, 7]
              # Actual:   [1, 3, 6, 7]  ← 6 is never checked!
  • Python runs to completion: no error, no warning, exit code 0
  • When 4 is removed at index 2, the list shifts left; the iterator advances to 7, skipping 6 entirely
  • This is a logic bug: the language permits the operation, so tests must detect the incorrect result

Why the Python loop skips an item

for item in items tracks a hidden index counter: it increments by 1 each step, unaware of any list changes:

  • Index 0 → 1 (odd, skip). List: [1, 2, 3, 4, 6, 7, 8]
  • Index 1 → 2 (even, remove). List: [1, 3, 4, 6, 7, 8]
  • Index 2 → 4 (even, remove). List: [1, 3, 6, 7, 8]
  • Index 3 → 7 (odd, skip): 6 shifted into index 2 and was never visited!
  • Index 4 → 8 (even, remove). List: [1, 3, 6, 7]
  • Index 5 → past end, loop stops

The counter keeps incrementing; the list keeps shifting. They fall out of sync.

Rust catches this at compile time

fn main() {
    let mut items = vec![1, 2, 3, 4, 6, 7, 8];

    for item in &items {                    // immutable borrow of `items`
        if item % 2 == 0 {
            items.retain(|x| x != item);   // ❌ ERROR: mutable borrow!
        }
    }
}
error[E0502]: cannot borrow `items` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:13
  |
4 |     for item in &items {
  |                 ------  immutable borrow occurs here
...
6 |             items.retain(|x| x != item);
  |             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here
  • for item in &items takes an immutable borrow of the whole vector
  • .retain() needs a mutable borrow of the same vector, at the same time
  • Rust’s rule: you cannot hold both simultaneously, so this does not compile
  • The logic bug that ran in Python is rejected by Rust’s borrowing rules before execution

Try it in Rust Playground

Why Rust catches it

for item in &items takes an immutable borrow of the entire vector: a compile-time contract:

“This data will not change while I hold this reference.”

  • Any method that mutates the vector (.retain(), .remove(), .push()) requires a mutable borrow
  • You cannot hold an immutable borrow and a mutable borrow at the same time, ever
  • The compiler checks this before your program runs: zero runtime overhead
  • This isn’t just about iteration: the ownership system makes any aliased mutation impossible

The Rust way: transform instead of mutate

When Rust says no to mutating in place, it is steering you toward a cleaner pattern:

fn main() {
    let items = vec![1, 2, 3, 4, 6, 7, 8];

    let odds: Vec<i32> = items
        .into_iter()
        .filter(|x| x % 2 != 0)
        .collect();

    println!("{:?}", odds);  // ✅ [1, 3, 7], correct every time
}
  • into_iter() consumes items, giving the iterator full ownership, with no borrow conflict
  • .filter() selects elements without touching the original collection
  • .collect() builds a fresh Vec<i32>
  • Python equivalent: odds = [x for x in items if x % 2 != 0]: same idea, now enforced by the type system

Try it in Rust Playground

Session 2: borrowing, references, and lifetimes

The problem: functions that take ownership

fn calculate_length(s: String) -> usize {
    s.len()
} // s is dropped here

fn main() {
    let my_string = String::from("hello");
    let len = calculate_length(my_string); // Ownership moved!
    // println!("{}", my_string); // ❌ ERROR: value borrowed after move
}
  • When we pass my_string to the function, ownership moves to s
  • After the function returns, we can’t use my_string anymore
  • This is annoying - we just wanted to read the length!

The solution: references and borrowing

  • A reference lets you refer to a value without taking ownership
  • References are created with the & operator
  • Using references is called borrowing
  • The original owner retains ownership, so the value won’t be dropped
fn calculate_length(s: &String) -> usize {
    s.len()
} // s goes out of scope, but it doesn't own the String, so nothing is dropped

fn main() {
    let my_string = String::from("hello");
    let len = calculate_length(&my_string); // Borrow, don't move
    println!("{} has length {}", my_string, len); // ✅ Still valid!
}

Try it in Rust Playground

Immutable and mutable references

  • By default, references are immutable (&T)
  • You can create a mutable reference with &mut T
  • Mutable references let you modify the borrowed value
  • But there’s a catch… (next slide)
fn add_world(s: &mut String) {
    s.push_str(", world");
}

fn main() {
    let mut my_string = String::from("hello");
    add_world(&mut my_string);
    println!("{}", my_string); // Prints: hello, world
}

Try it in Rust Playground

The borrowing rules (enforced by the borrow checker)

The borrow checker enforces these rules at compile time:

  1. At any given time, you can have EITHER:
  2. A reference cannot outlive the value it borrows

Why? These rules prevent data races at compile time!

Borrow checker error: multiple mutable references

let mut s = String::from("hello");

let r1 = &mut s;
let r2 = &mut s; // ❌ ERROR: cannot borrow as mutable more than once

println!("{}, {}", r1, r2);

The error:

error[E0499]: cannot borrow `s` as mutable more than once at a time
  • Rust prevents multiple mutable references to avoid data races
  • A data race occurs when two threads access the same memory and at least one is writing
  • Rust’s rule is stricter: only one mutable borrow even in single-threaded code

Try it in Rust Playground

Borrow checker error: mixing mutable and immutable

let mut s = String::from("hello");

let r1 = &s;     // Immutable borrow
let r2 = &s;     // Another immutable borrow (OK)
let r3 = &mut s; // ❌ ERROR: cannot borrow as mutable

println!("{}, {}, {}", r1, r2, r3);

The error:

error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
  • You can’t modify data while others are reading it
  • This prevents “reading while writing” bugs

Try it in Rust Playground

The fix: reference scope ends at last use

  • Non-Lexical Lifetimes (NLL) - a Rust 2018 improvement
  • A reference’s scope ends at its last use, not at the closing brace
  • This makes many common patterns “just work”
let mut s = String::from("hello");

let r1 = &s;     // Immutable borrow
let r2 = &s;     // Another immutable borrow
println!("{}, {}", r1, r2); // r1 and r2 last used here - scope ends

let r3 = &mut s; // ✅ OK: no immutable borrows are active
println!("{}", r3);

Try it in Rust Playground

AI copilot technique: debugging borrow-checker errors

The borrow checker is your friend, but it takes practice. Use AI to accelerate your learning:

Effective prompts: - “Explain this borrow checker error: [paste full error message]” - “Why can’t I borrow this variable as mutable? [paste code]” - “How do I fix ‘cannot borrow as mutable more than once’?” - “What’s a dangling reference and how does Rust prevent them?”

Best practice: Read the error message first, understand the rule being violated, then ask AI for deeper insight or alternative solutions.

Preventing dangling references

  • A dangling reference is a pointer to memory that has been freed
  • This is a critical security vulnerability in C/C++ (use-after-free)
  • Safe Rust prevents references from outliving the values they borrow
fn dangle() -> &String {  // ❌ Won't compile!
    let s = String::from("hello");
    &s  // ERROR: s will be dropped, but we're returning a reference to it
} // s goes out of scope and is dropped, but the reference would point to freed memory

The fix: Return the owned value, not a reference:

fn no_dangle() -> String {  // ✅ Ownership transfers to caller
    let s = String::from("hello");
    s  // Move ownership out
}

Introduction to lifetimes

  • Lifetimes are Rust’s way of ensuring references are always valid
  • Every reference has a lifetime - the scope for which it’s valid
  • Usually, the compiler can infer lifetimes automatically
  • Sometimes you need to annotate them explicitly with 'a, 'b, etc.
  • Lifetimes prevent dangling references at compile time

Why lifetimes exist: a problematic example

fn longest(x: &str, y: &str) -> &str {  // ❌ Won't compile!
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

The error:

error[E0106]: missing lifetime specifier
  --> src/main.rs:1:33
   |
1  | fn longest(x: &str, y: &str) -> &str {
   |               ----     ----     ^ expected named lifetime parameter
  • The compiler doesn’t know if the returned reference comes from x or y
  • It can’t verify the returned reference will be valid
  • We need to tell the compiler about the relationship between input and output lifetimes

Lifetime annotations: the solution

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}
  • 'a is a lifetime parameter
  • It says: “the returned reference will live as long as the shortest-lived input”
  • If x lives longer than y, the return value’s lifetime is tied to y (the shorter one)
  • The borrow checker uses this to ensure safety

Try it in Rust Playground

When you need lifetime annotations

You typically need lifetime annotations when:

  1. Function returns a reference that came from one of multiple input references
  2. Structs contain references (struct must not outlive the referenced data)
  3. Multiple references with complex relationships where the compiler can’t infer

Good news: In most code, lifetimes are inferred automatically thanks to lifetime elision rules

AI copilot technique: understanding lifetimes

Lifetimes can be confusing. Use AI to build intuition:

Effective prompts: - “Explain Rust lifetimes like I’m coming from Python” - “Why does this code need a lifetime annotation? [paste code]” - “What does ‘a mean in this function signature? [paste signature]“ - ”How do I fix ’lifetime may not live long enough’?” - “When can I omit lifetime annotations in Rust?”

Pro tip: Use Rust Playground to experiment. Change code, see errors, ask AI to explain the errors.

Lifetimes in structs

struct Excerpt<'a> {
    text: &'a str,  // This reference must be valid as long as the Excerpt exists
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    let excerpt = Excerpt { text: first_sentence };
    println!("{}", excerpt.text);
} // excerpt and novel both go out of scope here (OK: excerpt doesn't outlive novel)
  • When a struct contains a reference, we must annotate the lifetime
  • 'a says: “this struct can’t live longer than the data it references”
  • The borrow checker enforces that novel outlives excerpt

Try it in Rust Playground

The big picture: why ownership matters

What ownership gives you: - Memory safety without garbage collection - No data races in safe Rust (enforced at compile time) - Predictable performance (no GC pauses) - Compile-time checks that usually do not add runtime work

The trade-off: - Steeper learning curve (you’re learning now!) - Resolve borrow-checker feedback while learning the ownership model - More upfront thinking about data ownership

Transfer: Ownership practice gives you another way to reason about resource lifetimes, aliases, and mutation in Python, JavaScript, and other languages.

Case studies to investigate

Examples of organizations describing Rust migrations:

  • Discord: Discusses latency behavior in one service migration from Go to Rust
  • Dropbox: Describes design choices in a cross-platform sync engine
  • npm: Reports results from selected CPU-bound services
  • Microsoft: Discusses memory-safety vulnerabilities and safer systems-language research

Discussion question: Which results come from the language, the new design, the workload, or the implementation team?

Try it yourself: borrow checker challenges

Challenge 1: Fix the ownership error Rust Playground Link

Challenge 2: Fix the borrowing error Rust Playground Link

Challenge 3: Fix the dangling reference Rust Playground Link

Introducing Lab 10: ownership and borrowing

This week’s lab has two parts:

  1. Borrow-checker puzzles (practice)
    • Fix and run the seven commented ownership and borrowing examples
    • Interpret each compiler diagnostic
  2. Implementation exercises (graded)
    • Implement to_uppercase_owned, string_length, append_suffix, and concat_strings
    • Do not modify the supplied test module
  3. Required checks
    • Run cargo test, cargo fmt, and cargo clippy -- -D warnings
    • Push week10/ and confirm the matching README badge is green

Full instructions: week10/lab10.md

Resources for going deeper

Official Rust resources: - The Rust Programming Language book - Chapter 4 (Ownership) - The Rust Programming Language book - Chapter 10 (Lifetimes) - Rust by Example - Ownership - The Rustonomicon - Advanced ownership

Interactive learning: - Rust Playground - experiment in your browser - Rustlings - small exercises to get you used to reading and writing Rust code

Community: - r/rust - Rust subreddit - The Rust Programming Language Discord

Looking ahead: next week

Week 11 preview: structs, enums, and pattern matching - Creating custom data types with struct - Rust’s powerful enum type - Pattern matching with match - AI-assisted data modeling strategies

For now: Practice ownership and borrowing. These concepts support the Rust topics that follow.

Questions?

Remember: - The borrow checker is your friend (even when it feels like your enemy) - Read the compiler diagnostic before deciding on a fix - Use AI assistants to deepen understanding, not to skip learning - Explain each ownership fix in terms of the value, owner, and borrow involved

Office hours: Available on Microsoft Teams - reach out anytime!