Dark side of ergonomics in Rust
vorner.github.io
vorner.github.io
The author provides some "extreme" examples of bad ergonomics, and posits there are milder examples of similarly bad ergonomics. But their examples aren't on the scale of "ergonomics", they're on the scale of "weakening language guarantees".
Instead of writing an essay with (intentional) strawman examples and then saying "trust me this could happen with any new ergonomics" is much less persuasive than just bringing up any legitimate concerns on offending ergonomic features. This essay is kind of arguing against no one and everyone at the same time: No one disagrees that automatic unwrapping is a bad idea, but it's ridiculous to say that "I reach more ergonomics by accepting more programs and hoping they do what the author have meant, so I don’t have to bother the author with a compilation error." This isn't arguing against ergonomics, it's arguing against. . . I don't know, making Rust not Rust anymore?
But I agree that the danger remains hypothetical unless we can point to some dubious proposals that are gaining traction.
[0] https://blog.rust-lang.org/2018/03/12/roadmap.html#language-...
Like the OP says, the article is misguided, and if it has any effect, it will create FUD in people who have not followed the ergonomics proposals enough to know better.
Yes, in the end the changes don't sacrifice long-term maintainability over learnability/marketing, but that was a hard fight. And given editions will allow radical changes to the language and ecosystem every 3 years, I'm not sure how long that resistance is sustainable.
This is incorrect. Editions are incredibly limited in what they can introduce (it needs to be something that it is possible for the compiler to warn for, with no false positives or false negatives). In practice this means that they're mostly limited to introducing new keywords and other little bits of trivial syntax.
An example would be the talk about removing the ability to declare reference bindings with `ref` and `ref mut` and always inherit whatever the source mode was.
Edit: There is also talk about changing the prelude, so I would assume the standard library is also fair game.
Do you have examples for that?
Edit: I'm not doubting you, but having precedent of things that were rejected due to breakage it causes might be helpful in some current/future discussions :)
> Furthermore, just because we can imagine things that could be broken, it doesn't mean that the Rust developers interpret that as carte blanche to break things. The history of Rust development since 1.0 has been one of careful, gradual, and conservative change.
My fear is that this is currently changing. There are a couple of threads on the internals forum currently collecting ideas of what people want changed in future epochs. To me, that's a shift to a different mode of operation. From "editions allow breakage as last resort" to "editions make breakage no longer a big issue".
> It appears that you have a lot of fear about various things that you imagine might be changed (such as ref and ref mut in patterns, which is sort of head-scratching because I've never heard anyone propose removing them, and it wouldn't make sense to remove them anyway)
Here is one mention of it: https://github.com/rust-lang/rfcs/pull/2005#issuecomment-305...
The idea also seemed like a big hit in #rust-lang at the time. People were quite gleeful that `ref` could go away at some point.
I'm happy it's no longer on the table, but having to fight for the ability to destructure into mutable and immutable felt wrong.
Same thing with the modules redesign. Having to fight that hard for your existing workflows and use-cases not to be broken through multiple RFCs felt wrong as well.
Having some constructs auto-convert their final result value seems certain at this point.
> but I think such fears are unwarranted. I've been observing the Rust developers at work longer than almost anyone, and I've ended up with a degree of trust in their sense of taste that rivals that of any other project that I've seen. The people behind the language today are the same ones who have been behind the language for years now, so if you happen to like where their influence has led the project so far, I think it's reasonable to trust that they won't abruptly leap off the rails. :)
I've been following Rust since 0.10 or so, so I've been there quite a while as well. I was there for the uint wars, the battle of match ergonomics, and of course the big module rework skirmish.
I'm sure nobody is acting out of malice, my fears are more that with a certain community size, it is easy to be drowned out if you have different needs or opinions.
Isn't doing so just a rewrite of C++ ?
Would you mind sharing some of the proposals you thought were dangerous and whether you felt like they were close to being adopted?
The interesting (and to me frustrating) part is that often good improvements (or intentions to solve actual problems) come together with far-reaching changes in an all-or-nothing kind of way. Some examples:
* I'm still of the opinion that moving `&mut T` to `&uniq T` pre-1.0 failed because it also proposed getting rid of `mut` alltogether and be mutable by default.
* Everything that relates to errors often also tends to accumulate a push to make things look more like exceptions. That applies to `?` short-ciruiting, the current discussion about limiting the scope of that short-circuiting, and anytime auto-converting final-value result types comes up. (and it currently looks like that short-circuit construct will auto convert its final value when it arrives).
* Making paths saner and modules and visibility more intuitive first started out proposing completely removing the option of being explicit about project structure, and it took multiple rounds of RFCs to keep that control.
* The match ergonomics discussions hinted at a wish by some to get rid of `ref` and `ref mut` patterns which is what allows destructuring a mutable reference into mutable and immutable bindings.
For the vast majority of cases, it is an non-recoverable logic error to try to access an out-of-bounds elements, so panicking makes sense for the more common, more terse `my_array[i]` syntax.
For me, recoverability dictates whether or not to return `Option<T>` or `T-but-maybe-panic`. If a caller can reasonably recover (and in fact expects to get `None` some of the time!), then `Option<T>` is the right choice. If it is obviously an irrecoverable logic error, then maybe panicking is the right choice.
Typing .unwrap() forces you to acknowledge that what you have may not actually be what you expect.
Explicit flow control makes what is happening clear to understand and reason about without jumping about the code via exceptions.
Type conversions should also be explicit, (and automatic type conversions would needlessly complicate the Type Checker in any event).
I am sure there are places where "ergonomics" can be improved, but it is far more important to avoid the many (accidental) mistakes made by other programming languages.
Rust is going to be around for a long time. So any mistakes made now will also be around for a very long time.
It's quite hard to remove a feature that turns out to have unforeseen indirect consequences.
Automatic deref would be an absolute pain to live without for example.
(Not that I’m claiming to know your code base or specific challenge or anything; I’m just speaking generally)
I have some firmware on a small machine, there aren't any arrays with more than two dozen elements. On the eight bit machine the the code originally ran on using a 16 or 32 bit int caused a lot of code bloat. You might not think that's a problem but consider the price difference between a processor with 64k of flash and 128k might be a dollar. Times 100,000 units a year.
The above is why I'm not going to use rust anytime soon, because a rust binary size is about 4 times larger than C. That would add about $2-3 to the cost of the product. Or $200-300k a year for no real benefit at all.
The criticism isn't even specific to exotic environments. The same reasoning applies at bigger widths too. I've certainly used `u32` in places instead of `usize` to avoid doubling the size of my heap use on 64-bit systems.
Implicit widening would be nice, but it isn't necessary.
A lot of times the code size doesn't matter. In my case it's important. Consider a lowly printf statement in my firmware.
It takes about 120 bytes of code. A trivial amount! Lets see how much that costs us.
Marginal cost of flash is about $1.00/64k. So 120b/64k X $1 = $0.001875 per unit.
We ship 100,000 units per year.
So that printf costs $187 per year.
I don't want to say automatic deref is harmful, but a lot of the time I wish I could locally deduce more about the level of indirection.
Ada with 'range' probably gets this right.
It puzzles me a bit. For numerical software, exceptions work quite well: for performance, all allocations are in the constructors, so the RAII idiom fits naturally. Using exceptions saves from writing a great deal of error-handling, which easily can have errors itself.
Has anyone seen properly-RAII-ed code where exceptions still have drawbacks ?
auto button = Button::create();
window.addSubview(button);
button.setLabel("test");
button.setAlignment(Alignment::TOP_LEFT);
button.sizeToFit();
If setLabel() throws an exception, you'll end up with a half-initialized button in the view hierarchy.For exceptions to work predictably, all mutation within a try-catch block should be captured in a transaction that can be rolled back. Then you don't need to worry about each function possibly throwing an exception. Either the entire block commits its changes, or it's as if the entire block didn't happen at all.
You need total knowledge of what can throw exceptions, and while the same is true for status codes, the raison d'etre for exceptions is shrinking the amount of error handling in your code. If you're checking for exceptions at the same rate you'd be checking for status codes, exceptions are pointless.
But generally, your suggestion to change the architecture points to what the problem with these "ergonomic" changes engender. You more or less never have to rearchitect or refactor things when using status codes, and rearchitecting and refactoring are error prone and generally hugely fraught. The cognitive load of things like exceptions leads to lower productivity or lower quality, because you only have so many brain cycles and something has to give.
I think ergonomic changes are helpful to get more dynamic programmers into stricter languages like Rust. But I think we should keep in mind that typing and boilerplate are really never our problems and prioritize accordingly.
Rust tries to make it impossible in the general cases to do the wrong thing. And gives you an unsafe escape hatch for when you need to do something the compiler won't allow. The result is vastly safer code because the compiler guards against the most typical human failings.
Wouldn't that just be beautiful?
Another good-stuff perspective are processes or equivalents (e.g. erlang's internal processes or docker containers). Happy-path code can ignore errors; sufficiently serious errors tear down the "process" and the calling code can decide how to deal with that (ideally without cleanup responsibilities).
As long as indirect effects are well-contained, there's nothing necessarily wrong with ignoring errors. Especially when fine-grained error handling simply isn't interesting, it can be a relief simply to ignore errors. E.g. DB style fine-grained locking is really only possible because you don't need to manually deal with each and every possible failure moment (because there are unbelievably many).
not sure if i'd sign the statement "you typically communicate via a fairly narrow, well-defined channel, and not via a morass of side-effects" though ;)
[code] try { system("rm foo.txt");} catch (...) {} [/code]
Not everything can be rolled back. Often, automatic rollbacks lead to states unaccounted for by the developer.
Exceptions depends on RAII, that's true.
You could make this general idea work, though, by adding a "button.commit();" at the end of the function. The button destructor would remove itself unless commit() had been called -- basically an ad-hoc transaction on the level of a single button.
fn make_button(window: &mut Window) {
let mut button = Button::create();
button.set_label("test");
button.set_alignment(Alignment::TopLeft);
button.size_to_fit();
window.add_subview(button);
}
(complete toy example at https://play.rust-lang.org/?gist=cb1db59a3ee1f4bad0a5472267c... )In that situation, the button's destructor absolutely could delete itself.
The drawback is "properly-RAII-ed code". It's hard to write because where you write the RAIIed classes you don't have the context of the place / places where it's used. It's extremely hard to read and step through with a debugger - because control flow is all over the place. I don't want to write classes instead of simple regular control flow.
And regarding standard control flow code, I've never seen a code base that handles exceptions correctly / elegantly / in a disciplined way. The only way to deal with exceptions is handling them immediately, i.e.
try:
f = open(filepath)
except FileNotFoundError:
handle_file_not_found()
To be honest I prefer C-style return code handling. It would be only 2 or 3 lines instead of these 4, and no additional indentation, and no additional value return mechanism. The only cost of C-style handling vs exceptions is duplicating a few library functions: Need to offer interfaces that die and ones that allow handling the error. And there's the possibility of forgetting to handle the error (if the caller considers it an error, after all) once in a long while. But you have that problem with exceptions, too...In other words, exceptions are good for writing sloppy code (I like writing Python scripts, but I rarely handle exceptions), and very bad for serious code bases.
The proper way to use exceptions is when you want behavior that bubbles up. So if you do x=f(g(h(x))) and h(x) fails because the user didn't configure something, you only need the config-checking logic in h, not in g or f. This use-case is quite rare though, and overall not worth the extra language complexity.
I think there is one underused error handling mechanism: "asynchronous error handling" where you have to to ask for errors explicitly. Like in the OpenGL API, for example. It might be the best choice where there is a lot of communication between largely isolates states.
So that could be one answer: If possible design the API in such a way that functions that can have expected errors [1] do not return values that only make sense if there was no error. Because this means that control flow gets very irregular on the calling side. Such a design could be hard to do though, I don't know.
The other answer could be: Don't have errors that you don't know how to handle. Unless you write very low-level code, like a library (which means you probably want to delegate the handling), just die on errors that you don't know how to handle. Make the die() call a best-effort cleanup routine if possible, and keep the important to-be-cleaned up state as global as possible (NOT on the call stack), to support such a cleanup routine.
[1] I hate the vagueness of that term, but take it to mean "no statically obvious wrong usage by the caller", like passing in made-up or corrupted values
Often yes (one of the exceptions being a simple die() routine that cleans up only global state). It's just pseudocode, and in real code you will often not call any functions in the exception handling block.
open filepath `catch` handle_file_not_found >>= use_file_handle
handle_file_not_found :: FileNotFoundError -> IO a
use_file_handle :: FileHandle -> IO aA sibling comment hints at the way out:
> For exceptions to work predictably, all mutation within a try-catch block should be captured in a transaction that can be rolled back.
The instant an exception can be raised from an area in code is the instant you need to make that entire area into functionally-pure side-effect-free logic, so that you can reason about it even when the exception is raised.
And then you've got a function that could just as well return an Either<Result, Error> -- what a strange "coincidence"!
I mean, that would be lovely, but is really only feasible for exceptions raised from code that is operating exclusively on values in memory (NPEs, for example). Rather a lot of exceptions are raised from necessarily side-effectful operations, especially those dealing with I/O.
Since you can't predict the future, you can't guarantee purity/transactionality of code that raises that kind, so saying "code should be pure when it can throw" is a true-but-impossible statement. That's why most languages with an emphasis on purity tend to eschew exceptions and/or invert control flow such that effectful stuff happens externally to the functions handling its output (e.g. the Maybe or IO monads).
The further you push the potentially complicated code towards functional purity, the more the I/O and other side-effecting/hard-to-roll-back things float to the top... and it becomes almost natural to group all of them together...
... into what you might almost be tempted to call a "transaction".
(Indeed, like the IO monad leads one to do. I've explicitly been trying to avoid saying the "m" word, though, because it seems to be a cognitive stop-sign for a lot of folks, for whatever reason.)
Great insight. But i would say that explicit error handling doesn't help you much there. Side effects are hard and side effects can by nature normally not be reverted anyway. Deleted a file? Tough luck getting it back. IO and external interfaces could also be an issue, wrote 50% of a file to USB-stick when the user yanked it from the port? No amount of explicit error handling will help you remove it there.
If(error detected) { COME FROM (Error location); Handle Error }
It trades ease of writing error handling code for ease of debugging.
Your best bet to maintain a clean state with exceptions is to never catch them and just let the whole process die. Otherwise there will be a never ending stream of bugs that your maintenance programmers will be dealing with. They'll be cursing exceptions the whole time.
let x = match x {
Some(x) => x,
None => return None,
}
Rust doesn't have any other way to short-circuit and it gets pretty tedious.I think Kotlin's named returns are most ergonomic of all. That way you can just short-circuit from anywhere whether in the middle of a fold or a deeply nested iteration.
fn foo() {
for x in xs {
for y in ys {
if y == 42 {
return@foo 42
}
}
}
}
The other thing I'd like for other languages to steal for Kotlin is how everything has the .let/.apply/etc. methods.I'm rusty so this is more pseudocode, but these methods basically let you bring your own chaining to any value.
let y = x.let { x ->
x + 100
}.apply { x ->
println(x)
}.let { x ->
x * 2
}
Once you use it, it feels pretty silly that library authors in other languages have to deliberately design a chainable API for the same ergonomics. ...And infuriating when you want to chain some more in other languages but you've run out of chain context.Like when you use `.unwrap_or()` in Rust but still want to continue chaining on to the value. But can't because it's not in a container anymore with a chainable API.
let x = maybe
.map(foo)
.and_then(bar)
.unwrap_or(42);
// ugh, want to apply a function to the result so
// far but can't use `.map(to_base16)` because
// it's been unwrapped into u32.
Just some things from my wishlist for the next language someone makes. fn foo(xs: Vec<u32>, ys: Vec<u32>) {
'bar: for x in &xs {
for y in &ys {
if *y == 42 {
break 'bar;
}
}
}
} let x = match x {
Some(x) => x,
None => return None,
}
This implies your function returns an `Option`, and that `x` is an `Option` too. In which case you can just do let x = x?;For example, look a Swift's guard statements. It even narrows the type down-scope after you short-circuit.
If you borrow in the `match {expr}`, Rust still thinks it matters when you're trying to short-circuit in the `None` branch and will complain about the borrowed lifetime. Though this is something #![feature(nll)] fixes.
You can implement std::ops::Try for a custom data structure, but that doesn't help you in any of the cases where you just want to do some day-to-day short-circuiting without inventing your own data structure.
I shouldn't have used Option, though. It was a bad example.
fn do_something() -> Box<Future<Item=(), Error=Error>> {
let val = match do_result() {
Ok(x) => x,
Err(e) => return Box::new(err(e.into())),
};
// do something with `val`
unimplemented!();
} let val = do_result().map_err(|e| Box::new(err(e.into())))?;https://play.rust-lang.org/?gist=d40d8ca9bd77bbd60626be72740...
I only implement let and also from Kotlin, because Rust doesn't really have the idea of method receivers in quite the same way as Kotlin, so the others are pointless.
let y = x.let { x ->
x + 100
}.apply { x ->
println(x)
}.let { x ->
x * 2
}
I think you've just reinvented imperative control flow. Isn't your example just modeling a reusable function that represents a series of instructions on a value? func(x) {
let y = x + 100
println(y)
let z = y * 2
return z
} "foo" |> String.length |-> printf "%d\n" |> fun x -> x + 100Or is he just talking about some generic possibility that isn’t actually happening?
[0] https://blog.rust-lang.org/2017/03/02/lang-ergonomics.html [1] https://blog.rust-lang.org/2018/03/12/roadmap.html
I know that there are Rust ergonomics initiatives.
My question was: is there anything bad like he described in his post in one of these initiatives? Or is he just tilting at windmills?
Nothing wacky was even close to being accepted.
The weirdest proposal that made it into rust was auto deref and I think that’s a question of balance. I am on the side of the advantages outweigh the disavantages and rust without auto deref would not be a sane language but maybe some other patterns would have emerged.
When I hear/read the word "ergonomics", I think of proper posture and hand/head/eye position when you're working on a computer.
What does the word mean when used in this context?
Thanks. :)
If your language offers a good way to express the problem, but it's inconvenient (too much typing to write it out, makes you think too much about inessential problems, fragile and needs to be adjusted when other code changes, ...), the total cognitive overhead (TCO? heh) might be higher than if you just used a different, worse way to express the problem, thus potentially making really cool language features completely useless because people won't want to use them.
you know it when you see it. in whatever languages you know, look for places where there's a lot of tedious repetitive typing, and deep nesting that seems frustratingly unnecessary.
if you know Java, working with exceptions in Java has terrible ergonomics, bad enough that people come to resent the entire exception system even though it has many nice qualities.
Specifically, empty/null values and exceptions. For small code examples they might seem ergonomic at first glance, but from esperience with large projects in scala, java, and c++. The actually add writing code more difficult (at least if you care about handling error cases at all).
Regarding null, I think it is much more economic to work with an Option or Maybe type, with operations like `map`, `unwrap_or`, and pattern matching than checking if values are null all over the place. Not to mention that if values can be null, you have to know the details of any function you call to know if you need to worry about null values at all. See https://www.lucidchart.com/techblog/2015/08/31/the-worst-mis... for more on how terrible null is.
Likewise with exception systems, in large codebases you end up with try/catch statements all over the place, because it's hard to know if the functions you are calling will throw an exception, and even if you know they don't _now_, maybe someone will add a throw later. With explicit result types, you know if you need to handle errors or not, and that won't change unless the signature of the function changes.
As for implicit type conversion, the example in the OP is obviously bad. But I don't think it sacrifices safety to allow implicit conversions for widening integer or floating point types, such as widening u32 to u64 or f32 to f64 (but not u32 to i32, i32 to u64, etc. I think u32 to i64 would be ok, but I'm not 100% sure).
One reason why we have always to come up with our reflection solutions was because of this.
noexcept is part of the function type since C++17, so it will be encoded in binaries (https://stackoverflow.com/questions/46798456/handling-gccs-n...)
The Rust team thinks really hard about correctness before making any decisions and isn't going to add any ridiculous footguns to the language unless there's some organizational catastrophe that puts monkeys in charge of decision making.
* `?` can propagate values/short circuit control flow, but it also contains a hidden type conversion. The target needs to be a known type for it to compile.
* A proposed `catch` construct will include implicit type conversion for its result value.
Some things that came up in the past but met resistance:
* Removing project structure from in-code to being defined by the filesystem.
* Hiding `Result` types in signatures with special syntax.
* Dropping immutable by default, though this was pre-1.0.
Also, `Rc` and `Arc` are good examples for this. They do seem trivial, but having them auto-clone would mean:
* Every newcomer who writes `fn foo(val: Arc<Bar>) {}` instead of `fn foo(val: &Arc<Bar>) {}` or `fn foo(val: &Bar) {}` will get an implicit atomic increment/decrement at every function call.
* It also makes it a lot harder to reason about code if you want to use a clone-on-mutation-unless-not-shared strategy.
It now simply understands your intention, where before it would not. So previously you would write some code, be surprised that it does not compile, gain a thorough understanding of the limits of the borrow checker, write a workaround to appease the checker. Now you write the code, expecting it to be correct, and the borrow checker will agree.
Much more important than it understanding your intention is you understanding its limits. If it understands your intention 10% more of the time, but it's harder for you to understand what's going on when it doesn't, then that is not something everyone is going to find simpler.
Another word that gets applied to systems that try to guess at your intentions rather than operate according to an easily predictable model is 'magic'.
It's not magic when a compiler compiles your correct code.
How does this work for Mutex::lock()? Do you actually try to handle the case where taking the lock fails, because it has been poisoned?
For a mutex, this means that the lock and try_lock
methods return a Result which indicates whether a
mutex has been poisoned or not. Most usage of a mutex
will simply unwrap() these results, propagating panics
among threads to ensure that a possibly invalid
invariant is not witnessed.
A poisoned mutex, however, does not prevent all access
to the underlying data. The PoisonError type has an
into_inner method which will return the guard that
would have otherwise been returned on a successful
lock. This allows access to the data, despite the
lock being poisoned.
[0] https://doc.rust-lang.org/stable/std/sync/struct.Mutex.htmlSo it seems like the explicit unwrap() here is not preventing any bugs, it's just adding noise. Wouldn't automatic unwrap be better, at least in this case?
I'm left wondering if that was an experiment to see if anyone would notice. I don't wonder about his point; most working C++ programmers don't understand the difference either. "...yet another keyword nobody learns to use."
But is this a real danger now? Is the ergonomics initiative trying to make rust into JavaScript or Ruby? That would be unfortunate, languages should be different and the point of rust is sacrificing some ergonomic for safety and in the end more power...