[Pre-RFC] Elvis/coalesce + ternary operator

This proposes a new operator which is similar to ?? from Swift and ?: from Kotlin.

That could be a useful addition to Rust because match/ if let constructs sometimes are an overkill when dealing with nullable values, and single alternative to them - a diversiform combinators on Option<T> is hard to use especially for newcomers, since there’s no simple rule how to remember them and how to choose a proper function, plus Result<T, E> makes that even harder by implementing the same functions with different signatures, and after all some functions have the same downsides as closures: no ? operator inside, no early return/break, no possibility to mutate bindings in parent scope, unnecessary borrows, verbosity.

// An example of intended syntax
let num: i32 = Some(0) or Some(1) or 2;

// An example of the same in current Rust (for comparsion)
let num: i32 = Some(0).or_else(|| Some(1)).unwrap_or_else(|| 2);

Why exactly have this syntax:

  1. It perfectly describes action and it’s probably impossible to find something more expressive
  2. It’s compact, which is good for consistency and for alignment of code
  3. It wouldn’t be misinterpreted as a new boolean operator, which is good for understanding of code
// E.g. some symbolic sigil could be confusing here
return Some(0) ?: 1;

As a possible extension it also might be implemented to act as a ternary operator. Even if there’s many reasons to not introduce a ternary, this proposal bypasses some of them because resulted syntax and semantics would be very different.

// An example of ternary `or`
let num = condition() && 0 or 1;

This might be better than a usual ternary because && operator and its lazy nature here are reused, and or operator behaves just like || only indicating a threshold between <bool> and <T>. We only need to handle EXPR or EXPR after && differently than all other expressions.

The main idea behind that is to represent <bool> && <T> as Option<T>, e.g. on a && b && c or d expression a && b && c is the same as if a && b { Some(c) } else { None }.

This syntax will be possible only before or operator:

// The following line still wouldn't compile!
let _ = condition() && 0;

Either, in optional or in ternary context we would choose between a real values, which means that some conversion from/into wrapper types can be performed automatically by Rust awesome type inference. More specifically, a last value in or chain would determine return type of a whole expression.

And only nullable types would be permitted on a LHS of or operator to not make this too implicit, e.g.

  • Result<R, E> is not nullable: it cannot drop its Err(e) variant before or and replace it with the value on RHS of or because information that must be handled explicitly then could be silently lost.
  • Option<T> is nullable: its None variant can be safely replaced with the value on RHS of or because there’s nothing to lose; and its Some(T) variant can be safely converted into the type of value on or RHS because wrapper no longer needed and the target type is obvious.
let res: Result<i32, &str> = Some(0) or Err("missing zero");

Under the hood it seems to be relatively simple:

  1. The or operator is overloadable by std::ops::Or<T> trait, which allows for implementing type to appear on the LHS of or operator
trait Or<T> {
    fn into_option(self) -> Option<T>; 
}
  1. Nullable types implements it, e.g. the following is a default implementation for Option<T>
impl <T> Or<T> for Option<T> {
    fn into_option(self) -> Option<T> { 
        self 
    }
}
  1. std::convert::From<T> is used to “optimistically” construct a RHS type from LHS value, e.g. the following is a default implementation for Result<T, E>
impl <T, E> From<T> for Result<T, E> {
    fn from(t: T) -> Self {
        Ok(t)
    }
}
  1. Desugaring converts sole EXPR or EXPR into match expression with the use of traits from the above
let x = first or last;
/*
let x = match Or::into_option(first) {
    Some(o) => From::from(o),
    None => last
};
*/
let y = first or next or last;
/*
let y = match Or::into_option(first) {
    Some(o) => From::from(o),
    None => match Or::into_option(next) {
        Some(o) => From::from(o),
        None => last
    }
};
*/
  1. Ternary desugaring converts a whole ... && EXPR or EXPR into if expression that also uses the same traits inside
let x = a && b && next or last;
/*
let x = if a && b { 
    From::from(next) 
} else { 
    last
};
*/

let y = a && first or b && next or last;
/*
let y = if a { 
    From::from(first) 
} else { 
    if b { 
        From::from(next)
    } else {
        last
    }
};
*/

A problem here is that the following desugaring fails to compile:

match Some(0).into_option() {
    Some(o) => From::from(o),
    None => return,
};
/*
error[E0277]: the trait bound `(): From<i32>` is not satisfied
   --> src/main.rs:190:20
    |
190 | Some(o) => From::from(o),
    |            ^^^^^^^^^^^^^^^ the trait `From<i32>` is not implemented for `()`
*/

This because type inference tries to do something like ()::from(o), which obviously fails.
Implementing From<T> for () might lead into surprising results e.g. (Some(x) or return) would return () which would make impossible calling a function that’s expected to belong to x.

Overall, this is not a big deal, since code that leads to such desugaring wouldn’t be popular, and when it occur we can add type hint to make it compile.

However, having a solution would be better.


Some additional examples:

/// Current Rust
set_visible(view, if condition() { Visible } else { Gone });
set_visible(view, 
    if condition() { 
        Visible 
    } else { 
        Gone 
    });
set_visible(view, if condition() { 
        Visible 
    } else { 
        Gone 
    });

/// Proposal
set_visible(view, condition() && Visible or Gone);
set_visible(view, 
    condition() && Visible 
                or Gone);
set_visible(view, condition() && Visible 
                              or Gone);
/// Current Rust
let x = if y { 0 } else { 1 };
let an_option = if condition() { 
    Some(try_get_value()?) 
} else { 
    None 
};
let val = opt.ok_or_else(|| My::Error)?;

/// Proposal
let x = y && 0 or 1;
let an_option = condition() && try_get_value()? or None;
let val = (opt or Err(My::Error))?;
/// Current Rust
let name = condition().as_option().map(Something::create)
    .or_else(|| call(10).unwrap().unwrap())
    .or_else(try_alternative)
    .or(next_option)
    .or_else(|| inner_closure(|x| x + x))
    .or_else(|| a_result::<Generic>().ok())
    .or_else(|| (condition() || other_condition::<i32>()).as_opt().map(Foo::new));
    .unwrap_or_else(local_value)
    .do_something()
    .ok_or_else(|| if foobar || baz { asdfasdfa() } else { asdf() })?;
/// Proposal
let name = (
    condition() && Something::create()
    or call(10).unwrap().unwrap()
    or try_alternative()
    or next_option
    or inner_closure(|x| x + x)
    or a_result::<Generic>().ok()
    or (condition() || other_condition::<i32>()) && Foo::new()
    or local_value
)
    .do_something()
    or (foobar || baz) && asdfasdfa()?
    or asdf()?;

Drawbacks:

  • This might complicate language a bit
  • There also would be yet another way to do the same thing
  • Might confuse Python users
  • Gives impression that there’s and operator somewhere
  • Uses non-reserved keyword

Alternatives:

  • Don’t implement it
  • Use another symbol instead of or e.g. :?, else, <>
  • Extend || operator to be used in the same way as or

So you want a | b with Option types?

Not quite. I think he want a || b with Option types.

There is a difficulty as I checked that | is BitOr but there is nothing in std::ops that is ||, I think it is because it is short-circuited.

The document from std::ops:

Note that the && and || operators short-circuit, i.e. they only evaluate their second operand if it contributes to the result. Since this behavior is not enforceable by traits, && and || are not supported as overloadable operators.

However I believe the following short-circuited operations should work?

pub trait Or<RHS=Self> {
    type Output;
    fn or<F>(self, rhs: F) -> Self::Output where F: FnOnce() -> RHS;
}
pub trait And<RHS=Self> {
    type Output;
    fn and<F>(self, rhs: F) -> Self::Output where F: FnOnce() -> RHS;
}

Then we can introduce sugaring for || and &&:

a && b; // a.and(||b)
a || b; // a.or(||b) 

Or, another proposal is (requires specialization)

pub trait Or<F,RHS=Self>: BitOr<F>
where F: FnOnce() -> RHS{}
pub trait And<F,RHS=Self>: BitAnd<F>
where F: FnOnce() -> RHS{}

Then desugar as

a || b // a ||| b 
a && b // a &|| b

Per your own admission, part of the status quo’s problem is the different terminology used for combinators over Option and Result. This only works for Option and not Result (for good reason), but I think any proposition seeking to improve the status quo needs to work with any Try type.

Also, I don’t think any ternary operator is likely to work in Rust; there’s a reason that “the” ternary operator is the only ternary operator. Can you succinctly describe the associativity of “the” ternary operator?

Most cases served by “the” ternary operator c ? a : b can (in Rust) be written as if c { a } else { b }.


One change in this area I’d be somewhat interested in exploring is making if c { a } return an Option. But this wouldn’t be possible, because if c { () } is a valid ()-typed expression currently.

That shouldn't be a problem -- the only reason that .or and friends can't be provided methods on Try today is lack of GATs.

Well, one could in theory desugar if A { B } into if A { B } else { ImplicitElse::default() }. Then you still have if c { () }: (), but you have if c { Some(1) }: Option<i32>. That's certainly less nice than if c { 1 }, though, and as someone else said when this was last discussed "It hardly feels worth it when all it saves is else{None}" (paraphrased).

We can do similar (but not the same) in today’s Rust without change anything in the language. We only need to add some implementations to core.

//The space between the bit-or operator and the closure bar is necessary right now
let num: i32 = (Some(1) | || Some(2))?;

The only thing we need is

impl<F,T> BitOr<F> for Option<T>
where F: FnOnce() -> Option<T> {
    type Output=Option<T>;
    fn bitor(self, f: F) -> Self::Output {
        self.or_else(f)
    }
}

proof of concept (with newtyped Option)

1 Like

I'd say that I don't want either of them, because operator overloading is not a best way to go here. It would be surprising to lazily evaluate | and it wouldn't make sense to not evaluate it lazily because we already can use opt.or({ something() }) which is not that bad.

There is a blog post which describes all difficulties behind || and && overloading. I don't think that it makes sense to introduce something like that, especially when final result wouldn't better than or.

The problem is rather similar terminology. There is a lot of functions that exists on both Option and Result, some of them have similar signatures and some not, also there are functions that are specific for the concrete type. It's really hard to use all of that effectively, and proposed or operator could solve this problem at least partially.

Actually, this would work for Result type. On LHS of or we only need to explicitly turn it into Option by using .ok() function call to indicate that Err variant was dropped. The .ok() function as well might be moved into Try trait.

On RHS of or it should work by default e.g. option or result is a valid code that evaluates into Result.

Take a look at desugaring (below in this post), that might explain everything.
A very similar principle to ? operator is used behind (by utilizing From trait).

If I understand this correctly, or would have right to left associativity, and it would take precedence weaker than && and || operators.

  • a && b or c ---> ((a && b) or c)
  • a && b && c or d ---> (((a && b) && c) or d)
  • a || b && c or d ---> ((a || (b && c)) or d)
  • a && b or c && d or e ---> ((a && b) or ((c && d) or e))
  • a || b && c or d || e && f or g ---> ((a || (b && c)) or (d || (e && f)) or g))

The trick here would be to have a property on EXPR which indicates that && operator is inside and we should desugar it as a ternary when or will be present on the RHS.

  • a && b ---> true
  • a && b && b ---> true
  • a && b.c() ---> true
  • a ---> false
  • a + b ---> false

The last inner && separated expression would be a ternary "success" value, and the rest would be a ternary "condition", and value on RHS of or would be a ternary "fallback" value.

  • a && b or c ---> if a { From::from(b) } else { c }
  • a && b && c or d ---> if a && b { From::from(c) } else { d }
  • a && b.c() or d ---> if a { From::from(b.c()) } else { d }

In a case when && is missing, desugaring would be a bit different

  • a or b ---> match Or::into_option(a) { Some(o) => From::from(o), None => b }
  • a + b or c ---> match Or::into_option(a + b) { Some(o) => From::from(o), None => b }

I hope this explains everything under hood.

This could be considered as alternative to the common ternary operator because it can't be implemented in Rust due to syntax constraints, but I don't think that this could be considered as alternative to ternary operator from my proposal:

  • It looks ugly when something like if a { b } else { c } is not written in multiline style.
  • It can't infer return type and we must write boilerplate like if a { Some(b) } else { None }
  • There is else if construct which makes it more cumbersome as it should be
  • It feels more like imperative control flow operator

Just compare:

let x = if a { b } else { c };
let x = a && b or c;

let y = if a { 
    Some(b) 
} else { 
    None 
};
let y = a && b or None;

let z = if a { 
    b 
} else if c { 
    d 
} else { 
    f 
};
let z = a && b 
    or c && d 
    or f;

(ocaml syntax highlighting was applied for this example)

It seems too implicit to me because there's nothing that indicates an Option type. In my proposal there is or None part, which makes it explicit.

This could be better, but there's nothing to indicate that default value would be evaluated, and we must know which value is default. My proposal doesn't have such drawbacks.

Well, speaking of ?, we could spitball using the Try trait for A || B:

if let Ok(v) = Try::into_result(A) {
    Try::from_ok(v),
} else {
    B
}

This would be consistent with the current bool behaviour given an implementation like this:

struct True;
struct False;
impl Try for bool {
    type Success = True;
    type Error = False;
    fn into_result(self) -> Result<Self::Ok, Self::Error> {
        if self { Ok(True) } else { Err(False) }
    }
    fn from_error(False: Self::Error) -> Self { false }
    fn from_ok(True: Self::Ok) -> Self { true }
}

(Without saying whether that implementation would be a good idea.)

Would work for A && B too:

if let Err(v) = Try::into_result(A) {
    Try::from_error(v)
} else {
    B
}

That doesn't prohibit Some(1) && Some(2), though, which loses the 1 and is thus kinda weird. So people would probably rather try{(A?,B?)}, or similar, instead.

1 Like

Or require A: BitAnd<B> and implement a && b as

if let Err(v) = Try::into_result(a) {
    a & b?
} else {
    b
}

Why would if let Some(x) = opt { exists } else { missing } be an overkill? Fallible pattern matching and if are exactly suited for solving this kind of problem, that snippet does nothing more and nothing less. Rust doesn't need a conditional operator exactly because if is already an expression; there would be no added value of some basically redundant syntax for the same semantics.

I would even argue that people would start abusing it for the "virtue" of "more compact" code as is often the case in languages that have this operator (eg. C). This, against their intentions, mostly leads to more dense and actually less readable code.

10 Likes

I don't really see the point in using a Try type for null coalescing. However, something very similar to this could be a way to go for && and || operators overloading which would be orthogonal from null coalescing and therefore more suitable to be used in DSLs instead.

match LazyAnd::into_result(A) {
    Ok(v) => LazyAnd::with_right(v, B),
    Err(v) => LazyAnd::from_left(v),
}

match LazyOr::into_result(A) {
    Ok(v) => LazyOr::from_left(v),
    Err(v) => LazyAnd::with_right(v, B),
}

I don't know how that would be useful, and looks like this going a bit off topic...

Nope, that snippet defines:

  1. Imperative if statement
  2. Temorary let binding assignment
  3. A non exhaustive pattern matching

This is definitely an overkill when everything you want is just: opt or fallback. A current state of null handling in Rust reminds me of notorious Go error handling - it's dumb straightforward but unfortunately it's cumbersome and not user friendly.

Actually, you missed the point where or operator would have a different semantics and where it would add value. Is the same to say that Rust doesn't need a ? operator exactly because match result { Err(e) => return e, Ok(x) => x } already does the same thing, and there would be no added value of some basically redundant syntax for the same semantics.

People abuse ternary operator not because it makes code "more compact" but because it's single available expression for them. Some things definitely could be more readable with the use of Rust-style if expression, but it's just not available and programmers must decide between two evils where ternary is not always worst.

In this sense Rust is not very better: it don't provides a usual ternary, and we must abuse if to achieve our goals, which also leads into code that's more complex than it should be.

But this problem would gone when users will have freedom to choose a proper tool for their task.

In this sense Rust is not very better: it don’t provides a usual ternary, and we must abuse if to achieve our goals, which also leads into code that’s more complex than it should be.

But this problem would gone when users will have freedom to choose a proper tool for their task.

The usual ternary does the same thing if does, so I fail to see why if is more complex or how it is 'abuse' to use it for the task it is meant to be used for. What is the actual problem being solved? "I wish it had different syntax" isn't really a problem, it's just an opinion.

Is the same to say that Rust doesn’t need a ? operator exactly because match result { Err(e) => return e, Ok(x) => x } already does the same thing, and there would be no added value of some basically redundant syntax for the same semantics.

It is the same. That syntax is redundant. Just because one redundant syntax exists doesn't mean there's a reason for any other redundant syntax to exist; ? was motivated for other reasons -- because try! was unpleasant in prefix form, and because match is way too verbose for a task that common. if doesn't have any of these problems.

Nope, that snippet defines: [...]

This is definitely an overkill when everything you want is just: opt or fallback .

If you want 'opt or fallback', if condition() {opt} else {fallback} could not be a more apt way to express it. With condition() && opt or fallback, it's not at all clear what the precedence is or what it is doing wrt lazy eval and branching. That ambiguity is far less user friendly than the common if statement is.

trait Or<T> {
   fn into_option(self) -> Option<T>; 
}

Isn't this just the Some function? Why does it need a trait? Ah, I see. I'm a dummy. :stuck_out_tongue: But still.. this looks like it is Into<Option>.

Imperative if statement

Aside: if is not imperative.

3 Likes

A ternary does the same thing but in a different way which is better suitable for specific task.

A primary task of a ternary operator is to provide a fallback value as well as primary task of if expression is to split a control flow. It's abuse when we use a ternary operator for splitting control flow as well as when we use if expression for providing a fallback value.
It's abuse when we ignore the fact that a more suitable tool exists and choose something that could bring the same result ignoring whole negative impact.

I don't know what you mean under actual problem. Overall, this improves usability of language. How and where - that's listed in topic.

It is the same and that syntax is redundant only in a perspective of achieving the same results. But not only results matters, also matters how we achieve them. And from that perspective ? operator was a very huge step forward.

I wouldn't agree.
if also has prefix which is very unpleasant before simple conditionals, plus it has else word on the middle, plus it has two pairs of braces (which should be a clear indicator that we use it in a wrong way).
if in a ternary conditional and null handling context is also very commonly used, and I'd not call that a pleasant experience, since it's not very better than match in terms of verbosity.

Therefore, we have the same annoying pattern and the same solution.

All of that already was described in this answer. And why it should be a problem when similar operators for years exists in other languages?

It's not, since providing a overloadable operator that would be backed by Into<Option> would be really surprising.

I'd say that its nature depends on context where and how it's used

1 Like

A distinction without a difference, if you ask me. "A tree is for decorationg a yard" and "a tree is for chopping down to get wood" are both true things, but they are true in the sense that you're imprinting your own moral purposes onto a thing which has no fundamental purpose, -- it only has behavior. That you prefer to use a ternary rather than an if is your moral bias at work, because there is no fundamental difference in how they behave. One can open a can of paint with a screwdriver, but that doesn't mean anything wrong has happened. Use your tools to solve your problems rather than trying to give them some higher purpose.

Overall, this improves usability of language.

That's debatable. In fact, that's why there is a debate happening right now. You can't skip past it by just asserting it is true, you have to make a convincing argument.

It’s not, since providing a overloadable operator that would be backed by Into<Option> would be really surprising.

Why would it be surprising? I mean, it would be surprising because this syntax is confusing, sure, but why would it be more surprising to use an existing suitable trait rather than a brand new redundant one? Surprise being a function of expectation, why should anybody expect a brand new syntax to introduce a new trait to do something that an existing trait already does?

6 Likes

Adding new meaning to existing trait impls is scary.

Adding new language meaning to existing trait impls of a library trait is terrifying.

I care basically zero for this proposal, which is why I haven't said much; but it is unfathomable that it should be backed by anything other than brand new traits in core::ops.

2 Likes

I fail to see how it's "imperative". if in Rust is always an expression, so you don't have to go all imperative and e.g. introduce a temporary variable (which is a frequent workaround in other languages).

I don't see how the pattern match is non-exhaustive either (Option can only be Some or None), or even if it is (and by that you mean that the None case is not explicit), why that is a problem, since it works correctly anyway.

Again, if has all the features of a conditional expression. It's a fully-fledged first-class expression, you can use it in any expression-like context, and it evaluates exactly one of its arms depending on its condition. There's nothing abusive in applying it for… well, pretty much what it was designed to do.

I get that, but this is a breaking syntax change already. No existing code uses or like this, and nobody would be really be introducing it without knowing it is sugar for Into<Option>. (In other words, this wouldn’t be a new meaning for Into<Option>. It is just a new way to call it.)

What I mean is, what of all the existing impls of Into<Option<T>> in the world?

What if some of them are things that should not have the behavior provided by this feature?

What if some of them overlap with impls that people would want to write for this feature?

3 Likes

That could pose problems in theory, but in practice I don't see it being a problem. It is probably more of a problem that it wouldn't live in std::ops as it should.

What if some of them overlap with impls that people would want to write for this feature?

Wouldn't the orphan rules prevent anybody from relying on other implementations in the first place?