Rust’s borrow checker is genuinely great. It’s memory safe without making reference counting at runtime.
But it also comes with a deal you can’t really opt out of. Everything needs to be borrow checked. Whether you need it or not.
What if you don’t need your whole program to be borrow-checked?
What if it’s just… one allocation?
Here’s 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 that workspace is the only thing where performance actually matters?
One answer, 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 the same little 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. Only work is annotated own. The rest of your code doesn’t need to be restructured 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’s a spectrum.
| Mode | What it is |
|---|---|
| Default | Everything managed — reference counting + cycle collection, just works |
| 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 of multiple elements?
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 an arena you can scope with the in r {} construct:
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 what does this actually get you?
Most of the time I don’t think about memory at all, and I don’t want to. But when I find that one buffer that gets hammered, it’s nice to be able to just mark it as own and keep going.
Jac lets you pick the level of control at the binding-level granularity: where it actually matters. The rest of your doesn’t even know what a borrow checker is.