7.3 Memory Management

Memory Fundamentals: Bridging C/C++ and Rust
Section titled “Memory Fundamentals: Bridging C/C++ and Rust”Revisiting Stack and Heap: A C/C++ Perspective
Section titled “Revisiting Stack and Heap: A C/C++ Perspective”In C/C++, memory management involves a dual responsibility. Programmers explicitly manage memory on the stack for local variables and function call frames. This offers fast, automatic allocation and deallocation, as memory is managed in a Last-In, First-Out (LIFO) manner.
For dynamic data, whose size may not be known at compile time or whose lifetime needs to extend beyond a function’s scope, heap allocation is typically performed through the use of new/delete or malloc/free.
While the heap provides flexibility, it introduces the significant burden of manual memory management, which can lead to potential memory leaks, double-frees, or use-after-free errors if not meticulously handled.
Rust’s Approach to Stack and Heap Allocation
Section titled “Rust’s Approach to Stack and Heap Allocation”Rust, similar to C/C++, employs both the stack and the heap for memory allocation. Primitive types, such as integers, Booleans, characters, and fixed-size arrays, along with the metadata for complex types (e.g., pointers, lengths, capacities), are typically allocated on the stack.
Dynamically sized data structures, including String (a growable UTF-8 string) or Vec<T> (a growable list of elements), allocate their actual data content on the heap. However, the Vec object itself (which is a small triplet containing a pointer to the heap data, its allocated capacity, and its current length) resides on the stack.
Consider the following example of Vec allocation:
C++: std::vector<int> v = {1, 2, 3};
- The
vobject itself is stack-allocated, but the elements1, 2, 3are stored on the heap, managed by thestd::vector’s internal mechanisms. Rust:let v = vec!; - Similarly, the
vbinding (theVecstruct containing the pointer, capacity, and length) is stack-allocated, while the actual integer data resides on the heap.
For example:
fn main() { // Create a mutable vector containing 1, 2, and 3 let mut numbers = vec![1, 2, 3];
// {} is for human-friendly output using the Display trait println!("Capacity: {}", numbers.capacity()); println!("Length: {}", numbers.len());
// {:?} is for developer-friendly output using the Debug trait println!("Values: {:?}", numbers);}Gives the output:
Capacity: 3Length: 3Values: [1, 2, 3]Memory Safety: Temporal and Spatial Safety
Section titled “Memory Safety: Temporal and Spatial Safety”Rust’s design is fundamentally driven by the principle of memory safety, which is broadly categorised into two aspects:
- Temporal Safety: This ensures that memory is not accessed before it is allocated or after it has been deallocated. This prevents critical errors such as use-after-free, double-frees, and dangling pointers. Rust achieves temporal safety primarily through sophisticated compile-time checks, encompassing its ownership, borrowing, and lifetime rules. The overhead for temporal safety is incurred during compilation, not at runtime, rendering its runtime cost “free”.
- Spatial Safety: This prevents access to memory outside of its allocated bounds, thereby preventing issues like buffer overflows. Rust achieves spatial safety through a combination of library-provided abstractions and runtime checks. For example, safe iterators abstract away raw pointer manipulation, and bounds checks are performed for array indexing, along with null checks for
Optiontypes. While these runtime checks can introduce a minor performance overhead if not optimised away, Rust provides mechanisms, such as unsafe blocks for localised, performance-critical sections, to mitigate this.
In contrast, C++ relies heavily on developer discipline, careful code reviews, and external static analysis tools to manage these issues. The flexibility and lack of compiler-enforced memory safety in C++ mean that memory-related bugs, often leading to undefined behaviour, remain a constant concern if not carefully handled.
Variables and Mutability
Section titled “Variables and Mutability”Default Immutability in Rust
Section titled “Default Immutability in Rust”An immediate and obvious difference in Rust from C/C++ is that variables in Rust are immutable by default. Once a value is bound to a variable using the let keyword, its value cannot be changed. This design choice promotes safer code by reducing unexpected side effects and making data flow more explicit.
C++ Example:
int x = 5;x = 10; // Valid: 'x' is mutable by default, its value is reassigned.Rust Example:
let x = 5;x = 10; // This line would result in a compile-time ERROR: // "cannot assign twice to immutable variable `x`"Explicit Mutability with mut
Section titled “Explicit Mutability with mut”To allow a variable’s value to be changed after its initial binding, it must be explicitly declared as mutable using the mut keyword. This makes the intent to modify clear to both the compiler and other developers.
fn main() { let mut x = 5; // Declare 'x' as mutable x = 10; // Valid: 'x' can now be reassigned println!("x: {}", x); // Output: x: 10}Variable Shadowing: A Unique Rust Feature
Section titled “Variable Shadowing: A Unique Rust Feature”Rust introduces a concept called shadowing, which allows the declaration of a new variable with the same name as a previous one. This new variable “shadows” (or hides) the old one, rendering the old variable inaccessible within the new variable’s scope. This is distinct from mutability; shadowing creates a new binding, potentially with a different type, while mutability alters the value of an existing binding.
Shadowing is useful for re-using a logical variable name while performing sequential transformations on a value (e.g., trimming a string then parsing it), and it allows you to change a variable’s type without needing to invent multiple unique names. (This won’t be clear from the initial examples used.)
For example:
fn main() { let x = 5; // First 'x', value is 5 let x = x + 1; // Second 'x', shadows the first. Value is 6. // The old 'x' (value 5) is no longer accessible. let x = "hello"; // Third 'x', shadows the second. Value is a string. // The old 'x' (value 6) is no longer accessible. println!("x: {}", x); // Output: x: hello}When executed this program will give the output:
x: helloPlease note that if you paste this code example into VS Code you will see something similar to Figure 1, where the type inlay hints provided by rust-analyzer are displayed.

When you paste your code into VS Code (assuming you have the Rust extensions installed, e.g., rust-analyzer), you will likely see something like this displayed inline or as a hover tooltip:
let x: i32 = 5;let x: i32 = x + 1;let x: &str = "hello";Even though you never explicitly wrote the types, the tool is showing you what the compiler has inferred. This means you don’t need to write types in many situations and the compiler figures them out. The rust-analyzer extension helps you understand what types Rust inferred at each point. You can control this in VS Code using Settings → Extensions → rust-analyzer → Inlay Hints → Type Hints. You can turn it on/off, or configure which hints are shown.
While C++ allows re-declaration of variables with the same name in different nested scopes (e.g., within an if block), true shadowing within the same logical scope, where a new variable completely replaces an existing one with the same name, is not a direct feature in C++ and would typically involve different variable names or direct reassignment.
Shadowing serves as an ergonomic and semantically powerful feature that streamlines code when performing sequential transformations on a value. Instead of inventing new variable names for each step (e.g., s_raw, s_trimmed, s_processed), shadowing enables the programmer to reuse the same logical name. This clearly signals that the previous state of the variable is no longer relevant after the transformation, aligning well with Rust’s philosophy of explicit state changes and ownership. A key advantage of shadowing over simple mutability is its ability to change the type of the variable, for instance, from an integer to a string, which is not possible with let mut. This provides a clean, idiomatic way to manage the evolution of a variable’s value and type through a series of operations. For example, shadowing allows you to transform data while keeping a clean, logical name:
let input = " 42 ";let input = input.trim(); // Shadowing to trim (still a string)let input: i32 = input.parse().unwrap();// Shadowing to change type to an integerNext there is a short code demonstration where you can see a number of shadowing examples in action. After that there is a short summary table, Table 1, that describes a comparison between variable usage in C++ and Rust.
🎬Code Demo: Shadowing in Rust
Section titled “🎬Code Demo: Shadowing in Rust”Shadowing in Rust
Table 1: Variable Behaviour Comparison (C++ vs. Rust) For a C/C++ programmer, concepts like default immutability and shadowing represent significant departures in approach to writing code.
| Feature | C++ | Rust |
|---|---|---|
| Default State | Mutable | Immutable |
| Explicit Mutability | const keyword for immutability | mut keyword for mutability |
| Reassignment | Direct reassignment allowed | Not allowed by default; requires mut |
| Shadowing | Not a direct feature in same scope; typically new names or re-assignment | Allowed; new variable with same name shadows old one; can change type |
| Type Inference | Limited (e.g., auto for declaration) | Extensive (let y = 22; is common) |
Constants (const)
Section titled “Constants (const)”Constants in Rust are always immutable and are declared using the const keyword. They require an explicit type annotation and can only be set to a constant expression that can be evaluated at compile time. Use screaming snake case!
const MAX_POINTS: u32 = 100_000;This is similar to const variables in C/C++, though Rust’s constants have stricter requirements for compile-time evaluation.
const int MAX_POINTS = 100000;🧩Knowledge Check
Section titled “🧩Knowledge Check”Match Memory and Variable Concepts
Ownership: Rust’s Core Memory Management Model
Section titled “Ownership: Rust’s Core Memory Management Model”The Single Ownership Rule
Section titled “The Single Ownership Rule”Rust’s robust memory safety guarantees are fundamentally built upon the single ownership model. This core rule dictates that every piece of data in Rust has a single variable that acts as its “owner” at any given time. When this owner variable goes out of scope, Rust automatically deallocates the memory associated with that data. This system eliminates the need for manual memory management, such as new/delete or malloc/free, and avoids the runtime overhead and unpredictability of garbage collection.
This concept shares conceptual similarities with how std::unique_ptr is used in C++, where only one unique_ptr can own a dynamically allocated resource at a time. However, in Rust, this single ownership is the default behaviour for virtually all non-primitive types, not merely for objects managed by a specific smart pointer.
Move Semantics by Default
Section titled “Move Semantics by Default”A direct and critical consequence of the single ownership rule is Rust’s default move semantics. When a variable is assigned to another, or a value is passed to a function, Rust moves ownership of the underlying data. The original variable is then considered invalid and cannot be used, preventing common memory errors like double-frees and ensuring that only one owner exists for any given piece of data.
C++ Default (Copy): In C++, assigning one variable to another typically performs a copy. For complex types like std::string or std::vector, this usually implies a deep copy, creating independent copies of the data. Both the source and destination variables remain valid and manage their own copies.
std::string s1 = "Hello";std::string s2 = s1; // s2 is a deep copy of s1. Both s1 and s2 are valid // s1 and s2 each free their own memory when they go out of scope.Rust Default (Move):
let s1 = String::from("Hello");let s2 = s1; // Ownership of the "Hello" data moves from s1 to s2. println!("{}", s1); // This line would result in a compile-time ERROR: // "borrow of moved value: s1" // When s2 goes out of scope, "Hello" data is freed.This move operation is mechanically a simple bitwise copy of the object’s stack-allocated metadata. For a String, this means copying the pointer to the heap data, the length, and the capacity to a new location on the stack. Semantically, however, the compiler immediately “invalidates” the source variable.
Comparison with C++ std::move:
In C++, std::move is merely a cast to an r-value reference; it doesn’t perform a move itself. It relies on a move constructor to transfer resources, and the original object is left in a “valid but unspecified” state (often empty, but still accessible). This requires the programmer to remember not to use the “empty” object.
In contrast, Rust’s move is destructive and enforced. The compiler ensures that the moved-from variable “ceases to exist” for the rest of the program. Attempting to use it is a compile-time error. This eliminates entire classes of bugs (like double-frees or use-after-move errors) without any runtime checks or the need for programmer discipline.
This “destructive move” is more than a syntactic detail; it is a foundational design choice that underpins Rust’s entire single ownership guarantee. By statically invalidating the source, Rust’s compiler can prove that there is always exactly one owner of the data at any given time. This approach is more restrictive, but in return, it provides significantly stronger, compiler-enforced guarantees. The observation that Rust “lacks move constructors” from a C++ perspective can be reinterpreted: Rust does not need C++-style move constructors because its move semantics are deeply integrated into the language’s core ownership model, rather than being an add-on feature.
Copy and Clone Traits: Explicit Mechanisms for Duplication
Section titled “Copy and Clone Traits: Explicit Mechanisms for Duplication”Given that moves are the default behaviour, Rust provides explicit mechanisms for duplicating data when a copy is genuinely desired:
Copy Trait: For simple types (e.g., integers, Booleans, fixed-size arrays, or structs composed solely of Copy types) that do not contain pointers or manage external resources, Rust performs a copy instead of a move on assignment. These types implement the Copy trait (often implicitly or via #[derive(Copy, Clone)]). A Copy operation is a bitwise copy of the value and does not invalidate the source variable.
let x = 5; // i32 (integer) implements Copylet y = x; // x is copied to y. Both x and y are valid.println!("x: {}, y: {}", x, y); // Output: x: 5, y: 5Clone Trait: For types that do manage heap data or have complex ownership semantics (like String or Vec), a deep copy requires explicit action using the clone() method. These types implement the Clone trait.
let s1 = String::from("Hello");let s2 = s1.clone(); // s2 is a deep copy. Both s1 and s2 are valid.println!("s1: {}, s2: {}", s1, s2); // Output: s1: Hello, s2: HelloThis explicit distinction between Copy and Clone requires programmers to be intentional about data duplication, preventing accidental costly deep copies or shallow copies that might violate ownership invariants.
This section finishes with a code demonstration of the use of Copy and Clone, and Table 2 provides a summary of the ownership and assignment semantics between C++ and Rust.
🎬Code Demo: Copy and Cloning in Rust
Section titled “🎬Code Demo: Copy and Cloning in Rust”Copy and Clone in Rust
Table 2: Ownership and Assignment Semantics (C++ vs. Rust) This table contrasts the fundamental assignment and ownership transfer behaviours, which represent one of the most significant paradigm shifts for C/C++ programmers learning Rust. It illustrates how Rust’s default move semantics and explicit Copy/Clone traits are designed to enforce memory safety and prevent common C++ issues like double-frees or costly deep copies. Internalising these differences is important for understanding Rust’s memory model and writing idiomatic, safe Rust code.
| Feature | C++ (Default) | Rust (Default) |
|---|---|---|
Assignment (=) | Copy (deep copy for complex types) | Move (ownership transfer) |
| Moved-from Value State | Remains valid (but unspecified), usable | Invalidated, compile-time error to use |
| Function Parameter Pass | Copy (unless & or && used) | Move (ownership transfer) |
| Explicit Move Mechanism | std::move (cast to r-value ref, relies on user-defined move ops) | Implicit for non-Copy types; use clone() for explicit deep copy |
| Resource Deallocation | Destructor calls for each independent copy | Drop trait called exactly once by the owner |
Simple Types (e.g., int) | Copy | Copy (if Copy trait implemented) |
🧩Knowledge Check
Section titled “🧩Knowledge Check”Match Ownership and Assignment Semantics
© 2026 Derek Molloy, Dublin City University. All rights reserved.