The memory safety discussion between C++ and Rust has become one of the most active topics in software engineering. Government agencies have weighed in, conference talks are dedicated to it, and opinions run strong on both sides.

Let me be upfront: Rust is an excellent language. Its ownership model and borrow checker are genuinely innovative, and they catch entire categories of bugs at compile time. If you’re starting a new project and Rust fits your team and ecosystem, that’s a great choice.

At the same time, C++ remains the backbone of the world’s most performance-critical software: operating system kernels, game engines, browsers, databases, and financial systems. That’s not an accident, and it’s not because those teams haven’t heard of Rust.

What I want to explore in this post is a point that often gets lost in the debate: modern C++ (C++11 and later) provides robust tools for writing memory-safe code. Smart pointers, RAII, containers with bounds checking, and a rich standard library address the same categories of bugs that Rust’s compiler enforces. The two languages simply take different design approaches to the same problem: compiler enforcement vs. developer-chosen idioms.

Rather than argue in the abstract, let me show you what I mean with concrete, runnable examples.

Disclaimer: The opinions expressed in this article are my own and are based on my professional experience. All code examples are intentionally simplified for educational purposes. Every language has trade-offs; this post aims to encourage thoughtful discussion, not to disparage any language or its community. Your mileage may vary.

Example 1: Dangling Pointer (Use-After-Free)

This is one of the most common memory safety bugs. A pointer continues to be used after the memory it points to has been freed.

The Bad C++ (Poor Practice)

 1#include <iostream>
 2
 3int main() {
 4    int* ptr = new int(42);
 5    std::cout << "Value: " << *ptr << std::endl;
 6
 7    // ptr is now dangling (it still holds the address of freed memory)
 8    delete ptr;
 9
10    // Undefined behavior: reading freed memory
11    std::cout << "After delete: " << *ptr << std::endl;
12
13    return 0;
14}

What went wrong: The developer manually allocated memory with new, manually freed it with delete, and then continued using the raw pointer. This is a textbook use-after-free bug. The problem is not that C++ “let” this happen, the problem is that the developer used a 1990s-era pattern when modern alternatives exist.

The Good C++ (Modern Practice)

 1#include <iostream>
 2#include <memory>
 3
 4int main() {
 5    auto ptr = std::make_unique<int>(42);
 6    std::cout << "Value: " << *ptr << std::endl;
 7
 8    // Explicitly releases the memory
 9    ptr.reset();
10
11    if (!ptr) {
12        std::cout << "Pointer is null, safe to handle." << std::endl;
13    }
14
15    return 0;
16}

What changed:

std::unique_ptr owns the resource. When it goes out of scope or is reset, the memory is freed and the pointer becomes null-checkable. There is no dangling pointer. No undefined behavior. This has been standard C++ since 2011.

Example 2: Buffer Overflow

Reading or writing past the bounds of an array is another classic source of vulnerabilities.

The Bad C++ (Poor Practice)

 1#include <iostream>
 2#include <cstring>
 3
 4int main() {
 5    char buffer[10];
 6
 7    // Copying more data than the buffer can hold
 8    const char* source = "This string is way too long for the buffer";
 9    std::strcpy(buffer, source);
10
11    std::cout << "Buffer: " << buffer << std::endl;
12    return 0;
13}

What went wrong:

The developer used a C-era function (strcpy) with a fixed-size C array and zero bounds checking. This is the kind of code that gives C++ a bad name, but it’s not modern C++. It’s C code compiled with a C++ compiler.

The Good C++ (Modern Practice)

 1#include <iostream>
 2#include <string>
 3
 4int main() {
 5    std::string buffer;
 6
 7    // std::string manages its own memory and grows as needed
 8    buffer = "This string is way too long for a fixed buffer, but std::string handles it.";
 9
10    std::cout << "Buffer: " << buffer << std::endl;
11
12    // Need a substring? Bounds-checked:
13    try {
14        std::string sub = buffer.substr(0, 10);
15        std::cout << "Substring: " << sub << std::endl;
16
17        // This would throw std::out_of_range:
18        // std::string bad_sub = buffer.substr(9999, 10);
19    } catch (const std::out_of_range& e) {
20        std::cout << "Caught: " << e.what() << std::endl;
21    }
22
23    return 0;
24}

What changed:

std::string manages its own memory. It cannot overflow. Attempting an out-of-bounds operation with .at() or .substr() with invalid indices throws a catchable exception. This has been in the standard since C++98.

Example 3: Array Out-of-Bounds Access

The Bad C++ (Poor Practice)

 1#include <iostream>
 2
 3int main() {
 4    int arr[5] = {10, 20, 30, 40, 50};
 5
 6    // Accessing index 10 (way out of bounds)
 7    std::cout << "arr[10] = " << arr[10] << std::endl;
 8
 9    return 0;
10}

What went wrong:

Raw C arrays have no bounds checking whatsoever. The developer accessed an invalid index and got garbage (or worse, a security vulnerability). Again, this is a developer choice to use the most primitive data structure available.

The Good C++ (Modern Practice)

 1#include <iostream>
 2#include <array>
 3#include <stdexcept>
 4
 5int main() {
 6    std::array<int, 5> arr = {10, 20, 30, 40, 50};
 7
 8    try {
 9        // .at() performs bounds checking and throws on invalid access
10        std::cout << "arr.at(2) = " << arr.at(2) << std::endl;
11        std::cout << "arr.at(10) = " << arr.at(10) << std::endl;
12    } catch (const std::out_of_range& e) {
13        std::cout << "Caught out-of-range: " << e.what() << std::endl;
14    }
15
16    return 0;
17}

What changed:

std::array is a fixed-size container that knows its own size. The .at() method performs bounds checking at runtime and throws an exception if you exceed the bounds. Available since C++11.

Example 4: Double Free

The Bad C++ (Poor Practice)

 1#include <iostream>
 2
 3int main() {
 4    int* ptr = new int(100);
 5    std::cout << "Value: " << *ptr << std::endl;
 6
 7    delete ptr;
 8    // Double free (undefined behavior, potential crash or exploit)
 9    delete ptr;
10
11    return 0;
12}

What went wrong:

The developer freed the same memory twice. This can corrupt the heap allocator’s internal state and is a well-known exploitation vector. It is, once again, a manual memory management mistake.

The Good C++ (Modern Practice)

 1#include <iostream>
 2#include <memory>
 3
 4int main() {
 5    auto ptr = std::make_unique<int>(100);
 6    std::cout << "Value: " << *ptr << std::endl;
 7
 8    // Memory is freed exactly once when ptr goes out of scope.
 9    // You literally cannot double-free with unique_ptr.
10    // Calling reset() is also safe:
11    ptr.reset();
12    // No-op. No crash. No UB.
13    ptr.reset();
14
15    std::cout << "Done safely." << std::endl;
16    return 0;
17}

What changed:

std::unique_ptr guarantees single-ownership semantics. The memory is freed exactly once; either when the pointer goes out of scope or when reset() is called. Calling reset() on an already-null unique_ptr is a safe no-op.

Example 5: Shared Ownership Done Wrong vs. Right

The Bad C++ (Poor Practice)

 1#include <iostream>
 2
 3void use(int* p) {
 4    std::cout << "Using: " << *p << std::endl;
 5}
 6
 7int main() {
 8    int* shared = new int(99);
 9
10    use(shared);
11
12    delete shared;
13
14    // Another function tries to use the same pointer (use-after-free)
15    use(shared);
16
17    return 0;
18}

What went wrong:

Multiple parts of the code share a raw pointer, and there’s no clear ownership. Once one part deletes the memory, every other part is left holding a dangling pointer.

The Good C++ (Modern Practice)

 1#include <iostream>
 2#include <memory>
 3
 4void use(std::shared_ptr<int> p) {
 5    std::cout << "Using: " << *p
 6              << " (ref count: " << p.use_count() << ")" << std::endl;
 7}
 8
 9int main() {
10    auto shared = std::make_shared<int>(99);
11
12    use(shared);
13    use(shared);
14
15    // Memory freed automatically when the last shared_ptr goes out of scope.
16    std::cout << "Final ref count: " << shared.use_count() << std::endl;
17
18    return 0;
19}

What changed:

std::shared_ptr uses reference counting to manage shared ownership. The memory is freed only when the last shared_ptr referencing it is destroyed. No dangling pointers. No manual delete. Available since C++11.

Example 6: RAII - Modern C++ Safety

RAII (Resource Acquisition Is Initialization) is perhaps the single most important idiom in C++. The idea is simple: tie the lifetime of a resource to the lifetime of an object. When the object is created, it acquires the resource. When the object is destroyed (goes out of scope), it releases the resource. No manual cleanup. No chance to forget.

Smart pointers are just one application of RAII. The pattern works for any resource: files, mutexes, network sockets, database connections, GPU handles - you name it.

The Bad C++ (Manual Resource Management)

 1#include <iostream>
 2#include <fstream>
 3#include <stdexcept>
 4
 5void process_file(const std::string& filename) {
 6    FILE* f = fopen(filename.c_str(), "r");
 7    if (!f) {
 8        throw std::runtime_error("Cannot open file");
 9    }
10
11    char buf[256];
12    if (!fgets(buf, sizeof(buf), f)) {
13        // Oops - if we throw or return here, we leak the file handle
14        throw std::runtime_error("Cannot read file");
15    }
16
17    // If any exception is thrown above, fclose is never called
18    fclose(f);
19    std::cout << "Read: " << buf << std::endl;
20}
21
22int main() {
23    try {
24        process_file("example.txt");
25    } catch (const std::exception& e) {
26        std::cerr << "Error: " << e.what() << std::endl;
27    }
28    return 0;
29}

What went wrong:

The developer used C-style fopen/fclose. If an exception is thrown between the open and the close, the file handle is leaked. As the number of resources and error paths grows, reliably closing everything becomes a maintenance nightmare.

The Good C++ (RAII with std::ifstream)

 1#include <iostream>
 2#include <fstream>
 3#include <string>
 4
 5void process_file(const std::string& filename) {
 6    std::ifstream file(filename);
 7    if (!file.is_open()) {
 8        throw std::runtime_error("Cannot open file");
 9    }
10
11    std::string line;
12    if (!std::getline(file, line)) {
13        throw std::runtime_error("Cannot read file");
14    }
15
16    // No need to close (std::ifstream closes automatically when it goes out of scope, even if an exception is thrown)
17    std::cout << "Read: " << line << std::endl;
18}
19
20int main() {
21    try {
22        process_file("example.txt");
23    } catch (const std::exception& e) {
24        std::cerr << "Error: " << e.what() << std::endl;
25    }
26    return 0;
27}

What changed:

std::ifstream is an RAII wrapper around a file handle. The file is opened in the constructor and closed in the destructor guaranteed, regardless of how the function exits (normal return, exception, early return). Zero resource leaks by design.

RAII for Custom Resources (Mutex Lock)

RAII is not limited to the standard library. Here’s how it works with thread synchronization:

 1#include <iostream>
 2#include <mutex>
 3#include <thread>
 4#include <vector>
 5
 6std::mutex mtx;
 7int shared_counter = 0;
 8
 9void increment(int times) {
10    for (int i = 0; i < times; ++i) {
11        // std::lock_guard locks the mutex on construction,
12        // unlocks it on destruction (end of scope, even if an exception occurs)
13        std::lock_guard<std::mutex> lock(mtx);
14        ++shared_counter;
15    }
16}
17
18int main() {
19    std::vector<std::thread> threads;
20    for (int i = 0; i < 4; ++i) {
21        threads.emplace_back(increment, 10000);
22    }
23
24    for (auto& t : threads) {
25        t.join();
26    }
27
28    // Always prints 40000 (no data race, no forgotten unlock)
29    std::cout << "Counter: " << shared_counter << std::endl;
30
31    return 0;
32}

Writing Your Own RAII Wrapper

You can apply RAII to any resource. Here’s a minimal example:

 1#include <iostream>
 2#include <stdexcept>
 3
 4// A generic RAII wrapper (acquires in constructor, releases in destructor)
 5class SocketHandle {
 6    int fd_;
 7public:
 8    explicit SocketHandle(int fd) : fd_(fd) {
 9        if (fd_ < 0) throw std::runtime_error("Invalid socket");
10        std::cout << "Socket " << fd_ << " acquired." << std::endl;
11    }
12
13    ~SocketHandle() {
14        if (fd_ >= 0) {
15            std::cout << "Socket " << fd_ << " released." << std::endl;
16            fd_ = -1;
17        }
18    }
19
20    // Prevent copying (single ownership, like unique_ptr)
21    SocketHandle(const SocketHandle&) = delete;
22    SocketHandle& operator=(const SocketHandle&) = delete;
23
24    // Allow moving
25    SocketHandle(SocketHandle&& other) noexcept : fd_(other.fd_) {
26        other.fd_ = -1;
27    }
28
29    int get() const { return fd_; }
30};
31
32void do_network_stuff() {
33    SocketHandle sock(42); // "Acquired"
34    std::cout << "Using socket " << sock.get() << std::endl;
35    // Even if an exception were thrown here, the socket is released.
36} // sock goes out of scope (destructor called, socket released)
37
38int main() {
39    do_network_stuff();
40    std::cout << "Back in main - no resource leak." << std::endl;
41    return 0;
42}

Key takeaway:

RAII is not a workaround or a “best practice tip” - it is a fundamental C++ design pattern that makes resource leaks structurally impossible when applied consistently. It predates Rust’s ownership model and, in many ways, inspired it.

How Does Rust Compare?

Rust’s borrow checker catches many of these issues before your code even runs, at compile time. That is a genuine and significant advantage, and it deserves recognition.

Here’s how I’d frame the trade-offs honestly:

  1. Rust enforces safety at the compiler level; C++ provides safety through its standard library and idioms. Both approaches are valid; they reflect different design philosophies.

  2. Modern C++ prevents the same classes of bugs when its recommended tools are used. Smart pointers, RAII, std::array, std::string, std::span, std::optional; these are not obscure library features. They are the recommended way to write C++ and have been for well over a decade.

  3. Neither language is a silver bullet. Rust’s unsafe keyword exists for a reason; sometimes you need to step outside the safety net for FFI, performance, or low-level operations. And logic bugs, race conditions (in some patterns), and design errors exist in every language.

  4. Pragmatism matters. Billions of lines of battle-tested C++ run in production today. For many teams, adopting modern C++ practices, better tooling, and static analysis is a more realistic path than a full rewrite; and it delivers real safety improvements.

What About Tooling?

Modern C++ developers have access to powerful safety tools that catch issues even beyond what the language itself enforces:

ToolPurpose
AddressSanitizer (ASan)Detects use-after-free, buffer overflow, stack overflow
MemorySanitizer (MSan)Detects reads of uninitialized memory
UndefinedBehaviorSanitizer (UBSan)Detects signed overflow, null dereference, and more
ValgrindMemory leak detection and profiling
Clang-TidyStatic analysis with modernization suggestions
C++ Core GuidelinesIndustry-standard guidelines for safe, modern C++

Compiling the “bad” examples from this post with -fsanitize=address would immediately flag the issues at runtime. The tooling exists, the question is whether developers use it.

The Bottom Line

C++ and Rust approach memory safety from different angles:

  • Rust makes safety the default and requires you to explicitly opt out with unsafe.
  • C++ gives the developer full control and provides safety through its standard library, RAII, and well-established idioms.

Most memory safety issues in C++ codebases come from using outdated patterns, raw new/delete, C-style arrays, functions like strcpy; rather than the modern tools the language has provided for over a decade.

The good news is that the path forward for C++ is clear and well-documented:

  • Use smart pointers (std::unique_ptr, std::shared_ptr) instead of raw new/delete
  • Use standard containers (std::string, std::vector, std::array) instead of C-style buffers
  • Embrace RAII for all resource management
  • Leverage sanitizers (ASan, MSan, UBSan) and static analysis in CI pipelines
  • Follow the C++ Core Guidelines

Both languages are powerful tools, and both have a place in modern software development. The best choice depends on your project, your team, and your constraints; not on internet debates.

If you found this post useful or thought-provoking, feel free to share it. And if you have a different perspective, I’d love to hear it in the comments section below. The best engineering discussions happen when we argue with code, not just opinions.