Elvis operator for Rust

I usually shy away from new syntax discussions, but this bit seems interesting to me. A similar idea was discussed previously, but I think that post missed a rather crucial detail.

Summary

Add ?: (elvis) operator to Rust, with the following desugaring semantics:

$lhs ?: $rhs

=>

match Try::into_result($lhs) {
    Ok(it) => it,
    Err(_) => $rhs
}

?: is similar to unwrap_or_else, but is more powerful, as $rhs can contain control-flow expressions like return, break, continue or even ? (that's the missing detail from the previous post).

Motivation

The primary motivation here is dealing with Option in a context where the result is (). For example, the following pattern is pervasive in rust-analyzer:

fn complete_postfix(ctx: &CompletionContext) {
    let dot_receiver = match &ctx.dot_receiver {
        Some(it) => it,
        None => return,
    };

    ...
}

With ?:, this can be simplified to

fn complete_postfix(ctx: &CompletionContext) {
    let dot_receiver = &ctx.dot_receiver ?: return

    ...
}

Another example

for param in param_list.params() {
    let pat = match param.pat() {
        None => continue,
        Some(pat) => pat,
    };

    ...
}

=>

for param in param_list.params() {
    let pat = param.pat() ?: continue;

    ...
}

Naturally, the operator can be used instead of unwrap_or_else. For example, for "I want .unwrap by the type isn't : Debug problem".

foo.unwrap_or_else(|| panic!("some message"))

=>

foo ?: panic!("some message")

Discussion

This feature is blatantly borrowed from Kotlin.

It's very similar to various let ... else proposals, but:

  • It is restricted to Try types,
  • which makes it possible to introduce a very light-weight surface syntax

I am pretty bad at accessing "is it worth it" tradeoff, but:

  • this feature seems very cheap with respect to cognitive load:
    • implementation is desugaring
    • it's easy and natural to connect ? with ?:, so this should be pretty easy to learn. I'd even argue that it is simpler than .unwrap_or_else.
  • in the code I typically write, I find plenty of cases where I could have used ?: both in the simple unwrap_or_else form and in more powerful ?: ! form
  • in Kotlin, I've used ?: a lot
    • note that the previous two points might actually be "?: is useful for writing IDEs"
    • in Kotlin ?: is more expressive: it plays a role of both unwrap_or_else and or_else, because nullness is handled via union type and not a sum type.
22 Likes

I'll play the devil advocate and ask it. Do you think it would be used often enough to warrant the addition of the operator? Would a macro work for you to solve your use case? I know I've written code which used something similar and a single-letter-named macro did the trick.

4 Likes

So, first of all, I would very much like to see a good solution for the problem of invoking control flow on the RHS.

The specific syntax proposed would introduce an ambiguity; : is the "ascription" operator (still WIP), to say "the LHS has the type specified on the RHS". So lhs ?: rhs would parse as lhs? : rhs, meaning "apply ? to lhs and then make sure the result has the type rhs".

I also don't like that the desugaring ignores the error entirely (Err(_)).

6 Likes

Honestly, I don't know. For my personal use I lean towards yet, mostly because the diff to the language seems really minimal.

The specific syntax proposed would introduce an ambiguity;

We might be able to deal with it with jointness: parse ?: as a single operator if there's no white-space.

I also don't like that the desugaring ignores the error entirely ( Err(_) ).

Don't like this either, but I don't see a simple syntactic device that can help here. So, just use a match if you need to?

Would it be possible to have the desugaring take two forms, depending on whether the RHS is a function?

// Option 1 (rhs is not a function)

$lhs ?: $rhs
=>
match Try::into_result($lhs) {
    Ok(it) => it,
    Err(_) => $rhs
}
// Option 2 (rhs is a function)

$lhs ?: $rhs
=>
match Try::into_result($lhs) {
    Ok(it) => it,
    Err(err) => $rhs(err)
}

Alternatively, it could default to always expecting RHS to be a function, requiring you to write a closure which ignores the input param in the case of a constant value; or there could be two syntactic forms, one for unwrap_or and one for unwrap_or_else.

I like the idea, although handling Try types only seems slightly too specific imho. I'd rather have the let else idea explored a bit further, à la swift:

guard let Ok(x) = try_something() else {
    return;
    break;
    panic!("Whatever");
};
// x bound here

That being said, your idea has the potential of becoming a suffix operator:

.or_else!(...), à la .await:

foo .try_something()
    .or_else!(return)
2 Likes

IIUC postfix macros should remove the need for such operator:

let dot_receiver = &ctx.dot_receiver.unwrap_or_else!(return);
// it can be seen as a more general version of the usual `unwrap_or_else`
let a: Option<u8> = ...;
let b: u8 = a.unwrap_or_else!(1);

Although I don't know if it's possible to generalize such macro over different types (Option, Result, etc.).

1 Like

Type dependent desugaring is always a bad thing

7 Likes

Is this not the type of thing #![feature(try_blocks)] are suppose to solve?

1 Like

I'd love to see let-else, which would solve many instances of this problem.

(I'd also love to see suffix macros, but that seems more distant.)

I agree that there's a problem to be solved here. However, rather than introduce a new operator in ?: I would instead propose that let-chains be extended such that you can write:

for param in param_list.params() {
    let Some(pat) = param || continue;
}

This also extends naturally to e.g. (bikeshed formatting etc.)

for param in param_list.params() {
    let Some(pat) = param
    && let Ok(other) = do_work(pat)
    || continue;
}
12 Likes

Doesn't that parse as let Some(pat) = ⟨param || continue⟩;? There needs to be some indicator if I'm not mistaken to "loosen" the &&/|| in a let chain to make it parse as ⟨let pat = expr⟩ && expr as opposed to let pat = ⟨expr && expr⟩, which is how it parses today.

(So if you just used parenthesis, it'd be let (Some(pat) = param) || continue;.)

2 Likes

Indeed, that's true. You'll need to write that as (let Some(pat) = param) || continue; to change the precedence, but perhaps we can adjust things with an edition.

Wouldn't this be ambiguous? I can't tell just from looking at let $pat = $a || $b if the precedence is (let $pat = $a) || $b or let $pat = ($a || $b). Seems kind of subtle, you need the extra context that $pat is a refutable pattern, and if that ever changes, then the semantics this code snippet also changes.

1 Like

Sure, it is ambiguous, but that's not atypical for a binary operator to associate in one way or the other (consider a + b * c -- it's also ambiguous). The question is whether the associativity is problematic or not.

In the case of let $pat = ($a || $b), it is not useful to pattern match on e.g. let true = $a || $b and so the cases that remain are let ref? mut? $ident | _ = $a | $b. In other cases, e.g. let Some(x) = $a || $b this would not type check as ($a || $b) since bool and Option<?T> are of different types.

We could not change the precedence of the statement form let $pat = $a || $b; to use an expression instead without an edition. Whether we should do so or not depends in my view on what the most common case is. However, I think (let Some(x) = foo) || continue; also works well so we can also leave it be.

2 Likes

nvm, I forgot that || only works for bool. So if let chains are incompatible with extending || and && to other types, yes?

Sorta; ostensibly one could do hacks to make the semantics type-dependent, but I'm not sure that's a good idea. (See also https://github.com/rust-lang/rfcs/pull/2722#issuecomment-510725064 .)

2 Likes

Yes, I was a part of that discussion, I agree that it may not have been the best for Rust. I just wanted to clear up some confusion I had, thanks.

1 Like

Might ?? be a beter alternative (like C#)? So instead of:

let x = something_that_is_fallible() ?: return;

you could have:

let x = something_that_is_fallible() ?? return;

or even any of:

let x = something_that_is_fallible()??return;
let x = something_that_is_fallible()? ?return;
let x = something_that_is_fallible() ? ? return;

without ambiguity. Although, I'd personally prefer that is only parses if it is connected like ?? and not ? ?

Woudn't ?? fit better with current Rust grammar and parsing rules as well as with current idiomatic code?

So, we'd have ? operator for "early return on failure of fallible things" where the error is propagated/returned and ?? for "map failure to whatever you choose including an expression that evaluates to () and returns or breaks".

It seems like ?? would fit better both cognitively and be a better fit for existing grammar. Also, it aligns pretty closely with ?? in C# which is a "Prior-Art" bonus.

EDIT: It would also be nice to support something like:

fn a() -> Result<Actual,Error> {}
fn b(self : Actual) -> Option<Value> {}
fn c(self : Actual) -> Option<Value> {}
fn d() -> Value {}
let x = a() ? b() ??? c();
let y = a() ? b() ?? d();

where the above is equivalent to:

let x : Actual = match a() {
          Some(a) => a
          Error(b) => return Error(b);
}
let x : Option<Value> = match x.b() {
         Some(a) => Some(a)
         None => x.c()
}

and

let y : Actual = match a() {
          Some(a) => a
          Error(b) => return Error(b);
}
let y : Value = match x.b() {
         Some(a) => a
         None => d()
}

EDIT 2: It might even make sense to support the following:

fn a() -> Result<Actual,Error> {}
fn b(self : Actual) -> Option<Value> {}
fn c(self : Actual) -> Option<Value> {}
fn d() -> Value {}
fn e(self : Actual) -> Option<Value> {}
fn f(self : Actual) -> Option<Value> {}
let x = a() ? b() ??? c() ???? e() ?? d();

which would be preferably formatted like:

let x =    a() 
      ?    b() 
      ???  c() 
      ???? e() 
      ??   d();

and would be equivalent to:

let x1 : Actual = match a() {
          Some(a) => a
          Error(b) => return Error(b);
}
let x2 : Option<Value> = match x1.b() {
         Some(a) => Some(a)
         None => x.c()
}
let x3 : Option<Value> = match x2 {
         Some(a) => Some(a)
         None => x2.e()
}
let x : Value = match x3 {
         Some(a) => a
         None => d()
}

EDIT 3: This could continue to be extended like:

let x =     a() 
      ?     b() 
      ???   c() 
      ????  e()
      ????? f() 
      ??    d();

Which would be saying:

Let x be equal to the unwrapped result of a() (or early return the error) nvoking a().b() unless a().b() returns Result or Option, in which case use the value a().c() unless it returns a Result or Option, in which case use the value a().e() unless it returns a Result or Option, in which case use the value a().f() unless it returns a Result or Option in which case use the value d().

This idea can be extended to any number of consecutive ? operators to allow for easy traversal of trees of optional/fallible functions/methods to obtain the first non-failure/None value at multiple depths and branches in a fairly intuitive manner. You can liken the ? to . in file-system paths and ?? to .. and ??? to ..\.. etc. (NOTE: It's not an exact analogy, but, I think the intuition carries over reasonably well).

You could also do (changing d() to be fallible as follows):

fn d() -> Option<Value> {}
let x =     a() 
      ?     b() 
      ???   c() 
      ????  e()
      ????? f()
      ??    d()
      ??    return Result(Error);

EDIT 4: Upon further reflection, I think it might actually be better to represent as follows:

let x =   a() 
      ?   b() 
      ??? c() 
      ??? e()
      ??? f()
      ??  d()
      ??  return Result(Error);

That way this could make sense and be useful:

let x =     a() 
      ?     b() 
      ???   c()
      ??    c1()
      ???   c2()
      ???   e()
      ???   f()
      ??    f1()
      ???   f2()
      ???  f2_1()
      ??    d()
      ??    return Result(Error);

The above would evaluate as:

If a() returns Error or None - early return with error propagation; otherwise, the value returned is a().b() unless a().b() returns a failure value, then if a().c() is failure then the value is c1() unless that is a failure, then the value is a().c().c2() unless that returns failure, then value is a().e() unless it returns failure then if a().f() returns failure then the value is a f1() unless that returns failure, then the value is a().f2() unless that returns failure, then the value is a a().f2_1() unless that is a failure, then the value is d() unless that is a failure, then a return is performed with an error.

Basically the ?n operator can be interpreted as follows:

  • ? - return/propogate error from lhs or return evaluate to value of rhs using the lhs as the receiver (self)
  • ?? - evaluate to value of lhs unless it is Error/None, then evaluate to value of rhs
  • ??? - evaluate to value of lhs unless is is Error/None, then evalute to value of rhs using the previous non-error value in the chain as the receiver (self)
  • ???? - evaluate to value of lhs unless is is Error/None, then evalute to value of rhs using the 2ND-previous non-error value in the chain as the receiver (self)
  • ????? - evaluate to value of lhs unless is is Error/None, then evalute to value of rhs using the 3RD-previous non-error value in the chain as the receiver (self)
  • ...
  • ??{4,n} - evaluate to value of lhs unless is is Error/None, then evalute to value of rhs using the Nth-previous non-error value in the chain as the receiver (self)
4 Likes

A way to achieve the same thing, but in a less baked in way, would be for it to be a postfix macro:

let foo = foo.unwrap_or!(return);
let bar = bar.unwrap_or!(5);
3 Likes