New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a `let...else` expression, similar to Swift's `guard let...else` #1303

Closed
wants to merge 15 commits into
base: master
from

Conversation

Projects
None yet
@mbrubeck
Contributor

mbrubeck commented Oct 2, 2015

Rendered

Note: The initial version of this RFC used the syntax if !let PAT = EXPR { BODY }. In the current version this has been changed to let PAT = EXPR else { BODY } for reasons discussed below.

@mbrubeck mbrubeck closed this Oct 2, 2015

@mbrubeck mbrubeck reopened this Oct 2, 2015

@solson

This comment has been minimized.

Member

solson commented Oct 2, 2015

This would be awesome to have. I often want to check error conditions and early-return at the start of a function and have the rest of the code be clear and un-indented.

I'm usually pretty conservative when it comes to new syntax sugar, but I've wanted this exact feature so many times.

Importantly, it can un-nest the core logic of even a simple function:

fn foo() {
    if let Some(x) = bar() {
        // body goes on for a while
        // .
        // .
        // .
        // .
        // .
        // .
    } // wait, why am I doubly nested here?
}

vs

fn foo() {
    if !let Some(x) = bar() { return; }
    // body goes on for a while
    // .
    // .
    // .
    // .
    // .
    // .
    // end as usual
}

In my experience this reduces the cognitive load of reading such code. The error conditions are out of sight and out of mind while reading the body. In the former case the error condition lingers in the form of nesting, and possibly an else block, and it only gets worse in many real examples with multiple things to be unwrapped and matched.

Before the inevitable bikeshed sets in, I'll just say it's not that important whether we really use if !let or some of the many other possible variations, as long as the basic idea of eliminating rightwards drift is preserved.

@zslayton

This comment has been minimized.

zslayton commented Oct 2, 2015

This looks cool! I wonder though: if the primary use case would be Option and Result, is this substantially better than:

fn main() {
  let maybe: Option<usize> = None;
  if let None = maybe {
    println!("It was 'None'.");
  }

  let result: Result<(), ()> = Err(());
  if let Err(_) = result {
    println!("It was 'Err'.");
  }
}

?

Definitely neat for more complicated types!

@solson

This comment has been minimized.

Member

solson commented Oct 2, 2015

@zslayton Your examples don't allow the user to get the "success" value out of the Some or Ok, unless they also .unwrap(), and there may not be an unwrap for every type.

@Aatch

This comment has been minimized.

Contributor

Aatch commented Oct 2, 2015

Doesn't look bad, though I'm concerned about the implemention. Detecting divergence is easy enough (it more-or-less already exists, you'd just not get particularly nice error messages) but it's the rest that I'm worried about. It can't be implemented by desugaring, as it has an effect on the entire function, not just the little part it occupies syntactically. As such, this isn't really an extension of the current if let syntax.

Instead, it is much closer to let than if let and I'd like to see the RFC expand on the semantics in that respect.

@Havvy

This comment has been minimized.

Contributor

Havvy commented Oct 2, 2015

This feels too different from if let while at the same time feels like it's very special syntax to solve a very non-general problem. This also eats up a part of Rust's "strangeness" budget.

@SimonSapin

This comment has been minimized.

Contributor

SimonSapin commented Oct 2, 2015

While if !let in its own seems reasonable at first (it could expand to if match value { pattern => false, _ => true } { … }) I find very surprising that the scope of the bindings is after the thing that defines them, rather than inside like for match and if let.

The "must diverge" aspect is also unusual, but it shows that this proposal is firmly about returning early in "error-like" cases. We already have this for the Result type with the try! macro. I think if !let as proposed here is weird enough that I’d rather see try! extended to support more cases. Let’s enumerate combinations of Option and Result:

  1. With a Option<T1> parameter in a function that returns Result<T2, E>. This can not be automatic since the error value needs to come from somewhere, but Option::ok_or already works. For the example in the RFC (E = &'static str): let a = try!(x.ok_or("bad x"));
  2. With a Option<T1> parameter in a function that returns Result<T2, ()>. This works: try!(x.ok_or(())) but it’s kinda ugly. try!(x) would look better, mapping None to Err(()) implicitly.
  3. With a Option<T1> parameter in a function that returns Option<T2>. Map None to None
  4. With a Result<T1, ()> parameter in a function that returns Option<T2>. Map Err(()) to None.
  5. With a Result<T1, E> parameter in a function that returns Option<T2>. It is possible to map Err(_) to None, but I don’t know if dropping the error value silently is desirable.

I have an experiment that does 2~4 with Sufficiently Advanced Generics. 5 could be added easily. It also converts to/from bool (mapping false to/from None or Err(())). There’s a combinatorial explosion of the number of impl, but I think it’s manageable since they can be in a library, and we’re unlikely to add more types to the Result/Option/bool mix.

For the 1 case, maybe try!(x.ok_or("bad x")) could instead be written as try!(x else "bad x") or something.

@olivren

This comment has been minimized.

olivren commented Oct 2, 2015

I find it disturbing that this new form of let is actually not introducing a variable.

if !let Some(a) = x {
    println!("{}", a); // hoops, `a` does not even exist here!
}

Also, I don't understand why there should be a requirement about the divergence of the body, except for the fact that it would be a common use case for this construction.

As an alternative, you can add that the same result can be achieved using this construct:

if let Some(a) = x {
} else {
    return;
}
@solson

This comment has been minimized.

Member

solson commented Oct 2, 2015

@olivren The a is in scope after the end of the if !let. This is why @Aatch said it's more like let than if let. That's also why it would be illegal for the body not to diverge, since if it didn't it would then enter into the code where a is in scope, even though a doesn't exist in the case that the body was run in the first place.

@solson

This comment has been minimized.

Member

solson commented Oct 2, 2015

I'll present an alternative syntax that might get the point across better:

// Unlike in a regular let, this pattern must be refutable, not irrefutable.
let Some(a) = x else { return; }
// use `a` here

The above would be equivalent to this RFC's:

if !let Some(a) = x { return; }
// use `a` here
@olivren

This comment has been minimized.

olivren commented Oct 2, 2015

@tsion Oh I missed that a was available afterwards, indeed. I would never have expected the binding to escape its block, this is surprising.

@Manishearth

This comment has been minimized.

Member

Manishearth commented Oct 2, 2015

I like @tsion's alternate syntax.

FWIW, in clippy we have https://github.com/Manishearth/rust-clippy/blob/master/src/utils.rs#L266, which works pretty well

I'm a bit uneasy that this introduces a branch without indentation. It's a bit jarring to read.

@solson

This comment has been minimized.

Member

solson commented Oct 2, 2015

I feel like it's the same as:

if !some_important_condition() { return; }

And the error handling block could be placed on the next line and intended as normal, etc.

@Manishearth

This comment has been minimized.

Member

Manishearth commented Oct 2, 2015

Yeah, but returns/breaks/continues are pretty well known across the board. This is ... strange, and cuts into our "strangeness budget as @Havvy mentions.

@solson

This comment has been minimized.

Member

solson commented Oct 2, 2015

I have to say I'm surprised at the references to the strangeness budget, because I find this to be a very natural extension of the language (at least with a sufficiently bikeshedded syntax).

The essence of the idea is this: lets that can fail.

Then you need a block to handle the failure case, hence the else { return; } after the let in my alternate syntax.

(Note that this is a different sense of "lets that can fail" than if let deals with. if let is just a match in disguise and doesn't really introduce the idea of a refutable let. Hence @Aatch's concerns above about this requiring non-trivial implementation work.)

@Manishearth

This comment has been minimized.

Member

Manishearth commented Oct 2, 2015

The main "strange" bit is that it introduces nonlinear flow, in a novel way. return/break/continue are known to behave that way. They also have obvious points to jump to.

fn foo() {
  if something{
    if !let ... {}
    if !let ... {}
  }
}

if one of them fails, where does it jump to next? It's not immediately obvious. return/break/continue are known constructs, and everyone knows where they jump to. This is completely non-obvious on the other hand, especially for people coming from other languages.

@solson

This comment has been minimized.

Member

solson commented Oct 2, 2015

@Manishearth But the bodies of those if !lets must contain a return/break/continue/panic! or some diverging expression. There is no new kind of flow control introduced by this RFC.

@Manishearth

This comment has been minimized.

Member

Manishearth commented Oct 2, 2015

Oh, right. In that case, I'm mostly okay with this.

@solson

This comment has been minimized.

Member

solson commented Oct 2, 2015

To be specific, for anyone else reading, if !let pat = expr {} with an empty body like that would be rejected, because the error handler block {} isn't diverging.

@solson

This comment has been minimized.

Member

solson commented Oct 2, 2015

(Or let pat = expr else {}, how ever the syntax gets bikeshedded. 😛)

@nagisa

This comment has been minimized.

Contributor

nagisa commented Oct 2, 2015

I like the @tsion’s example above and think the syntax could be expanded even further to subsume unwrap_or_else-like methods, but without closure scope:

let Some(a) = x else true; // default value?! 
let Err(string) = err else "no error";
let Token(TokenLiteral::String(s)) = token else return Err("string token expected");
@glaebhoerl

This comment has been minimized.

Contributor

glaebhoerl commented Oct 2, 2015

I like this idea! I first proposed it (using @tsion's syntax) almost two years ago :), and made a tracking issue for it one year ago.

I prefer either the let..else or unless let syntax to if !let, for the same reason that I'm wary of proposals (which have also been made) to allow things like if let Some(x) = foo && let Some(y) = bar. Right now, !, &&, and || are operators on bools - this is clear and simple. The potential for generalization to pattern matching is tantalizing to be sure, but it's not obvious (at least to me) how you could really do that - how to formulate them as operators which apply to either booleans or lets in general. I feel like either we should actually figure out how to do it, or we should keep them as they are, but we shouldn't add syntactic special cases to make it look like they've been generalized when they haven't been.

@RalfJung

This comment has been minimized.

Member

RalfJung commented Jun 12, 2017

I had similar thoughts considering the if guards, but to me if let ... if ... { ... } looks really odd. It looks like you actually meant if let ... { if ... { ... } }, except you forgot the braces and the compiler accidentally accepted the code. I feel like it is not clear what if let ... if ... { ... } else { ... } does -- which if does the else "belong to"?

I take your point about having complex, compound structures just "fall out of" putting together their pieces. But having two if in a line mean conjunction doesn't seem to be a desirable outcome to me.

@glaebhoerl

This comment has been minimized.

Contributor

glaebhoerl commented Jun 12, 2017

else-binding is a good point, even if it's obvious to me that it can only belong to the outermost if (just like you can't attach an else to an if guard in a match arm).

@phaux

This comment has been minimized.

phaux commented Sep 12, 2017

If #2107 gets accepted we could write

let Ok(x) = foo() else throw Error:FooError;

which is a nice syntax

@nox

This comment has been minimized.

Contributor

nox commented Sep 12, 2017

@phaux Please no. What's wrong with staying away from ill-advised exceptional terminology from other languages?

let Ok(x) = foo() else return Err(Error::Foo);
@algesten

This comment has been minimized.

algesten commented Sep 12, 2017

Dear lord. Please no exceptions!!!

@burdges

This comment has been minimized.

burdges commented Sep 12, 2017

We already have let x = foo().map_err(|e| Error:FooError)?; which looks vastly more clear @phaux

@scottmcm

This comment has been minimized.

Member

scottmcm commented Nov 21, 2017

Another possibility to throw out there, now that we can add keywords:

    unless let Statement {
        source_info,
        kind: StatementKind::Assign(lvalue, Rvalue::BinaryOp(_, lhs, rhs)),
    } = bin_statement
    {
        continue;
    };
    ... do stuff with the 4 bindings ...

Which would desugar to

    if let Statement {
        source_info,
        kind: StatementKind::Assign(lvalue, Rvalue::BinaryOp(_, lhs, rhs)),
    } = bin_statement
    {
        ... do stuff with the 4 bindings ...
    } else {
        let _: ! = {
            continue;
        };
    }

Which demonstrates that we already have sufficient language support for checking that a block is divergent, and I think having to add (source_info, lvalue, lhs, rhs) twice is a better motivation than the plethora of Some(x) above.

Edit: Oh, glaebhoerl already suggested that two years ago in #373 (comment) 😅

@comex

This comment has been minimized.

comex commented Nov 21, 2017

Meh. My sentiment is the opposite of @glaebhoerl's from a few months back: these proposals are going to contortions to work around a problem that would be more elegantly solved by making let an expression (and thus allowing if !let, &&, etc.). I feel like that could be the same kind of change that if let itself once was: on paper it looked unnecessary, perhaps inelegant, but once people tried it out, they wondered how they'd lived without it.

…Or I could be wrong, since it's not like I've tried it out. But I don't think so.

@CAD97 CAD97 referenced this pull request Nov 23, 2017

Closed

Guard Clause Flow Typing #2221

@kennytm

This comment has been minimized.

Member

kennytm commented Nov 23, 2017

@comex The problem of using let as an expression is that we can't use it to "flatten" the nest if clauses involving variable declarations. The main advantage of let ... else is that we can write:

// example 1
    let x: Result<i32, i32> = Ok(3);
    let Ok(y) = x else { return };
    println!("{:?}", y); // 3

However, in if !let where let is simply an expression, we cannot force the else block to be diverging or have the pattern Ok(_), thus

// example 2
    let x: Result<i32, i32> = Ok(3);
    if !let Ok(y) = x { 
        println!("{:?}", x); // this should be acceptable right?
        // (not diverging)
    }
    println!("{:?}", y);  // `y` cannot be defined here.
@burdges

This comment has been minimized.

burdges commented Nov 23, 2017

We've had a long thread with many strong objections to the let .. else syntactic sugar, although adding a keyword like guard sounds less bad. You could presumably still write let y = if !let Ok(y) = x { ... } else { y } if lets ever become expressions, otherwise you'd only allow !let Ok(_). An infix operators is/matches vs. "everything including let is an expression" sounds like a mostly aesthetic distinction, so folks should figure out what is easier to read.

I suggested in the guard clause flow typing thread that we wait and watch the parser performance between Rust and Puffs. I think folks will care if Puffs sees performance benefits attributable to their usage of subtyping and refinement types and/or their assertion/proofs business. If we wind up wanting refinement types for performance reasons anyways, then they might produce syntax usable in the guard let .. else situation, ala my let Ok(y) = refine x { Err(y) => diverging(y); }; syntax. If the proofs matter more, then those bring different features like prove_unreachable!().

@nox

This comment has been minimized.

Contributor

nox commented Nov 23, 2017

What does Puffs have to do with the gained expressivity of let ... else?

I long for that feature every day.

@canndrew

This comment has been minimized.

Contributor

canndrew commented Jan 12, 2018

How about let match syntax? It looks like this:

let match Ok(x) = foo() {
    Err(e) => return Err(e),
}
// x is in scope here

This way you have access to the information that didn't match in the "else" clause.

@hauleth

This comment has been minimized.

hauleth commented Jan 12, 2018

@canndrew what would be advantage over

let x = match foo() {
  Ok(x) => x,
  Err(e) => return Err(e)
}

Because it seems negligible.

@canndrew

This comment has been minimized.

Contributor

canndrew commented Jan 12, 2018

@hauleth
It saves one line. That is a line I have to write very, very often though.

@burdges

This comment has been minimized.

burdges commented Jan 12, 2018

I find let match less objectionable than other proposed syntaxes, but .map_err(...)? remains easier.

I still think refine gives the most elegant answer: refine acts like a non-exhaustive match with presumably divergent cases but returns a "refinement type" so that let Some(x) = refine foo { None => return ...; }; works. These break though:

let y = refine bar { None => None }; // Type error if y can only contain a Some, not a None
let mut z = refine baz { Err(e) => return ..; };  // Is mutability compatible with refinement types?
z = z.map(..)  // Not obviously wrong but insane to prove correct
        .and_then(..);  // Type error surely?

And refine should fit into some larger story around constrained types, design by contract, or maybe even formal verification.

@nox

This comment has been minimized.

Contributor

nox commented Jan 13, 2018

This RFC introduces something that is more expressive than .map_err(...)?, for example you can't use that in a function that returns (). It also only works on Try types, which aren't applicable everywhere.

Why would the else part of the refine expression be itself a list of match arms? That's entirely different than this RFC is about, and would even require extensions for the type system. Furthermore, the suggested syntax is no shorter than the equivalent match expression.

What is the difference between the two following expressions in your examples?

let y = refine bar { None => None };
let Some(y) = refine bar { None => None };

The two being apparently allowed looks weird to me.

@burdges

This comment has been minimized.

burdges commented Jan 13, 2018

In the first, y is a refinement subtype of Option that only allows Option::Some; hence my comment that let mut y = refine .. looks problematic. In the second, the refinement type is destructured to produce y, so let Some(mut y) = refine .. is not problematic.

refine would not merely be syntactic sugar but part of adding stronger correctness enforcing properties to the type system, so like numerically constrained types too. It does support almost all the match sugar ever proposed though:

let x = refine x { VariantA(..) => .. }
do_something();
let x = refine x { VariantB(..) => .. }
do_something_else();
let VariantD(..) = refine x { VariantC(..) => VariantD(..) }
@nox

This comment has been minimized.

Contributor

nox commented Jan 13, 2018

@burdges This is highly complicated, without any precedent, requires rustc to create subsets of enum types and whatnot on the fly (because the non-diverging path may have n - m variants defined, where n is the total number of variants and m the number of variants discriminated against in the refine arms), and the last example seems to require the same machinery as what was
in #1303 (comment) and later argued against.

This also still seems completely unrelated to let..else so it should rather go in its own PR rather than in a new PR about let..else.

@burdges

This comment has been minimized.

burdges commented Jan 13, 2018

Yes it be complicated. Right now, it's an argument against doing any let ..else syntax until Rust figured out if or what direction they want for stronger correctness properties.

@Pzixel

This comment has been minimized.

Pzixel commented Mar 6, 2018

I really want this RFC to be accepted in its original form. Then, when you write:

if !let Some(x) = get_some() {
    return Err("it's gone!");
}
println!("{}", x); // and other useful stuff
// more stuff

which translates into

if let Some(x) = get_some() {
    println!("{}", x); // and other useful stuff
    // more stuff
}
else {
   return Err("it's gone!");
}

Yes, it's a bit uncommon that instead of been declared in block scope x is declared elsewhere but in this scope but it comes very natural with this operator, and this is how it works in several others languages (e.g. C#)

@darkwater

This comment has been minimized.

darkwater commented Apr 17, 2018

I feel like the ! in !let could be easily missed, maybe if not let is better? Or even if let .. != x but I think if not let is ideal.

@eonil

This comment has been minimized.

eonil commented May 13, 2018

IMO, Swift guard syntax is a boolean conditional branch at first.

guard a != 123 else { return }
guard let b = get_optional_value1() else { return }

They're specifically designed for check and forced early-exit logic. And guard let optional value binding is a sort of extra feature aligned to existing if let a = ... { ... } syntax.

IMO, new relatively unfamiliar keyword guard has been introduced to provide consistent look for "check & forced early-exit" statements. As this guard let statement has two big difference with if let statement,

  • The bound name b is placed in and accessible from outer block unlike if statement.
  • Control flow must diverge in else block.

using of if doesn't feel good or right to me. Users more likely to be confused, and need to focus more to distinguish them which means less readability. Swift tried to bind meaning of "check & forced early-exit" solely in the keyword guard, and it's very successful for readability. Is there better reason to avoid this?

IMO, just using guard would be better for consistency and readability. Users immediately recognize that the statement is specialized "check & forced early-exit" rather than generic conditional branch. With guard statements, logic layout becomes like this in many cases.

// many check & early-exit to exclude errors and issues (no mutation yet)
guard let a = get_something1() else { return }
guard a > 20 else { return }
guard a < 30 else { return }
guard let b = get_something2() else { return }
guard b > 20 else { return }
guard b < 30 else { return }
guard let c = get_something3() else { return }
guard let d = get_something4() else { return }

// perform mutation.
if a == b {
    do_first(a, b);
    do_second(c, d);
}
else {
    do_first(c, d);
    do_second(a, b);
}
return;

Series of guard statement build a big cluster and delivers strong visual signal to readers.


Or just using nothing would be better for extreme brevity by sacrificing readability.

let a = get_optional_value1 else { return }.

As it's clear that the name a is placed in outer block, and there's no if, but now it can be confused with plain let statement. I'm not sure it's worth to pay...

@canndrew

This comment has been minimized.

Contributor

canndrew commented Aug 6, 2018

In a similar vein to this RFC I've seen proposals to allow && in if let statements:

if let Some(x) = foo && condition { ... }

if let Some(x) = foo && let Ok(y) = bar { ... }

I've also seen people propose allowing || with if let:

if let Some(x) = foo || let Ok(x) = bar { ... }

I think the lesson here is that we could unify and generalize all these proposals to allow a whole algebra of binding conditionals where !, || and && can be used as operators. Here's how it would work:

A conditional consists of one of:

  • A boolean expression. This binds no variables.
  • let pat = expr: This positively binds all the variables in pat
  • !conditional: This changes all positive bindings in conditional to negative bindings, and vice-versa.
  • conditional_a && conditional_b: This is only valid if the two conditionals have disjoint sets of positive bindings and identical sets of negative bindings. The resulting conditional positively binds all the positive bindings in both conditionals, and negatively binds the same variables as the two conditionals.
  • conditional_a || conditional_b: This is only valid if the two conditionals have disjoint sets of negative bindings and identical sets of positive bindings. The resulting conditional postively binds the same variables as the two conditionals, and negatively binds all the negative bindings of both conditionals.

With this system you could write stuff like

if some_bool || !let Some(x) = foo || !(let Some(y) = bar && let Some(z) = qux) {
    return;
}

// do something here with x, y and z
@Pzixel

This comment has been minimized.

Pzixel commented Aug 6, 2018

In a similar vein to this RFC I've seen proposals to allow && in if let statements:

#2497

@nielsle

This comment has been minimized.

nielsle commented Aug 6, 2018

@canndrew Interesting idea. AFAICS rust shouldn't allow matches on both branches of an if-statement. As an example the following code looks meaningful, but also slightly confusing.

if !let Some(x) = foo || let Some(y) = bar || let y=2  {
    // Do something with y
    return
}
// Do something with x

Nitpick: In your example you cannot do stuff with x unless Some(x) matches to foo, so you will never have access x and y at the same time, but that doesn't detract from the general idea.

@canndrew

This comment has been minimized.

Contributor

canndrew commented Aug 6, 2018

AFAICS rust shouldn't allow matches on both branches of an if-statement.

Yeah, it's actually impossible with just !, || and && to have both positive and negative binds in a conditional (using the same terminology as before). The code you gave doesn't work for instance because y isn't necessarily bound in the if block.

Nitpick: In your example you cannot do stuff with x unless Some(x) matches to foo, so you will never have access x and y at the same time, but that doesn't detract from the general idea.

I'm not sure I follow. If Some(x) matches to foo then !let Some(x) = foo fails and so the next conditional (!(let Some(y) = bar && let Some(z) = qux)) gets evaluated too. We only make it past the if block if all of x, y and z get bound.

I guess this shows that this syntax has the potential to be too confusing though,

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment