Rust’s borrow checker is genuinely great. It’s memory safe without reference counting at runtime.
But it comes with a contract you can’t really opt out of. Everything needs to be borrow checked. Whether you need it or not.
Sometimes you just want to write code that you know is already memory safe. What if you don’t need your whole program to be borrow-checked?
What if it’s just… one allocation that is critical?
Here’s a snippet from an image editor I was working on.
struct Editor {
images: Vec<Image>,
workspace: Vec<u8>,
}
fn blur(mut workspace: Vec<u8>, image: &Image) -> Vec<u8> {
todo!()
}
fn apply_blur(editor: &mut Editor, i: usize) {
let result = blur(editor.workspace, &editor.images[i]);
// error: cannot move `workspace` out of borrowed `editor`
editor.images[i].pixels = result;
}
blur needs to own workspace while applying the filter. However, the data model is set up so that workspaces are stored in an Editor. And now, suddenly the whole way you access Editor has to change to call this one method. You have to borrow it differently, clone it, or pull it out of the struct entirely.
This is a good thing. It’s how Rust provides its guarantees. But notice what happened: a rule about one allocation started dictating how you structure everything around it.
This is totally worth it if your whole program needs those guarantees. But what if workspace is the only thing where performance actually matters?
One answer, if your appication is in something like Python, is to rewrite the workspace operations in something like C FFI. But you lose out on static types if you have to create the shims yourself.
Jac has a different approach. You can have managed memory for your entire application AND opt into ownership when performance is critical. How?
Jac’s ownership model
We’ll reuse this example for the rest of this post:
obj Image {
has pixels: list[int] = [];
}
def blur(src: &Image, radius: int, work: &mut list[int]) -> Image {
work.clear();
for pixel in src.pixels {
# simplified filter
work.append(pixel);
}
return Image(pixels=[p for p in work]);
}
def handle(images: list[Image]) -> list[Image] {
work: own list[int] = []; # the important bit
out: list[Image] = [];
for img in images {
out.append(blur(&img, &mut work));
}
return out;
}
The Image buffers are all managed. work is annotated own, which turns on borrow-checking for that one object. The rest of your code doesn’t need restructuring just because operating on pixel buffers is expensive.
blur borrows work, applies its function, the compiler knows it can deallocate work at the end of its lifetime. No RC allocations at all!
But what if I store an owned value into a managed one?
This is the part that confused me at first. Jac has what it calls the membrane between owned and managed which defines the rule for what happens then:
owned in managed -> ownership is surrendered
managed in owned -> uniqueness can't be assumed implicitly
In practice it looks like this:
obj Frame {
has canvas: Canvas = Canvas();
}
a: own Canvas = Canvas();
f = Frame();
f.canvas = a; # moved into managed storage
When you assign a into f.canvas (which is managed), a just becomes managed.
And if you don’t want that, then own types exist! Attributes in a managed object can be owned while it’s parent isn’t!
obj Layer {
has canvas: own Canvas;
}
Now, canvas’s lifetime is coupled with Layer, so you can assign owned instances while ensuring its lifetime only ends when you need it to.
Ownership as a spectrum
Once ownership is per-binding instead of a binary switch, it becomes an ownership spectrum.
| Mode | What it is |
|---|---|
| Default | reference counting + cycle collection |
| Annotated | You add own / &mut where you want; the compiler checks moves/borrows and can elide RC ops it proves unnecessary |
| Enforced | Heap values need an explicit ownership state |
| Headerless | Fully static cleanup |
And this is what makes it borrow checking, not just gradual ownership types. A value can start its life fully managed and slide toward compile-time cleanup as you need more performance.
This has explained how Jac manages expensive objects efficiently, but what if many lifetimes need to exist beyond a scope?
What if many allocations share the same lifetime?
own is great for one value. But sometimes you have expensive operations that don’t scope to just a single lifetime.
Jac has Regions, which is essentially an arena allocator. The in r {} construct lets you use it like so:
def process_batch() {
in r {
scratch = Canvas();
temp = Image();
}
# everything in there is reclaimed at once when `r` dies
}
Everything allocated inside in r belongs to that region. The Region reclaims the whole arena when the handle goes out of scope. So ownership can be as granular as a single binding or lexical scope, while still retaining memory safety and performance.
So what does this actually get you?
Jac’s ownership model does not achieve rust-parity; lifetimes aren’t included for example. But I think that’s the point. Most of the time I don’t want to think about memory at all. But when I find that one buffer that gets reallocated 3 thousand times a second, it’s nice to be able to just mark it as own and not have to borrow check the rest of my application too.
With Jac’s ownership model, you pick the level of control at binding-level granularity: where it actually matters. The rest of your code doesn’t know what a borrow checker is.