[Pre-RFC] Elvis/coalesce + ternary operator

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?

I meant this can happen even within a crate. Into is such an extremely vague and general purpose trait. A lot of people use Into in their own crates for their own random reasons. Some of these people might also want to use or on an overlapping set of types.

1 Like