I first came across Rust back in 2010 or 2011, and it was a very different language than the one it is today, both syntactically and semantically. I remember at the time that newcomers would often complain loudly about the terse keywords—like the fact that the
return keyword had been shortened to
ret—and the omnipresent tildes scattered throughout the language like fallen leaves in autumn. My programming background was in functional languages—specifically in Scheme and Haskell—and I found this language fascinating, sitting in an interesting and unexplored place in the spectrum of programming languages and bringing something genuinely new to the table.
As I followed its development, I fell more and more in love with it. The core developers were pragmatic and thoughtful, and while I wouldn’t always have made the same decisions as them, I always felt like they were making decisions that were well-thought-out and reflected a deep appreciation for both the piece of technology they had created as well as the community that was sprouting up around it. But more than that: I felt like the decisions reflected principles that I, as an engineer, found to be important.
For example, one such decision—a wildly contentious one when it happened!—was the removal of the
~ syntax. Back before 2014, the type
~T represented a unique pointer to a value of type
T on the heap, and the expression
~expr allocated a value on the heap and returned a unique pointer to it. Rust removed these entirely: the type became
Box<T>, and the corresponding expression became
Box::new(expr). There was some forum discussion about whether this was happening in order to introduce syntactic salt which would make heap allocation more painful, but the primary motivation, as described in the RFC was different: it was removing the special case
~T in favor of a single more general mechanism, both to accommodate a larger number of use-cases (such as parameterizing
Boxed values by an allocator) and remove redundancy in the language. The text of the RFC even suggests leaving the
~ in the language, but proposes removing it “[…in] the spirit of having one way to do things”.
This wasn’t the only instance of such a decision: another good example is the evolution of Rust’s closure types. Rust’s closure system evolved multiple times and there were many proposals for how to develop it, but my memory of Rust at the time involved (for example) “stack closures” (which meant they could be passed to functions but never returned from functions) and
procs, (which took ownership of the things they closed over and could therefore only be called once.) The syntax used to create them was different, their types were different, and they were treated differently and specially by the compiler. Eventually, Rust switched to its current system of unboxed closures, which are subject to the same borrowing rules as other types and use traits like
FnOnce in order to abstract over their function-like behavior. Again, this takes something which used to be a special-case and turned into something general, built in terms of powerful existing building-blocks of Rust.
If you want to see the results of these changes, look at the current version of the Periodic Table of Rust Types, which looks now like a very rote mechanical chart, and compare it to the first version of the same chart from January of 2014, which features wild irregularities and special cases. The fact that Rust would take such pains to pare the language down to powerful-but-orthogonal abstractions was, by and large, my favorite feature of the language.
I should be clear about why I like this so much: I would argue that the process of taking special cases and turning them into expressions of general properties is not merely a nice aesthetic property, but in fact one of the most important things we do as software engineers and especially as programming language designers. This is the entire purpose of abstraction: it allows us to build tools which can broadly apply to many situations, and in a programming language, it allows us to build languages which have a smaller surface area and therefore are easier not just to learn—which must only be done once—but also to remember and reason about their semantics—which must be done perpetually as we use them. A programmer writing in a language must model, in their head, the meaning and execution of the language, and consequently, a language composed of small, straightforward parts is going to be easier to model than a language composed of large numbers of special cases and situation-specific abstractions.
All of this is why I’m pretty dismayed by the current direction of Rust.
The recent contentious discussion is about a much-bikeshedded piece of sugar usually called
try fn. The motivation for
try fn has to do with the often-repetitive nature of handling errors in Rust using the
Result type. Rust has several pieces of built-in support for writing functions that return
Result<T, E> to represent either a successful return value of type
T (represented as
Ok(expr)) or an error of type
E (represented as
Err(expr)). For example, the sugar
expr? requires that
expr is a
Result value, and will desugar to this:
That is to say: in the
Ok case, it’ll turn a
Result<T, E> into the
T it contains, but in the
Err case, it will return early from the enclosing function with an
try fn feature extends this use-case to make using
Results even simpler. One of the current frustrations is the preponderance of
Ok() constructors. Consider that when you’re returning a value of type
Result<(), Err> you will often have to end your block—as well as any “successful” early return—with the slightly unwieldy expression
A proposal like
try fn would introduce a sugar that automatically wraps
Ok around successful cases. There are dozens of variations that have been described, but the above example might look something like this:
To be perfectly honest: I think this sugar is a bad idea. I think that it adds an extra hurdle to learnability1, it obscures the (incredibly simple!) way that Rust’s error-handling works, it produces an unfortunate asymmetry between producing
Result values (where the shape of the data is implied by the context) and matching on
Result values (where you will still use the constructors verbatim), and worst of all, it’s only faintly nicer to read than the other, and thus a fair bit of fuss over a comparatively minor win in readability2. When compared with my earlier examples of changes to Rust, which were about removing special cases in favor of general mechanisms, this change takes a powerful feature implemented in terms of a general mechanism (errors represented as a
Result, a plain ol’ Rust
enum) and turns it into a special case.
A change like this increases the language’s surface area, and results in aggregate in more complexity to the language. One might argue that there’s a sense in which
try fn is a simplifying change: the body of the above function is shorter and simpler than it was without it. But this is only a local simplification: it streamlines the process of writing code by allowing a programmer to ignore some details about the semantics of their program, but those details are still present, they’re just not locally evident, and instead are expressed as part of a larger context. A programmer who is reading code like this now has to be aware of more details of the code in question, as the meaning of an expression like
return 5 can only be resolved by looking at the entire function definition. And now there are extra inconsistencies and special cases in the language, which puts a greater burden on the programmer’s mental model and introduces more cases to consider in an already large language.
A feature like
try fn isn’t, taken on it own, a major shift in the language in any way, but it’s still very plainly a move towards special cases, and consequently it’s a change that runs counter to the things that I loved about Rust in the first place.
There’s a very meaningful parallel that various Swift developers brought up: Swift has an
Optionaltype that works very much like Rust’s
Maybe, but in order to make it as familiar as possible, they introduced a number of pieces of syntactic sugar to make working with it as smooth as possible. The unfortunate side-effect of this is that it actually obscures the way that
Optionalworks, as newcomers to the language see the constructors so rarely that they assume that
Optionalis a built-in compiler feature unlike other algebraic data types! If
try fnis implemented in Rust, then my suspicion is that we’ll see a whole swath of new Rust developers thinking the same thing here, that
try fnis a special error-handling construct and not merely sugar for
I know that
Ok(())is a bit of a weird-looking expression, but I think that could be smoothed over without modifying the language’s features by, say, introducing a function
ok()that always returns
Ok(()). Additionally, libraries like failure already include macros that help in the error case, so I could today rewrite that function as something more or less like:
which is more readable and requires no language modification at all!↩