5

C++ & Rust: (Interior) Mutability, Moving and Ownership

 2 years ago
source link: https://www.tangramvision.com/blog/c-rust-interior-mutability-moving-and-ownership
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
5fffb6d1f24947d2c7540d9e_shape-09.svg
5fffb6d1c79af55ba5f583c5_shape-06.svg
5fffb6d18073fc2254899566_shape-07.svg
< Back to Blog

C++ & Rust: (Interior) Mutability, Moving and Ownership

June 29, 2022

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 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:

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:

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 (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
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;
        }
    }
}
/// 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.

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:

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.

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.

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. 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.

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, 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 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. 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. UnsafeCell simply wraps an object and owns it. It has one particularly interesting function:

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:

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 Rcs 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.

Mutable Access Multi-threaded C++ Rust
No No std::shared_ptr<const T> std::rc::Rc<T>
Yes No std::shared_ptr<T> std::rc::Rc<std::cell::RefCell<T>>
No Yes std::shared_ptr<const T> std::sync::Arc<T>
Yes Yes std::shared_ptr<T> + some other sync* std::sync::Arc<std::sync::Mutex<T>>
💡 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.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK