This post is about Valen's new flexible borrow checker. Welcome!
You all know me, I love exploring new memory safety approaches and blending them together in weird ways.
It's part of my eternal quest to find the memory safety holy grail: something as powerful as Rust's borrow checking, yet something as simple and flexible as reference counting or garbage collection. 0 1
I've long suspected that to find the holy grail, we'd have to solve three memory-safety challenges:
- Can we make a more relaxed borrow checker? 2
- Can we make a borrow checker read more complex data? 3 4
- Can we make those two systems work together at the same time?
Vague and arcane, I know! Further below I'll explain what that all means, and how Valen might have found a solution for them. 5
This post mainly focuses on the first piece: Valen's more relaxed borrow checker.
So in this post, I'll talk about what it is, what it can do, and how it works.
And, along the way, I'll explain some of the weirder tidbits, like:
- How a compiler can remember where a reference is pointing.
- How Valen has one kind of borrow reference, as opposed to the two that other borrow checkers have.
- How Valen's borrow checker is shaped to help it call into Rust code.
Also, this article is gloriously long and has a lot of context, so I'll let you know when to skip ahead.
Let's dive in!
Group borrowing: a near miss, and huge potential
(This is just backstory that I love telling, feel free to skip this section!)
Valen's approach is built on the "Group Borrowing" proposal which was designed by my friend Nick Smith.
It's built around the core concept that the compiler remembers where a reference points to, (I'll explain this later) and it uses that information to check that programs are memory-safe.
The design's life had a rough start; it was almost buried in the flows of time. It was originally designed with Mojo in mind, but alas, despite my best efforts I couldn't convince the higher-ups that we should use it. 7 Group borrowing was dead before it had the chance to succeed.
But I knew it had massive potential, if it could just make it into the world somehow.
So we spent months sharpening the explanation and figuring out its benefits, and wrote a post 8 on it last year, hoping other languages would pick it up.
And they did! It spread far and wide. 9
- Google's Carbon language pivoted completely to group borrowing (and even credited our article, which was nice of them!)
- Ante started working it into their language, see Shared Mutability and Stability.
- Plecra's exclusion typing is redesigning the Rust borrow checker with concepts from group borrowing and GhostCell. 10
- Zeta blended group borrowing with disjointness 11 to factor run-time facts into compile-time memory safety.
- Fang is working it into their designs, see Cyclic References in Fang.
- And now, my Valen combines group borrowing with Vale's 12 approach, and makes it interop with Rust code too.
Most of these languages are circling the same sort of challenge: how to make a more flexible borrow checker.
For example, Valen wants to make a borrow checker that's flexible enough to work with reference counting and generational references, and Carbon wants to make a borrow checker flexible enough to work with C++ code.
This is difficult, because there's been a fundamental conflict between borrow checking and other approaches. It can be summed up like this: 13
- Borrow checking has the "shared-xor-mutable" restriction: if you hold a reference to an object, nobody else can change the object.
- Other approaches want to allow holding a reference to an object while letting others change it. 14 15
You're probably thinking, "There's definitely no way to resolve this."
It does seem that way!
But group borrowing actually resolves the conflict, by relaxing "shared-xor-mutable" to "no use-after-free".
That was cryptic and won't make any sense, but it will later when I explain how group borrowing works.
So let's see what group borrowing can do, and then I'll explain how it works.
Group borrowing gives a program memory safety without run-time cost. 16
It catches use-after-free problems at compile time, and it does it in a way that has less restrictions than previous approaches.
Here's an example where a use-after-free error is caught at compile time:
struct Entity {
hp int;
}
struct World {
entities Vec<Entity>;
}
func main() int {
let world = World(Vec<Entity>.new());
world.entities.push(Entity(42));
let first_ref = &world.entities[0];
world.entities = Vec<Entity>.new();
return first_ref.hp;
}
It's an unusually flexible approach. Usually, compilers have trouble with the below program, but this approach understands that it's safe: 17
func main() int {
let list = Vec<int>.new();
let ref_a = &list;
let ref_b = &list;
ref_a.push(42);
ref_b.push(73);
return 42;
}
Group borrowing accepts a lot of patterns that are normally hard for borrow checkers, such as:
- Having multiple local variables that point to the same object, and write through any of them (like above).
- Take a parameter pointing at an object, and another parameter pointing somewhere inside that object, and write through only the latter (we'll see this in the next section).
- Having multiple function parameters that point to the same object, and write through any of them. 18
- Make a "rollback" struct, that will change an object when it goes out of scope, like the ScopeGuard pattern. 19 20
- Having multiple structs that have mutable references to a common subsystem. 21
- Plus a lot more that I'll explain further below. 22
These are patterns we love from C++, but no language has figured out how to do all of these in a memory-safe way at compile time.
This quest led to a lot of interesting experiments with constraint references, linear types, reference counting, and a lot more.
0% of this article was written by AI. More thoughts here.
My stance: if you want someone to take the time to read something, take the time to write it by hand.
Thanks for reading =)
Or more specifically, "can we make a borrow checker that supports multiple read-write borrow references to a single object?"
This post explains that in full, so keep reading!
I'll explain this more below, but by "more complex data" I mean entire graphs of data, where objects can freely have references to other objects without restriction, like you see in C++, Java, etc. as opposed to something like Rust or Fortran where things generally can't have references to each other.
So when I say "read more complex data", I mean: how can we have immutable borrow references pointing at that kind of arbitrary graph of data?
(Note, Rust structs can have references to other Rust structs, but only temporarily. This is why ECS is such a popular choice in Rust games. That's why I still count Rust as more on the Fortran side of things.)
In short, this one is answered by Vale's blend of generational references and region borrowing; its immutable region borrowing lets the compiler track all pre-existing data as a temporarily frozen "region" of data, that we can have borrow references into.
For now, I'll just hint that Valen might have solved these by blending Vale's approach, a certain thought experiment from 2023, Nick Smith's Group Borrowing approach, and a few twists to make things work together harmoniously.
Specifically, in today's compiler, one needs to manually dereference, index into arrays directly like my_vec.data[0] instead of the struct, and put types on all parameters like entity &Entity in world.entities[].
Such is life when working on someone else's language!
This post you're reading right now is a much better explanation of group borrowing, but you can see the old post at Group Borrowing: Zero-Cost Memory Safety with Fewer Restrictions.
Soon I'll be writing an article on how all of these languages' approaches work and compare with each other, stay tuned!
It rejects less code by using first class permissions to merge lifetimes with the type checker. Definitely check it out!
Zeta's borrow checker can prove when two references point to different parts of the same data (they're "disjoint"). For example, it can prove list.get_mut(i) and list.get_mut(j) are disjoint when it knows that i != j, even if both indices are runtime values. Read more here!
The main differences between Valen and Vale:
- Valen will have group borrowing, Vale had region borrowing.
- Valen will have normal generational references, Vale had probabilistic generational references.
- Valen will have (and Vale didn't have) Rust interop, Rc, comptime.
- Valen won't have (and Vale did have) perfect replayability, fearless FFI.
Both have linear types.
Their syntax is still mostly the same, but Valen might change to be more of a "simpler Rust" syntactically.
I often called this conflict "the language killer" because it destroyed many versions of many experimental languages, including one of Vale's earlier approaches.
Between constraint references and Vale's current blend, there was an approach I called "hybrid-generational memory", where you could keep a reference alive by "tethering" a generational reference for the duration of a scope. I went through thirty two versions of it before giving up.
C++ wants this because that's often how we use it; we want a single unique_ptr owning an object, and a bunch of non-temporary raw pointers referring to it. Reference counting and garbage collection want the same sort of thing. In both cases, difficulties arise when someone else can modify an object while you have a borrow reference to it.
This can be a controversial goal; some of us believe that a reference's referent should never change out from under you, but an ID's/index's referent should be able to (like in Rust). Others believe that that's too restrictive. I think the user should be able to choose.
Though it's debatable if any language can get memory safety with zero run-time cost. See Chasing the Myth of Zero-Overhead Memory Safety, it applies to group borrowing as well.
Normally, borrow checkers don't understand how to have two references that can both modify an object, so they give compile errors. Group borrowing is flexible enough to not forbid these multiple read-write references, because it knows how to more precisely guard against use-after-free problems specifically.
For an example of this, see the "fn attack" examples in this page. This is usually hard for borrow checkers. Though, GhostCell sometimes helps!
Normal borrow checkers usually have trouble with this because the user wants two read-write references (which violates shared-xor-mutable): one to use directly, and one that lives inside the ScopeGuard object.
This could also be the foundation for a user-defined defer statement!
I don't have an example of this handy, but imagine that you wanted a bunch of File objects to each have a reference to a common FileSystem object. Borrow checking usually has problems with that (too many read-write references to the same object), but it should work fine in group borrowing.
I'll cover it more below, but using group borrowing as a starting point, we can:
- Make programs that are faster at run-time, because we can give more information to the optimizer.
- Use reference counting without Cell or RefCell or Mutex.
- Use generational references, for the same reason.