cat posts/2015-05-19-rust-ownership-for-cpp-programmers.md

Rust ownership for C++ programmers

· 6 min read · rust cpp programming

If you’ve done COM you’ve spent at least one long night hunting down a missing Release() or an extra AddRef(). I’ve had several. I’ve written C and C++ on and off since Turbo C++ on DOS, and a lot of Windows SDK and COM code in the late 90s and 2000s, and that’s the background I brought to Rust 1.0, which came out on Friday and ate most of my weekend.

I’ve been half following Rust for a couple of years, mostly by reading release notes and being glad I wasn’t the one rewriting my code every time I pulled a nightly. A stable 1.0, with a promise not to break things, is the point where I’m willing to actually write something in it. Most of what people find hard about Rust, the borrow checker, is a formal version of rules you already follow in C++ if you’re careful. The difference is that Rust doesn’t trust you to be careful.

Ownership is RAII with moves by default

In modern C++ you’re supposed to have one owner for every heap allocation, usually a std::unique_ptr or a container, and the destructor frees it when the owner goes out of scope. Rust has exactly this, it just calls the destructor drop. What changes is what happens when you assign.

let s = "hello".to_string();
let t = s;
println!("{}", s);   // error: use of moved value: `s`

In C++, std::string t = s; copies the string, and you’d have to write std::move(s) to transfer it. In Rust, assigning a String moves it, always, and the compiler then refuses to let you touch s again. Here’s what’s going on in memory:

stack heap s ptr len 5 cap 5 moved, unusable t ptr len 5 cap 5 h e l l o let t = s copies three words and nothing else. Only t will free the buffer, when it goes out of scope.
A move in Rust is a shallow copy plus a rule: the old name is dead.

A move is a memcpy of the three words on the stack. The heap buffer isn’t touched. The important part is that afterwards there’s exactly one owner, so exactly one drop, so no double free, and you can’t read through s after the buffer has gone away. In C++ a moved-from std::string is “valid but unspecified”, and using it is legal and nearly always a bug. In Rust it doesn’t compile.

Small types like integers implement Copy, so they copy on assignment the way you’d expect. Anything that owns memory doesn’t, and if you want a deep copy you call .clone(), explicitly, which I’ve come to like. You can see every expensive copy in the code.

Borrowing

You don’t want to move things into every function you call, so Rust has references, which it calls borrows. &T is a shared borrow and &mut T is a mutable one, and the rule that everything else hangs off is this: at any moment you can have either any number of &T or exactly one &mut T to a value, never both.

It’s a reader-writer lock that the compiler checks and that costs nothing at runtime. Here’s the classic C++ bug it rules out, the one I’d put on any interview for a C++ position:

std::vector<int> v = {1, 2, 3};
int& first = v[0];
v.push_back(4);
std::cout << first;   // undefined behavior if push_back reallocated

And the same thing in Rust:

let mut v = vec![1, 2, 3];
let first = &v[0];
v.push(4);            // error: cannot borrow `v` as mutable
                      //        because it is also borrowed as immutable
println!("{}", first);
stack heap v ptr len 4 cap 6 first ptr new buffer, after push 1 2 3 4 old buffer, freed by push 1 2 3 first now dangles C++ lets this compile. Rust stops at v.push(4), because first is still borrowing v.
Why you can't mutate a vector while holding a reference into it. Growing it can move every element.

When the vector is full, push allocates a bigger buffer, copies the elements, and frees the old one. Any pointer into the old buffer now points at freed memory. In C++ that’s undefined behavior that works fine in your tests, because the allocator usually hasn’t reused that memory yet, and then corrupts something in production. Rust sees that first is a shared borrow of v, that push needs &mut v, and that the two overlap, and it won’t compile it.

The same rule catches iterator invalidation (modifying a collection while looping over it), which is the same bug wearing a different hat. It’s also what makes Rust’s threading story work. If only one thread can hold a &mut to something, two threads can’t write to it at once, and the types that are safe to send across threads are marked by the Send and Sync traits, so the compiler checks those too.

One thing that tripped me up: right now a borrow lasts until the end of the block it’s in, even if you never use the reference again. So the error above happens even if you delete the println!. You fix it by putting the borrow in its own { } block, which feels clumsy. I’m told the compiler team wants to make this smarter at some point.

Lifetimes

Lifetimes are where the syntax gets ugly, and it’s the part people complain about most. Most of the time you don’t write them, because the compiler infers them. You write them when a function returns a reference and the compiler can’t tell which input it came from:

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

'a is just a name. The signature says the result lives no longer than both inputs do. With that, the caller can’t keep the result around after either string is freed. In C++ the same function returns a const std::string& and you hope the caller reads the comment.

I found it helpful to think of lifetimes as documentation that the compiler checks. Every C++ codebase I’ve worked on has comments like “the returned pointer is valid until the next call to Reset()”, and in Rust that comment is part of the type.

Where it hurts

I’m not going to pretend this is all pleasant. My first afternoon was mostly arguing with the borrow checker over code that I was pretty sure was fine, and I was right about maybe a third of the time. Data structures where things point at each other, a doubly linked list or a graph with back edges, don’t fit “one owner” at all. You end up with Rc<RefCell<T>>, which is a reference-counted pointer with runtime borrow checks, roughly a shared_ptr with a runtime borrow check attached, or with indices into a Vec, or with unsafe. The error messages are good, but there are a lot of them.

And the ecosystem is young. Cargo is nice, and crates.io has a couple of thousand packages, but most of them are pre-1.0 themselves. A few weeks ago, there was a whole drama about thread::scoped, which turned out to be unsound if you leaked its guard, and it got pulled from stable at the last minute. That’s a good sign for how seriously they take soundness, and a reminder that this is all quite new.

Would I use it at work? Not yet. We write web services, and garbage-collected languages are fine for that. But if I were starting something where I’d otherwise reach for C++, a parser, a network daemon, something that has to be fast and can’t crash, I’d try Rust first, and I wouldn’t have said that a year ago. Even if it never gets big, I think a lot of C++ programmers will write better C++ for having spent a week being told no by this compiler.