Skip to content
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 from

Conversation

@mbrubeck
Copy link
Contributor

@mbrubeck 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 force-pushed the mbrubeck:if-not-let branch from 8cd47d4 to cf95a3b Oct 2, 2015
@mbrubeck mbrubeck closed this Oct 2, 2015
@mbrubeck mbrubeck reopened this Oct 2, 2015
@solson
Copy link
Member

@solson 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
Copy link

@zslayton 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
Copy link
Member

@solson 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
Copy link
Contributor

@Aatch 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
Copy link
Contributor

@Havvy 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
Copy link
Contributor

@SimonSapin 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
Copy link

@olivren 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
Copy link
Member

@solson 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
Copy link
Member

@solson 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
Copy link

@olivren 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
Copy link
Member

@Manishearth 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
Copy link
Member

@solson 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
Copy link
Member

@Manishearth 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
Copy link
Member

@solson 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
Copy link
Member

@Manishearth 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
Copy link
Member

@solson 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
Copy link
Member

@Manishearth Manishearth commented Oct 2, 2015

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

@solson
Copy link
Member

@solson 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
Copy link
Member

@solson solson commented Oct 2, 2015

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

@nagisa
Copy link
Contributor

@nagisa 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");
@canndrew
Copy link
Contributor

@canndrew 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
Copy link

@hauleth 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
Copy link
Contributor

@canndrew canndrew commented Jan 12, 2018

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

@burdges
Copy link

@burdges 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
Copy link
Contributor

@nox 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
Copy link

@burdges 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
Copy link
Contributor

@nox 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
Copy link

@burdges 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
Copy link

@Pzixel 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
Copy link

@darkwater 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
Copy link

@eonil 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
Copy link
Contributor

@canndrew 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
Copy link

@Pzixel Pzixel commented Aug 6, 2018

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

#2497

@nielsle
Copy link

@nielsle 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
Copy link
Contributor

@canndrew 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,

@christopherswenson
Copy link

@christopherswenson christopherswenson commented Mar 11, 2019

It seems to me like we need a real guard statement in addition to !let.

It would be very unintuitive (and would probably lead to much confusion) if

if !let Some(x) = optional {
    return Err(“No value”);
}

added x to the outer scope.

While this is still useful even if it doesn’t add bindings to the outer scope, it doesn’t solve the nesting problem that guard statements address.

I agree with @eonil that Swift’s guard style is the most appropriate:

guard let Some(x) = optional else {
    return Err(“No value”);
}
println!(“x = {} is in scope”, x);

I think it’s abundantly clear what’s going on here.

Additionally, it should be possible to have a guard statement without a let:

guard x > 0 else {
    return Err(“X must be positive!”);
}
// only execute this code if x > 0
@Pzixel
Copy link

@Pzixel Pzixel commented Mar 11, 2019

Used this feature yesterday in real C# code:

private Task ValidateTransactionAsync(ILogger logger, RabbitMessage message)
{
	if (!(message is ValidationRabbitMessage validationMessage))
	{
		logger.Error("Cannot validate message {@ValidationMessage}", message);
		return Task.CompletedTask;
	}

	switch (validationMessage.Message)
	{
		case Request request:
			return ValidateRequestAsync(logger, request, validationMessage.TransactionHash);
		case RequestStatusUpdate requestStatusUpdate:
			return ValidateRequestStatusUpdateAsync(logger, requestStatusUpdate, validationMessage.TransactionHash);
		default:
			throw new InvalidOperationException($"Unknown message type '{message.GetType().Name}'");
	}
}

Still waiting this feature.

@JelteF
Copy link

@JelteF JelteF commented Sep 23, 2019

It's been 3.5 years since this was closed as postponed. In my opinion this RFC is still very relevant, so I think it might be time to reopen it.

@scottmcm
Copy link
Member

@scottmcm scottmcm commented Sep 23, 2019

@JelteF It doesn't fit on this year's roadmap, so probably doesn't make sense to reopen right now. That said, we're only a few months away from setting a 2020 roadmap, so consider whether there's a good theme you could propose in a blogpost to make it something that would fit next year.

@BatmanAoD
Copy link
Member

@BatmanAoD BatmanAoD commented May 22, 2020

Having just discovered this issue, here is how I think of it: ? provides ergonomic pattern matching combined with early-return, but it is not generalizable enough:

  • It only unwraps Option or Result, not arbitrary patterns
  • It only returns Option::None or Result::Err

I think these problems can be addressed separately. The second issue is much easier to address with new syntax, I think: ? { expr } could return expr. This can be done independently of addressing the first issue.

Solving the first issue would, I think, require some new syntax for indicating what pattern the ? should expect. I think a new keyword, perhaps expect, would be helpful:

let x = foo expect SomeVariant?;

Both new features together would result in:

let x = <expr> expect <pattern> ? { <early return> }

As an interim solution for the first issue, it should be fairly easy to create one or more macros to provide ergonomic ways to translate patterns into Options or Results. This could be somewhat like matches!:

let x = expect!(foo, Foo::SomeVariant) ? { () };

...would desugar to something like:

let x = match foo {
    Foo::SomeVariant(x) => Some(x),
    _ => Err(SomeErr),
}?;

... or, for unwrapping arbitrary enums, it could be a derive:

#[derive(variant)]
enum Foo {
    Num(i32),
    Empty,
}

fn main() -> Result<(), UnexpectedVariant> {
    let foo = Foo::Num(3);
    let x = foo.variant::<Num>()?;
    println!("Got a number: {}", x);
    Ok(())
}
@kennytm
Copy link
Member

@kennytm kennytm commented May 22, 2020

The x ? { y } syntax breaks this code:

if w == x? {
    y
}
{
    z
}
@BatmanAoD
Copy link
Member

@BatmanAoD BatmanAoD commented May 23, 2020

@kennytm Ah. I hadn't thought about that.

@trevyn
Copy link

@trevyn trevyn commented Oct 23, 2020

Not quite the same, but I've found myself using 1.42's matches! macro to do a form of guarding:

if !matches!(EXPR, PAT) { return ... }

This feels really clunky, and is not as powerful as what's presented in this RFC. Would love to see this RFC re-opened.

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