It was interesting enough that I knew I had to write a post about it. Happy to answer any questions!
2,146 karma · joined May 25, 2018
It was interesting enough that I knew I had to write a post about it. Happy to answer any questions!
I think the need for Pin could then disappear, because the user would have no way to destroy it, since it's opaque and elsewhere on the heap, and therefore no way to move it (because having move-constructors implies that moving is conceptually destroying then recreating things).
OP shows the benefits of a linear RC that tracks references dynamically, and one can even imagine a version where the compiler tracks the references instead.
For example (for those who are more familiar with Rust) imagine that it had linear types and that we used them for matthieum's static-RC [0] (like suggested by frank-king at [1] to prevent leaks) and you'd have a pretty good idea of linear static RC.
I also talked about a possible alternative linear static RC at [2] and [3] (I didn't explain it well at all, happy to elaborate), though lately I suspect that one would need to add a GhostToken-ish [4] substance to allow safely reading/writing the shared value, or perhaps a RefCell.
I suspect that something like GhostToken could also solve OP's restriction that types be opaque, if Haskell has something like GhostToken... I could be wrong on that though.
[0] https://github.com/matthieu-m/static-rc
[1] https://github.com/matthieu-m/static-rc/issues/7
[2] "Linear reference counting" in https://verdagon.dev/grimoire/grimoire#the-list
[3] https://www.reddit.com/r/rust/comments/1d06gjo/comment/l61qm...
[4] https://www.reddit.com/r/rust/comments/p5f78s/ghostcell_sepa...
TL;DR: The lowest common denominator between Rust and any other memory-safe language is a borrow-less affine type.
The key insight is that Rust is actually several different mechanisms stacked on top of each other.
To illustrate, imagine a program in a Rust-like language.
Now, refactor it so you don't have any & references, only &mut. It actually works, if you're willing to refactor a bit: you'll be storing a lot of things in collections and referring to them by index, and cloning even more, but nothing too bad.
Now, go even further and refactor the program to not have any &mut either. This requires some acrobatics: you'll be temporarily removing things from those collections and moving things into and out of functions like in [2], but it's still possible.
You're left with something I refer to as "borrowless affine style" in [1] or "move-only programming" in [0].
I believe that's the bare minimum needed to interoperate with Rust in a memory safe way: unreference-able moveable types.
The big question then becomes: if our language has only these moveable types, and we want to call a Rust function that accepts a reference, what then?
I'd say: make the language move the type in as an argument, take a temporary reference just for Rust, and then move-return the type back to the caller. The rest of our language doesn't need to know about borrowing, it's just a private implementation detail of the FFI.
These weird moveable types are, of course, extremely unergonomic, but they serves as a foundation. A language could use these only for Rust interop, or it could go further: it could add other mechanisms on top such as & (hard), or &mut (easy), or both (like Rust), or a lot of cloning (like [3]), or generational references (like Vale), or some sort of RefCell/Rc blend, or linear types + garbage collection (like Haskell) and so on.
(This is actually the topic of the next post, you can tell I've been thinking about it a lot, lol)
[0] "Move-only programming" in https://verdagon.dev/grimoire/grimoire#the-list
[1] "Borrowless affine style" in https://verdagon.dev/blog/vale-memory-safe-cpp
[2] https://verdagon.dev/blog/linear-types-borrowing
[3] https://web.archive.org/web/20230617045201/https://degaz.io/...
PS. Congrats! https://verdagon.dev/blog/easter-egg-notes
(One caveat is for linears in globals, which aren't implemented in Vale yet. We haven't decided which strategy we're going with there, but TFA talks about some)
1. drop_into(list, func): It consumes the list, calling the given `func` for each element.
2. expect_empty(list): Consumes the list, panicking if the list isn't empty.
Alas, I haven't started on sealed interfaces yet. Right now I'm occupied with the Rust interop; I'm hoping we can be able to seamlessly e.g. `import rust.std.collections.Vec<i32>;` and have it work. A lot of pieces have to fall into place for that to work though, especially since that's generic and Rust doesn't even have a stable ABI. It's kicking my ass, and I'm having a lot of fun with it!
This godbolt hopefully illustrates: https://godbolt.org/z/qqMnnKbnd though apologies if it's not too clear.
If C++ didn't have its quirks, you'd fundamentally be correct. Higher RAII can almost completely be implemented by this one sentence: make destruction private. (In fact, that's why Vale's `destruct` keyword is file-private)
1. In RC and tracing GC languages, a linear object would need exactly one special "linear" reference to it, which would be responsible for eventually "consuming" (though not deleting) the object by handing it to its final destination function. (You can tell, I'm trying real hard to not say destructor here)
2. In single-ownership languages like C++, Rust, etc. we've been conflating the destructor to handle two things: fulfilling the object's final purpose, and handling exceptions/panics. It seemed convenient at the time, but it also prevented linear types.
But honestly, I think we were just stuck in a local maximum. There was no reason to change those points because we didn't know about higher RAII, and we didn't know about higher RAII because those two points prevented linear types.
But with more of us exploring this new area (Vale, Austral, maybe even Mojo!), I daresay we'll one day see linear types and higher RAII entering the mainstream. Especially if someone can come up with a better name!
Even if a language really wanted to have linear types in reference-counted objects, I don't think we'd see cycles in practice. Generally, for a reference-counted object to contain a linear type, we'd need exactly one reference to be the special "linear reference" (think a linear flavor of unique_ptr) which has ultimate responsibility for "consuming" (albeit not deleting) the contents. Making a unique_ptr cycle is much harder than making a regular RC cycle, so I'm not too worried, even if it is theoretically possible.
Re: trivial moves, I'm not sure what you mean. The system works through the entire program, not just local variables. Also, the `onException` method would also handle panics. Hope that helps!
This is my favorite part of language design: you get to play with weird ideas and see how the implications cascade through the rest of the program. It kind of blew my mind when a linear type solved a caching problem.
Now it's kind of a curse, because any time I code in a normal language (even the modern ones like Scala and Rust) I see places (concurrency, coroutines, etc) where we could have statically prevented these bugs.
Biggest example I've seen: wanting to use Java for a web server where a 10-line node server did wonderfully. Or similarly, wanting to use async/await in Rust when threads are more than sufficient.
Sometimes I think we make our problems unnecessarily complex by chasing efficiency perfection.
This brings me back. I used to make little games for Palm OS, and I was so excited for the next version of the OS which would let one use the (then new) Palm OS Development Suite to make programs. It was also the last OS I've used where an app had a central event loop. Everything else today has UI frameworks that handle it for you. Things are easier now, but I still miss it.
Perhaps there are other mechanisms out there too.
I feel like the ability to destroy another thread isnt inherently bad, just... bad with today's languages. Just a feeling though.
Rather, I think that the static analysis we see in today's languages just isn't powerful/flexible enough to reason about safety in a lot of the patterns that we know are safe. I'm also uncertain if it can _ever_ catch up to what we know to be safe, but I wouldn't be surprised if we get there in a few hundred years.
For example, borrow checking is a step forward and can guarantee safety, but does nothing about the other half of correctness, specifically liveness. [0]
Linear types (like in Austral [1] and Vale's higher RAII [2]) can help guarantee liveness, but we still have further to go.
Both are based on single-ownership (in the C++ sense) like Rust, which introduces errors that e.g. Haskell would not.
But even Haskell (and LiquidHaskell which has linear types) don't go far enough; Coq goes even further.
So yes, like you say, we have a long way to go w.r.t. correctness, even past the borrow checker though it is a big step forward.
To my original point though, even all of these tools put together will put restrictions on a program such that it sometimes won't be allowed to take the most optimal approach. Perhaps someday we'll get there!
[0] https://en.wikipedia.org/wiki/Safety_and_liveness_properties
> As seen in the benchmarks, the current Rust standard library unstable sort implementation outperforms the C++ standard library counterparts
And I can't find any generalizations like that from the author, though I may have missed it.
There is truth in this, but I'm not sure whether the reader can/should extrapolate this to larger situations (not that the author implied we should, but it was my first interpretation).
We know that in certain situations, borrow checking works really well and allows us to guarantee safety with minimal friction and no overhead.
But there are other cases where safety and performance _are_ in contention, and we must choose one or the other. Anyone who has been forced to satisfy the borrow checker by using a .clone(), using Rc, or refactoring objects into a hash map and referred to them by an ID (that must be hashed to exchange with a reference), has felt this contention. In https://verdagon.dev/blog/myth-zero-overhead-memory-safety, I concluded that there's no general approach that always has zero overhead, at least not yet.
So perhaps the best interpretation from this study is that often, for small enough programs/areas, there is no conflict between safety and performance.
For larger programs with more complex requirements and data interrelationships, the question becomes much more interesting.
> I see no reason why a straight port from Rust to C++ wouldn't have been possible while satisfying their requirements.
Like the author, I also don't see a reason for this, but I've never tried myself. I've always thought that with the restrict keyword, one could make any C++ as performant as any Rust code. Perhaps something else got in the way there.
There is truth in this, but I'm not sure whether the reader can/should extrapolate this to larger situations (not that the author implied we should, but it was my first interpretation).
We know that in certain situations, borrow checking works really well and allows us to guarantee safety with minimal friction and no overhead.
But there are other cases where safety and performance _are_ in contention, and we must choose one or the other. Anyone who has been forced to satisfy the borrow checker by using a .clone(), using Rc, or refactoring objects into a hash map and referred to them by an ID (that must be hashed to exchange with a reference), has felt this contention. In https://verdagon.dev/blog/myth-zero-overhead-memory-safety, I concluded that there's no general approach that always has zero overhead, at least not yet.
So perhaps the best interpretation from this study is that often, for small enough programs/areas, there is no conflict between safety and performance.
For larger programs with more complex requirements and data interrelationships, the question becomes much more interesting.
> I see no reason why a straight port from Rust to C++ wouldn't have been possible while satisfying their requirements.
Like the author, I also don't see a reason for this, but I've never tried myself. I've always thought that with the restrict keyword, one could make any C++ as performant as any Rust code. Perhaps something else got in the way there.
Also, not super relevant, but in e.g. combat situations one is often better off running now in any direction rather than pondering which direction is absolutely optimal for running from the lion. You'll know soon enough whether it was the right direction. There's probably a metaphor somewhere in there.
(If not, I have a lot of redesigning to do for Vale!)
Also, doesn't your mentioned problem also happen for full threads? I'd imagine they'd contend for those 10 connections no matter how you did your concurrency.