contexts
— 2019-03-31
I've recently been thinking a lot about Rust's ergonomics, especially around async and error handling. I've come up with quite a few fun ideas that I'm excited to share. But I think that before I can meaningfully explain their significance, it's important to take a step back and create a lens through which to view them.
Note: I'm don't consider myself a language designer, so don't take this as language design advise. I mostly just wanted to talk about how I'm currently thinking about things, and figured writing this in long-form might be better than as a string of tweets. Anyway, this is all just my opinion & thoughts, and I'm not on Rust's lang team so this is in no way representative of anyone on there.
And with that said, let's dig in!
Matrix
I think one of the cool things about Rust is that it's a single language that allows you to write the low-level bits of a program, and the high-level abstractions in the same language. This means that we have a language that needs to balance an incredible amount of different aspects, and merge them into a cohesive whole.
This is not easy, and I can't claim to have nailed the grand unifying theory of language design. But I do think I have a neat angle to apply to high level Rust design. It's the following table:
Synchronous | Asynchronous | |
---|---|---|
Infallible | fn foo () {} | fn foo() -> Future<Item = ()>{} |
Fallible | fn foo () -> Result<(), io::Error> | fn foo() -> Future<Item = Result<(), io::Error>> |
I think for high-level Rust there are essentially 2 axis, and 4 different language combinations. There's the question whether operations can fail or not. And the question whether functions can be suspended or not (e.g. futures in this case, though generators behave similarly).
Using this lens we can explain some of the historical problems we've had. E.g. the original Futures design didn't care to handle the Infallible + Asynchronous case; probably because it didn't consider anyone might ever need it. But then Fuschia came along, and very clearly did need it, which meant another round of design was needed.
Contexts
In fact, a good way of viewing the different axis in the table is as contexts. If we're in a
function that returns a Result
, we're in a fallible context. If we're in a function that returns
a Future
, we're in an async context.
Rust's language design acknowledges that async contexts are a real thing, and has appropriately
introduced the async fn
keyword. This sugar has allowed the language to remove a lot of
boilerplate around suspension points, and borrowing across them, creating a better experience
throughout.
I think we have similar boilerplate problems with Result
, and by treating it as the dual of async fn
we could create a better experience -- in particular for fallible contexts that don't return
values (getting rid of Ok(())
everywhere).
Given we could have a throw
keyword in argument position, we could then have clearer signaling of
the context we're executing in, and in turn remove all the boilerplate currently required to achieve
the same result.
Synchronous | Asynchronous | |
---|---|---|
Infallible | fn foo () {} | async fn foo() {} |
Fallible | fn foo () -> throw io::Error | async fn foo() -> throw io::Error |
Just to finish the thought: fallible contexts would pair with a throw
keyword in body position to
return errors:
// current
return Err(e);
// new
throw e;
Also none of this is my idea. I believe various stages of RFCs and discussions exist around this in the wild. But I hope the general point makes sense about contexts.
Applications of the Context Lens
Some other language features I've been applying this lens to is extensions of async await. For
example yielding a TcpStream
is always fallible, so what if we introduced a way to consider
fallible iterators? What about async fallible iterators?
// synchonous, current
let listener = TcpListener::bind("127.0.0.1:80")?;
for stream in listener.incoming() {
let stream = stream?;
stream.write_all(b"hello world")?;
}
I think most people would agree that let stream = stream?
feels like a bit of boilerplate that
could probably be considered in the language design. What would it look like if we could fill out
this chart?
Synchronous | Asynchronous | |
---|---|---|
Infallible | for item in iterator {} | ? |
Fallible | ? | ? |
Similarly there's been talk about adding a select {}
keyword for futures. There's currently a
select!{}
and try_select!{}
macro in the future-preview
crate.
// The first future to complete will assign its value to `res`.
// All other futures are then cancelled.
let res = await select {
my_struct.do_thing(),
other_struct.thing()
}
The synchronous counterpart to awaiting futures would probably blocking threads until a thread's
JoinHandle
returns. Filling out the chart again, what would each entry be?
Synchronous | Asynchronous | |
---|---|---|
Infallible | ? | await select {} |
Fallible | ? | ? |
Other Contexts
But the above is not the only way to slice our cake. There are many more constraints to consider when thinking about features:
- Do we have
std
support? - Are we in a
const
context? - Are we in a browser? E.g.
wasm32-unknown-unknown
probably means we can't use the usual networking / filesystem APIs.
And the slightly more difficult topic of:
- How does it compare if we tried to solve this problem in userland?
- How does this feature interact with future follow-ups?
- How does this feature interact with currently proposed language extensions?
- Who are we building this feature for?
- Are there any unsolved problem
Conclusion
And that's it. There's not much of a conclusion here. But I wanted to write down how I'm currently thinking about (language) design, and the lens I'm applying. I think building up lenses like these are useful to consider design decisions through, and I hope I can build up a bigger collection of these as time goes on.
Anyway, I hope this is useful!