Rust Bites: Undefined Behavior

Reading from and writing to unowned memory

Well-defined behavior in the Rust world means two things:
- Memory safety:   There is no way for code to access memory that it does not own or borrow.
The compiler applies rules that govern which borrows are valid.
- Data race safety:   In a multi-threaded environment, all mutable non-atomic data shared between threads can only be accessed by one thread at a time.
Undefined behavior is the absence of either or both.
Preventing undefined behavior blocks attacks and unreliable behavior. Rust delivers that protection primarily by enforcing ownership policies at compile time through static code analysis. Static code analysis is conservative. The Rust compiler rejects any code for which it cannot guarantee that ownership policies hold. Most correct code passes static analysis, but a few cases - most often in multi-threaded designs - defeat static analysis, so the compiler may reject correct code. A subsequent Bite shows examples. Rust provides a mechanism, called interior mutability, that defers enforcement to run time. This lets correct code build even when static analysis cannot verify it, at some cost to performance from run-time ownership checks. When code fails a run-time check, the program panics and shuts down before it can access unowned memory or corrupt data through a race. Fortunately, most code does not require this deferred checking. Run-time checking is clever and inexpensive, but avoiding interior mutability where feasible skips even that small cost.

1.0 Examples of undefined behavior:

The examples below demonstrate undefined behavior in C++, then show the same processing in Rust. C++ makes it easy to access unowned memory. The dropdown below shows this by:
  • creating a std::vector<int>
  • filling it to capacity
  • taking a reference to one of its elements
  • pushing back another element into the vector.
That last push forces the vector to allocate new memory to hold the added element and copies every existing item from the old location to the new. The reference now observes memory the vector no longer owns. The code fragment in the dropdown illustrates this.
C++ Ref Unowned Memory  
The reference reads memory owned by no program instance and returns its value, so the program can continue computing on invalid data. The process exits normally, as if nothing had gone wrong. The next dropdown shows another way C++ code can access unowned memory: indexing an array past its last element.
C++ Index out of Bounds  
The code indexes past the end of an array and returns a value from unowned memory. Program flow continues, so invalid data can propagate through the rest of the computation, and the program exits normally. In fairness to C++, neither fragment is idiomatic. The first example should use an iterator rather than a reference; the iterator would throw an invalidation exception before any unowned access. The second example should use a range-based for loop, which avoids out-of-bounds indexing. C++ is memory safe by convention, and that works well in practice. However, in large programs - perhaps several hundred thousand lines - a few lapses in practice can slip through and permit unsafe memory access, and those few can be very hard to find. Rust, in contrast, is memory safe by construction. Ownership policies prevent unsafe memory operations. The following examples duplicate the process flow of the two C++ fragments above. The first dropdown below sets up the same processing as the first C++ example and shows that it fails to compile.
Rust Attempt to Ref Unowned Memory  
As in the first C++ example, this Rust code creates a vector, fills it to capacity, and takes a reference to one of its items. The attempt to mutate the vector by adding another element fails to compile. That action violates Rust ownership policy. The compiler pinpoints the problem and its exact location. Rust's compiler error messages are exceptionally well crafted, which makes software development significantly more productive. The Rust code in the last dropdown replicates the out-of-bounds indexing of the second C++ example. This code compiles, but when it attempts to read past the array, the program never touches that memory. Instead, a panic triggers an orderly shutdown.
Rust Index out of Bounds  
Program output shows the panic firing before the code can access unowned memory, causing termination rather than a normal exit.

2.0 Conclusions:

Rust code is memory safe by construction. Language-enforced ownership policies rule out access to unowned memory, so memory-unsafe operations fail at compile time. A later Bite shows situations where ownership checking must defer to run time, most often in multi-threaded code. The interior mutation section of the Safety Bite covers those cases. C++ is memory safe by convention. Rust is memory safe by construction. Construction means that violations cannot occur in code that compiles. Convention means that violations do not occur as long as programmers follow the conventions.

3. References:

Code for Examples - in CppUndefinedBehavior
Arguing about Undefined Behavior - video
UDB Examples - Wikipedia
Falsehoods about undefined behavior
Cost of Rust bounds checking