Jonathan Blow, in a video critiquing Catherine West’s closing keynote at Rust 2018, posed an interesting question about whether Rust guided Catherine towards a more correct program. The crux of this argument is that replacing references with indexes effectively circumvents the borrow checker, enabling logical errors similar to those it solves. Thus, in Jonathan’s opinion, the development costs imposed by the borrow checker aren’t worth the effort.

This mindset is common among programmers with a cursory knowledge of Rust. The borrow checker is the most novel, and most central, feature of Rust. While it’s natural to focus on it, doing so to the exclusion of Rust’s other features is, itself, a fallacy.

This is the first post in a series that analyzes Jonathan’s argument and demonstrates how Rust’s features and idioms supplement the borrow checker. I’ll begin by describing a primitive version of Blow’s argument. In the next post, I’ll build an instance of Catherine’s UR-ECS, intentionally introducing the “logical borrow errors” described above. I’ll then refine the UR-ECS, demonstrating how Rust’s features mitigate (and, at times, eliminate) the logical borrow errors. Let’s begin with an examination of arrays of integers.

An array’s memory layout is simply a block of contiguous memory, partitioned into an arbitrary number of fixed-length cells. A [i32;16], for example, consists of 64 bytes of memory mapped into 32-bit elements. There are two ways that a value in the array could be represented, either as a reference to the element, or as an index into the array.

In Rust, the reference approach creates a conflict if, later, the program attempts to modify the array:

fn main() {
    let mut foo = [0;16];

// error[E0506]: cannot assign to `foo` because it is borrowed
    let bar = &foo[3];
//             ------ borrow of `foo[..]` occurs here
    foo = [1;16];
//  ^^^^^^^^^^^^ assignment to borrowed `foo[..]` occurs here
}

A similar error occurs if, instead, one tries to modify an element within the array:

fn main() {
    let mut foo = [0;16];

// error[E0506]: cannot assign to `foo[..]` because it is borrowed
    let bar = &foo[3];
//             ------ borrow of `foo[..]` occurs here
    foo[0] = 1000;
//  ^^^^^^^^^^^^^ assignment to borrowed `foo[..]` occurs here
}

Or if one simply tries to borrow mutably:

    fn main() {
        let mut foo = [0;16];

        let bar = &foo[3];
//                 ------ immutable borrow occurs here
        let baz = &mut foo[0];
//                     ^^^^^^ mutable borrow occurs here
    }
//  - immutable borrow ends here

As Catherine points out, storing indices to the array solves this problem, as there is no borrowing, and thus no need to borrow-check:

fn main() {
    // compiles!
    let mut foo = [0;16];
    let bar_idx: usize = 3;
    foo = [1;16];

    foo[0] = 1000;

    let baz = &mut foo[0];
}

Here we can see part of Mr. Blow’s argument. bar_idx no longer refers to the same data as when it was created, because the array was replaced on the preceding line. Indeed, this is no different than reading a pointer whose memory was reused1!

Let’s extend the example one step further by introducing Option<T>:

fn main() {
    let mut foo = [Some(1);16];
    let tmp_idx = 3;
    foo[3] = None;
    foo[3] = Some(2);
}

This snippet, to the casual eye, reveals two more similarities with pointers. The line foo[3] = None; is cleary similar to assigning null. In fact, if foo[3] had a type Option<string>, assinging None would deallocate the string. The following line reveals another variant of a dangling pointer, as foo[3] was “freed” and reusued.

Thus, it’s clear that indices, as used above, are a stand-in for pointers. Their purpose, insofar as this frame of reference is concerned, is silencing the borrow checker. The argument is that, if the borrow checker, hadn’t been subverted, these mistakes wouldn’t occur. And, since the mistakes can still occur, Rust hasn’t led Catherine to a better design, merely a different one.

In a vacuum this would be correct, but the borrow checker is only one of many tools that Catherine West mentioned in the talk. Others include Option<T>, Vec<T>, and generational indices. In order to explore the model more deeply, we’ll need an UR-ECS of our own. The next post in this series will create that system for a CLI-driven Tic-Tac-Toe game.

Thank you kyrenn, SeanMiddleditch, and louiswins for your feedback!

  1. Derefencing a dangling pointer is undefined behavior, which could do anything. For the sake of argument, however, let’s assume that the compiler simply reads the memory at the pointer’s location.