How to think about Rust ownership versus C++ unique_ptr

The Rust programming language, which is nearing its important version 1.0 release, but already seen a lot of use, has many interesting features, the most prominent of which is its ownership static type system that prevents certain kinds of bugs common in many programs in C or C++.

For example, it is common in most applications to create graphs of objects pointing to each other, in which it is not clear how many pointers point to a particular object, who is to free the object’s memory when it is no longer needed, and what happens to all outstanding pointers to that block. Languages supporting precise garbage collection use various algorithms to determine when memory can be freed, and have become particularly popular in the past twenty years, but there are still situations in which garbage collection is not ideal, hence the continued relevance of languages such as C++.

When I was working as a software engineer in the 1990s developing desktop applications with user interfaces for the X Window System, we used frameworks that included C++ smart pointers that used reference counting to handle graphs of interconnected data. The C++ standard itself lagged behind in standardizing smart pointers; it started with the terribly flawed and unusable auto_ptr that finally got deprecated in C++11, then moved on finally to unique_ptr and shared_ptr and weak_ptr.

When talking with C++ programmers about Rust, I have found that often they have been puzzled about how Rust’s ownership system does anything better than what C++ unique_ptr already does. Here is an explanation (I will not be discussing analogues of shared_ptr and weak_ptr here).

Simplest example: compile-time type-checking vs. run-time segmentation fault

First, go read Steve Klabnik’s article about the simplest possible example illustrating what Rust offers over C++ unique_ptr.

Basically, in C++, unique_ptr is supposed to be a way to indicate that a given pointer is the unique “owner” of a piece of data. If you want to transfer ownership from one unique_ptr to another, you have to call move on it. After that, it is an run-time error to try to access the data using the original unique_ptr. This is great, except for two things:

  • By “it’s a run-time error”, we mean that the original unique_ptr’s embedded pointer gets mutated to nullptr, so that you have to perpetually check your unique_ptr for nullptr, otherwise get a segmentation fault.
  • In many situations, we’d prefer to have a compile-time guarantee that we will never dereference nullptr, so that we don’t have hidden memory safety bugs lying around in our code that don’t happen until well into a long-running program.

Here is Steve’s example of an unintended segmentation fault from C++:

#include <iostream>
#include <memory>

using namespace std;

int main ()
{
    unique_ptr<int> orig(new int(5));

    cout << *orig << endl;

    auto stolen = move(orig);

    cout << *orig << endl;
}

Steve shows an “equivalent” Rust program in which code attempts to move ownership fails to compile because of a type error (I edited to correspond more closely with the C++):

fn main() {
    let orig = box 5i;

    println!("{}", *orig);

    let stolen = orig;

    println!("{}", *orig);
}

Understanding C++ behavior by modeling it in Rust, safely

But it is not quite accurate to call the Rust program “equivalent” to the original C++ program. It’s really a “cleaned up” or “restricted” version of that program (and the argument for using a type-safe language like Rust is that we often like these restrictions, as a tradeoff for safety or efficiency).

Here is a Rust program that more accurately models what the C++ unique_ptr actually does, which happens dynamically at run-time, not at compile time, hence the segmentation fault: the transfer of ownership by move on a unique_ptr is not type-checked at compile-time.

For illustration’s sake, we have modeled C++ directly in safe Rust through indirection by treating a C++ pointer to T (C++ pointers can always be nullptr) as an Option<Box<T>>, where nullptr is modeled as None and a non-null pointer is modeled as Some(pointer_nonnull). This Rust code type-checks, compiles, and simulates a segmentation fault.

You can play with this code at this Rust playground link. (You may find it interesting to examine the assembly code generated.)

// Simulate a segmentation fault.
fn seg_fault() {
    panic!("segmentation fault");
}

fn main() {
    // Rough modeling in Rust of C++ unique_ptr<int>
    // because C++ pointers can always be null.
    let mut orig: Option<Box<int>> = Some(box 5i);

    match orig {
        // In C++, deferencing null seg faults.
        None => seg_fault(),
        Some(orig_nonnull) => {
            println!("{}", *orig_nonnull);

            // The equivalent of C++ unique_ptr move.
            let stolen = Some(orig_nonnull);
            orig = None;

            match orig {
                // We seg fault after the C++ style move.
                None => seg_fault(),
                Some(orig_nonnull) => println!("{}", *orig_nonnull)
            }
        }
    }
}

Nobody would ever write this kind of code in Rust, but implicitly, all C++ programs are semantically basically doing this, except that for efficiency, the nullptr checking is not done at the language level but just results in an actual segmentation fault at the operating system level.

An example of smart pointers inside a collection

The single most disturbing failure mode of the C++-based software product I worked on in 1995-1997 in C++ was segmentation faults resulting from mysteriously disappearing pointers. What I mean is, behavior like the following, where a container containing smart pointers to objects was meant to own them, but there was no compile-time way to verify this fact, because of the nature of the object graph. If someone didn’t play by the uncheckable rules and inadvertently took ownership of (instead of “borrowing” through a raw pointer) something embedded in the collection, then nullptr appeared, and a segmentation fault happened.

#include <vector>
#include <memory>
#include <iostream>

using namespace std;

int main() {
    vector<unique_ptr<int>> v;
    v.push_back(make_unique<int>(5));

    cout << *v[0] << endl;

    // C++ happily allows this.
    auto pointer_to_5 = move(v[0]);
    cout << *pointer_to_5 << endl;

    // Seg fault.
    cout << *v[0] << endl;
}

You can probably guess what some developers did to “fix” the segmentation fault. They started adding nullptr checks everywhere to prevent crashing, but this only resulted in corrupt user data and documents, because in fact, we lost data in those collections and smart pointers to the data should never have ended up nullptr!!

The complexity of the application and the deadline pressures made it impossible to fully figure out what was going wrong and where. I actually ended up writing an external “validation” utility program in Standard ML of New Jersey that parsed serialized object graphs and tried to fix them up in some fashion. This particularly helped the QA team a lot when dealing with old documents that were already corrupted.

The Rust version will not compile

The Rust type-checker rejects any attempt to move ownership out of a collection. (Try to compile it in this playground.)

fn main() {
    let v = vec![box 5i];

    println!("{}", *v[0]);

    // Attempted move: type error at compile time.
    let pointer_to_5 = v[0];
    println!("{}", *pointer_to_5);

    println!("{}", *v[0]);
}
illegal_move_out_of_vector.rs:7:24: 7:28 error: cannot move out of dereference (dereference is implicit, due to indexing)
illegal_move_out_of_vector.rs:7     let pointer_to_5 = v[0];
                                                       ^~~~
illegal_move_out_of_vector.rs:7:9: 7:21 note: attempting to move value to here
illegal_move_out_of_vector.rs:7     let pointer_to_5 = v[0];
                                        ^~~~~~~~~~~~

Often, you do want to share

I have to be honest: at first, Rust’s ownership type system seems like quite a restriction, and adhering to it strictly would require the restructuring of certain kinds of programs. Static type safety of any kind in any language is always a matter of tradeoffs.

But note that Rust is flexible: you don’t have to use Rust’s default pointer type! You can use one of Rust’s many other pointer types, such as Rc or Arc, that allow reference-counted shared ownership (like C++ shared_ptr), if that’s what you really want. (Playground here.)

// Reference-counted smart pointer.
use std::rc::Rc;

fn main() {
    let v = vec![Rc::new(5i)];

    println!("{}", *v[0]);

    let pointer_to_5 = v[0].clone();
    println!("{}", *pointer_to_5);

    println!("{}", *v[0]);
}

Unsafe

Finally, Rust does allow you to go all out and write unsafe code, if you truly need to for raw C performance or FFI reasons. Most of the time you do not need to, because the whole point of Rust is to compile down to the same kind of assembly code you would get from C.

extern crate libc;

use libc::{size_t, malloc};
use std::mem;
use std::ptr;

fn main() {
    // How ugly it is to pretend Rust is unsafe C.
    unsafe {
        let mut orig: *mut int = malloc(mem::size_of::<int>() as size_t)
            as *mut int;
        ptr::write(&mut *orig, 5i);

        println!("{}", *orig);

        orig = ptr::null::<int>() as *mut int;

        // null pointer crash!
        println!("{}", *orig);
    }
}

Conclusion

I hope this little article shows a little bit about what Rust’s ownership type system can do to make pointer-heavy code memory safe, unlike C++, and also gives you a taste of how Rust’s flexibility also allows you to use C++-style reference-counting if desired, and even raw unsafe code. Personally, I am excited about the upcoming 1.0 release of Rust, and although I have not done low-level systems programming for almost two decades, if I ever was to do it again, I would immediately reach out for Rust as a language of choice for the ultimate combination of safety, expressiveness, and performance (in use of time and space).

All code for this article is available in a GitHub repository.

Comments (9) Archived from Disqus

Steven Harris View on Disqus ↗

I appreciate the point you make, but, as I often wind up saying to about
these anti-C++ articles, I don't find the comparison to be fair. In
C++, it's not possible to accidentally move an object. In your (and SK's) examples, you deliberately moved the object in question when it was permissible to just "borrow it", to use Rust's parlance.

I agree that the danger and subsequent failure aren't noted at compile time. However, the mistake is much easier to avoid than you may think. It's rare to deliberately move an object like this. Usually a program will fail to compile because you didn't explicitly move the object, which makes you stop and think, "Oh, I see, I have to give up ownership here. Is that acceptable?" In the examples shown here, there was no motive to give up ownership when unique_ptr's operator*(), operator->(), or get() functions would have sufficed.

J W View on Disqus ↗

The primary advantage Rust has over idiomatic modern C++ is that it makes regular references completely safe. C++ has no equivalent for this (rvalue references are extremely limited by comparison). It obviates the need for smart pointers altogether most of the time, and prevents errors like iterator invalidation and dangling string_view that are virtually impossible to avoid in sufficiently complex C++ code. Unfortunately, most of these comparison articles do not make this point...

Franklin Chen View on Disqus ↗

One "surprise" for me has been seeing people who would never touch C++ happily use Rust to get their hands dirty in systems programming. People coming from Ruby, Haskell, etc. It's just so much more pleasant using a safe language.

Oded Arbel View on Disqus ↗

I would guess it has more to do with the friendly closures and iterator mapping syntax and less with memory safety - a feature that programmers used to GCs don't normally think about, seeing as no one gets a segmentation fault when using Ruby.

Oded Arbel View on Disqus ↗

I don't see how you can say that Rust references are better than C++ auto-references (&&). I understand the concept of "safe by design, unsafe if you need to" that Rust employs, but like other languages that try to do that (C++-CLR comes to mind), it often (as is the case in Rust) falls to "safe by design, deteriorate into ugly C mess if you need power".

C++ OTOH (IMHO) trades some complex syntax for a wide spectrum of capabilities - you can write safe code, you can write slightly unsafe code, you can write somewhat unsafe code, you can write mostly unsafe code and you can write horrible "shoot myself in the foot" code - depends on what you are comfortable with. Yes - it does require all developers on the project to be more proficient in the language.

Rust seems to me like "the system language to use when you can't train system programmers properly", and that's fine - that's where Java made all its money ;-)

Franklin Chen View on Disqus ↗

In my experience with C++ code bases using smart pointers, I have seen all kinds of really bad code, especially in the face of program evolution in which expectations change. Nobody deliberately writes code as in the snippets I provided for illustration of semantics, but code like it does arise when new programmers join a team and pile on new features (which is exactly what happened in the real world case I mentioned at my job two decades ago). For example, suddenly deciding to need to stash pointers into one container into another container but not rethinking the ownership intentions. Or taking shortcuts and returning a raw pointer (intending to "borrow") but then stashing that somewhere in another smart pointers, in which case reference counts become incoherent. I've seen it all, in real code. Yes, you can blame "bad" programmers, and indeed, crappy code can be written in any language (I recently dealt with some really bad Haskell code), but I do believe the pointer and memory unsafety mess of C++ is a problem worth solving by type system fiat.

Ronald Phelps View on Disqus ↗

"anti-C++"? This writing is about as neutral and objective as it's possible to be yet somehow irrational C++ tribalists conclude their sacred language is being treated unfairly and demean legitimate work. How small. The more I see of this from C/C++ fanatics the happier I am for the possibility of an alternative to you and your legacy language.

Ondřej Čertík View on Disqus ↗

As @seharris:disqus suggested (though didn't exactly make the point I want to make), the C++ examples are a straw man, because when done properly, you will get an exception, although at runtime only. In fact, when following a simple set of idioms, you can never segfault a C++ code (unless there is a compiler bug of course). You need to use smart pointers, but not from the standard library (those are indeed inadequate, as you pointed out in the examples), but for example from Trilinos. Here is documentation with examples:

http://web.ornl.gov/~8vt/Te...

The way it works is that in Debug mode, all pointers are checked, so if the original pointer gets deallocated, and you still access it from some other place, you get a nice error message (Trilinos has RCP for reference counted pointers and you can get Ptr from it, to borrow pointers, and it will not segfault even if RCP goes out of scope. I don't think it currently has something like the unique_ptr, but it can be added, with the same debug checking). You need to check your program for all possible user input and make sure you never trigger such errors. Then you switch to Release mode, when nothing is checked (so in principle things could segfault, but if they do, you just switch to Debug mode and get an exception), and things are very fast, the Ptr class is as fast as a raw C++ pointer.

The big improvement of Rust is thus only in the fact, that it moves some (but not all!) of the checks from runtime to compile time (which is always good and I wish C++ did more at compile time), and I 100% agree with that point: the more you can check at compile time, the better. However, even Rust can't check everything at compile time, a canonical example is accessing an array (out of bounds) based on an index that the user provides at runtime. Here is an actual example in Rust that gives an error at runtime: http://is.gd/9d9sZU

Thus even in Rust you inevitably need a Debug and Release mode. It seems the Debug mode (with bounds checking) is on by default, in fact it is not possible to disable it? That means that C++ in Release mode will always be faster, and if Rust is to match it, it must be possible to turn bounds checking off.

Conclusion: C++ in Debug mode (with the above smart pointers, or you can implement any missing features) and following a few simple rules (see the document above) that can be easily checked by visual inspection, the code can't segfault. Just like in Rust. Rust provides more compile time safety, but it can still give an error at runtime (as shown above). It's good that the safety is part of the language in Rust, unlike C++ where you currently need external classes for that. C++ in Release mode is very fast (but could segfault), while Rust doesn't seem to allow to disable such runtime checks.