Skip to content

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

Closed
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
284 changes: 284 additions & 0 deletions text/0000-guard_clause_flow_typing.md
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
Copy link
Member

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

let File = File::open("program.cfg");

if let Err(_) = file { return Err(Error::ConfigLoadFail };

let Ok(f) = file;

is preferable to

let f = match File::open("program.cfg") {
    Ok(f) => f,
    Err(_) => return Err(Error::ConfigLoadFail),
};

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or

let f = File::open("program.cfg").map_err(|_| Error::ConfigLoadFail)?;

Copy link
Author

@danielpclark danielpclark Nov 19, 2017

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.

Copy link
Member

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 an Ok.

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 of match?

Copy link
Author

@danielpclark danielpclark Nov 19, 2017

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.

match

map_err

guard_clause

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 and map_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.

Copy link
Member

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.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 of match?

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 into match. 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.

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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already valid Rust.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct.

Copy link
Member

Choose a reason for hiding this comment

The 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

Copy link
Contributor

Choose a reason for hiding this comment

The 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 match, ask why they have so many nested clauses, then get directed to ?/try!() and all of Result's methods for dealing with these, which has happened before and most new users go "Oh okay thanks" and go on about their day and start using things like map_err.

Most of the above examples above given as an example can easily be rewritten to avoid nested if/else clauses.
For example Wrong A can be rewritten:

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.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mark-i-m That's not the line following this comment.

Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Author

@danielpclark danielpclark Nov 20, 2017

Choose a reason for hiding this comment

The 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 Result<Config> through each iteration? And the ownership of data would only work if the underlying type is Copy here right? I've never seen for used in this way: generating & ignoring extra instances and only returning the last.

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.