Edit: Thanks to this Reddit reader for pointing out that the original comparison in this article was not effective. I’ve since updated the post to better illustrate what I’m trying to convey.
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:
obj Image {
has pixels: list[int] = [];
}
def blur(src: Image, radius: int, work: list[int]) -> Image {
work.clear();
work.append(0);
running = 0;
for pixel in src.pixels {
running += pixel;
work.append(running);
}
out: list[int] = [];
for i in range(len(src.pixels)) {
lo = max(0, i - radius);
hi = min(len(src.pixels), i + radius + 1);
out.append((work[hi] - work[lo]) // (hi - lo));
}
return Image(pixels=out);
}
def handle(images: list[Image], radius: int) -> list[Image] {
work: list[int] = [];
out: list[Image] = [];
for img in images {
out.append(blur(img, radius, work));
}
return out;
}
Everything here is managed. work is created once in handle, borrowed through Jac’s normal managed semantics, and reused for every image.
Here’s the same data flow in Rust:
struct Image {
pixels: Vec<u64>,
}
fn blur(src: &Image, radius: usize, work: &mut Vec<u64>) -> Image {
work.clear();
work.push(0);
let mut running = 0;
for &pixel in &src.pixels {
running += pixel;
work.push(running);
}
let mut out = Vec::with_capacity(src.pixels.len());
for i in 0..src.pixels.len() {
let lo = i.saturating_sub(radius);
let hi = (i + radius + 1).min(src.pixels.len());
out.push((work[hi] - work[lo]) / (hi - lo) as u64);
}
Image { pixels: out }
}
fn handle(images: &[Image], radius: usize) -> Vec<Image> {
let mut work = Vec::new();
let mut out = Vec::new();
for image in images {
out.push(blur(image, radius, &mut work));
}
out
}
Rust handles this example cleanly as well. The distinction is that ownership is the default for every Rust value. In the Jac version, images, Image, and out can stay managed while only work and the functions borrowing it participate in ownership checking.
But what if work is the only thing where performance actually matters?
Jac’s ownership model
We’ll reuse the same data model and operation. The ownership version changes the contract around work, but does not move the buffer or make blur perform a different job:
obj Image {
has pixels: list[int] = [];
}
def blur(src: Image, radius: int, work: &mut list[int]) -> Image {
work.clear();
work.append(0);
running = 0;
for pixel in src.pixels {
running += pixel;
work.append(running);
}
out: list[int] = [];
for i in range(len(src.pixels)) {
lo = max(0, i - radius);
hi = min(len(src.pixels), i + radius + 1);
out.append((work[hi] - work[lo]) // (hi - lo));
}
return Image(pixels=out);
}
def handle(images: list[Image], radius: int) -> list[Image] {
work: own list[int] = []; # the important bit
out: list[Image] = [];
for img in images {
out.append(blur(img, radius, &mut work));
}
return out;
}
The Image buffers are all managed. work is annotated own, which turns on ownership checking for that value. blur declares a mutable borrow, and the call site creates it with &mut work.
The function boundary and call site also need to state the borrow. But the data model, operation, and location of the buffer are identical. The rest of the application still doesn’t need to become ownership-checked.
The compiler can use ownership facts to elide operations it proves unnecessary, but marking this one binding own does not make the whole artifact reference-count-free.
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 image: Image = Image();
}
def store() {
image: own Image = Image();
frame = Frame();
frame.image = image; # moved into managed storage
}
When you assign image into frame.image, the value moves into managed storage. The original image binding can’t be used again.
And if you don’t want that, then own fields exist! Attributes in a managed object can be owned while its parent isn’t:
obj Layer {
has image: own Image = Image();
}
Now image stays in the owned world and its lifetime is coupled with Layer.
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 around the values you want checked; the compiler can elide RC operations it proves unnecessary |
| Enforced | Heap values in a module need an explicit or inferred ownership state |
| No GC | Static cleanup with no RC or collector runtime in the artifact |
And this is what makes it borrow checking, not just gradual ownership types. A codebase can start fully managed and move more bindings toward compile-time cleanup as needed.
This explains the single-value case, but what if many allocations need to share the same lifetime?
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 Region, which is essentially an arena allocator. An anonymous region looks like this:
def process_batch() -> int {
result = 0;
in Region() {
scratch = Image(pixels=[1, 2, 3]);
temp = Image(pixels=[4, 5, 6]);
result = len(scratch.pixels) + len(temp.pixels);
}
return result;
}
Everything allocated inside in Region() {} belongs to that region and is reclaimed together when the block ends.
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.