Aliasing-xor-mutation WITHOUT borrow-checker

Hi, Rust experts! I've been pondering a question for a long time: is the borrow checker the only solution for achieving "aliasing XOR mutation" ? After years of exploration, I personally feel there might be other solutions to this problem. All the thinkings result in an experimental language zinc-lang · GitHub . Let me explain my design, and I would appreciate it if the experts here could point out any aspects I might have overlooked or failed to consider correctly.

  1. use RC to manage all heap memories

    RC is very good at tracking aliasing, since it inherently keeps a record of aliasing count.

    Zinc uses "Automatic Reference Counting", which means the compiler automatically choose atomic or non-atomic RC pointer according to pointee type. Thanks to thread-safety feature, the compiler knows whether a pointer can be transferred across thread boundaries, so it knows whether atomic ref-counting is needed. ARC is the most commonly used pointer type in Zinc language. So the simplest syntax is chosen for this type: *T.

  2. borrow to heap objects should change the ref-count of the heap object

    fn main() {
        let mut p1: *UInt = box 1_u;
        let b1 = &*p1;  // b1 is pointing to a heap object, the ref-count of the object increases to 2.
        println(*b1);   // prints 1
    
        p1 = box 2_u;  // p1 is pointing to a new object, the ref-count of the old object decreases to 1.
        println(*b1);  // prints 1, because the old object is not freed. The borrow continues to be valid.
        println(*p1);  // prints 2, because p1 is pointing to the new object.
    }
    

    This means borrow pointer will never become a dangling pointer. A heap object will not be freed when there is an aliasing pointing to it.

  3. return fat borrow type when a borrow to heap object escapes outside a fn

    Let's consider how to implement fn index of Vec<T> type. Its return type is expected to be a &T type, which points to an item on the heap.

    // test case:
    fn main() {
        let mut vec = (type Vec<Int>)::from(&[1i,2i,3i,4i] as Slice<Int>);
        let p: &Int = vec.index(1); // we get a borrow pointing to heap object
        vec.push(5); // push might triger re-allocation
        println(*p); // how to ensure p is valid ?
    }
    

    Solution: the fn index should return an user-defined-type instead of a borrow pointer. The user defined type contains a borrow pointer and a RC pointer both, which can be viewed as a "fat borrow pointer". And it can be implicitly deref to a borrow pointer.

    // explain:
    fn main() {
        let mut vec = (type Vec<Int>)::from(&[1i,2i,3i,4i] as Slice<Int>);
        let _temp: ItemRef<Int> = vec.index(1); // the index method actually returns a struct ItemRef, it is an invisible local variable
        let p: &Int = _temp.deref(); // the struct ItemRef can be implicitly deref to a borrow pointer by compiler
        vec.push(5);  // Since ItemRef contains a RC to heap objects. So re-allocation will not cause old objects be freed
        println(*p);  // p is always valid
        // when _temp is dropped, the old heap objects will be freed
    }
    
  4. use copy-on-write to implement value type collections

    Copy-on-write is the key technique to achieve "aliasing xor mutation".

    Take String type as an example. It is possible to implement a memory-safe and thread-safe String type without ownership and borrow-checker. It is implemented like this:

    1. internal byte buffer is managed by ARC pointer
    2. methods with &mut self receiver always check the ref-count before modification

    We can ensure when mutation happens, there is always only one aliasing pointing to the byte buffer. It conforms "aliasing xor mutation" rule perfectly.

  5. avoid invalid iteration

    Without borrow checker, we can still avoid iterator invalidation issues during iteration.

    fn main() {
        let mut vec = (type Vec<Int>)::from(&[1_i, 2_i, 3_i] as Slice<Int>);
    
        // The iterator increased the ref-count of underlying array buffer
        for i in vec {
            // when calling mutable methods of vec, it will copy its underlying array buffer to a new allocation, and then modify.
            // this behavior is called copy-on-write.
            // the old array buffer is still alive until the iterator is dropped.
            vec.push(4);
            println(i);
        }
    
        // After above for loop, the vec's value is updated to [1,2,3,4,4,4].
    }
    
  6. what if a mut borrow is created, and then add immut aliasing later?

    // test case:
    fn main() {
        let mut vec = (type Vec<Int>)::from(&[1i,2i,3i,4i] as Slice<Int>);
        let p: &mut Int = vec.index_mut(1); // we get a mut borrow pointing to heap object
        let vec2 = vec; // shallow copy of vec
        *p += 1;        // Will this modification affect vec2? Is there a data race if vec2 is send to another thread?
    }
    

    The answer is that this case will panic when copying the vec. The index_mut method will do a copy-on-write first and then set the ref-count to MAX. So increasing ref-count will cause integer overflow panic if the mut borrow is alive. This is basically the same logic as that of RefCell.

  7. borrow to enum payload

    fn test(o: &Option<Int>) {
        if o is Some(ref p) {
            f();
            println(*p); // how can we ensure p is valid?
        }
    }
    

    For this case, I don't have a good idea to solve it. For now, I just simply disallow borrow pointers pointing to enum payload. It should be improved later.

  8. thread safety

    Similar with Rust, thread-safety is maintained by Send & Sync traits in Zinc.

    struct S {}
    impl !Send for S {}
    static G: S = {...}:S; // error. `S` is not Send type
    
    // ok. `&AtomicUInt` is Send type
    static M: &'static AtomicUInt = &AtomicUInt::new(0);
    

    Note: there is a difference between Rust and Zinc that, Rust requires Sync constraints for globals, whereas Zinc requires Send constraints for globals.

    Below types conform Send trait, and can be safely transferred across threads:

    1. "value semantic" types
    2. "reference semantic" types which points to synchronized types
    fn send<T>(x: T) where T: Send {}
    
    fn main() {
        let mut vec1: Vec<Int> = Vec::new();
        let mut vec2: Vec<*Int> = Vec::new();
    
        send(move vec1); // ok. `Vec<Int>` is Send type.
        send(move vec2); // error. `Vec<*Int>` is not Send type
    }
    

Back to the original question at the begining, is my design a correct solution to "aliasing xor mutation"? I'm not sure. And I'm eager to hear your opinions. Thank you very much!

I think you're missing the point of aliasing xor mutation: it was never a goal, but a mean to have memory safety without garbage collection, reference counting, or in general runtime overhead, i.e. while keeping reference just simple pointers. Your approach achieves aliasing xor mutations just like functional languages with copy-on-write that optimize allocation reuse, but it's very far from the C/Rust runtime behaviour.

12 Likes

I think the big question is "what's the problem?"

To me, the borrow checker's superpower is that it enforces that something ends. There are plenty of other approaches that extend the lifetimes of things, but I don't want that.

I don't want request-scoped things to be magically extended to live longer than the request; I want a compiler error if I try to put them into a global cache.

I don't want my UI button to be kept alive forever because I accidentally captured it in a callback; I want closing the window to actually free all the UI widgets from that window.

And "well I just stuck everything in an Rc" doesn't do any of the things I want.

8 Likes

Thank you very much! I guess this is my personal misunderstanding that: since only Rust claims to guarantee memory safety and thread safety among commonly used languages. I have the impression that Rust's design is the only way to achieve this. This becomes a problem in my mind: is there another way to achieve memory safety and thread safety. It turns out that if we don't persue "zero cost abstraction", there are many ways to achieve "memory safety and thread safety".

Note that it's specifically data race safety, not other kinds of thread safety.

Also, I don't think that's true? Java, for example, gives memory safety and data race safety as well. (Albeit in a less interesting way by just not letting you use things that aren't atomic.)

Well yes, fairly obviously so. That's the domain that most languages target.

4 Likes

Thanks, I've learned something new again. Anyway, I do think this is important for me: trading off execution performance and ease of use is possible while keeping the same level of safety. That means my experimental language has some values.

I do think many Rust users don't realize this, and the impression is well spread that: "borrow checker" is essential to achieve memory safety and thread safety. (I am a classic example.)

I'm really not sure that wrapping everything in std::shared_ptr makes code more ergonomic. And notably, it assumes that all types can be cloned...

Wait. Why does example 3 work at all? Would it work as well if we put _temp into a block?

fn main() {
    let mut vec = (type Vec<Int>)::from(&[1i,2i,3i,4i] as Slice<Int>);
    let p: &Int = {
        let _temp: ItemRef<Int> = vec.index(1); // the index method actually returns a struct ItemRef, it is an invisible local variable
        _temp.deref()
        // when _temp is dropped, the old heap objects will be freed
    };
    vec.push(5);  // Since ItemRef contains a RC to heap objects. So re-allocation will not cause old objects be freed
    println(*p);  // p is always valid
}

_temp is either kept alive by something, or is dropped and therefore cannot protect vec.

1 Like

It should be a compile error if we manually write your code. ItemRef has a lifetime parameter like this: struct ItemRef<'a, T>.

  1. ItemRef can not live longer than original vec
  2. its deref() method returns &T. Which means the borrow pointer of _temp.deref() can't live longer than _temp it self.

Lifetime check still exists in zinc. (Though the implementation is incomplete currently.)

_temp is kept alive by compiler, and it is dropped at the end of the block.

At a high level: yes, one can have memory safety without a borrow checker. It is quite possible that there are many people who may not be aware. But it may be a design that people are not interested on.

Many people do not see the point in exploring languages design and would rather improve the one language they like.

Runtime memory management and in particular copying is a legit way to avoid aliasing. It is an approach that is different from Rust (and you are asking in a Rust forum).

When embracing copying in the language design, the challenge is going to be that copying is costly. In other words: can one identify those cases where the copying is unnecessary and optimize away the copy. The static analysis needed to do that may end up being very similar to what the borrow checker does.

Safety and performance/resource efficiency are thus closely related in practice.

The space for programming language design is large. There are always more languages because it gets easier to build one. The combination of safety (guaranteed absence of UB) and zero overhead makes the space narrower by excluding built-in garbage collection.

There are still many possibilities and combinations left to be explored even in this corner of the space,. eg type systems with fancy annotations, better analyses paired with different restrictions, or requiring the user to do the copies. What is important that it should be possible to prove the safety, at least in principle. I have such a research (toy) language in the works, too, complete with safety proof, trying to write a readable text about that.

If you are interested, a different language that you may want to check out is Hylo which is exploring mutable value semantics (MVS), or Ferlium, which also uses MVS and is more Rust like.

Finally, the Carbon folks are also working on getting to memory safety in a zero-cost setting without a borrow checker. They are aiming to use invalidation. It will remain to be seen how that comes together and whether that leads to a safe fragment that is also useful. It is a failure to achieve statically guaranteed safety by ruling out too many useful programs. The borrow checker has set a new standard by combining dataflow with type checking. Carbon's invalidation will have to do something similar.

So bottom line: more research is needed :sweat_smile:

4 Likes

Thank you for providing so many informative suggestions. :+1:

Memory-safe languages (in the fairly strict sense of languages without Undefined Behavior) have existed for more than 3 decades (e.g. Lisp, Standard ML, Haskell, Java). So I would assume most people are aware that indeed they are possible without a borrow checker. :slight_smile:

Rust never claimed to be the first memory-safe language. That would be a very silly claim. But it is the first widely adopted language that achieves memory safety for heap-allocated data without a GC, and without forcing all language-native types to fit into a single word. (The latter is needed for memory safety in the presence of concurrency.)

6 Likes

This is of course true. However, that notion also used to be called "safety" (e.g. Cardelli's 2004 Type Systems handbook article). The use of "memory safety" seems more recent, and the nuance that these are the same requires a detailed understanding.

Language designers have used built-in GC to elegantly avoid problems for a long time. However, concurrency was not as developed in all these languages. Multi-core CPUs became widely available only after 2005.

So arguably, the "space of language designs" changed. And with that, the scope of what "safety" means changed as well.

Of course I am on the same page with respect to "no memory safety without thread safety."

Yet, people may disagree, and not only out of ignorance. Someone who wants to claim that go is memory safe has reasons to prefer talking about single-threaded [memory] safety. And that notion matches what safety meant before 2005 and is enough to "beat" C and C++ in terms of safety guarantee.

So including concurrency in safety is a real differentiator. If you look at it this way, then among the widely used, modern no-GC languages, Rust does seem to hold a special place. And the claim that it is unique is not all that silly.

2 Likes

Indeed "memory safety" is a term with many meanings. I have met people who do "memory safety proofs of Java programs", by which they mean "prove that the program does not throw null pointer exceptions". If we take that definition, a Rust program that panics is also not "safe".

I am not aware of any definition of memory safety where Rust would fall in a different bucket than Java.

I don't think so. Java had safe concurrency since ~forever. In fact, I think Go is pretty much the only language that is often claimed to be safe without being thread-safe (in the sense of a no-UB guarantee for concurrent programs). Swift might fall into the same bucket depending on where you draw the line between the safe and unsafe fragment of the language, though nowadays Swift does have safe forms of concurrency so unlike Go they are at least trying.

I agree that Rust does more than Java to prevent concurrency bugs. But the same is true for sequential code, e.g. Rust statically prevents iterator invalidation. So concurrency is not a differentiator here either. I don't think that this difference can be captured by saying that Rust is safe in a sense that Java is not. Rust has a stronger type system, but the additional strength does not manifest in new metatheoretical properties.

If you look at it this way, then among the widely used, modern no-GC languages, Rust does seem to hold a special place. And the claim that it is unique is not all that silly.

"no-GC" is doing all the heavy lifting here. All the other no-GC languages are "obviously" not memory safe, as far as I can tell.

So yes, I agree that Rust is unique in that it achieves memory safety without a GC. I already said that in my previous post. But there were claims further up this thread which seemed to say that Rust is unique for achieving memory safety at all, and that "memory safety without a borrow checker" would be somehow a new thing. Those are the claims I pushed back against. Your reply now argues for something entirely different.

4 Likes

Just meant to add perspective - not argue against anything you said. Safety was a design goal of Java (it seems they had had enough of C++ in 1995) and many languages before it.

The main message here is that 1. language safety is a thing and 2. since essentially forever. There are other meaningful notions of safety and memory safety (e.g. "crash/panic safety"), different from "guaranteed absence of UB", but that does not matter in this context.

About "differentiator", I meant that "single-threaded safety" is less than safety-including-concurrent-programs. Java certainly had to deal with data races from day one, e.g. by specifying that word tearing is possible, even when things were executed on single-cores.

I will just stop now, lest I appear to be challenging when I am actively agreeing : )

2 Likes

You should take a look at Apple's Swift, which is also a compiled language that defaults to Automatic Reference Counting for everything then optimizes it out.

1 Like

Personally, I’m more drawn to the Mojo model. Like Rust, Mojo ensures memory safety without a garbage collector by using an ownership system, it doesn't sacrifice performance (I find this is very important to maintain equal speed to C/C++/Zig), and also utilizes XOR aliasing that makes it is also safe from data race. What’s interesting is that it’s more flexible than Rust, and requiring neither lifetime annotations nor the anonymous lifetime '_. In my view, Mojo is the successor to Rust, it’s truly compelling

It looks to me though that Mojo uses same lifetime system, just branded 'origin' and moved to the domain of 'normal' generic parameters. It's also not clear if it's scope or control flow based, and whether it supports 'origin elision' of sorts. Its type system also seems to be less expressive or flexible than Rust's.

Mojo uses control flow based management rather than scope based for both dropping variables and lifetimes. The best part is that there's no need to manually annotate lifetimes or even use anonymous lifetime annotations, as the compiler tracks them through data flow analysis. It also has generics and traits, making its type system equally as expressive as Rust's. Additionally, it features a comptime capability that is more powerful than Rust's current const system.

Here are good resources to read more about it :

Tragically, it’s modeled after Python and uses syntactically significant whitespace. Womp womp. :sob:

It certainly does.

From the link you shared in the next comment:

Most of the time, origins are handled automatically by the compiler. However, in some cases you'll need to interact with origins directly:

  • When working with references—specifically ref arguments and ref return values.

  • When working with types like Pointer or Span which are parameterized on the origin of the data they refer to.

That's the same as rust: lifetimes are generally elided, but you might have to specify them when returning references (notably here Rust still allows lifetime elision in some cases, while Mojo forces an origin annotation when returning refs), when multiple arguments need to share the same reference (it wasn't mentioned but I'm pretty sure Mojo shares the same limitation) and for types parameterized over lifetimes.

Rust's borrow checker and drop flags are already "control-flow based", though the borrow checker is not currently "control-flow sensitive" (Polonius being the "fix" for that). It's not clear from what I saw whether Mojo is control flow sensitive or not (though the use of the "origin" name, which is also used in Polonius, might suggest this).

This however does nothing to explain whether lifetime/origin annotations are required on functions/types. Control flow is a function local-concept, while lifetime/origin annotations are necessary for non-local relations between functions/types.

3 Likes