- Feature Name: refutable_let
- Start Date: (fill me in with today's date, YYYY-MM-DD)
- RFC PR: (leave this empty)
- Rust Issue: (leave this empty)
Summary
This RFC is a continuation of the postponed RFC #1303.
Where a regular let binding takes an irrefutable pattern and binds it to the containing scope,
and if let takes a refutable binding and binds it to a new scope,
let else takes a refutable binding and binds it to the containing scope.
It does this by providing a block of code to run if the binding fails which is required to diverge, that is,
to unconditionally break, continue, return, or call some diverging fn (-> !).
Motivation
The primary use case for refutable let is to reduce indentation on the happy path. Consider the case where you have an enum and a function that only handles one case:
enum Token {
Ident(Symbol, Span),
// other variants
}
fn do_something_with_token_ident(ident: Token) {
// implementation
}In current Rust, the obvious way to handle this is to use if let to destructure the Ident:
fn do_something_with_token_ident(ident: Token) {
if let Token::Ident(symbol, span) = ident {
// multiple lines
// that use
// symbol and span
} else {
panic!("do_something_with_token_ident only takes Token::Ident");
}
}This needlessly indents the happy path and separates the error handling from where the binding occurs.
A slightly more clever Rustacean might use a match to mitigate this problem:
fn do_something_with_token_ident(ident: Token) {
let (symbol, span) = match ident {
Token::Ident(symbol, span) => (symbol, span),
_ => panic!("do_something_with_token_ident only takes Token::Ident"),
}
// multiple lines
// that use
// symbol and span
}This is better, because the error handling case is closer to the failable operation.
However, this is still problematic, because it requires (symbol, span) to be written three times.
Under this RFC, it would be possible to write:
fn do_something_with_token_ident(ident: Token) {
let Token::Ident(symbol, span) = ident else {
panic!("do_something_with_token_ident only takes Token::Ident");
}
// multiple lines
// that use
// symbol and span
}This example would probably be better served by refactoring to allow do_something_with_token_ident to ensure it gets
a Token::Ident at the type level, by introducing a TokenIdent struct which Token::Ident wraps, or some other
method. However, in many cases this kind of refactoring would be prohibitively expensive or outright impossible.
Consider when you have a loop over some syn::Stmt and you want to
do some process for just the assignment statements. This is done in MIRI using rustc types, but here we use syn for
the purpose of using the more public API. With this RFC, one could get this behavior with the following code:
let syn::Stmt::Local(syn::Local {
pat,
ty,
init,
..
}) = stmt
else {
continue;
}
// do somethingThe solution using if let would needlessly indent the happy path a level and separate the special-case handling from
the binding. The match solution requires repeating (pat, ty, init) in this case, and the amount of repetition (read:
silent ordering mismatch opportunities) grows as more complicated and/or deeply nested types are destructured.
Guide-level explanation
TODO: For now, see motivation.
Reference-level explanation
let else is a simple desugar similar to that of if let, to the match formulation that it replaces.
The key difference is that the failed-binding branch is required to diverge by assigning it the ! type,
rather than allowing that branch to produce the (implementation detail) tuple used to extract the bindings
into the containing scope.
let PATTERN = EXPRESSION else {
BLOCK
}desugars to
let (B,I,N,D,I,N,G,S) = match EXPRESSION {
PATTERN => (B,I,N,D,I,N,G,S),
_ => {
let _: ! {
BLOCK
}
}
}where (B,I,N,D,I,N,G,S) represents a tuple of all bound names in the pattern. This does not actually have to restrict
itself to just bindings, but can also include unit-variant structs (which look like bindings until type information);
this will require the compiler to unify the unit-variant again but does not affect the created bindings.
There is an edge-case ambiguity with this syntax. The problem is that let PATTERN = if EXPRESSION { BLOCK } is valid
syntax today. That means that let PATTERN = if EXPRESSION { BLOCK } else { BLOCK } has two different valid parses
under this RFC:
let PATTERN = ( if EXPRESSION { BLOCK } ) else { BLOCK }
let PATTERN = ( if EXPRESSION { BLOCK } else { BLOCK } )However, mitigating this issue is that if EXPRESSION { BLOCK } is only a valid rvalue when BLOCK's type is ().
If the type is anything else, said fragment will fail to compile complaining about the missing else block.
In order to maintain backwards compatibility, the currently compiling code of let a = if b { c } else { d } must
remain interpreted in the same manner. This RFC maintains that this is the correct resolution of this ambiguity, and
that the other resolution in favor of let else would fail type check in all non-trivial cases,
and would be meaningless when valid code.
To clarify,
let PATTERN = if EXPRESSION {
BLOCK(THEN)
} else {
BLOCK(ELSE)
}should parse as it does today, and
let PATTERN = if EXPRESSION {
BLOCK(THEN)
} else {
BLOCK(ELSE)
} else {
BLOCK(DIVERGE)
}should parse as a let else where the expression is the if else expression and the diverging else is the second.
Usage of complicated expressions in this position could be linted against (e.g. in clippy), but the RFC makes no case
that restrictions beyond that of the expression in let/if let should be levied on the expression of a let else.
Drawbacks
TODO: see internals thread(s).
Rationale and alternatives
TODO: see internals thread(s).
Prior art
TODO.
- Swift
guard let - Others?
Unresolved questions
TODO: see internals thread(s).