loading...

Refutable Let and Rust in 2018

cad97 profile image Christopher Durham ・5 min read

This examines the postponed RFC #1303 "Add a let...else expression", addressing the RFC issue #373 "Explicit refutable let.

This probably will end up formatted like an RFC, and I'd be willing to adapt it to a new RFC since the old one was postponed nearly two years ago now.

A motivating example

Consider a simple example:

if let Some(a) = make_a() {
    if let Some(b) = make_b(a) {
        if let Some(c) = make_c(b) {
            Ok(c)
        } else {
            Err("Failed to make C")?
        }
    } else {
        Err("Failed to make B")?
    }
} else {
    Err("Failed to make A")?
}

There are two key problems here. The first is locality of error handling. The error for line 1 is returned on line 12 of this tiny example. The second is rightward drift. The actual meat of the function (here just Ok(c)) is indented three levels deep. This RFC introduces a "refutable let binding" in order to address both of these issues.

First, let me address the obvious refactor to this minimal example. The current function signatures look something like the following:

fn make_a()     -> Option<A>;
fn make_b(a: A) -> Option<B>;
fn make_c(b: B) -> Option<C>;

The obvious "best" answer would be for these to return a Result with a descriptive error message that you could then propogate with ?. In a real case, though, maybe Option really is the right semantic type to return, and maybe it's vendor code you can't change, etc. etc..

Secondly: You could Option::ok_or_else?. This is how I would write this today. To be fully honest, I'd maybe still write it that way, because ok_or_else is the exact behavior that I want and is very clear. But assume for the sake of argument that this is a more complicated example, such as destructuring a complicated ADT enum. I use Option here for simplicity.

The third option is to destructure using match:

let a = match make_a() {
    Some(a) => a,
    _ => Err("Failed to make A")?,
};
let b = match make_b(a) {
    Some(b) => b,
    _ => Err("Failed to make B")?,
};
let c = match make_c(b) {
    Some(c) => c,
    _ => Err("Failed to make C")?,
};
Ok(c)

or by "stuttering" an if let:

let a = if let Some(a) = make_a() {
    a
} else {
    Err("Failed to make A")?
};
let b = if let Some(b) = make_b(a) {
    b
} else {
    Err("Failed to make B")?
};
let c = if let Some(c) = make_c(b) {
    c
} else {
    Err("Failed to make C")?
};
Ok(c)

playground

Here is the same example using a refutable let:

let Some(a) = make_a() else {
    Err("Failed to make A")?
};
let Some(b) = make_b(a) else {
    Err("Failed to make B")?
};
let Some(c) = make_c(b) else {
    Err("Failed to make C")?
};
Ok(c)

This is actually implementable using macro_rules macros, though not without some difficulty. playground

Why?

The point of a refutable let is that you have some destructuring to do, and you want to handle the case where you cannot destructure by diverging from the function.

The simple desugar of let...else is the following transformation:

let PAT = EXPR else BLOCK;
// =>
let (bindings) = match EXPR {
    PAT => (bindings),
    _ => BLOCK: !,
}

where BLOCK: ! is type ascription. The ascribed ! type makes it such that BLOCK needs to diverge (this effectively means return, break or continue). (bindings) here is a tuple of all assigned bindings in the pattern, so that they can be moved out of the pattern and into the containing scope.

You can hopefully see that this pattern reduces the required stuttering in using a match or a let = if let for this pattern of early-return to handle errors locally. More key, however, is that this enforces the diverge. If you write a match or let = if let or unwrap_or_else or anything other than a ?, then the error handling branch could instead be a default value branch. Requiring the diverge at a language level increases the guarantees that a reader has when reading the code.

Problems and alternate syntaxes

Consider parsing the following:

let foo = if bar { baz() } else { quux() };

// Option 1:
let foo = 
    (if bar { 
        baz()
    } else {
        quux()
    });

// Option 2:
let foo = (if bar { baz() })
else { quux() };

The second is actually a vailid parse option if baz() is of type (). if COND { () } is valid in expr position and has type (). playground

Unfortunately, this ambiguity means that if PAT = EXPR else is not a possible unambiguous syntax; there is a reason else isn't in the follow set for expr.

Other proposed syntaxes:

<keyword> let PAT = EXPR else BLOCK

Suffers from the same ambiguity.

if !let PAT = EXPR BLOCK

Not ambiguous, but suffers from overloading if let, which doesn't put bindings into the scope that contains it.

<keyword> let PAT = EXPR BLOCK

Unambiguous, but requires a new keyword. Keyword possibilities include unless.

Recently

Two RFCs recently popped up that relate to this are #2221 Guard Clause Flow Typing and #2260 if- and while- let chains.

The former is looking to get the same functionality from flow typing, and the author seems to not consider let...else as being a valid "guard". I'll not say more, lest I assert something that isn't true.

The latter looks to allow chaining if let to reduce nesting. Though this doesn't seem direclty related, it was mentioned often that having a refutable let would much reduce the necessity of being able to chain if let together at one block level.

Looking forward to #Rust2018

Unless I've missed some glorious obvious-in-retrospect syntax, if !let PAT = EXPR BLOCK seems to be the only no-new-keyword-viable solution to the refutable let. Probably, this will end up being the syntax to use, and while maybe alien now, it might become as second nature as if let is now.

I can introduce a new RFC proposing using this syntax again, and detailing the problems with the other syntaxes. Maybe there the design can be fully, properly 🚲 :shed: (you pick the emoji :slight_smile:).

For the rest of Rust in 2018, I agree with much of the other #Rust2018 posts. Rust would probably be best served by a tick/tock cycle, where this year is spent on paying down stabilization debt. Many great features are just around the corner and just need that final push on design/implementation work.

This is not to say nothing new should happen; WebAssembly work should continue, the RFC machine should continue moving (though we shouldn't tell people to go submit ideas as rapidly as happened before the impl period cutoff). Stabilizing key tools, landing the impl period features, and (re)clarifying stability. Some way to endorse crates as high quality, ready-for-production libraries. Async/await/generators.

These are all great things which have been started. Let's move them towards finished together!

Oh, and my never-gonna-happen breakage whishlist: make all of the Iterator fn return impl Iterator instead of concrete types. impl Trait all of the things.

UPDATE to above: u/QuietMisdreavus pointed out the flaw with this:

This is actually a loss of information in many cases. Several of the Iterator wrappers also conditionally implement DoubleEndedIterator, ExactSizeIterator, FusedIterator, etc, based on their contained type. As far as i know, there's no way to represent that in impl Trait syntax.

Having realized the error of my ways, this is no longer a choice I would make.

Posted on by:

cad97 profile

Christopher Durham

@cad97

Rust fan and Programming Language Enthusiast

Discussion

markdown guide