Let's explore if we can help accelerate your perception development and deployment.
Table of Contents
<style>
table { width: 100%; }
th, td { padding: 1px; border: 1px white solid; }
td { text-align: center; }
blockquote p { margin: 0; }
</style>
In this article we continue with our series of posts comparing and contrasting C++ and Rust. If you haven’t read our other posts, please check them out here.
In particular, this article will explore mutability and ownership as well as related topics like move-semantics and how Rust allows for certain behavior like shared ownership even though the [Borrow-Checker](https://blog.logrocket.com/introducing-the-rust-borrow-checker/) theoretically disallows it. In addition, we’ll look into how the differences in the respective languages’ philosophies regarding ownership affect: performance, the need for standard library features and the strictness of the respective language compilers.
This article is probably best suited to those with at least some familiarity with both languages. For example, we’ll assume a passing familiarity with what “moving” is. The article may be particularly helpful for C++ developers who are just starting to get into Rust.
# Const-ness in C++ and Rust
## Values
Rust and C++ have two similar concepts: mutability/immutability in rust and constness/non-constness in C++.
In Rust, a given value is either mutable or immutable. Precisely as the names of these qualifiers imply, mutable values can be changed and immutable values cannot be changed. In contrast to C++, however, immutable Rust values can be *moved*, allowing code like this:
```rust
fn foo() {
let x = 20;
println!("{}", x);
let mut x = x;
x *= 2;
println!("{}", x);
}
```
Similarly in C++, values are either const or non-const. Const values *cannot* be moved (we’ll talk more about moving later).
ℹ️ C++ has a `mutable` keyword for granting an exemption which allows non-const access to a field through a const-qualified object. While useful in certain scenarios, it’s not especially common, so we won’t discuss it further.
## References
References in C++ and Rust are somewhat similar. For both languages, a reference is a handle to a piece of data that allow programmers to refer to that data without passing around copies. In both cases, reference are syntactic sugar for pointers. Unlike pointers, references are more-or-less guaranteed to point to valid data (at least when you create them).
Careless use of references is a big source of bugs in C++. Rust, on the other hand, provides certain safety guarantees that eliminate most of these bugs. For example, C++ does not guarantee that a reference remains valid for as long as the reference is around, meaning you can write this:
```cpp
int& bad_foo() {
int x = 0;
return x;
}
```
This function returns a reference to a value allocated on the stack. When the function returns, the stack frame is deallocated and the reference is no longer valid. Oops.
Modern C++ compilers and linters will generate diagnostics for simple cases like this, but they may not help in more complicated scenarios. The Rust compiler uses lifetime annotations to make this sort of thing *impossible* unless you use explicitly unsafe code.
## Borrowing
Another major difference arises in the limitations Rust places on the creation of references, a process referred to as *borrowing*. In Rust you can only mutably borrow a mutable value, much like in C++ where you can (usually) only create a non-const reference to a non-const value. The difference is that in Rust, while you may have multiple immutable borrows to a value, you can only have one exclusive mutable borrow at a given time (i.e. no other borrows, mutable or immutable). C++ has no such restrictions.
You may wonder what the advantage is here. One might be led to believe that this is about safety, since it disallows multiple simultaneous immutable and mutable references, and you would be partially correct: this borrowing behavior does eliminate race conditions in multithreaded scenarios. However, it also makes any data sharing beyond read-only access across threads impossible, and Rust has other tools to make non-trivial sharing across threads possible and safe.
Instead, what the borrowing rules are *really* doing is preventing any *memory aliasing*. Memory aliasing occurs when two pointers (and we’ll include references here) point to the same or an overlapping region of memory. In particular, we care about the case where we might be both reading and writing to these pointers.
## Memory Aliasing
Aliasing is an impediment to compiler optimizations. Reading and writing to memory are frequently the slowest parts of a given function and the potential presence of aliasing can force the compiler to issue load instructions more frequently than the code’s author may expect.
As an example, consider a function that repeatedly reads from an input slice, performs some calculations, writes the result into an output slice and then decides where to read from next based on the input value. The compiler will clearly have to issue a load instruction to read from the input array initially. One might assume a compiler could cache that input value, but if that check is positioned after a write and aliasing is possible, the compiler may be forced to reload the value from memory. Even if the intention of the function is for the inputs and outputs to be disjoint, the compiler can’t prove that’s the case in general.
Because of the rules imposed by the Borrow-Checker, the Rust compiler is free to assume no aliasing can occur. C++ compilers can’t do so. Some C++ compilers have flags which allow it to assume no aliasing will occur. Some C++ compilers also have [keywords](https://en.wikipedia.org/wiki/Restrict) (e.g. `__restrict__`) that may be used to annotate pointers— allowing the programmer to communicate their assumptions to the compiler. These are assumptions and not promises, so they may be violated unwittingly, resulting in bugs or undefined behavior. For an example of the `__restrict__` keyword, see the function signatures in `libc` for `[memmove](https://github.com/llvm/llvm-project/blob/main/libc/src/string/memmove.h#L16)` which assumes the source and destination may overlap (i.e. they may alias) and `[memcpy](https://github.com/llvm/llvm-project/blob/main/libc/src/string/memcpy.h#L16)` which doesn’t make that assumption.
Let’s show a quick example that has the sort of sequencing of reads and writes that can cause performance issues related to aliasing. The following two snippets show the same function written in Rust and C++. The C++ example has a `#define` which lets us toggle the compiler’s aliasing assumptions using a flag (`-DMAYBE_RESTRICT=__restrict__` will add a no alias assumption). Note that it would be impossible to call `foo` with `src` and `dst` overlapping in safe Rust.
```rust
/// Rust
fn foo(src: &[u32], dst: &mut [u32]) {
assert_eq!(src.len(), dst.len());
let mut i = 0;
while i < src.len() {
dst[i] = src[i];
if src[i] % 2 == 0 {
i += 1;
} else {
i += 2;
}
}
}
```
```cpp
/// C++
#include <cstdint>
#ifndef MAYBE_RESTRICT
#define MAYBE_RESTRICT
#endif
void foo(const uint32_t* MAYBE_RESTRICT src, uint32_t* MAYBE_RESTRICT dst, std::size_t len) {
std::size_t i = 0;
while (i < len) {
dst[i] = src[i];
if (src[i] % 2 == 0) {
i += 1;
} else {
i += 2;
}
}
}
```
Not shown are main functions that allocate the buffers, fill them with values (100,000,000 of them), and measure the time it takes to run just `foo`. After compiling with `rustc` and `clang++` at max optimization levels, the Rust version and the C++ version that uses the `__restrict__` keyword take about the same amount of time. The C++ version without `__restrict__` takes about twice as long to run.
So why are we talking about performance in an article about constness? Constness vis-à-vis immutability in Rust and the Borrow-Checker’s rules related to mutable borrowing are how we can guarantee no memory aliasing. The advantages are performance and correctness at the cost of having to play by the Borrow-Checker’s rules. The exact same thing is possible in C++, but *you* have to inform the compiler of your assumptions. If you want safety-from-bugs it’s up to *you* to enforce the assumptions, but you also don’t have to fight the Borrow-Checker.
# Moving in C++ and Rust
Move semantics in C++ and Rust are conceptually similar, but differ in how they were integrated into the respective languages. C++ received move semantics relatively late in life, while Rust integrated moving into the design of the language early on.
## C++
In C++, moving is highly related to the class-type special member functions: constructors and assignment operators. The constructors and assignment operators are overloaded to accept various types of references. C++ differentiates references based on “value category”. L-value references refer mainly to references to named values and are denoted with a single ampersand. R-value references refer mainly to temporary results of expression evaluations and are denoted with two ampersands.
⚠️ That summary of value categories was a huge over-simplification but should suffice for the purposes of this article. See here for more info.
When invoking one of these special member functions, the overload resolution process will select the matching overload based on what sort of reference you have. Let’s say we have the following struct:
```cpp
struct S {
S() {} // default constructor
S(const S&) {...} // Copy Constructor
S(S&&) {...} // Move Constructor
S operator=(const S&) {...} // Copy Assignment Operator
S operator=(S&&) {... } // Move Assignment operator
// ... more functions and data fields etc.
}
```
This struct has both move and copy constructors/assignment operators, so if we e.g. tried to construct a new value using an existing reference, the compiler has to decide which sort of reference (i.e. what value category it has) to decide whether to move-construct or copy-construct.
If the reference were the return value of a function or the result of evaluating an expression, the reference would be an R-value and thus get moved. If we simply passed a named value, the reference would be an L-Value and thus get copied. This has the benefit of moving objects that are unlikely to be reused instead of copying them. See the example blow.
```cpp
void foo() {
S s;
S s_copied(s); // Copy-Constructed: "s" is a named value.
S s_moved(make_me_an_S_please()); // Move-Constructed: passing a result
}
```
ℹ️ In later versions of C++, various additional constructor rules and Return-Value-Optimizations allow the compiler to elide and optimize some constructions even if the optimization could change the observable behavior of the program.
So let’s say we have a an L-value (i.e. a named value, not a temporary), how would we move it? The answer is to use `std::move`. But what does this function do? Is it some slick function that moves the object for you? Not really, it’s simply a cast. `std::move` casts a reference to an R-Value reference. It actually does nothing on its own, but when used in conjunction with a constructor or assignment operator, it will cause the move versions to be selected which causes the object to be moved.
```cpp
void foo {
S s{};
std::move(s); // This has no effect at all
S s2{};
s2 = std::move(s) // moves `s` to `s2`, by selecting the move-assignment operator
}
```
This aspect of C++ move semantics is often where programmers can get into trouble. `std::move` is just a cast that causes special member function calls to select the R-value Reference overloads, i.e. the ones that move. In effect, moving in C++ is just passing a value to one of these special member functions. The rub is that this has no formal effect on the moved-from object’s lifetime. In the above example, the object `s` is not unavailable or out of scope simply because it got “moved”. This means that we’re free to continue using `s` but there’s no guarantee in the language that the object is valid or useful after being moved from. Many compilers will warn about use-after-move, but the warnings don’t cover all the cases.
The other issue with C++ move semantics is that it’s up to programmers to write the moving functions. This of course means it’s possible to write buggy move constructors, but setting that aside, it means there’s no consistent interpretation of a what move constructor or move assignment operator should even do. It’s broadly understood that these functions should transfer (i.e. give away, not copy) the resources of the moved-from object to the moved-to object in a performant way.
If we take a dynamic vector as an example, we’d expect the vector class to have three fields: a pointer to a possibly-allocated buffer, a current size and a capacity for the buffer. Moving this object means simply copying these three values to the new object. This passes the ownership of the buffer to the new object by simply copying a few words instead of deep copying the whole buffer (that would semantically be a copy, not a move).
The problem then, is what to do with the moved-from vector. If we knew that vector were never to be used again, we might be tempted to simply do nothing. No one ought to be making any changes through the moved-from vector, but we shouldn’t forget that the moved-from vector is still around and will eventually go out of scope and the destructor *will* run— meaning if we don’t clear-out the values in the moved-from vector we could get a [double free](https://docs.microsoft.com/en-us/cpp/sanitizers/error-double-free?view=msvc-170). In this case, there’s a somewhat obvious answer: put the vector into the same state as a default-constructed vector (which has no allocation). This avoids the double free case and the object will be cheap to destruct.
However, not everything works out so easily, as not all objects are default-constructable and putting the moved-from object into a valid state after giving away its resources can often be a very expensive operation. This often leads to additional states being added to an object to avoid expensive re-initialization. It’s not uncommon for more complicated objects to end up with variables like `bool _isInitialized` or `bool _shouldDestroyX` added to the data members to handle the conditional behavior of a destructor. It’s also pretty common to see people wrap an object in a `std::unique_ptr` even when use of values rather than pointers is more natural, simply so they can avoid the invocation of destructors when moving an object.
To top it all off, there’s no definitive guidance on what to do. Various sources suggest these moving functions should put the moved-from object in a “valid, but unspecified state,” which is somewhat vague.
## Rust
Moving has a been a formal part of Rust for a greater part of its existence. Accordingly, one gets the feeling like it’s more thoroughly integrated. Because it’s (mostly) not up to a programmer to define moving behavior, moving in Rust is a bit easier to explain.
- Moving is always just a byte-for-byte memcpy.
- Moving consumes the moved-from object. After an object is moved from, it’s a compiler error to access or reference it in any way.
- If the type implements the `Copy` trait then moving is a still a memcpy but the move doesn’t consume the object. This should only be used for relatively simple types which don’t have any complicated ownership semantics. For example, simple built-in primitive types like integers are `Copy`, but `String` isn’t because it owns a heap-allocated string. In the `String` example, making it `Copy` (which isn’t possible because its internal `Vec` isn’t itself `Copy`) would be a shallow-copy and would thus violate the idea that a `String` is the sole owner of it’s allocation.
- Moving does not cause the moved-from object to drop. `Drop` is a trait that allows custom end-of-life behavior for struct instances that implement it. Implementing `Drop` in Rust is akin to defining a non-trivial destructor on a class-type in C++.
## Comparison
Put briefly, the main differences are that Rust has destructive-moves and C++ doesn’t, and C++ uses special member functions, whereas Rust doesn’t.
Like many comparable features in Rust and C++, one can qualitatively summarize the comparison by saying that Rust is more opinionated and C++ is more flexible. Frequently, you don’t have to explicitly think about any of this stuff in Rust, but handling some edge case (where bespoke moving behavior would help) becomes more difficult. In C++, you have greater potential for bugs. On the other hand, if you try to build types that are well-behaved and have [value semantics](https://isocpp.org/wiki/faq/value-vs-ref-semantics), and you compose newer types from those other nicely-behaved types, you’ll likely infrequently (if ever) need to write your own move-constructor. In that case, the process can be painless most of the time. However, that’s the sort of thing you get by following convention more so than by being told explicitly what to do by a compiler.
# Shared Ownership in C++ and Rust
Before delving into ownership, we should define it. There are a few facets to ownership depending on the technology in use and whether you’re interfacing with potentially-external owners (e.g. the OS kernel, which possibly lent you a resource). In this particular context, ownership refers to control of an object’s lifetime and when that control ends. A big part of ownership is deciding when and who should delete an object. Clear ownership rules can avoid bugs like deleting an object twice or referencing an object that’s already been deleted.
In both C++ and Rust, regular value objects (i.e. not pointers nor references) that live on the stack or are members of other objects can be thought of as being owned by the scope that contains them. Of course, objects can move between scopes by being moved-to or returned-from functions, but ultimately, if the object hasn’t moved and the scope ends, then the object’s life is over.
### Single-Owner: Box and std::unique_ptr
The main way we can separate ownership from scope is by placing the object on the heap. In both C++ and Rust, the preferred way to interface with the heap is through smart pointers. They work much the same in both languages. In both languages, the smart pointer owns the allocated object, tying the lifetime of the allocated object to the pointer. Thus whoever owns the smart pointer transitively owns the allocated object.
**C++**
In C++, by virtue of its relationship to C, there are a number of more manual ways to allocate on the heap (i.e., `new`, `malloc` etc.). Those ways only provide the programmer with possession of a pointer as a semblance of ownership, but that pointer may be copied or leaked without complaint from the compiler. Manual memory management isn’t considered idiomatic in modern C++. The common approach now is to use `std::unique_ptr` for single-owner heap ownership. though it is allowed to contain a non-allocation (i.e. `nullptr`).
**Rust**
In Rust, you use `Box` to store heap allocations. A Box owns the heap allocation, but unlike `unique_ptr` it must be initialized in safe Rust. Box also [requires](https://doc.rust-lang.org/std/boxed/struct.Box.html#method.new) a fully formed object, which first must be built and then moved into heap memory. There is no way to construct an object directly in heap memory like in C++ with `std::make_unique`. However, compiler optimizations will often elide extra copies and stack allocations if possible.
These smart pointers are for the single owner case. `unique_ptr` has a deleted copy-constructor and copy-assignment operator. `Box` does not implement `Copy` and only implements `Clone` for inner types which are also `Clone`, with `clone()` causing a deep copy. The thinking in both cases being that these are handles that represent single ownership and that copying would essentially add owners, thereby violating the single ownership principle. `Box` and `unique_ptr` are great if you need a heap allocation (e.g. because it’s a dynamically-sized allocation) but ultimately they have the same sort of ownership semantics as regular values. Other tools are necessary for shared ownership.
### Shared-Owner: Rc, Arc, and std::shared_ptr
Shared ownership has to happen on the heap. Shared ownership is mediated through smart pointers that do reference counting. These smart pointers own a heap-allocated resource and maintain a count of the number of owners. Copying the smart pointer (via cloning, or copy-constructing) adds a new owner and increments the reference count. Each time a smart pointer goes out of scope, it decrements the reference count. If a smart pointer instance goes out of scope and it’s the last owner, the object gets deleted.
**C++**
`shared_ptr` is C++’s reference-counted smart pointer for shared ownership. By design, it doesn’t provide much in terms of aliasing or thread-safety, but will correctly handle lifetime management of the shared resource, ensuring that it is neither leaked nor double freed if used reasonably. `shared_ptr` provides thread-safety but only as far as the reference counting is concerned. Multiple threads are free to add or remove owners at will, and the reference counting mechanism’s atomicity will ensure that changes aren’t missed and that the object is eventually deleted (just once). The data being pointed to isn’t thread-safe, and thus safety must be managed in some other way.
**Rust**
Rust has two reference-counted smart pointers. One `Rc` (aka Reference Count) is for use in single threaded applications and `Arc` (aka *Atomic* Reference Count) is for use in multi-threaded applications. In safe Rust, the compiler will enforce that there is no mutable aliasing and no data-races. The smart pointers don’t grant a magical exception to these rules. You’re still not allowed to alias even though your data may now have multiple owners, even in a single threaded situation. This means that anything beyond relatively trivial read-only access through the multiple owners is impossible without using additional tools.
# Getting Rust to Work: Interior Mutability, Cells, and How They Work
So far, we’ve encountered a few self-imposed limitations in Rust. We’re unable to have more than one outstanding mutable borrow to one object. Similarly, we’re unable to mutate an object if it has multiple owners regardless of whether those owners are on separate threads or just one.
Ultimately, all these limitations result from the rules around aliasing in Rust. We need a way around these limitations, or else we’ll be unable to do anything interesting. One could simply dip into the world of unsafe code, but fortunately Rust provides a way around these limitations while maintaining the safety guarantees with only a small amount of performance overhead.
The way we get around these limitations in Rust is by using *[Cells](https://doc.rust-lang.org/std/cell/)*. Cells have a property called “Interior Mutability” which is when an object’s internal contents can be borrowed-mutably even if the object itself is immutable.
There are a few different types of Cells which are useful in different scenarios. All of them are based on [UnsafeCell](https://doc.rust-lang.org/stable/std/cell/struct.UnsafeCell.html). UnsafeCell simply wraps an object and owns it. It has one particularly interesting function:
```cpp
pub fn get(&self) -> *mut T
```
`get` returns a *mutable* pointer given an *immutable* borrow of `self`. This is fundamentally where we get interior mutability. Interestingly, and despite the name, this struct doesn’t actually use any unsafe code. In Rust, you’re free to *make* pointers in safe code, but *dereferencing* is unsafe, meaning that an UnsafeCell is safe to make but unsafe to use in any non-trivial way.
So in fact, you do need unsafe code to get around the Borrow-Checker, but fortunately the standard library will do the unsafe parts for you inside the implementations of the other higher level Cell types. There’s rarely a need to use UnsafeCell directly, so let’s look some of the available Cell types.
## **Cell**
Cell avoids aliasing by eliminating borrowing altogether and instead opting for a copy. Accessing the value inside the Cell will return a copy, and updating the value involves passing a new value to the cell which will swap it internally. Most of Cell’s method’s require that the inner type be `Copy`. This type of cell has overhead from copying, but since there’s no borrowing, there’s no overhead from additional mechanisms that ensure borrows don’t alias. This cell is probably best suited to small primitives. In fact, you can see Cell in use inside the implementation of RefCell where it is used to store the reference count.
## **RefCell**
RefCell allows for borrowing of the internal value both mutably and immutably. It has two methods to facilitate that:
```rust
pub fn borrow(&self) -> Ref<'_, T>
pub fn borrow_mut(&self) -> RefMut<'_, T>
```
These functions return wrappers to the borrowed internal value. Note how even for the mutable borrow, the functions take an immutable borrow of self. Using RefCell doesn’t grant an exception to aliasing rules. It just allows you to work around the borrow-checker; ultimately the aliasing rules still have to be enforced.
RefCell abides by these rules by counting the number of outstanding mutable and immutable borrows. Borrowing from the RefCell will increment the count and when the guards (`Ref` or `RefMut`) get dropped, the count is decremented again. If at runtime you try to borrow in such a way that the aliasing rules are violated, the program will panic.
RefCell uses a neat little trick for reference counting: it uses just one signed integer, with positive values counting immutable borrows and negative values counting mutable borrows (which should never exceed -1).
## **Mutex**
Mutex isn’t listed in the `cell` module but its objectives are similar and does in fact use UnsafeCell under the hood. Instead of having its own counting mechanism, Mutex uses whatever underlying synchronization mechanism (e.g. a POSIX Mutex) it has to determine whether it can borrow the value or if it has to block. We can also lump in other similar synchronization primitives like `RwLock` which allow for mutable borrows across threads.
# Usage
In Rust, you have to use the right combination of tools for your application, or your code won’t even compile. If you’re looking to have multiple-ownership, you can start with `Rc` in the single threaded case, but the compiler will prevent you from copying `Rc`s into new threads, in which case you have to use `Arc`. Both `Arc` and `Rc`, implement `Borrow` which allow for immutable borrowing, but neither allow mutable borrowing in the general case.
If you need to mutate the shared object, you have to use something with Interior Mutability. `RefCell` will allow for mutable borrowing, but its reference count is not threadsafe. In addition, it only verifies borrowing rules by panicking. That is to say, it doesn’t enforce the rules by synchronizing threads. `Mutex` or `RwLock` will enforce borrowing rules by ensuring only one thread can have write access at a time, for instance. Accordingly, `RefCell` cannot be used with `Arc`, something like `Mutex` is necessary. Note that even in the single-threaded case, we have to have two reference counts: `Rc` will count the number of owners and `RefCell` will count the number of borrowers.
This table provides a quick guide about what tools we recommend to use in a few common scenarios. These may not be the right choices for all scenarios, but they provide a good starting point.
<table>
<colgroup>
<col style="width: 15%" />
<col style="width: 15%" />
<col style="width: 35%" />
<col style="width: 35%" />
</colgroup>
<thead>
<tr class="header">
<th>Mutable Access</th>
<th>Multi-threaded</th>
<th>C++</th>
<th>Rust</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>No</td>
<td>No</td>
<td><code>std::shared_ptr<const T></code></td>
<td><code>std::rc::Rc<T></code></td>
</tr>
<tr class="even">
<td>Yes</td>
<td>No</td>
<td><code>std::shared_ptr<T></code></td>
<td><code>std::rc::Rc<std::cell::RefCell<T>></code></td>
</tr>
<tr class="odd">
<td>No</td>
<td>Yes</td>
<td><code>std::shared_ptr<const T></code></td>
<td><code>std::sync::Arc<T></code></td>
</tr>
<tr class="even">
<td>Yes</td>
<td>Yes</td>
<td><code>std::shared_ptr<T></code> + some other sync*</td>
<td><code>std::sync::Arc<std::sync::Mutex<T>></code></td>
</tr>
</tbody>
</table>
💡 There isn’t one idiomatic way to do synchronization. C++ mutexes don’t own the data they guard, so they have to be stored somewhere alongside the data. It’s not uncommon to build thread-safety into T if its data are already compartmentalized.
### Conclusion
Hopefully this article has helped illuminate some of the differences and commonalities between Rust and C++. We showed how differences in the languages affect performance, development style and tooling. While there are some strong differences, the languages share a target user demographic. They both provide high performance, with the tools needed to safely build larger and more complex projects.
At Tangram Vision we write all of our Computer Vision algorithms in Rust. If you’re a Rust or C++ programmer looking join a new team, check out our Careers page.
Tangram Vision helps perception teams develop and scale autonomy faster.