Aliased Xor Mutable core for a high-level language

I've explained myself poorly.

By default my description uses borrows/references/pointers, even across functions, loops and threads, until it violates the Shared Xor Mutable rule, which it fixes by cloning or prevents with a compiler error.

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

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

If the above code was Rust, it's sort of restricting mutation to the local scope, but it's definitely not using copies, since Rust doesn't do that. It uses something like a mutable borrow/reference. My description does the same thing.

If we wanted a second point, and we also wanted to use the first point, we would need to clone the first one.

// IMPORTANT: This is Rust code, not the system I describe.

fn main() {
    let mut point_1 = Point { x: 3, y: 4 };

    point_1 = double(point_1);

    // This would move point_1, but we want to reuse point_1 later.
    // let point_1 = double(point_1);

    // We have to clone point_1
    let point_2 = double(point_1.clone());

    draw_line(point_1, point_2);
}

Maybe a better way to describe it is that the return value and the assignment negotiate what to borrow without needing separate borrow syntax. A function returning a parameter indicates that it's mutating that parameter, and the function caller assigning the return value to something indicates what variable the function is allowed to mutate.

The following Rust snippet does the same thing.

fn main() {
    let mut point_1 = Point { x: 3, y: 4 };

    double(&mut point_1);

    // Cannot do because that would create a shared mutable reference.
    // let point_2 = double(&mut point_1)

    // Must clone point_1.
    let point_2 = double(&mut point_1.clone());;

    draw_line(&point_1, &point_2);
}

The following Python snippet is similar:

def main():
    point_1 = Point(x=3, y=4)

    double(point_1)

    // Cannot do because it would get changes from point_2.
    // point_2 = point_1

    point_2 = Point(point_1.x, point_1.y)
    
    double(point_2)
    
    draw_line(point_1, point_2)
    // Point(6, 8) to Point(12, 16)

Python would allow us to do point_2 = point_1, but then when we double point_2 that would also double point_1, creating a line from x: 12, y: 16 to x: 12, y: 16.

The Koka docs indicate that mutable variables cannot be sent out of scope, while my description doesn't have this limitation. Mutable variables can be passed around freely.

My description allows mutation of external variables, similar to how Rust does it, but instead of &mut, it uses function signatures and the call site’s inputs and outputs (assignment), like I showed in the example code above comparing Rust and Python.

I believe it's efficient enough to calculate.

There's basically five types of functions.

  1. Functions that create and return a variable.
  2. Functions to mutate a variable, that take a mutable parameter, mutate it, and return it.
  3. Functions that only take an immutable parameter for things like printing it to the console or sending it over the network. There is no return value.
  4. Functions that take multiple immutable parameters and also return one or more of them, such as taking two strings and returning the longer one for further processing.
  5. Functions that take ownership, either mutable or immutable, for closing resources like files and network connections. Closing does not return the closed parameter.

This information is used for inferring the ownership and lifetimes of variables, and can be cached for speed.

Within a function, ownership and lifetimes are easy enough to track, even for a human.

This isn't more work than the Rust compiler does, so it shouldn’t encounter the halting problem.

Yeah, I'd misunderstood Perceus the first time. The system I describe is a lot like compile time reference counting. To clarify, Koka/Perceus also does the work at compile time and does not require a run time system, but it's different from the system I describe because mutable variables cannot be returned from functions in Koka, as I mentioned above.

As I said above, there's only one place where my system performs a copy, and it absolutely has to perform that copy for correctness and safety, just like Rust. If it reused the same reference, like plain reference counting without copy-on-write, then it would cause incorrect and even unsafe behaviour, such as using the same reference for sorting a list, which would make the “unsorted” list point to the same data as the “sorted” list.

As I said above, my description allows free mutation of variables and passing them around easily. It even allows safely mutating variables from within a loop, for example.

In the example below, it loops over from without mutating it. It only mutates to two times for each iteration.

fn main() {
    let first = ["ay", "bee", "sea"];
    let mut second = vec![]

    second = copy_items(first, second)

    dbg!(vec)
    // [
    //     "ay", "ay!!",
    //     "bee", "bee!!",
    //     "sea", "sea!!"
    // ]
}

fn copy_items(from: [String], to: Vec<String>) -> Vec<String> {
    for str in from {
        to = push(to, str)
        to = push(to, string_join(str, "!!"))
    }
}

Because main calls the function with vec_2 and also assigns the result back to vec_2, the function mutates it in-place using a borrow.

Like I said above, there's basically only one place it performs copies, and it has to do that to avoid shared mutability and the problems it brings.

As I said above, my description uses pointers/borrows/references under the hood, even across function bodies and scopes. The comparison to the Rust and Python examples should show the similarities.

Like I said above, I am using mutable borrows/references under the hood, even across function and loop boundaries, but without separate borrow syntax for beginners to manage.

I hope the Python and Rust comparisons help explain my proposal better. The system I describe basically allows everything that Python and other high-level languages allow[1], but with the safety and performance of Rust.

Thanks for all the feedback!


  1. Except for circular references, which can solved by a special internal reference type that I mentioned in previous posts. ↩︎

I think, TBH, you've probably already reached the point where it's going to be really hard to work well in a discourse thread. To me, it sounds like you've reached the point where you have enough of an idea that to figure it out any further you'll need to either

  1. Write some kind of formal proof of some property of interest, or
  2. Write the language and see how it goes :slightly_smiling_face:

After all, Rust only became as good as it is by going through a rather drastic transformation. At one point it had a GC and Green Threads, famously. There's no substitute for making it exist and seeing how it does on a real problem.

4 Likes

I actually have the first 80% of the language design figured out, because I wanted to be sure I had something worth people’s attention before I posted about it. I even spent several weeks editing and rewriting an introductory post, but I seem to have missed the mark. I still got some useful feedback though. Thank you for taking the time!

Did you find anything that’s still unclear from my posts? And was there anything in my replies that helped clarify doubts? Any quick notes or questions that enter your mind would be valuable, no matter the importance, even without much explanation or specific quoting.

TBH, I kinda pattern-matched it in my brain to "what if all mutation just used Arc::make_mut, but optimized well". Which seems entirely plausible a high level, but I have trouble coming up with good questions without just trying it out on real things and seeing how it goes.

The classic problem is that a "sufficiently smart compiler" can do lots of things, but we can't always make one :upside_down_face: Sometimes it turns out to be entirely doable, like fixing drop flags in Rust was. Which case is this? No idea.

1 Like

Doesn't this pose a problem "a mutex is unimplementable"? That is, either there must be a magic primitive of channels, or a thread could not communicate its progress before termination.

I actually found copy elision was surprisingly similar, but then I’ve also been working through this for a few months. I see what you mean, though. Thanks for the feedback!

Yeah, a Mutex would be a special type provided by the platform, similar to async in Go, Python, etc.

I've actually been experimenting for a few years now on my own hobby language design using a very similar form of reference inference and optimization. (Primarily as a spec/reference-like document.) Some of the observations I've made along the way:

  • Inference relies on being monomorphic. Any dynamic functionality (e.g. function pointers or dynamic trait/interface dispatch) needs to be conservative and support any possible consumer, or it needs to be restricted to require consumers to observe the correct borrowing pattern.
  • If references are an argument-passing convention only, they can be trivially inferred. This includes, as you note, compiling move-and-replace as mutate-in-place.
    • Well, it works great for copyable value semantics, but unfortunately, if you have "RAII[1]" style destructors, you lose the locality of reasoning about when destructors get called because the same argument passing syntax could pass ownership or not depending on the function's exact signature.
      • Requiring by convention that automatic destructors be considered pure (i.e. that delaying them or even skipping them entirely has no consequences except resource consumption) and for impure cleanup to go through scoped constructs like try-with-resources or Rust's scoped threads is one way of doing destructors without RAII, but prevents you from passing full ownership of an open resource.
      • Also note that you ideally want to use references for less obvious cases, such as using the produced temporary as an argument to another function, or when the assignment is to a different place
    • Rust is limited in when it can change the indirection quality of a function because you can observe places' address, and Rust guarantees that the argument place does not overlap addresses with any other live place (e.g. the parameter in the caller's scope[2]).
      • Forbidding observing the address of a parameter is sufficient to avoid this. A "scripting" level language likely doesn't allow observing the address of any value, or at least does so in a way with very limited guarantees about address stability. One of the subtle benefits of no-expose-address GC languages is that they can move things around in memory to fight memory fragmentation[3] and improve locality.
    • In-place mutation syntax can still be a useful API design tool, however; consider the "inout" parameters supported in some languages.
      • I'm currently thinking that for my language passing x will infer copy/ref/mut-by-replace, x.move will force ownership transfer[4] $expr.mut at the call-site for this in my language and $expr.ref for explicit pass-by-sharing for when I'm not confident that inference is both possible and desirable. (I like postfix keywords.)
  • Returning "view references" is likely the main design hurdle here; think fn(&Vec<T>) -> &[T].
    • For methods, inferring when the return value can be borrowed from self is reasonable for the same reason that autoref works in Rust (method call syntax is privileged already).
    • For mutable references, tracking field copy-and-replace and aliasing in parallel will be more difficult than shared references, but sounds like it'll be a tractable dataflow analysis problem.
    • For free functions, I'm a fan of explicitly specifying the parent lifetime for API design reasons,
  • Inferring when struct fields can be references is a lot more involved and sounds eerily likely to be a whole-program optimization question; I'd need to spend more time trying to design the algorithm for doing so.
    • Rust answers this by making references first-class values. C++ answers this with "just use pointers" and/or std::reference_wrapper to elevate references from a value category to a first-class type. Your design wants to make them entirely invisible and fall back to copying when the usage isn't (provably) well-scoped.
      • Note that inferring references must almost certainly fail in the same kinds of self-borrowing patterns that Rust struggles to express safely. Inferring yoked self-borrows definitely has a requirement of considering all code allowed to access the borrowed "cart" field.
    • For my language, I'm already considering having different type "kinds" for simple struct types, RAII resource types, and potentially even class types enabling self-borrowing and inheritance tricks, so adding another "kind" for reference structs that borrow data and have scope-restricted availability seems like it'll be the appropriate approach for my application.

  1. I can't hate the name "RAII" enough. RAII means using type associated deterministic destructors to clean up resources, i.e. impl Drop (plus inherited recursive drop glue) in Rust. Notably, it is not the finalization seen in some GC languages, which are non-deterministically called at some point after the last handle is dropped and often can't use any of the value's fields that may have been collected already. ↩︎

  2. For non-Copy types, the opsem probably (formally undecided) invalidates the source place before initializing the argument place, allowing the source and destination place to "overlap" in that way. However, for Copy types, the source place is not invalidated, forcing the argument to be copied to a new address if the optimizer can't prove that the addresses are never compared, so passing impl Copy by value can sometimes counterintuitively be more expensive than passing an equivalent non-Copy type (i.e. when the codepath is hot enough without being inlined and the value is large enough that the difference between pass-by-reference and pass-by-reference-to-copy is noticeable). This is a design wart that I hate, but one that I think that we have to accept for the same reasons that eager drop is problematic. At least tail become calls don't have this issue. ↩︎

  3. Memory fragmentation is often worsened due to "box everything" semantics, and GC heap generations exist primarily to mitigate this cost, but it doesn't come around solely due to reliance on the GC. Any domain that has an unpredictable mix of short-lived and long-lived resources can benefit from a similar resource management scheme, and they're also the ones that feel the worst to try and model in Rust without a GC to help. ↩︎

  4. If the called function is generated only taking references, x.move will drop the value after the function call to behave as-if ownership was passed. ↩︎

1 Like

This is, I think, one of the biggest things that Scale (what happens after Rust) will address. The fact that the compiler can't move around an i32 because the &dyn Foo might care about its address is brutal for a whole bunch of "obvious" optimizations.

2 Likes

could expand on what Scale is ? never heard of it before.

4 Likes

It's not something that actually exists, just a joke about the phase after rust.

8 Likes

I see this claim a lot but I don't think it aligns with my experience. People who program in a language that handles the memory for you generally don't struggle with any of that other stuff, but remove the GC and many of them lose the ability to be productive. 99% of what happens in a program is manipulating memory in some way, so it makes sense to focus language design effort on that problem in particular.

The bar should be higher than "not struggling".

In languages without deterministic destruction, resource cleanups are a chore and a systemic source of bugs.

With mutation without exclusive ownership, unexpected changes of shared objects are problematic and need defensive copying.

Just a few minutes ago I've read about pains of Web Streams API. Forgetting to call releaseLock(), or having excessive memory usage from not using response bodies (due to them being "leaked" until GC finds them) are typical problems that deterministic destruction can reliably fix.

6 Likes

My $RealJob is C#, and it's absolutely a source of pain despite the GC. I've had production outages from running out of windows handles, for example.

Remember that GC only prevents use-after-free bugs; it doesn't fix literally anything else. (Notably it doesn't prevent leaking for any useful meaning of the word "leak".)

I suspect that the real answer why people "generally don't struggle" with that in those languages is that most of the time they're not trying to run on the edge of resource usage in the first place. (And expectations are so low that nobody worries about rebooting the server periodically or knowing that you just can't keep something open for a long time.) It's particularly common that programs with a GC run fine if you just stop using the GC entirely and just never free anything, which is the same kind of thing as how (especially these days) in C it "works" really quite well to just never call free and thus never have use-after-free issues.

1 Like

I reboot Windows servers weekly, Windows just seems to work better that way, leaving them for months I found strange issues would develop. But not particularly due to C# issues, I think IIS worker pools recycle every few hours.

Hey cool! Thanks for sharing your notes on it! My reply is a bit all over the place, but I'm pretty sure I addressed everything.

I think the major difference is that I don't see a need to expose references in a high-level language, since functional languages seem to be doing well without it, and everything above Go doesn't expose them much either.

It's kinda cool that this allows optimisations unavailable to Rust. As an aside, I wonder what a run time defragamentation system would look like.

The biggest concern with removing observable references would be self-referential structures like graphs and linked lists, for which some kind of arena should help. I was considering a type "kind", as you put it, that allows fields to refer to each other, even if they're cyclic. Their lifetimes would be tied to the owning variable, so they'd be freed together.

An "arena" with internal references could be used, perhaps, for a structure like Yoke, but Yoke strikes me as a performance optimisation to avoid copies, which the compiler should be able to achieve with the right code structure, but where it really matters FFI or rewrite to Rust might be the better solution. Another option might be a low-level sub-language that's hidden from most users, like Rust's unsafe, but I don't think it'd be better overall than using Rust.

View types are also a similar question. A view should only be aliased to a source if the source is immutable during the lifetime of the view, otherwise the view's contents would change without touching the view. Users shouldn't be expecting such behaviour, because the rest of the (theoretical) language operates in "copies". If we wanted an up-to-date view, then we should call the view function each time we used it. This unlocks two optimisation opportunities. Each call can use the same memory, and it can even elide any further calls if the inputs to the view function remain stable. The input for a last function would be the length of the Vec, for example, which means it stays the same as long as no function mutate it. This does mean something like partial borrows, where the compiler infers the exact fields of a type that a function uses.

Using aliases to fields is a similar issue as partial borrows. It's sort of a whole program optimisation issue, but this information can be cached and even shipped with packages, which means only newly created files need to be analysed. If it did become hard to track aliases across many function calls and newly created structs, at that point it might be better to use clones for cache locality anyway, which the compiler can do by tracking the number of indirections.

I think the point about dynamic functions meant that they must be conservative in what features are allowed, so that all callers might be supported, or they must be restricted to specific callers. I think functions can be monomorphised at compile time or run time. The key one to consider is functions like longer, that take multiple parameters and return a subset with possibly unmatched lifetimes.

fun main() {
   let str = "Hi"

   longer = move_out(str)

   println(longer)
}

fun moving(s: String) -> String {
   longer(s, "Howdy") ++ "!!"
}

fun borrowing() {
   println(longer("Hey", "Howdy") ++ "!!")
}

fun longer(a: String, b: String) -> String {
   if len(a) > len(b) { a } else { b }
}

longer can then be turned into a function that takes ownership, and another that takes borrows, with usage based on the call site. This creates some extra work at run time, but not much, and only if there's dynamic dispatch, plus it doesn't affect safety or ergonomics.

As another aside, if mutation works with assignment and function returns, then all functions can be chained safely, and there's no need to have methods as well.

Oh, I missed destructors in all that. It should be fine as long as the destructor is called after the last use of the resource, right? If the user did care about when exactly the resource is freed, then they could call the destructor explicitly. Any function that calls the destructor for a parameter would necessarily take ownership and prevent further use of the variable. Yeah, you'll find out from the compiler error, but a function would only call the destructor if it needs to, and thus named and documented appropriately. Even so, it's not so different from Rust passing ownership or copies based on a type's exact signature. Incidentally, closing/freeing is the only use I see for taking ownership, because at this point the only thing ownership communicates to the user is that a variable can't be used again, meaning that it's been freed. I'm not sure how the stack frames for temporaries relate to ownership and RAII.

Explicit references also strike me as a performance optimisation. I'm not sure what inout parameters do. The system I describe will pass variables to a function based on the call site, and the function will then mutate that variable in place.

I think emy’s point could be better stated by saying low-level languages turn all types into a resource for users to manage, which increases the chance for severe bugs in most low-level languages, but also creates more burden for the programmer, even in Rust, which is a worthy trade-off for large/long-term projects that demand performance. I’d actually advocate for functional languages for most people, if not for the comparative lack of resources and support.

It seems I phrased my point too weakly: In all my time working on teams in mainstream garbage-collected languages I have never once seen an issue caused by leaking or use-after-release of file handles, network sockets, or locks in those languages. I have seen memory leaks, memory-related performance issues, and concurrency issues. If you counted the lines of code dealing with memory and the lines of code dealing with all of the other "resources," the two numbers would be orders of magnitude apart. That said, it's just my experience. Your and @scottmcm's experiences may be different.

Also, to clarify: I'm not trying to defend garbage collection here. The point was only that memory is significantly more important than other kinds of resources. Garbage collectors' existence is an assertion of such, and their widespread use is evidence.

I think that's a good way of framing it, yes. I'm not trying to advocate for garbage collection or anything, but I think acknowledging the difference in importance between memory and non-memory resources is a good way of understanding why people enjoy using GC languages so much, even when they lack features for handling the latter.

I'm not disagreeing that the there's a difference in how frequently you have to manage allocations vs other resources. Manual memory management is obviously error prone and a chore, so I'm not advocating for it as an alternative for GC. It's the other way: GC doesn't go far enough. GC automating 99% of resource management is falling short of automating 100% of it.

Even if memory management is 99% of the resources you deal with by number of objects or lines of code, leaving out automation of other resources makes managing of them 100% manual. It means you get systematically worse APIs for non-memory resources. Even if they're rare by lines of code, they're still something that can go wrong.

Even in languages that have a GC, I'd love to have deterministic destruction and exclusive ownership. To me they're not merely a solution to memory management, but a tool for API design. You can get nicer APIs for so many things: handling database transactions, cleaning temporary files, closing windows, even changing colors in terminal output.

6 Likes