Elvis operator for Rust

I'll note that I once proposed spelling this operator as else.

I'd be more or less just as happy with a postfix macro; though it seems they would be somewhat less convenient to chain.

3 Likes

?? has meaning today, though, as two applications of the ? operator. ?: at least doesn't have a stable meaning.

3 Likes

At this point it feels to me like we're playing a game of "syntax bingo". So how about ?! as an operator? The ! is currently only used for inversion (the Not) trait, never types, and for macro invocation.

IIUC there would be no ambiguity for any of those cases:

  1. The not operator is a prefix operator. This is a suffix operator.
  2. The context in which ?! would be used is at the expression level, not the type level.
  3. It's fairly obvious when something is a macro invocation due to the rather specific syntax used there.

Aside from a couple of weird initial looks from Rustaceans (e.g. foo?!) I don't see any issues. But perhaps someone else does?

Not in an infix position, though. You can disambiguate between the two by using the exact same check that is used to disambiguate between & as a postfix reference operator and & / && as an infix and operator.

This is exactly one of the reasons why I wouldn't want something like this.

If you want to perform control flow, you can still use unwrap_or_else(|| …)?, with the ? operator applied to its result. There's no need for an operator that essentially replicates this pattern. (That only works for return, but that's by far the most common case. And additional cleverness or compression without a very common use case is bad.)

I respectfully disagree here. I don't find this pattern overwhelmingly frequent in either my own code or that of others, and I don't think it warrants a completely new built-in operator, unlike try!() was.

2 Likes

This only works if what you want to do in the Err case is return an Err. (And it's good for that case!) But there are other control flow options that it's useful to trigger on the Err branch that can't be handled by .map_err(..)?/ .unwrap_or_else(..)?: continue, break, and return which isn't Err(..).

If you want to return an Err, you can .map_err(|_| ..)?. If you want to call some fn -> !, you can .unwrap_or_else(|_| ..)?. If you want to do anything else on an Err case, you have to fall back to a match.

Here's a simple, common examples for all three:

for file in directory {
    // ignore individual file errors, process the rest
    let file = file ?: continue;
    process(file);
}
fn foo() -> Foo {
    // ...
    let bar = maybe_bar() ?: return Foo::default();
    Foo::from_bar(bar)
    // The alternative here
    //     maybe_bar()
    //         .map(Foo::from_bar)
    //         .unwrap_or_else(Foo::default)
    // only makes more sense if the map is one line only
    // because otherwise it separates the default a lot
    // and it only works because this is the end of the fn
}
// this is effectively a `map_take_while`
for child in children {
    let child = child.cast::<FancyChild>() ?: break;
    process(child);
}

And of course, unwrap-but-the-err-type-isnt-Debug can be spelled .unwrap_or_else(|_| panic!("..")), or with elvis, ?: panic!("..").

These examples are obviously synthetic, but I think they succeed in showing realistic cases where the control-flow-capable "Elvis operator" actually adds expressivity to realistic scenarios that can't be handled just by combinators.

I honestly suspect that adding ?: would be a similar case to adding ?. There will be a fight that it's hiding control flow and needless compression, but after people get used to it, it will be more appreciated for eliminating boilerplate.

6 Likes

In those cases it's better to just spell out whatever is the intent itself, for two reasons:

  1. It takes less thinking to write – often, several valuable working hours are wasted because programmers try to be clever and as brief as possible with their code, but in the end, the "clever" solution is just convoluted, and even if shorter, it's neither more idiomatic nor easier to come up with. I find this to be a problem especially in the case of people coming from a FP background, trying to replicate e.g. point-free style to the greatest possible extent, not knowing that in Rust, the ultimate goal is not to follow all the Haskell conventions (for example).

  2. If an expression is so complex, and more importantly, uniquely-structured that's it's not reasonable to describe it with either the standard combinators or as a simple (but surprisingly powerful) chain of unwrap-or-return operations, then I would also like to see what exactly it's trying to do, in all of its naked glory. Adding more and more operators to cater more and more special cases of ever-increasing complexity only hurts readability of the code.

    That is, I don't doubt that legitimate use cases exist in which it's not possible to express the control flow using either ? or combinators.

    Rather, I'm arguing that such rare cases should not be considered the norm, and instead of adapting the language to each of them, they should just be written as-is, exactly because they are more complicated than what is idiomatic.

1 Like

Can you elaborate more on whether this is the same problem domain as Pre-RFC: Overload Short Curcuits or Something for coalescing; aka generalized/improved `or_else`?

Note that try+? gives you a conceptual "and" -- try { (a?, b?) } needs both of them to be Some/Ok, whereas this thread is more about something that can give non-None despite the LHS being None.

TBH, I don't understand the question. I can say that coalesce = ?: + overloadable ||. Specifically, coalesce!(x, y, z, t) is equivalent to x || y || z ?: t if we have both overloading and ?:. Does this answer work for your question? :slight_smile:

1 Like

The thing is, unwrap-or-return doesn't exist. That's what this is; an unwrap-or-control-flow operation.

Any combinator on a Try type necessarily takes a closure, which introduces a control flow barrier. So the best you can do with a Try type is ?, which is great when that's what you want, but doesn't apply generally as unwrap-or-return; it's unwrap-or-return-err instead.

If you're saying unwrap-or-return-err is enough, then state it as such. We don't have unwrap-or-return.

7 Likes

Missed that. So try would work for this if this if we had a early-return-on-Ok version of ?. Would a early-return-on-Ok operator would be more useful than an Elvis operator? I'm not sure, but I think it's a question worth asking.

I would agree that ?: is does not warrant a new built-in operator, primarily because it is too limited to Try. However, in the compiler, I frequently see deeply nested pattern matching on ASTs, so e.g. (let ExprKind::Foo(baz) = expr.kind) || return; would be helpful there; that said, if let a = b && c { goes a long way.

3 Likes

Yes, I think unwrap-or-return-err is enough. I didn't mean to be misleading, I assumed it was to be understood as such. (I should have been explicit with that. Words, like operators, evoke different meanings in different minds. :stuck_out_tongue:)

I'd prefer to have postfix macros in the language first, so we could see if .unwrap_or!{ return } is sufficient to handle this case, and many others.

20 Likes

I don't think these block level control flow would be spaghetti code. Since the other option is using flag variables which bloat the programmer's data set to keep track of.

I prefer explicit control flow verbs like these to setting flags and having those as loop invariants since it is easier to track.

However, I would prefer a postfix macro solution instead.

3 Likes

Interesting bit from Kotlin:

It has something rather close to postfix macros: inline functions.

inline fun<T> T?.unwrapOrElse(f: () -> T): T {
    return if (this == null) f() else this
}

let foo: String? = null
foo.unwrapOrElse { return }

Kotlin Playground

That is, control flow operations such as return or .await (which is not spelled explicitly in Kotlin) are allowed inside f lambda, and affect the outer function.

I would almost cite this as an evidence for "hey, ?: is useful even you have a more general feature", except that, unlike return, break and continue are not allowed. I don't think there's a fundamental reason for this, it should be possible to support. I've just never needed them , probably because all my use-cases were covered by ?:.

EDIT: ah, docs say that break and continue will be supported. I guess that means that Kotlin finds ?: valuable despite the fact that a more general solution is designed and partially implemented.

7 Likes

On a more general note, I find Kotlin's inline functions an under-appreciated point in the language design space. They get you many benefits of more traditional macros, with virtually zero down sides.

Like, macros, they allow one to express weird control flow (select in Kotlin is a library feature, which is mind-blowing for me. Really, go read and understand how select works in Kotlin) or effect-polymorphic code (in Kotlin, you can just call async functions from .map, .filter and friends).

Unlike macros,

  • they are fully-integrated into name resolution and type inference: it's possible to declare an extension inline function for a specific types
  • code in the lambda passed to the inline function is a usual expression, which is resovled in the context of the caller. There's no "to understand the meaning of the syntax at the macro call site, I need to look at the def site" problem. This reduces the cognitive burden of a feature to that of a usual function. This also means that IDE just works (which is an unsolvable problems for general macros).
  • no leakage of compiler APIs or current syntax details happens, which is often a case in procedural or by-example macros.
12 Likes

Another related feature is call-by-name. Basically the same semantics, but formulated in terms of evaluation strategy (slash continuations) rather than implementation details like "inline."

I've never thought about call-by-name in this context, but now it looks like a really appealing point between macros, lazy evaluation, and closure arguments.

Edit: It is also general enough to describe built-in operators like &&, ||, and placement (box/<-)!

4 Likes

For a while I've wished Rust implemented something like this. As I envisioned it, it could be done on the caller end rather than the callee end. The user could write something like (actual syntax TBD):

let x: i8 = foo do |x: i32| -> i64 {
    // Here, control flow such as break and return applies
    // to the outer function.
    if x == 1 {
        break 'a 42; // breaks from loop containing the call to foo
    } else if x == 2 {
        return "asdf"; // returns from outer function, not the lambda
    } else {
        x as i64 // this is returned from the lambda, though
    }
};

...and it would be transformed into something like:

enum OpaqueJumpTarget {
    BreakA(i32),
    Return(&'static str),
}
let ret: Result<i8, OpaqueJumpTarget> =
    foo(|x: i32| -> Result<i32, OpaqueJumpTarget> {
        if x == 1 {
            Err(OpaqueJumpTarget::BreakA(42))
        } else if x == 2 {
            Err(OpaqueJumpTarget::Return("asdf"))
        } else {
            Ok(x as i64)
        }
    });
let z = match ret {
    Err(OpaqueJumpTarget::BreakA(n)) => break 'a n,
    Err(OpaqueJumpTarget::Return(s)) => return s,
    Ok(n) => n,
};

A new "jump target" type would be generated for each use of the syntax; it would be opaque to the user, but would be implemented as an enum. The compiler would scan the lambda for control flow constructs that "jump outside the lambda" (like break and return in this case), and create a variant for each possible destination.

For this to work correctly, foo is expected to pass on Err values it encounters when calling the lambda to its own return value, i.e. the normal behavior if you use the ? operator. If it fails to do so, the behavior would be surprising, though not unsafe.

In this case, a possible implementation of foo would be

fn foo<E>(f: impl FnOnce(i32) -> Result<i32, E>) -> Result<i8, E> {
    f(123)?;
    Ok(42)
}

Similarly, to get the equivalent of @matklad's example of unwrap_or_else on an Option, we'd want some method like

impl<T> Option<T> {
    fn something<E>(self, f: impl FnOnce() -> Result<T, E>) -> Result<T, E> {
        match self {
            Some(t) => Ok(t),
            None => f(),
        }
    }
}

Well, that doesn't currently exist, but suppose it did. Then @matklad's example could be translated as:

let foo: Option<String> = None;
// Syntax sugar: If there are no arguments, you can leave
// out the ||.  Like with normal lambdas, you can also leave
// out the return type to infer it.
let realFoo = foo.something do {
    return; // return from the outer function
            // (assumed to have return type ())
};

which would be transformed into

enum OpaqueJumpTarget {
    Return(()),
}
let ret = foo.something(|| -> Result<_, OpaqueJumpTarget> {
    Err(OpaqueJumpTarget::Return(()))
});
let realFoo = match ret {
    Ok(x) => x, // x: String
    Err(OpaqueJumpTarget::Return(y)) => return y, // y: ()
};

Heck, you could probably prototype this as a macro.

2 Likes

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

2 Likes