Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

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 v object itself is stack-allocated, but the elements 1, 2, 3 are stored on the heap, managed by the std::vector’s internal mechanisms. Rust: let v = vec!;
  • Similarly, the v binding (the Vec struct 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: 3
Length: 3
Values: [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 Option types. 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.

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`"

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
}

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: hello

Please 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.

Figure 1: The same code pasted into VS Code showing type inlay hints.

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 integer

Next 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.

Rust concept demo

Shadowing in Rust

src/main.rs
1fn main() {
2 let x = 5;
3 let x = x + 1;
4 let x = x * 2;
5 println!("x = {x}");
6}
terminal — cargo run
$ cargo run
x = 12
What just happened

Each `let x = …` creates a brand-new variable that happens to reuse the name `x`. The previous `x` is shadowed — still alive in memory until the new binding takes its place, but no longer reachable by that name. This compiles even though every `x` is immutable, because we're not mutating anything: we're introducing fresh bindings.

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.

FeatureC++Rust
Default StateMutableImmutable
Explicit Mutabilityconst keyword for immutabilitymut keyword for mutability
ReassignmentDirect reassignment allowedNot allowed by default; requires mut
ShadowingNot a direct feature in same scope; typically new names or re-assignmentAllowed; new variable with same name shadows old one; can change type
Type InferenceLimited (e.g., auto for declaration)Extensive (let y = 22; is common)

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;
Concept Match

Match Memory and Variable Concepts

Drag each definition into its matching concept slot, then click Submit. Tap × to return a placed card to the pool.

Temporal Safety
drag a definition here…
Spatial Safety
drag a definition here…
Shadowing
drag a definition here…
Immutable by Default
drag a definition here…
Stack vs Heap
drag a definition here…

Definition Pool

Prevents out-of-bounds access, such as buffer overflows, through bounds and null checks.
Primitive types live on the stack; dynamically sized data like String or Vec live on the heap.
Re-using a variable name by creating a new binding, potentially with a different type.
Rust's design choice where variables cannot be changed after binding unless 'mut' is used.
Ensures memory is not accessed before allocation or after deallocation, preventing dangling pointers.

Ownership: Rust’s Core Memory Management Model

Section titled “Ownership: Rust’s Core Memory Management Model”

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.

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 Copy
let y = x; // x is copied to y. Both x and y are valid.
println!("x: {}, y: {}", x, y); // Output: x: 5, y: 5

Clone 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: Hello

This 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.

Rust concept demo

Copy and Clone in Rust

src/main.rs
1fn main() {
2 let x = 5;
3 let y = x;
4 println!("x = {x}, y = {y}");
5}
terminal — cargo run
$ cargo run
x = 5, y = 5
What just happened

`i32` implements the `Copy` trait, so `let y = x;` makes a bit-for-bit copy of the value and both bindings stay valid afterwards. `Copy` is reserved for types whose whole value lives on the stack with no owned heap data — primitives (`i32`, `f64`, `bool`, `char`), shared references (`&T`), and tuples or arrays of `Copy` types. The duplication is cheap and safe, so Rust just does it for you on assignment.

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.

FeatureC++ (Default)Rust (Default)
Assignment (=)Copy (deep copy for complex types)Move (ownership transfer)
Moved-from Value StateRemains valid (but unspecified), usableInvalidated, compile-time error to use
Function Parameter PassCopy (unless & or && used)Move (ownership transfer)
Explicit Move Mechanismstd::move (cast to r-value ref, relies on user-defined move ops)Implicit for non-Copy types; use clone() for explicit deep copy
Resource DeallocationDestructor calls for each independent copyDrop trait called exactly once by the owner
Simple Types (e.g., int)CopyCopy (if Copy trait implemented)
Concept Match

Match Ownership and Assignment Semantics

Drag each definition into its matching concept slot, then click Submit. Tap × to return a placed card to the pool.

Single Ownership Rule
drag a definition here…
Move Semantics
drag a definition here…
Destructive Move
drag a definition here…
Copy Trait
drag a definition here…
Clone Trait
drag a definition here…

Definition Pool

Rust's compile-time guarantee that a moved-from variable cannot be accessed, preventing double-frees.
An explicit mechanism for performing a deep copy of heap-allocated data (e.g., String::clone()).
Every value has exactly one owner; when the owner goes out of scope, the memory is freed.
The default behaviour where ownership is transferred, rendering the original variable invalid.
An implicit bitwise copy for simple types (like integers) that doesn't invalidate the source.