Tell HN: Rust Is Complex
Update: It is possible to abuse existing CoerceUnsized implementations on stable. See #85099 (although I created that issue before reading any of this issue and its IRLO thread, so don’t expect any syntactic similarity to the unsoundness examples of this issue).
The type Pin<&LocalType> implements Deref<Target = LocalType> but it doesn’t implement DerefMut. The types Pin and & are #[fundamental] so that an impl DerefMut for Pin<&LocalType>> is possible. You can use LocalType == SomeLocalStruct or LocalType == dyn LocalTrait and you can coerce Pin<Pin<&SomeLocalStruct>> into Pin<Pin<&dyn LocalTrait>>. (Indeed, two layers of Pin!!) This allows creating a pair of “smart pointers that implement CoerceUnsized but have strange behavior” on stable (Pin<&SomeLocalStruct> and Pin<&dyn LocalTrait> become the smart pointers with “strange behavior” and they already implement CoerceUnsized).
More concretely: Since Pin<&dyn LocalTrait>: Deref<dyn LocalTrait>, a “strange behavior” DerefMut implementation of Pin<&dyn LocalTrait> can be used to dereference an underlying Pin<&SomeLocalStruct> into, effectively, a target type (wrapped in the trait object) that’s different from SomeLocalStruct. The struct SomeLocalStruct might always be Unpin while the different type behind the &mut dyn LocalTrait returned by DerefMut can be !Unpin. Having SomeLocalStruct: Unpin allows for easy creation of the Pin<Pin<&SomeLocalStruct>> which coerces into Pin<Pin<&dyn LocalTrait>> even though Pin<&dyn LocalTrait>::Target: !Unpin (and even the actual Target type inside of the trait object being returned by the DerefMut can be !Unpin).
Methods on LocalTrait can be used both to make the DerefMut implementation possible and to convert the Pin<&mut dyn LocalTrait> (from a Pin::as_mut call on &mut Pin<Pin<&dyn LocalTrait>>) back into a pinned mutable referene to the concrete “type behind the &mut dyn LocalTrait returned by DerefMut”.
From: https://github.com/rust-lang/rust/issues/68015#issuecomment-835786438
Where's that elegance now? I still maintain that I'd rather use Go when the problem domain allows for it (e.g. can use a garbage collector, don't need fast interoperability with C, don't need maximum performance.)
101 comments
[ 3.0 ms ] story [ 152 ms ] threadGiven the complexities of async and how other alternatives such a Java Loom’s virtual threads, in about 10 years I am not sure that we won’t see Rust going all in on async as a mistake.
I'm about a month into Rust, but Rust didn't go all in to async; it only went halfway in, which is why some functions are async and others aren't.
Erlang is all in on async/green threads/tasks. From what I gather, so is Loom. There's no special way to write async code, you just write regular code and it works; for Loom you just spawn your threads a little differently. For Rust, you have to be somewhat careful about what you do or don't do in a task, and you have to write .await everywhere.
Rust can merge the entire call tree of async execution (which can be as large as the whole program if async recursion isn't used) into one struct that represents entire state space needed from start to finish. This struct has a static type and a fixed size known at compile time. Describing that type in the type system, including all the generics and lifetimes, is pretty complex.
Pin is a bit of a technical debt. It has been invented to ship async/await without having to first add non-moveable/self-referential types to the language. It is more clever and clunky than a first-class language feature could be. OTOH async/await has been production-ready for a few years now, and non-moveable types are still not a thing, so the trade-off was worth it.
In practice you don't need to even know about Pin if you're just using async/await syntax. It's needed only for lower-level plumbing, like custom runtimes or clever low-level primitives that can't be expressed in normal async code.
It feels like the designers of go tripped over themselves to make things simple for some definition of simple and then ran into some feature they wanted and then just bolted the feature on without thinking about whether simplicity/consistency tradeoffs were a thing.
If you want a much more annoying example of inconsistency in the initial language design, look at the behaviour of nil and closed channels:
- sending data to a nil channel blocks forever;
- receiving data from a nil channel blocks forever;
- sending data to a closed channel causes a panic;
- receiving data from a closed channel is fine: you receive the zero value, forever.
I can understand the logic behind the last two (sending to a closed channel is an error in program logic, and closing a channel is a common idiom for broadcasting completion of a task to several goroutines), but the first two are just a massive footgun, and, in my opinion, should cause panics instead.
That being said, I was quite surprised this doesn't compile... (I understand why, but I expected the compiler to have all required information to perform reborrowing).
Go is a language for high-level application development. It has a runtime, garbage collection, and a sophisticated M:N threading scheduler that are all designed to help the programmer express themselves in terms of the problem domain. It competes with Java, C#, and (in some cases) Python or Ruby.
Rust is a language for low-level systems programming. It needs far more complexity than Go because the programmer might need to make extremely specific guarantees about performance or memory layout. It competes with C++ and C.
Is Rust a more complex language than C++? It's not obvious to me that this is true when you add in the third-party tooling (linters, static analysis) required to provide Rust's correctness properties in a C++ codebase.
Is Rust a more complex language than Go or Java? Sure. Obviously. Anyone who says it's not is lying or foolish. But that's not the comparison being made.
Rust's syntax and verbosity sometimes leaves much to be desired or the convoluted syntax and structures to get interior mutability, but it doesn't matter because of its immense power in the spirits of say ML, Haskell, or C#. Rust can do a lot of what they do and a lot more, and safer. Pony and Idris are barely used but have features Rust doesn't have.
I think too many people poo-poo Rust because they either don't want to understand it or can't grasp its concepts like lifetimes (liveness) or move. It's easy to dismiss what one doesn't understand and stake a claim with an irrational, tribal ego opinion rather than consider the alternatives in the programming toolbox for a given purpose.
Once you start reaching for more off-the-shelf solutions, you hit diminishing returns with it as a language.
I think it's funny/sad when I hear C++ programmers complain about this. If you don't understand lifetimes in Rust, you don't understand lifetimes in C++ either. Rust however will prevent you making a mistake. That makes it less complex for you. If you can't get your program to compile in Rust, you might get it to compile in C++, but you shouldn't.
As far as I have read, Pin has never been a satisfactory thing but a necessary one. Even recently Rust has been trying to decide how to really handle unsafe mutable pointers in a way that COULD be safe. I'm surprised you didn't bother to use that example, as it is much more convincing in my opinion.
But that gets to the point, not all of Rust is simple (Indeed, most novices will exclaim it is way too complex for merely having a borrow checker) or elegant, but that does not prevent the average use case from being so (the exact situations that you claim to want to use Go in, why would you be using two nested Pins in any normal situation?). Moreover, Rust is actively evolving, and we can hope that these rough edges do get better with time.
Like c++, languages that strive for zero cost abstraction seem to put a large cost burden on the developer in understanding it.
I recommend that you broaden your horizons by studying something really complex like Haskell and something really simple and stripped down like Lua.
I tried Haskell, but I found it unreasonably complex to be practical. I only learn programming languages if I have a practical reason to use them, so I never picked it up again. Perhaps you have a point, everything is relative.
There’s something almost magical about writing software in a language like Haskell, when you have defined good and elegant abstractions, it’s as if you peer into the matrix and touch the divine.. so to speak.
Rust largely won’t let you write the wrong thing, but you have to fully understand the nuts and bolts to know what it will let you do.
what is more important is to understand how to architecturally build software and achieve performance and maintainability and so on. i rather spend my limited hours becoming better in a language like c or c++ which is not going away anytime soon than learning syntax, a new way of thinking and a package management system i don’t need.
Also see https://github.com/rust-lang/rust/pull/103070 and https://reviews.llvm.org/D136659, which start introducing further Rust-specific optimizations.
There's even nobody forcing you to use async code. In fact it might be the wrong choice for > 99% of use-cases, since it adds complexity and limits on which functions can be called in certain places, adds limitations like not being able to perform recursive function calls, and can even lead to higher latencies (no preemption) and more memory usage (for `Future` state allocations) than the boring synchronous versions.
I've worked together with multiple teams building > 30 applications/services in Rust and had seen even > 100 engineers in those teams picking up Rust successfully. So for me there's more than enough datapoints that it's complexity is manageable after an initial rampup period of 1-3 month.
To be fair: What I definitely haven't found is engineers outside the Rust core group that really understands how async and Pin work - usually people just put async in function definitions and .await where the compiler asks and assume that will do it's job - which is mostly good enough. So yes, I definitely second that those details are hard. And even I - as someone who contributed to async/await standardization and was active in the async working group am giving up mentally in the new discussions around Pin/poll_fn safety. But as I said earlier - knowing Pin is not necessary to write programs successfully in Rust.
... in your greenfield solo project with no legacy code or third party contributors.
This kind of argument does not work in community and professional projects where you do get forced to use the features that your colleagues or predecessors have used in the codebase.
And if predecessors have moved on, teams should typically be empowered to make changes to the codebase which make the code understandable and maintainable for them (but sure - I am fully aware about that these changes are not easy to get prioritized in a business sense).
My thinking now is to combine Nim with other languages and their frameworks. For example a NextJS/React front-end with some GraphQL servers in Nim. Alternatively Nim and Python mix quite well.
My recent observation is that programming languages are a bit like warfare. We are drawn to discuss syntax and features, or machine guns and fighter jets, but what seems to trump these on any practical timeframe is logistics.
When coding up a new project, I need a language which gets a lot of mileage, so the bugs are stamped out. It needs good tooling so I'm not stuck making it myself. It needs robust package support, so I can just type the project-specific code. Oh and the language can't be unworkable, and vaguely needs to fit technical spec.
It's a sad chicken-and-egg situation, but users are often not in a position to take the risk.
The issue isn't actually async here, it's #[fundamental]. The holes it pokes in the orphan rule system lead to many unintentional outcomes.
It is genuinely shocking to me that people just live with this. This is completely inexpressible with safe Rust, as it should be.
It is something you learn very early, because the syntax is indeed a bit peculiar, and kind of a footgun. However it implicits teaches you to not try to play with the memory backing the array (which is something go programmers are very happy not doing).
For example, recent struggle with rust I had. Was storing something pre-computed into a global variable (in C terms think of pointer to binary data). And after it's stored this variable is never mutated anymore, just read, so it's completely safe. Unfortunately it felt almost impossible to get rust to understand this, and it always wanted to copy the damn thing, or preaching me how it's unsafe.
That said, rust is still very much good tool to have in pocket when you have to program high-performance and secure software. But I would not ever program a huge project with it, perhaps use it only in critical parts.
1) Use lazy_static crate 2) Use a arc wrapped rwlock
I also get slight PTSD whenever I have to write `new`, not sure if the method will allocate or not behind the scenes. This is one of the things zig gets right I think:
- No hidden control flow.
- No hidden memory allocations.
I agree it's not an easy language to pickup, but the use case you mentioned is a pretty common one and there are several ways to solve it. For example, any kind of config reading from a file needs it. Similarly, connection pools for DB etc are shared this way so its not an uncommon use case in day to day usage of Rust.
In my case, after fighting for literally days, the crate OnceCell did the job (kind of), but I felt awkward retorting to an external crate to deal with a simple static-global-initialize-once-never-touch-again piece of data.
At the very least, you need some sort of locking mechanism to deal with the potential issue where multiple threads try to initialize the variable concurrently. And you need some poisoning mechanism to prevent panics and reentrancy during initialization. Rust makes you realize "oh, yeah, I need to think about these too".
In any case, OnceCell is a very reasonable way to do this and is on track for stabilization.
This application was also single-thread (a command line utility to parse a text file). I now understand that Rust cannot guarantee the safety of initializing global variables so it makes me take the long road, but it could be great it could infer better about each particular case and enforce only when required.
If I can shoot myself in the foot with C then Rust would be kind-of clamping down on me and nailing my feet to the ground. At the end of the day, it hurts more or less the same.
That was my experience as a rookie with Rust in this particular case.
One of the ways Rust is successful at scale is that the intraprocedural analysis is sophisticated, but the interprocedural analysis is deliberately quite basic. It's not quite in keeping with that to do things like selectively enforce bounds on global variables.
For use cases like the one you mentioned, the general strategy Rust wants you to use is to pass around a context with your data inside it (and the data could be in OnceCells if it's lazily fetched). This is also a more testable design. Global state is meant to be used sparingly.
To address some of your complaints: yes, there are a lot of concepts to understand. I like wrappers, I found it crazy that in C, you first declare a mutex_t variable, and then you specifically have to call chMtxObjectInit(*mutex_t mutex) to initialise it [1]. If you forget? UB, kernel panic sometime in the future. I think Mutex::new() is far cleaner, and it's namespaced without arbitrary function prefixes. Binaries are tiny in comparison to JS/Python with deps, they will be larger than C. Compile times aren't that slow and you can't make extra language features happen out of thin air.
In C, I've found that it's commonplace to do a lot of clever and mysterious pointer and memory tricks to squeeze out performance and low resource utilisation. In embedded, there's usually a strong inclination to using "global" static variables, even declaring them inside function bodies because it "limits the visibility/scope of the variable". Not declaring a static variable inside a function is what knocked a few points off my Bachelor's robotics project.
I personally don't like this. It puts a lot of pressure on the programmer to understand the order of execution, and keep a complex mental model of how the program works. Large memory allocation, such as a cache, can be hidden in just about any function, not just at the top of a file where global variables are usually defined.
It sounds like what you're trying to accomplish is inherently unsafe, hence the "preaching", as in it requires the programmer's guarantee that 1) the data is fully initialised before it's accessed and 2) once the data is initialised, it's read-only and can therefore safely be accessed from other threads. C doesn't care, it will let you do a direct pointer access to a static variable with no overhead. Where's the cost? The programmer's mental model. I haven't tried, but I imagine that Rust's unsafe block will allow you to access static variables, just like in C with no overhead, effectively giving your OK to the compiler that you can vouch for the memory safety.
Rust solutions: lazy_static crate (safe, runtime cost in checking if initialised on every access), RwLock<Option<T>> (safe, runtime cost to lock and unwrap the option), unsafe (no overhead, memory model cost and potentially harder debugging), extra &T function parameter (code complexity cost, "prop-drilling", cleaner imo). On modern hardware, the runtime cost is absolutely negligeable.
Why would you not want to use Rust for a large project? This seems a bit contradictory to me. The safety guarantees in my opinion really pay off when the codebase is large, and it's difficult to construct that memory model, especially with a team working on different parts. Instead, you overload that work to the compiler to check the soundness of your memory access in the entire codebase.
If you like C, by all means keep on using it, I enjoyed my forray into C, it's simple and satisfying, but would much prefer Rust, after spending a lot of time tracking down memory corruption. Rust's original design purpose was to reduce memory bugs in large-scale projects, not to replace C/C++ for the fun of it. We usually have a natural inclination to what we know well and have used for a long time. Feel free to correct me if something is wrong.
1: http://www.chibios.org/dokuwiki/doku.php?id=chibios:document...
I stand that rust is more fun to write when you work higher level, treat it higher level language, where you'll have less control over the memory model of program. It all starts breaking down and you need to become a "rust low-level expert" when you want to work closer to the memory model (copy-free, shared memory, perhaps even custom synchronization / locking models ...). It does make sense, but in my opinion figuring out how to map your own model into rust concepts is not trivial, it requires lots of rust specific knowledge, which will take a long time to learn IMO.
When unsafe was marketed to me, I thought it was a tool I could use to escape the clutches when I'm sure what I'm doing and don't want rust to fight me, but sadly it doesn't work that way in practice, but the real way is to actually write C and call it from rust.
I also agree with your other statement. I think Rust tries to abstract a lot of behind into its own type system, such as Box<T> for pointers, whilst keeping it relatively fast. C is definitely the right tool for the job if you want direct memory access, I also think this is a relatively small proportion of people, working on OS, embedded systems or mission critical systems such as flight control/medical equipment.
Even lazy Rust code can still be incredibly performant, testable and fault tolerant. The lower level you go, the more you need to know about Rust and how what it does maps to memory, because it is a systems programming language and if you wanna write the equivalent of a kernel in it you will need all power you can get. The point here however is: you rarely really need to go that deep if you are writing whatever you'd be writing in Go.
… which is how we got slow, bloated legacy C++ codebases today, should we make the same mistake with Rust? If you are wrapping everything in ref-counted smart pointers it might probably be better to just use a garbage-collected language like Java or C# instead…
Rust can go anywhere inbetween counting every bit and byte and doing no such thing at all. My point was that counting every bit will probably be not be the way 90% of the programs will be written if they are written in Rust (mostly due to the fact that programming in such a way is both much harder and takes longer — independent of the programming language).
So if someone who does not count bits programs a software in a field where it would be paramount to do precisely that, then this is not the fault of the programming language, but the fault of that person, their organisation or whatever.
https://stackoverflow.com/questions/9378500/why-is-splitting...
Same with smart pointers: Languages like Java and C# have highly tuned garbage collectors that can be faster than reference counting in most situations. The rationale mostly deployed for using RC isn’t simply just raw performance (it can be slower because of the refcount read/write for every dereference), but predictable performance (you don’t need to worry about GC lag spikes, which is important for real-time applications. But even that advantage seems to be disputed.)
No? Google suggests it is possible
I think I understand that await causes the half of function to be rewritten into a state machine that returns immediately to caller and the promise object implements the rest of the control flow.
I don't understand waiters, pollers.
When you don't want to block your process and program in a reactive, non-blocking way, which is what futures and promises are an implementation of, what you are actually defining is something like this:
> "our process will do some synchronous work, then it will need to stop at point X to wait for asynchronous data"
> "after that data arrives, we can restart our process where it stopped, at point X, and do some more work until it hits point Y, where it will need to wait some more asynchronous data"
> "after that also completes, we can restart from Y, and process until the end of the function"
However, the problem that arises is _how do you store the variables that are being used in this function_? Particularly those values that would be saved in the stack, because by returning the function, the program needs to pop all those stack frames so that the caller can continue executing.
There are two ways of accomplishing this. The first way is to use stackful co-routines, aka threads. The OS does this, but platform threads have very high overhead, so many languages avoid it. You can also use the OS's reactive APIs or thread pools and implement your own scheduler with virtual threads on top, which is what Go or Erlang (and soon Java) do, but then you can't avoid that runtime overhead, which Rust and C++ don't want to have, or maybe you want a more lightweight model (which Javascript wanted).
In that case, the other approach you can do is explicitly represent the processes' variables and state as a structure in the heap, and create functions that operate on those structures. In Javascript you can easily do this (you can capture the values in a closure), and in Rust it is more complicated but doable, and in general you can do this in most languages.
The downside here is that, instead of being able to simply code your process in a straightforward way and have it block implicitly, you need to explicitly code the synchronous steps between each asynchronous wait, which results in either callback hell, confusing functional styles such as continuation passing, or future combinators and callbacks.
What async lets you do, then, is have the compiler write those state machines for you. If you mark your function as async, the compiler will create the closure for you, the state transitions for you, deal with waiting for nested futures for you, and so on, and you can just write the code in the 'dumb' synchronous style. This is common to all the async implementations, from Rust to JS to C#.
However, at some point, you need to actually drive these state machines. You need some kind of scheduling that is able to receive external events, get the state machine that is waiting for it, and execute the next state transition. Driving these state machines is done using those waiters and pollers. Rust exposes these APIs, because it does not bundle the executor, and so it exposes those methods so that async runtimes like tokio can be built against a standard API. Javascript, on the other hand, still needs to perform those tasks but, because it has a runtime of its own, implements an execution service implicitly, and so the external interface of Promise does not need to expose that concern.
I still need to learn more. I think I'm not sure how someone would write their own runtime with pollers and waiters. Those are the only APIs provided by Rust.
- noisy syntax
- concerning and very bad compiler performance
I can definitely see Swift replacing Rust in the short term, Apple seems to be committed
Rust is trying competing yes, Swift is not trying to compete, they use it, they do what they have to do [1]
What happened to Mozilla's Rust plan with Firefox? so far, no bueno
> And nobody trusts Apple for cross-platform support.
Swift supports a wide range of targets and architectures [2]
They officially provide release build for all major OSs [3]
[1] - https://github.com/apple/swift/blob/main/docs/OwnershipManif...
[2] - https://github.com/apple/swift/blob/aff7c14d92fd241aff85ba4c...
[3] - https://www.swift.org/download/
Ironic because safe Rust can't express many valid programs, like those with cyclic data structures.
> Some parts of Rust are starting to remind me of the horror I ran from with C++.
Rust is most certainly designed for C++ programmers. C is all about minimalism and simplicity. I think there is a niche for a C like language with memory safety that retains the simplicity of C. Rust isn't it.
Isn't that the aim of Carbon, the Google-backed language whose specification was announced a few months ago? I might be wrong.
This alone to me is mind-blowing.
Figure out which parts you like, and use them.
edit: Assumed you were referring to embedded GPIO pins. It seems this is something else.