So what are we left with? Many ways of dealing with control flow, but no silver bullet. Of course, it makes sense to pick the right tool for the job, we don’t need a single way of doing things if it compromises too much. But the reality is, Rust doesn’t let you pick the right tool for the job here, only the least bad one, but it’s always going to be painful anyways. This is less apparent in simple code, where pattern matching looks elegant, method chains are clean and the try operator makes your problems vanish, but beyond slideware, reality is less glamorous.
I’ll add that while refactoring is usually nice in Rust thanks to its type system, making a method chain more complex or converting it to pattern matching as it grows is often going to give you headaches. You’ll get there, the …
So what are we left with? Many ways of dealing with control flow, but no silver bullet. Of course, it makes sense to pick the right tool for the job, we don’t need a single way of doing things if it compromises too much. But the reality is, Rust doesn’t let you pick the right tool for the job here, only the least bad one, but it’s always going to be painful anyways. This is less apparent in simple code, where pattern matching looks elegant, method chains are clean and the try operator makes your problems vanish, but beyond slideware, reality is less glamorous.
I’ll add that while refactoring is usually nice in Rust thanks to its type system, making a method chain more complex or converting it to pattern matching as it grows is often going to give you headaches. You’ll get there, the compiler will make sure of that, but it won’t be pleasant[3].
Error handling
Error handling is a funny subject in Rust. Its evangelists will let you know how it is one of the best things Rust has to offer and how much better it is than in other languages. Yet I disagree and more importantly, the Rust Library team also admits that there’s a lot more work to be done here. I’ve said that I wouldn’t talk about current issues in Rust that are expected to be fixed sometimes in the future, but the work that has been done so far and the direction that this is all taking doesn’t indicate to me that my concerns are ever going to be addressed.
Let’s start with the good parts first. Rust has predictable, value-based, sum-type error handling. Of course, you can still return error codes and use out parameters instead (or even std::process::exit(1) your way out of errors), but the language provides sugar that makes it easier to do your error handling the idiomatic way.
For example, Rust will warn you if you ignore return values but it has no concept of an out parameter. Also, integer return codes aren’t as safe as strong types, since the compiler won’t make sure that you match over all the values that you’ve produced for that specific error type. You also can’t use the try operator with them.
On the other hand, if you use match over custom sum types, you’ll need to opt out of handling all variants, including if you add new ones and you’ll get to use the try operator to simply bubble that error up.
Rust not supporting exceptions means that the control flow is (mostly[4]) predictable from anywhere, which is also a win: your error handling won’t launch you some other place without you seeing, which makes auditing code much easier. It also makes performance predictable: while you should not use exceptions as control flow anyways, not having exceptions means that you can never pay their cost (both in terms of space and time).
Finally, Rust supporting destructors (via the Drop trait), means that there’s no need for cleanup sections to goto like in C or manual defers like in Zig.
First off, it’s important to note that all the pitfalls of Rust’s flow control also apply to its error handling, since error handling has a large intersection with error handling[5]. Pattern matching is painful, especially as you start to compose error enums together, method chains are annoying to work with or downright unusable in some situations and using C-style error codes is very error-prone.
Beyond just pattern matching on sum types that might be errors, something that is annoying is defining and composing those types in the first place, and dealing with those wide enums at the call site. There are some libraries that are meant to alleviate that pain, both at the declaration and call sites, but adding (especially non-standard) dependencies to a project isn’t a good solution, particularly if it involves mixing code using different libraries. Something else that people often overlook is how expensive that kind of explicit, value-based error handling is. It boils down to chains of conditions and jumps, which are expensive, especially on modern hardware, which thrives on predictable code. People love to hate on exceptions because they have a cost, but in practice, that cost is only paid in code size (and Rust doesn’t optimize for this so it’s not an obviously bad trade-off) and error path execution, which doesn’t happen often for exceptional errors (note that exceptional errors can very well happen in very hot spots). I’m not going to argue in detail why I think that more people should consider exceptions for handling kinds of errors in C++ (I’m reserving that for another post), but I do think that Rust not providing the option makes it suboptimal in some (many) cases, both in terms of performance and ease of use[6].
Although we can’t do much about the underlying checks and jumps resulting from the use of values as errors, the try operator provides a way of greatly improving the ergonomics of dealing with them: you use it and it will bubble up errors if it encounters any. In practice though, it is a bit more complicated than that: you can’t bubble anything and you can’t bubble out of anything either. For example, you can’t use the try operator on a boolean, which means that you can’t just give up based on some predicate. Conversely, it is not possible to bubble out of a void function. Those limitations are problematic to me since they occur in perfectly valid scenarios and forking out of the usual, idiomatic error handling patterns in only some cases makes code more complicated and less readable.
The try operator also doesn’t magically solve the problem of plumbing between different types. Even if you constraint yourself to using std::{option::Option,result::Result}, you’ll still have to explicitly convert one into another (and provide an Err value in one of the cases) and the variant types won’t be implicitly converted from one to another. Arguably, that last point isn’t desirable given Rust’s design, but some way of improving conversion ergonomics would be nice.
The try operator also encourages bubbling up errors up without thinking too much about it, which means that all you’re left with at some point is some value and no context attached to it. std::error::Error is supposed to support backtraces at some point, but it remains to be seen if it actually solves the problem in a practical way when it goes live.
Finally, there are proposals and lots of traction to get syntactic sugar into the language that would allow handling errors in a way that looks like checked exceptions in other languages. I doubt that this is a good idea, as it would make the language even more complex and would provide little benefit (this wouldn’t change anything about performance and types would still need to be converted) and might mislead many users about what is happening. Granted, the same arguments were held against the addition of the try macro/operator, so we’ll see how this evolves.
Finally, let’s talk one last time about method chains. We’ve established that they were quite limited for general purpose control flow, and while error handling tends to be more straightforward, we hit another major issue: method chains use closures a lot. More specifically, many standard error handling methods take closures, either to decide what should happen when a condition is met or to lazily/dynamically produce values (e.g. Result::or vs Result::or_else). The issue here is that the scope that one can return from in a closure is that closure itself. In other word, it’s not possible to bail out of a function from within a closure that is evaluated as part of an error handling method chain. This sounds obvious from a language standpoint (and it is, really, I wouldn’t want a function to be able to randomly goto_upper_scope_twice), but it makes method chains impractical anyways.
Ok, I think that’s enough talk about control flow and error handling, let’s move on...
Language bloat
While I believe that a good language has a good deal of necessary complexity (like some way of doing generics, destructors, some way of ensuring safety and strong typing), I prefer tools that remain simple where possible. The biggest reasons for that are that a complex language is harder to learn and be productive in, easier to misuse and harder to build competing implementations for. On top of that, the more complex a language gets the more risk there is to get something wrong and it only gets more complicated to fix those mistakes as the language gets more traction. The obvious example for all of those pitfalls is obviously C++, which is a disgusting, complicated monster that no one can build compilers for from scratch anymore. Another example outside of the PL sphere would be the web (with web browsers being the implementations). Our field being so young entails that this balance is still highly subjective, but this article is about my preferences, so that’s fine.
A good example of language bloat is if a feature can’t be implemented in terms of the language itself, but requires calling into some magic compiler hook. This is unfortunately the case of the format_args macro:
macro_rules! format_args {
($fmt:expr) => {{ /* compiler built-in */ }};
($fmt:expr, $($args:tt)*) => {{ /* compiler built-in */ }};
}
This makes me a bit sad because it is unnecessary and implies that doing something somewhat like formatting arguments (which is considered regular code in many systems languages) requires extending the compiler, which is obviously not feasible in the vast majority of cases. What makes me even more sad is that Rust is getting implicit named arguments in formatting macros, which makes the whole thing even more magic. I’m also still of the opinion that none of that matters, bikeshedding how formatting calls look like is unimportant. On the other hand, this increases the language’s complexity and introduces more ways of doing the same thing. I’ve explained earlier why I think that’s a bad thing. On the other hand, Rust avoids the bloat that comes with varargs by usually using macros instead. Ironically, I’m not sure that this is the best way of dealing with this (I think I quite like how Zig passes tuples to regular functions instead), but at least we don’t have a complicated, broken version of varargs in the language, which is nice.
Another example of language bloat is standard intrinsics, which Rust has a lot of and I have a issues with.
Intrinsics are nice, they give users access to special things that either their compiler or platform supports, which often results in additional functionality or performance. What can make them hard to use is the fact that they are not portable: what a platform exposes might not have a direct equivalent everywhere else (same goes for compiler intrinsics). After all, if that was not the case, they’d just be exposed as libraries or language features! So if you want to rely on them, you’ll have to carefully pick an abstraction that works for you. If you’re working with a platform that supports a specific feature and try to port your code to another one that doesn’t support it, you might want to consider picking a different algorithm/design altogether rather than implement that feature in software. Conversely, if you’re moving to a platform that has two different features to achieve that same functionality, you’ll have to decide which one to pick, based on trade-offs (e.g. execution speed vs precision) that only make sense to your specific application. This is the price you have to pay for the benefits that using intrinsics provides you with.
The same goes for compiler intrinsics (like Clang’s __builtin_memcpy_inline, which is platform-independent): if you depend on one, be ready to do without it to support other compilers or to pick which one is the most appropriate in the event that this new target offers more choice in that domain.
Hopefully, this illustrates why some things are intrinsics and not regular functions/language features. With that being said, what does it mean for an intrinsic to be standard? Well, it means that every Rust compiler must expose them for every platform. What if some platform doesn’t support computing the sine of a float? I guess your implementation of sinf64 will be in software then ¯\_(ツ)_/¯. This isn’t bad per se, but should be exposed as a function rather than an intrinsic, which in turn shows how these operations should be exposed as functions rather than intrinsics.
We also have black_box, which essentially is used to prevent optimizations on a chunk of code, for example in benchmarks. However, it is provided on a “best-effort basis”, a.k.a. it will do absolutely nothing with some compilers and is unpredictable with regards to portability. At this point, why not just keep this a rustc intrinsic, document it well in that context and keep it at that? That would be hard enough to deal with in a correct manner, making some API standard and only dictating “this does something, maybe” is pointless and dangerous.
Compile-time evaluation
Rust’s compile-time evaluation closely resembles that of C++: one can define constants almost like variables (using const instead of let) as long as the expression yielding the value is a constant expression. Constant expressions be formed from function calls, as long as those functions are marked const.
The obvious downside to this is that any function not marked const cannot be used in constant expressions. This includes code over which you don’t have control.
Another downside is that there are contexts in which it is not convenient to declare something const. One example which I have recently encountered (in C++, but it would have been similar in Rust) looked fundamentally something like this:
// serialize a foo_t in a buffer_t with a certain precision
void serialize(buffer_t*, foo_t const&, precision_t);
// call site
constexpr precision_t precision = /* ... */;
serialize(buffer, foo, precision);
Note how I needed to extract the initialization of the precision parameter in order to ensure that it would be constructed at compile-time. The compiler might have done that either way as an optimization had I inlined the construction of the precision parameter in the function call, but that is not guaranteed.
This is only a simple example and is not that big of an issue in practice but it keeps me wondering if some other model could be more effective. I don’t know what it might look like, though.
Low-level obfuscation
This is in parts a generalization of the previous section. In short: I don’t know what the compiler is going to do and what (machine) code it is going to spit out. I intend on writing more at length about this in another article, so I’ll keep this brief.
Rust theoretically enables code generation that is close to optimal. For example, iterators can compile down to assembly that is as good as a for loop. However, that requires that the iterator is well-written and that the compiler optimizes all of its code away. Of course, the same goes for all “zero-cost abstractions”, not just iterators.
My issue with this is that this requires a lot of faith, which isn’t exactly engineering. One needs to trust that the pile of abstractions that they write will actually be well-optimized. Without language-level guarantees, it’s not possible to be sure that ideal code will be generated. Where the language does provide guarantees, it is not always easy to spot whether they apply in a codebase or not, especially transitively. In other words, you can’t at a quick glance be sure that the code that’s in front of you will compile to good assembly, especially when involving auto-vectorizers (I recommend reading Matt Pharr’s story of ispc for valuable insight about this). I wish that the language would make it easy for me to reason at a high level about code generation (which is something that C still rightfully gets praised for).
The next best thing is tooling. To be fair, Matt Godbolt’s Compiler Explorer is a tremendous tool when it comes to that. However, it is not tractable at scale: it makes no sense to “godbolt” your whole codebase. More critically, there are no tools that I know of that can help monitoring that over time to prevent regressions, either due to compiler changes or increased complexity in the code.
I have personally seen C++ code be orders of magnitude slower when using STL “zero-cost abstractions” like std::accumulate rather than the corresponding for loop. This is downright unacceptable and should be illegal by the specification, especially for anything in namespace std and makes me dread what me code will compile to, especially in a cross-platform setting. C++ is not Rust, but bear in mind that its major compilers are much more mature than rustc is and don’t just throw tons of IR at LLVM for it to optimize.
Performance, simplicity and safety are often touted as primary goals for modern systems languages. I wish that they would consider predictability of generated code as well.
In today’s Rust, I know that iterators are better than for loops over arrays because they avoid bounds checking, as long as the iterator code gets optimized away, but also unless the compiler can figure out that there would be no out-of-bounds accesses ahead of time by just using a loop. In other words, it is not obvious how to iterate over an array-like type in a performant manner to someone who has actually looked into it. This is terrible as it adds incredible amounts of mental overhead, doubt and uncertainty to what should be trivial programming.
Unsafe
This deserves it’s own section because it is extremely important. Without unsafe, Rust would be a high-level language with an annoying borrow checker and likely a pile of standard structures that might be fast but couldn’t be implemented by regular people. This means no custom data structures, no custom allocators, no unions, no talking to the hardware via memory-mapped IO, nothing interesting, really.
Unsafe is cumbersome
This is all in all very minor, but it bothers me anyways. Code that is unsafe heavy (think access to unions in a CPU emulator, anything low level that needs to cast between different numeral types, not just data structures or “scary pointer manipulation”) tends to be ugly. If you want to limit the area of your unsafe code (which I do), you’ll need to write the keyword a lot. You could have helper functions for that, but this would be hiding the unsafety of it and is less direct than seeing what’s going on. This is personal, but I like that aspect of C.
I agree that this isn’t best practice, but not everything needs to be. The more narrow the scope of your code, the less you need to put big warning signs everywhere, especially in a part of your code where this is implied (e.g serialization). For example, it is good to outline potentially tricky portions of C++ with {static,reinterpret}_cast, but it’s just painfully heavy in some context.
I’m not saying that Rust is wrong here, it just bothers me sometimes.
Unsafe is more dangerous than C/C++
This one on the other hand I think is dangerous. It is nice that the Rust community frowns upon excessive/unaudited usage of unsafe, but that doesn’t apply to projects that aren’t part of it and real systems programming does mandate the use of unsafe operations (at least if you want to spend your time building things rather than jumping through hoops, hoping that the abstractions that you’re using are zero-cost for your use case[7]).
“So what, all of C/C++/other-systems-language is unsafe, so that makes Rust better!” What does that mean exactly? When you break it down, there are two problematic patterns with the use of unsafe code, in any language. You could do something stupid in an unsafe context that would break your program immediately or corrupt its data, like dereferencing a null pointer. No language is going to save you from that and this is precisely where you would use a feature like unsafe anyways (except obviously you’d have intended to do something like memory-mapped IO, not just dereferencing nullptr...). You could also do something that would break the invariants of your safe code. In C++, this would be something like freeing the underlying storage of a std::unique_ptr or a std::string. The same thing can happen in Rust too.
The key insight here is that unsafe code affects “safe” code: if you can’t uphold invariants in unsafe code, your program is ill-formed and will do nasty things, especially after the compiler takes advantage of optimizations enabled by undefined behavior. The two things that you can do to minimize the likelihood of that happening is by either limiting the places where the invariant breaks can happen (unsafe blocks) and looking really really really carefully at them to make sure or limiting how many invariants you need to uphold (or how hard they are to uphold). If you consider that every C++ program essentially runs in the context of unsafe fn main(), Rust is definitely better equipped in that domain. In terms of the quantity/nature of invariants to uphold, it gets trickier. The Rust reference states about the list of behaviors considered unsafe that “There is no formal model of Rust’s semantics for what is and is not allowed in unsafe code, so there may be more behavior considered unsafe”. In other words, not only are there more things that you need to look out for in unsafe Rust than e.g. C++, anything you do in an unsafe block that you couldn’t do outside may or may not be undefined behavior, which may or may not consequently break your “safe” code. Scary, right?
In practice, the compiler probably won’t generate garbage code for something that isn’t well-known to be undefined behavior, but still.
Async
I’m fortunate enough that I don’t write a lot of async code in Rust. I don’t like async Rust. It is sometimes necessary, but for the most part I can just pull in tons of dependencies, wait for them to compile for 15 minutes the first time, slap the boilerplate where it needs to be and go back to writing straight code again. Oof, it already sounds bad, doesn’t it?
My biggest gripes with async Rust are its color problem, how disparate the ecosystem around it is and the general heaviness and clunkiness of it all. Yes, you can use smaller runtimers or roll your own, but that’s still a lot more code or more dependencies and now you can’t interact with crates that depend on Tokio anymore, hardly a win.
Regarding the color problem, some people think that it is not a problem and others even think that it is a good thing. I understand their points but I just disagree.
Just slap it on an executor No! If your function does something useful, I want to be able to call it from a sync context and just wait for it to complete, with no executor, no additional crates, no nothing. Maybe I don’t care about performance, maybe I just want to download a goddamn page to scrape it for content and I don’t care about waiting a few milliseconds for it to get there. If it takes me too much effort to look up how to get Tokio running in my project, download it, compile it and add all the boilerplate, I’ll just get the thing in another language and read it from Rust. Type safety is good, Future is just like Option I agree with this, it is important that one can discern what’s going on. I don’t want the language to do stuff behind my back or to rely on documentation to figure out if a function is/can be async. std is sync only, what are you complaining about? I don’t trust the Rust foundation to ensure that this will remain true, but even assuming that, this tells nothing about the wider ecosystem. If a significant portion of it isn’t trivially compatible with my code, be it sync or async, I’m sad. If we need tons of code to be duplicated, I’m sad. If for avoiding code duplication we need to introduce executor into sync codebases, I’m sad.
To be fair, I don’t think that how languages like Go handle this is the right way to go about it (I’ve never messed with Erlang/Elixir, so I don’t know about that). Also I’m not an async expert. From what I’ve seen, Zig feels a little closer to what I like, but a global
pub const io_mode = .evented;
// or
pub const io_mode = .blocking;
is terrifying to me and I’d rather explicitely pick between sync and async execution per function call. I don’t care much about a little additional verbosity, but I wish the compiler would just transitively turn all the async code that I call with a specific generic parameter or keyword into sync code.
I also wish actual async code was more approachable, but that’s just wishful thinking, since I also don’t want a default, standard executor to magically run behind the scenes when I write async code.
Overall, I don’t like async/await, coroutines and stuff like that so I’m happy that most of what I do works well with straightforward “one thread of execution per OS thread” code, since Rust definitely doesn’t change my feelings about that.
Standard library
I don’t have tons of complaints about the standard library, it’s pretty nice overall. It’s a shame that a lot of containers don’t expose their allocator (or something like that), which makes them impossible to use in scenarios where a custom allocator is mandated but implementing a BTreeMap from scratch might not be worth it. Some APIs are abit annoying to learn: for example, the first time you want to iterate over a string, trying to grok what Chars is takes a bit of time. Same goes for most of std::process More annoyingly, algorithms can’t return pointers/iterators to you like in the C++ STL, which makes sense, Rust being Rust. Overall, the standard library provides good foundational collections and decent APIs for interacting with your OS.
Misc
One of the most obvious issues about Rust is how terribly long its build times are. This is a Rust issue, not just a rustc problem (rustc actually uses rather cutting edge technology): the language is complicated and build times will almost always be traded for performance or safety. I’m happy about that trade-offs, but that doesn’t make my projects build any faster...
On a related note, Rust is quite annoying to integrate to cross-technology projects. Cargo is a really nice tool, but it almost only cares about Rust. I hear that Bazel (which is great btw) has decent support for Rust, which is nice, but that’s about it.
Integration with other languages, even with C via FFI, is also rather heavy. Again, there are decent reasons for that, but I understand why Zig enthusiasts enjoy being able to just #include C code in their projects.
As a last note, I’ll say that I’m not a fan of the Rust ecosystem. There are great things about it, lots of innovative crates that are simply the best out of any language out there today. Yet, many of them have integration and/or dependency issues. Many depend on a specific async runtime, don’t expose sync APIs, don’t compose well with other crates in the same domain. Many also have gigantic transitive dependencies for no good reason, which makes them a lot harder to package, audit and keep secure. Many crates are also strongly depended on and cease getting any update as their sole maintainer loses interest in them or decides to write a better alternative to them. This is understandable of course and is to be expected from open source libraries, which often depend from the continuous efforts of single individuals. The Actix fiasco is a sad illustration of that and how the Rust community can make this problem even worse.
That’s it, most of the things I dislike about Rust. I’m sure that I forgot some and that I’ll update this post as I hit them again, since I’ll surely be writing tons more Rust in the years to come...
[1] Unless you don’t care about staying DRY at all, in which case you could always just write C/assembly/microcode, but you don’t want that if you’re interested in C++/Rust anyways. Jump back
[2] Like using semi-colons for type specification and type instantiation, parenthesis for function calls and destructuring types, using square brackets to index values or call macros, mixing types and lifetimes in generic lists, etc. Jump back
[3] It is still heaps better than with a dynamic language like Python or Javascript, so we can have nice things. I just wish we could have even better ones. Jump back
[4] Rust still supports destructors, which is hidden control flow and you can always panic. Jump back
[5] Yes, error handling involves more than just the mechanical act of doing something in the face of an error, including a general plan with regards to error handling, defining surfaces delimiting around which errors can/cannot happen due to sanitization, which parts of the system are expected to fail and if so, in what way, determining with what granularity errors must be reported, etc. But this doesn’t change much that what what we care about from a language is what facilities it provides to realize that error handling strategy. Jump back
[6] I understand the argument that exceptions make the code harder to reason about, and while I agree with that to some extent (e.g. if an external library can throw anything at any point and all you can rely on is at best documentation detailing what might happen), I think that those cases can largely be mitigated, from both technical and social standpoints. For example, exceptions could be forbidden forbidden to cross crate boundaries. This way, what a library does would be none of your problem (you already trusted them to not panic/be fast enough before anyways, what’s the difference?). Within a single crate, the use of exceptions (or lack thereof) is mostly a social problem. Don’t want them in your code? Don’t let them in! Want them in your code? Define where and how they can be used and enforce that during code review. Or even better, show that Rust can be better and write tools that do that for you! Jump back
[7] Quick reminder that zero-cost abstractions doesn’t meant that the code that you’re using is optimal, merely that what it was designed for can’t be written in a more efficient way. Barring the fact that you’re still paying for them in terms of compile times and abstraction overhead (how much more space it takes in your brain and how many layers you need to reason through), if your use case isn’t exactly what that abstraction was built for, there likely is a better way to solve your problem. Jump back