-
Notifications
You must be signed in to change notification settings - Fork 1.6k
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
Guard Clause Flow Typing #2221
Conversation
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 comment
The 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 comment
The 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 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:
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 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.
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.
@mark-i-m That's not the line following this comment.
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.
@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 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.
fn read_config4() -> Result<Config, Error> { | ||
let file = File::open("program.cfg"); | ||
|
||
// Correct use of Guard Clause |
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
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),
};
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
let f = File::open("program.cfg").map_err(|_| Error::ConfigLoadFail)?;
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 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
?
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
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.
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.
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
).
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.
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.
These guards make code harder to read because they break up expressions and require more reasoning about flow control. Any new users will benefit far more from being pointed to As a separate matter, there are good odds this syntax would cause problems in future because it "dances around" powerful type system ideas like subtyping, refinement types, etc. that require deeper exploration to do well. In many cases, these require more exploration of type system features we already have, especially ATCs and As an example, I could imagine a
I've no clue if such a syntax is wise, or even if the required type system makes sense, but at least it remains expression oriented and makes refinements explicit. We probably need actual examples where Puffs produces faster parsers than Rust specifically through using such type system features, at which point folks will develop the interest and expertise to do these things properly. Adding reminiscent syntax haphazardly will complicate even thinking clearly about larger type system features. |
@danielpclark your examples would be more convincing if you use types without combinator methods e.g. extracting ownership from a |
If you only support one variant, then |
To “break up expressions” is to simplify. And “reasoning about flow control” is what programming is and for simplified steps I don't see more harm in more reasoning.
I don't have experience in developing languages so I'm not one to say what may come of such decisions in the future. I was only very recently introduced to type-flowing via a comment leanardo gave on the Pre-RFC for this. I've looked into it and at first glance I think further enabling Rust's pattern matching with a type flow system would be an awesome powerful utility to have within the language. The potential disadvantages I'm very much ignorant of and so I will gladly defer to input from those more knowledgeable in language design than me.
Anything to help would be appreciated ;-) |
@burdges No |
I think These let statements change the interpretation of the type, which makes your coloring argument wrong too, and worse does so as a side effect. It's nice to break up an expression with Instead, type refinements should change the actual type, and do so in a human expressible way, which requires fancier type system features. We might get those features eventually, but I dearly hope they pick some explicit syntax and require an assignment, like the I'm confident this would harm new users by distracting them from using |
I think the non-locality of this that sfackler mentioned is my biggest hesitation with this RFC. Take this from one of the examples: // Safe use of pattern matching assignment after Guard Clause
let Ok(f) = file; Today (well, with the inhabitability changes from #![feature(never_type)]
enum Infallible {}
fn foo<T>(x: Result<T, Infallible>) -> T {
let Ok(v) = x;
v
} Especially since this seems intended only for things with two variants, so tying the two together syntactically seems better to me. I really worry that allowing syntax like this encourages if let Statement(s) = node { ... }
else if let Function(f) = node { ... }
else if let Struct(s) = node { ... }
return Err(...); instead of the |
That does seem like a very bad possibility this introduces. I agree with you on that. |
@scottmcm Thinking more about it, that poorly designed if else scenario is still possible in the language as it is. That being that you can check conditions of multiple separate items through an if else chain. It's not a byproduct of this feature being added to the language. It's just this feature being substituted in place for an eye sore of a potential code practice. |
That's true, but you are not supposed to write code like that. The idiomatic thing to do would be to use |
I think this attempts to address the same thing that a
I agree that in this example case, the "map_err?" syntax is the best option. However, for other enums that do not have such expressive composition fn available, or even just because you don't know about the possibility to The refutable let I believe solves the exact usability point that this RFC wants to address, and does so in a more internally consistent way (we have Note that though the RFC is about adding an amount of flow typing, the motivation is to enable ergonomic use of a "guard clause" without using |
@CAD97 I'm not sure that qualifies to be called a Guard anymore. Guards have explicit returns before use of assignment. Also the use of If the pattern matching is going to be preceded by a keyword (or the like) I would expect the guard's block to treat it's contents as a specific I prefer the existing |
@danielpclark Good point. Using the flow typing, it'd actually be enabling if let Statement(s) = node {
...
} else if let Function(f) = node {
...
} else if let Struct(s) = node {
...
} else {
let Enum(e) = node;
...
} which definitely doesn't compile today, and I think is even less nice than the if-let chain in my comment above, what with that weird else block. |
@rfcbot fcp close If I understand correctly, this RFC proposes a flow sensitive typing in which we remember which the variants against which we have attempted to match, and thus permit exhaustive matching in cases where it would otherwise be forbidden: let x = File::new( ...) ;
if let Ok(v) = x { }
// now we know `x` must be `Err`, so this is allowed:
let Err(e) = x; As others have noted, the primary motivation here seems to be the same as I'm actually not sure how we would implement this -- I don't doubt it's possible, but it doesn't trivially integrate into the existing exhaustiveness check, and we'd have to figure out quite a bit to make it work. The problem being solved doesn't seem to merit that much effort. =) Moreover, I think if we were going to address this problem, I'd prefer a syntactic setup like Therefore, I move to close. Thanks to @danielpclark for the novel suggestion, in any case. I think it's a clever idea, if not one I think we should adopt at this time. |
Team member @nikomatsakis has proposed to close this. The next step is review by the rest of the tagged teams: No concerns currently listed. Once these reviewers reach consensus, this will enter its final comment period. If you spot a major issue that hasn't been raised at any point in this process, please speak up! See this document for info about what commands tagged team members can give me. |
As noted elsewhere, we maybe need a survey of what different features from formal verification languages like F* and ProVerif might look like in Rust and what benefits they bring. If refinement types were found to really add to Rust then this feature provides an elegant way to teach them, just not without some clear syntax that screams "refinement", i.e. not |
🔔 This is now entering its final comment period, as per the review above. 🔔 |
🔔 This is now entering its final comment period, as per the review above. 🔔 |
The final comment period is now complete. |
Closing as per @nikomatsakis's summary. Thanks for the RFC, @danielpclark! |
No description provided.