Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter 6: Ownership, Borrowing, and Memory Management

In C, manual memory management is a central aspect of programming. Developers allocate and deallocate memory using malloc and free, which provides flexibility but is notoriously prone to errors like memory leaks, dangling pointers, and use-after-free bugs. C++ introduced RAII (Resource Acquisition Is Initialization) and smart pointers to automate resource management, reducing some risks. Many higher-level languages (Java, Python, Go, etc.) employ garbage collection (GC), which simplifies memory management significantly but often introduces runtime overhead and non-deterministic pauses, making it less suitable for performance-critical systems or embedded environments.

Rust presents a unique alternative: compile-time memory safety without a garbage collector. It achieves this through a system of ownership, borrowing, and lifetimes, enforced by the compiler. This approach ensures memory safety with minimal runtime overhead, making Rust a compelling choice for systems programming.

This chapter introduces these core concepts, primarily using Rust’s String type as an example. Its dynamic, heap-allocated nature makes it ideal for illustrating ownership principles clearly. We’ll compare Rust’s mechanisms with C/C++ idioms where helpful. We will also briefly touch upon Rust’s smart pointers and the unsafe keyword for scenarios requiring more manual control or C interoperability, deferring deep dives to later chapters (Chapters 19 and 25).


6.1 The Ownership System

In Rust, every value has a variable that is its owner. The ownership system is governed by a simple set of rules enforced at compile time by the borrow checker:

  1. Single Owner: Each value in Rust has exactly one owner at any given time.
  2. Scope-Bound Cleanup (Drop): When the owner goes out of scope, the value it owns is dropped (its resources, like memory, are automatically deallocated). This “drop scope” is lexical; destructors run at the end of the block where the variable is declared, not necessarily immediately after its last use.
  3. Ownership Transfer (Move): Assigning a value from one variable to another, or passing it by value to a function, moves ownership. The original variable becomes invalid.

This system prevents common memory errors like double frees (since only one owner can drop the value) and use-after-free (since variables become invalid after moving ownership).

If custom cleanup logic is needed when a value is dropped (e.g., releasing file handles or network sockets), you can implement the Drop trait, similar in concept to a C++ destructor.

6.1.1 Scope and Automatic Cleanup (Drop)

Consider this Rust code:

fn main() {
    {
        let s = String::from("hello"); // s comes into scope, allocates memory
        // ... use s ...
    } // s goes out of scope here. Rust calls drop on s, freeing its memory.
}

When s goes out of scope, Rust automatically calls the necessary cleanup code for String, freeing its heap-allocated buffer.

6.1.2 Comparison with C

In C, the equivalent requires manual intervention:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main() {
    {
        char *s = malloc(6); // Allocate memory
        if (s == NULL) { /* handle allocation error */ return 1; }
        strcpy(s, "hello");
        // ... use s ...
        free(s); // Manually free the memory is crucial
    } // Forgetting free(s) causes a memory leak.
    return 0;
}

Rust’s automatic dropping based on scope prevents leaks without requiring manual free calls.


6.2 Transferring Ownership: Move, Copy, and Clone

How data is handled during assignment or function calls depends on its type. Rust distinguishes between moving, copying, and cloning.

6.2.1 Move Semantics

Types that manage resources on the heap, like String, Vec<T>, or Box<T>, use move semantics by default. When ownership is transferred (either through assignment to another variable or by passing the value to a function), the underlying resource is not duplicated; only the “control” (ownership) moves. The original variable binding becomes invalid.

Move via Assignment:

fn main() {
    let s1 = String::from("allocated"); // s1 owns the string data on the heap
    let s2 = s1; // Ownership MOVES from s1 to s2. s1 is now invalid.
    // println!("s1: {}", s1); // Compile-time error! s1's value was moved.
    println!("s2: {}", s2); // s2 now owns the data. Prints: allocated
} // s2 goes out of scope, its owned string data is dropped.

Move via Function Arguments:

Passing a value to a function transfers ownership in the same way.

fn takes_ownership(some_string: String) {
    // `some_string` takes ownership of the passed value
    println!("Inside function: {}", some_string);
} // `some_string` goes out of scope, Drop is called, memory is freed.

fn main() {
    let s = String::from("hello"); // s comes into scope
    takes_ownership(s);             // s's value moves into the function...
                                    // ...and is no longer valid here.
    // println!("Moved string: {}", s); // Compile-time error! s was moved.
}

Move via Function Return Values:

Similarly, returning a value from a function moves ownership out of the function to the calling scope.

fn creates_and_gives_ownership() -> String { // Function returns a String
    let some_string = String::from("yours"); // some_string comes into scope
    some_string                             // Return some_string, moving ownership out
}

fn main() {
    let s1 = creates_and_gives_ownership();
    // Ownership moves from function return to s1
    println!("Got ownership of: {}", s1);
} // s1 is dropped here.

What Actually Happens During a Move?

When a value like String (or Vec<T>, Box<T>) is moved – either through assignment (let s2 = s1;) or by passing it by value to a function (takes_ownership(s1);) – the operation is very efficient at runtime. Remember that a String value itself (the metadata) consists of a small structure holding {a pointer to the heap data, a length, a capacity}. This structure usually resides on the stack for local variables.

During a move:

  1. Bitwise Copy of Struct: The {pointer, length, capacity} structure is copied bit-for-bit from the source (s1) to the destination (s2 or the function parameter). This is a fast operation, similar to copying a simple struct in C. No heap allocation occurs for this structure itself; the bits are copied into the stack space already designated for the new variable or parameter.
  2. No Heap Interaction: The character data stored on the heap is not copied or modified. The pointer value that is copied simply points to the same heap allocation.
  3. Ownership Transfer: The responsibility for managing and eventually deallocating the heap buffer is transferred to the new variable/parameter.
  4. Invalidation: The original variable (s1) is marked as invalid by the compiler. Its destructor (Drop) will not run when it goes out of scope, preventing a double free.

In essence, a move in Rust for types that manage heap resources avoids expensive deep copies by simply copying the small, fixed-size ‘handle’ or ‘metadata’ and transferring the unique ownership rights to the underlying resource.

A Note on Function Calls and Borrowing (e.g., println!)

You might wonder why passing a String to the println! macro doesn’t move ownership, allowing you to use the String afterwards:

fn main() {
    let message = String::from("Hello, Rust!");
    println!("First print: {}", message); // Pass owned String to println!
    println!("Second print: {}", message); // Still valid, can use message again!
}

This works because println! is a macro. Macros can be more flexible than regular functions. println! expands into code that uses formatting traits, and these traits typically operate on references. When you pass an owned String, the macro expansion effectively takes a shared reference (&String, which often further dereferences to &str for formatting) for the duration of the call. It borrows the value rather than consuming it, leaving the original message variable and its ownership intact. While some generic functions can also accept different types via traits that involve borrowing (like AsRef), the specific ability of println! to seem like it takes ownership but doesn’t is characteristic of its macro implementation. Contrast this with regular functions taking String by value, which do move ownership as shown previously.

Comparison with C++ and C

  • C++: Assignment (std::string s2 = s1;) typically performs a deep copy. To achieve move semantics, you must explicitly use std::move: std::string s2 = std::move(s1);. After moving, s1 is left in a valid but unspecified state. Passing by value also typically copies unless std::move is used or specific compiler optimizations occur (like RVO/NRVO for returned values).
  • C: Assigning pointers (char *s2 = s1; where s1 is malloced) creates a shallow copy—both pointers refer to the same memory. Passing pointers copies the pointer value, still resulting in shared mutable state without ownership tracking. There’s no compile-time help to prevent double frees or use-after-free if one pointer is used after the memory has been freed via the other pointer.

Rust’s default move semantics enforce single ownership, preventing these C/C++ issues at compile time.

6.2.2 Simple Value Copies: The Copy Trait

Types whose values can be duplicated via a simple bitwise copy implement the Copy trait. This applies to types with a fixed size known at compile time that do not require special cleanup logic (i.e., they don’t implement Drop). When assigned or passed by value (either to another variable or as a function argument), variables of Copy types are duplicated (copied), and the original variable remains valid and usable. Examples include integers, floats, booleans, characters, and tuples/arrays containing only Copy types.

fn makes_copy(some_integer: i32) { // some_integer gets a copy
    println!("Inside function: {}", some_integer);
} // some_integer (the copy) goes out of scope.

fn main() {
    let x = 5;    // i32 implements Copy
    let y = x;    // y gets a COPY of x's value. x is still valid.
    println!("x: {}, y: {}", x, y); // Both usable. Prints: x: 5, y: 5
    makes_copy(x); // x is copied into the function.
    println!("x after function call: {}", x); // x is still valid and usable here.
}

These types are Copy because copying their bits is cheap and sufficient to create a new, independent value. There’s no owned resource (like a heap pointer) requiring unique ownership or cleanup via Drop. Types implementing Drop cannot be Copy, as implicit copying would make resource management ambiguous.

6.2.3 Explicit Deep Copies: The Clone Trait

If you need a true duplicate of data managed by an owning type (like String or Vec<T>) – meaning, new heap allocation and copying the data – you must explicitly request it using the .clone() method. This requires the type to implement the Clone trait (most standard library owning types do).

fn main() {
    let s1 = String::from("duplicate me");
    let s2 = s1.clone(); // Explicitly performs a deep copy. s1 remains valid.
    println!("s1: {}, s2: {}", s1, s2); // Both are valid and own independent data.
} // s1 is dropped, then s2 is dropped. Each frees its own memory.

Because cloning can be expensive (memory allocation and data copying), Rust makes it explicit via a method call. This encourages programmers to consider whether they really need a full copy or if borrowing (using references, discussed next) would be more efficient. Note that for Copy types, clone() is usually implemented as just a simple copy.


6.3 Borrowing: Access Without Ownership Transfer

Often, you need to access data without taking ownership. Rust allows this through borrowing, using references. A reference is like a pointer that provides access to a value owned by another variable, but unlike C pointers, references come with strict compile-time safety guarantees enforced by the borrow checker.

There are two fundamental types of references, which are commonly called “immutable” and “mutable” references, but are more precisely understood as providing shared access and exclusive access, respectively:

  1. Shared References (&T): Allow read-only access to the borrowed data. Multiple shared references to the same data can coexist concurrently.
  2. Exclusive References (&mut T): Allow read-write access to the borrowed data. Only one exclusive reference to a given piece of data can exist at any time.

Using the terms “shared” and “exclusive” helps clarify the fundamental nature of Rust’s borrowing rules: &T allows data to be shared among multiple readers, while &mut T grants exclusive permission to modify the data. This distinction is crucial for Rust’s compile-time memory safety.

6.3.1 References vs. C Pointers

While similar in concept to C pointers (*T), Rust references have key differences:

FeatureRust References (&T, &mut T)C Pointers (*T)
NullabilityGuaranteed non-nullCan be NULL
ValidityGuaranteed to point to valid memory (via lifetimes)Can be dangling (point to freed memory)
Access RulesStrict compile-time rules (one exclusive XOR multiple shared)No compile-time enforcement
ArithmeticGenerally not allowed (use slice methods)Pointer arithmetic is common
DereferencingOften automatic (e.g., method calls)Explicit (*ptr or ptr->member)

Because of these guarantees, Rust references are sometimes called “safe pointers” or “managed pointers.”

Method Calls and Automatic Referencing/Dereferencing

You might notice you can call methods like .len() directly on both an owned String and a reference &String (or &str):

fn main() {
    let owned_string = String::from("hello");
    let string_ref = &owned_string;
    // Both calls work:
    println!("Owned length: {}", owned_string.len());
    println!("Ref length:   {}", string_ref.len());
}

This convenience is enabled by Rust’s method call syntax and automatic referencing and dereferencing. When you use the dot operator (object.method()), the compiler automatically adds necessary &, &mut, or * operations to make the method call match the method’s signature regarding self, &self, or &mut self.

  • If owned_string is String and .len() expects &self (a shared reference), the compiler automatically calls it as (&owned_string).len().
  • If string_ref is &String (a shared reference) and .len() expects &self, the compiler uses it correctly. (It might also involve dereferencing &String to &str first via the Deref trait, then calling len on &str).

This mechanism significantly cleans up code, avoiding manual (&value).method() or (*reference).method() calls in most situations. The Deref trait (covered later) plays a key role in this process for types like String and smart pointers.

6.3.2 The Borrowing Rules

The borrow checker enforces these core rules at compile time:

  1. Scope and Validity: A reference cannot outlive the data it refers to. References are always guaranteed to point to valid data of the expected type (no dangling or null references). (This is primarily enforced by lifetimes, detailed in Section 6.6).
  2. Access Exclusivity: At any given time, you can have either one exclusive reference (&mut T) or any number of shared references (&T) to the same piece of data. You cannot have both types of references active to the same data simultaneously.

Rule 2 ensures that you cannot obtain an exclusive reference while any shared references exist to the same data, nor can you obtain (or keep active) multiple exclusive references simultaneously. This “one or many” rule is fundamental to preventing data races and ensuring safe mutation.

Example: Shared References (Aliasing Allowed)

You can have multiple shared (immutable) references to the same data concurrently. Crucially, this is allowed whether the owner variable itself was declared with mut or not. The mut status of the owner primarily determines if exclusive borrows (&mut T) can be taken or if the owner can be directly modified, not whether shared borrows (&T) are permitted.

fn main() {
    let s1 = String::from("hello"); // Owner is not mutable
    let r1 = &s1; // Shared borrow from immutable owner
    let r2 = &s1; // Another shared borrow
    println!("r1: {}, r2: {}", r1, r2); // OK

    let mut s2 = String::from("hello"); // Owner is mutable
    let r3 = &s2; // Shared borrow from mutable owner is fine
    let r4 = &s2; // Multiple shared borrows are fine
    println!("r3: {}, r4: {}", r3, r4); // Also OK
}

This is safe because shared references guarantee the underlying data won’t change unexpectedly while they are active.

Non-Lexical Lifetimes (NLL) Example

The following example demonstrates how the compiler precisely tracks borrow durations:

    fn main() {
        let mut s1 = String::from("hello");
        let r1 = &s1;                      // (1) Shared borrow starts
        println!("r1: {}, s1: {}", r1, s1); // (2) Last use of r1 (in the success case)
        s1.push('!');                      // (3) Needs exclusive borrow of s1
        println!("s1: {}", s1);
        // println!("r1: {}", r1); // (4) Potential later use of r1
                                // -> uncommenting causes compile error
    }

This code highlights how precisely Rust’s borrow checker analyzes borrow durations, thanks to a feature called Non-Lexical Lifetimes (NLL). Introduced formally in the Rust 2018 Edition, NLL means that borrows are typically considered active only until their last actual point of use within a scope, rather than necessarily lasting for the entire lexical scope (code block) they are declared in.

Let’s trace this example:

  1. A shared borrow r1 begins.
  2. r1 is used in the println!.
  3. s1.push('!') attempts to take an exclusive borrow of s1. This is only allowed if no shared borrows (like r1) are currently active.
  4. The commented-out line represents a potential later use of r1.
  • When line (4) is commented out: The compiler sees that r1’s last use is on line (2). Due to NLL, the shared borrow r1 is considered finished after that point. Therefore, the exclusive borrow needed for s1.push('!') on line (3) is permitted because r1 is no longer active. The code compiles.
  • When line (4) is uncommented: The compiler sees r1 is used again on line (4). NLL determines that the shared borrow r1 must remain active until line (4). This means r1 is still active when line (3) (s1.push('!')) tries to take an exclusive borrow. This violates the rule (‘cannot borrow s1 as mutable because it is also borrowed as immutable’), and compilation fails, typically with an error message pointing to line (3).

This NLL behavior allows more code to compile than older versions of the borrow checker while still strictly preventing errors caused by conflicting borrows.

Example: Exclusive Reference (Exclusive Access)

You can only have one exclusive (mutable) reference to a piece of data in a particular scope. Furthermore, the variable bound to the data must be declared mut to allow exclusive borrowing.

fn main() {
    let mut s = String::from("hello"); // Must be `mut` to borrow exclusively
    let r1 = &mut s; // One exclusive borrow

    // The following lines would cause compile-time errors if uncommented:
    // let r2 = &mut s; // Error: Cannot have a second exclusive borrow.
    // let r3 = &s; // Error: Cannot have a shared borrow while an exclusive one exists.
    // s.push_str("!"); // Error: Cannot access owner directly while exclusively borrowed.

    r1.push_str(" world"); // Modify data through the exclusive reference
    println!("r1: {}", r1);
} // r1 goes out of scope here. The exclusive borrow ends.

6.3.3 Why These Rules Benefit Single-Threaded Code

The borrowing rules, especially the “one exclusive (&mut) XOR many shared (&)” rule (Access Exclusivity), might seem overly strict if you’re only thinking about multi-threaded data races. However, they are fundamental to Rust’s safety and predictability guarantees even in single-threaded code.

Consider the following example, which Rust refuses to compile:

fn main() {
    let mut v = vec![1, 2, 3];
    let first = &v[0]; // shared borrow occurs here
    v.push(4); // exclusive borrow occurs here
    println!("{:?} {}", v, first); // shared borrow later used here
}

This code attempts to keep a shared reference to an element of a vector while later modifying the vector. Rust rejects this pattern because changes to the vector, such as inserting a new element, may require reallocating its internal memory buffer. Such reallocation would move the elements in memory and make existing references invalid, potentially leading to undefined behavior (e.g., using a dangling pointer).

Without Rust’s strict aliasing rules, several subtle but serious problems could arise:

  • Iterator Invalidation: Imagine iterating over a Vec<T> while simultaneously holding another reference that adds or removes elements from it. This could lead to skipping elements, processing garbage data, or crashing. C++ programmers are familiar with similar issues where modifying a container invalidates its iterators. Rust’s rules prevent modifying the Vec (via an exclusive reference) while shared references (used by the iterator) exist.

  • Data Structure Integrity: Consider an enum with variants like Int(i32) and Text(String). If multiple exclusive references were allowed, one reference might be interacting with the Text variant (e.g., reading the String’s length or characters). Simultaneously, another exclusive reference could change the enum’s variant to Int(42). This would overwrite the memory that the first reference assumes holds valid String metadata (like its pointer, length, and capacity). Attempting to use the String through the first reference after this change would lead to accessing invalid data or memory corruption. Rust’s borrowing rules prevent this entirely by ensuring only one exclusive reference can exist at a time, guaranteeing that such conflicting modifications cannot happen simultaneously and preserving data structure integrity.

  • Unpredictable State: If multiple exclusive references (&mut T) could alias the same data, calling methods through one reference could unexpectedly change the state observed through another, leading to complex, hard-to-debug logic errors. The exclusivity rule ensures that when you modify data through an exclusive reference, you have sole permission during that borrow’s lifetime.

  • Ambiguity and Undefined Behavior: Consider how C handles aliased mutable pointers:

    #include <stdio.h>
    
    void modify(int *a, int *b) {
        *a = 42; // Write through pointer a
        *b = 99; // Write through pointer b
        // If a and b point to the same location, what is the final value?
    }
    
    int main() {
        int x = 10;
        modify(&x, &x); // Pass the same address twice
        // The C standard considers this potentially undefined behavior depending
        // on optimizations. The compiler might assume a and b don't alias.
        printf("x = %d\n", x); // Could print 42 or 99?
        return 0;
    }
    

    The C compiler might optimize based on the assumption that a and b point to different locations. If they alias, the result becomes unpredictable. Rust’s borrow checker forbids creating such ambiguous aliased exclusive references in safe code, preventing this class of errors at compile time.

In summary, the borrowing rules eliminate many potential pitfalls familiar from C/C++, ensuring data consistency and predictable behavior even without considering threads. They also enable the compiler to perform more aggressive optimizations safely.

Invalid Reference Example (Dangling Pointer Prevention)

Rust also prevents references from outliving the data they point to:

fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String { // Tries to return a shared reference to a String
    let s = String::from("hello"); // s is created inside dangle
    &s // Return a reference to s
} // s goes out of scope and is dropped here. Its memory is freed.
  // The returned reference would point to invalid memory!

The compiler rejects this code because the reference &s would outlive the owner s. This is handled by Rust’s lifetime system, ensuring references are always valid.


6.4 The String Type and Memory Details

Understanding how String works internally helps clarify ownership and borrowing.

  • Stack vs. Heap: While the String metadata lives where the String variable is declared (stack for local variables, potentially heap if part of another structure), the actual character data resides on the heap. This dynamic allocation is why String isn’t Copy.
  • String Structure: A String consists of three parts stored together (often on the stack):
    1. A pointer to a buffer on the heap containing the actual UTF-8 encoded character data.
    2. A length: The number of bytes currently used by the string data.
    3. A capacity: The total number of bytes allocated in the heap buffer.
  • Growth: When you append to a String and its length exceeds its capacity, Rust reallocates a larger buffer on the heap (often doubling the capacity), copies the old data over, updates the pointer, length, and capacity, and frees the old buffer.
  • Dropping: When a String owner goes out of scope, its drop implementation frees the heap buffer.

6.5 Slices: Borrowing Contiguous Data

Beyond references to entire values, Rust provides slices, which are references to a contiguous sequence of elements within a collection, rather than the whole collection. Slices provide a non-owning view (a borrow) into data owned by something else (like a String, Vec<T>, array, or even another slice). They are crucial for writing efficient code that accesses portions of data without needing to copy it or take ownership.

Internally, a slice is typically a fat pointer, storing two pieces of information:

  1. A pointer to the start of the sequence segment.
  2. The length of the sequence segment.

Because slices borrow data, they strictly adhere to Rust’s borrowing rules: you can have multiple shared slices of the same data, or exactly one exclusive slice, but not both at the same time if they could overlap.

6.5.1 Shared and Exclusive Slices

There are two primary kinds of slices, mirroring the two kinds of references:

  • Shared Slice (&[T]): Provides read-only access to a sequence of elements of type T.
  • Exclusive Slice (&mut [T]): Provides read-write access to a sequence of elements of type T.

The type T represents the element type (e.g., i32, u8).

6.5.2 Array Slices

Slices are commonly used with arrays (fixed-size lists on the stack) and vectors (growable lists on the heap).

fn main() {
    let numbers: [i32; 5] = [10, 20, 30, 40, 50]; // An array

    // Create shared slices using range syntax
    let all: &[i32] = &numbers[..];          // Slice of the whole array
    let first_two: &[i32] = &numbers[0..2];  // Slice of elements 0 and 1 ([10, 20])
    let last_three: &[i32] = &numbers[2..];  // Slice of elements 2, 3, 4 ([30, 40, 50])

    println!("All: {:?}", all);
    println!("First two: {:?}", first_two);
    println!("Last three: {:?}", last_three);

    // Create an exclusive slice (requires the owner to be mutable)
    let mut mutable_numbers = [1, 2, 3];
    let exclusive_slice: &mut [i32] = &mut mutable_numbers[1..]; // Slice of elements
    // 1 and 2. Index access refers to the slice itself: index 0 of the slice is
    // index 1 of the array.
    exclusive_slice[0] = 99;
    // mutable_numbers is now [1, 99, 3]
    println!("Modified numbers: {:?}", mutable_numbers);
}

Note: The .. range syntax creates slices: .. is the whole range, start..end includes start but excludes end, start.. goes from start to the end, and ..end goes from the beginning up to (excluding) end. This syntax works on arrays, vectors, and existing slices.

6.5.3 String Slices (&str)

A string slice, written &str, is a specific type of shared slice that always refers to a sequence of valid UTF-8 encoded bytes. It’s the most primitive string type in Rust. You can create string slices by borrowing from Strings, other string slices, or string literals using range syntax with byte indices.

fn main() {
    let s_ascii: String = String::from("hello world"); // ASCII string

    // Slicing ASCII text is straightforward as byte indices match character boundaries
    let hello: &str = &s_ascii[0..5]; // Slice referencing "hello"
    let world: &str = &s_ascii[6..11]; // Slice referencing "world"
    println!("Slice 1: {}", hello);
    println!("Slice 2: {}", world);

    // With multi-byte UTF-8 characters, indices must respect character boundaries
    let s_utf8 = String::from("你好"); // "Nǐ hǎo" - 6 bytes total, each char is 3 bytes
    // let invalid_slice = &s_utf8[0..1]; // PANIC! 1 is not a character boundary.
    // let invalid_slice = &s_utf8[0..2]; // PANIC! 2 is not a character boundary.
    let first_char: &str = &s_utf8[0..3]; // OK: Slice referencing first character "你"
    let second_char: &str = &s_utf8[3..6];//OK: Slice referencing second character "好"

    println!("First char: {}", first_char);
    println!("Second char: {}", second_char);
}

Because &str must always point to valid UTF-8 sequences, creating string slices using byte indices ([start..end]) has an important restriction: the start and end indices must fall on valid UTF-8 character boundaries. Attempting to create a slice where an index lies in the middle of a multi-byte character sequence is a runtime error and will cause your program to panic (a controlled crash indicating a program bug).

For the simpler examples in this chapter introducing slices, we often use ASCII text where each character is conveniently one byte long, making byte indices align with character boundaries. When working with text that may contain multi-byte characters, slicing using direct byte indices requires careful validation; often, iterating over characters or using methods designed for UTF-8 processing is a safer approach than direct byte-index slicing. Operations that could break the UTF-8 invariant (like arbitrary byte mutation within a &mut str) are also carefully controlled, as discussed later.

6.5.4 String Literals

Now we can understand string literals (e.g., "hello"). They are essentially string slices (&str) whose data is stored directly in the program’s compiled binary and is therefore valid for the entire program’s execution. Their type is &'static str, where 'static is a special lifetime indicating validity for the whole program runtime.

fn main() {
    let literal_slice: &'static str = "I am stored in the binary";
    println!("{}", literal_slice);
}

6.5.5 Slices in Functions

One of the most common uses for slices is in function arguments. Accepting a slice (&[T] or &str) instead of an owned type (like Vec<T> or String) makes a function more flexible and efficient, as it can operate on different kinds of data sources without taking ownership or requiring data copying.

// Function accepting an array/vector slice
fn sum_slice(slice: &[i32]) -> i32 {
    let mut total = 0;
    for &item in slice { // Iterate over elements in the slice
        total += item;
    }
    total
}

// Function accepting a string slice
fn first_word(text: &str) -> &str {
    // Iterate over bytes, find first space
    for (i, &byte) in text.as_bytes().iter().enumerate() {
        if byte == b' ' {
            return &text[0..i]; // Return slice up to space
        }
    }
    &text[..] // No space found, return whole slice
}

fn main() {
    // Array slice example
    let numbers = [1, 2, 3, 4, 5];
    // Can pass reference to array directly (coerces to slice)
    println!("Sum of numbers: {}", sum_slice(&numbers));
    // Or pass explicit slice
    println!("Sum of part: {}", sum_slice(&numbers[1..4]));

    // String slice example
    let sentence = String::from("hello wonderful world");
    println!("First word: {}", first_word(&sentence)); // Pass slice of String
    let literal = "goodbye";
    println!("First word: {}", first_word(literal)); // Pass a string literal directly
}

Note: Due to automatic deref coercions (discussed later), functions expecting &[T] can often directly accept references to arrays (&[T; N]) or Vec<T>s. Similarly, functions expecting &str can accept &String.

6.5.6 Exclusive Slices (&mut [T] and &mut str)

Exclusive slices (&mut [T]) allow modification of the elements within the borrowed sequence:

fn main() {
    let mut data = [10, 20, 30];
    let slice: &mut [i32] = &mut data[..];
    slice[0] = 15;
    slice[1] *= 2;
    println!("Modified data: {:?}", data); // Prints: [15, 40, 30]
}

Exclusive string slices (&mut str) exist but are more restricted. Because a &str (and &mut str) must always contain valid UTF-8, arbitrary byte modifications are disallowed. Furthermore, the length of a string slice cannot be changed, as this would require modifying the owner (e.g., reallocating a String), which a borrow cannot do. This prevents simple appending operations directly on a &mut str.

Exclusive string slices are primarily useful for in-place modifications that preserve UTF-8 validity and length, such as changing case via methods like make_ascii_uppercase(). For operations that need to change string length or might temporarily invalidate UTF-8, working directly with an owned String or an exclusive byte slice (&mut [u8]) is necessary.

fn main() {
    let mut s = String::from("hello");
    { // Limit scope of exclusive borrow
        let slice: &mut str = &mut s[..];
        slice.make_ascii_uppercase(); // In-place modification allowed
    } // Exclusive borrow ends here
    println!("Uppercase: {}", s); // Prints: HELLO
}

Remember that all slice operations must respect the borrowing rules – particularly the exclusivity of exclusive borrows for potentially overlapping data.


6.6 Lifetimes: Ensuring References Remain Valid

Rust lifetimes, those '_ things, are not directly about the liveness scope of values or variables, nor are they about when a value gets destructed. Instead, they are primarily about the duration of borrows. They are a compile-time concept, a type-level property that gets discarded after borrow checking completes and is not present during runtime. Variables themselves do not inherently have “Rust lifetimes” (those '_ things); rather, these lifetimes parameterize types, especially references.

Every reference in Rust has a lifetime, but the compiler can often infer them without explicit annotation through a set of rules called lifetime elision rules. You only need to write lifetime annotations when the compiler’s inference rules are insufficient to guarantee safety. This is always the case for structs containing references and is also required for functions where the relationship between input and output reference lifetimes is ambiguous.

6.6.1 Explicit Lifetime Annotation Syntax

When you need to be explicit, lifetime annotations use the following syntax:

  • Names: Lifetime names start with an apostrophe (') followed by a short, lowercase name (conventionally starting from 'a, e.g., 'a, 'b, 'input). The name 'static has a special, reserved meaning (see below).
  • Declaration: Generic lifetime parameters are declared in angle brackets after a function name (e.g., fn my_func<'a, 'b>) or struct/enum name (e.g., struct MyStruct<'a>).
  • Usage: The lifetime name is placed after the & (or &mut) in a reference type (e.g., x: &'a str, y: &'b mut i32).

Lifetime annotations do not change how long any values live. Instead, they describe the relationships between the validity scopes (lifetimes) of different references, allowing the borrow checker to verify that references are used safely. They act as constraints for the compiler’s analysis.

Example: Function with Lifetimes

Consider a function that returns the longer of two string slices. Because the returned reference borrows from one of the inputs, the compiler needs explicit annotations to know how the lifetime of the output relates to the lifetimes of the inputs.

// `<'a>` declares a generic lifetime parameter `'a`.
// `x: &'a str` and `y: &'a str` constrain both input slices to live at least
// as long as `'a`.
// `-> &'a str` declares that the ret. slice is also bound by this same lifetime `'a`.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

fn main() {
    let string1 = String::from("long string is long");
    let result;
    {
        let string2 = String::from("xyz");
        // The compiler enforces that 'a is, at most, the shorter borrow duration
        // of string1 and string2 relevant to this call.
        result = longest(&string1, &string2);
        println!("The longest string is '{}'", result); // Works here, result is valid.
    }
    // println!("The longest string is '{}'", result); // Compile-time error!
    // The borrow duration associated with `result` (which might point to `string2`'s
    // data) has ended because `string2` went out of scope. Using `result` here
    // would risk accessing freed memory.
}

The annotation 'a connects the lifetimes: the returned reference is guaranteed to be valid only as long as both input references (x and y) are valid. If the function tried to return a reference to data created inside the function (like the dangle example earlier), the compiler would reject it because that data’s lifetime would be shorter than the required lifetime 'a.

Important Clarification: When a function’s return type is annotated with a lifetime from an input (e.g., -> &'a str), it signifies that uses of the returned value keep the corresponding input borrow(s) active. It’s not that the lifetime of the return value is decided at the call site based on the inputs; rather, the borrow checker verifies that the returned reference’s actual use duration respects the longest possible duration implied by its inputs.

The 'static Lifetime

The special lifetime 'static indicates that a reference is valid for the entire duration of the program. String literals (&'static str) have this lifetime because their data is embedded in the program’s binary. References to global constants or leaked Boxes can also have the 'static lifetime.

Mastering lifetimes, particularly understanding elision rules and when annotations are needed, is key to leveraging Rust’s compile-time safety guarantees effectively. We’ll encounter more complex lifetime scenarios later.


6.7 Overview of Smart Pointers

In much of your Rust code, you’ll work with values stored directly on the stack or use standard library collections like Vec<T> and String, which manage their internal heap allocations automatically. However, Rust also provides smart pointers for specific situations requiring more explicit control over heap allocation, different ownership models (like shared ownership), or the ability to bypass certain borrowing rules safely (via runtime checks). Smart pointers are types that act like pointers but have additional metadata and capabilities, often related to ownership, allocation, or runtime checks. They provide abstractions over raw pointers for managing heap-allocated data or implementing these specific ownership patterns. Here’s a brief preview (detailed in Chapter 19):

  • Box<T>: The simplest smart pointer. Owns data allocated on the heap. Used for transferring ownership of heap data, creating recursive types (whose size would otherwise be infinite), or storing fixed-size handles to dynamically sized types (like trait objects).

    fn main() { // Added main wrapper for editable block
        let b = Box::new(5); // Allocates an i32 on the heap, b owns it.
        println!("Box contains: {}", b);
    }
  • Rc<T> (Reference Counting): Allows multiple owners of the same heap data in a single-threaded context. Keeps track of the number of active references; the data is dropped only when the last reference (Rc) goes out of scope. Use Rc::clone(&rc) to create a new reference and increment the count (this is cheap, just updates the count, not a deep copy).

    use std::rc::Rc;
    fn main() { // Added main wrapper for editable block
        let data = Rc::new(String::from("shared data"));
        let owner1 = Rc::clone(&data); // owner1 shares ownership
        let owner2 = Rc::clone(&data); // owner2 also shares ownership
        // Rc::strong_count shows the number of Rc pointers to the data
        println!("Data: {}, Count: {}", data, Rc::strong_count(&owner1)); // Prints 3
    } // owner1, owner2 go out of scope, then data. Count drops to 0, String is freed.
  • Arc<T> (Atomic Reference Counting): The thread-safe version of Rc<T>. Uses atomic operations for incrementing/decrementing the reference count, allowing safe sharing of ownership across multiple threads.

    // Example requires threads, make non-editable or more complex
    use std::sync::Arc;
    use std::thread;
    fn main() { // Added main wrapper
        let data = Arc::new(vec![1, 2, 3]);
        println!("Initial count: {}", Arc::strong_count(&data)); // Count is 1
        let thread_handle = Arc::clone(&data); //Clone Arc for another thread, count=2
        let handle = thread::spawn(move || {
            println!("Thread sees count: {}", Arc::strong_count(&thread_handle)); // 2
            // use thread_handle
        });
        println!("Main sees count after spawn: {}", Arc::strong_count(&data)); // = 2
        handle.join().unwrap(); // Wait for thread
        println!("Final count: {}", Arc::strong_count(&data)); // Count is 1 again
    } // data goes out of scope, count drops to 0, Vec is freed.
  • RefCell<T> and Cell<T> (Interior Mutability): Provide mechanisms to mutate data even through an apparently shared (immutable) reference (&T) – this pattern is called interior mutability.

    • RefCell<T> enforces the borrowing rules (one exclusive XOR multiple shared) at runtime instead of compile time. If the rules are violated, the program panics. Often used with Rc<T> to allow multiple owners to mutate shared data (within a single thread).
    • Cell<T> is simpler, primarily for Copy types. It allows replacing the contained value (.set()) or getting a copy (.get()) even through a shared reference, without runtime checks or panics (as simple replacement of Copy types doesn’t invalidate other references).
    use std::cell::RefCell;
    use std::rc::Rc;
    fn main() { // Added main wrapper for editable block
        let shared_list = Rc::new(RefCell::new(vec![1]));
        let list_clone = Rc::clone(&shared_list);
        // Mutate through RefCell (runtime borrow check)
        shared_list.borrow_mut().push(2);
        list_clone.borrow_mut().push(3);
        // Access sharedly (also runtime checked)
        println!("{:?}", shared_list.borrow()); // Prints [1, 2, 3]
    }

These smart pointers offer different strategies for managing memory and ownership, providing flexibility beyond the basic rules while maintaining Rust’s safety guarantees (either at compile-time or runtime).


6.8 Unsafe Rust and C Interoperability

While Rust prioritizes safety, sometimes you need capabilities that the compiler cannot statically guarantee are safe. This is often required for low-level systems programming tasks (like interacting directly with hardware), optimizing performance-critical code, or interfacing with other languages like C that don’t share Rust’s guarantees. For these situations, Rust provides the unsafe keyword (detailed in Chapter 25).

6.8.1 unsafe Blocks and Functions

Inside an unsafe block or function, you gain access to five additional capabilities (“superpowers”) that are normally disallowed in safe Rust:

  1. Dereferencing raw pointers (*const T, *mut T).
  2. Calling unsafe functions or methods (including C functions via FFI and low-level intrinsics).
  3. Accessing or modifying mutable static variables.
  4. Implementing unsafe traits.
  5. Accessing fields of unions (unions require unsafe because Rust can’t guarantee which variant is active).
fn main() {
    let mut num = 5;

    // Creating raw pointers is safe (doesn't dereference)
    let r1 = &num as *const i32; // Immutable raw pointer
    let r2 = &mut num as *mut i32; // Mutable raw pointer

    // Dereferencing raw pointers requires an unsafe block
    unsafe {
        println!("r1 points to: {}", *r1); // Read via raw pointer
        *r2 = 10; // Write via raw mutable pointer
    }
    // Outside the unsafe block, normal rules apply again.

    println!("num is now: {}", num); // Prints: num is now: 10
}

Using unsafe signifies that you, the programmer, are taking responsibility for upholding memory safety for the operations within that block. The compiler trusts you to ensure that raw pointers are valid, functions uphold their contracts, etc. It’s crucial to minimize the scope of unsafe blocks and carefully document why they are necessary and correct. unsafe does not turn off the borrow checker entirely; it only enables these specific extra capabilities.

6.8.2 Interfacing with C (FFI)

Rust’s Foreign Function Interface (FFI) allows seamless calling of C code from Rust and exposing Rust code to be called by C. This involves using raw pointers and often unsafe blocks.

Calling C from Rust:

// Declare the C function signature using `extern "C"`
// This tells Rust to use the C Application Binary Interface (ABI).
// In Rust 2021+, extern blocks require `unsafe` if they contain functions.
unsafe extern "C" {
    fn abs(input: i32) -> i32; // Example: C standard library abs function
}

fn main() {
    let number = -5;
    // Calling external functions declared in `extern` blocks is unsafe
    let absolute_value = unsafe { abs(number) };
    println!("The absolute value of {} is {}", number, absolute_value);
}

Calling Rust from C:

Rust code compiled as a library (crate-type = ["cdylib"] or similar):

// Disable Rust's name mangling and use the C ABI
#[no_mangle]
pub extern "C" fn rust_adder(a: i32, b: i32) -> i32 {
    println!("Rust function called from C!");
    a + b
}

C code linking against the compiled Rust library:

#include <stdio.h>
#include <stdint.h> // For int32_t

// Declare the Rust function signature as it appears to C
extern int32_t rust_adder(int32_t a, int32_t b);

int main() {
    int32_t result = rust_adder(10, 12);
    printf("Result from Rust: %d\n", result); // Output: Result from Rust: 22
    return 0;
}

Tools like cbindgen (generates C/C++ headers from Rust code) and bindgen (generates Rust bindings from C/C++ headers) automate much of the boilerplate involved in FFI.


6.9 Comparison Summary: Rust vs. C Memory Management

FeatureC / C++ (Manual/RAII)Rust (Ownership & Borrowing)
Memory SafetyProne to leaks, dangling ptrs, double frees, use-after-free, buffer overflowsCompile-time prevention of these memory errors in safe code
Resource MgmtManual (free) or RAII (destructors)Automatic (Drop trait based on scope/ownership)
Data RacesPossible via aliased mutable pointers (even single-threaded UB), or thread concurrencyPrevented by borrow checking (shared/exclusive references), Send/Sync traits for threads
PointersRaw pointers (*), potential null/invalid state/aliasing issuesSafe references (shared/exclusive), guaranteed valid/non-null; raw pointers only in unsafe
ConcurrencyRequires manual locking/synchronization, error-proneOwnership/borrowing + Send/Sync provide compile-time concurrency safety
Runtime OverheadMinimal (manual) or depends on smart pointer/RAII logicMinimal (compile-time checks, Drop calls, slice bounds checks)
FlexibilityHigh, but requires significant discipline for safetyHigh, with safety by default; unsafe provides low-level control when needed

Rust’s ownership and borrowing system provides performance and control comparable to C/C++ while eliminating many common memory safety and concurrency pitfalls at compile time. This shifts bug detection much earlier in the development cycle.


6.10 Summary

This chapter introduced Rust’s core memory management philosophy, centered around ownership, borrowing, and lifetimes:

  • Ownership: Every value has one owner; when the owner goes out of scope, the value is dropped. This “drop scope” is lexical, meaning destructors run at the end of the variable’s declared block. Ownership transfers via move semantics for types managing resources (like heap data), both in assignments and function calls/returns.
  • Copy vs. Clone: Simple value types use cheap copy semantics (Copy trait), leaving the original variable valid. Types managing resources require explicit, potentially expensive cloning (Clone trait) for deep copies.
  • Borrowing: References (&T, &mut T) allow temporary access to data without taking ownership. These are more precisely understood as shared references (for read-only access, allowing multiple concurrent borrows) and exclusive references (for read-write access, allowing only one concurrent borrow). These strict compile-time rules prevent data races and other aliasing bugs, even in single-threaded code. Method calls often use automatic referencing/dereferencing for convenience.
  • Lifetimes: Ensure references never outlive the data they point to, preventing dangling references. Crucially, Rust lifetimes ('_ things) are about the duration of borrows, not directly about the liveness scope of values or variables, nor when values are destructed. They are a compile-time concept, a type-level property for the borrow checker’s analysis. Often inferred (elision), but sometimes require explicit annotation ('a) to clarify relationships for the compiler.
  • Slices (&str, &[T]): Non-owning references (borrows) to contiguous sequences of data (like parts of Strings or arrays), enabling flexible function APIs. These also follow the shared/exclusive access rules.
  • Smart Pointers (Box, Rc, Arc, RefCell): Provide patterns like heap allocation, shared ownership (single/multi-threaded), and interior mutability, abstracting over raw pointers while maintaining specific safety guarantees. Used for specific scenarios beyond standard stack/collection usage.
  • Unsafe Rust: Allows bypassing some safety checks within designated blocks for low-level control and FFI, requiring manual programmer verification of safety.
  • C Interoperability: Rust provides a robust FFI for calling C code and being called by C.

Mastering ownership, borrowing, and lifetimes is fundamental to writing effective, safe, and performant Rust code. It allows Rust to offer memory safety comparable to garbage-collected languages without the runtime overhead, making it highly suitable for the systems programming tasks familiar to C programmers.