Idea: Early returns and irrefutability

A common pattern in languages with nulls is

if (x != nullptr) {
  return expr;
}
x->blah(); // Treat x as nonnull, no safety though!

This is really, really useful for readability, because it avoids rightward drift (which if let Some(_) tends to make really really bad in long chains of if-lets).

What I want is the following behavior:

if let None = x {
  return expr
}
let Some(x) = x;

In particular, if an if-let can only execute if x matches a pattern, and its block types as !, then that pattern should be “ruled out” from the list of patterns that are needed to make a match irrefutable. The same holds for breakless while-lets, and match arms that type as !. For example:

enum E { A, B, C, D }
let x: E = E::A;
// E::A is ruled out, since the type is `!`.
if let A = x { return }

// No E::A arm is ok, since x match E::A has been ruled 
// out.
match x {
  E::B => panic!(), // Types as `!`, so its pattern is
                    // ruled out in the containing block.
  E::C => {},
  E::D => {},
}

while let E::C = x {
  // no breaks
}
// To reach this line, x cannot be E::C, so that 
// pattern is ruled out.

let E::D = x; // Irrefutable, since other variants have
              // been ruled out.

(For silly bonus points, you could type x as ! if all its variants are ruled out, but this seems kind of like a bad idea…)

I think this mostly only targets Option right now; if and when we get negative if let, you might imagine that the following is allowed:

// Imaginary syntax, do not bikeshed.
if !let Some(..) = x {
  return
}
// Here, *all* patterns except Some are ruled out.
let Some(x) = x;

Of course, you might say “why not just use Option<T>: Try?” Like, you could, but that doesn’t give you a chance do to processing between the is-None check and the return, and you are limited to returning an impl Try. You could also use Option combinators, but I’ve found that long chains of combinators and closures are not exactly readable (see: why we have for-in, and why Haskell has do). Anything more complicated than x.map(..).unwrap_or() is almost certainly too clever.

4 Likes

This is typestate. See previous discussion here:

Ah, excellent. I was somewhat surprised that this hadn’t otherwise been thought of.

Also, you can do this right now

let x = match x {
    Some(inside) => inside,
    None => return
}
5 Likes

Because match is an expression (see @RustyYato 's answer), and Rust’s control flow analysis is sound and strongly connected with the type system, so using a common, simple, and teachable pattern, this problem is already almost trivially solvable.

1 Like

Hmm, fair enough. I feel like rust has a serious “rightward drift” problem, though, which I care a lot about solving. I think this requires more thought than “dragging C++ patterns over”, though.

One doesn’t need to introduce flow typing or other static analyses at all to get rid of the rightward drift. As previously proposed one could just add new surface syntax that has a different nesting structure. The linked RFC was postponed in 2016, but I feel it’s been long enough to revisit it, and any case I doubt whether a far more invasive language change such as flow typing would fare any better.

I’ve written a decent deal on this topic before, both in a blog post pretending to be a rust2018 post and here on irlo.

I do like refinement typing / flow typing / smart casts in Kotlin, but in Kotlin it only happens for a) non-nullable from nullable and b) subtype from supertype.

But I honestly think that “guard let”/“refutable let” is the way to go for Rust, making a semantic version of let pat = match expr { pat => pat, _ => ! }.

The problem is that it requires introducing a new syntax to do something that is already very possible (using the “match” desugar). It’s a question of how much gain we get for the cost.

And I don’t know whether it’s really worth it. The match version is strictly more powerful, and the _ arm is already required to be appropriately typed, which includes diverging/breaking.

Where it starts becoming a clear win is when you are binding more than one thing in the refutable let. But how much does that actually happen, where you actually have a deep pattern and extract multiple values or bail?

I think it’s worth it to “canonize” the “guard” pattern with some dedicated syntax to encourage people to use that syntax instead of introducing rightward drift. But proving that to people and the several valid arguments against is no simple task.

Not to mention the bikeshed potential which tends to drown out meaningful talk a lot of the time :sweat_smile:

This is definitely a pain point for me. Here’s some examples from Quinn:

That’s just one file and only those instances where we used x as the variable name, because they were easy to find (which to me also means that one of the pains associated with this problem is having to introduce an intermediate variable name).

While I do like something more pattern-general, I think these cases could all be done with something like let stream = self.streams.get_send_mut(id) || continue; using

3 Likes

What do you think about relying on the unstable try_trait and a macro? Something like:

macro_rules! unwrap_or_else {
    ($e:expr, $op:tt) => {
        if let Ok(x) = $e.into_result() {
            x
        } else {
            $op
        }
    };    
}

This can be improved a lot, but I just wanted to show a basic idea. Obviously, we need to stabilize the Try trait, at that point we could experiment with different solutions that do not require any changes in the language.

I would like a single-line solution for this pattern, but this usage of continue seems to deviate somewhat from what I've seen so far (IIRC it's mostly used as a statement rather than an expression?).

In your case it’d be break since that’s what the code uses currently.

But yes, all of return _, break, break _, and continue are diverging expressions. Effectively they evaluate to ! and can coerce to any type, so the usage of “.unwrap_or!” here does make sense.

@RustyYato's match form makes this a little more readable IMO:

let ss = match self.streams.get_send_mut(frame.id) {
    None => continue,
    Some(x) => x
}

But a generalization of ? does seem like it would be even better...

let ss = self.streams.get_send_mut(frame_id).unwrap_or! {
    continue
};

(with a block argument because it's going to do a control transfer)

That's exactly the macro I linked in the previous post :stuck_out_tongue:

continue and break and return are absolutely expressions -- you can already do things like a == b || continue; today, though I agree that conventions tend to prefer ifs for that.

That is… a scary, valid use of short-circuiting I had not thought of. It is Javascripty in the worstest way, though. ._.

2 Likes

It's Perl5-y, actually -- or die is classic, as seen in open - Perldoc Browser

C# seems to have picked it up too. In constructors it's common to do this.whatever = whatever ?? throw new ArgumentNullException(nameof(whatever)); -- basically it's the same as an unwrap or precondition-assert!.

You say that like it's supposed to make me feel better... =P

I'm mostly unhappy about mixing things that are clearly not bools in with short-circuiting (I write a lot of C++ at work, and operator bool is not banned by our style guide...). At least, I don't think of short-circuit operators as control flow, and it's really wierd to someone who doesn't know that break (and keywords that desugar to it) type at !. Something something there should be a clippy lint for using break/continue/return (and maybe in general expressions that type at !) with short-circuit operators.

2 Likes

Ups, I did not check :sweat_smile:. However, the version you linked is more interesting than my macro (and safe against other “into_result” methods). Maybe the only problem is that the coalesce crate is already taken :sweat:.

I'm not sure what you mean by "Javascripty", but this is not one of those "wat" moments that arises due to ad-hoc design. foo || continue falls out of continue : ! and ! coercing to any other type which is consistent with the language overall.

Also, short-circuit operators really should be thought of as control flow and they may desugar into matches in the HIR soon.

1 Like