Mutable Value Semantics (MVS) or Ownership & Borrowing: A Trade-off Analysis
Edit: Take a look at the discussion in the Programming Languages subreddit.
This is the second post in the series dedicated to the Eter programming language, a side project I dedicate my free time to. The previous post is The Mutable Value Semantics (MVS): A Non-superficial Study. As the former, this post is not part of the Eter language itself. It's currently in the design and development phase. For the moment, this series (including this post) should be considered an exploration of ideas that will serve as a foundation for the language. No prior knowledge of Eter, or of its motivations, is required to read these early posts. However, if you are interested in the project, you can find more information in the Eter compiler monorepo. The Eter language reference and the Eter Doxygen documentation, although not yet complete, are also good starting points.
The first post, in its prelude, introduces what we are currently researching for the Eter language. It discusses the mutable value semantics (MVS) and how the popular programming languages (e.g., Swift , Hylo ) implement it. I strongly suggest you read the first post if you are not familiar with the MVS model.
We left it at that, saying that I wasn't 100% satisfied with these approaches, but without specifying exactly what bothered me. That's why I'm writing this post.
A premise
I just want to say two things before we start. First, this post is not intended as a dismissal of existing approaches—each language's choices are coherent with its design goals, and behind them are many researchers and experts who have worked on these topics for decades. I'm simply exploring the ideas that will serve as a foundation for the Eter language. Second, for now, I'm just thinking things through, and this post might seem like a list of problems and the solutions I'd like to see—without actually offering any solutions: after all, that's exactly the case, and I don't yet know if there will be any. The next post, hopefully, will provide a first sketch.
Introduction
The safety of a programming language has been a topic of interest since time immemorial. In recent years, the focus was on the type system and the static analysis rather than garbage collection strategies or automatic region-based memory management . In 1987, Girard proposed linear logic. Three years later, Wadler said: "Linear types can change the world!". He didn't get very far. After all, Rust has a substructural type system .1 Specifically, it boasts an affine type system with polymorphic regions (lifetimes) where the references are first-class citizens. The borrow checker statically detects aliasing violations, ensuring there is only one owner of a value at any given time, and so on. The MVS model opposes the Ownership & Borrowing model but shares the same goal: memory safety and data race freedom. In Hylo, references are second-class elements that are not observable to the programmer, and the language advocates for value independence to support local reasoning.
Rust's friction points: does it have any?
The MVS researchers would say yes: they argue that lifetimes complicate the type system, and they are not alone in their opinion. I have a deep love for the C and OCaml programming languages, so I couldn't help but fall in love with Rust as well. However, I partially agree with the MVS researchers. The Ownership & Borrowing model has a steep learning curve, and Rust is often considered harder to pick up than languages with a GC. Consider the following illegal Rust code:
let s1: &str = "Hello";
let s2: &str = " World";
let s3: &str = s1 + s2; // ERROR: cannot add `&str` to `&str`
The + operator calls fn add(self, s: &str) -> String, which takes self by value. Since s1: &str is a reference to a borrowed string, it cannot be consumed; you would need s1.to_string() + s2 instead.
Also, the following illegal Rust code shows how the lifetimes come into play:
fn identity(x: &str) -> &str { x }
fn longest(x: &str, y: &str) -> &str { // ERROR: expected named lifetime parameter
if x.len() > y.len() { x } else { y }
}
While for the identity function, the compiler can infer the lifetimes, for the longest function, the compiler cannot due to the Lifetime Elision Rules.
If the compiler tries to associate the lifetimes at each reference, the following code would be produced:
fn longest<'1, '2>(x: &'1 str, y: &'2 str) -> &'? str { ... }
.
But an ambiguity arises during the lifetime inference of the return type.
Of course, the programmer can just annotate each reference with the same lifetime (e.g., 'a) to guarantee the soundness.
Let me complicate things a bit more. Consider the following illegal Rust code:
fn call<F>(f: F, e: &u8) -> &u8
where F: Fn(&u8, &u8) -> &u8
{ f(e, e) }
The aim of the higher-order call function is to invoke the function f with the same argument.
As before, the compiler must reject this code due to the lifetimes.
But we can try to fix manually the problem:
fn call<'a, F>(f: F, e: &'a u8) -> &'a u8
where F: Fn(&u8, &u8) -> &u8
{ f(e, e) }
The purpose of this code is: "It doesn't matter what the lifetime of the argument e: &u8 is, I want to call the function f with the same argument regardless."
But unfortunately, the compiler must reject also this code since 'a is not universally quantified.
It requires a Higher-Rank Trait Bound (HRTB) to fix the problem.
Consider the following code:
fn call<F>(f: F, e: &u8) -> &u8
where F: for<'a> Fn(&'a u8, &'a u8) -> &'a u8
{ f(e, e) }
The for<'a> keyword introduces a HRTB that allows the compiler to resolve via the de Bruijn indices every call site.
In the previous post, I mentioned that the following Rust code (without the highlight lines) would be valid in Hylo:
#[derive(Debug)] struct T;
fn own_t(t: T) {
panic!()
}
fn ref_mut_t(t: &mut T) {
own_t(*t); // ERROR: cannot move out of `*t` which
// is behind a mutable reference
std::panic::catch_unwind(|| { println!("{:?}", t) }).unwrap();
*t = T;
}
In this example, as SkiFire13 pointed out, the Rust compiler must reject the code since the ref_mut_t could catch the panic and then read the mutable reference pointing to an invalid memory location.
While it's actually not ubiquitous, I don't personally like this approach.
Languages like Hylo and Swift explicitly mark the function with throws to indicate that the function can throw an exception.
While I understand the motivation, we could easily live without panic-catching in this context—errors can be handled as values...
The current implementation of the Rust compiler is not capable of specializing generic functions (Bruzzone and Cazzola tried to address this issue in the [preprint]). To date, the specialization feature remains confined to the nightly channel, as stabilization attempts have stalled due to potential soundness issues and implementation complexities . Consider the following invalid code:
trait Trait { fn f(&self); }
impl<T> Trait for T { default fn f(&self) {} }
impl<T: 'static> Trait for T { fn f(&self) {} }
The core issue was that specialized implementations could inadvertently violate expected lifetime constraints, leading to dangling references and other memory safety vulnerabilities. This problem arises because lifetimes are erased before code generation (specifically, during the MIR-to-LLVM IR lowering), preventing the specialized implementation from being correctly monomorphized with respect to lifetime parameters.
At a given call site, e.g., "foo".f(), the compiler is not able to determine which implementation of f is applicable.
I can narrow down the list of limitations I'm running into to just the more trivial ones. Consider the following valid snippet:
fn double_all(data: &mut Vec<i32>) {
for x in data.iter_mut() {
*x *= 2;
}
}
fn main() {
let mut v: Vec<i32> = vec![1, 2, 3];
double_all(&mut v);
}
There are three distinct flavors of mutability here: let mut v marks the binding as mutable,
&mut v passes a mutable reference,
and iter_mut() combined with the dereference *x allows writing through the iterator.
Each level requires its own explicit annotation—even when the overall intent (mutate every element in the pipeline) is clear from context.
A subtler limitation appears when dealing with split mutable borrows on arrays.
The following code is illegal even though i and j are statically proven to be different:
const fn get<const T: usize>() -> usize { T }
fn f(a: &mut i32, b: &mut i32) { }
fn main() {
let mut a: [i32; 2] = [0, 1];
const i: usize = get::<0>();
const j: usize = get::<1>();
f(&mut a[i], &mut a[j]); // ERROR: cannot borrow `a` as mutable more than once
}
The borrow checker reasons about paths (a[_]), not values (0, 1).
Both &mut a[i] and &mut a[j] are seen as mutable borrows of a, regardless of whether the indices are provably disjoint.
The canonical workaround is a.split_at_mut(1), or unsafe code, both of which shift the burden of proof onto the programmer.
Of course, the previous code would not compile also if we manually constant-propagated the indices.
Self-referential structs are another source of friction.
Suppose you want a Parser that holds both the input string and a slice pointing into it:
struct Parser {
input: String,
current: &str, // ERROR: missing lifetime specifier
}
There is no way to express "the lifetime of current is tied to self.input" in safe Rust.
The usual workaround is to replace the reference with an index:
struct Parser {
input: String,
pos: usize, // index instead of a reference into `input`
}
This is safe and idiomatic, but it trades a static guarantee for a runtime invariant:
the programmer must ensure pos always stays in bounds.
The alternative, raw pointers with unsafe, restores the direct reference but abandons the borrow checker entirely.
Finally, graphs and doubly-linked lists expose the limits of exclusive ownership most starkly. A naïve attempt fails immediately:
fn main() {
let mut a = Node { value: 1, next: None, prev: None };
let mut b = Node { value: 2, next: None, prev: None };
a.next = Some(Box::new(b)); // b is moved
b.prev = Some(Box::new(a)); // ERROR: use of moved value: `b`
}
Box<T> enforces exclusive ownership, so two nodes cannot mutually own each other.
The idiomatic Rust solution requires layering four distinct wrappers:
use std::rc::{Rc, Weak};
use std::cell::RefCell;
struct Node {
value: i32,
next: Option<Rc<RefCell<Node>>>, // shared ownership + interior mutability
prev: Option<Weak<RefCell<Node>>>, // weak reference to break cycles
}
Rc provides shared ownership via reference counting: each Rc clone increments the strong count, and the value is dropped only when it reaches zero.
However, Rc alone would create a cycle (node A keeps B alive, B keeps A alive) that never reaches zero — a memory leak.
Weak breaks the cycle: cloning a Weak increments the weak count without affecting the strong count, so it observes the value without keeping it alive.
When all strong references are dropped, the weak pointer becomes dangling and upgrade() returns None.
When opting into these wrappers, the static guarantees of the borrow checker are traded for runtime panics.
MVS to the rescue? Let's examine.
It is natural to ask: does MVS solve them? The two most prominent MVS languages today are Hylo and Swift. The answer is nuanced: some problems vanish by design, some are merely reframed, and a few persist regardless of the memory model. More interestingly, both approaches introduce friction points of their own.
Hylo
Hylo is built around MVS with one key design choice: references are second-class. Concretely, this means references cannot be returned from a function, stored in a data structure, or assigned to a variable on their own—they are always tied to their source's scope. Hylo defines five parameter passing conventions: let (immutable access, default), inout (mutable exclusive access), sink (consuming/ownership transfer), set (output-only, initializes a dead binding), and yielded (only for subscript coroutines).
In addition to let and var bindings, Hylo allows for inout and sink let/sink var bindings (see Hylo's specification).
To see why this matters, ask: what does the convention tell you about how you can use the parameter?
For let, the answer is immediate: read only, full stop.
For inout, equally clear: mutable, and you signal every write with &.
In both cases the convention is the complete answer.
Now try sink. It says "ownership is transferred to you; you get a local copy." Fine, but then what? Can you modify it?
The convention alone doesn't tell you. So Hylo needs a second keyword:
sink let x means "I own it and it's frozen", sink var x means "I own it and I can change it" (the var keyword can be omitted).
Understanding a single parameter thus involves two pieces of information: the convention and the binding kind.
The same split appears with inout local bindings, which introduce a projection alias alongside the binding kind.
Subscripts introduce a fifth convention, yielded, with its own rules.
When a subscript coroutine suspends and hands a projection back to the caller, that projection can be used directly (read it, write through it, pass it to an inout parameter), but if you need to forward it further to something that will itself re-yield it, the receiving parameter must be declared yielded, not inout.
A projection yielded by a subscript coroutine can be used as an inout argument to regular functions, but forwarding it through another coroutine that will itself re-yield it requires the receiving parameter to be declared yielded rather than inout.
This is a natural consequence of static safety: the compiler must track how every piece of data was obtained, not just what it is.
The programmer must handle these cases explicitly, for instance:
type T {
fun f() -> Self {
let { /* ... */ }
inout { /* ... */ }
sink { /* ... */ }
set { /* ... */ }
}
}
Projections of any kind cannot be stored in data structures. This has a deeper consequence than it might seem: any data structure that traditionally relies on internal pointers or references—linked lists, trees, graphs—must be redesigned. The standard workaround is to replace pointers with indices into an owner array (an "arena" or "pool"). Instead of nodes that point to each other, you keep all nodes in a flat array and store integer indices:
// Hylo: projections cannot live in structs, so use indices instead
type Node {
var value: Int
var next: Int // index into the pool, -1 = none
var prev: Int // index into the pool, -1 = none
}
type DoublyLinkedList {
var pool: Array<Node>
var head: Int // index of first node, -1 = empty list
var tail: Int // index of last node
}
The list owns all its nodes outright: no cycles, no shared ownership, no reference counting. The trade-off is that indices are plain integers: the compiler cannot verify that an index actually points to a valid, live node, so a stale index is a logic error rather than a type error. This is a well-known pattern in systems programming (arenas, slot maps, generational indices), and Racordon has explored the ownership implications in the paper "Who Owns the Contents of a Doubly-Linked List?" (I was a spectator of the talk at the <programming>'25 conference in Prague).
However, this design directly eliminates the first three Rust friction points.
Lifetime elision, HRTB, and the string concatenation asymmetry all stem from first-class references: because Rust
references can be returned from functions and stored anywhere, the compiler must track their validity (lifetimes) and
quantify over them (HRTB). In Hylo, you cannot return a projection of a parameter, so the Rust
longest pattern is simply inexpressible.
To be clear about what Rust's version actually does: with 'a, the caller
keeps both strings alive and gets back a zero-cost reference.
Hylo offers no equivalent. The closest approximations each give something up:
// `let`: caller keeps both strings, but the return is a copy (allocation cost)
fun longest(_ x: let String, _ y: let String) -> String {
if x.count() > y.count() { x } else { y }
}
// `sink`: no copy (move), but caller loses both strings
fun longest(_ x: sink String, _ y: sink String) -> String {
if x.count() > y.count() { x } else { y }
}
I really like the Hylo approach, but it is not a panacea. Of course, in the second case, since the two strings are moved, the stack allocation could be reused (LLVM performs this optimization via NRVO/RVO/Copy Elision), but that's not the point.
Hylo does not have a catch_unwind-equivalent for the same reason: recovering from a panic while a projection is active could leave dangling references. Functions that can fail use throws instead.
Mutability verbosity is reframed.
In Hylo, parameter conventions carry the access intent and the call site must mark mutation with &.
The annotation model is different from Rust's let mut / &mut T / iter_mut(),
but not obviously lighter—it is a trade-off between two mental models.
More importantly, Hylo still has var and let at the binding level, independently from the
parameter convention.
From my POV, the convention should be the single source of truth for mutability—but that's a matter of taste.
To see the difference concretely, compare the Rust double_all from earlier
with the manual-index Hylo equivalent—the direct counterpart without syntactic sugar:
fun double_all(_ data: inout Array<Int>) {
var i = data.start_position()
while i != data.end_position() {
&data[i] *= 2
&i += 1
}
}
public fun main() {
var v: Array<Int> = [1, 2, 3]
double_all(&v)
}
The real syntactic differences from Rust's manual-index version are:
inout in the signature instead of &mut,
and & required before every mutation site
(&data[i] *= 2, &i += 1).
The separate var i for the loop counter still needs its own keyword, a small but concrete illustration of the redundancy noted above.
For split borrows on struct fields, Hylo and Rust are roughly equivalent: both allow simultaneously projecting
disjoint paths (&p.x and &p.y at the same time), and both struggle with dynamic array indices.
The reason is that an expression like v[i] is a subscript call taking the whole array
as an inout parameter, so the compiler conservatively treats it as a projection of the
entire buffer [discussion].
Fixed-size arrays are syntactic sugar for tuples, so field accesses like &v.0 and
&v.1 are recognized as disjoint; the language may eventually extend similar
reasoning to statically-known subscript indices. For dynamic cases, the standard library provides
split_at to express disjoint sub-regions in the type system, and
positionless algorithms are being
explored as an alternative approach.
What Hylo's subscript coroutines genuinely add is a different capability:
the ability to yield a computed value mutably, with automatic write-back after the caller is done.
Consider a type that stores an angle in radians but exposes it in degrees:
type Angle {
var radians: Float64
property degrees: Float64 {
let { yield radians * 180.0 / Float64.pi() }
inout {
var d = radians * 180.0 / Float64.pi()
yield &d // yield a temporary
&radians = d * Float64.pi() / 180.0 // write-back after caller mutates
}
}
}
public fun main() {
var a = Angle(radians: Float64.pi())
&a.degrees += 10.0 // reads, mutates, writes back, all through a computed view
}
In Rust, a &mut reference must point to an existing memory location—you cannot return a mutable reference to a computed temporary.
The equivalent would require a separate setter method or a callback, neither of which composes with += directly.
Hylo's coroutine model makes this work: the subscript suspends at yield, the caller mutates the projected value, and then the subscript resumes to perform any cleanup.
The law of exclusivity is enforced statically via dead-binding tracking.
A subscript declaration in Hylo contains one or more named implementations:
let yields an immutable projection,
inout yields a mutable one,
sink behaves like a function and returns a value by consuming the subscript, and
set initializes or overwrites the subscripted location.
Both let and inout are coroutines: the body suspends at
yield, the caller uses the projection, then the body resumes for any cleanup.
The spec mandates that each such body must contain exactly one yield on every
terminating execution path: this is a compile-time invariant.
When only a subset of implementations is given, the compiler synthesizes the rest where semantically sound:
an inout body implies let
(read through the same projection) and sink
(move the yielded value out of its location).
Here is a minimal example with explicit implementations:
type Counter {
var value: Int
subscript current: Int {
let { yield value } // immutable projection — object locked for reading
inout { yield &value } // mutable projection — object locked for writing
}
}
public fun main() {
var c = Counter(value: 0)
let v = c.current // invokes `let` — locks `value` immutably, released at end of expression
&c.current += 1 // invokes `inout` — locks `value` mutably, released at end of expression
}
The caller never touches a raw pointer; it gets a temporary, scope-bound window onto the data.
The compiler knows statically which object is locked and for how long,
which is what makes non-overlapping simultaneous projections (e.g., &p.x and &p.y)
safe to allow.
However, the second-class restriction creates its own friction. Hylo does support slices (range subscripting) and
closures with inout captures, so those specific patterns are not impossible.
The real tension appears when you need an external iterator—an object that yields mutable projections on successive
next() calls.
Such an iterator would need to hold an exclusive projection into the collection across call boundaries, which would lock
the collection for the iterator's entire lifetime.
Because projections cannot be stored in a struct and independently returned from a function, the external iterator
pattern requires special language machinery (e.g., subscript coroutines or a "single projection rule for methods").
Hylo currently addresses this through internal iteration (closures passed to collection methods)—dubbed as inversion of control—and through lazy views with method chaining (e.g., data.lazy[].filter(...).max_index()), which provide a similar pipeline style without storing projections across calls.
The generic problem is also real. If you want a type like Optional<T> where T can
be either a value or a projection, the second-class restriction forces you to duplicate the type:
Optional<T> for values, and a separate variant for projections.
This is the kind of complexity that can accumulate across an entire standard library.
Finally, if the language tries to recover expressiveness through implicit lifetime inference (the compiler silently
extends a projection's lifetime to make something work), changing an internal implementation detail can silently
alter the invisible contract of a public function and break callers.
Let me make these design characteristics concrete with five short examples.
(1) The return problem
A regular Hylo function cannot return a projection of one of its parameters.
The shape that Rust writes as fn max_mut(v: &mut [i32]) -> &mut i32
simply has no equivalent free-function form:
// Does NOT compile in Hylo:
// regular functions cannot return a projection.
fun max_element(_ arr: inout Array<Int>) -> inout Int {
return &arr[0]
}
Hylo recovers expressiveness via subscripts, which are coroutines that yield a projection
instead of returning a value:
type Stats {
var data: Array<Int>
subscript max: Int {
inout {
var best = 0
var i = 1
while i != data.count() {
if data[i] > data[best] { &best = i }
&i += 1
}
yield &data[best] // exactly one yield on every terminating path
}
}
}
public fun main() {
var s = Stats(data: [3, 7, 1, 9, 4])
&s.max += 100 // mutates the maximum element in place
}
This works, but it introduces a separate language feature with its own syntax: a function and a subscript are
not interchangeable, and each comes with its own rules.
That said, composability is not compromised: subscripts can be defined as free functions
(not just members of a type), and projections compose naturally—a projected element can
be bound to a local inout variable and passed to any other subscript or function
expecting an inout parameter [discussion]:
subscript min<T is Comparable>(xs: auto T[]) -> T {
inout {
var k = 0
for var i in 1 ..< xs.count() where xs[i] < xs[k] { &k = i }
yield &xs[k]
}
}
public fun main() {
var data: Array<Int> = [3, 7, 1, 9, 4]
inout x = &min(&data) // free-function subscript yields a projection
&x += 100 // that projection composes with regular functions
}
Moreover, ad-hoc filtering can be expressed through method chaining using lazy views and indices, avoiding deeply nested subscripts:
public fun main() {
var data: Array<Int> = [3, 7, 1, 9, 4]
var sum = 0
if let target? = data.lazy[].filter(fun (x) {
&sum += x; return x < 10
}).max_index() {
&data[target] += 100 // mutate via index, not reference
}
}
This separates "knowledge of some data" from "access to that data": the lazy pipeline computes which element to target, while mutation happens directly on the owner.
(2) Closures with mutable captures
A closure that captures a binding mutably is, internally, a value that holds an inout projection. Hylo allows this, but the captured object is exclusively locked for the entire lifetime of the closure value—so common callback patterns become surprisingly constrained:
public fun main() {
var counter = 0
let increment = fun() { &counter += 1 }
increment() // OK: counter becomes 1
print(counter) // ERROR (current compiler): `counter` is projected mutably
// by `increment`, which is still alive in scope
}
This is a current compiler limitation: the compiler conservatively extends the lifetime of captured projections to the entire lexical scope of the closure rather than using non-lexical lifetimes (NLL). The Hylo team has stated that this limitation will be lifted eventually [discussion], so the sequential-use pattern (capture, invoke, then read) will work once the lifetime analysis improves.
However, patterns like "register two callbacks that each update a shared accumulator" cannot be expressed by simply capturing the same variable in two closures, even if the two callbacks are never active concurrently: the second capture would violate uniqueness. This is not a compiler limitation but a consequence of the MVS model itself—shared mutable state is fundamentally disallowed because it breaks local reasoning.
This distinction reflects a deliberate design tension.
The standard sequential NLL—where a closure's projection is released after its last
use—is planned and covers most practical cases.
But chasing maximal compiler cleverness to prove that two closures are never active
simultaneously across arbitrary control flow would add significant complexity to the
type system, making correctness sensitive to small, non-local changes in the code.
The MVS philosophy prioritizes local reasoning: the programmer should be able to
understand exclusivity by inspecting the immediate scope, not by tracing every possible
execution path.
As the Hylo team puts it, uniqueness is not only useful to guarantee safety, it also
helps humans apply local reasoning
.
A related constraint governs escaping closures—closures that outlive the scope in which they are created.
A closure that captures an inout projection cannot escape: the projection would become dangling once the source goes out of scope.
A closure that needs to escape must instead capture by value, storing copies of its captures in a heap-allocated environment.
This is related to a more general mechanism in Hylo called remote parts: data structures can store projections by declaring fields with a remote type, but types with remote parts are not movable and are always stack-bound [discussion].
The compiler tracks their lifetimes through a lightweight analysis rooted at bindings rather than types: when a projection is captured or stored, it extends the live-range of its source, preventing conflicting mutations for the duration.
Hylo makes this distinction explicit in the type of the closure itself:
// Non-escaping closure: may capture inout projections (stack-bound)
fun double_in_place(_ data: inout Array<Int>) {
data.for_each(fun (_ x: inout Int) { &x *= 2 })
// closure does not escape, projection onto `data` is safe
}
// Escaping closure: must capture by value (heap-allocated environment)
fun make_adder(_ n: Int) -> [](Int) -> Int {
return [let captured = n](x: Int) -> Int { x + captured }
// `captured` is an owned copy living in the heap-allocated environment
}
The programmer must decide at the point of writing the closure whether it will escape—and that decision determines what it is allowed to capture. This is a natural consequence of keeping projections safe from dangling: the type system must distinguish the two cases, and the programmer must be aware of which one applies.
(3) Iterators
The Rust pattern trait Iterator { fn next(&mut self) -> Option<&mut T>; }
is fundamentally incompatible with second-class projections: an external iterator object would need to
store an inout projection of its collection across next() calls, but projections cannot
live inside a struct.
Hylo offers two alternative routes, neither of which matches Rust's pipeline composability:
// Route A: internal iteration via collection methods
public fun main() {
var arr: Array<Int> = [1, 2, 3]
arr.for_each(fun (_ x: inout Int) { &x *= 2 })
// arr is now [2, 4, 6]
}
// Route B: subscript coroutines (one method per pattern)
type Cursor {
var data: Array<Int>
var i: Int
subscript next: Int {
inout {
let k = i
&i += 1
yield &data[k] // one yield per path
}
}
}
In Rust you write v.iter_mut().filter(p).map(f).sum() and the
iterators compose generically.
Hylo supports the same pattern through lazy views: data.lazy[].filter(...).max_index()
composes operations via method chaining, with each step returning a new lazy projection.
However, the architecture differs: Rust passes mutable references through the adapter chain,
while Hylo separates knowledge from access—the lazy pipeline computes which element
to target (e.g., an index), and mutation happens directly through the original owner.
(4) Slices
Hylo does have slices: a range subscript yields a projection of a contiguous sub-region of an array. That works at the call site—but the same return-problem from (1) applies, so you cannot factor a slicing operation into a free function:
// At the call site, range subscripts yield a slice projection:
public fun main() {
var arr: Array<Int> = [1, 2, 3, 4, 5]
inout view = &arr[1 ..< 4]
&view[0] *= 10 // arr is now [1, 20, 3, 4, 5]
}
// But you cannot encapsulate a slicing operation as a regular function:
// fun middle(_ a: inout Array<Int>) -> inout Slice<Int> {
// return &a[1 ..< a.count() - 1] // ERROR: cannot return a projection
// }
The fix is, again, a subscript on a wrapper type—which spreads what could be a single library abstraction across two language constructs (function and subscript).
(5) Generics over projections
Type parameters in Hylo range over value types. A generic Optional<T>
cannot be instantiated where T stands for a projection, because projections are not values:
// Works for values:
let answer: Optional<Int> = .some(42)
// Does NOT work — there is no `Optional<inout Int>`:
fun first_or_none(_ arr: inout Array<Int>) -> Optional<inout Int> { ... }
The standard library is therefore forced to ship two parallel hierarchies—one for values and one for projections—or a single value-flavored hierarchy that silently copies on access. Both options introduce additional complexity across every generic abstraction (containers, options, results, futures, etc.): each one must decide whether it speaks values, projections, or both.
Swift
Swift takes a different path.
Rather than enforcing a single memory model, Swift separates value types (struct, enum)
from reference types (class).
Value types have value semantics: assignment copies the value, and the standard library implements copy-on-write (COW)
internally so that copies are cheap in practice.
Swift also has inout parameters with the same & call-site marker as Hylo,
and since Swift 5.9 it has gained borrowing (immutable access, no copy) and consuming
(ownership transfer) parameter modifiers, plus ~Copyable for types that cannot be implicitly duplicated.
These additions bring Swift much closer to Rust's ownership model on the surface.
The surface syntax for mutation is familiar: inout in the signature, & at the call site.
Here is the Swift counterpart to the Rust and Hylo double_all shown earlier:
func doubleAll(_ data: inout [Int]) {
for i in data.indices { data[i] *= 2 }
}
var v = [1, 2, 3]
doubleAll(&v)
No iter_mut(), no explicit dereference.
The array is a value type with COW semantics: var b = v shares the underlying storage
until either is mutated, at which point Swift makes a copy transparently.
The programmer observes value semantics; the compiler keeps it efficient.
The critical difference from both Rust and Hylo is how Swift enforces exclusivity. Swift applies its law of exclusivity both statically and at runtime. For local variables the compiler can often verify exclusivity statically, but for class properties, global variables, and captures in closures the compiler cannot always prove safety, so it inserts runtime checks that abort with a diagnostic message on violation. The following code compiles without warnings and crashes at runtime (Compiler Explorer):
func moveElements(from src: inout Set<String>,
to dest: inout Set<String>) {
while let e = src.popFirst() { dest.insert(e) }
}
class Names { var pool: Set<String> = ["a", "b"] }
let names = Names()
moveElements(from: &names.pool, to: &names.pool)
// Runtime crash: simultaneous accesses to 0x...,
// but modification requires exclusive access.
This is a fundamentally weaker guarantee than Rust's or Hylo's purely static enforcement: the error is discovered in production, not at the keyboard.
The deeper issue is the class escape valve.
Whenever value semantics cannot express a pattern—graphs, doubly-linked lists, observer callbacks, shared caches—Swift programmers reach for class.
Classes have reference semantics: two variables can alias the same object, mutations are visible through all aliases,
and the memory is managed by ARC (automatic reference counting).
ARC is safe, but it is not free: every strong reference write is a reference-counted operation, and—unlike Rust's
borrow checker—ARC cannot prevent reference cycles.
A cycle between two class instances will never be collected; the programmer must manually break it with
weak or unowned references:
class Node {
var value: Int
var next: Node? // strong: keeps next alive
var prev: Node? // LEAK: cycle if both nodes point to each other
var prev: Weak<Node>? // correct: weak reference breaks the cycle
init(_ v: Int) { value = v }
}
The moment you use class, you exit the value semantics model entirely.
There is no borrow checker watching class references; aliasing and mutation happen freely, and correctness
is back to being a runtime property.
1 Linear type systems are a particular form of substructural type systems. Among exchange, weakening and contraction, only the former is allowed. ↩