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.

Update: There is a new version of this RFC in #3137.

@mbrubeck mbrubeck closed this Oct 2, 2015
@mbrubeck mbrubeck reopened this Oct 2, 2015
@solson
Copy link
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
Copy link

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

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

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

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

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

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

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

solson commented Oct 2, 2015

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

@nagisa
Copy link
Member

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");

@burdges
Copy link

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

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

@Centril Centril added A-syntax Syntax related proposals & ideas A-expressions Term language related proposals & ideas A-control-flow Proposals relating to control flow. labels Nov 27, 2018
@christopherswenson
Copy link

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

@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 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 commented May 22, 2020

The x ? { y } syntax breaks this code:

if w == x? {
    y
}
{
    z
}

@BatmanAoD
Copy link
Member

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

@trevyn
Copy link

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.

@Johannesd3
Copy link

In #1303 (comment), 5 years ago:

But one thing we all agree on is that we'd like to wait on this feature while the ? notation percolates through Rust idioms, and to see whether we can find a more general idea/a better sense for the overall scope of how we'd like to improve pattern matching ergonomics.

As such: closing as postponed.

The ? operator has been established for a long time, and AFAIK no one came up with a better idea. Isn't it time to revisit this RFC? Otherwise, I would expect other criteria to be given until when it should remain postponed.

@nikomatsakis
Copy link
Contributor

I still feel quite good about this feature too. i would like it if someone would produce a short summary of the grammar challenges we encountered, I remember them being quite minor.

@bstrie
Copy link
Contributor

bstrie commented May 13, 2021

I haven't re-read all the hundreds of comments here, but to summarize the most prominent syntactic hurdle:

The RFC proposes the syntax let PAT = EXPR else { BODY }. The problem arises when EXPR is an if expression: let PAT = if EXPR { B1 } else { B2 } can be parsed either as (let PAT (if EXPR B1 B2)) or (let-else PAT (if EXPR B1) B2). The same ambiguity exists for if let as well.

The most natural compromise is to say that the former parse must always be chosen (not just for compatibility with existing code, but also because it's what users would naturally expect).

The syntax for this feature would need to be changed from EXPR to some subset of expressions that excludes if and if let. Usefully, Rust's syntax already contains such a subset: see https://doc.rust-lang.org/reference/expressions.html#expressions and note that the Expression class is composed of ExpressionWithoutBlock and ExpressionWithBlock. For maximum expedience, the syntax for this feature could instead be let PAT = EXPR_WITHOUT_BLOCK else { BODY }. This would cause the if and if let forms to parse as expected, and would additionally cause let foo = loop { break bar } else { qux }; to be rejected (IMO, totally reasonable) but would also cause let foo = unsafe { bar } else { qux }; to be rejected (possibly controversial). Of course, a more precise class of expressions that contains only if and if let could be defined, but it would be both easier and backwards-compatible to start with EXPR_WITHOUT_BLOCK for now and expand the definition later, if desired.

@Fishrock123
Copy link
Contributor

I am actively drafting a modernized RFC for this. You can follow along in Zulip if you'd like: https://rust-lang.zulipchat.com/#narrow/stream/213817-t-lang/topic/.60let.20pattern.20.3D.20expr.20else.20.7B.20.2E.2E.2E.20.7D.60.20statements

@Fishrock123
Copy link
Contributor

In case anyone from here missed it and is still interested, new RFC has been up for a few days at #3137

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-control-flow Proposals relating to control flow. A-expressions Term language related proposals & ideas A-syntax Syntax related proposals & ideas final-comment-period Will be merged/postponed/closed in ~10 calendar days unless new substational objections are raised. postponed RFCs that have been postponed and may be revisited at a later time. T-lang Relevant to the language team, which will review and decide on the RFC.
Projects
None yet
Development

Successfully merging this pull request may close these issues.