Elvis operator for Rust

If so, why does it necessitate a special edition-breaking operator?

2 Likes

This all sounds like enabling code that some would love to write, but everyone would hate to read.

2 Likes

Who said anything about edition-breaking? My suggested syntax (do) is a reserved keyword, and it's just a suggestion in any case.

A macro-based implementation of my idea would have significant downsides: it wouldn't be able to perfectly identify the control flow in question (especially if there are nested macros), and it would be verbose. Good enough for a prototype, but not a substitute for a real language feature.

1 Like

To get back to the topic, here are some thoughts:

Kotlin's elvis operator

let foo = bar ?: return baz;
  • Pro: Should be easy to learn, most popular programming languages have a null-coalescing operator
  • Pro: To provide a default value, it's shorter than .unwrap_or_else(|| ...)
  • Con: Only works for types that implement Try.

Syntax:

  • ?: will be ambiguous when type ascription gets stabilized
    • Solution: Parse ?: as elvis operator and ? : as type ascription
  • ?? is ambiguous when followed by -, *, & or ||
  • ?! and || would be possible
  • or keyword would be possible

Let else

let Ok(foo) = bar else { return baz };
  • Pro: Works for values that don't implement Try.
  • Con: The right-hand side has the ! type, so it's no substitute for .unwrap_or_else(|| ...)

The braces could be optional. Examples:

let PATTERN = EXPR else EXPR;
let PATTERN = EXPR or EXPR;

Postfix macros

let foo = bar().unwrap_or!{ return baz };

This would only work for types implementing Try, but we could write a postfix macro for other enums:

let baz = expr.kind.get_variant!(ExprKind::Foo else return);
  • Pro: Postfix operators are useful in many situations
  • Con: More verbose; encourages to use macros much more pervasively, which might impact compile times and IDE experience (please correct me if I'm wrong).

Summary

The elvis operator and let-else have slightly different use cases. The elvis operator can provide a default value, whereas let-else works on types that don't implement Try. Postfix macros can do anything, but built-in syntax might be better.

3 Likes

I'm very much against this proposal, for the very same reasons as I have been in favour in let..else for as many years as this other proposal existed. I need a feature like let..else that isn't restricted in any way. I keep finding the pattern "if this value matches pattern, then bind a few values, otherwise return early" everywhere and there is still nothing to fill this void in Rust.

On the naming front, I have no damn idea why it's named "Elvis" and I think that's a pretty bad name for a control-flow operator.

5 Likes

That isn't mandatory.

The answer can be found online:

elvis

I find it easy to remember, but you can call it null try-coalescing operator if you like.

Surely that would be nice. But the proposal in the thread you linked looks like magic and might be confusing for many Rust programmers.

I think the best way forward would be postfix macros, because they're the most powerful. More specifc syntax could be added later; remember that ? was also implemented as a macro first.

Postfix macros are an often requested feature; what are the main concerns?

4 Likes

Would it not be clearer to use a normal if statement here? It could look like this:

for file in directory {
    if file.is_err() {
        continue;
    }
    process(file.unwrap());
}

Perhaps rustfmt could be taught to format if statements with very short branches like this:

for file in directory {
    if file.is_err() { continue; }
    process(file.unwrap());
}

I find that code super clear — very readable and straight-forward. In particular, there's no syntax here that someone coming from Java or C++ would be unfamiliar with: just method calls and normal conditionals.

1 Like

You can do that, and in practice that's how I'd probably do it today. The other option is of course if let Ok(file) = file.

But the point is that your option requires using unwrap(), if let requires a level of indentation for everything remaining, and let file = match file { Ok(file) => file, Err(_) => continue, }; is a huge mouthful for something so theoretically simple.

8 Likes

Overall, I like the motivation for this, but I don't particularly like the choice of ?: If only because i find it really easy to overlook the : being practically blind.

The problem as I see it is we want something like "one-legged if", but If expects a bool, and in this case we've got... something else.

If it were practical to take "when" as a keyword, I would love

let file = when !file? { continue; }

Which I think reads pretty clearly, However this really requires more careful consideration regarding the meaning of the positive fragment.

2 Likes

I doesn't look less or more like magic than this operator proposal.

I disagree with the idea that the more general solution is unambiguously better. We already have the most general tool -- match. What we want is to restrict a power of match to get lighter syntax, that still covers many use-cases.

let .. else is more general, but also more verbose and more complicated. The most substantial difference is you don't need to come up with a name for a binding to use ?:. It is restricted to Try (which wraps a single value), so it can be an expression. In contrast, let..else either needs to be a new kind of statement, or, (in expr is pat proposals) it needs changes to how name bindings fundamentally work (ie, switch from "binding is defined in the subtree of the syntax tree" to "binding is defined in the dominated expressions").

EDIT: this fundamentally more complex, non-expression desugaring is probably what is called magic by @Aloso.

For comparison, here are all constructs together for the simple case:

for line in buf_read.lines() {
    let line = line ?: continue; // works only for `Try` types
    let Ok(line) = line else { continue };
    let line = match line { Ok(it) => it, _ => continue };
    let line = if let Ok(it) = line { it } else { continue };
}

Here is a (bad) example of using expression capabilities of ?:

for line in buf_read.lines() {
    process(line ?: continue); // works only for `Try` types
    process({ let Ok(line) = line else { continue }; line });
    process(match line { Ok(it) => it, _ => continue });
    process(if let Ok(it) = line { it } else { continue });
}

Finally, there's a constructed example which is much shorter with ?::

foo ?: bar ?: baz ?: return

The question is, which proportion of cases, covered by let..else, is also covered by ?:? For example, all examples in this comment on the let..else RFC are actually covered by ?:.

To clarify, I don't really have an opinion of which syntax strikes the better trade-off, if..else or ?:. My personal gut feeling is that we might need both ?: and expr is pat.

8 Likes

I've said multiple times that I've wished for the let..else construct in cases where the types at hand were not Try. match is extremely verbose for this use case, especially when you want to extract multiple bindings and not just once.

2 Likes

My personal gut feeling is that we might need both ?: and expr is pat .

I'd say I fundamentally disagree with the notion of "needing" anything.

My experience from developing code with a blanket ban on ?, continue, break, loop, return and catch_unwind is that the readability wins have been extremely beneficial; to a point where any future additions to subvert the control flow would just end on the same pile of "banned keywords".

(I received code using continue as a contribution once and I still can't read it without mentally desugaring it to the labels/jumps a compiler would emit.)

In the end the core question is: how many ways to encode "do either A or B based on X" do we need? I'd say having if expressions and pattern matching is already one to many – while this proposal is adding another option to the ~half-dozen(?) existing ways.

Language design is what's left out, not what can be added in, especially as Rust does not have a working deprecation process for the language/standard library (outside type-safety bugs and security critical issues). This means everything you add is there forever.

6 Likes

I main issue is you have made a choice not to use ?, In the situations in this thread the choice has been made for us by syntactic restriction.

1 Like

... and the more Rust turns into "do whatever you want", the more unwanted work it puts on people's plates to restrict it down to some sane subset.

2 Likes

Syntax sugar is difficult to discuss because we all have different tolerance thresholds to glucose.

On this particular occurrence I'd fall in the camp of using a macro to start experimenting with the syntax. Ideally, it would be a postfix macro as already suggested in the thread, but barring them we could have:

unwrap_or_else!(lhs, rhs)

expand to what is presented in the first post.

In a previous post I already suggested using a macro to emulate let... else patterns, I think we could follow the same approach here. Maybe have a crate with some of these "useful control flow" macros, and see where we can go from there?

This is kind of a tangent, but I would be interested to read such code and see how readability is improved by banning these keywords. For instance without early returns, how do you express the following pattern without rightward drift:

if not some_precondition(inputs) {
    return;
}
// proceed in the rest of function assuming some_precondition(input) is met

?

Similarly, without catch_unwind, how do you catch panics from e.g. dependencies you don't fully control, and that should be allowed to fail in unexpected ways without tearing down you whole application? Or, how do you prevent your own application to unwind into FFI should it encounter a panic, which is UB? (I agree catch_unwind shouldn't be used for "normal control flow" though, but I guess most people here agree on that particular point)

2 Likes

I have a concern with all of the competing proposals, that hasn't come up yet: I don't think it should be easy to discard errors without reporting them somehow. The original code fragment does exactly that:

for file in directory {
    // ignore individual file errors, process the rest
    let file = file ?: continue;
    process(file);
}

In code review I have been known to call "ignore errors" an outright bug. I want to see something like this instead:

for file in directory {
    let file = match file {
        Err(e) => { log_error(e); continue },
        Ok(f) => f,
    }
    process(file);
}

Filtering out nuisance errors is the responsibility of the logging system.

It is a little annoying to have to write Ok(f) => f here. If I could write it this way I probably would:

for file in directory {
    let file = file.unwrap_or_else(|e| { log_error(e); continue; })
    process_file(file);
}

But there are good reasons why that doesn't work. The proposed ?: operator specifically does allow control flow operations on its right-hand side, but it doesn't give me a way to access the error value, and therefore I don't like it.

(This is not a problem with the existing ? operator because the error value is returned to the caller.)

6 Likes

...but this could work with let .. else .. proposal could it not?

I don't see how let .. else .. lets me access e in the else clause?