-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Guard Clause Flow Typing #2221
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
Guard Clause Flow Typing #2221
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,284 @@ | ||
- guard_clause_flow_typing | ||
- Start Date: 2017-11-19 | ||
- RFC PR: (leave this empty) | ||
- Rust Issue: (leave this empty) | ||
|
||
# Summary | ||
[summary]: #summary | ||
|
||
Having _Flow Typing_ for _Guard Clause_ scenarios will allow explicit use of pattern matching assignment | ||
and remove the need for `match`, `map_err()?`, and `unwrap` in any situation where an explicit return is | ||
used for one of the given enum types as a _Guard Clause_. Using an `if let None = thing {return Error;}` | ||
is one example of a `Guard Clause` handling the `None` path for the item `thing`. With that path handled | ||
we should then be able to simply use pattern matching for assigning out the value from thing with | ||
`let Some(value) = thing;` and not need to use any of the aforementioned match/map/unwrap. | ||
|
||
# Motivation | ||
[motivation]: #motivation | ||
|
||
The biggest motivations for this is for providing simplicity and clarity in our code base. When teaching | ||
some one how to program if I only need to show a person how to pattern match to get the values they want | ||
then the learning overhead is minimal. Even as an experienced developer having so much extra syntax | ||
required for your day to day situations will cause us to need more time to take in the situation of what's | ||
going on in the code base. This is especially true when dealing with many nested conditional blocks through | ||
either `if` or `match`. With using a _Guard Clause_ and being able to pattern match assignment, you | ||
can avoid many scopes of indentation and the extra lines of outer scope variable creation for return values. | ||
Using a _Guard Clause_ allows you to inline scope of the code and provide maximum legibility. | ||
|
||
|
||
# Guide-level explanation | ||
[guide-level-explanation]: #guide-level-explanation | ||
|
||
A `Guard Clause` is to be used whenever you have a need to explicitly return from one of the paths of | ||
any given Enum. For the `Option` type if you want to return a default value, or an error when it's `None`, | ||
then you may use an `if let` assignment check to explicitly return the value you want for this path. You | ||
may then use pattern matching to extract the value from `Some` and continue your code within the same scope | ||
and block providing maximum code readability. | ||
|
||
Lets look at three examples of what not to do before showing how to correctly use a _Guard Clause_. The | ||
examples will be return a `Result` type that will have it's own custom `Error` type used internally. _This | ||
is a simplified configuration file parsing example._ | ||
|
||
### Wrong (A) | ||
```rust | ||
fn read_config1() -> Result<Config, Error> { | ||
let file = File::open("program.cfg"); | ||
if let Ok(f) = file { | ||
let mut buf_reader = BufReader::new(f); | ||
let mut contents = String::new(); | ||
|
||
if buf_reader.read_to_string(&mut contents).is_ok() { | ||
let mut data: Vec<u8> = vec![]; | ||
|
||
for item in contents. | ||
split("\n"). | ||
map(|s| s.to_string()). | ||
filter(|s| !s.is_empty()). | ||
collect::<Vec<String>>() { | ||
|
||
let num = item.parse::<u8>(); | ||
|
||
if let Ok(conf) = num { | ||
data.push(conf); | ||
} else { | ||
return Err(Error::ConfigParseFail); | ||
} | ||
} | ||
|
||
Ok( Config { data: data } ) | ||
} else { | ||
Err(Error::ConfigLoadFail) | ||
} | ||
} else { | ||
Err(Error::ConfigLoadFail) | ||
} | ||
} | ||
``` | ||
### Wrong (B) | ||
```rust | ||
fn read_config2() -> Result<Config, Error> { | ||
let file = File::open("program.cfg"); | ||
match file { | ||
Ok(f) => { | ||
let mut buf_reader = BufReader::new(f); | ||
let mut contents = String::new(); | ||
|
||
match buf_reader.read_to_string(&mut contents) { | ||
Ok(_) => { | ||
let mut data: Vec<u8> = vec![]; | ||
|
||
for item in contents. | ||
split("\n"). | ||
map(|s| s.to_string()). | ||
filter(|s| !s.is_empty()). | ||
collect::<Vec<String>>() { | ||
|
||
let num = item.parse::<u8>(); | ||
|
||
match num { | ||
Ok(conf) => data.push(conf), | ||
_ => { return Err(Error::ConfigParseFail); }, | ||
} | ||
} | ||
|
||
Ok( Config { data: data } ) | ||
}, | ||
_ => { Err(Error::ConfigLoadFail) } | ||
} | ||
}, | ||
_ => { Err(Error::ConfigLoadFail) } | ||
} | ||
} | ||
``` | ||
### Wrong (C) | ||
```rust | ||
fn read_config3() -> Result<Config, Error> { | ||
let file = File::open("program.cfg"); | ||
|
||
if let Ok(f) = file { | ||
let mut buf_reader = BufReader::new(f); | ||
let mut contents = String::new(); | ||
|
||
if buf_reader.read_to_string(&mut contents).is_ok() { | ||
let mut data: Vec<u8> = vec![]; | ||
|
||
for item in contents. | ||
split("\n"). | ||
map(|s| s.to_string()). | ||
filter(|s| !s.is_empty()). | ||
collect::<Vec<String>>() { | ||
|
||
let num = item.parse::<u8>(); | ||
|
||
if let Ok(conf) = num { | ||
data.push(conf); | ||
} else { | ||
return Err(Error::ConfigParseFail); | ||
} | ||
} | ||
|
||
return Ok( Config { data: data } ); | ||
} | ||
} | ||
|
||
Err(Error::ConfigLoadFail) | ||
} | ||
``` | ||
|
||
And here is the correct usage of a _Guard Clause_ which allows us to avoid deeply nested logic. | ||
|
||
### Correct | ||
```rust | ||
fn read_config4() -> Result<Config, Error> { | ||
let file = File::open("program.cfg"); | ||
|
||
// Correct use of Guard Clause | ||
if let Err(_) = file { return Err(Error::ConfigLoadFail); } | ||
|
||
// Safe use of pattern matching assignment after Guard Clause | ||
let Ok(f) = file; | ||
|
||
let mut buf_reader = BufReader::new(f); | ||
let mut contents = String::new(); | ||
|
||
// Correct use of Guard Clause | ||
if let Err(_) = buf_reader.read_to_string(&mut contents) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is already valid Rust. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @sfackler I think the key is that the following line would currently be an "irrefutable pattern" error: https://github.com/rust-lang/rfcs/pull/2221/files#diff-5935e1ae73da8f77fd623e86bd4eb1eeR159 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I get the point of this is supposed to provide alternatives to map_err and stuff, but I find that this: let file = File::open("program.cfg").map_err(|_| Error::ConfigLoadFail)?; is far more readable and a one liner than: let file = File::open("program.cfg");
if let Err(_) = file { return Err(Error::ConfigLoadFail); }
let Ok(f) = file; It kind of obscures what's going on and you mentioned this was going to help new users, but I find it more confusing and less clear as to what's going on. This guard clause feels like a weird version of ?/try!(). I'd expect new users to use copious amounts of Most of the above examples above given as an example can easily be rewritten to avoid nested if/else clauses. fn read_config1() -> Result<Config, Error> {
let file = File::open("program.cfg").map_err(|_| Error::ConfigLoadFail)?;
let mut buf_reader = BufReader::new(f);
let mut contents = String::new();
buf_reader.read_to_string(&mut contents).map_err(|_| Error::ConfigLoadFail)?
let mut data: Vec<u8> = Vec::new();
for item in contents
.split("\n")
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
.collect::<Vec<String>>()
{
Ok(Config {
data: data.push(item.parse::<u8>().map_err(|_| Error::ConfigParseFail)?)
} )
}
} Which makes the function shorter but is far more readable as well. If the goal is to teach new people Rust, then we should be focusing on the documentation to cover this and make it less confusing if there is some, because this is considered more idiomatic. I don't think we need more ways to do match. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @mark-i-m That's not the line following this comment. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @sfackler I meant the line with that I linked... It seemed like it would be confusing to add a new comment on that line instead... sorry for the confusion. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @mgattozzi In your revised code sample isn't the for loop generating a new |
||
return Err(Error::ConfigLoadFail); | ||
} | ||
|
||
let mut data: Vec<u8> = vec![]; | ||
|
||
for item in contents. | ||
split("\n"). | ||
map(|s| s.to_string()). | ||
filter(|s| !s.is_empty()). | ||
collect::<Vec<String>>() { | ||
|
||
let num = item.parse::<u8>(); | ||
|
||
// When all paths only have very short code blocks | ||
// a Guard Clause isn't necessary as the readability | ||
// of the code is no burden in this context. | ||
match num { | ||
Ok(conf) => data.push(conf), | ||
Err(_) => { return Err(Error::ConfigParseFail); } | ||
} | ||
} | ||
|
||
Ok( Config { data: data } ) | ||
} | ||
``` | ||
|
||
Using a _Guard Clause_ should be thought of as a best practice for explicit return scenarios whenever it | ||
provides better clarity by avoiding nested additional scopes. This will likely be in the majority of | ||
cases where an explicit return is used. Exceptions to this is may be when working with enums that have | ||
more than two paths to work with and the other paths require more involved code blocks… in that case how | ||
you implement it would make more sense with all the paths following the same form factor like you can | ||
have with `match`. | ||
|
||
With the acceptance of this RFC you can replace your usages of `unwrap` everywhere with pattern matching | ||
assignment. This should improve readability. | ||
|
||
# Reference-level explanation | ||
[reference-level-explanation]: #reference-level-explanation | ||
|
||
The _Guard Clause_ will only be explicitly returned during an `if let`. The compiler will take into | ||
account that one of the paths have been accounted for in the _Guard Clause_ and assignment with pattern | ||
matching will work for the rest of the scope _(that the item is owned within)_ rather than using `unwrap`, | ||
`ok`, or any other extracting methods for the item. | ||
|
||
Combining use of a _Guard Clause_ with a `match` to follow should be taboo _in my opinion_ as it's | ||
using two separate systems for implementing the same logic. So raising an explanatory error on why the | ||
_Guard Clause_ is not to be intermixed with a `match` should appear. The `match` not allowing a | ||
preceding _Guard Clause_ should be considered a style warning and not an incompatible use of code. | ||
To get rid of that warning they should use something like `#[allow(match_after_guard)]`. I believe | ||
it's fine to have `match` still require the path be handled even when a _Guard Clause_ and `allow` have | ||
been used. | ||
|
||
An equivalent from the earlier example which does not use pattern matching is: | ||
|
||
```rust | ||
// if let Err(_) = file { return Err(Error::ConfigLoadFail); } | ||
// let f = file.unwrap(); | ||
let f = file.map_err(|_| Error::ConfigLoadFail)?; | ||
``` | ||
but this isn't something a new programmer could understand as easily which is why using pattern matching is | ||
preferable. | ||
|
||
Because `if let` doesn't allow other conditionals there isn't any compounded complexity in evaluating the | ||
paths which have been followed for any given scope. The compiler can simply keep a marker for items with | ||
_Guard Clause_ paths taken and not need to raise the following error any longer: | ||
|
||
``` | ||
error[E0005]: refutable pattern in local binding: `Err(_)` not covered | ||
--> src/lib.rs:166:7 | ||
| | ||
166 | let Ok(f) = file; | ||
| ^^^^^ pattern `Err(_)` not covered | ||
|
||
error: aborting due to previous error | ||
``` | ||
|
||
One area that might be a little more complex to account for is if some one were to try putting further | ||
conditional logic inside the _Guard Clause_ code block. | ||
|
||
```rust | ||
if let Err(_) = file { | ||
if random_value > 5 { return Err(Error::ConfigLoadFail); } | ||
} | ||
``` | ||
|
||
The compiler should be able to not allow the user this behavior by simply treating it as not being a | ||
_Guard Clause_ so any assignment use of it later will require the `Err` path to still be accounted for. | ||
|
||
These are the edge cases I can think of. | ||
# Drawbacks | ||
[drawbacks]: #drawbacks | ||
|
||
None (TBD) | ||
|
||
# Rationale and alternatives | ||
[alternatives]: #alternatives | ||
|
||
Using Rust's existing pattern matching and furthering its ability will reduce overall complexity. This | ||
feature will help the Rust community grow more rapidly as it helps alleviate the barrier to learning | ||
that we currently have with the heavy use of our syntax. | ||
|
||
Alternatives: [`let … else {}` RFC #1303](https://github.com/rust-lang/rfcs/pull/1303) | ||
|
||
# Unresolved questions | ||
[unresolved]: #unresolved-questions | ||
|
||
- What parts of the design do you expect to resolve through the RFC process before this gets merged? | ||
|
||
(TBD) | ||
|
||
- What parts of the design do you expect to resolve through the implementation of this feature before stabilization? | ||
|
||
(TBD) | ||
|
||
- What related issues do you consider out of scope for this RFC that could be addressed in the future independently of the solution that comes out of this RFC? | ||
|
||
During discussion in the [Pre-RFC](https://internals.rust-lang.org/t/pre-rfc-allow-pattern-matching-after-guard-clause/6238) | ||
a few people were more interested in reviving [`let … else {}`](https://github.com/rust-lang/rfcs/pull/1303) rfc rather | ||
than discussing the merits of this pattern matching approach. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't see how
is preferable to
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Or
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My main point on these is that the level of complexity for new software developers just learning how to program will find our current methodologies much harder to grasp as much more is going on in these code samples. One of the things that I've read within our community is that Rust wants to become easier for people to learn and not need to have so much extra syntax used for common scenarios.
I like the
map_err
myself, but I'm not likely going to teach people Rust as a first language if this is our primary methodology.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If anything it seems like this change would increase the cognitive overhead of reading code. Interaction with algebraic datatypes is currently "local", while this change would make it nonlocal in that you can perform destructuring that would normally not be allowed as long as the control flow somewhere some arbitrary distance earlier in the function made it clear that some
Result
is actually only anOk
.You're not going to teach Rust as a first language if what exactly is the primary methodology? The use of methods defined on Result? The use of closures? The use of
?
? The use ofmatch
?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We, as experienced developers, take for granted what we're familiar and comfortable with. The complexity may be harder for us to see because of our own bias. So I will use visual cues to help illustrate complexity for non-Rust developers and what they need to take in. The idea is that small simple steps are easier to grasp than more compounded into the same space.
People who are coming from other languages will majoritively be comfortable with an if condition and a code block. Some may also come from pattern matching languages. These two concepts are shared by the vast majority of languages available to program in and this allows more expedient learning of a language.
As Rust developers we love our language and the power each design we have permits us to use. It's also generally true that people form their own preferences/biases in how they like to implement their code. This often leads to people in their programming language being resistant to change because "this is the way it is" and therefore "should be". What looks good to us though is a bias built over time… and that's not a bad thing, but I believe we should be aware of it and more keen to listen to other input knowing our own bias.
The colors above indicate distinct orders of concepts occurring and what we have to grok when we look at the code in groupings/code clumps. The
match
andmap_err
examples have roughly 4 phases occurring within a tightly knit space where as the simple assignment and pattern matching, though it may be a little more verbose in lines, keeps the grouped concepts of what's happening down to about two steps and the principles behind them share more with other languages in my opinion.For us I'm perfectly fine with using
match
,map_err
, and?
because this we know well. It is my hope that we can endevour to broaden the power and ability we have within Rust to make the language more welcoming to others. And I believe broadening the ability of pattern matching and creating a flow-typing environment will feel more natural to many.I'm not against the way we do things, I merely want to broaden our horizons. And I think in the end we'll be better for it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But this assumes that the syntax is the limiting function in learning the language rather than the semantics, which doesn't really seem true to me.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree in general, but I would say that a great deal of this bias (at least for me) comes from the fact that Rust syntax tends to be very elegant, expressive, and ergonomic IMHO; I would like to keep it that way. I still spend a lot of time writing C and Java, and it still surprises me how much easier it can be to write rust.
That's not to say I'm unopen to new syntax -- I just don't think we should introduce new idiomatic syntax purely for teachability unless there is a significant community consensus that existing syntax is confusing (e.g. what sort of happened with
dyn Trait
).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For a new developer…Of these three I believe
closures
would be the more difficult of these to teach (not for me to say, for others to understand).match
would take some time to get across, especially with assignment beforehand, but pattern matching would be the first thing to teach before delving intomatch
. After pattern matching is taught the?
wouldn't be too hard to teach. But having them all in one statement would be a bit much for a beginner.It's been both my hobby and profession to teach concepts and programming through blog posts for many years. Thinking about clarifying things is pretty much what I do in life.