Christopher Durham
Posted on January 11, 2018
This examines the postponed RFC #1303 "Add a let...else
expression", addressing the RFC issue #373 "Explicit refutable let
.
This probably will end up formatted like an RFC, and I'd be willing to adapt it to a new RFC since the old one was postponed nearly two years ago now.
A motivating example
Consider a simple example:
if let Some(a) = make_a() {
if let Some(b) = make_b(a) {
if let Some(c) = make_c(b) {
Ok(c)
} else {
Err("Failed to make C")?
}
} else {
Err("Failed to make B")?
}
} else {
Err("Failed to make A")?
}
There are two key problems here. The first is locality of error handling. The error for line 1 is returned on line 12 of this tiny example. The second is rightward drift. The actual meat of the function (here just Ok(c)
) is indented three levels deep. This RFC introduces a "refutable let binding" in order to address both of these issues.
First, let me address the obvious refactor to this minimal example. The current function signatures look something like the following:
fn make_a() -> Option<A>;
fn make_b(a: A) -> Option<B>;
fn make_c(b: B) -> Option<C>;
The obvious "best" answer would be for these to return a Result
with a descriptive error message that you could then propogate with ?
. In a real case, though, maybe Option
really is the right semantic type to return, and maybe it's vendor code you can't change, etc. etc..
Secondly: You could Option::ok_or_else?
. This is how I would write this today. To be fully honest, I'd maybe still write it that way, because ok_or_else
is the exact behavior that I want and is very clear. But assume for the sake of argument that this is a more complicated example, such as destructuring a complicated ADT enum. I use Option
here for simplicity.
The third option is to destructure using match
:
let a = match make_a() {
Some(a) => a,
_ => Err("Failed to make A")?,
};
let b = match make_b(a) {
Some(b) => b,
_ => Err("Failed to make B")?,
};
let c = match make_c(b) {
Some(c) => c,
_ => Err("Failed to make C")?,
};
Ok(c)
or by "stuttering" an if let
:
let a = if let Some(a) = make_a() {
a
} else {
Err("Failed to make A")?
};
let b = if let Some(b) = make_b(a) {
b
} else {
Err("Failed to make B")?
};
let c = if let Some(c) = make_c(b) {
c
} else {
Err("Failed to make C")?
};
Ok(c)
Here is the same example using a refutable let:
let Some(a) = make_a() else {
Err("Failed to make A")?
};
let Some(b) = make_b(a) else {
Err("Failed to make B")?
};
let Some(c) = make_c(b) else {
Err("Failed to make C")?
};
Ok(c)
This is actually implementable using macro_rules
macros, though not without some difficulty. playground
Why?
The point of a refutable let is that you have some destructuring to do, and you want to handle the case where you cannot destructure by diverging from the function.
The simple desugar of let...else
is the following transformation:
let PAT = EXPR else BLOCK;
// =>
let (bindings) = match EXPR {
PAT => (bindings),
_ => BLOCK: !,
}
where BLOCK: !
is type ascription. The ascribed !
type makes it such that BLOCK
needs to diverge (this effectively means return
, break
or continue
). (bindings)
here is a tuple of all assigned bindings in the pattern, so that they can be moved out of the pattern and into the containing scope.
You can hopefully see that this pattern reduces the required stuttering in using a match
or a let = if let
for this pattern of early-return to handle errors locally. More key, however, is that this enforces the diverge. If you write a match
or let = if let
or unwrap_or_else
or anything other than a ?
, then the error handling branch could instead be a default value branch. Requiring the diverge at a language level increases the guarantees that a reader has when reading the code.
Problems and alternate syntaxes
Consider parsing the following:
let foo = if bar { baz() } else { quux() };
// Option 1:
let foo =
(if bar {
baz()
} else {
quux()
});
// Option 2:
let foo = (if bar { baz() })
else { quux() };
The second is actually a vailid parse option if baz()
is of type ()
. if COND { () }
is valid in expr position and has type ()
. playground
Unfortunately, this ambiguity means that if PAT = EXPR else
is not a possible unambiguous syntax; there is a reason else
isn't in the follow set for expr
.
Other proposed syntaxes:
<keyword> let PAT = EXPR else BLOCK
Suffers from the same ambiguity.
if !let PAT = EXPR BLOCK
Not ambiguous, but suffers from overloading if let
, which doesn't put bindings into the scope that contains it.
<keyword> let PAT = EXPR BLOCK
Unambiguous, but requires a new keyword. Keyword possibilities include unless
.
Recently
Two RFCs recently popped up that relate to this are #2221 Guard Clause Flow Typing and #2260 if- and while- let chains.
The former is looking to get the same functionality from flow typing, and the author seems to not consider let...else
as being a valid "guard". I'll not say more, lest I assert something that isn't true.
The latter looks to allow chaining if let
to reduce nesting. Though this doesn't seem direclty related, it was mentioned often that having a refutable let would much reduce the necessity of being able to chain if let
together at one block level.
Looking forward to #Rust2018
Unless I've missed some glorious obvious-in-retrospect syntax, if !let PAT = EXPR BLOCK
seems to be the only no-new-keyword-viable solution to the refutable let. Probably, this will end up being the syntax to use, and while maybe alien now, it might become as second nature as if let
is now.
I can introduce a new RFC proposing using this syntax again, and detailing the problems with the other syntaxes. Maybe there the design can be fully, properly 🚲 :shed: (you pick the emoji :slight_smile:).
For the rest of Rust in 2018, I agree with much of the other #Rust2018 posts. Rust would probably be best served by a tick/tock cycle, where this year is spent on paying down stabilization debt. Many great features are just around the corner and just need that final push on design/implementation work.
This is not to say nothing new should happen; WebAssembly work should continue, the RFC machine should continue moving (though we shouldn't tell people to go submit ideas as rapidly as happened before the impl period cutoff). Stabilizing key tools, landing the impl period features, and (re)clarifying stability. Some way to endorse crates as high quality, ready-for-production libraries. Async/await/generators.
These are all great things which have been started. Let's move them towards finished together!
Oh, and my never-gonna-happen breakage whishlist: make all of the Iterator
fn return impl Iterator
instead of concrete types. impl Trait
all of the things.
UPDATE to above: u/QuietMisdreavus pointed out the flaw with this:
This is actually a loss of information in many cases. Several of the
Iterator
wrappers also conditionally implementDoubleEndedIterator
,ExactSizeIterator
,FusedIterator
, etc, based on their contained type. As far as i know, there's no way to represent that inimpl Trait
syntax.
Having realized the error of my ways, this is no longer a choice I would make.
Posted on January 11, 2018
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.