Very interesting insight from Graydon, in hindsight I too would have loved something more towards ML than C++. I never liked the kitchen sink approach that I see first C++, now Rust moving towards, but I respect what Rust has managed to solidify into. It's a good language.
That said, I still hate async with a passion, it makes the language more complex and not very elegant (i.e. function coloring). And now that I know how it works behind the scene (thanks to Jon Gjengset [1]), it feels so complicated and hacky, a mediocre very high level concept that someone managed to implement as a zero-cost abstraction. Impressive, but still a bad idea.
I'm sure the pro of having a BDFL instead of a committee is being able to follow a singular vision, instead of trying to appease members by adding the fad du jour which might stray a little too far from the original vision. Too many chefs in the kitchen and all.
Function coloring article conflated two things: one was legitimate limitation of JS unable to wait for async result from sync call, and the other was just author's opinion how the syntax should look like.
Rust's async doesn't have the limitation author described – you can spawn and block both (Tokio has some limits there, but that's Tokyo's choice, not language limitation), making "color" largely irrelevant.
The second point was that both should have identical syntax, which Rust deliberately chose not to, because from Rusts perspective that would be too much implicit magic.
I disagree. You can call an async function in Rust from a regular one, that's true, but the returned value still needs to be passed through an executor to be useful.
Async without function coloring means being able to call `async fn add(a, b: usize) -> usize` from anywhere and having an usize back, whether you're calling from a regular or async function. If you need slightly different logic because of async, you got function coloring.
In fact, a hypothetical async without coloring functionality would not even need the `async` keyword at all, as all functions would be effectively the same, so you could choose to call one asynchronously or not with no particular ceremony. Is this even possible to do without compiler magic?
Thing is, it's a function coloring that mostly doesn't impede you by making certain operations impossible without massive refactoring. Regardless of an extra method call needed, it gets rid of the "you can only call a red function from within another red function" barrier by allowing you to do it, as long as you specify how (execute inside this thread, or delegate to a worker thread). This makes most of the issues associated with Javascript function coloring evaporate.
Fair enough. It's not a deal-breaker kind of coloring, my issue is mostly conceptual. It's simply not elegant as a construct, but perhaps it doesn't have to be.
My experience with asynchronous programming is brief, but it taught me that you will never have elegant parallelism. It's a completely different problemset, you need a completely different solution to handle it. But the ideal API wants to treat the problems as one in the same.
So the two camps I've seen on solving it is either
a) blackbox it and set some rules for implementers, inevitably leaving to some hidden and very nasty bugs on the user end or bottlnecks for advanced users, or
b) give implementers the full tools to setup, launch, and synchronize themselves, which may end up being slower than a single threaded solution if the user isn't adept already with parallel programming.
You inevitably always have bits of a) creep in, even for the most explicit solutions
Its certainly possible with compiler magic. Zig does exactly that - where functions can be colorless and they work slightly differently in async and syncronous contexts, doing the obvious thing in both cases. I'm not sure how well it works in practice (I haven't spent enough time with zig to know). I'd worry that bugs would creep in to whichever variant of the function you aren't exercising.
_Unyielding_ describes the issues that arise when the author of the code doesn't have control of the transactionality / atomicity of their operations at the level they need to: https://glyph.twistedmatrix.com/2014/02/unyielding.html
To my mind it's very much a balancing act between "low power to the developer, high power to the language" and "high power to the developer, low power to the language" all up and down the stack from software / hardware to "consumer / framework".
TL;DR - he says that "doParallel" and "doConcurrently" are separate operations with distinct semantics that designers of programs must care about and that conflating the two (especially the common "doConcurrently-and-often-but-not-always-in-parallel") is one of the most common causes of bugs in programs that need to make progress in multiple threads of execution.
A common thing about writing like this article is that it confidently declares futures/promises are good for parallelism, but this is wrong - they are flawed because they introduce priority inversions.
To schedule tasks properly, you need to know who is waiting on them as early as possible. With a promise, you only know when you get to the "wait()" call, which is too late.
The correct solution is called structured concurrency.
Depends on if you're using Monix/Haskell style tasks (lazy, run when depended on) or JS-style promises (E Promise / C# Task / Java Future - run when instantiated, waiting is post-submission). Agreed completely though, structured concurrency is fantastic.
Not knowing about function coloring is a stupid dream and has terrible implications on the performance of your code, which is infinitely more important than you losing 2 minutes having to figure out that you really want to `runBlocking { callThatBlocksForADamnLongTime() }`.
async functions without coloring means that the only warning you'll ever get that `calculate2Plus2()` actually ends up running a distributed BigQuery and writing the end result to disk, printing it to stdout() and parsing that result to give it back to you is... hopefully, documentation is up to date and you read it?
Async function coloring is not a problem. Async function coloring is a solution to "software developers are awful and will do awful things without any warning". If `calculate2Plus2` did not exhibit the write to disk behaviour in v1.0.0, but bumping to v1.0.1 does, I'd really want a warning that it does at least, and ideally a compiler error. The proper solution to function coloring is to have a pleasant API to interop between both worlds so that, at worst it's just a dozen characters more to say "yep, I really want to block here".
Async doesn't solve that problem. What you're asking for is some sort of expression in the type system of expected performance, but there's no tech that can do this. Consider that modern NVMe SSDs can do disk reads faster than many calculations over the content of what was just read. A function that changes its algorithm to have worse time complexity won't be marked as async but could break your app, adding a single disk read would require marking as async but probably won't break your app (especially if it's in a non-hot path), adding 10,000 more reads where previously you had one doesn't require changing your async annotation but might break your app, etc.
So the whole sync/async distinction doesn't really make much sense outside the context of single threaded UI toolkits, where indeed you have to read the docs anyway because doing some very CPU intensive work on the UI thread will block it even if no "blocking" APIs are ever invoked (and what is or is not blocking is somewhat arbitrary anyway in many cases).
There are type systems which can track complexity (e.g. http://gallium.inria.fr/~fpottier/slides/fpottier-2011-01-jf...) but that's still different to performance; async/sync tells us things like "touches disk" or "touches network" but not "O(2^n) might take a really long time".
Alternately, an effect system can communicate "touches disk", can make functions which are polymorphic to effects (e.g. map touches the disk if the mapping function touches the disk), and can distinguish different effects, unlike async/sync being anything-or-nothing. IMO async is more of an implementation detail (as "may suspend/yield" is not a very precise or interesting effect), whereas effects would communicate the properties that ohgodplsno wants to know.
Yes, but are there any effects systems in use outside of maybe Haskell? Effects seem mostly to be stuck in the research lab and have been for a long time.
Verse has effects, which by virtue of exposing it to the entire Fortnite community probably makes it a bigger language than Haskell in terms of user counts.
I don't mean "effects" in the sense where you can have effect handlers which get delimited continuations and all; it'd just mark what could happen (like checked exceptions). But I can't think of any languages with that and not effect handlers; Koka, Eff, and Unison come to mind for effects, though their practical-ness may vary.
Adding a disk read won't break my software, but it can certainly make my software non-portable. If it suddenly expects a disk write and I'm running on a ROM, I'm going to have a hard time. If it suddenly tries to contact google.com and I'm on an airgapped machine, I'm going to have a hard time. It's not even a matter of breaking the app actually, it's a matter of warning that "I'm going to go outside the realm of just your CPU and your memory". Sure, you could also mark as as async a function that adds two numbers. It's stupid, it's a JS thing to do, but you could. Except that you need to actively opt-in to being async in this case, as opposed to have it be forced upon you because you used a function that is also async.
Blocking has meaning in a lot more contexts, and being a consultant on JVM related topics, you should know that: it's the entire purpose of Project Loom, and Loom doesn't entirely get rid of colour coding either explicitly for that purpose. Loom wasn't made because the guys at Oracle have a deep love for JavaFX, but rather takes into account the server world, where you really want to know that you're going into another context, another computer, etc. The only time where the existence of async doesn't make sense is if your entire language and ecosystem expects everything to already be distributed. In which case, you've just switched the default color to async.
Finally, you chose to read async as the current JS/C# abomination implementation, but most of the sensible languages have implemented it as an effect: Kotlin has suspend funs, they don't return a Promise, but they tell you two things: they're going to touch something like the disk or the network, and if you really want to have them in a non-suspend context, you can either get a Deferred<T> out of them (and find another thread to run it on, and handle synchronization yourself), or run them on the current thread (and block everything).
>Adding a disk read won't break my software, but it can certainly make my software non-portable. If it suddenly expects a disk write and I'm running on a ROM, I'm going to have a hard time. If it suddenly tries to contact google.com and I'm on an airgapped machine, I'm going to have a hard time. It's not even a matter of breaking the app actually, it's a matter of warning that "I'm going to go outside the realm of just your CPU and your memory". Sure, you could also mark as as async a function that adds two numbers. It's stupid, it's a JS thing to do, but you could.
You're conflating too many orthogonal things.
It's not merely because of the performance of touches disk/network that async was used for those cases, it's because that waiting is not because you're held up by the language doing calculations. That isn't the case with a function like you describe.
Marking functions async when they aren't yielding just to signal that they might be slow is a bizzare idea. That's not what async is and it doesn't bring any real benefit, it's just abusing the notion (and limiting the contexts where you can run those functions). You'll still be using libraries which wont follow this strange idea, and you should know their performance characteristics.
Blocking code exists in all major languages, including JS. In a single thread context, having something "async" wont help you at all, if it calls anything blocking, which can be something as common as JSON.parse
>If it suddenly expects a disk write and I'm running on a ROM, I'm going to have a hard time. If it suddenly tries to contact google.com and I'm on an airgapped machine, I'm going to have a hard time.
All of those have nothing to do with async, and what async is created and used for.
What you want is something like Haskell's IO "tainting", a (side) effects system, or something to that (no pun intended) effect.
Blocking in Loom is primarily about waiting for network traffic, possibly with extensions to file IO in future - it doesn't suspend if you do file IO today. Loom does get rid of coloring, or rather, doesn't introduce more of it and lets you phase out what exists, so I'm not sure what exactly you mean by that.
Kotlin suspend funs do not tell you they're about to touch network disk, that's the reason they use "suspend" and not "async". Suspend funs can also use generators (with yield) in which case no I/O is happening but they are nonetheless suspending.
So this is why blocking as a concept isn't a great one, IMO. The Kotlin designers were right to not use the word in their implementation of coroutines. Where Loom discusses blocking a bit, it's not defined as being about blocking, it's about being able to scale up threads to way beyond what was previously possible. It just so happens that the primary reason you'd want to do that is if you have lots of threads that spend lots of time waiting for things, but that doesn't automatically require network or disk access. For example you can use threads that spend all their time in Thread.sleep if you were writing an agent simulation.
> possibly with extensions to file IO in future - it doesn't suspend if you do file IO today
Wasn’t basically all of the JDK’s file APIs rewritten to io_uring-like calls to support Loom? I have thought that IO was definitely something that Loom handled.
This is a perfect solution fallacy. Yes, "whether this function will suspend (and shift execution onto a different thread)" does not tell you absolutely everything about that function. But it tells you things that are worth knowing, that you want to be visible (not super intrusive, but visible) in your IDE. And in a language with a decent type system the cost is pretty small; functions that are agnostic about whether they will run as async can and should simply be polymorphic in async-ness.
This comment reads like it's from an alternate dimension where "calling read(2) marks functions as async" is a common language feature. Over here, read(2) can block forever and normal functions can call it in just about every language.
I agree that hiding coloring entirely is really a trap. I want to know when some IO work runs because it means potential exceptions unknown with unpredictable latency in my program.
As a design choice of OCaml’s eio - an async runtime, you must pass around a dependency to all your IO functions. With the “net” object passed visible in my function type signature, I can tell some network work may occur. The first benefit is we get synchronous Go-like code.
1. Low enough that you don't need to worry for most web backend code and 2. Anything is easier than that. *XYZ is not Send* amiright?
With Rust's async you have to worry about a different worker thread picking up the work after a context switch, which makes things complicated. Not so with Java.
You can do it using a runtime with scheduling and fibers.
The issue is that you want to be able to save the entire thread stack cheaply so that you can switch to another task at an async yield point, and then go back to your previous stack frame. You want to do this without spawning system threads.
Say you have an f -> g -> h call stack that blocks on IO. If all the functions are in fact async state machines (or some other kind of callbacks) your thread stack will look like this:
Executor.loop() -> Executor.run(h) -> h.await()
That is, your functions are really objects that are queued up and taken as needed by the executor. If h has a yield point, you can put it back on the queue, go back to the loop and pick up some other work. Later you can do the above again and await h to again up to the next yield point.
Now consider the case where the middle function g is NOT async. In that case the call from f to g will be a normal function call that gets put on your call stack. In a language where this is allowed, you'll still have to wait or execute h and you'll get a call stack that looks like,
(where our awaitUntilCompletion is minimally just calling h.await in a loop until h is finished). At this point, you are stuck: you can't yield from g() because it is a normal function with a normal stack frame, so your thread has to wait till g() finishes before it can be used for anything else. You are basically blocking a thread on h. At this point you might spawn a new thread to keep your thread pool count up (which is expensive and memory hungry) or just accept the forced synchronous blocking (which reduces throughput).
If this happens over and over again, you either end up running out of memory or end up running code in a synchronous fashion but with worse performance (because of all the state machines). This is why functions are usually coloured, so that you don't get yourself in this accursed state by accident. AFAIK the alternative is to allocate all of your call stacks in the heap so that you can switch them in and out of kernel threads, which is what fibers are. This requires a complicated runtime with a scheduler such as in Loom,
Algebraic effects like how OCaml’s now implementing it may be possible! The ecosystem around effects in OCaml is still really young, but here’s an example of an http request being made that is asynchronous, non-blocking but looks synchronous with no special syntax.
Performing these effects is similar to throwing exceptions up the callstack where whichever ancestor handles the IO work, then resumes the child with IO work done in hand.
As someone who likes OCaml, but hasn’t touched OCaml in ~5 years, that’s a very hard example to read. I can tell it’s making a network request, but I have no clue:
- what the type of res is (is it a string? A buffer? Async string? Something else?)
- how this code does not block: what is happening in parallel? At which point will it block?
I would like to be excited about OCaml’s algebraic effects, but right now I don’t really get it.
- It's some type from which you can read a response body. In OCaml we often work in terms of abstract types i.e. the operations which can be done on types. If you want to see the exact name of the type you can always look up the signature of `Client.get`: https://github.com/mirage/ocaml-cohttp/blob/16e991ec1f7e5f0c... (nowadays LSP support in any good editor will show the type on hover)
- It's implementing non-blocking I/O using effect handlers. The complexity is not exposed to the library user, which is actually the whole point. If you want to dive into the concurrency library (Eio) and study how it works, that's very doable, just like any concurrency library in any other language.
There's really not that much to get–OCaml will do async I/O without having to colour functions, just like Go, Erlang/Elixir, and soon Java. It's not something sexy like monads that excite people with mysterious FP concepts, it's just a lot of hard work that went into simplifying how developers code at scale.
Yawar’s explained it all, but I’ll share tidbits of my own.
When reading ocaml on _text_ like from GitHub, I personally accept that I won’t need to know the type and underlying data structures just yet unless I read and have open the .mli file.
I’m excited first and foremost because we can have async code feel synchronous like Go.
How does the code run? IO runtime is defined in userland, like Rust’s Tokio. That’s the “Eio_main.run” part. Underneath, eio spins up and manages processes in a non blocking manner, even scheduling work.
Since effects are essentially resumable exceptions, this particular http request throws an effect to the runtime. The logic and this particular process pauses (okay I may be wrong here). Logic flow now continues somewhere in our runtime level (the effect handler), where all the async IO work happens. The runtime resumes the child process and returns the http result back to the child via a “delimited continuation”.
You can't evaluate a future in normal rust as there's no default executor, you need to pull in some library to even make blocking calls to async functions.
It's a bit strange indeed. On the other hand you can't allocate either if you don't have an allocator. The difference is just that the allocator is opt-out while the reactor and executor are opt-in with no formal default.
It's the same in C++. The compiler and language design came first and then the library (aka executor) part comes next.
Rust has basically 2 executor libraries, tokio and async-std. It seems to me like tokio is solidifying as the executor of choice and it's only a matter of time before that design is baked into the std library.
In C++ there is a default executor that is part of the standard library and has been so since C++11, e.g. no external library is needed - std::async.
std::async is mostly "good enough" for ordinary cases where you only want to fire-and-forget and have no control over the underlying dispatching and scheduling algorithms. If you want something tweaked towards your use-case, and hence actual high-performance executor library, std::async is not gonna cut it. And that's where the "C++ executors" proposal come in.
That's...not precisely true. The C++ standard doesn't specify how std::async works, and for a while GCC just ran the operation sequentially, and later both GCC and Clang launched new OS threads by default. https://stackoverflow.com/q/10059239/1858225
I've been using C# and (Java|Type)Script pretty much since they were invented. Both use a function coloring async system. I don't know what it's like in Rust, but at least in the two examples with which I have experience with this apparently much-maligned system, I really don't get the complaints. Having to "color" functions really isn't that big of a deal.
JS has first class functions that you can pass around. The different colors debate was just a confusion - there where only one type of function, just that some functions took a function as an argument, althgough JS has no async functions. All async functions in JS comes from the runtime, like addEventListener for web DOM events. The "solution" to the non existing problem the committie came up with was to introduce 3 new types of function. So JS went from one type of function, to four types of functions.
Nim is the closest to what I had originally hoped Rust would be. Especially Nim 2 with the new GC is really exciting. I suspect it will outperform all but the best hand tuned allocators.
It's even hard real time capable, which makes it potentially viable in scenarios like games where GC traditionally hasn't been.
It's crazy that the programming community even accepted the concept of async/await as a sane one.
Being sync or async is essentially a property of the attention of the caller, not of the action itself. Is "eating a donut" a sync or async action? If I'm focusing all my attention on it, essentially putting all tasks aside (after) - then it's a synchronous action. If I'm reading a book/watching a video/walking/etc, while eating – it's an async action.
How does "func EatADonut() async {}" aka "eating a donut is an inherently async action" even make sense to people?
> How does "func EatADonut() async {}" aka "eating a donut is an inherently async action" even make sense to people?
Of course it does, read it as "beware, something blocking down the road".
If you can EatADonout without blocking, please do, but want it or not that's a different implementation, one that doesn't block and the signature it's telling you so.
We're so used to sync and having hidden blocking operations. I wonder if in an alternate universe the first languages considered the blocking/async nature of operations and then some newer languages considered hiding this information into seemingly sync functions would produce similar but opposite outrage against it.
> want it or not that's a different implementation,
Implementation is the same. In both cases it's the same set of CPU instructions, but async/await languages create artifical division, forcing developer to think othervise.
So, let me explian my reasoning. Code starts with a developer's mental model of a problem and behavior of the system and then translating it into the code. The more straightforward this translation, the more readable the code. Code is a second degree map of problem domain so to speak ("real" world -> mental model -> code).
Like if you want to add two values, the simplest form of code would be "add(2,2)" which is pretty straightforward. If the code forces you to do some mental gymnastics (i.e. "2 2 addOnlyEvenNumbers") - that's less straightforward, less clear and less readable.
In the same vein, if you want to execute some function ("EatADonut" or "MakeHTTPCall") – you may care or not care about blocking and waiting for results. But it's your call. So it makes sense to give you two options to run this function. Go has simplest possible solution – "eatADonut()" vs "go eatADonut()". It doesn't matter what is a "default" here – it could be "eatADonut()" (go to background) vs "sync eatADount()". What matters that "eating a donut" is just a set of instructions to the CPU, and it's up to caller to decide how you want to execute it in terms of concurrency.
Now, "async/await" approach turned this ownership of "synchronicity" around. Now function is deciding how it should be called. Mental model of "actions" now needs to be translated into "actions being async or sync for the purpose of fitting into this language concept". Which is cognitively expensive for no added benefit.
Sure you can rationalize it, and get used to it as to any other absurd design, but it still adds unneeded complexity to the code, makes it less readable and less clear.
>In both cases it's the same set of CPU instructions
It's actually a very different set of CPU instructions. the function EatADonut is the same set, but "async" means the kernel needs to take time out of its execution to do some action as small as accessing an open thread, or as large as "gain access into a completely different piece of hardware" before putting that set of instructions onto that different thread. Not the program, the actual scheduling process between your program and the OS you are executing on. Then it needs a way to to get that result and sync it back onto whatever thread spawned it and access the result.
It is in fact a huge action, so marking it with Async is basically a very explicit warning.
>Which is cognitively expensive for no added benefit.
On the contrary, I can't even begin to imagine the amount of compiler optimizations it saves on as well to have that be explicit in code. I'm sure Go has to do all that on the fly while a colored language gets to allocate all those potential processes before the program runs. It's only no added benefit if you dont care about performance. But to be frank, you probably don't need more than a single thread if your problem isn't bounded by perormance. Parallel programming is all about getting something done faster after all.
How about waiting until all or part of donut pack is eaten by multiple eaters? Jokes aside, async/await comes from easier handling of a callback code and automatic function splitting. Writing asynchronous donut eating in loops or with yielding of partial results is very easy to swallow with such syntactic sugar. The whole model is very easy to grasp and to work with. I'm not saying that's the best solution but it definitely works well.
Regarding stickiness of function colors - it never happens when async/await is used correctly (in the same principle as IO monad isn't sticky).
Doesn't matter. You either wait until donut is eaten by others or you don't. Caller is deciding if it's "synchronous" (blocking) action or not.
The question is how you translate mental models from real world to the code, and async/await fails here spectacularly. It's just weirdly unnatural thinking about sequencial processes. It requires tremendous amount of cognitive gymnastics just to reason about simple things.
I’m sorry but isn’t this exactly how async/await works from the perspective of the caller? If you want to wait until the donut is eaten you `.await`. If you don’t, you do other stuff and then join the future later. The fact that the executor can get other stuff done while you’re awaiting the donut consumption is largely invisible from the caller’s perspective.
That's only if you marked function with "async". Developer who writes code for "eating a donut" supposedly has better knowledge of how I'm supposed to eat it - synchonously or asynchronously. So if they didn't mark function as "async" I can't decide how I should eat it, it's decided for me.
You can eat it in the same thread. Some frameworks do that implicitly (even without await) . You can also control if it's consumed in particular thread or thread pool and decide which thread should continue. More, you can chain and/or parallelize it with other async and not-async functions without any effort. Additionally, you can handle errors and exceptions same as it's async and not-async function. Every good async/await framework offers that out of the box. More, some frameworks abstract executors and you can run the function remotely or even persist it with context. Of course you can achieve all that without async/await, but it makes that extremely easy.
This was my first (and obviously wrong) mental model of how async works.
There are functions, you can call them sync or async if they handle IO or UI or they will get a necessary data later.
I still don't understand why I can't fetch a URL from top level javascript. Also I don't understand why zig async and await passes the control flow that seemingly total arbitrary way. The naive approach (put async calls in a queue, and periodically check if they are completed or can be executed) seems fast, deterministic, and good enough in every way?
Okay, maybe zig needs the speed, and can't just stop the execution of the sync code time to time to do something else, but why javascript? Maybe javascript async is build upon regular promises and regular objects, instead as a proper language element with proper support in the javascript engines? I don't know. Anyway async as is used with colors is totally against the picture that the words "async" and "await" suggest.
> I still don't understand why I can't fetch a URL from top level javascript.
> Maybe javascript async is build upon regular promises and regular objects, instead as a proper language element with proper support in the javascript engines?
Yes, they are promises.
> The async and await keywords enable asynchronous, promise-based behavior to be written in a cleaner style, avoiding the need to explicitly configure promise chains.
> The naive approach (put async calls in a queue, and periodically check if they are completed or can be executed) seems fast, deterministic, and good enough in every way? Okay, maybe zig needs the speed, and can't just stop the execution of the sync code time to time to do something else, but why javascript?
You're describing preemptive multitasking. You can create separate execution threads pretty easily in most languages (even super old C code), but that's not why Javascript and Zig have the 'async' keyword. The difficult part isn't the asynchronous execution; the hard part is handling the result from asynchronous code (i.e. the 'await' part). Promises are a simple mental model for organizing how pieces of code depend on the results of other pieces of code. The C# documentation does a good job explaining this:
Note: From Zig's documentation it sounds like 'async' is cooperative multitasking. This is single-threaded execution. It is "concurrent" code, but it isn't executing in "parallel".
Concurrency is difficult because it is explicitly resource management. You don't need concurrency for calculating the correct answer, you only need it for managing time.
You can see the async keyword on a function definition as a hint to the caller, that the action will be IO bound rather than CPU bound.
Caller can use this information to invoke 10000 IO bound tasks in parallel, whereas cpu bound tasks are limited to number of cores and may want to be throttled. In your analogy, I can eat a donut while simultaneously listening to music, but I can not eat a donut while simultaneously eating fish soup.
Now where it get complicated is when a function does both io wait and heavy cpu processing. Now neither color of the function match.
Forcing this upon the caller to invoke such functions in special ways is the strange part. Taken to the absurd you ought to have special syntax for every resource constraint in the computer, memory bound, disk bound, wifi bound, and so on. It would be better if the runtime could figure this out by itself and parallelize accordingly.
The single threaded aspect of async functions does have certain other benefits, like not having to care as much about muxes when synchronizing state.
There are so many nuances to function execution’s resource usages that I don’t see a point in putting boxes on that. The important part and the more intuitive human model for execution is from the POV of a single, calling thread. Whether it semantically makes sense to wait for the result of this call, or it doesn’t. If you need the result there is no going around that, you have to wait on that — it’s this function’s caller that has to decide what to do now.
Other possible semantics would be to start multiple tasks and wait for them all — this is where structured concurrency comes in, giving a good analogue with gotos.
Async-await is fundamentally an optimization, not about semantics only. Languages without runtimes simply can’t reason about it without language support, but managed ones could do so as every IO call goes through it — scheduling multiple tasks to a single core is now possible.
Does blocking I/O or not is very much a property of the function itself, as is "may finish up running on a different thread from the one it started on" (or, if you prefer, "needs to be run under a runtime that provides that capability").
Sending a letter and getting a reply is an inherently async action; you can stare at the mailbox all day but you probably don't want to. Waiting in line at the bank is inherently sync; if you try to do something else and then come back they'll make you start over again.
Async/await, at least in JS, is here to make asynchronous code look like synchronous code, as in you can say "let toto = await asynchronousFunction(); doSomething(toto)".
Without it when you want to do asynchronous stuff you either block on it (not good since JS is single-threaded), eg "let toto = asynchronousFunction(); doSomething(toto)" and nothing can be done during the time asynchronousFunction is waiting on IO or something (in a browser environment that means part of your website stops responding to user input); or pass as an argument to the asynchronous function a function that will be executed by the asynchronous function once it "wakes up" (usually called a "callback"), and while it's waiting, the JS runtime can execute other stuff, eg "asynchronousFunction((toto => doSomething(toto)))".
Callbacks were I think from the start in JS. After a while promises were introduced, which compared to callbacks avoids nesting, and probably have some other advantages that I don't know about. Still, they are method chaining and not "regular code". For example regular try/catch won't work as usual, you have to use .catch(). Even later async/await got introduced, which allow you to write asynchronous code as if it was regular code, with a few exceptions (for example you can only use await in an async function, top-level await took a while to land on Node, stuff like that).
I don't think using real world human actions helps with understanding any of that. Async functions make sense in JS because again, JS is single threaded and if you do a blocking call (like asking the kernel to read a file, or making an HTTP request and waiting for the response), the event loop is blocked. From Node.js documentation's on the synchronous function in the fs module:
> The synchronous APIs perform all operations synchronously, blocking the event loop until the operation completes or fails.
Is "network request" a synchronous or asynchronous activity? It depends whether your code blocks and wait's until response (or timeout) or continues executing and handling it when it comes. It's property of the "attention" of the caller, not of activity itself.
But "can be gracefully executed asynchronously" is a property of an action.
Something that pegs the CPU to 100% because it's doing intense processing isn't a good candidate for async. Similarly, some code can cause issues when written in a non-streaming fashion. Take the following example (in python):
x = [x for x in range(1_000_000_000)]
y = (x for x in range(1_000_000_000))
If you spawn a bunch of async workers doing the first one concurrently, you'll OOM your system. If you spawn a bunch of async workers doing the second one concurrently, your system will be fine.
In other words, "async" is a label on a box of donuts that implies (though doesn't ensure, people can of course still do bad things) that the donut won't explode if you look away from it.
Hmm, is it a post-factum rationalization or it's the original logic behind async/await? Let's mark "heavy" functions with a label, so user has to call them differently not to overload the system?
Even if this is an original logic, why language is deciding for me what is considered heavy or not? What if I'm fully aware that I'm doing heavy processing and I want it to be happening in the background. What if I'm writing HFT software and every call is heavy for me. Language is not a right level of abstraction of marking "heaviness" of the code.
It really just doesn't make sense. Why stop there and not start marking functions with how many times per second they can be called? Like you can call "light" function 100 trillion times per second and OOM the system, so let's mark it "func light() async 2_times_per_second {}". It only can be called from functions that have lower per second label.
Plus, if Python cares so much about not OOM-ing the system I would start by not requiring 32-bit int to occupy 28 bytes in the first place.
>is it a post-factum rationalization or it's the original logic behind async/await?
Well, I'm not that deeply versed into any language's history, but I imagine any 15+ year old language had a point where they needed asyncrhonous functionality but had to write around legacy code. I don't think Javascript would have been written the way it was in the 90's if asynchronous operations were a mandatory priority.
So it's probably a lot more "this is how we hack this in without creating Python 3.0". Relatively elegant to make it an optional part of the language that you explicitly choose to delve into, instead of a core feature that would have broken thousands of sites behind the scenes if you made it "right".
>why language is deciding for me what is considered heavy or not?
because we decided decades ago that we didn't like using fork(), nor creating/destroying processes ourselves. problems that would inevitably need multiple solutions when supporting multiple platforms because these aren't language features so much as OS calls made in a language that you may or may not be writing in.
Remember at the end of the day that languages are abstractions on top of an OS. Any interesting ideas for problems involving more than a single process space that your OS makes for you is bound by what the OS's API lets you do. It's arbituary but also based on historical problems to wrangle.
So we haven't had enough problems where we need to bound a function by how much it is called. We have for how to interact with all the above OS/language problems.
It's only functions that "block and wait" that it makes sense to be async - the point being that blocking and waiting doesn't use up CPU cycles, so you might as well free the thread up to do other stuff. If a function issues a socket write, then continues utilising the CPU while also periodically checking if data's available to read, it's effectively doing manually what async does for you, though I'm not sure there are too many real life examples of such functions. But pre-async it was certainly common enough for block-and-wait functions to tie up a thread and hence execution of programs with limited multi-threading (even today, GUIs often require all events and updates to be processed on the primary thread).
>How does "func EatADonut() async {}" aka "eating a donut is an inherently async action" even make sense to people?
You have to think of it from a higher level.
Nobody knows if eatdonut is strictly async but everyone knows all IO calls are async. So something like socket.send would be async, this is obvious.
Then anything that calls socket.send would then in turn become async itself. The async sort of spreads around like a virus. Any function that uses other async functions gets polluted with the async marker.
So in this sense, you should think of it like this. If eatADonut is async it means it touches IO. It sends data somewhere else to some other thing. It also means it's not unit testable.
In a nut shell This is the intuition you should have about async:
Async Tells You about the properties of eatADonut. eatADonut doesn't tell you anything about whether or not it's async.
This is largely identical to how the IO monad works in Haskell. Unless you want all your logic polluted with the IO monadic wrapper or the async marker you have to learn to segregate your functions into async functions and non-async functions.
Under this intuition, EatADonut, by good design principles should NOT be async. This is what it should be:
The async marker forces you to organize your code in this way, You have to do this unless you want all your functions to be polluted by the async marker. This is the proper way to organize code Anyway, as it allows your code to be unit testable. Async calls are, in general, not unit testable because the functions are not pure. So if you look at what I did above, I segregated eatAdonut Away from IO and made part of the logic of the overall code testable via unit tests.
IO calls should be very general and small functions while your other functions should be pure and unit testable. This is the overall intuition you should have about async.
Believe it or not, this specific aspect of async is actually an improvement over golang style concurrency. Not an overall improvement, it's still debatable which style is better, but I'm saying the async marker is actually a feature that other styles of implementing concurrency don't offer.
Basically, async encodes the concept of IO and function purity into the type system. It allows the type checker and You to tell which functions touch IO and which functions are pure and unit testable.
People think unit testability is some kind of marginal benefit because you can cover testing with integration tests which are basically tests that happen across IO. It's more than that. The ultimate dream of programming something as if it's a bunch of modular lego blocks that you can elegantly place together is most fully realized with pure functions. Async enables more of this style of modularity in Rust (but not without some cost which is why it's debatable whether or not it's better than go style concurrency).
Do you mean on a hardware level? Because otherwise, hell no.
If I’m writing a binary file parser that buffers some data and processes it, I sure as hell want to wait for IO, there is no other meaningful way forward in most cases. It is also not a small isolated part, but a tight loop bouncing between CPU and IO.
I hate async as well. Developers should have learned about communicating sequential processes and blocking queues and none of that would have been necessary. It creates a weird divide in every language. Just learn about threading and do it.
Rust Channel have the nice additional that they don't panic, and don't have all the weird Go Channel Axioms[1] that you have to wrap your head around to get things to work correctly[2].
For what it's worth, I don't think Java Virtual Threads (a.k.a. project loom) has implemented an explicit concept of channels, although I guess BlockingQueue could work as a channel.
There is a big issue with bringing CSP to languages like Java 21 or Go, that use so-called "colorless concurrency" or "stackful coroutines". CSP losses a lot of its modeling power when you combin shared mutable state and pre-emptive (or invisible) parallelism. And this is exactly what both of these languages do.
"Colored" abstractions like async/await are more cumbersome (especially in Rust), but they let you know exactly at which points of execution your stackless coroutine may suspend, and you can handle synchronization accordingly.
Those functional concepts are cumbersome, but threads ("green", "kernel", "virtual", "goroutines" or otherwise) are not the panacea their proponents claim them to be. I had to debug way too many data races in my life to consider them perfect. Rust still has a lot to improve on its async front (no async traits, async closures or async iterators and the async runtime confusion), it can at least promise me that I won't get any data races.
> it can at least promise me that I won't get any data races.
But not any other kind of race condition.
Also, data races are “safe” in java. Plus you can rely on immutable data for those parts. Parallelism will always be hard, there is no model for shared mutability that would be easy.
Yep, it's pretty alarming that a developer would toss away async when threading isn't a fit for all situations - what happened to the right person tool for the job? As a professional, I 'hate' some things too, but I'm not going to blindly make my work harder and less efficient out of some small preference I have.
async has fragmented the crates ecosystem. If you want to want to write async-free code with threads, you'll probably still have to opt-in to an async executor for some dependency because these days many libraries will implement only an async interface
I think you're manifesting what I'm talking about. The reason you cannot replace async with threads has nothing to do with the ecosystem, but the simple fact that threading is a model of parallelism and async/await is a model of concurrency. These are two subtly different concepts in software architecture, but they are not the same thing.
More concretely, threaded code has nothing to say about whether or not its synchronous or asynchronous, and asynchronous code has nothing to say about whether or not it's evaluated in parallel.
> I think you're manifesting what I'm talking about. The reason you cannot replace async with threads has nothing to do with the ecosystem, but the simple fact that threading is a model of parallelism and async/await is a model of concurrency.
That is an artificial distinction you made up. I have written many apps and desktop applications, all of which where using 3-10 threads, some of them doing "parallel" work meaning the parallel computation of divide and conquer algorithms, some were doing concurrent work.
I tried async often. I threw it away every single time, because it "infects" the entire codebase. I suspect the only reason async exists is because the Javascript event-loop is single-threaded and there are no blocking primitives in JS. If you don't have access to blocking data-handoff between threads, you can choose between callback hell or async/await.
It's not an artificial distinction at all, but it is subtle.
2, 4, even 256 sockets exchanging data can be worked with concurrently on a single thread and gain performance vs blocking and waiting for the first socket to finish. There's no parallelism, since they're never actively reading/writing at the same time, but they're concurrent because they exist and operate in overlapping timeframes.
Running two independent algorithms could mimic this - run part of algo A, then part of B, then A, etc. It's not useful for performance, though. To be useful you require parallelism - you have to have the algorithms executing at the same time, using multiple threads.
On async itself - it's not perfect, but honestly, there are contexts where it makes a lot of sense. I work on a lot of non-blocking C code - the entire programs are basically epoll and timer driven. As a result, the high level coordination is just callback hell. Async is very nice in comparison.
Yes, you can spin up threads to do everything with blocking, but non-blocking I/O came around specifically because the threads add overhead and kind of suck. It's worth noting that having threads can also infect the codebase. You either have to carefully manage mutexes, or you only communicate with channels and have to worry about keeping things updated and in sync. Sometimes this works great with minimal communication between threads. However, if you have one socket per thread and the sockets are all triggering actions that mess with the same data... it's not so great.
They should have fixed the overhead of Threads instead of changing the languages. Fibers/virtual threads eliminate this overhead, Java can run millions of them.
Besides IO Completion Ports for async IO has been the fastest way to do heavy IO on Windows, and it was introduced years before JavaScript even was a thing.
And that's just one example. So hardly think JavaScript was the main driver there.
I really wish people stopped using this concept, especially in the context of Rust because the `async fn`/“regular function” split is strictly equivalent to the `try_something()`/`something()` split (the first one being failible and returning a `Result` in case of failure). `Result`s and `Option`s are coloring the stack in exactly the same way a `Future` does (and `async` is pure syntactic sugar on top of future).
So, someone may like exceptions and green threads more than `Result` and `async` (and this is a completely valid PoV, even though I personnaly like the explicitness better), but thinking `async` is somehow special is just a conceptual mistake.
BTW, in the original blog post the “red” functions were actually functions with a callback parameter, which is actually very different.
> There's consistent use of Result and Option in std,
Well, there are dozens[0] of incompatible error types in std, two of them being just called `Error`[1][2].
Everytime you create a function that use more one of the different error types from std (for instance, an I/O error and a TryFrom-related error) you have to create a custom Error enum to accommodate the different kinds of errors from std…
> community agreement to do things that way
And the community agreement is e̶r̶r̶o̶r̶-̶c̶h̶a̶i̶n̶, ̶f̶a̶i̶l̶u̶r̶e̶, ̶ s̶n̶a̶f̶u̶, this_error, unless it's for a binary crate and then it's ̶a̶n̶y̶h̶o̶w̶, ̶e̶y̶r̶e̶, color-eyre.
The error ecosystem is much more fragmented than the async one (even though I think this_error is here to last, so I don't have to migrate once again…). In the async world, there's pretty much tokio (but I think it would actually be better if it interoperated better with other runtimes so alternatives could emerge)
> A lot of it is much harder to read.
Ah yes, fut.await is fundamentally harder to read than res?, right?
Overall, you vastly underestimate the friction that the `Result`-based error handling adds. I still think it's worth it, and it's been slowly getting better (did I say I loved this_error?), but it remained one of the biggest point of friction in Rust over the 8 years (time flies) I've been using it. Async is comparatively less disrupting (mostly because it only appears in the call stacks where you're doing I/O, whereas errors are ubiquitous).
> That said, I still hate async with a passion, it makes the language more complex and not very elegant (i.e. function coloring)
Function coloring seems to come up a lot in these discussions, but I don't see a better way without providing a runtime. Could you propose an alternative approach to async, without sacrificing ability to write zero-overhead, high-performance bare metal systems?
The transition was slow (Python 2 was supported in parallel to 3 for a long period of time so there was more than enough time to migrate), relatively easy to do, and brought huge benefits to the ecosystem.
It could have been done and forgotten in 2 years if some community members had not been dicks about it.
It really seemed that the issue was mostly "Unicode is really hard if you haven't thought _deeply_ about it" because application code, library and framework code, _and_ "system" Python code had to go through several iterations of "here's how to do it right" before something reasonable came out that solved issues for most / all stakeholders. And the Swift / Raku people are still sitting on the sidelines smiling, knowing that there are a lot more "lenses" to view text through that Python doesn't make easy (but neither does Haskell, JS, or Java, so there's good company in good-enough-for-now land).
JS devs have been transpiling for years now and it works.
Python should have released a conversion tool that could automatically convert most stuff and identify the stuff it couldn't convert. This kind of directed upgrade would have made the process much easier for developers to actually accomplish.
It's important to note that JS has an unfair advantage in that if you want to write code that runs on the universal platform, you're forced to use either it or something that transpiles to it. Python reached where it was on merits alone.
(That said, JS is actually a very versatile and almost-great language, getting better all the time)
Most universities I knew were still teaching Java well into python's rise to popularity a decade ago. Python got where it is by focusing on being simple, easy, and nice to use. Seriously take a look at PEP-20 sometime:
His dislike for functional constructs didn't do Python any favour, but now that he's gone I see Python is adding the kitchen sink as well. I haven't kept up with the language since that pattern matching proposal.
People who are deeply into programming languages seem to like FP too much. That and theories of type systems. Python is doing some great things, but also just fucking around a lot.
As someone who doesn't like Python. Python's problems have almost nothing to do the lack of functional programming. It does have the worst lambda syntax of any programming language but that is about it.
If they're all terrible, then the language won't appeal to anyone and will die off naturally.
On the other hand if everyone gets to add their favorite terrible idea, there may still be enough non-terrible parts to appeal to enough people to keep momentum.
> [Async/await] feels so complicated and hacky, a mediocre very high level concept that someone managed to implement as a zero-cost abstraction.
I feel like I need to point out that Donald “Structured programming with GO TO statements” Knuth included an example of coroutines in the first volume of The art of computer programming, the first edition, dated 1968. In assembly language for an accumulator machine. With a box of scraps!^W^W^W^W^WThat is to say, that C and most other languages have made coroutines awkward and thus virtually unused in the past three decades or so does not mean they are particularly novel or high-level.
Granted, Knuth used coroutines in a simulation and not for I/O, so he did not need that much of a scheduler, but still.
The Rust async story would be much nicer if they'd put in the hard work up front to support higher-kinded types, as then it could have a monadic async API like OCaml or Haskell. I don't know anyone who's used async in both Rust and Haskell who prefers the Rust approach. It'd also fix oddities like why it's possible to write a function like the following in C++ but not Rust:
template<template<typename> class MyContainer>
int getFirstInt(const MyContainer<int>& myContainer) {
return myContainer[0];
}
(In Rust it's not possible to write something like MyContainer<int>; only the int is allowed to be generic).
Are you aware of generic associated types, which landed in 1.65 (late last year)? To a considerable extent, they’re Rust’s answer to higher-kinded types. Less expressive in some ways, but fitting Rust way better, as basically a relaxation of a former restriction, rather than a new feature. If I’m reading your snippet right, GATs let you express exactly that, though minus `template`’s duck-typiness so you’d have to spell out what contract the container must adhere to.
>less expressive in some ways, but fitting Rust way better, as basically a relaxation of a former restriction, rather than a new feature
They're not expressive enough to implement a monadic async library, as far as I'm aware, so they don't fulfill the role of proper HKTs in this context.
>minus `template`’s duck-typiness so you’d have to spell out what contract the container must adhere to.
Do you think it's consistent that the T in MyContainer<T> can be "duck typed" but not the MyContainer?
> They're not expressive enough to implement a monadic async library, as far as I'm aware
AIUI you need GC in order to do that. There's a reason why Rust's async/await support needs compiler magic.
(I'd like to see standard interfaces for "pluggable" garbage-collected arenas in Rust, but this will need to wait until after Allocators/Storages get fully stabilized.)
In Rust neither is duck typed. For T you must specify the traits it must have. This makes compilation fail at definition, not at the point of incorrect usage.
For an example of GATs, you can see the following:
If Rust was a higher-level language, then I'd say yes, just automatically handle running Futures in parallel, join them when you actually need to resolve the data, and pretend that they look like synchronous functions in the 80% of cases. Though things like `select!` wouldn't make any sense mixing the two.
I continue to find the "function coloring" argument misses the point unless you're arguing from a developer experience perspective. Why should two fundamentally different things look and function the same? Want this the ultimate pitfall of early RPC implementations where everything looks synchronous?
In Rust, a lot of the friction is due to how async functions/futures fundamentally differ in model of execution and how that interplays with the rest of the language. Other languages get to hand-wave a lot of these problems away with a GC. (It certainly could be less of a pain nonetheless.)
- Futures don't execute until polled, can partially execute if something isn't ready (and would block), can partially execute and be arbitrary cancelled and dropped. There is no equivalent in sync functions unless you make them co-routines.
- Since you can "pause" futures if something would block, you need a place to store the function's state for when it's resumed. Now you must be consider if the async function's state is `Send` if the future wants to move to a different thread to continue executing -- which is why you see `pin!` used. Sync functions don't care about this since you always run to completion on the same thread, and the stack is ephemeral.
- Likewise, the `Future` returned by the async function is going to need to encapsulate it's state that it saves. In the general case, this is a compiler generated anonymous struct that changes if any state saved across `.await` changes, hence the opaque `impl Future`. This is why you see `BoxedFuture` a lot to abstract this away at the expense of an allocation. Ideally, the new associated types with lifetimes can avoid this with traits.
So if all functions were co-routines (i.e. functions that can be resumed and re-entered) they would all have the same "color". But all you really did was "lift" all sync functions to be "async" functions with no internal await points.
(IMHO, if the C# team back in the day decided to implement full blown co-routines into the language instead of just `async/await` as a compiler trick, I think many other projects would have followed suit with a more general co-routine solution instead of treating `async/await` as this special thing which is just a specific compiler generated implementation of co-routines.)
> That said, I still hate async with a passion, it makes the language more complex and not very elegant (i.e. function coloring).
async in Rust is a popularity issue even more than a technical issue.
A whopping amount of people who are coming to Rust are doing so because they want a good ecosystem for implementing network service servers. Rust/cargo/crates hits the sweet spot for them.
I am with you that I loathe async/await in Rust. However, I also have to acknowledge that without async/await, Rust is a vastly less popular language.
All the other uses of systems programming are simply dwarfed by number of people building network services. That's just the sad reality.
A zero cost abstraction is just an abstraction you couldn’t write any better yourself, not one that is actually free. If you’re lookint for an item in a hashmap, you still have to pay the cost of looking up the key. The promise of zero-cost abstractions is that you’re paying the lowest possible cost to do that, with a nice interface that you didn’t have to write from scratch.
Agreed, that's why I said might. Whether rust async is the best possible abstraction probably depends on the event loop, and your code. I could be wrong but I think if you write a trivial async function it will still have spinach for the async even if there are no yield points and it's effectively a non async function
I think a big part of any successful endeavour is alignment of authority, responsibility and competence. The problem with group decision making is responsibility is very diluted. If a bad decision is made, no one in particular is responsible.
> Library-defined containers, iteration and smart pointers. Containers, iteration and indirect-access operations (along with arithmetic) comprise the inner loops of most programs and so optimizing their performance is fairly paramount; if you make them all do slow dispatch through user code the language will never go fast. If user code is involved at all, then, it has to be inlined aggressively. The other option, which I wanted, was for these to be compiler builtins open-coded at the sites of use, rather than library-provided. No "user code" at all (not even stdlib). This is how they were originally in Rust: vec and str operations were all emitted by special-case code in the compiler. There's a huge argument here over costs to language complexity and expressivity and it's too much to relitigate here, but .. I lost this one and I still mostly disagree with the outcome.
Trust me, you do not want to put this stuff into the compiler. It's not just that it's cheating for perf, but it's confusing to users (no source code to read how these work) and frustrating that they can't write their own. Ultimately what this really means is that these things are indeed written in some language--the compiler's IR. JavaScript actually has a ton of this kind of things and every JS engine has gone through multiple generations of "what language do we write Array.sort in!?". In V8, these intrinsics are written in a DSL because doing them in asm, a special dialect of C++, or one of two compiler IRs ended up being more trouble than its worth.
You want a clear separation between what is language and what is library.
I read this as wanting a different "feeling" for the language. Go uses this approach, with maps and arrays being baked into the syntax, and things work just fine. Implementing Vec in user code is rather the C++ "feeling".
Personally I'm happy Rust took the C++ route, it makes things more interesting, but I can see his point.
Almost all high-level languages have builtin containers. I do think it's a bad fit for Rust (at least for what Rust became), but it's not the end of the world by itself.
The fact that JS has about 3 of them (is it only 3? I'm not sure of this), with different interfaces, and non-usual behavior is what creates problem there. But other languages quite successfully make their own containers either different enough or similar enough that it's not a problem.
> The fact that JS has about 3 of them (is it only 3? I'm not sure of this), with different interfaces, and non-usual behavior is what creates problem there.
V8 has over 35,000 lines of Torque, the DSL for describing these kinds of built-ins. Big chunks of that are dealing with Array methods like splice, sort, from, foreach, etc; and then there is stuff like BigInt, arguments object, a zillion methods on strings.
> Almost all high-level languages have builtin containers.
I don't think that's really true. Maybe for Python and Ruby, and other dynamically typed languages. I dunno about Swift. But it's not really true for Java or C#. Those languages have their collection/containers libraries written in themselves. Java has other problems separating the language from the libraries, but it's not due to collections.
As someone casually following Rust and haven't touched it or C++ in probably 5+ years (but keeping tabs in hope of coming back to that world), I feel like a lot of decisions he mentions are what made me optimistic about Rust adoption/positioning itself as a modern C++ replacement. So I kind of agree with his conclusion - his version of Rust would be much less interesting to me, and I think a lot of it's current users. eg. I only started playing with Rust once they removed green threads. Zero cost abstractions are a major selling point.
I was at the sidelines when Rust 1.0 was being made and I think it got into an llvm induced feedback loop. Slowly turning into C or C++ with other features but the same type, object and memory model.
Part of the reason was Rust's desire to show itself as a direct competitor w.r.t performance, I think.
Someone much more insightful than me pointed out that most of the safety advantages of Rust are really a cultural phenomenon, rather than a strictly technical one. You could write unsafe unsafe Rust that derefs invalid pointers all day but when building systems and libraries with Rust, people value safety and Rust enables that as a priority.
It is also what attracted me into C++ coming from Turbo Pascal and Turbo Basic, back in the early 1990's.
Although C++ culture could be much better towards safety, it is definitely better than whatever WG14 is doing, or C has brought into the picture for the last 50 years.
Also anyone that just copy pastes C like code into C++, is the kind of developer that will be using unsafe{} all over the place, on the languages that have them.
Telling people to stop using unsafe is much easier than telling people to not have undefined behaviour.
C developers like telling themselves that only people with bounded rationality make security critical mistakes. All the skilled C developers have ascended beyond the mortal realm and would never let themselves be chained up with crutches for the weak like affine types or overflow/bounds checking.
> Someone much more insightful than me pointed out that most of the safety advantages of Rust are really a cultural phenomenon, rather than a strictly technical one
It's not. It's a safety phenomenon. See Java, C# etc.
Java doesn't have huge focus on it, but managed to get it right.
It's not an error by default and more importantly it's not an error under right circumstances (e.g. lets say number of pops and inserts is supplied via arguments).
Yes defaults matter, it doesn't change the fact that protective gear is available, and like seatbelts, helmets and motorrad full body armour, it is up for security conscious people to make do of what is made available to them.
It's not just that defaults matter, the problem here is why have footgun as the default?
In a runtime example I can run it with tests and it would behave fine if both values are same or first arg is bigger, in Rust's case it would behave valid for ANY combination of arguments.
Heavy disagree. This is less giving a safety belt and more like saying we got airbags (that are deadly without seatbelts) and a DIY seat belt is somewhere in there too (it's in a box under the seat).
Coming from Java the C++ stuff is jarringly unsafe.
Agree that Java is much better than using raw C++.
Still, if you want to contribute to OpenJDK internals, they will only be taking pull requests in C++, not Rust.
So better learn how to build those seatbelts and airbags, for all the projects we rely on, including Rust's own reference implementation, that aren't going to rewrite their code from C++ into something else.
And what should be defined behavior? There are quite a few choices that depend on particular situations. Feature designers have no knowledge about what would you want so they left it up to application programmer to check the situation upfront and act accordingly to their wishes. If you want same behavior across the whole application you can always write generic function doing just that.
No. In Rust check is there by default, with optional unchecked access. In C++ the safety is off by default and you have to remember to check.
It's like Yaml parsers, they had "load_yaml" which is unsafe and "safe_load_yaml" which is the secure option. Imagine no surprise when Rubyists went for shorter safe looking method and got their servers pwned.
I think you are wrong. I looked and in Rust doc it says that: "Removes the last element from a vector and returns it, or None if it is empty". You better be doing check for "None".
The check is still there, that's not under dispute. GP is saying that it's still not the "same thing" as C++, since Rust will refuse to compile the code unless you either perform the check, or actively opt into unchecked access. In contrast, C++ will happily compile the code if you unintentionally neglect to perform the check.
To be precise, Rust will refuse to compile code that accesses the value without checking that it is not empty.
Rust does that with the combination of "sum types" (aka "enums with data") and pattern matching. An `Option<T>` is either `Some(T)` or `None`. Matching on an option with the pattern `Some(value)` creates a new syntactic scope where the value is accessible. This scope is only entered if the `Option` is actually non-empty.
All ways of getting the value from the option are ultimately shorthand for a match in that way. For example, option.unwrap() will either get the value if it there, or panic. Option.unwrap_or(x) will either get the value of it there, or use x instead.
In practice this is x100 less error prone than C++. Source: was burned by vector.front() on empty vectors more than once. In C++, UB. In rust, usually the "empty case" is considered when first writing the code, or at least caught during review (unwraps tend to be very visible in reviews)
for some reason I can not answer your subsequent reply so I do it here:
I looked at your example and played a bit with it and yes I agree with you - compiler does help in this case.
My old text:
So instead of checking if vector is not empty you check that the return result is not empty. I do not see much difference. If Rust compiler would choke when "else" clause in your example is not present I would understand your point about compiler preventing improper access.
`x`, the value of the Vector access, only exists within the context of the first block. It does not exist in any other scope. This makes it impossible to access when the result is not valid.
> If Rust compiler would choke when "else" clause in your example is not present
The compiler won't choke, but it will stop you from accessing the value.
It doesn't matter if you omit the `else` clause or not, the type system ensures that you can't access invalid values.
> for some reason I can not answer your subsequent reply so I do it here
Hacker News will try and discourage really quick back and forth comments. To do so, the reply button is hidden. However, if you click on the timestamp, to go to the comment's own page, you can reply there.
Problem is, you call pop on empty vector in C++, you get nasal demons. Not a case in Rust.
I'm not a C++ expert but here is my understanding. Vector is essentially a tuple of (dynamic_array_address: ptr, size: size_t, capacity: size_t). To pop a value from vector you just decrements size. So what happens when you have empty vec? Your size is 0, and you're substracing 1, which causes undefined behavior.
Correct way is to check BEFORE you pop_back().
In Rust, any number of invocation of pop() will not result in undefined behavior. You can ignore the value, or you can check it, but it doesn't expose you to UB just for slightly misusing a vector.
Absolutely and this is exactly what I do. I always check the containers before removing elements.
>"In Rust, any number of invocation of pop() will not result in undefined behavior. You can ignore the value, or you can check it, but it doesn't expose you to UB just for slightly misusing a vector."
I do not understand "slightly misusing" part. I assume in Rust it would bomb if pop() returns "none" and you try for example add said "none" to some number.
The safe thing Rust did here is affordable in Rust (Option<T> is the same size as T for many T including all references) so they could afford to do it, whereas it's expensive in C++. Could it have been made cheaper in C++? Sure, but safety wasn't their priority so who cares?
That prioritisation applies to the whole ISO language, not only to the standard library.
You turned on a feature which helps diagnose this type of mistake at runtime, and it helped you by diagnosing the mistake at runtime. What does that prove?
What I'm talking about is that Rust's Option is very cheap in all the cases where it can be very cheap, which makes this whole design feature more affordable. C++ eventually grew std::optional which is not powerful enough for this work and yet is also bigger and slower. They could have done better, but safety wasn't a priority.
It proves that there is a way to diagnose this type of mistake, that is what matters.
Unlike C, which the only way to be safe is not to touch it at all.
As for performance, ISO doesn't implement compilers, there are many ways to improve performance while keeping the semantics in line with the standard.
If the compiler vendors decide to focus elsewhere is another matter, a bit like there are languages as complex as Rust and compile faster, because that has been a concern, whereas rustc developers have their concerns elsewhere.
It doesn't, and that would be a bad thing, because warnings aren't magic and bug-free themselves. Nor is there any reason to comply with every single warning every person in the world has come up with.
> It's easier to work with than C++, but that's fairly faint praise.
So, while Rust positions itself as a C++ alternative with all the complexities that C++ developers love, it may become less attractive for other use cases. For instance, I find it highly questionable to develop a web app in Rust...
>So, while Rust positions itself as a C++ alternative with all the complexities that C++ developers love, it may become less attractive for other use cases. For instance, I find it highly questionable to develop a web app in Rust...
But we already have plenty of options for this use case (.NET, JVM, Go, Python, Ruby, node.js, PHP, Erlang, etc. etc.). Very mature ecosystems that solve a lot of different edge cases.
There are very few C++ alternatives worth mentioning, none as mature as Rust in terms of adoption/tooling.
If you need to write a webapp that would require C/C++ kind of memory handling/performance then Rust would be the ideal candidate I think.
I don't know what kind of tradeoff matrix makes you use the Rust graydon describes over the existing options.
For example my main issue with D (but TBH that's something I've given up on actively tracking 10 years ago probably) is that by including GC in the runtime/stdlib - it basically painted itself as a poorly supported competitor to C# rather than a C++ replacement.
We build a proof-of-concept backend replacement for what is essentially “SharePoint being used as a DB with a frontend by people who don’t know how to use SharePoint” and was a nice experience. We also build it in a few other languages, C#, Go, Python and TypeScript and Rust was probably the best experience of them all. We ended up going with C# because we needed Odata, and at the time we hadn’t yet run into the many “joys” of working with Odata, ASP and Entity Framework and how their model builders, really, really, really, won’t play together nicely.
Knowing what we know now, we should’ve gone with TypeScript and just written our own Odata filter on top of it, but live and learn.
If I was in a position where I could pick and chose languages, and not worry about how not having TypeScript in most things will mean our best front-end developer can never go on vacation because he’s sort of our only front-end developer, I wouldn’t mind using Rust for web-backends.
Rust GUI is obviously not a great experience. At least not yet. But it’s not that bad either. I think it’s mostly the case of how JavaScript is just so good at it and seeing such a fast pace of improvements because the entire world uses it for most GUIs these days, that it’s just hard for anything else to compete. I mean, look at stuff like Flutter or Blazor, they are backed by Microsoft and Google and they’re vastly inferior choices for most use cases compared to simply building things in React, ReactNative or even electron, and that’s not because I have some wild love for JavaScript, it’s because it’s seeing rapid improvements they dwarf it’s competition simply by being used by a lot of people.
I wish Rust would have someone like Facebook pick it up and build a frontend framework for it, but I think that is just too unlikely for you to bet on, and you certainly wouldn’t want to do it yourself, even as open source because then that would probably be your entire job.
On the flip-side, the packages that handle basic back-end web stuff for Enterprise use are rock solid in Rust. Which is impressive, at least to me, considering it’s young age. I have no idea why, but maybe some serious players are contributing to it because they use it themselves. There isn’t a “Django” or Ruby+Rails for Rust, but if what you’re building is a lot of smaller APIs with various transport methods and data access in an federated authentication scenario then Rust is surprisingly mature for the web. It’s primary disadvantage being that your TypeScript and Rust developers won’t be able to cover for each other (which is why we didn’t poc with Java).
As someone who recently started their first web app in Rust and is using Yew for the front-end, I wish they included benchmarks against other Rust frameworks. I also wish I had more experience and had looked harder; Yew is very verbose in my experience and Leptos looks a little more sane.
> it may become less attractive for other use cases. For instance, I find it highly questionable to develop a web app in Rust...
I mean who'd want to and why bother trying to support that use case? Basically none of Rust's value proposition exists for a web app while nearly every single one of the downsides do.
Hard disagree. Rust brings a lot to the table for a web app. Compiled vs interpreted makes it easier to catch (syntax) errors beforehand. You can even go as far as compiled templates. Mapping JSON (or whatever) to strongly typed objects is great. Dependency management is best in class. I rather like diesel.rs (although it is very much an acquired taste) especially if you treat it like a safe query builder rather than an ORM.
The single biggest problem I've run into is that there's no real good story for a web app framework (especially since everyone's gone crazy over async). Rocket is perhaps a bit too magical for rust folks and it's been abandoned, but I rather liked it. I've warmed up to axum, but all this async stuff still rubs me the wrong way.
Or Java, F#, Scala, Kotlin, Haskell. If we were to randomly pick a language, chances are it could be a good fit for these — Rust became as well-known as is because it was made for a different niche, where there were no competition.
Hell, even Python can map JSON to a strongly typed object. I am a firm fan of Pydantic and its competitors when dealing with JSON blobs. No-one deserves a KeyError in prod code.
Yeah, I ask because the JVM world has had that since... whenever Maven's central repository came about [0][1][2], I guess, and it ensures that there's no debate about namesquatting [3], nor typosquatting attack vectors [4].
I am constantly baffled that NPM, PyPi, crates.io etc. didn't copy this idea for those last two reasons. In my mind, it's not quite best in class without it.
Although all of those things are true, I think the main point being made is why choose Rust for that, instead of Go, .NET, etc, any of which offer everything you've outlined as being worth having, while having fewer of the downsides of Rust.
To me, one thing missing from most popular web languages is the single best part of Rust: A well-defined handling of the non-happy path.
I'm talking about things like exhaustive "switches", built-in, ENFORCED handling of "missing" values (null, undefined), and finally useful error handling.
I actually don't need any of the baremetal features of Rust. It's just that most of it's "zero-cost" abstractions are still far superior to those of other languages.
They don't, that's the "problem". Off the top of my head dependency management in Go is not best in class to put it charitably. .NET will tie you more closely to Windows. Sure, mono is a thing but you'll have more packages to choose from and fewer compatibility issues running .NET on Windows.
Perhaps. But TS is still Javascript under the hood which means implementation details leak and they smell awful. If they didn't it'd come down to a preference for compiled vs interpreted.
The number one syntactical beef I've is operators. e.g. == vs ===, ""+number.
>"I mean who'd want to and why bother trying to support that use case"
I write my web apps in C++. They tend to be little bit more than just query database / update database. They're exposed as JSON based RPC and can be accessed by JS front end from browser or third party systems we interact with. The performance is stellar and the code size is not much different comparatively to using PHP / Ruby / Python / your_pet_goes_here.
It makes me smile that my top-level comment says I would have preferred the ML-version of Rust, while you say you prefer the zero-cost-abstraction version of Rust that's more C++-like.
Indeed in software engineering there is no silver bullet nor a perfect language for everybody :-)
ML-version of Rust would not have been a uniquely interesting language. It targeting the C++-niche is what made it into quite the big name it has become.
A lot of Graydon's ideas feel like interesting extensions to ML-style languages. I bet if he had continued down that path, it would have been a lot more of an experimental language with a hodgepodge of different ideas. Which is totally valid (you need these languages to test new paradigms and features), but definitely would not have become mainstream.
Basically, I view Grayson as a leader who set the tone for Rust being a language that was willing to take ambitious swings on cutting edge features. But I don't think he would have been the person to eventually make the cuts and compromises necessary to hew the language into a cohesive, mainstream language. Rust ending up as a replacement C++ helped it not only determine which features to keep and which rules to follow, but also helped it create the right pitch for developers to use it.
This does lead to a larger question about BDFLs. Perhaps, like CEOs, the BDFL you want when you're starting a language is not the BDFL you want when you're maturing a language, or maintaining a language. Especially around feature selection, in the beginning it may pay off to add a lot of features based on user feedback, but later on it may be better to push back more. And from a psychological standpoint, I have wondered about the pressure of being a BDFL. Grayson has been open about stepping down partially due to reaching his limits, and I suspect other BDFLs have thought about it too. The job sounds exhausting and thankless. At a certain point, wouldn't you want to leave and start a new project? And wouldn't we want the person who had success once to give it another shot?
If anything, it's more a C replacement than a C++ replacement. It will take some market share from both of course (and other languages to a lesser extent too), but functionality-wise, it just isn't (currently, at least) practically able to replace some C++ use cases.
(Not your parent) Many people point to more advanced meta programming, such as variadrics, higher kinded types (though as mentioned GATs advance Rust in this general area), and specialization.
This is just one example, but moves are always bitwise, correct? So if I want an object to track all users of it in C++, I can just make a smart pointer for it whose copy/move/destruction operations notify the object about each event. How would you do that in Rust? (Similarly, what if I want a relative pointer?)
You can track all of this (things like Rc or Arc do) except move.
Relative pointers are possible, depending on what you mean. Making this safe (e.g. preventing users of that type from breaking the relative addressing) is done via the Pin type.
> You can track all of this (things like Rc or Arc do) except move.
Which means you can't track this. Tracking moves is fundamental here.
> Relative pointers are possible, depending on what you mean.
Pretty sure they're not possible in the sense I mean, for the same reason as above - you need custom moves for this. I'm referring to a pointer (not an offset; a pointer) that automatically adjusts itself when copied or moved. So that it always points somewhere N blocks before/after itself.
These are just two examples, to get the point across that Rust actually lacks some capabilities (since somehow that surprises people). You can find more.
I mean, arguably. If it is fundamental for your hypothetical use case, then sure, but this is not required for a lot of use cases, like smart pointers.
Am I the only one who works at a place where things don't make it into production unless at least one other person on the team fully understands the code during review?
Plenty of people don't, it's the most common thing. Maybe right now you are lucky enough to work in good contitions (that's great!), but are you sure it couldn't happen to you in 10 years? I think the standard tools we use in our profession should work in less than ideal conditions as well.
Oh I want my tools to help me, don't get me wrong. I just wonder if all the horror stories I hear are the norm, or just the stories we like to share the most.
In some ways I think Rust is a bad replacement for both. It's an alternative in some ways to C and an alternative in some ways to C++.
I don't think anyone who still uses C today and hasn't lived in a cave for the last 20 years would be very interested in Rust since it's just not at all like using C. There is the rare Bryan Cantrill who for some reason was seemingly unaware that other languages existed from 1990 until 2018 but I think it's safe to say most other people who primarily use C would not prefer the leap to something like Rust.
For the people who use C++, certainly they're already used to a language that wants to dominate, so Rust should be fine. In terms of features, apparently it doesn't hold up, but because it's like C++ I'm sure they're just a couple of years away from adding those things too, and then the language can continue being more important than the actual data transformations the programs are supposed to be doing.
> but because it's like C++ I'm sure they're just a couple of years away from adding those things too
If they're smart (which they are), I'm sure they'll eventually cave and add some of the missing stuff, no matter how much they want to believe these features are unnecessary. Just like how C is finally coming around and adding generics and all that. The particular capabilities I mentioned here wouldn't be impossible to add, and they can probably achieve some of them better than C++ did. But I do think there will remain use cases that Rust will fail to accommodate.
I love Rust and built some production code with it in the past. But nowadays I want something more simple so that not-so-senior developers can pick it up quickly, and I want flawless tooling, and willing to sacrifice a bit of performance. So basically I often end up with Go. Go is exceptionally great in tooling, ecosystem, any objective metrics like build times or crosscompilation... but I still don't like the language itself personally.
If there would be something just like Go, but with a bit more powerful typesystem like Rust has (Option<T> instead of `err != nil`, and so on), and a simplified ML-like language instead of an imperative one... that would be my dream.
I wish someone would go back and give love to Standard ML. it such a great language, small, and easy to learn, extremely fast, compiles fast, and expressive to boot.
It's tooling isn't great though. I think its syntax is a lot nicer than OCaml's for example.
There is this wonderful language that is up and coming called Roc that look promising.
Yeah, that is exactly what I want for when I build web applications. Something with an ecosystem like Rust's, with more advanced type system than go but which sacrifices some performance for ease of development.
I feel like Crystal is close, they just need more community to build out their ecosystem. Otherwise it seems like a perfect language (fun, simple to understand, compiled + fast, has types)
Already played with it, what has killed me are the outragously long compile times for nontrivial code and no "easy" way to crosscompile binaries, besides from some docker hack. Also its like a modern Ruby, which is great in general, but I really want ML-like functional style programming instead of OOP
Perhaps you'd be interested in Inko (https://inko-lang.org/). It's obviously not there yet in terms of tooling and what not, but it might scratch an itch for those looking for something a bit like Rust, but easier to use.
Lots of languages are aiming for "like Rust, but easier to use" -- I think I could name half a dozen. It's a laudable goal!
I'm curious how references in your language work. I see the very small example, but it doesn't explain much.
Some questions in that regard: Is `&T` a type? Can you store it in a structure, or return it? Can you have a reference to a reference? If you can have a function `f(&T, &T) -> &T`, how do you distinguish whether the reference it returns lives as long as the first or second argument? If references can't be stored in structs, how do you do non-owned iterators, or string slices?
Inko's syntax for references is `ref T` for immutable references/borrows, and `mut T` for mutable ones. Unlike Rust, you can't implement methods/traits _only_ for references, instead you can only implement them for the underlying "base" type. So `impl ToString for String { ... }` is valid, but `impl ToString for ref String { ... }` isn't.
> Can you store it in a structure, or return it?
Yes.
> Can you have a reference to a reference?
No, `ref ref T` is "collapsed" into just `ref T`, and the language has no notion of pointers and pointer-pointers.
> If you can have a function `f(&T, &T) -> &T`, how do you distinguish whether the reference it returns lives as long as the first or second argument
Inko doesn't have a borrow checker, so it doesn't. Instead it relies on runtime reference counting to prevent dropping of values that still have references to them. Over time I hope to implement more compile-time analysis to reduce this cost as much as possible, but borrow checking/lifetime analysis like Rust isn't something Inko will have.
Or to put it differently, I want the compiler to catch say 80-90% of the obvious "this ref outlives its pointee" errors without complicated borrow checkers. For the remaining 10-20% the runtime check should suffice.
Rust borrow checker and lifetimes are sometimes so frustrating. I hope that the Jakt language will evolve to the language I'd like to see: Swift without ties to Apple and Objective-C.
Apple has been pushing Swift on Linux enough that I've been checking out its progress every so often. It will probably never get entirely free of ObjC but it's a nice enough language that I'd really like to be able to use it for cross-platform desktop applications. Having a pretty robust standard library and a stable ABI (currently just on macOS) are big plusses, in my book. Having a built in REPL is icing (delicious, delicious icing).
Do you want reasons? Here are a couple from someone that has learned ML with Caml Light, and most of the latter ML variants.
VS Tooling lacking versus C#/VB, no support for code generators, no support for Roslyn, no support for GUI frameworks, no support for EF tooling, many .NET vendors don't support projects if using F#, community likes to create their own wrappers instead of embracing standard .NET projects, ....
Can I natively build self-contained binaries? One of Go's biggest advantages is the delivery chain from code to server (build for target arch, copy to target, ./run)?
I can vouch for it! I returned to C# for a tiny desktop GUI recently. I was blown away by how simple is the tooling to create a single monolithic binary. It's great for enterprise distribution. "No more installer."
I had to deal with it, when I hadto parse Transac-SQL code, and it's not handy. Very difficult to have a single executable file containing everything, it needs a whole subtree.
OCaml is easier with Opam and ocamlbuild tools.
Nowadays I program in a mostly Microsoft world (VSCode, Github, npm, typescript, GPT, ...) and I think its quite good, so no bias here. But the "shipping a native binary straight to the server that just works" is absolutely crucial for me
> If there would be something just like Go, but with a bit more powerful typesystem like Rust has (Option<T> instead of `err != nil`, and so on), and a simplified ML-like language instead of an imperative one... that would be my dream.
Not to be snarky (for once) but
- More powerful type system: that goes against the whole implementation culture behind Go and their wider philosophy
- ML-inspired instead of imperative: goes even more counter to the above
I have never tried Go and I will probably never care to try it, but I have never seen a language which manages to both be (1) simple in the Go-sense and (2) look remotely anything like an ML language.
Nim punts too many things to random GitHub libraries. Tagged unions with exhaustiveness checks for something like pattern matching on them is the responsibility of some random person in the Nim community, for example, which is a step too far, IMO.
I think what it comes down to is that the creator/BDFL of Nim has an eclectic set of things that he simply doesn't care about and will never add to the language even though they are basically table stakes nowadays.
really tried to get into it for a while, but the tooling situation feels like the polar opposite of Go, where everything needed in daily work is right there in a standardized way and just works out of the box.
I kinda like the language (its what I want basically) but the operational aspects are what I actually need and want first and foremost.
You could try C# (or Kotlin if you have a MS bias ;-)). Both really nice general purpose languages, quite performant despite having a GC and great available tooling.
C# is horribly OOP. I use it in my day job, and the frameworks wield OOP overcomplexity proudly. It has likely gone beyond Java as the posterchild for OOP: interfaces that are implemented once, classes that are instantiated once. You can't get away from it either, it is practically part of the stdlib and everyone cargocults it.
The language doesn't push you towards interfaces implemented once, but many developers indeed persist doing it for no reason at all. With proper code review we're able to make that practice go away on the projects I'm working on.
Isn't this just mostly an issue with mock testing in C#? Developers cargo cult single implementation interfaces because its so hard to mock concrete classes.
You can mock (well, "fake") POCOs with packages like AutoFixture that use reflection to generate mostly fake data, depending on what you need. You don't need interfaces or virtual, but you do need public getters unfortunately.
Lots (all?) major frameworks push for dependency injection though, where interfaces are a must, as far as I know (not for DI-the-principle, but DI-as-implemented). ASP.Net Core is a good example. It's not C# at the language level forcing interface-driven development, but frameworks like ASP.Net are so tightly integrated that boundaries blur.
You don't need interfaces for dependency injection, you can inject classes directly. I see interfaces more for separation of concerns (with domain-driven design pushed from all parts in the .NET world at the moment...), but that's not an obligation.
Yup, that's what I meant by "practically." Nobody forces you to use the Microsoft.x nuget packages, but good luck at finding packages that don't rely on Microsoft.DI, or Microsoft.Hosting, or what-have-you.
I have seen way too much of interfaces implemented once in Java, and have argued against it unsuccessfully many times. I am not working in Go, and see the same.
You can avoid quite a bit of OOP with Kotlin if you like, and with Arrow make some really nice functional code. Imo it offers a great balance of imperative vs functional, providing powerful tools for whatever style suits the project best.
Too bad graydon didn't get his way with build times. I've come to believe that the most important feature of any development environment is to minimize the built-test-debug cycle. Of course, a real system has many different ones, ranging from "language level" to "I have to redeploy and perform a complex series of actions in an app or 3". But at the language level I've found that build performance is of paramount importance. And when a project ignores build perf, everyone suffers every time they build. Since the lower-limit is the language compiler, it should therefore be kept very fast. (And the people working on the applications must constantly resist adding features that slow the BTD loop down any further.)
A good example of this trade-off in Java is Lombok. A very handy library that legitimately avoids a ton of boilerplate, but it also absolutely tanks your build time. In a real system, a large one, your team is better off just getting good enough with their editor that they can generate the hateful boilerplate, and leave Lombok out. Because you'll be paying for Lombok all the time, and only need it a small fraction of the time. There are hundreds, thousands of these conveniences that are deeply tempting but should be avoided, in every build. The problem is that the programmers become attached to these little nicities and actively resist giving them up, even though they are so costly.
On top of this, there's this bogus mantra in the Rust community that "if it type checks then it works."
I've spent of hours interactively debugging Rust code, crawling through tracing output etc, and hours waiting for Rust code to link with mold because I added an eprintln somewhere, so I'm not convinced.
I love writing Rust and think it makes my life easier, but there's this stockholm syndrome about compile times. It sucks, and it's not the type system or borrowchecker's fault.
The bottlenecks in Rust builds arent't type checking/borrow checking. It's things like macro expansion, module resolution and compilation, codegen from LLVM IR, and linking. Some of these things have workarounds like sccache, using mold instead of the system linker, etc but others are problems that not only remain unsolved but are actively getting worse in the ecosystem.
I think the "with mold" comment is expressing that even with the state-of-the-art fastest linker that's generally available for use with Rust, it still is slow. If you used the default linker, it would be even slower.
> I've come to believe that the most important feature of any development environment is to minimize the built-test-debug cycle.
I think the next innovation in statically typed languages should be to somehow break the build-test-debug cycle. We basically have the same interface to programming as dynamically typed languages, despite having considerably more information available because of the types. This should be exploitable somehow to give typed languages more advantages, and change the very nature of the loop.
>A good example of this trade-off in Java is Lombok. A very handy library that legitimately avoids a ton of boilerplate, but it also absolutely tanks your build time.
I don't think this is actually true right now and for a while.
>In a real system, a large one, your team is better off just getting good enough with their editor that they can generate the hateful boilerplate
Which you can get by delomboking if you really need to.
I'm not here to advocate for Lombok. However, you are first that I saw here to complain about much slower compile times. Can you teach me more? On a multi-core 5GHz desktop PC with 64GB RAM, who is really thinking about Java compile times these days? And I have worked on 1M+ line projects. Sure, the first compile is slow, but after, everything is incremental.
My only experience with very bad class loading performance was debugging the Eclipse Collections. Single handling, it was the worst I ever saw. Everything else is manageable. Does anyone know why that particular JAR is so bad?
It's not the compiler, in this case. Lombok generates code, and for reasons (which I have since forgotten) it always generates code and invalidates incremental builds in gradle, for example, at least when used in combination with ORM annotations. This happened at a previous contract on a company computer so I don't have the links, but IIRC the gradle docs mention the interaction(s).
I wanted to delombok to save 30s off of each build, but the rest of the team(s) refused to let it go. I thought, and still think, that was a short-sighted mistake.
I don't understand the big issue with build times: I have always found that they are almost instant for incremental builds (assuming you use lld or mold).
> I have always found that they are almost instant for incremental builds (assuming you use lld or mold)
They are not almost instant once your project grows to a certain size, which is still well within the bounds of a realistic single company’s project (I work on Materialize which is all in rust).
I don't know what that is. Some googling suggests that it's a nightly-only feature, whereas we use the stable compiler. If it's something that can be done in stable, I'd appreciate a pointer.
In C++ a small change to a header can result in having to rebuild thousands of different files. Even if each file builds fast the total can be long. I have also benchmarked including a specific header (not using it, just including it) costs .5 seconds which adds up quick in those thousand files. Another benchmark found a specific boilerplate code construct added .1 seconds to the time to compile the file each time you added that one line - and it was a line commonly repeating in a header (MOCK_METHOD from gmock - there is a FAQ entry on how to get this time down)
I do not think it has anything to do with C++. If you are using language that is strongly typed and compiled upfront and have defined some MYTYPE that is used in thousands of files all those need to be recompiled should you change the definition of MYTYPE.
Then you're not working on big enough projects or in ecosystems where you need to regularly perform clean compiles, because afaik the "incremental" output from cargo/rustc isn't relocatable.
A bunch of things you don't like about Rust? Turns out that the person who originally created the language doesn't like them either.
I know that the main point is about governance and how having a BDFL would have led to a completely different language but I really would have preferred the Graydon-BDFL-Rust to what we have today.
> Turns out that the person who originally created the language doesn't like them either.
But it also turns out the very same isn't as neurotically attached to his 'likes' as most of us are:
> The Rust I Wanted probably had no future, or at least not one anywhere near as good as The Rust We Got. The fact that there was any path that achieved the level of success the language has seen so far is frankly miraculous. Don't jinx it by imagining I would have done any better!
He understands that his likes are just contingent facts about a single mammal, not truths. This is a hard-won understanding. Most people never reach it.
I am very glad to not have Rust end up along that path (I am sympathetic to the threading idea he mentioned though).
Rust as an alternative to C++ does, for me, involve all of the weird magical nonsense that you kind of need to get any of this working. The extreme use of generics to build out DSLs to get things working. General libraries being very hard to write, but still possible, to get alright ergonomics for usage itself. And yeah... the zero-cost abstraction thing.
I think Graydon-Rust would have also been very interesting, but it sounds unappealing to me, person who wants "C++ but nicer".
But to his point... saying "you could have much faster compile times" is very tempting! Just, especially when it's messing around with Rust "for fun", the ergonomics sound pretty unfun.
Go is actually a competitor with StandardML and loses badly in every way except having Google's deep pockets to carry it.
StandardML uses a far superior Hindley-Milner type system. It has pattern matching. It has Option types for good error handling. It doesn't have bad features (for GC'd languages) like direct pointers and slices. It has immutability by default. CML even offers a better take on channels too. Modules keep interfaces more standardized (superior for big projects IMO). And to top it all off, SML is easier to learn than Go. It's also as fast as Go (despite the compilers being a side project).
Go's only advantage is more extensive libraries, but that would be fixable in SML with just a fraction of the money Google spent on Go.
> Traits. I generally don't like traits / typeclasses. To my eyes they're too type-directed, too global, too much like programming and debugging a distributed set of invisible inference rules, and produce too much coupling between libraries in terms of what is or isn't allowed to maintain coherence.
Very much this!
> I wanted (and got part way into building) a first class module system in the ML tradition. Many team members objected because these systems are more verbose, often painfully so, and I lost the argument. But I still don't like the result, and I would probably have backed the experiment out "if I'd been BDFL".
I seen this the ML first class module system mentioned in a few different contexts and want to pick it up at some point.
I often felt that being canonical was a pretty big advantage of traits. For example if you use first-class modules then every time you want a sort or a binary search tree you need to specify the choice of comparison operator for the elements. And a data structure for a search tree must somehow encode this in the type system so eg you can’t merge two trees with the same key type but different comparison functions. The most obvious way to implement this causes problems for other cases like writing functions generic in the types of keys/values (else you get extremely verbose code).
Canonical instances goes against modularity but something like modular implicits for ML-family languages could improve the terse was a lot to something closer to Haskell levels. You still miss out on the other advantage rust has: the dot operator is great because it allows identifiers to contain less context (they don’t need to have long global names or be hidden away in some tree of modules: you can have .map mean different things for different types) and gives a massive hint to autocomplete for which things to suggest (I don’t know how you even signal to autocomplete in a language like ML that you would like a function to operate on the following value so please suggest things with the right type).
Most type systems are not full-featured enough to elegantly encode traits/typeclasses or ML modules. You need dependent types, as seen in languages like Agda or Idris. And these in turn are hard to implement in a language with a traditional compile/run phase distinction like Rust: the _full_ feature-set of dependent types is pretty much only available at compile time. (Which is why dependent-typed languages tend to add "program extraction" features, which reintroduce that phase separation in a rather ad-hoc way.)
May I ask what you don’t like about traits (and perhaps insight on what the author meant)?
Being a relative rust noob compared to other languages, I always felt traits were a super power when compared to eg interfaces in Java/C++ and flexible but with useful constraints compared to the structural typing nature of Typescript interfaces
In a trait-like system (including Java interface), there can be only one implementation of a trait for a type. That's why it is global, unlike ML module-like systems. Global is another word for anti-modular.
For example, in the real world, there are multiple ways to order strings (called collation), but since there can be only one implementation of (String, Ord) type-trait pair, one ordering is canonical. That may be bearable, but having canonical (String, Hash) implementation is not. What if you want to use (say) faster CityHash instead of canonical MurmurHash? So Rust resorts to things like BuildHasher, to get back multiple implementations.
Because it is global/anti-modular, it interferes with separate compilation, and it is one of reasons why Rust is slow to compile.
Then why would one use trait instead of module? Since there can be multiple implementations in module, you need to specify. So module is more verbose. In my (and Graydon's) opinion, a bit more verbosity is worth it for modularity, but many people disagreed.
This is another theme: Graydon is okay with verbosity, boilerplate, and being bureaucratic. In my (and Graydon's) opinion, programming is work that is secretarial, not artistic, so it is unimportant whether code is ugly or not. You may think current Rust is ugly, but no, it is the way it is because lots of people really cared about Rust code being pretty. If Graydon was a BDFL, Rust would be even more ugly, and in my opinion, as a result, would be a better programming language.
Thanks for this - the sort example made a lot of sense to me.
I thought it was possible to parameterise with specific trait implementations for such a case though I haven’t used it much with I could be misunderstanding.
Importing a trait into scope can silently materialize methods on other types, with no syntax at the method call site pointing to which import statement created the methods, requiring you to ask the compiler/IDE. This can also mean that removing a use statement with no references to the type being used in the entire rest of the source file, can make code suddenly stop compiling. I've worked around this by strictly confining "specialized" traits (like std::io::Write or std::fmt::Write, why are there two Writes?!) to the beginning of a single function, but I'm lazy and put "general" traits like std::borrow::Borrow and std::str::FromStr at the top of my file.
Which types grow new trait methods is dependent on trait implementations, which can be generic and apply to an unbounded number of types, based on complex matching rules, and figuring out requires reading every impl of that trait to see if any match a given type, or asking the compiler/IDE. I want to explore languages which explicitly select a trait implementation at the call site (like a Heap<int, int_greater> type which uses int_greater::cmp() to implement a min/max heap), or naming the trait (but not picking an impl) at the trait method call site (like Write::write_all(stream, "hello world") or (stream as Write).write_all("hello world")). I think Zig takes a similar direction.
There's worse. When your trait adds function foo and your code does obj.foo(), if the underlying type later adds a foo method, compilation breaks. This is very common with traits that usefully add useful methods that are missing in libstd... which break when said method is finally added there.
I ran into this case yesterday, for some cases there are warnings now. In this case I used ".div_ceil(...)" from from the Num [0] crate.
(some_integer).div_ceil(&2)
^^^^^^^^
= warning: once this associated item is added to the standard library, the ambiguity may cause an error or change in behavior!
= note: for more information, see issue #48919 <https://github.com/rust-lang/rust/issues/48919>
= help: call with fully qualified syntax `num::Integer::div_ceil(...)` to keep using the current method
= note: `#[warn(unstable_name_collisions)]` on by default
I loved reading that part! I've always found ocaml's (and now rescript's) first class modules flexible and powerful. They're definitely awkward but in that way where you're having to explicitly think about and declare relationships you'd happily ignore until they bite you.
Recently I've seen a lot of programmers I respect consider them a weaker or failed alternative to typeclasses, basically a dead-end. And I had been reluctantly coming to the conclusion that I must be wrong in some way I'm not able to fully perceive yet. Seeing such a notable PL designer come out on their side makes me feel less like a fool.
Committees tend to design less beautiful things. Look at modern c++ too. What a mess. Everyone in the committee has to jam in their favorite feature, much of which is sugar.
Modern C++ _is_ actually a mess to be fair. Overall it's still an improvement on pre-c++11, but when looking at it end-to-end it feels awfully disjointed.
If you have a mess and add something to it, you still have a mess, probably a bigger one. To clean the mess you need to remove something (which is really hard in a programming language) not to add something.
Selecting and enforcing usage of a subset of C++ is a way to deal with that mess which companies frequently use.
They do so once in a blue moon, and only for ideas so spectacularly bad that either they were never implemented or never used by anyone. Those aren't the main problem with C++ mess, the issue is the many small but important details which are deeply embedded into the language since it started as a super-set of C.
Sure, but that's why Carbon and Cppfront are a thing now. These two actively remove stuff from C++.
We'll probably see this happen in the Rust community as well once the newer Crab language becomes established. Then we'll have to rewrite everything in Crab, but it will probably be a lot simpler than the Rust rewrite.
I don't think that modern C++ is any less messy than old C++. If anything it's more so, as even more difficult conscessions to backwards compatibility emerge. That's not the say the new functionality isn't useful, but I don't think it cleans anything up. Modern C++ is approaching an order of magnitude more complexity than pre-C++11 (which is not surprising if you are adding features but not removing or changing old functionality), and I don't think it's a sustainable increase.
C++11 is exactly where the mess started ;) C++ releases got more frequent since then, but at the same time, more half-baked features slip in that haven't been battle tested in the real world before, or need refinement over the next decade worth of releases (if they can be fixed at all).
It suffers from the same problem C++ does though, it's got a broken "prelude" and the committee just doesn't have the courage to ever fix it. Weirdly a long time ago the opinion was that since the language was academic they weren't hindered by the demands of stability that corporate users might so they could do epic things like the switch to monadic I/O.
I feel if the Haskell committee could simply pick one of the alternative preludes, and gather some packages into a new standard library, and announce it all as a Haskell 2.0 or Haskell prime or whatever it could really reinvigorate the community. But what committee would be motivated to do such a thing, bearing the responsibility if it went wrong?
I agree with the broken "Prelude". Fortunately, you can mostly ignore the Prelude and replace it with your own fairly easily. (As you suggest.)
The bigger problem is that strings are broken: by default they are linked lists of Characters. You can use better strings (like Data.Text) and you even get to use them as literals in your source, but the language ecosystem of libraries mostly assumes that you are using the default strings. And converting back and forth is annoying.
Because of all the existing libraries, it's harder to route around [Char] by yourself.
They are fixing it... it's just happening at committee speeds.
I would really like to be able to specify what prelude I want at the .cabal file. AFAIK, that's one change that wouldn't break anything.
(Anyway, Haskell is one case of a language asking for a fork. Maybe it takes the form of Idris or some other derived language having some sudden growth instead, but you are right, the committee currently isn't as fast as the community.)
> A bunch of things you don't like about Rust? Turns out that the person who originally created the language doesn't like them either.
Yep. That just goes to show that you shouldn’t put individuals on pedestals as if they are superheroes. More things than we usually think are in fact team efforts.
> I know that the main point is about governance and how having a BDFL would have led to a completely different language
That isn’t the only main point.
> > The Rust I Wanted probably had no future, or at least not one anywhere near as good as The Rust We Got.
If you would be fine with a niche language then that “Rust” would have worked for you. But if you also wanted a language with wide industry backin etc.—maybe not so much.
After reading this I'm really quite happy Graydon created Rust, but then conceded the path it has taken.
It really is an incredibly language and ecosystem, in large part, because of its performance potential.
To be clear, the only real options in this space were arguably C, C++, and maybe in some circles D in my mind. C++ and C by far had the mind share.
Had Rust gone the way Graydon wanted I don't think Rust would be so interesting in the OS and Embedded space. This is a space that it turns out is really ripe for change.
Embedded application are growing more connected, and more complex all the time. Security is a serious concern perhaps followed by or proceeded by performance depending on who you ask. Rust checks so many boxes off in this space its really hard to argue that it isn't a better solution.
Would you rather write a little embedded http server on an IoT device in C, C++, or Rust? What about an embedded networking stack? What about a mesh network stack? I know the answer I'd have every time for this myself.
,,I would have traded performance and expressivity away for simplicity''
,,A lot of people in the Rust community think "zero cost abstraction" is a core promise of the language. I would never have pitched this and still, personally, don't think it's good''
If the language makes compromises in performance, it's not a real C++ competitor anymore.
Some things are not about what people ,,like'', but that we need a language that is safe and can compete with C/C++ in performance for systems level programming, as most security problems in the world come from C/C++ memory management.
If it's significantly slower than C++, Mozilla couldn't have picked it up to replace C++ code base, as there was a huge competition in performance between browsers.
Rust does make compromises of performance. That is not always bad so long as the compromises are minor. Fortunately Rust can do most of the checking at compile time, but it if you read from an empty vector Rust doesn't have undefined behavior and that means there is a runtime check of some sort in at least some cases.
The trick is to find the right place to compromise so the cost is minimal overall even if it isn't zero.
The thing is, although the check looks like work, a correct C++ solution almost always needs to do the same work. There are real world cases where it doesn't, but lots more where in C++ we need to explicitly do the work or our program malfunctions, sometimes in subtle ways - while in Rust we get this right by default.
There are cases where you as the programmer have enough to know that there is data in the container even though anything less than whole program analysis cannot prove it. Rust will do unneeded check in those cases.
I do agree though, the cost of the check is so small that it doesn't matter in most cases.
Dropping to unsafe rust is quite easy as long as you understand what you're doing (and reading pointer is generally unsafe).
In the case of automatically upgrading from int to bignum, or green threads, going lower level would be much harder.
I just wish Rust stayed focused on being the best systems level programming language (for example finishing the SIMD package, getting into stable Rust, getting language level GPU integration similar to CUDA / OpenCL).
PyToch, and now the newer GGML for example is still written in C++ for example
I still like the changes being made in the language, just not the focus.
Rust also gives you choices. If, for whatever reason, you need to get rid of that runtime check, you can always use `unsafe`. Safety is the happy path, but you aren't locked in.
I would have had much less interest in Rust as a peanut gallery participant if it didn’t have that goal of competing with C++. Not because I am a C++ or C programmer myself, but because it has been interesting to see if alternatives to such well-established languages can in fact be successful.
Think about it: we have dozens upon dozens of Hoare-esque “sacrifice some performance for ergonomics” languages out there. But what are you gonna do when you need performance that the language is too inflexible for? Hmm... perhaps write some of your app in C...?
If it weren’t for Rust and similar languages, we would never have a hope of moving on to more modern languages for those “can’t do this in my $mainlang due to performance” problems. How would yet another language that (according to Hoare):
> and I would have traded lots and lots of small constant performancee costs for simpler or more robust versions of many abstractions.
have helped with that? It wouldn’t! You would have still been stuck with having to bind to C, C++, assembly, or whatever else sufficiently “zero-cost abstraction” language.
"Complex grammar. I've become somewhat infamous about wanting to keep the language LL(1) but the fact is that today one can't parse Rust very easily, much less pretty-print (thus auto-format) it, and this is an actual (and fairly frequent) source of problems. It's easier to work with than C++, but that's fairly faint praise. I lost almost every argument about this, from the angle brackets for type parameters to the pattern-binding ambiguity to the semicolon and brace rules to ... ugh I don't even want to get into it. The grammar is not what I wanted. Sorry."
I'm not parsing expert, but being hard to parse makes it harder to write tools that work with the language.
Personally I find rust to be not easy at all for humans to read, and so it's interesting that it's also hard for parsers to parse. Not sure what was optimized for in the design.
I've coded a moderate amount in Rust since it was born, I think the prevailing rustfmt style is too bent towards machine-perfect neatness (one example: function definitions are split so that every parameter has a new line).
This machine perfect neatness comes from lots of small places in the syntax where Rust decided let's make the syntax permissive so that it's easy to autogenerate code with some macro or to make good diffs.
Then a systematic implementation of trailing commas and allowing leading/trailing separators in some places. The result is quite sterile and to me is no longer organically nicely readable.
This is an interesting read, because I read it as "I would have done all these things which would have kept the language more pure to my vision but less accessible".
I also found this bit interesting: "It's easier to work with than C++, but that's fairly faint praise".
I see this kind of thing in my own personal projects all the time. I'm thinking "oh it would be really cool if I built X" when in reality most of the time users just want really simple stuff.
Being easier to work in than C++ might be faint praise, but it's probably the biggest draw of Rust for me. I don't want to touch C++ with a 10ft pole, but I love using Rust.
Note that the quote is specifically about parsing Rust syntax, it’s not about the language in general (which is a lot easier and safer to work in than C++).
I'm not sure it would be less accessible. I think the trade-offs would lean towards simplicity, and perhaps less familiarity. But with the right mental models, I think this could enhance accessibility (less "magic", or strange edge-cases). For example, see the discussion on not minding if users have to write something in a more verbose way if it preserves the language's principles. This type of trade-off is something that lowers the barrier to entry for beginners, who don't mind writing things out the long way.
I'm using accessible in a very general sense. The sense of "how niche would this be". My impression is that Rust would have been a very niche language if Graydon was the BDFL.
Over the evolution of Rust, I've been increasingly despairing about many of the things Graydon here dislikes. I assumed the present "syntactical insanity" was, somehow, intended; it seems, really, it wasn't.
I find Rust basically unusable -- at the level of abstraction I want to write code, basic definitions break line limits.
Rust seems to be a repetition of C++'s mistake: a language which conspires you to pretend it's another. There are now nearly as many Rusts as C++s.
If I return to any domains where Rust would be relevant, I'd probably now opt for Zig or equivalent.
Can you give an example where Rust is unusable?
I have been getting into Rust recently and it feels very natural after getting accustomed to dealing with `Result` and `Option`.
And what do you mean about "a langauge which conspires you to pretend it's another"?
What is it that gives you problems? The first example mainly uses a method chaining style of programming which might throw you off?
Or maybe the use of lambdas?
The first example seems quite readable to me. Also no heavy usage of generics or heavy type sorcery which would be more confusing.
The second example also isn't visually assaulting. The struct definitions have macro annotations but doing this in any kind of language would lead to more verbose code.
I think the main problem might be if you are familiar with functional languages which lean heavier on data flow and lambdas.
I've included just a light rephrasing below, of the first part of the first link -- but with a better design we could radically reduce it much more. Nevertheless, I find the below less of a visual assault,
use rustpython_vm.. builtins.PyDictRef, py_compile, py_serde, scope.Scope
use rustpython_vm.. InitParameter, Interpreter, PySettings, VirtualMachine
use rustpython_vm.pyobject.. ItemProtocol, PyObjectRef, PyResult
use logic.. ProgramError, ProgramResult
setup_scope :: (vm: ref VirtualMachine) -> PyDictRef =
let code = vm.new_code_object(run py_compile(
file = "stdlib/rumblelib.py",
module_name = "rumblelib"
))
let attrs = vm.ctx.new_dict()
let run : () -> PyResult[None] = fn
return? attrs.set_item("__name__", vm.ctx.new_str(own "<robot>"), vm)
return? vm.run_code_obj(code, Scope.with_builtins(PyNone, clone attrs, vm))
let sys_modules: PyDictRef =
v unwrap vm.get_attribute(vm.sys_module.clone(), "modules")
.downcast()
.ok()
.expect("sys.modules should be dict")
return? sys_modules.set_item("rumblelib", clone attrs as object, vm)
return ok(None)
vm.unwrap_pyresult(run)
return attrs
py_to_serde(type T) ::
(py: ref PyObjectRef, vm: ref VirtualMachine) -> ProgramResult[T]
let val = return? py_serde.serialize(vm, py, serde_json.value.Serializer)
let out = return? serde_json.from_value(val)
return ok(out)
The `return?` idea of yours is nice as the `?` operator at the end might be overlooked but is a significant control flow operation.
Some of your potential syntax changes seem less readable but they might look nicer to you such as changing `attrs.clone().into_object()` into `clone attrs as object`. You probably come from a python background I would assume but the rust version is easier to parse and understand but maybe to you not visually appealing.
Having an explicit return argument I could understand. I found this weird in the beginning but this is something I got accustomed.
Not sure why square brackets should be favored instead of angle brackets.
The imports in the Rust version are also very explicit and fine I think.
Replacing `()` with `None` seems silly, same with replacing `Ok` with `ok`.
Maybe the `py_to_serde` arguments of the function definition spanning 4 lines could be considered unastethically pleasing. This is something I noticed quite a lot with Rust code. Fmt also seems to break to multiple lines quite fastly when writing function or method definitions.
I find `<>` are visually distracting and take up far too visual space for their importance. `F[X]` is less arresting than `F<X>`, the thin vertical lines melt-away easier.
I replaced () with `None` (& with ref, ! with run, etc.) because I want code to read like english (ie., literate) all other things being equal. I dont find `()` "pays the cost" of illegibility by syntactical convenience.
Changing "operator-like methods" to operator words would have a radical impact on the parsing precedence and "overall look" of the lang. ... so `clone ...` would be more readable if those changes were made. The advantage of many keyword primitives is that you dont keep reusing the same syntax for everything... syntax is there to support expression. I think more is better.
`Ok()` to `ok()` was more a broad notational philosophy of basic type constructors being unobtrusive.
Scala has gone from C-like (v2) to python-like (v3) syntaxes --- and I think the ML-ish whitespace, python-ish "beat poetry" approach is mostly just better.
I buy some arguments that symbolic redundancy can help (ie., both indent and use symbols)... but Rust's syntax philosophy is clearly to "keep adding symbols", and I dislike it.
C uses symbols, but I do think C is quite beautiful mostly -- because it's so simple. Rust is creaking under the weight of its size and symbolic choices
I would prefer not wanting to read code like english. You most certainly don't want o replace common math operators with plus/minus
`c = a + b` is preferrable over `c is a plus b`. The second one is harder to parse and understand.
When writing code you are pattern matching and certain special symbols such as `()` over `None` and `attrs.clone().into_object()` over `clone attrs as object` are just faster to detect at an instance.
Using more english prose becomes a word salad that is harder to parse than using special symbols which convey meaning. Certainly more familiarity with a programming language and it's syntax elements will help you your pattern matching mechanism and allows you to understand code faster. In my opinion Smalltalk gets it quite well in this regard.
I also would rather prefer lisp language syntax, or apl.
Rust is a low-level programming language meant to control everything about the code’s execution. It will by almost definition, be complex.
You seem to want contradictory things — if you don’t need that level of control just use any of the litany of high level languages with nice syntaxes. I don’t think we can eat this cake anytime.
The major innovation in this space is to make the compiler an interpreter which can evaluate the language at compile time (and emits code for runtime where it cannot).
This makes brining in the "full power of dynamic languages" almost trivial and extremely performant.
Delete half of rust's symbols, get rid of its macro system, rewrite it until lifetimes are inferred /or/ allocators are explicitly chosen, etc. etc.
I don't want to feel visually assaulted when writing the type signature of the sort of function common in a dynamic language. Here `mypy` is also treasonously guilty.
Consider, eg., zigs "function which returns a type at compile-time" is an example of where blindingly-obvious syntax retains its blindingly-obviousness because of compile-time eval... ie., we dont need a "second syntax" to program the compiler.
This "two syntaxes, one for the runtime and one for the compiler" approach -- has swamped Rust as it aims for greater expressiveness. Not least, because it has a third syntax: one for unsafe.
You quickly gets into the undecidable category once you to down that road, which might be fine if you either don’t care about safety, or have runtime checks for that, but none of those were an option for rust (or at least an option that would have made it remotely interesting).
It's undecidable in the general case, but it becomes doable in non-Turing-complete languages that are still expressive enough to be useful for many practical cases. (These languages can express unrestricted recursion as an I/O-like effect that's only available outside the language proper, as part of compiling to a binary.)
Then you are not the target audience of a low-level language that supposed to run without a runtime, but still safely. Don’t try to change the tool, when you could just choose a more fitting one.
> This "two syntaxes, one for the runtime and one for the compiler" approach -- has swamped Rust as it aims for greater expressiveness. Not least, because it has a third syntax: one for unsafe.
What are you referencing, concretely? For example, "unsafe" doesn't add or remove syntax.
Split the syntax into whether it 1) emits no code (compiler-facing); 2) emits weakly-related code (eg., macros, etc.); 3) emits code close to what's written abstractly (eg., value operations); 4) emits code close to what the machine needs (eg., unsafe code) -- etc.
And you'll find about "4 languages" all mixed together.
I still have no idea what you're talking about. Can you give an example?
Most "syntax" in AOT, statically typed languages does not directly generate machine code, but it does directly impact what machine code is generated. So there's not a clear distinction in practice.
For example, a lot of the syntax is used to control method selection and verification - that's not a "different" syntax or language by most folks' definitions.
Compare this with lisp, where there's one syntax -- compiler operations are "compile-time" interpretations of the very same syntax.
Or below, let's invent a language where there's (in my sense) "one syntax for everything",
Eg., consider something like,
const SimpleTrait = trait with:
val name
def GenericApiTrait(type T) =
return new trait with:
def response : self -> str
const MyAPI = GenericApiTrait(SimpleTrait(new class)) with:
val name = "World"
def response : self -> str = f"Hello $name!"
# later on, in the app,
def calc(x, y) : int, int -> int =
return x + y
# ie., the **same** syntax as that earlier which targets compile-time
Here you can see that code with ordinary run-time semantics is used for the compile-time operations of generating a trait and specifying that a class implements that trait (ie., we call the trait-defining function on the class).
Whereas in Rust, the syntax for returning values and "quantifying over types" is radically different.
This is the historical approach (C++, C# .. almost all langs) -- but not one modern innovative langs follow.
I think with as much desire for novelty rust has, cramming it all into its "compile-time syntax" has hobbled it.
A simple uniform language for both c-time and r-time could be used
Ah ok, I see what you're saying. It's an interesting idea.
One of the reasons that something like unified syntax is not preferred is because there are some important distinctions between runtime and comptime, and a lisp-like syntax doesn't translate well because lisp doesn't have a meaningful distinction between the two.
For example, it's desirable for some folks to have the "comptime" work (declaring types, imports, function signatures, etc) to have a purely declarative syntax. And languages like Rust use declarative syntax to express things about types, interfaces, and their constraints. Contrast that with the logic of the program which is more convenient for a programmer to express in the imperative style.
> The major innovation in this space is to make the compiler an interpreter which can evaluate the language at compile time (and emits code for runtime where it cannot).
Show us an extant language that does this, then.
Partial Evaluation (automatic) was a very unsolved problem, last I looked.
Yeah... Rust was first intended to be a high-level language where one could create an entire browser engine without sacrificing a lot of performance.
The fact that it is a law-level language best fit for system programing came much later. Late enough that there are people here and there still trying to use it for the original use-cases.
To be successful, it is not enough for a language to be good. It might not even be necessary. What matters is if there is s significant niche where the language is a better fit than any alternative.
PHP show that a language only needs to get that one thing right.
Rust have found its niche. Graydons vision seem to be a more elegant language which would compromize on the points which actully make Rust succesful.
> What matters is if there is s significant niche where the language is a better fit than any alternative.
This is a wonderful comment, and puts into words something I've been thinking for a long time. The best general-purpose languages always seem to start out with a strong niche, then grow from there.
At least Rust has left the door open to allow panicking on overflow even in release builds in the future - presumably on platforms where hardware support makes this cheap enough.
The correct (but hard) way to prevent overflow-related bugs would be to insist on the compiler being able to prove that no overflow can occur. Basically the same thing you do in your head to convince yourself that the program is correct and won’t overflow, only in a more formally rigorous fashion. Modulo semantics by itself doesn’t prevent bugs.
Rust has an optional clippy lint that will warn/forbid all basic arithmetic that might over/underflow, forcing you to use dedicated methods with explicit behaviour instead.
It's very annoying , but I use it in code where this is critical.
> And then you may just have introduced side channels in crypto code.
incorrect crypto code. If overflow is intentional, it should be annotated as such in operations, will generate similar assembly and won't panic. If it isn't intentional, then the code was bad to begin with.
If the branch can be mispredicted, and the misprediction happens depending on internal state you don't want to leak (cf. Spectre), then you have a side channel even if the branch is never actually taken.
Haven't followed Rust too much, but I'm always surprised when I hear that Rust is too difficult or not ergonomic. As I understand it is meant to be a systems level language; something you'd use to write kernels, TCP stacks, browsers and ssh daemons.
Anyone writing these things today in C or C++ already understands object lifetimes and Rust just adds a static checker for them.
In such projects churning out lines of code is not the bottleneck, ease of development should not be prioritized over long term maintainability.
Why on earth would you try to rewrite python CRUD apps in Rust?
> Anyone writing these things today in C or C++ already understands object lifetimes and Rust just adds a static checker for them.
I've certainly seen seasoned C++ programmers saying this. Of course, a few of them of say they understand object lifetimes so well, they they don't need the static checker!
> Why on earth would you try to rewrite python CRUD apps in Rust?
This is one of the great mysteries of our times. I do think the enthusiasm for using Rust for web apps and such (a) is misplaced and (b) has been a drag on Rust developing into a better C++ replacement.
> Of course, a few of them of say they understand object lifetimes so well, they they don't need the static checker!
There is a difference between understanding lifetimes and being able to keep track of them. I have a lot of objects in my more than 10 million lines of code. Most of them have simple lifetimes that are easy to track, but a few for reasons (which may or may not be valid - often the reasons are it was built in C++98 and updating to modern lifetimes is hard when it is used all over) have complex lifetimes that are tedious to track. It isn't that I can't, it is that I get bored/make mistakes and the static analyzer wouldn't (Or course C++ can't be statically analyzed, but if it could the static analyzer wouldn't fail for the same reasons I fail)
I think there's enough data that even the best human programmers and lifetime checkers aren't able to track lifetimes over a 10 million line code base.
That's why we're stuck with lifetimes as function contracts.
Being able to stop constantly keeping things in the back of your head and just trusting the compiler to complain if something is off was the biggest differentiator for me by far. Less footguns = more better
When I switch to C++11 and unitue_ptr things got a lot better. There is still a lot of cruft from old code, but C++ is a lot better as of 12 years ago. I don't let people manage raw pointers without good reason (I wouldn't let someone use unsafe rust without good reason either)
> Why on earth would you try to rewrite python CRUD apps in Rust?
Because Rust has a lot of incredibly helpful features that make bigger systems far less of a pain to maintain. I work in Java/Kotlin, and my entirely gut-based estimate is that 66% of problems wouldn't happen in Rust.
My big favorites are:
- Sane, well-defined, enforced, opt-out error handling
- Sane, well-defined, enforced, opt-out handling of "missing" values
- Exhaustive switching
- WITH usable ADTs (enums) for encoding valid state
None of the web languages I know have all of that.
But even Java is pretty much there: checked exceptions are available (and imo superior), but Optional is also an.. option. Kotlin/scala can handle nulls, but java can also statically analyse every usage with annotations.
switch expressions are exhaustive in java over enums and sealed ADTs. (Rust has a misnomer, their enums are ADTs, java has both real enums and ADTs now).
>Anyone writing these things today in C or C++ already understands object lifetimes and Rust just adds a static checker for them.
As someone who only used Rust casually - understanding object lifetimes and knowing how to encode this in Rust type system is not the same thing. Not to mention that Rust can't statically prove some things that are valid (eg. cyclic references).
One thing I wished was on this list, but wasn't, is syntax. I love many syntax decisions Rust made, but I wish Rust hasn't borrowed so much syntax from C/C++. The syntax of these languages was designed under (for todays standards) weird keyboard and encoding constraints and many choices are just odd.
To give you a few examples:
- = instead of == for equality would have been the natural choice
- := for assignment is similar enough to what is used in math for definition, so that languages like Pascal use it
- <> for inequality is something SQL got right
Smaller things that bug me are the ubiquity of the double colon (::) and the weird mixture of snake case and camel case conventions.
And not to leave the wrong impression, I think Rust got many things very right. My personal highlights are:
- -> for the return value
- concise keywords like `fn`
- `where` for constraints
In general more Algol/Pascal and Haskell - less BCPL and C/C++.
Assignment is more common than equality. Typing two identical sequential characters "==" is much easier than typing ":=" especially since on most keywords colon requires shift and equal does not. And having a two-character equality operator helps align things with "!=", ">=", "<=".
> - := for assignment is similar enough to what is used in math for definition, so that languages like Pascal use it
I think its cppfront that is taking the approach of `:=` being a declaration with the type being inferred (ie shorthand for `: Type =`). Reading up on that has made me the most ok with applying this to functions (which I see coming up more these days) but I think i still prefer functions having a more distinct look as I process them differently when reading. Now, cppfront's approach to types I think is bonkers, making critical details hard to find except maybe through convention.
> Tail calls. I actually wanted them! I think they're great. And I got argued into not having them because the project in general got argued into the position of "compete to win with C++ on performance" and so I wound up writing a sad post rejecting them
I don't understand the subtleties here: Is tail calls an optimization the compiler can do irrespective of the language? Or is there something that prevents this and requires the compiler to use stack here? Is there any visible effect to the programmer from supporting tail call or not, other than performance and stack depth?
How does not supporting them compete with C++ performance?
Tail call optimisation means no work can be done after the call and before the return. You can't deallocate stack, call destructors, convert types, rethrow exceptions.
Therefore _yes_ the compiler can always do it, but it may involve patching the called function to do some work. Splicing code into it and/or changing the calling convention. That's difficult to do for unknown caller/caller pairs, e.g. function pointers or separate compilation to machine code without enough metadata to patch it later. It runs a bit close to "sufficiently smart" compiler which usually means doable in theory but unlikely in practice.
If you pick global designs to make them easier - probably most notably having the callee clean up the stack frame from the caller - and that makes other things slower, then you've given the competition an edge.
Clang now has tail call annotations for C++, which works mostly because the compiler can reject them in the cases where it hasn't implemented the lowering. Rust can probably have it in the same approximate circumstances as C++.
Interesting that tail calls are mentioned and implied to never come to Rust. I agree with your assessment and actually there is a RFC[1] in the works to support them. (Note that while I'm the author, this RFC is definitely a community effort.)
It’s about stack size. Programs that rely on tail calls (in particular recursive ones) may cause the stack to overflow when the language implementation doesn’t actually support tail calls, for example when using tail calls to recursively process a list that is larger than (some constant fraction of) the stack. With an infinite stack, it would just be an optimization, but in practice the stack is finite (and significantly smaller than the heap), and thus doesn’t lend itself to such a programming style when the implementation doesn’t support tail call optimization.
That's why we write programs that rely on tail calls only for languages that have TCO in their specification. Where TCO is idiomatic (e.g. Elixir) implementations not supporting it are impossible because they will be able to run only the simplest programs.
The problem is adding TCO to a language that was born without it. It won't break existing programs but you'll be able to run a program using TCO only with a specific compiler or interpreter from a given version. It could catch up if the main implementation of the language does it, but often we care about compatibility with the alternative implementations. Think about Ruby and Python, MRI and CPython and all the other implementations of the language. If I'm not wrong, MRI added TCO under a runtime configuration variable but JRuby doesn't support it.
What I actually meant to ask is: given what you said, is supporting it then even a language feature, and not a compiler optimization instead?
If rust says they don't support it, does it mean they don't even allow the compiler to do it?
Obviously the syntax of the language itself already supports calling the function itself, and whether tail call optimization is supported or not doesn't affect the visible result afaik (except in case of stack overflows / performance)
It’s a language feature in the sense that it determines which programs are viable in the language. This is similar to garbage collection. In principle, you never have to explicitly free memory. Given infinite memory, garbage collection would only be an optimization. But since memory is finite, it becomes a language feature. Without garbage collection, programs that don’t free their unused memory will typically run out of memory sooner or later, similarly to how a tail-calling program will run out of stack space if the language doesn’t support it.
Its not that the compiler cannot do it, it is that you cannot be sure the compiler will.
Some constructs cannot be optimized for tail recursion. Languages with tail calls have guidelines of how to must write your code to ensure the tail call happens - often a seemingly small code change is the difference between tail call optimization happening or blowing up the stack.
Second, in at least some cases where the optimizer can apply tail calls you need to be guaranteed it will happen. Nothing stops a C++ optimizer from applying tail call optimization (I don't know if any do, but it is allowed in some cases), but the language doesn't require it, so even if your optimizer supports it you can never know that it happens - and more importantly you cannot be sure that after changing the code or upgrading your compiler you will still get it. Thus even if your optimizer supports tail code optimization you dare not do deep recursion.
If your language doesn't have support for tail calls you cannot do deep recursion with confidence. If your language does, then you can do deep recursion so long as you follow the rules of the language. (whatever those are - I'm not up on the latest research here, so I don't know the state of modern tail recursive languages are.)
I suppose a difference of speed turns into a difference of ability at some point. If you can't rely on tail calls, you will have to manually trampoline the calls, just in case the optimization didn't kick in and your program dies.
Or like xmtp or mail or whatever, you generally write your client assuming you are going to talk to any spec compliment server, which can stop you from using some extensions if you aren't sure they will be supported.
I thought on 64 bit systems the stack could basically be infinite for all practical purposes. Is this only a problem on more limited (i.e. <64bit) systems or am I misremembering when I last learned about stacks 10+ years ago?
It is always a problem, in the same way that holding on to huge objects is always a problem. Let's think about this in terms of a memory map, so ignoring limits of physical memory. On 64bit systems we actually have 48bits of usable address space, so that's 256TB. Now, the OS is going to need some workspace, but I think we can ignore that for this argument. So if you're only going to run 4 threads then each one could have a huge stack of 32TB, but if we wanted to run a huge server with a thread per connection then maybe we can only have 256MB per thread.
Now, 256MB is pretty huge, but we probably want way more memory kept for heap storage and similar things (because managing lifetimes purely on the stack will be hard and might require a lot of copying of data), so it will be less than that. Now, you may ask, "Why not start with small stacks and make them bigger as needed?" It's a good question, but since our stacks are contiguous areas of memory and we don't know what's in them, we still have to space them out in our memory map allowing for as much growth as is needed.
We might solve some of these problems by introducing segmented stacks, but this is one of those problems that crosses so many language, runtime, and OS boundaries that it's been hard to do in the general case, and it feels like gently nudging people towards writing code that can be tail-optimised is easier, just as it's easier to push people to use async than it is to provide systems and APIs that would allow for blocking code and a huge number of threads.
Thanks, I didn't realise the usable address space was so "small", those numbers are well within reasonable usage so that's definitely too limiting to just say it's infinite and not worry about it.
Apart from address space, there’s also simply the question of wasted resources, because in tail-call-optimizable programs, that stack space is by definition not strictly needed (otherwise it couldn’t be optimized away), so you might unnecessarily be wasting many GBs of memory. Lastly, TCO can improve cache locality and thus potentially speed up the program.
Yes and no. In theory it would be totally fine to "allocate" a stack that was exobytes in size (or whatever extremely large number). Thanks to virtual memory no one really cares... if that memory isn't actually being used. But as your stack grows larger and larger the OS has to find pages of physical memory to assign to that allocation, and you're simply going to run out of memory. A high end home PC or average server is only going to have 64-128 GB of RAM, a very high end server might have a few TB. Of course when you start to run out of physical memory the OS will start paging (dumping data from RAM to disk to free up memory), but that will kill the performance of your app even with high end SSD's. If you keep chugging along with your stack growth you'll probably hit OS limits on page file size next, if not you'll eventually run out of disk space.
I’m very glad expressivity won out. I would like our profession to stop accepting tools that waste effort.
I’ve always seen safety and lifetimes and borrowing as the main value prop, so I was surprised to see he was sort of aiming at an ML without GC, rather than a C++ that doesn’t blow up.
One thing I want to add wrt. "Cross-crate inlining and monomorphization. I wanted crates to allow inlining inside but present stable entrypoints to the outside." [..] is that it was in general well desired AFIK by most developers but so far out of scope that you probably would need to had the resources rust has today _before_ the 1.0 release to get it done right. At lest with the state CS research had been at during that time. By now due to various reasons (e.g. swift) there is much more research in that direction already done hence a new language has it much easier.
The problem is that it's not that simple to make it work nicely you need to design you language around such constraints. Trying to do it the other way around is even harder.
For example rust in it's current design relies quite a bit on the combo of: generics monomorphisation + inlining + dead code elimination. But this reliance doesn't play well with an stable ABI. Similar features like generic associated types and similar do not make the story easier. Like an rust stable ABI likely would only support `dyn Trait` and no generics or "on the fly" provide a `dyn` variant for generic method where possible. But even that isn't really good enough for a lot of use cases in a lot of different ways. Additionally a ton of important rust things are not `dyn` compatible at all.
Even some of the "easy" to solve things aren't that easy for non-technical reasons. E.g. a lot (but not all) of `impl Into<T>`, `impl AsRef<T>`, `impl Borrow<T>` etc. cases are best handled for a stable ABI context by aplying the single function of the trait (`.into()`,`.as_ref()`, etc.) _before_ calling the function and only having an ABI stable version for that. The side of which traits qualify for this is easy (you annotated the traits) the "when not apply it even if it seems valid" part isn't that easy not for technical reasons but for communication/documentation/avoiding unexpected outcomes reasons.
Ah, Sather gets mentioned. That's a name I haven't heard in a long time. I remember looking at it during the latter half of the 90s when I was searching for the perfect OO language...
Sather was something I was first exposed to at university in the mid 90s, and I still have a lot of respect for its looping mechanism. Code inclusion in classes rather than inheritance was also pretty cool. Pre/post conditions, invariants. I miss a lot of it.
I recently revisited it for Advent of Code (where part of the challenge was getting the compiler itself to even build with even semi-modern tooling), and a lot of the above features still have value.
Can someone comment on "Library-defined containers, iteration and smart pointers"? I have no rust experience so far. Magic compiler support for such primitives is something I usually very much, really strongly dislike, it was always my experience that it is the wrong point of abstraction because it just reduces the design state so much. But then you need good support for inlining the relevant parts of the language, which I think is very often very poorly done. In fact, I have so far never seen a solution I like.
What is the rust experience wrt to inlining? Can every expression be inlined or only selected ones? How can you know what got inlined in some expression? Do you have to manually annotate every single function call you have to inline or is there a more general command?
Everything that's visible to the compiler is subject to automatic inlining. That is all code in the current crate (compilation unit), all concrete instantiations of generics (regardless where defined), and all functions marked inline (regardless which crate).
Stdlib containers are all in the generics category.
> all functions marked inline (regardless which crate).
Is this a problem is rust? Is too much/too little marked inline? What if you really need to inline some function from some library that was not marked inline by the author?
If containers, control flow and so forth are expressible as library code you get a simpler compiler and stuff determined users can reasonably debug, modify, replace. It means you have to improve the core language enough to implement them and/or have magic compiler intrinsics which are only intended for use by that library.
If you implement these things in the compiler (open code means emit the implementation inline as you go, can also emit calls to the compiler runtime which is roughly similar to library code that the compiler ships and knows lots about) then users need to hack the compiler to change them.
However, if the structures are in the compiler, and you've done things like encode them directly in the AST, the compiler has a better chance of emitting useful diagnostics for them and of optimising them at the semantic level of the container.
C++ goes with library code supported by compiler intrinsics, and a common developer experience is compilation errors referring to iterators some distance into the library code. It also can't sanely do things like call reserve on a vector outside of a loop, because by the time it's ready to optimise things it's holding raw pointers with mangled names, not a hashmap instance.
Conventional wisdom is to put containers in the stdlib. D has some support in the compiler. I'm starting to think this is one where conventional wisdom has got it wrong.
It sounds like the Rust He Wanted has a lot of thematic similarities to Elm. Interestingly, Elm has a BDFL, and a development process that reflects that. And he’s right - there are a lot of people who really don’t like that!
Overall, a really interesting article. Though I like today’s Rust, I do think I would prefer the trade-offs made by the alt-Rust outlined here.
Perhaps it’s just my own personal preference, but I think there is a strong bias in users towards what they are already familiar with, and it’s hard to break away from those without a BDFl or similar position of authority who can impose their vision.
The language creator has preferred to work on the development of the language largely in private, for the last couple of years. He’s explained why he’s chosen to do this and why he thinks it is beneficial. But, understandably, some people haven’t take too well to the lack of updates.
I think whether you view that as benevolent or not (I would, because I agree with lots of de facto development standards being detrimental to language quality, but I understand that that is my opinion and not hard fact) is in the eye of the beholder.
Great read from a creator of a language. There are so many new languages these days, it is always enlightening to hear the author discuss design tradeoff's.
That being said, man, its easy to forget how difficult and complicated creating a good language can be.
Makes me wonder if things like Linux, or C++, were historical anomalies, the stars aligned. How many good projects fail because of 'loosing arguments' that should have been won, or the community didn't form, etc... a million things..
> I was weirdly focused on [the Actor] model that in practice has many issues
I maintain the actor model is probably the most theoretically perfect concurrency and distributed computing model. The holy grail. We just don't have the right hardware for it and it's extremely limited by addressability issues with current technology.
So I don't really find this surprising, nor disagreeable. It's just not a model that Works Well at present.
I'm not sure I agree. Runtimes like Erlang's work great for many things.
You just have to be aware that actors aren't going to magically get you more CPU cores — i.e. you can have as many IO-bound actors as you want; but a CPU-bound actor (done correctly, such that "gets out of the way" of actor scheduling) is just a regular CPU-bound preemptive OS thread; and you can only realistically have as many of those as you have CPU cores in your machine, before you start experiencing highly degraded performance.
Most systems don't need more than 100 (different) CPU-saturating things to happen at a time. If you do, actors won't save you... but nothing else will, either. You'll need to scale horizontally. (At which point, the actor model becomes very useful from another perspective — that of transparent distribution of messages between nodes.)
But tbh, there's a reason that, even on the systems Erlang was originally designed for and is "idiomatic" for — those being telecom packet switches — the Erlang software only performed the role of the control plane. There was also a data plane in each of those boxes — some kind of FPGA or ASIC — designed specifically for the job of applying a list of active routing rules at each input port, such that packets on that port would be unwrapped, maybe filtered, route-matched to an output port, maybe buffered to combine with other packets, then re-wrapped and emitted at said output port. The job of the Erlang code was to listen for signals bubbled up from the data plane, in order to build complex state-machines, do accounting, etc., resulting in change commands being pushed back down into the data plane ruleset.
Actors are great for keeping a bit of local state in order to make decisions, and coordinating with other actors and their local state to make more complex decisions.
Actors implemented naively — where all actors are uniformly green threads — are not so great for doing the same thing over and over, at scale. Telling N actors to do the same thing is no substitute for a DSP, nor for a tensor core.
But this isn't an indictment of the actor model, nor of current hardware, but rather of the current state-of-the-art in actor-model languages. You could totally create an actor-model programming language where actor-pools that are doing SIMD are transparently "promoted" during compilation into GPU shaders / FPGA gate-networks / etc. Nobody's done it, but there's nothing stopping anyone. (Anyone interested in trying this could probably do a proof-of-concept on top of Elixir's Nx library.)
> and you can only realistically have as many of those as you have CPU cores in your machine, before you start experiencing highly degraded performance.
Which is precisely why I stated that they're the best model theoretically that simply do not work on our current technology.
I don't think a language solves things. It's a fundamental shortcoming of our technologies, routing topologies, etc.
Graydon may lament the features that got away, but being a good loser may be the best way to encourage contributions and to respond to community needs. The historical forces on a language are both its constraints and its drivers. Languages that are nice conceptually are not responsive to history.
Both Swift and Rust both veered away from their original champions. The champions helped by focusing the problem and providing a technical skeleton, but the need (the pain of C/C++/Objective-C) was both intense and complex, so the community was stronger than the BDFL model.
Interestingly, Swift has seen Rust forge ahead on a number of fronts, but is quietly adopting the best of Rust, and soon interoperating with C/C++ will be frictionless. The ties to Apple are being loosened, with a more portable stdlib and a Foundation library that subsets the legacy Apple Foundation instead of dragging Apple API's into other platforms. If/since Apple is to rewrite its systems in Swift, Swift will likely evolve into the best language for migrating off C/C++.
Compare Graydon, von Rossum, or Chris Lattner to Java's Mark Reinhold. Mark has been quietly at the helm of Java since 1997, navigating: the Oracle and open-source transitions, partners ranging from IBM to broad developer communities, continuous VM updates that kept Java relevant, and the quick pace of recent language/library upgrades: lambdas (method and field handles), vector processing and FFI, native...
"Exterior iteration. Iteration used to be by stack / non-escaping coroutines, which we also called "interior" iteration, as opposed to "exterior" iteration by pointer-like things that live in variables you advance. Such coroutines are now finally supported by LLVM (they weren't at the time) and are actually a fairly old and reliable mechanism for a linking-friendly, not-having-to-inline-tons-of-library-code abstraction for iteration. They're in, like, BLISS and Modula-2 and such. Really normal thing to have, early Rust had them, and they got ripped out for a bunch of reasons that, again, mostly just form "an argument I lost" rather than anything I disagree with today. I wish Rust still had them. Maybe someday it will!"
I remember that one. The change was shortly after I started fooling with Rust and was major. Major as in it broke all the code that I'd written to that point.
"Async/await. I wanted a standard green-thread runtime with growable stacks -- essentially just "coroutines that escape, when you need them too"."
I remember that one, too; it was one of the things that drew me to the language---I was imagining something more like Pony (https://www.ponylang.io/).
"The Rust I Wanted probably had no future, or at least not one anywhere near as good as The Rust We Got."
Almost certainly true. But The Rust We Got is A Better C++, which was never appealing to me because I never liked C++ anyway.
A really unknown and underrated language for how good it is right now is rescript. It's ocaml's type & module systems and runtime semantics grafted onto JS. Ocaml's tooling, ecosystem, and standard lib situation are all ...fine... but quirky in ways that can be barriers when learning it.
Rescript lets you skip most of those, plus the (overblown but also real) weird syntax. I like ocaml and use it for projects still but if you just want to play around with what's unique and strong about it, rescript is what I would recommend right now.
Technically, Rust had no future prior to the 2018 edition. The fact that Rust can add new features as the use cases for it evolve is a strength of the language, one that it had from the start even with Graydon as a BDFL.
I think Rust is somewhat cumbersome as a language, but I also think the right tradeoffs were made. There are far fewer and far worse competitors in the high-performance space than in the general purpose space.
When I first read Graydon talking about his plans for Rust, I pictured it as StandardML (or OCaml) without a garbage collector, and I was sold. This list doesn't look fully like that, but is closer than current Rust is.
My interest back then was in a higher level language with type inference and a modern ML-like type system but that could be used for systems programming, especially in database, virtual machine, and even operating system dev.
These days I work pretty much full-time in Rust, and I think Rust as it is today delivers on some of that promise, but not all. I feel like the language's borrowing and ownership checking are pretty brilliant but really begin to become a pain when dealing with nested and interrelated trees of objects and iterators (like if building a compiler or query evaluator, etc.), and resorting to Arc/Rc/RefCell, etc. feels awkward.
I'm not sure if the language Graydon talks about here would have been better for that or not.
But I'm also happy we have Rust, because it's an improvement over what else is out there, and I hope the community gets through its growing pains.
I’ll get straight to the point I want to make: Rust is suffering from an identity crisis. Much like Javascript.
Realizing this, I thoroughly feel the need for a “Rust, the good parts” doctrine.
A good portion of use-cases could be successfully implemented with a small subset of language. The small subset doesn’t need to be any more complicated than Go. And in doing so, we’d be reducing the entry barrier for masses and encouraging wider adoption.
Can you expound upon why Rust is having an identity crisis? Rust's current mission is: "empowering everyone to build reliable and efficient software." It seems like it's hitting that goal to me (for .e.g., it empowered me, a person who's never written serious code in C/C++, to write wasm-based speech recognition reliably and efficiently).
Also, what is the identity crisis that JS is having?
Funny how every time I click a link to a dreamwidth.org hosted blog the,
>"Hello, you've been (semi-randomly) selected to take a CAPTCHA to validate your requests. Please complete it below and hit the button!"
...pops up and the button doesn't actually work. Truly one of the worst blog hosts out there if you actually want everyone to be able to read what you write.
> The other option, which I wanted, was for these to be compiler builtins open-coded at the sites of use, rather than library-provided. No "user code" at all (not even stdlib). This is how they were originally in Rust: vec and str operations were all emitted by special-case code in the compiler.
One of the things I like about Delphi is that it has some powerful types as compiler intrinsics. Sets, strings (the compiler-generated code does call into RTL methods for things like finding substrings, but the string type itself), and so forth are all compiler-generated.
I would like to see more, in fact: I think a map type would be a great inbuilt addition. (What I'd really like is compiler stubs so you could link in your own implementation. Whatever is linked in, it's then heavily optimised by the linker to be inlined etc as appropriate.)
Rust moved in a direction where it is now a suitable alternative to C/C++ all the way down to operating system code and bare-metal embedded firmware.
Graydon wanted something else even before Rust 1.0 was released. He wanted an OCaml-like language with modern Go/Erlang-inspired higher level concurrency abstractions.
I admire Graydon's humility on the subject--and sure enough, I disagree with some of his ideas in this post, and agree with other ones.
- Explicit lifetimes are what make rust what it is. It would have surely failed had they not been introduced.
- I disagree about having a first-class module system instead of traits. Coherence and implicit instance resolution are a core value of Rust. `Send / Sync` are key examples of that.
- Green threads probably wouldn't been viable for rust, given the kind of programs it's targeting.
- Pretty much everything else however, I agree with.
I can't imagine Rust being even remotely viable without having generics or using LLVM to target a wide variety of platforms with sufficiently good codegen quality.
While it has a been a great piece of wrong to introduce affine type systems into mainstream, it hardly justifies outside domains where any kind of automatic memory allocation isn't either a blocker, or religious issue that won't be sorted out even by proving the contrary.
I see the ongoing attempts to add linear types for low level coding, alongside automatic resource management more future proof.
> The priorities I had while working on the language are broadly not the revealed priorities of the community that's developed around the language in the years since
Isn't that a matter of self selection? A language which developed around those priorities would have a community sharing those priorities now.
The point is if that community would be as large as the current one, larger, smaller.
It's not really mentioned in the post, but one thing Rust had since fairly early on was a use case, it was used to build a browser engine. As I remember it from the time, the feedback from Servo development tended to pull Rust in a more performance-oriented direction.
The fact that the community that formed trended in that way can be taken as an indication that those directions attracted more people than the original vision. Note how often Graydon was basically outvoted on design decisions. It would have been conceivable for the project to attract mostly people that fully shared Graydon’s vision, but that’s apparently not what happened.
"Benevolent dictator for life (BDFL) is a title given to a small number of open-source software development leaders, typically project founders who retain the final say in disputes or arguments within the community. The phrase originated in 1995 with reference to Guido van Rossum, creator of the Python programming language."
Of course Guido ended up not completing the "For Life" part -- neither his life nor the life of the language. (I think PEP 8016 is the one that started the steering council to take the place of the BDFL? https://peps.python.org/pep-8016/)
Interesting. Isn't a BDFL needed in Open Source projects (not talking specific about Rust, but Open Source in general)? I work in a commercial software company. We have a CTO and he retain the final say in disputes or arguments. He also steers the global technical direction we take the software and has the final say, but he can get fired. Is that the main difference between a CTO and a BDFL?
The usual alternative is a committee, see for example Apache Software Foundation's Project Management Committee Guide: https://www.apache.org/dev/pmc.html
I suspect an informal grouping (i.e. more long-standing/senior contributors with default control over some parts of the codebase and operating by informal consensus between themselves) is probably at least as common (maybe more so) than an officially named committee with rules of procedure.
The main difference between a CTO and a BDFL is that if you disagree with a BDFL, you can just fork the project under a new name. This right to fork is key to making FLOSS development work. If you disagree with a CTO, there isn't much that you can do besides buying up the whole company (see Elon Musk and TWTR as an example).
A euphemism for a software project dictator where the moral superiority is baked into the title. The dictator does not have to be benevolent in practice.
> Not 100% clear about actors -- I was weirdly focused on that model that in practice has many issues
Does anyone have any insights on the particular issues with actor-based concurrency vs. "direct parallelism like threads or locks" that he might be thinking about here?
Has Rust gone beyond the point of no return? Have less elegant features possibly been embedded in the language that have put Rust on the track to never become the hypothetically perfect or unblemished language?
Interestingly like Graydon suggests this and 'tis something D has, you can only have `ref` for function parameters. This is something the users sometimes complain about, but I guess it has positives.
I wanted nearly the same language Gradyon wanted, but I also suspect that my tastes are insufficiently mainstream for a language to my tastes to be as popular as Rust has become (much less as C++ is).
I know we haven’t gotten to that point yet, but I’m curious to see if in my lifetime we get a successor to Rust that fixes a lot of issues people have with the language now.
I never understood that one either. There is always only one "solution" that will make your program compile, so why not just let the compiler figure it out?
I did not mention lifetimes, but the principle is the exact same: the lifetime annotations are an API promise, and so inferring them means that a change in the body of the function would change the promise, breaking other code.
> Environment capture. I often say (provocatively) that "I hate lambda", but lambda is actually (at least) two separate language features: one is a notation for anonymous function literals; another is equipping those anonymous function literals with environment capture. I don't really care either way about having such a literal syntax, but I really do dislike equipping it with environment capture and think it's a mistake, especially in a systems language with observable mutation. Early Rust had something called "bind expressions" which I copied from Sather and which I think are better (clunkier but better). Rust gained lambda-with-environment-capture in a single package which I didn't object to strongly enough, as part of trying to make interior iteration work on LLVM-with-no-coroutines, and this motivation was later removed when we moved to exterior iteration. But "if I were BDFL" I would probably roll back the environment capture part (it's easier to tolerate for non-escaping closures which many are, eg. in non-escaping coroutines / stack iterator bodies, but .. eh .. complex topic).
TFA loses me here. I rather like the Fn/FnMut/FnOnce business, though yeah, closures of dynamic extent are very limited unless you Box them to make them of indefinite extent... and so the whole language lacks that character that functional languages with GCs have, but it's still functional, just functional with a straight-jacket.
Earlier in TFA there's a mention of exterior iteration as in generators, and I want to point out that while generators are very nice, they are not a substitute for closures. Icon, for example, had iterators and first-class co-routines ("co-expressions"), but no closures, and so where one needed closures one had to use co-routines (costly!). It's true that with generators one needs closures less than without generators, but still, closures are very important.
> Traits. I generally don't like traits / typeclasses. To my eyes they're too type-directed, too global, too much like programming and debugging a distributed set of invisible inference rules, and produce too much coupling between libraries in terms of what is or isn't allowed to maintain coherence. I wanted (and got part way into building) a first class module system in the ML tradition. Many team members objected because these systems are more verbose, often painfully so, and I lost the argument. But I still don't like the result, and I would probably have backed the experiment out "if I'd been BDFL".
This loses me too.
It's great then that Rust didn't have a BDFL! :)
> Underpowered existentials. The dyn Trait mechanism in Rust allows runtime and heterogeneous polymorphism, a.k.a. Existentials. These types are useful for many reasons: both solving heterogeneous representation cases and also selectively backing-off from monomorphization or inlining (when you have those) in order to favour code size / compile time or allow dynamic linking or runtime extension. Early Rust tried to use these extensively (see also "first-class modules") and actually had an intermediate-rigidity type called an obj that was always a sort of Cecil-like runtime-extensible existential glued to a self-type record that allowed method-by-method overriding at runtime (almost like a prototype-OO system). Today's Rust strongly discourages the use of any such dynamic dispatch, a feedback loop arising from both technical limitations placed on them and a library ecosystem that's taken that as a sign never to use them.
This, on the other hand, is a brilliant observation and I agree with it as with much else in TFA.
> Tail calls. I actually wanted them! I think they're great. And I got argued into not having them because the project in general got argued into the position of "compete to win with C++ on performance" and so I wound up writing a sad post rejecting them which is one of the saddest things ever written on the subject. It remains true with Rust's priorities today, I doubt it'll ever be possible across crates (maybe maybe within), but as with stack iterators IMO they're a great primitive to have in a language, in this case for writing simple and composable state machines, and "if I were BDFL" I probably would have pointed the language in a direction that kept them. Early Rust had them, LLVM mostly made us drop them, and C++ performance obsession kept them consigned to WONTFIX bug status.
Oh dear. TCO is essential, IMO. I understand that it may not be possible in cross-crate cases, but still, TCO is very important.
I haven’t cared about what Hoare thinks of Rust since 2015.
1. He hasn’t been involved in the language for a long time
2. His vision for the language was completely different compared to how the remaining developers ended up designing it. So if I ended up liking Rust under his “BDFL”ing then it would be for completely different reasons compared to why I like (and dislike) Rust today
That said, I still hate async with a passion, it makes the language more complex and not very elegant (i.e. function coloring). And now that I know how it works behind the scene (thanks to Jon Gjengset [1]), it feels so complicated and hacky, a mediocre very high level concept that someone managed to implement as a zero-cost abstraction. Impressive, but still a bad idea.
I'm sure the pro of having a BDFL instead of a committee is being able to follow a singular vision, instead of trying to appease members by adding the fad du jour which might stray a little too far from the original vision. Too many chefs in the kitchen and all.
1: https://www.youtube.com/watch?v=ThjvMReOXYM