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

RFC for if let expression #160

Merged
merged 2 commits into from
Aug 27, 2014
Merged

RFC for if let expression #160

merged 2 commits into from
Aug 27, 2014

Conversation

lilyball
Copy link
Contributor

@lilyball lilyball commented Jul 9, 2014

  • Start Date: (fill me in with today's date, YYYY-MM-DD)
  • RFC PR #: (leave this empty)
  • Rust Issue #: (leave this empty)

Summary

Introduce a new if let PAT = EXPR { BODY } construct. This allows for refutable pattern matching
without the syntactic and semantic overhead of a full match, and without the corresponding extra
rightward drift. Informally this is known as an "if-let statement".

Motivation

Many times in the past, people have proposed various mechanisms for doing a refutable let-binding.
None of them went anywhere, largely because the syntax wasn't great, or because the suggestion
introduced runtime failure if the pattern match failed.

This proposal ties the refutable pattern match to the pre-existing conditional construct (i.e. if
statement), which provides a clear and intuitive explanation for why refutable patterns are allowed
here (as opposed to a let statement which disallows them) and how to behave if the pattern doesn't
match.

The motivation for having any construct at all for this is to simplify the cases that today call for
a match statement with a single non-trivial case. This is predominately used for unwrapping
Option<T> values, but can be used elsewhere.

The idiomatic solution today for testing and unwrapping an Option<T> looks like

match optVal {
    Some(x) => {
        doSomethingWith(x);
    }
    None => {}
}

This is unnecessarily verbose, with the None => {} (or _ => {}) case being required, and
introduces unnecessary rightward drift (this introduces two levels of indentation where a normal
conditional would introduce one).

The alternative approach looks like this:

if optVal.is_some() {
    let x = optVal.unwrap();
    doSomethingWith(x);
}

This is generally considered to be a less idiomatic solution than the match. It has the benefit of
fixing rightward drift, but it ends up testing the value twice (which should be optimized away, but
semantically speaking still happens), with the second test being a method that potentially
introduces failure. From context, the failure won't happen, but it still imposes a semantic burden
on the reader. Finally, it requires having a pre-existing let-binding for the optional value; if the
value is a temporary, then a new let-binding in the parent scope is required in order to be able to
test and unwrap in two separate expressions.

The if let construct solves all of these problems, and looks like this:

if let Some(x) = optVal {
    doSomethingWith(x);
}

Detailed design

The if let construct is based on the precedent set by Swift, which introduced its own if let
statement. In Swift, if let var = expr { ... } is directly tied to the notion of optional values,
and unwraps the optional value that expr evaluates to. In this proposal, the equivalent is if let Some(var) = expr { ... }.

Given the following rough grammar for an if condition:

if-expr     = 'if' if-cond block else-clause?
if-cond     = expression
else-clause = 'else' block | 'else' if-expr

The grammar is modified to add the following productions:

if-cond = 'let' pattern '=' expression

The expression is restricted to disallow a trailing braced block (e.g. for struct literals) the
same way the expression in the normal if statement is, to avoid ambiguity with the then-block.

Contrary to a let statement, the pattern in the if let expression allows refutable patterns. The
compiler should emit a warning for an if let expression with an irrefutable pattern, with the
suggestion that this should be turned into a regular let statement.

Like the for loop before it, this construct can be transformed in a syntax-lowering pass into the
equivalent match statement. The expression is given to match and the pattern becomes a match
arm. If there is an else block, that becomes the body of the _ => {} arm, otherwise _ => {} is
provided.

Optionally, one or more else if (not else if let) blocks can be placed in the same match using
pattern guards on _. This could be done to simplify the code when pretty-printing the expansion
result. Otherwise, this is an unnecessary transformation.

Due to some uncertainty regarding potentially-surprising fallout of AST rewrites, and some worries
about exhaustiveness-checking (e.g. a tautological if let would be an error, which may be
unexpected), this is put behind a feature gate named if_let.

Examples

Source:

if let Some(x) = foo() {
    doSomethingWith(x)
}

Result:

match foo() {
    Some(x) => {
        doSomethingWith(x)
    }
    _ => {}
}

Source:

if let Some(x) = foo() {
    doSomethingWith(x)
} else {
    defaultBehavior()
}

Result:

match foo() {
    Some(x) => {
        doSomethingWith(x)
    }
    _ => {
        defaultBehavior()
    }
}

Source:

if cond() {
    doSomething()
} else if let Some(x) = foo() {
    doSomethingWith(x)
} else {
    defaultBehavior()
}

Result:

if cond() {
    doSomething()
} else {
    match foo() {
        Some(x) => {
            doSomethingWith(x)
        }
        _ => {
            defaultBehavior()
        }
    }
}

With the optional addition specified above:

if let Some(x) = foo() {
    doSomethingWith(x)
} else if cond() {
    doSomething()
} else if other_cond() {
    doSomethingElse()
}

Result:

match foo() {
    Some(x) => {
        doSomethingWith(x)
    }
    _ if cond() => {
        doSomething()
    }
    _ if other_cond() => {
        doSomethingElse()
    }
    _ => {}
}

Drawbacks

It's one more addition to the grammar.

Alternatives

This could plausibly be done with a macro, but the invoking syntax would be pretty terrible and
would largely negate the whole point of having this sugar.

Alternatively, this could not be done at all. We've been getting alone just fine without it so far,
but at the cost of making Option just a bit more annoying to work with.

Unresolved questions

It's been suggested that alternates or pattern guards should be allowed. I think if you need those
you could just go ahead and use a match, and that if let could be extended to support those in
the future if a compelling use-case is found.

I don't know how many match statements in our current code base could be replaced with this
syntax. Probably quite a few, but it would be informative to have real data on this.

@liigo
Copy link
Contributor

liigo commented Jul 9, 2014

I like this!

@chris-morgan
Copy link
Member

This has interesting potential. Where I might currently write

match foo {
    A { .. } => {
        a
    }
    B(..) => {
        b
    }
    C => {
        c
    }
    _ => {
        d
    }
}

I could now write

if let A { .. } = foo {
    a
} else if let B(..) = foo {
    b
} else if let C = foo {
    c
} else {
    d
}

Exhaustiveness checking wouldn’t come with this way of doing it, though. (Leastways, not easily, and making it an error would be distinctly suspect.)

I think you’re probably right about guards. Although it could potentially be nice to have them, if let PAT if GUARD = EXPR is just a bit too icky.

👍 from me.

@sinistersnare
Copy link

I don't know if I like this, it adds unneeded complexity to the language, and is just sugar for a time in this language where we do not need/want sugar (of course this could be a post 1.0 thing then disregard that part). Also it is not the easiest thing to grok, it confused me for a good bit. I also dislike the syntax, but if it gets accepted I do not think I will complain very much.

-1

@nielsle
Copy link

nielsle commented Jul 9, 2014

Just a nitpick: If optVal is an option, then you can also do

optVal.map( |x| 
    do_something_with(*x)
);

@lilyball
Copy link
Contributor Author

lilyball commented Jul 9, 2014

@nielsle Only if using a closure isn't a problem, which it often can be, and if you have no need for an else clause.

@lilyball
Copy link
Contributor Author

lilyball commented Jul 9, 2014

@sinistersnare What do you mean, a time in this language where we do not need/want sugar? for loops are sugar. Do you think those aren't helpful?

@sinistersnare
Copy link

The way I see it, we are striping the language to its bare essentials and necessary features for 1.0, and working out ergonomics from there.

As you said, this could be easily desugared into a match block, whereas a for loop is a classic idiom from many programming languages. I consider the way for loops are done in Rust a huge selling point for the language, safer, faster, and better looking.

I just do not think this would be greatly beneficial to Rust, but if the majority opinion says go, sure why not.

@bachm
Copy link

bachm commented Jul 9, 2014

To me it seems the benefit of this is essentially not having to write the None => {} case in a match. In other words, we want a non-exhaustive version of match, which seems like the simpler solution. Let's call it select:

select optVal {
    Some(x) => doSomethingWith(x)
}

@netvl
Copy link

netvl commented Jul 9, 2014

@bachm, I think the main benefit is that there is no extra nesting and indentation here. Your select will still need additional level of indentation.

The proposal looks great, +1 from me.

@stepancheg
Copy link

I always wanted something like this.

BTW, I'd like to propose an alternative: is-expression. It looks like this:

if opt is Some(ref v) { ... }

is-expression in contrast to if-let:

  • looks more natural
  • allows mix of any expressions in if-cond, like:
if (has_next_in_buf() || fetch_from_stream()) && next() is Identifier(name) { ... }

is-expression can be used anywhere deep inside of any expression, like this:

foo(bar is [20, ..]) // pass true if bar is a slice starting with 20, otherwise pass false

However, pattern element can be bound to variable only if is-expression is in &&-argument of if-cond, thus this is invalid (or probably a warning):

if foo is Some(x) || bar is None { ... } // x is not in the scope of then-block

Somewhat similar is-expression is present in Kotlin programming language. Their is expression does two things:

  • checks instance type
  • does smart cast: inside of then-expression type of left argument of is is adjusted

@dobkeratops
Copy link

alternatives - could macros be beefed up to reduce rightward drift. Imagine if you could do this...

expr.macro!(....) // macro with receiver, comes in as $self  .. 
macro! (..) { ... }   // macro with multiple bracket types, separate's its arguments more

opt.if_is!(Some(x)) { .. do stuff with x.. }

you could build macro forms that fit in more naturally, and have more ways of fighting rightward drift.
(here the motivation for a 'receiver' is approximating infix, not dispatch)

Other than that... to me it does seem sensible to borrow ideas from swift - the language will be very widespread. And "if let ...." certainly makes sense to me coming from C++ where we can write if (auto p=dynamic_cast<Foo*>(expr)) { ... do stuff with p.. }.

@glaebhoerl
Copy link
Contributor

+1 to the original proposal just as it is. I love it when someone submits the same idea I was going to.

@lilyball
Copy link
Contributor Author

lilyball commented Jul 9, 2014

@stepancheg An is operator like your proposing seems appropriate for pattern-matching, but it doesn't feel to me like it's appropriate for producing a let-binding as a result of destructuring. Especially if it's part of a && chain. For matching alone, your is operator looks basically like the proposed matches!() from rust-lang/rust#14685.

@stepancheg
Copy link

@kballard no, is looks nothing like matches!(). matches!() doesn't bind variables, unlike is:

if foo is Some(ref s) && bar is Some(ref t) {
    println!("both foo and bar are some: {}, {}", s, t);
}

@lilyball
Copy link
Contributor Author

lilyball commented Jul 9, 2014

@stepancheg Right, that's why I said "for matching alone". As I stated, I don't think it's appropriate to bind variables with an is statement, that feels extremely surprising and counterintuitive.

@zwarich
Copy link

zwarich commented Jul 9, 2014

@stepancheg Would you really want to allow multiple is instances in the same if? Then you could do things like

if foo is A(ref s) || bar is B(ref t) {
...
}

You have to bind every variable exactly once on every path through the conditions. The if let syntax makes this more clear and integrates the existing linearity checking for patterns.

@stepancheg
Copy link

@zwarich Multiple is separated by || should be allowed similarly to how multiple alternatives are allowed in match: all alternatives must fill the same variable:

struct Foo(int, int);

fn bar(foo: Foo) {
    match foo {
        Foo(1, x) | Foo(y, 2) => {}, // error
        Foo(3, x) | Foo(x, 4) => {}, // OK
        _ => {},
    }
}

fn baz(foo1: Foo, foo2: Foo) {
    if foo1 is Foo(1, x) || foo2 is Foo(y, 2) { ... } // error
    if foo1 is Foo(3, x) || foo2 is Foo(x, 4) { ... } // OK
}

@zwarich
Copy link

zwarich commented Jul 9, 2014

@stepancheg So what does the grammar for your proposed construct look like? You have to add a new nonterminal that is neither an expression nor a pattern.

@stepancheg
Copy link

@zwarich

You have to invent a new nonterminal that is neither an expression nor a pattern

Sorry, didn't understand that part.

So what does the grammar for your proposed construct look like?

is-expr should be a regular expression. Grammar could be something like this:

if_cond: or_expr
or_expr: and_expr ('||' and_expr)*
and_expr: not_expr ('&&' not_expr)*
not_expr: '!'? (comparison | is_expr)
comparison: ... // down to term
is_expr: expr 'is' match_pat
match_pat: ... // used in match grammar

There's no special treatment of if ... is at parser level. However, typechecker must ensure that either each variable bound in is_expr is either anonymous (e. g. Some(_)) or a part of if_conf and assigned exactly once.

@lilyball
Copy link
Contributor Author

@stepancheg You haven't addressed my primary objection to is, which is that binding a new variable on the right-hand side of an operator is excessively strange. It also produces a scope that's quite hard to track, because presumably if x is Some(y) && y == 13 { ... } should be valid, but if (x is Some(y) && y == 13) && foo(y) { ... } must be illegal. So the bound variable is visible to all subsequent expressions chained from &&, but it's not visible outside an enclosing expression (e.g. the () group), and it also can't be visible to any subsequent patterns chained with ||. But conversely it is again visible to the body of the if statement. And even considering how other non-boolean expressions interact is confusing.

I think this boils down to conflating an expression with a construct that has the power to bind a variable. Today the only ways to do that are with a declaration (an item or a slot (e.g. a let-binding)) or as part of a match arm, in which any bound variables cannot escape the associated match expression (or even the match arm, i.e. into other match arms). But you're trying to define an expression that does explicitly leak its let-bindings out into the surrounding code, but only under very specific circumstances (i.e. when used in the conditional of an if expression, depending on certain rules around && and || and the complete lack of other operators or expressions being involved). This makes it extremely hard to reason about or to predict the behavior of any complex bit of code that uses the operator.

@stepancheg
Copy link

@kballard

You haven't addressed my primary objection to is, which is that binding a new variable on the right-hand side of an operator is excessively strange.

Well, I must admit, is is at least unusual, and precise rules of is are significantly more complex than rules of if let.

Probably, all those complex rules are not really needed in practice (or not needed in the first version). If so, I have much simpler, bikeshed, proposal: take yours proposal, and replace

if let PAT = EXPR { BODY }

with

if EXPR is PAT { BODY }

It is almost as simple, as if let, and has advantages:

  • it can be upgraded to full-featured is expression if there will be demand for it
  • it is not confusing. If-let looks like C' assign and use if (a = expr) { ... }, but it is not.

@lilyball
Copy link
Contributor Author

@stepancheg That is certainly much cleaner than your previous approach, but here are the downsides:

  1. It prevents using is as an operator. You cannot take this proposal and expand it to a full-featured is operator without reintroducing all of the issues I raised before (most importantly, the very bizarre rules around the scope of any bound variables).
  2. It contradicts all existing mechanisms for binding variables in that the bound variables appear on the right-hand side of the delimiter instead of the left-hand side, e,g if foo() is Some(x). The natural way to read this is to treat x as a pre-existing variable and match on its value, rather than treating it as a new let-binding.
  3. It reintroduces ambiguity with braced blocks and struct patterns. if let Foo { x } = foo() { ... } is legal with my proposal, but yours makes that if foo() is Foo { x } { ... } and that's an ambiguous parse. It's currently illegal to wrap a struct pattern like that in parentheses so you cannot resolve this as if foo() is (Foo { x }) { ... } without changing how patterns are parsed.

All in all, I'd rather treat is as a potential operator with the semantics of the matches!() macro from rust-lang/rust#14685.

@stepancheg
Copy link

@kballard

  1. It reintroduces ambiguity with braced blocks and struct patterns

That's a problem, thanks for pointing it out.

@Valloric
Copy link

This RFC is excellent. We need to improve Rust's ergonomics; lately it's been going down.

@nielsle
Copy link

nielsle commented Jul 10, 2014

Perhaps "is" could be replaced by @ in the version proposed by @stepancheg . That would be somewhat consistent with match statements and it would not require a new keyword..

if foo  @ Some(x) {
    doSomethingWith(x)
}

EDIT: Changed => to @

@hatahet
Copy link

hatahet commented Jul 10, 2014

I like the fact that it is easy to visually notice a match expression, and immediately be able to tell that there is an exhaustive matching going on. It may be harder to do with if let expression. Furthermore, how would this interact with HKT + do expressions? Wouldn't the latter reduce the "burden" of plain match expressions?

@lilyball
Copy link
Contributor Author

@hatahet Well, the point of an if let is that it's not exhaustive, so I guess I'm not sure what point you're trying to make.

As for HKT + do, could you elaborate? I'm unaware of any concrete proposals for HKT in Rust, or for do expressions, so I can't very well talk about how if let would interact. But since if let is isomorphic to a subset of match expressions, I don't expect there to be any conflict with any future Rust proposals (as the functionality of match is pretty well established at this point and is exceedingly unlikely to change in any meaningful fashion).

If HKT + do extends the behavior of match in any fashion, it's plausible that if let could be similarly extended, but that depends entirely on what changes HKT + do actually makes to match.

@SimonSapin
Copy link
Contributor

if let is great. +1

@lilyball
Copy link
Contributor Author

@glaebhoerl Yeah that's how it would work.

loop match foo { ... } looks pretty weird to me. That looks like loop is being used as some sort of prefix modifier, which would suggest it should work for other expressions too.

@mahkoh
Copy link
Contributor

mahkoh commented Aug 26, 2014

+1

match maybe_x() {
    Some(x) => { ... },
    _ => match maybe_y() {
        Some(y) => { ... },
        _ => { ... },
    },
}

is way too noisy.

@glaebhoerl
Copy link
Contributor

@kballard Yeah, maybe. But weighed against the alternative of adding a new keyword? More economical to just make loop match be the "keyword". Kind of similar to the logic for if let. (Or of course, could just do nothing and keep requiring nested blocks. Not a formal proposal, just brainstorming "if we want to solve this, how could we solve it" w.r.t. @cmr's desire.)

@lilyball
Copy link
Contributor Author

What new keyword?

@glaebhoerl
Copy link
Contributor

Quoth @cmr:

I've often wanted a keyword for the loop { match foo { ... } } construct.

@lilyball
Copy link
Contributor Author

@glaebhoerl Yes, and that's what the while let ... I mentioned would do. No new keyword, just a desugaring of an existing-but-illegal keyword combination (just like if let).

@alexcrichton
Copy link
Member

This was discussed in today's meeting.

Concerns were brought up about how a simple AST rewrite can yield surprising results in, but it was also decided that an implementation not specc'd to be an AST rewrite would not be accepted. Due to lingering uncertainties, we decided to merge this with the caveat that it is all initially behind a feature gate. @kballard, can you update the RFC to reflect this?

We also decided to postpone something like while let to a future RFC rather than asking for inclusion in this one.

Also fix a couple of typos.
@lilyball
Copy link
Contributor Author

@alexcrichton Note about feature gate added.

@alexcrichton alexcrichton merged commit d7a1dba into rust-lang:master Aug 27, 2014
@lilyball lilyball deleted the if_let branch August 27, 2014 03:21
stepancheg added a commit to stepancheg/rust-protobuf that referenced this pull request Dec 6, 2014
(I'd like Rust to have more generic `is` expression:
rust-lang/rfcs#160 (comment) )
@Biluoshilang
Copy link

find no goods for it

@lilijreey
Copy link

I think it is so bad Idea.
match Option Type just need add maroc. like this.
if Some(x) match V {
do(x)
}

@chris-morgan
Copy link
Member

@lilijreey This landed over three years ago, well before Rust 1.0. It’s not going away. We like it, anyway, and it’s consistent with the rest of the language. (Your suggestion also leads to parsing difficulties, as if can then be followed by a pattern or an expression.)

@najamelan
Copy link

I think making the syntax more concise is an important thing, since rust has a lot of boilerplate, which hinders readablility. Hence I was wondering why the following doesn't work:

if let Some( content_type ) = res_in.headers.get( "Content-Type" )
&& let Ok  ( parsed       ) = content_type.parse()
{
   out = out.with_header( hyper::header::ContentType( parsed ) );
}

This expresses "Run this code block if all of these conditions are met". As opposed to:

if let Some( content_type ) = res_in.headers.get( "Content-Type" ) {
if let Ok  ( parsed       ) = content_type.parse()
{
   out = out.with_header( hyper::header::ContentType( parsed ) );
}}

This isn't much longer to write, but what does it express? Confusion? To not be confusing to read, it should be written as:

if let Some( content_type ) = res_in.headers.get( "Content-Type" )
{
   if let Ok( parsed ) = content_type.parse()
   {
      out = out.with_header( hyper::header::ContentType( parsed ) );
   }
}

Which now is a lot more noisy. Seeing this type of construct in any other language indicates "poor condition logic probably hiding a bug", but in rust the language just obliges us to write stuff like this.

The contrast between the two paradigms will grow if you have more conditions to chain up.

@chris-morgan
Copy link
Member

@najamelan This RFC was finished over three years ago. Any changes such as you suggest are additions to the language which will need to be in the form of a new RFC.

@najamelan
Copy link

Ok, I'm quite new to rust, but if people think this has a chance of getting accepted, I'd be willing to write the RFC.

@dhardy
Copy link
Contributor

dhardy commented Dec 7, 2017

If you want another channel for feedback before writing an RFC, use https://internals.rust-lang.org/

@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 23, 2018
@kennytm kennytm mentioned this pull request Aug 23, 2021
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
Projects
None yet
Development

Successfully merging this pull request may close these issues.