The Rust Language and Special Cases
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 Box
ed 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 proc
s, (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 Fn
, FnMut
and 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:
match expr {
Ok(v) => v,
Err(e) => return Err(e.into()),
}
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 E
value.
The try fn
feature extends this use-case to make using Result
s 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 Ok(())
:
fn sample(lst: Vec<T>) -> Result<(), String> {
for x in lst {
if is_this() {
return Ok(());
} else if is_that() {
return Err("Bad!".to_owned())?;
}
}
Ok(())
}
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:
try fn sample(lst: Vec<T>) -> () throws String {
for x in lst {
if is_this() {
return;
} else if is_that() {
Err("Bad!".to_owned())?;
}
}
}
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
Optional
type that works very much like Rust'sOption
or Haskell'sMaybe
, 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 thatOptional
works, as newcomers to the language see the constructors so rarely that they assume thatOptional
is a built-in compiler feature unlike other algebraic data types! Iftry fn
is implemented in Rust, then my suspicion is that we'll see a whole swath of new Rust developers thinking the same thing here, thattry fn
is a special error-handling construct and not merely sugar forResult
. 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 functionok()
that always returnsOk(())
. 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:fn sample(lst: Vec<T>) -> Result<(), String> { for x in lst { if is_this() { return ok(); } else if is_that() { throw!("Bad!"); } } ok() }
which is more readable and requires no language modification at all!