Skip to main content

String Becomes Copy-on-Write and the Last Legacy Dispatch Path Is Retired

· 8 min read
daily-progress: Part 10 of 11

Two days on the ownership substrate. Day one cleared the last legacy dispatch path out of the collections; day two made string itself a copy-on-write value, with &T borrows behind it.

HashMap loses its second dispatch path

For most of v0.4, the type checker had two ways to admit a HashMap method call. There was the new resolved-call path — the checker resolves map.insert(k, v) to a concrete impl method and records that resolution authoritatively. And there was the old one: a hand-curated allowlist of (K, V) pairs, each emitting a MethodCallRewrite that codegen would later turn back into a runtime symbol by name.

Both fired. That’s the part I’d been letting slide. Eight dispatch sites across HashMap (insert / get / remove / contains_key / len) and HashSet (insert / contains / remove / len) emitted both the resolved call and the legacy rewrite, on the theory that one of them would win and the other was harmless. Two parallel admission contracts for the same operation is exactly the kind of thing that looks harmless until they disagree.

The fix is to make record_resolved_hashmap_call / record_resolved_hashset_call the sole admission contract and delete the rest (e36e03ef). The per-K/V allowlist is gone. So are the resolve_hashmap_runtime_symbol / resolve_hashset_runtime_symbol helpers that turned a value type into a runtime symbol name, and the intermediate registry wrapper that nobody else called.

The interesting part is what replaced the allowlist. The old code asked “is K one of these specific primitive types I know how to hash?” The new code asks the marker resolver “does K satisfy Hash + Eq?” — which is the same question the impl declaration actually asks. So a HashMap<f64, _> no longer gets a stringly-typed "f64 is not supported"; it gets a structured BoundsNotSatisfied diagnostic that names f64 as the witness for the unsatisfied Hash bound. The checker now fails closed in the same way no matter how you reach it: a bare type annotation, a method call that goes through the resolver, or one of the methods that bypasses the resolver (is_empty, keys, values, clone, clear). None of those can escape as an unrewritten method call into codegen anymore.

While the allowlist was being deleted I also wired the primitive layout descriptors directly to the runtime’s shipped hew_layout_key_<prim> / hew_layout_val_<prim> statics. The old codegen tried to synthesise a hash thunk per type, which it couldn’t do for a string key — a string is an LLVM pointer, not an integer it could hash inline. So HashMap<string, i32> simply didn’t compile through that path. Now it does.

HashSet stops being a special case

The built-in HashSet<T> already existed, but there was also a 152-line Hew-source wrapper in std/collections/hashset/ — an opaque, non-generic handle with insert_int, insert_string, contains_int, contains_string, and a free() you had to call yourself. Two APIs for one data structure, and the wrapper was the worse one.

So the wrapper is deleted entirely (5f04f2e4). HashSet<T> is now the only HashSet, symmetric with HashMap, which never had a wrapper. The one live caller migrated from hashset.new() to HashSet<T>.new(), and built-in HashSet<T> releases through RAII — there’s no free() to forget. The migration turned up exactly one operation the built-in was missing that the callers actually used — is_empty — which is now wired through all six dispatch surfaces, mirroring the len shape that was already accepted.

Opaque handles

Between the collection cutover and the string work, a smaller piece of type-system honesty landed. The channel Sender and Receiver types are runtime handles. The compiler has no business looking inside them — they’re pointers to runtime-owned state, and the type parameter is managed by the compiler while the underlying handle is type-erased. Up to now there was no honest way to say that in the type system.

#[opaque] is that way (2502600d, end-to-end in f384e8be). An #[opaque] type declaration is classified BitCopy in the HIR, accepted at the codegen-readiness boundary, and lowered to a bare LLVM ptr — pointer-width and ABI-compatible with the runtime’s *mut T. The resolution is structural, not name-matching: the opaque names ride a set on the layout map, populated from the HIR, rather than codegen pattern-matching on the spelling Sender.

// A runtime handle whose innards the compiler never inspects.
#[opaque]
type Sender {
}
 
extern "C" {
    fn channel_send(tx: Sender, data: string);
}
 
fn forward(tx: Sender, msg: string) {
    // `tx` is BitCopy — passed by value, lowered to a raw pointer.
    unsafe {
        channel_send(tx, msg);
    }
}

There are accept and reject fixtures behind this: the FFI round-trip is accepted, while constructing one, accessing a field, or giving it a non-empty body is rejected. An opaque type is a handle you pass around, not a struct you build.

string becomes copy-on-write

This is the headline, and it’s the one that took the most care to land safely.

A string is a heap value. Until now, cloning one — every time you passed a string to a function, every time the runtime needed a second owner — deep-copied the backing buffer. Correct, but wasteful, and at odds with the value-semantics contract I want: when you pass a string, the callee gets its own value, full stop. The mechanism that makes “its own value” cheap is copy-on-write — share the buffer under an atomic refcount, and only fork a private copy when someone writes.

That can’t be bolted on. It needs the allocator to cooperate, so the first piece is a header-aware C-string allocator (f241b83b). Every allocation now carries a 16-byte header — [magic:u64 | rc:AtomicU32 | reserved] — and hands back data = base + HEADER_SIZE. free_cstring walks back to base, checks the u64 magic before freeing, and aborts loudly if it’s wrong. That catches header corruption and, as a side effect, a double-free, because the first free poisons the magic. It deliberately does not try to classify foreign or headerless pointers — reading the header of a pointer this allocator didn’t produce is itself out of bounds, so the contract is that only its own results come back to it. Allocation sizes are checked and capped at isize::MAX; an overflowing request fails closed to null instead of under-allocating.

At that commit the refcount is inert — always 1, reserved for later — so it’s observably a no-op. That’s on purpose. I wanted the allocator change to be provably behaviour-identical before anything started depending on the refcount.

Then the refcount goes live (8db4da75). hew_string_clone now routes through retain and returns the shared pointer instead of deep-copying; hew_string_drop routes through release, which decrements and frees only at the final owner. The first mutation calls ensure_unique, which forks a private copy if the count is above one. The String layout witness binds copy to clone and destroy to drop, so generated value handling uses the shared representation automatically.

Here’s what that looks like from the language:

fn main() {
    let original = "hello";
    var working = original.clone();
    working = working + " world";
    println(original);
    println(working);
}

original.clone() doesn’t copy the buffer — it bumps the refcount and hands working a second pointer to the same bytes. The two bindings physically alias. Then working = working + " world" forks: the write sees a refcount above one and copies before mutating, so original is untouched. The output is hello then hello world. The copy happens at the write, not the clone — that’s the whole point.

There’s a sharp edge I want on the record. The retain path guards against u32 refcount overflow: if the prior count ever crosses u32::MAX / 2, it aborts fail-loud, mirroring Arc. A wrapped refcount could free a buffer that’s still aliased, which is a use-after-free, and I would much rather abort than risk that. Drop-site emission — the compiler deciding exactly where to insert the drops — is deferred to the drop-elaboration phase; the mechanism here is exercised through the FFI boundary and the refcount unit tests, where the refcount ladder 1 → 2 → 1 → 0 is asserted directly so the tests fail if clone or drop were secretly inert. (Update, June 2026: drop elaboration has since landed — the compiler now emits the releases itself, for string and bytes ownership, collection and record and enum state-field overwrites, and plain-Vec scope exits.)

A day later the Vec string elements move onto the same discipline (cabffcd3). Pushing a string into a Vec now copies the producer’s buffer into a fresh header-bearing allocation; internal propagation retains via the same refcount; egress and drop release. The element-COW tests assert the same 1 → 2 → 1 → 0 ladder across clone/get/set/pop/truncate/free. hashmap and hashset string elements are still headerless bare strdup for now — that migration is owed — and the untyped generic Vec family, which can’t honour the refcount discipline, rejects a String-kind element fail-closed rather than half-managing it. (Update, June 2026: the owed migration landed — string keys and values in hashmap and hashset are header-aware end to end now, and there’s no bare strdup left anywhere in the map runtime.)

&T arrives, and refuses to lie

Value semantics by default raises an obvious question: how do you pass a string to a function that only reads it, without paying for a copy at all? The answer is a borrow — a non-owning, read-only view. The surface for it is &T (e2e1d492).

extern "C" {
    // Compute length of `s` without copying.
    fn hew_util_strlen(s: &string) -> i32;
    // Compare two strings without taking ownership of either.
    fn hew_util_streq(a: &string, b: &string) -> bool;
}

&T marks a parameter or return type as a non-owning immutable borrow: the callee gets a read-only view, no retain or release happens, and the borrow cannot outlive the owner. &mut T is rejected — there’s no exclusive-borrow surface in v0.5 — and the legacy &var T spelling is a parse error. Right now the marker is accepted in extern "C" signatures, where it’s the honest annotation for a *const T you don’t own. Transparent coercion at ordinary Hew call sites — passing a plain string where &string is expected — is follow-on work, so for the moment &T lives at the FFI boundary. (I’d rather ship the marker and the contract now, and wire the coercion rule when the rest of the ownership model is settled, than fake the coercion and have it mean nothing.)

The other half of this commit is a diagnostic that fails closed. The value-clone deep-copy path — the one that should eventually back a bare x.clone() on any heap value — isn’t wired yet. So an unresolved, zero-argument .clone() doesn’t silently return the same handle and quietly violate value semantics; it errors with CloneNotYetSupported. Already-resolved .clone() methods, like string’s own, compile and run exactly as before — that’s why the copy-on-write example above works. The intercept is narrow on purpose: it catches only the unresolved case that would otherwise produce a silent alias. No silent aliasing is the rule the whole value model rests on, so the gap announces itself instead of pretending.

Tuples and codepoints

Two smaller things landed in the same stretch.

Tuple literals lower end to end now (ddefae80). (1, 2) and tuple-returning functions materialise as stack-local struct aggregates with per-element stores, and the verifier checks the literal’s arity against the declared tuple type — a width mismatch is a CheckerBoundaryViolation, not a miscompile.

And string gets indexing and slicing with codepoint semantics, never byte semantics (9239294f). s[i] returns the i-th codepoint; s[a..b] walks codepoints and produces a freshly allocated string of that range. Both abort on a negative or out-of-bounds index rather than returning garbage. The distinction matters the moment a string isn’t ASCII:

fn main() -> i64 {
    let s: string = "héllo";
    let slice: string = s[1..2];
    slice.len()
}

"héllo" is five codepoints but six bytes — the é is two bytes in UTF-8. s[1..2] selects one codepoint, the é, and the re-encoded slice is two bytes long, so this returns 2. The bytes-value slice path and open-ended ranges like s[a..] are still deferred; the MIR emits the calls but codegen returns a fail-closed error if a bytes local ever materialises, so nothing can silently miscompile in the meantime.

(Update, June 2026: the semantics that shipped in v0.5.0 are byte-oriented throughout, not the codepoint surface described above — .len() counts bytes, s[i] and s[a..b] address byte offsets, and .find returns a byte-index i64 with −1 for absent. Open-ended s[a..] and bytes locals landed too. To walk codepoints, use .chars().)

The thread running through all of this is the same: one authority per decision, and a loud failure wherever the real mechanism isn’t wired yet. It’s the substrate the value-semantics model needs underneath it before any of that model becomes invisible to the person writing Hew.

Hew Notes

Language development, engineering articles, releases and community projects. About once a month, with an extra issue for a significant release. Written in English.

Subscribe to Hew Notes