Aliased Xor Mutable core for a high-level language

I have, I believe, discovered a way to use lifetimes and the Alias Xor Mutate rule to infer when references can be used and falling back to clones, without any borrow or lifetime annotations, which allows the creation of a high-level language with similar safety and performance guarantees as Rust.

fn main() {
    let mut point = Point { x: 3, y: 4 };
    point = double(point);
    dbg!(point);
}

fn double(p: mut Point) -> Point {
    p.x = p.x * 2;
    p.y = p.y * 2;
    p
}

struct Point {
    x: i32,
    y: i32,
}

Transferring ownership back and forth looks like a high-level language, such as Python or Javascript, in the sense of not having any reference annotations. Ownership also means that there are no lifetime errors or annotations to deal with, at least when using mutable parameters. This is a good foundation for removing mutable borrows (&mut).

Ownership is purely conceptual, it is not something you can see in a disassembler.

parasyte (on Rust User forums)

Content about Rust might imply that moves must copy data to a new memory location, while mutable borrows don't; however, that's not truly the case. Sometimes that might occur, especially for smaller values, but being explicit about that is not a concern for a high-level programmer. It is good enough to have the language figure out when it can reuse memory. The following link explains how a move in Rust can compile to the equivalent of a mutable borrow.

Whether “move” operation on a stack value is cheap?

This means mutable borrows can be replaced by moving ownership with only the explicitness cost.

fn main() {
    let mut point = Point { x: 2, y: 7 };
    show(point);
    point = double(point);
    show(point);
}

fn show(p: Point) {
    println!("  {}", p.y);
    println!("  |");
    println!("x +--{}", p.x);
    println!("  y");
}

If mutable moves can be inferred as borrows, immutable moves can be also be inferred as borrows when passing immutable parameters, so that variables can be reused even after passing them to functions. Where the parameter is returned and the function is called at the end of the current scope, or lifetime, it can transfer ownership instead, as in the following example.

fn main() {
    let outer = Point { .. };
    
    let final = {
        let inner = Point { .. };
        further(inner, outer)
    };

    show(final);
}

// Further from (0, 0)
further(p1: Point, p2: Point) -> Point {
    let origin = Point { x: 0, y: 0 };
    let l1 = line(origin, p1);
    let l2 = line(origin, p2);

    if l1 > l2 { p1 } else { p2 }
}

With these two changes, users don't have to deal with lifetimes.

fn drop(f: move SpecialFile) {
    // Code to close file.
}

A move annotation can be used for cases where you want to ensure a resource is closed and not reused. This can be inferred for parameters that are passed to functions that take ownership, such as built-ins and standard library functions.

After all that, people will still need to manage all their clones if we leave it there. While there's something to be said for keeping the costs explicit, the goal of a high-level language is to abstract over memory management. So, instead of calls to clone(), whenever a variable is re-used after a place that requires moving it, the variable will be cloned automatically.

fn moving_point() {
    let p1 = Point..

    // Move ownership because p1 isn't reused
    let p2 = double(p1)

    draw_circle(p2)
}

fn cloning_point() {
    let p1 = Point..

    // Clone to p2 because p1 is reused later
    let p2 = double(p1)

    draw_line(p1, p2)
}

There are, of course, some types which can't simply be cloned, such as files, so they'll need to be copied with explicit functions like copy_file(path). These are also the types that we might need to move for things like closing.

In summary, we default to borrows for all data, but if the data is aliased and mutated, then we clone it, otherwise it would violate the Alias Xor Mutate rule. We identify mutable borrows from mutation by assignment. Additionally, some types cannot be cloned, only borrowed or moved, which will be prevented if it violates the Alias Xor Mutate rule.

We can apply this idea to loops and closures as well.

pub fn main() {
    let list = [
        Point { x: 1, y: 1 },
        Point { x: 9, y: 3 },
        Point { x: 7, y: 6 },
    ];
    for point in list {
        point = double(point)
        // Error:
        // list = append(list, point)
    };
    dbg!(list)
    // Point { x: 2, y: 2 }
    // Point { x: 18, y: 6 }
    // Point { x: 14, y: 12 }
}

When looping over a variable, only the individual items can be mutated, but not the variable itself, since that would be two mutable aliases.

pub fn main() {
    let p1 = Point { };
    // p1 is immutable, so aliasing is safe
    let p1_print = || dbg!(p1);
    p1_print();

    let p2 = Point { };
    // The closure mutates p2, but p2 isn't reused,
    // so we can transfer ownership instead of cloning
    let p2_print = || dbg!(double(p2));
    p2_print();

    let mut p3 = Point { };
    // Clone p3 for closure because it's mutated later
    let p3_print = || dbg!(p3);
    p3 = double(p3);
    p3_print();
    dbg!(p3);
}

Similarly, a closure will use an alias if there is no shared mutable alias, otherwise it will use a copy.

There are a lot of interesting properties if this works, but I'm a hobbyist programmer without much experience, so even though I'm confident it will work, I don't have the skills or knowledge to verify it in a reasonable amount of time, so I've focussed on the core idea - the possibility of a high-level language with similar safety and performance as Rust. I await your questions and critique with great suspense.

This is what the Nim devs try to solve since a decade now, and I think they actually solved it with latest ARC/ORC memory management and the new Nimoy compiler generation. They might have even solved breaking up cycles and freeing up cyclic data structures automatically. But that was really hard, so I never tried to understand it.

But one advantage of Rust's borrow and ownership model is, that we always see when a clone() actually is required, so the performance costs are more transparent.

1 Like

To clarify, I think what you’re talking about is the Nimony compiler for what’s going to become Nim 3.0. From briefly skimming material on it, it seems like they use reference counting. The difference here is that reference counting occurs at run time, while what I describe occurs at compile time, which means it’ll run faster than Nim/Nimony.

Nimony design post

As for making clones explicit, I only show that we can reliably infer when clones are required, but if we wanted we can require the user to type it out explicitly with help from the language server.

Indeed, I always confuse the two names. Reference counting is an very old form of garbage collection. I am quite sure that Nim uses with ARC/ORC strategies that use most memory handling at compile time like Rust -- Mr. A. Rumpf knows the Rust concept well. One point that I can remember is, that the compiler uses typically move semantics, but falls back to making a deep copy when the moved item is later still needed. They started long time ago with a published paper, I think it was called the Bacon-Paper, but found out that the described simple strategy was not correct for corner cases, so they modified and improved the concept. They put really a lot effort in it. When I left Nim in 2022, ARC/ORC was working already quite fine for single threaded code. I don't know if they already solved threading fully. At least the new Nimony compiler seems to support CPS ( Continuation-passing style).

I think this is, really, the core values question.

I don't think either position is strictly right or wrong, but there seems to be a strong disinction between

  • "It's annoying to stick all these stupid annotations everywhere" people, and
  • "It's so nice having these annotations so I can tell what's going on" people.

You'll see this come up all over the place, like the "I use impl Into<Foo> everywhere" people vs the "Look, you call .into() yourself" people.

(Obviously no binary is perfectly strict, but I think most lean pretty clearly to one side or the other.)

Personally, for example, in C# I often wish that it had Rust-like & annotations on parameters, since without it pervasive GC'd mutability there means that wrappers tend to make defensive copies because it's unclear whether modifying the object is allowed or not. And they've been adding things like "ref returns" as a way to help avoid copying.

I think this is slightly misstating things. I think you'll go much further if you look instead at abstracting over resource usage. Memory is the easy one -- you have a ton of it, and it's not time-sensitive. It's dealing in the mutex locks, open TCP ports, etc that are far more meaningful from a language utility sense.

How do you handle storing things in structures? That's where lifetimes get interesting.

7 Likes

Ah, ARC is basically what I’m describing! From what I understand, it seems to track variable usage at compile time for deterministic memory management.

Introduction to ARC/ORC in Nim

However, since cyclic references seem to be a core part of Nim, ARC would cause memory leaks, so ORC layers a garbage collector on top.

I didn’t mention it to focus on the core reference inference idea in this post, and I haven’t explored cyclic references much, but a simple solution is to have special types that allow internal references. This means that items within the variable can refer to other items within the same variable, and the lifetimes of these references are tied to the lifetime of the variable that owns them, so they are valid together and destroyed together. Assigning a reference to an external variable would also follow the Alias Xor Mutate rule, which means using a clone if required.

1 Like

High-level mutable languages, such as C#, tend to pass references everywhere. What I describe will fall back to clones to preserve the Alias Xor Mutate rule, where Rust would force you to use a clone anyway. Because the language would ensure safety, there would be no need for defensive copies. Function parameters default to immutable references, and mutable parameters will be marked as such and must be returned and then assigned to a variable in order to mutate it.

fn main() {
    let point = Point { x: 3, y: 3 };
    double(point);
    dbg!(point); // Point { x: 3, y: 3 }

    let point2 = double(point);
    dbg!(point2); // Point { x: 6, y: 6 }
}

fn double(p: mut Point) -> Point {
    p.x = p.x * 2;
    p.y = p.y * 2;
    p
}

In the above example, the double function’s return value is ignored at the first call site, so it won’t mutate point. C# would’ve mutated point anyway, which means point2 would be Point { x: 12, y: 12 }. A language adopting what I describe could safely remove the first call to double, since it has no effect, and it would simply reuse point for point2 through ownership, since point isn’t reused. The double function also marks the mutable parameter, which makes it clear.

Resource types are similar to other values, except they can’t/shouldn’t/won’t be automatically cloned. Managing them uses a similar strategy as Rust, in that it will call the default destructor at the end of its lifetime/scope.

It would use the same rules of aliasing as long as it doesn’t violate the Alias Xor Mutate rule. Since there’s no way to store references like &, structures would store what look like copies of the data, but it will often just be a transfer of ownership, or a clone if required. In either case, the structure would own the data and lifetimes wouldn’t be a concern. It’s also best practice in Rust to use owned data in a structure.

struct Line {
    start: Point,
    end: Point,
}

It’s impossible to state that start and end must store references. They will use references, if possible, otherwise they’ll use clones.

If you have an example in mind, I could show what I mean more clearly.

I think the use of cyclic references is very similar in Rust and Nim -- typically we can avoid them. And yes, ARC causes memory leaks for cyclic references. But in Rust cycles always causes leaks, while Nim offers the compile flag ORC which generates a bit of overhead, but can break cycles and so free all unused memory. Disclaimer: ORC was not working fully reliable some years ago, but it might now.

I don’t think Rust leaks memory, at least not by default. Arenas are a common way to allow cyclic references for a limited duration.

Arenas in Rust - In Pursuit of Laziness

Special types with internal references are basically arenas and avoid the need for a garbage collector, since references are all inside the variable/arena and can be freed at the same time when the variable/arena goes out of scope.

See Reference Cycles Can Leak Memory - The Rust Programming Language

1 Like

This is called "mutable value semantics", and there are languages like Swift, Mojo, and Hylo using it.

The problem of inferring lifetimes and exclusive access is tractable when you don't allow putting temporary references in structs (and then there's still a way to create syntax sugar for getters and setters that can replace some usecases for references in structs).

1 Like

Yes! I’ve actually followed a bunch of discussion on them, and they achieve similar results, but I believe the differences are meaningful.

(Removing explicit/temporary references actually surprised me with all the things it unlocked!)

There are two differences with Swift, with the user-facing one being that it mixes reference semantics in some places, though that has a lot to do with Objective-C compatibility. The other difference is that Swift’s value semantics are implemented with reference counting and copy-on-write, as far as I’m aware, which includes run-time overhead that can be avoided by what I describe, which instead performs compile time analysis. Herd is an interesting exploration of running with mutable value semantics using copy-on-write. What I describe achieves the same semantics and ease of use without the performance overhead.

Herd

I haven’t been able to fully digest what Mojo is doing but it seems to require many kinds of annotations, with an ownership system that’s very similar to Rust.

Hylo promises to remove lifetime concerns, but it still has two or three different kinds of parameters, and a function-like syntax construct called a subscript. Perhaps the language has changed significantly since they last updated their documentation, but the last material I’ve seen mentions function bundles and sink parameters.

I think Mojo and Hylo’s complexity comes partially because they’re trying to be systems languages with low-level control, and even explicit costs in the case of Hylo. They’re probably easier than Rust, but still not beginner languages. What I describe would be much easier to learn and use. For example, function parameters don’t actually need any annotations whatsoever, such as mut or move, because mutation doesn’t take effect without assignment, and move is only used for closing resources by calling a platform/stdlib close function, or simply exiting the scope.

fn main():
    p := Point(x: 3, y: 4)
    // Warning: no effect
    double(p)
    p = double(p)
    println(p) // Point(x: 6, y: 8)

fn double(p):
    p.x *= 2
    p.y *= 2
    p

The parameter p can be inferred as mut because it’s mutated and returned at the end, and its type can also be inferred from what values it’s called with.

The moving situtation is similar.

fn main()
    file := std.result.unsafe_unwrap(
        std.file.open("example.txt")
    )
    str := std.file.read_line(file)
    str2 := read_and_close(file)
    // Error:
    // str3 := std.file.read_line(file)

fn read_and_close(f):
    str := std.file.read_line(f)
    std.file.close(f)
    str

In the above example, f is inferred as a move parameter because it’s a non-clone-able resource type that’s being used with a close function provided by the standard library, which requires ownership, which means read_and_close also requires ownership. If close hadn’t been called, then read_and_close would’ve borrowed file, and it would’ve been dropped automatically at the end of the main scope.

I’m using the Python-like syntax without type annotations to show how much it can look and behave like a scripting language with lightweight syntax while achieving similar safety and performance as Rust. A beginner to the language can program as if they’re dealing with copies everywhere[1] without incurring the cost of it.


  1. Almost everywhere, since files and other resource types would require explicit copies like file.copy_contents(contents, new_path). ↩︎

In hindsight, this seems to be the main concern, and I didn't clearly address it in my previous reply, partly because it's about values and "style", which is a nuanced topic, and I didn’t immediately notice it.

I'd say the difference is in when you need to tell what's going on.

In many cases, it's so obvious that annotations feel "stupid", like comments above each line of code describing what it does. We can already see what’s going on in the code, and those comments are redundant.

When it comes to aliasing however, high-level languages use them everywhere, which makes it hard to know what’s going on. This is where Rust-like annotations help us understand what’s going on to avoid subtle bugs.

With what I describe, users deal with “copies”, which makes the code safe and avoids the subtle errors of shared mutability that explicit annotations solve.

I'm actually one of the "Can we use .into::<Foo>?" people! I just find it freaky that a left-hand-side type annotation can change the function, but I also realise that Rust's design keeps it explicit with the type annotation, so it's not a big issue, just unusual.

I think C# defaults to mutable references everywhere, so & would be for immutable parameters? With what I described, parameters would default to being immutable and they'd be mutable if you defined them as mut. They also won't mutate an object until the result is assigned back to it, similar to moving ownership.

fn main() {
    let mut point = Point { x: 3, y: 4 };
    
    // No effect:
    double(point);
    
    // Assignment explicitly mutates `point` in place:
    point = double(point);
    
    dbg!(point);
    // Point { x: 6, y: 8 }
}

// `mut` explicitly allows mutation of `p`.
// No need to defensively copy.
fn double(p: mut Point) -> Point {
    p.x += p.x;
    p.y += p.y;
    p
}

As I said in my previous reply, defensive copies would be unnecessary because modifying the object would always be explicit and safe with what I describe.

This seems to be about passing references to immutable value types, which are copied by default. What I describe only copies things when aliased and mutated, which means it can mutate strings and structs in place if there's no aliasing, such as in the example I showed above. It behaves a lot like moving mutable ownership.

Wrapping it all up, being explicit is a useful value when you can't tell what's going on. With the pervasive aliasing in high-level languages, Rust-like annotations are important, otherwise it leads to surprising behaviour like what Manish Goregaokar describes in his post on shared mutability. What I describe prevents shared mutability without requiring borrow or lifetime annotations, and requiring them would be much like commenting every line of code. To the user it would look like copying everwhere, like C or Go without pointers, but under the hood it mutates in-place wherever it is safe and performant.

The Problem With Single-threaded Shared Mutability

I found the mentioned Bacon paper: Wayback Machine

It was also mentioned in Ownership you can count on - Muxup

Another paper of that author was mentioned recently in Reddit - The heart of the internet

Ah, thanks for the links!

The abstract of the Bacon paper says that it catches most ownership errors at compile time, which suggests there are ownership errors at run time, meaning run time overhead.

The Muxup post says that Nim tried to use the paper but later switched to ORC because it was too hard to work with. There’s a lot of interesting links in the post, however, which I'm slowly working through.

The last paper seems to be a summary of garbage collection strategies, including reference counting, which are all run time systems.

The core thing that make me think of, though, is C++ which has copy-by-default and copy elision. And that seems to be widely regarded as sub-optimal now, as it leads to lots of copies that people were maybe not expecting, and for large std::vectors that can be really expensive.

Maybe that's ok, but often one of the things people like about Rust is that you don't get those implicit copies. So I don't know how to weigh those things.

3 Likes

Looks like you are describing a functional language and hopying that a compiler for it can be sufficiently smart to remove costly copies, a bit a la Perceus GC.

What usecases do you imagine this language would be good for? I don't see it competing with neither Rust (due to losing control over memory allocations) nor with other common high level languages (due to making mutable reference semantics very annoying).

2 Likes

Copy elision sounds ridiculously close to what I described! It's incredible how many related things I keep learning about. I think the unexpected copies in C++ might be caused by the complexity of C++, which would make analysis and elision difficult. I believe what I describe can reliably avoid copies unless absolutely required.

Explicit copies make sense for low-level systems languages like Rust and C++, but I'm aiming for a high-level language with similar (not exact) safety and performance guarantees as Rust.

I’m describing unrestrained in-place mutation, so it's not really a functional language, though it does share some properties, just like Rust does.

Also, I'm convinced that the compiler will remove all unnecessary copies. What I'm hoping for is that nobody tells me I'm wrong :stuck_out_tongue:

Perceus GC is actually quite similar. I'd seen it before, but I had dismissed it because the Koka home page and the paper's introduction keep talking about reference counting, which I think of as a run time system, though the system I describe is a lot like compile time reference counting. A big difference, from what I see in the Koka docs, is that it leans heavily into functional programming, and mutable variables are strictly restricted to the scope they're created in. What I describe gives much more freedom.

Koka Tour - Local Mutable Variables

Rust is a low-level systems language for experts, where I'm describing a high-level imperative language that's easy for beginners and doesn’t really compete with Rust. It wins against other high-level languages by having much better safety and performance because it prevents shared mutability, removes boxes, removes garbage collection, and allows things like mutating strings in-place.

The Problem With Single-threaded Shared Mutability

I'm not quite sure what mutable reference semantics mean. My description has "copy" semantics to avoid the pitfalls of shared mutability, while still using references under the hood. User created references, as far as I know, are most useful for self-referential or circular structures. An easy way to achieve those is special types that allow internal references. This ensures that references live only as long as the values they reference, which is the lifetime of the variable the values and references inside. This is basically like an arena.

Maybe I'm missing something, but I don't really see big differences between the two. In both cases the only mutation allowed is reassignment of local variables.

That sounds like a halting-problem kind of task, so it most likely will have to go wrong somewhere. When it comes to semantic analysis of programs never promise more than you already proved can work.

The runtime aspect is present in both your prosal and Perceus GC: in yours it results in copies, while in Perceus it results in (runtime) reference counting operations. In both cases you're trying to have the compiler do some analysis of the code to determine when such operations are not needed.

Also, arguably the reference counting operations are less costly than plain copies (imagine you happen to copy a vector of 100M elements instead of incrementing it reference counter!)

I wouldn't define your language as imperative though. The functional nature might but also might not help beginners though.

That's only if you can actually avoid unnecessary copies. I find that pretty debatable.

It refers to the ability of mutating some data that exists somewhere else through some kind of reference, pointer or in general an indirect way to access it.

You are not avoiding the pitfalls of shared mutability, you are avoiding most of mutability itself. That way you avoid the problem but you also make everything that was relying on it much harder, like getting good performance. There's no free lunch in this world.

4 Likes