Rust Bites: Safety

Rust is safe by construction via compiler enforcements

Ownership rules all
Borrow checker stands its ground,
Safety without fear.
- Haiku by ChatGPT 4o

1.0 Why Rust?

Rust combines the memory safety of C# and Java with the performance of C++. Unlike these languages, Rust also provides data race safety by construction. Modern C++ idioms let a developer build memory safe programs without data races. But in large systems with many thousands of lines of code, a developer can easily forget an idiom in a few lines, or unintentionally share data between threads without proper locking. C++ achieves memory and data race safety by convention. Rust, however, ensures memory and data safety by construction. Code with unsafe memory access or data races fails to compile. Rust accomplishes that with an interesting ownership model. The sections below cover the details, and other Bites explore code, e.g., Undefined Behavior and Ownership.

2.0 Memory Safety

Memory Safety means a program cannot read or write to memory it does not own, so memory safe code:
  1. Cannot construct uninitialized references.
  2. Cannot allow references to become invalid due to reallocation of memory by the referend.
    For example, a vector holding a reference to its interior might reallocate to make room for additional data.
  3. References cannot outlive the instances they reference.
  4. Cannot read from or write to memory accessed by indexing beyond the size of any indexable collection.
To see what could happen without these constraints, look at Undefined Behavior with C++. Modern C++ provides facilities and idioms that prevent undefined behavior by convention. Rust does so by construction. Rust's safety mechanisms prevent unsound code by refusing to build it. Rust rejects some safe programs to reject all unsafe programs, e.g., its heuristics produce occasional false positives - claiming a sound program is unsound - but no false negatives. Section 7 covers this in more detail. This link opens a YouTube video: Rust Safety Short Video

3.0 Rust Safety Invariants

Rust ensures memory safety, for all code outside unsafe blocks, by enforcing two invariants:
  1. Safe referencing - No shared mutability through references.
  2. Safe indexing - All collections, including native arrays, are sized, and any attempt to index outside that sized area results in a panic, e.g., an orderly shutdown of the current thread before any reads or writes can complete.
Using these two simple invariants as programming guides makes working with Rust easier. The first invariant often causes problems for developers new to Rust when working with linked data structures.
Fig 1. Directed Graph using references
Fig 2. Directed Graph using vector indexes
Note that the first invariant is a very strong condition. Useful program constructs, like directed graphs, depend on shared mutability to function as expected. A child graph node, like node 0 in Fig 1., may be shared between several parents. Without mutation of the node's value, we could only build constant graphs. Rust can build non-constant directed graphs. Constructing graph edges using references is not easy. If we move all graph nodes into a vector, each parent can refer to its children using vector indices. Handling node deletions and reusing slots is straightforward. A program can safely share mutability through vector indices because indices remain valid over vector buffer reallocation when adding new nodes. The first safety invariant applies to references, not to indices. Several years ago, before learning Rust, I built a C++ Directed Graph class using the strategy suggested above. That approach proved the easiest way to build a memory safe structure, even though C++, unlike Rust, allows pointers to establish parent-child relationships. Rust's safety mechanisms may lead us to write some small part of our code using different strategies than we would with C++ or other modern languages. That is eventually a good thing.

4.0 Ownership - static analysis

Rust rejects unsafe programs at compile time with ownership rules derived from the "no shared mutability through references" invariant. Ownership Rules:
  1. Every data item has exactly one owner. That owner deallocates the data when it goes out of scope, using a drop operation similar to a C++ destructor invocation.
  2. Ownership can be transferred with move operations.
  3. Ownership can be borrowed by creating references.
  4. Any number of readers (immutable references) may have access to a data value simultaneously.
  5. Writers (mutable owner or mutable reference) get exclusive access to a value - no other readers or writers.
Borrowing from an owner prevents the owner from mutating its data. Mutably borrowing prevents all other borrows.
Evaluating these rules can be complicated when you first start writing Rust code. You do not have to. The Rust compiler reports errors with just the right amount of detail, and often provides a suggested solution. Rust checks these rules with static analysis performed by a "borrow-checker". The checker determines whether your code violates the "no shared mutability with references" invariant.

5.0 Non-lexical Filters - refining static analysis

No build environment can accept and build every safe program while rejecting every unsafe program. As a consequence, Rust is conservative. Rust rejects some safe programs to ensure it rejects all unsafe programs. Rust reduces the number of false alarms generated by the Ownership Rules with a couple of additional filters.
  1. Calling a function with one or more immutable references following a mutable borrow builds successfully, since no data in the function can be mutated during invocation. The same holds for other expressions that take immutable references.
  2. Creating and using an immutable reference in the scope of a mutable reference builds if, and only if, the mutable reference is not used after declaration of the immutable reference.
The Rust community calls these non-lexical scope rules.

6.0 Interior Mutability - invariant analysis at run-time

Some code blocks do not violate the "no shared mutability of references" invariant but fail static analysis because they violate the Ownership rules stated above. They never share mutability due to the way they operate in time. For example, you might want to create a constant collection but cannot initialize it until runtime, e.g., some values are not known at compile time. You want to mutate it once, then provide immutable shared access. Rust provides a construct called RefCell<T> that appears immutable to the compiler but lets code extract both mutable and immutable references. RefCell<T> does not allow you to violate the invariant because it checks each borrow at run-time, looking for shared mutability. If your program has concurrent mutable and immutable borrows using RefCell<T>, it will panic, terminating the thread on which it runs. In the initialization example above, we build the collection values with a single mutable reference obtained from RefCell<the_collection>, then never use it again. Your code can then take multiple immutable references, as needed, to view the collection. This builds successfully due to the non-lexical scope filter, and will not fail at run-time since there is no concurrent mutation and aliasing. Note that, for single threaded programs, we resort to run-time checking with RefCell<T> only when static analysis produces a false alarm, due to the small additional expense of run-time checking. Rust uses this same run-time checking process for threads that need to share and mutate data by using a Mutex<T>, similar to a thread safe version of RefCell<T>. Mutex<T> holds both the protected data and a lock. A great benefit of Rust's design: Mutexes protect specific data, not critical sections of code, so two threads cannot accidentally use different locks to access the "protected" data, e.g., they share the Mutex that wraps the data. They do not share the data inside a protected area.

7.0 Evaluating Safety

This final section circles back to the question: in the presence of safety checking, "is it possible that Rust prevents us from building some safe program?" A thought experiment illustrates the answer.
Figure 1. Soundness Metaphor
Suppose we could map program activities into the x-y plane, as shown in Figure 1. - don't quibble, this is a metaphor. Further, suppose we can exactly measure the effort required to separate sound code from unsound code, as shown by the soundness boundary. As we approach the boundary, we must spend more evaluation time, and be very clever, to determine if a program is sound. That approach is not feasible for a practical compiler, so instead, Rust chooses a simpler evaluation strategy, outlined in sections 2 and 3. That simplified strategy creates a second boundary inside the soundness region that is relatively easy to evaluate but rejects some sound programs. Rust rejects some safe programs to ensure it rejects all unsafe programs. Every program activity in area (1) is provably sound and compiles; every activity in area (2) is sound but cannot be easily proven so, and does not compile. This may worry you. Suppose a program you are developing requires some activities between the two boundaries, e.g., sound but not easily evaluated as sound. Compilation fails, and the compiler's error messages tell you why. There are three things that you can do to resolve this issue:
  1. Rust standard types cover much of the territory between the boundaries, i.e., region #2. These types use unsafe blocks carefully vetted by the development team to be sound. Your program simply uses one or more of these types without directly using unsafe blocks.
    Consider the standard library's vector. Some of its operations clearly violate the scope-based rule about no shared mutability. Vector holds a reference to its internal memory but hands out a mutable reference to your program. That is safe because the vector and compiler manage change on your behalf, refusing to mutate when outstanding references exist. If curious, you can look at unsafe blocks in vector from its source documentation.
    We use the vector, the compiler does not complain because of vector's few small well crafted unsafe blocks, and we benefit from the library developer's expertise.
    You seldom know your program is walking in the valley of "hard to evaluate region #2". You just use the standard library types and all is well.
  2. In some of that territory, program operations do not violate the invariants, but static analysis cannot determine that, e.g., there is no simultaneous shared mutation using references because of the way the program operates in time. For this case, one may use "interior mutability", discussed in section 6.
  3. You may change the design to avoid violating invariants, often by using indexing instead of references, like the directed graph class mentioned above.
You do not need to think about all this when developing code. The compiler does the low-level thinking for you. Understanding why the language works the way it does, and why it works so well, still helps.

Productivity Tips

Here are some tips that may make you more productive:
  • Use the Rust library linked types (HashMap, linked_list, ...) instead of trying to craft your own.
  • Find alternatives to custom linked data structures, as we did for the graph type discussed above. That usually involves use of vectors.
  • Pass arguments to functions by reference. Passing by value moves the caller's argument into the function, making it permanently invalid in the caller's scope.
  • In methods of a type you have developed, return a member value by reference. Otherwise it moves to the caller and the member becomes invalid. If returning by value matters for ergonomic reasons, return a clone by value, incurring the performance penalty of making a clone.
  • For all other functions, return a locally created object by value so the data moves to the caller. That is fast, and the returned data continues to be valid. In this case, Rust will not let you return by reference, citing lifetime issues.
You may encounter coding contexts where you elect not to use one of these tips, but that happens infrequently.
After writing a lot of Rust code - more than 20 repositories - I never had to use unsafe blocks or write contorted code to build projects. It was not that hard. The compiler's error messages are so good they guide you to a sound implementation.

7.0 References:

Reference Description
Jon Gjengset Considering Rust - why should you explore Rust?
intorust.com Excellent discussion of ownership and safety by an expert in five short videos.
Temporarily opt-in to shared mutation Blog by Alice Ryhl, a primary maintainer of the Tokio crate.
Rust: Unsafe in Rust: Syntactic Patterns Interesting analysis of unsafe in crates reported by Alex Ozdemir
Rust: A unique perspective Best description of Rust ownership and sharing that I've found. Check it out!
RustTour.pdf Quick tour of the Rust programming language emphasizing its unique attributes.
Why Rust for safe systems programming - Microsoft Microsoft is exploring Rust for system programming.
The Rust Book - Ownership Ownership rules with examples and discussion
Rust edition-guide Non-lexical lifetimes
Rust Book RefCell<T> and the Interior Mutability Pattern