Skip to content

Rust vs C++: Who Checks the Lifetime, and When

Rust vs C++: Who Checks the Lifetime, and When

Short version for the impatient: the real difference between Rust and C++ is not speed. Both compile to native code and both can hit the same hardware limits. The difference is who checks your lifetimes, and when. C++ lets you check them yourself, with discipline and tooling. Rust makes the compiler check them before the program exists.

I want to show that with a small program rather than a slogan, because I’ve read a lot of “Rust vs C++” posts that are just two benchmark charts and a fan club. I write mostly PHP, Go and TypeScript for clients, so I’m not a systems programmer, and I won’t pretend to have shipped a game engine in either language. What I can do is take a mistake that every C++ beginner makes, run it on a real machine, and compare it to what Rust does with the same idea.

The bug that compiles

Here is a C++ program that moves a string out of a smart pointer and then reads from the pointer anyway.

#include <iostream>
#include <memory>
#include <string>

int main() {
    auto name = std::make_unique<std::string>("report.pdf");
    auto other = std::move(name);
    std::cout << *name << "\n";
}

I built this with g++ -Wall -Wextra on a Linux box. No warnings. No errors. It ran and died with a segmentation fault (exit code 139). The move constructor did exactly what it promises: name is now an empty pointer, and dereferencing an empty pointer is undefined behaviour. The compiler knew that std::move had just been called on name. It let me carry on anyway, because the language says a moved-from object is valid but unspecified, and the language leaves it to me to remember.

That’s not a knock on C++. A segfault in a toy program is the good outcome. The bad outcome is the same mistake inside a request handler where the pointer isn’t null but points at memory something else now owns. Nothing crashes. Something gets quietly overwritten.

Now the same idea in Rust:

fn main() {
    let name = String::from("report.pdf");
    let other = name;
    println!("{name} {other}");
}

This does not build. The compiler stops with error E0382, “borrow of moved value”, and points at the line where name was moved and the line where I used it afterwards. There is no runtime to crash, because there is no executable. The Rust Book chapter on ownership states the rules in three lines: each value has an owner, there is one owner at a time, and when the owner goes out of scope the value is dropped. The move bug above is simply the second rule being enforced.

Ownership is a rule, not a convention

C++ has all the same ideas. Smart pointers, RAII and move semantics are the C++ answer to manual memory management, and modern C++ written with them is far safer than the C-with-classes code that gave the language its reputation. The C++ Core Guidelines are full of advice in exactly this direction: prefer unique_ptr, avoid raw owning pointers, don’t hold references past the lifetime of the thing they point at.

So if both languages have the idea, what’s the difference? Enforcement. In C++ the guidelines are guidelines. A tool such as clang-tidy or a static analyser can flag many violations, and a code reviewer can catch more, but the language itself accepts the program. In Rust the same rules are part of the type system, and a program that breaks them is not a valid Rust program.

I find it useful to think of it as the difference between a speed limit sign and a speed bump. Most drivers slow down for both. Only one of them works at 3am when you’re tired.

There’s a cost, and I’ll get to it. First, the second kind of bug, because it is the one that actually worries me.

Dangling references and the lifetime annotation

Returning a reference to something that no longer exists is the classic. In C++ it looks harmless:

const std::string& longer(const std::string& a, const std::string& b) {
    return a.size() >= b.size() ? a : b;
}

int main() {
    const std::string& r = longer("a long literal", std::string("hi"));
    std::cout << r << "\n";
}

The temporary std::string("hi") is destroyed at the end of the full expression. If it happened to be the longer one, r would now refer to a dead object. Here the literal wins, so this particular run probably prints correctly, which is the whole problem: it works until the inputs change. I’m describing that behaviour from the language rules, I didn’t run this one to see what my compiler does with it.

Rust forces you to say how long each reference lives:

fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() >= b.len() { a } else { b }
}

fn main() {
    let result;
    {
        let short = String::from("hi");
        result = longest("longer text", &short);
    }
    println!("{result}");
}

The 'a says that the result lives no longer than the shorter of the two inputs. Because short is dropped at the closing brace and result is used after it, the compiler rejects the program with E0597, “short does not live long enough”. The function is correct. The caller is wrong, and Rust tells the caller.

Those annotations are the most disliked part of the language, and I get why. They look like noise until you’ve been bitten by the thing they prevent. The Rustonomicon is blunt about where the boundary sits: in safe Rust the compiler guarantees the absence of this class of bug, and in unsafe blocks the guarantee becomes your job again. That is a much smaller area to audit than “the whole codebase”.

What Rust costs you

I’d be doing the thing I complained about at the top if I stopped here. Rust costs real time.

The borrow checker will reject programs that are fine. Graph structures, doubly linked lists and anything with cyclic references fight the ownership model, and the usual escape hatches (Rc<RefCell<T>>, indices into a Vec, unsafe) each give up something. When I read Rust written by people who are new to it, the pattern I see most is .clone() sprinkled on until the errors go away. That works, and it quietly spends the performance you chose Rust to keep.

Compile times are slower than I’d like. The ecosystem is younger, so a library you take for granted in C++ for audio, CAD or an old hardware vendor’s SDK may not exist, or may be a thin wrapper over the C++ one anyway. And if you have a decade of C++ in production, rewriting it is a business decision, not a technical one. I covered a smaller version of that trade in my post on Rust 1.99 and the box leak habit I had to drop, where even a language that polices ownership still lets you leak memory on purpose if you ask.

C++ has costs too, and they’re mostly invisible until they aren’t. The language is huge, with decades of overlapping ways to do one thing. Two engineers on the same team can write “modern C++” that looks like two different languages. Undefined behaviour doesn’t announce itself.

How I’d choose

I’d pick C++ when the code has to live next to an existing C++ codebase, when a library only exists there, or when the team already has people who know where the bodies are buried. Rewriting a working system to get memory safety is rarely worth it by itself, and incremental approaches that put new code in Rust next to the old code are usually kinder to the schedule.

I’d pick Rust for new code that handles untrusted input, runs for a long time, or gets maintained by people who weren’t in the room when it was written. Parsers, network services, command-line tools and anything that sits between the internet and a database are good fits. The ownership rules double as documentation. When a function takes String instead of &str, that signature tells the next developer who is responsible for the memory.

For the kind of work I do for clients, neither is the default. A Laravel app or a Go service covers most of what people hire me for, and I reach for a systems language only when a hot path justifies it. You can see what that mix looks like on my portfolio.

Try it yourself this week

Don’t take my word, or any benchmark’s word, for any of this. Spend an hour on it:

  1. Write the unique_ptr move bug above in C++ and compile it with -Wall -Wextra. Then compile the same file with -fsanitize=address,undefined and run it. See what each tool tells you.
  2. Install Rust with rustup and write the longest function with the nested block. Read the full E0597 message, not just the first line. Then delete the 'a annotations and read the different error you get.
  3. Pick one small function from your own C++ code that returns a reference or a pointer. Write down, in a comment, who owns the thing it points at. If you can’t answer in one sentence, you’ve found the kind of bug Rust is built to catch.

If step three turns up a real question, that is a better reason to learn Rust than anything I’ve written here.