Modern system programming constantly faces the challenge of balancing performance and memory safety. While C++ has reigned as the king of this domain for many years, Rust has recently emerged to threaten its position. The most prominent feature of Rust lies in its concepts of “Ownership” and “Borrowing”, which guarantee memory safety at compile time without relying on Garbage Collection (GC).
In this article, we will thoroughly compare C++ pointers (raw pointers, std::unique_ptr, std::shared_ptr) with the Rust ownership model. We will use code examples and diagrams to explain how the Rust compiler (Borrow Checker) prevents Use-After-Free (using memory after it has been freed) and Data Races.
1. Basics of Memory Management: Stack and Heap
To understand the basics of memory management, let’s first review how a program utilizes memory. Memory regions are broadly categorized into the “Stack” and the “Heap”.
Stack
This is the region where local variables during function calls are placed. It has a LIFO (Last-In, First-Out) structure, making memory allocation and deallocation extremely fast. Only data with a size determinable at compile time is placed here.
Heap
This region holds data whose size is determined dynamically at runtime, or data that needs to outlive the scope of a function. It is accessed via pointers (or references).
In languages without garbage collection like C++ and Rust, the management cost of heap memory can be mathematically modeled as follows. Assuming the total number of objects is $N$, the average allocation time is $T_{alloc}$, and the average deallocation time is $T_{dealloc}$, the total memory management cost $C_{memory}$ is:
$$ C_{memory} = \sum_{i=1}^{N} (T_{alloc, i} + T_{dealloc, i}) + O_{sync} $$Here, $O_{sync}$ is the overhead for mutual exclusion (such as mutexes or atomic operations) in a multi-threaded environment. Because Rust determines the timing of memory deallocation at compile time, it eliminates the throughput degradation (Stop-The-World) caused by runtime garbage collection, while executing $T_{dealloc}$ at a reliable and safe timing.
2. C++ Pointers: The Trade-off Between Freedom and Danger
Let’s look at the evolution of memory management in C++.
The Era of Raw Pointers and Their Problems
Raw pointers (*) inherited from C provide ultimate freedom, but simultaneously become a hotbed for critical bugs such as:
- Memory Leak: Forgetting to
deletememory allocated withnew. - Dangling Pointer: Accessing a pointer after the memory has been freed (after
delete). - Double Free: Freeing the same memory region twice with
delete.
| |
The Advent of RAII and Smart Pointers (C++11 and Later)
Since C++11, smart pointers based on the concept of RAII (Resource Acquisition Is Initialization) have been standardized, and the direct use of raw pointers is deprecated.
std::unique_ptr
A pointer that expresses single ownership. When it goes out of scope, the memory is automatically freed. It cannot be copied; ownership can only be “moved” (using std::move).
| |
std::shared_ptr
A pointer that allows multiple pointers to share the same object. It uses Reference Counting, freeing the memory when the count reaches zero. Since it requires atomic increment/decrement operations, it incurs a slight performance overhead (corresponding to $O_{sync}$ mentioned earlier).
3. Rust’s Ownership: A Paradigm Shift
Rust places the concept of C++’s std::unique_ptr at the core of its language specifications, adopting a much stricter “Ownership Model”.
The 3 Rules of Ownership
The Rust ownership system is based on the following three extremely simple rules:
- Each value in Rust has a variable that’s called its owner.
- There can only be one owner at a time.
- When the owner goes out of scope, the value will be dropped.
In Rust, resources are “moved” by default. Even without explicitly using something like std::move in C++, an assignment operation transfers ownership.
| |
This feature of “making variables inaccessible at compile time after a move” is one of the reasons why Rust is safer than C++’s std::unique_ptr.
4. Borrowing and References
If ownership is constantly moved, it would be extremely inconvenient to have to return ownership every time a value is passed to a function. This is where “Borrowing” comes in. It corresponds to C++ pointers and references.
There are two types of borrowing in Rust:
- Immutable Reference:
&T(Similar toconst T&in C++) - Mutable Reference:
&mut T(Similar toT&in C++)
The Ruthless Laws of the Borrow Checker
The Rust compiler has a built-in “Borrow Checker” that verifies the validity of references. The borrow checker enforces the following strict rules:
In any given scope, you may have either one of the following:
- Exactly one mutable reference (
&mut T)- Any number of immutable references (
&T)
This principle is known as “Multiple Readers XOR Single Writer (MRSW)”. It can be expressed with a mathematical XOR; for a given state $S$, the number of immutable references $N_r$ and the number of mutable references $N_w$ must satisfy the following constraint:
$$ (N_r \ge 0 \land N_w = 0) \oplus (N_r = 0 \land N_w = 1) $$This rule completely eliminates data races at compile time. A data race occurs when: (1) two or more pointers access the same data simultaneously, (2) at least one is writing, and (3) there is no synchronization mechanism. Rust proactively prevents data races by destroying condition (2) at compile time.
| |
5. Prevention of Iterator Invalidation
As a concrete example where the power of the borrow checker shines the most, let’s look at a classic bug known as “Iterator Invalidation”.
Iterator Invalidation in C++ (Runtime Crash)
If you modify a std::vector in C++ during a loop, the underlying memory might be reallocated, turning existing references into dangling pointers.
| |
Compile-Time Defense by Rust
Let’s write the exact same logic in Rust.
| |
In this way, Rust prohibits at the compiler level “modifying a value (mutable borrowing) while it is being read (immutable borrowing)”, ensuring that fatal bugs like Use-After-Free and iterator invalidation are reliably caught at compile time.
6. Shared Ownership in Rust: Rc and Arc
Rust also provides shared ownership corresponding to C++’s std::shared_ptr, but the types are clearly separated for single-threaded and multi-threaded use.
For Single-Threaded: Rc<T> (Reference Counted)
Rc<T> is a non-thread-safe reference-counting smart pointer. It increments and decrements the count without using atomic instructions, making it extremely fast within a single thread. However, attempting to send this to another thread results in a compile error (because it does not implement the Send trait).
For Multi-Threaded: Arc<T> (Atomic Reference Counted)
When sharing across threads, Arc<T>, which performs atomic increments and decrements, is used. It incurs a cost equivalent to C++’s std::shared_ptr.
Furthermore, in C++, simultaneously writing to a variable shared via std::shared_ptr from multiple threads causes a data race. To prevent this, you must manually use std::mutex correctly.
On the other hand, in Rust, you cannot mutate the internal data of Arc<T> alone. If modification is necessary, it must be combined with a mutex, such as Mutex<T>.
| |
What is especially noteworthy is that Rust’s Mutex<T> is not just a locking mechanism; “it encapsulates the data it protects as its type.” This completely prevents the mistake of “accessing data while forgetting to take the lock” at the compile level. Unless you acquire the lock (lock()), you are mechanically unable to obtain access rights (a reference) to the data inside.
Conclusion: “Pre-check” by the Compiler vs. “Self-responsibility” of the Developer
C++ pointers and smart pointers offer developers a high degree of control and performance, but their correct usage relies entirely on developer discipline. The introduction of RAII and std::unique_ptr dramatically increased the safety of C++, but it still cannot completely prevent “undefined behaviors” like use-after-free or iterator invalidation at the language level.
On the other hand, Rust embeds the rules of Ownership and Borrowing into the compiler, detecting these errors at compile time rather than at runtime. The strong guarantee that “if it compiles, it is memory safe” is the biggest reason why Rust is rapidly gaining support in system programming.
“Fighting the borrow checker” presents a significant hurdle for beginners, but it simply means the compiler is strictly taking over the complex calculation of “tracking pointer lifetimes” that C++ programmers originally had to perform in their heads.
If you learn Rust while understanding the freedom and dangers of C++ pointers, you will gain a much deeper understanding of the philosophy behind the design of the ownership model: “Why was it designed this way?”
This article is a comparative analysis of memory management techniques in C++ and Rust. We hope it serves as a helpful reference for choosing the appropriate language depending on the requirements of your project.
