· 7 min read
Who gets the buffer next?
Make values affine by default and a plain collection type can give map, filter, sorting, and tree rewrites room to reuse memory.

Suppose a sensor writes -1 when it fails. I load an afternoon's readings, keep the nonnegative ones, and never look at the original batch again. An eager filter says what I want, but tends to build another vector. retain can compact the one I already have. I end up choosing a different verb partly to tell the runtime where the bytes may go.
The useful fact is mundane: this is the last use of the input. I would like to hand that fact to filter, while Vec<Int> keeps meaning “a vector of integers.” It need not grow an ownership adjective every time it crosses a function boundary.
Let use carry the ownership rule#
Here is the sketch of a language I have in mind. The syntax is invented, but the rule is familiar: a binding can be consumed once, and may be dropped without being consumed. That is the affine part.
fn inspect(read values: Vec<Int>) -> Unitfn filter(values: Vec<Int>, read keep: Int -> Bool) -> Vec<Int>let values = loadReadings()inspect(values)let kept = filter(values, n => n >= 0)inspect(values) // error: values was moved into filterA parameter marked read temporarily borrows its argument. An unmarked parameter takes it. The first call to inspect leaves values available; filter takes it, and the final call is rejected. The access rule is visible in the function signature, where callers need it, while both functions still talk about the same Vec<Int>. Reading a value many times is fine. The “once” applies to passing on ownership. Trivial scalars such as integers can remain copyable.
The checker still records whether each binding is available, borrowed, or moved. The value type stays Vec<Int>; a parameter mode tells callers what this particular call will do with their handle. If a read lasts beyond the call, its lifetime has to be accounted for as well.
Rust already makes moves and temporary borrows concrete, although its reference types spell out more of the borrowing arrangement. Its ownership chapter shows why moving a heap-backed value need not copy its allocation, and its borrowing chapter explains why an exclusive use must wait for other references to finish. My proposed change is in the surface of the API: make affine use the default for ordinary values, and put short-lived access modes on parameters instead of manufacturing a different collection type for each arrangement.
The old allocation is a good place for the answer#
Suppose the input is [7, -1, 4, -3, 9]. Once filter owns it, the result [7, 4, 9] can occupy the first three slots of the same buffer. A possible lowering for this integer example keeps one index for reading and one for writing:
write = 0for read in 0..values.length { n = values[read] if n >= 0 { values[write] = n write += 1 }}values.truncate(write)return valuesThe source operation can still return a new value. The compiler is free to make that value from old storage because no reader can observe the intervening writes. The order stays the same; the loop takes linear time and constant extra space. For a generic element type, it must also move survivors and dispose of rejected elements correctly, including when the predicate fails. The integer version keeps the memory argument in view without pretending those details disappear.
The same opportunity comes up often. A same-size map can overwrite elements as it walks the buffer. A partition can swap elements within it. A quicksort can sort the consumed array without another array, though its control stack and worst case still matter. On linked lists, reverse can turn each next pointer around, and merge sort can relink the existing nodes. A tree rotation can reuse the nodes it rearranges. Each algorithm has its own space story. They all receive storage no earlier observer can demand back.
The tree case is the one that made this click for me. A traversal has to remember where it came from. In the Perceus paper, a tree node can temporarily serve as a visitor frame, then become a tree node again on the way back. The visitor takes its working storage from the part of the tree it is visiting.
The compiler has to earn that reuse: it must know an old object is dead and that its cell fits the replacement. Lean's array documentation describes destructive updates when an array has one reference. Perceus pairs a consumed list node with a new node of the same size, so a functional map can refill the old cell. The FP² paper works through list reversal, sorting, and tree operations with explicit accounting for reusable cells. I would take that lesson into an ordinary collection API: a dead input object is a candidate for the next output.
Sharing spends the easy reuse#
Sometimes I do want the old readings. In this sketch, duplicating the value is an explicit operation. An implementation may copy the buffer there or let both handles share it until one needs a change.
let (before, work) = dup(values)let kept = filter(work, n => n >= 0)inspect(before)If dup shares storage, the two handles can have the same Vec<Int> type and still point at one allocation:
The before and work handles both point to one backing array after duplication.
- before: Vec<Int>: storage points to backing array
- work: Vec<Int>: storage points to backing array
- backing array: slots: 7, -1, 4, -3, 9
If dup shared the buffer, filter(work, ...) needs a uniqueness check or a fresh buffer. A persistent tree can copy just the path it changes. Explicit duplication marks the point where the cheap reuse option might disappear; the cost can land at dup or at a later write, depending on the representation.
A borrowed read is different. It creates a temporary observer and ends when that use ends; afterward the owner can move again. A view returned from a function, a pointer held by foreign code, or a reader on another thread lives longer and needs an explicit lifetime or sharing contract. Those relationships still belong in API contracts.
Leave room for the cases that need new memory#
Reuse has a shape requirement. A filter that shrinks a vector can keep its capacity. A map that changes element representation may need another buffer. Growing beyond capacity needs more storage. A stable pointer handed to outside code cannot silently be moved, and a graph with many incoming edges will need handles, an arena, or deliberate sharing. Some algorithms need scratch space even when they own their input; a stable array merge sort is a good example.
I want to write filter(readings, valid) and have the last use of an unshared value count for something. The bytes are already there. Let the implementation try to use them.