contexts
— 2019-03-31

  1. matrix
  2. contexts
  3. applications of the context lens
  4. other contexts
  5. conclusion

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:

SynchronousAsynchronous
Infalliblefn foo () {}fn foo() -> Future<Item = ()>{}
Falliblefn 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.

SynchronousAsynchronous
Infalliblefn foo () {}async fn foo() {}
Falliblefn foo () -> throw io::Errorasync 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?

SynchronousAsynchronous
Infalliblefor 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?

SynchronousAsynchronous
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:

And the slightly more difficult topic of:

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!