Syntactic sugar for `if x { return | break | continue }`

I thought about opening an RFC for this and thought I'd like to hear some feedback first. The core idea is this: Everyone knows code like that, especially in Go:

f, err := os.Open("filename.ext")
if err != nil {
    log.Fatal(err)
}

Or the Rust equivalent:

let Ok(f) = open("filename.txt) else {
    eprintln("Failed to open file.");
    return None;
}

I would say, Rust is halfway there - as long as we talk about a pattern and we don't need the undesired value. Otherwise we are back at match. This unfortunately does not really help in cases, where a different condition is checked.

if !may_i_do_this {
    return Err(SomeError::Forbidden);
}

I propose the following syntax to make this more concise:

return Err(SomeError::Forbidden) if !may_i_do_this;

The since it is basically just reordering, it can be rearranged in the compiler to just be treated as the original code. Also, it would be quite easy to apply this to break and continue as well:

// Print 0, 2
for i in 0..4 {
    continue if i == 1;
    println!("{i}");
    break if i == 2;
}

To add some clarity on what syntax i think should be allowed:

  • return [<expr>] [if <expr>];
  • break [<expr>] [if <expr>];
  • continue [if <expr>];

Edit: Reasons why (taken from my comment): I have a few points for it, lets start with the weakest:

  • It reads more naturally: return value if something reads easier than if something { return value } (plus you save the braces
  • The most relevant information comes first: If you read return if your brain screams "important". When reading if { ... - especially in a method clustered with if's - it is easier to overlook the implication of possibly ending the method right then and there.
  • Reduces nesting. This is quite helpful when dealing with a big method, either one where there is a lot of nesting going on already (sure this is bad code in itself but still happens quite commonly) or when a static analysis tool would complain about complexity, even if it is just a method with a lot of different error handling (early returns)
  • Formatting: A lot of tools are configured to not allow one line if's. (which imho. makes sense most of the time) With this solution it would not do so because of the missing braces. If you just leave the line breaks, it adds more weight to the previous points.

What do you think of this idea? Is there something that might break? Is it pointless to just save two line breaks and make it slightly more readable? Would you support this?

4 Likes

I'm not against it exactly but I'm not clear on the motivation for it. Compare:

return Err(SomeError::Forbidden) if !may_i_do_this;
if !may_i_do_this { return Err(SomeError::Forbidden); }

I'm not clear on what we gain by having two very slightly different ways to do the same thing?

12 Likes

I have a few points for it, lets start with the weakest:

  • It reads more naturally: return value if something reads easier than if something { return value } (plus you save the braces
  • The most relevant information comes first: If you read return if your brain screams "important". When reading if { ... - especially in a method clustered with if's - it is easier to overlook the implication of possibly ending the method right then and there.
  • Reduces nesting. This is quite helpful when dealing with a big method, either one where there is a lot of nesting going on already (sure this is bad code in itself but still happens quite commonly) or when a static analysis tool would complain about complexity, even if it is just a method with a lot of different error handling (early returns)
  • Formatting: A lot of tools are configured to not allow one line if's. (which imho. makes sense most of the time) With this solution it would not do so because of the missing braces. If you just leave the line breaks, it adds more weight to the previous points.
4 Likes

Specifically for this case, and not considering the other use cases, my experience is that it's usually better to refactor so that may_i_do_this is either Result<(), SomeError>, or Result<(), AnError> where impl From<AnError> for SomeError exists, making the code may_i_do_this?; instead.

My error enums tend to use #[derive(thiserror::Error)], which makes the impl From<AnError> for SomeError trivial, and which also allows me to have richer errors:

#[derive(thiserror::Error)]
pub enum AnError {
    #[error("The user '{0}' is not known")]
    UserUnknown(Username),
    #[error("The safe is on a timer seal until {0}, and all operations are forbidden until it opens")
    TimerSeal(DateTime),
    …
}

#[derive(thiserror::Error)]
pub enum SomeError {
    #[error("Operation forbidden")]
    Forbidden(#[from] AnError),
    …
}

This now has a simple "Operation forbidden" error, but also means that I have error context via Error::source if I want to go into details about why the operation was forbidden.

8 Likes

I agree on this, most of the time it is indeed the better solution (and i also use it), though i have an example where this is not a valid solution. I cut the code short for convenience:

...
let sc = expect!(&parser, Token::Semicolon);
return rc_rep.report_error_missing_semicolon(sc) if !sc;
aggregate!(&parser, Node::Expression, Token::Semicolon);
...

Due to the side effects of the error report, yeeting the error (which is lowered to a boolean here for simplicity) after expect. Doing this differently would indeed be possible, but i had no influence on the codebases creation and no one pays us to do such a huge refactor :stuck_out_tongue:

I'm not a fan of such syntax, because the action with a side effect is very easy to see, but the conditional if comes later, when I already expect the code to perform the side effect. To me it reads like "do this thing with a side effect, …no wait, actually don't!" It's unnatural and tricky.

Currently in Rust if I see a code like:

return Err(some expressions here)

I can just note that it returns, and not read into the details of the error code if I don't need to. I already know the function diverges right here.

But if this syntax existed, I would have to pay attention to the code following return Err(… to see if there's an if hiding in there.

Also order of execution of the code is reversed:

return evaluated_second() if evaluated_first();

So reading code top to bottom left to right I can't simulate in my head what's happening, because the thing I read first may or may not happen, so I need to mentally "undo" the action if the condition is false.

30 Likes

FWIW, Perl has this general syntax form called statement modifiers.

3 Likes

IIRC Ruby also has a similar construct.

2 Likes

Ah, the Ruby FAQ cites Perl for that:

Ruby’s syntax and design philosophy are heavily influenced by Perl. [...] Statement modifiers (if, unless, while, until, etc.) may appear at the end of any statement.

Wikipedia also lists this as a feature of BASIC-PLUS, which further mentions JOSS conditions.

I tend to agree, but I’ll note that that’s also an issue with let/else (which is part of why the inspiration from Swift spells it guard let), or even let with an if or match in the value.

I’ve used the equivalent feature in Ruby and I go back and forth on whether I like it. It’s visually compact and it does read nicely out loud, but it also feels a bit like one of those “gotcha” quizzes that starts with “read all instructions before doing any work” and ends with “ignore instructions 2-10”.

8 Likes

I don't think let-else has the same issue: it's logically evaluated in order, it's just that the pattern-match may fail. By contrast, with return-if, there's nothing in the return that could fail, it's that the condition may cause it to not happen.

9 Likes

You may be interested in anyhow::ensure and snafu::ensure.

They don't satisfy your points 1 and 2, but they do satisfy your points 3 and 4.

4 Likes

I'm not a fan of this, since it's not LL(1). If you see return if, you don't know which form it's taking, since it could be return if b { 1 } else { 3 };.

(Not that Rust is always strictly LL(1), but in general I think it's a good thing to strive for when making new syntax.)

Note that you can already write <expr> || return <expr>; if you want.

(You'll get warnings, and I know a bunch of people hate code like that, but it's possible.)

6 Likes

It's certainly not going to happen now, but things like this are why I was a fan of unless instead of let-else.

Like how we write assert!(foo) for "unless foo, panic!()", it would be the same as that for other diverging constructs, like unless x > 0 { continue }.

The value of not just writing if !(x > 0) is that you know from the unless that it's a guard -- if the condition doesn't hold, it's guaranteed that control flow will diverge (like the else in let-else now guarantees). And thus unless wouldn't allow an else at all.

So you get the "control flow warning" up front from it, without needing to put the expressions in the opposite textual order compared to their evaluation order.

3 Likes

Yes, and it is IMO one of the biggest obstacles when reading Perl code. Evaluation order should match source code order, everything else is just actively misleading.

Rust has right-to-left evaluation order for =, which is bad enough but not sufficient justification for adding more footguns of that sort.

10 Likes

How about to write nested returns?

return return Err(SomeError::Forbidden) if !may_i_do_this if !may_i_do_that;

Is it clearer?

1 Like

You'll get fun errors if you try to chain further expressions too:

    expr || return || expr2;
    expr || return && expr2;

The first tries to return a closure, and the second a &&bool.

4 Likes

While I don't think in general we need to adhere strictly to fixed lookahead, I do think in this case it's a problem that human lookahead is unbounded. return 42 if expr might look obvious at a glance, but return very_long_expr() if expr, where very_long_expr() may even span multiple lines, makes it look much less obvious.

7 Likes

While I don't particularly like the proposed syntax, you can write run-on code sentences in regular rust. Some straightforward formatter/linter settings should ask you to convert return very_long_expr() if expr to something more readable.

You can do this in a macro, if you use a token like , from the :expr follow set.

macro_rules! fi {
    ($do:expr, if $cond:expr) => {
        if $cond { $do; }
    };
    ($do:expr, unless $cond:expr) => {
        if !$cond { $do; }
    };
}

fi!(return Err(SomeError::Forbidden), if !may_i_do_this);
fi!(return Err(SomeError::Forbidden), unless may_i_do_this);
1 Like