Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow Overloading || and && #2722

Open
wants to merge 3 commits into
base: master
from

Conversation

@Nokel81
Copy link
Contributor

commented Jul 11, 2019

This is an rfc for allowing the user-of-rust to overload || and && for their own types. And adding various implementations for Option<T> and Result<T, E>

@KrishnaSannasi

This comment has been minimized.

Copy link

commented Jul 11, 2019

Rendered

@comex

This comment has been minimized.

Copy link

commented Jul 11, 2019

    /// Decide whether the *logical or* should short-circuit
    /// or not based on its left-hand side argument. If so,
    /// return its final result, otherwise return the value
    /// that will get passed to `logical_or()` (normally this
    /// means returning self back, but you can change the value).
    fn short_circuit_or(self) -> ShortCircuit<Self::Output, Self>;

If we're going to have such an elaborate setup with an intermediate value, might as well allow the intermediate value to be a different type, so that short_circuit_or can provide arbitrary information to logical_or. Something like:

trait ShortCircuitOr<Rhs = Self>: Sized {
    type Intermediate: LogicalOr;
    fn short_circuit_or(self) -> ShortCircuit<Intermediate::Output, Intermediate>;
}
trait LogicalOr<Rhs = Self>: Sized {
    type Output;
    fn logical_or(self, rhs: Rhs) -> Self::Output;
}
@KrishnaSannasi

This comment has been minimized.

Copy link

commented Jul 11, 2019

@Nokel81 Please provide the full implementation of the traits for Option<T> and Result<T, E> in the "Reference-level explanation" section

Also, shouldn't we follow precedent of Option::and and Option::or to allow Option<T> && Option<U> and similarly for Result<T, E>

@comex We can simplify to

trait LogicalOr<Rhs = Self>: Sized {
    type Output;
    type Intermediate;
    
    fn short_circuit_or(self) -> ShortCircuit<Self::Output, Self::Intermediate>;
    fn logical_or(intermediate: Self::Intermediate, rhs: Rhs) -> Self::Output;
}

We don't need two traits, that seems like needless bloat. If you don't want short-circuiting behavior then you should overload & and | instead of && and ||

@comex

This comment has been minimized.

Copy link

commented Jul 11, 2019

@KrishnaSannasi Good point, I like that better.

@Nokel81

This comment has been minimized.

Copy link
Contributor Author

commented Jul 11, 2019

Ah so, just have a short circuit trait? If that is the case, then why have the custom intermediate type?

Also, as mentioned in the rfc we discussed auto traits which is not how rust does things (Add is not required for AddAssign)

@KrishnaSannasi

This comment has been minimized.

Copy link

commented Jul 11, 2019

Ah so, just have a short circuit trait? If that is the case, then why have the custom intermediate type?

The intermediate type allows us to minimize coupling between the two functions, for example we could implement LogicalOr for bool like so

pub struct BoolShortCircuitFailure(());

impl LogicalOr for bool {
    type Output = bool;
    type Intermediate = BoolShortCircuitFailure;
    
    fn short_circuit_or(self) -> ShortCircuit<Self::Output, Self::Intermediate> {
        if self {
            ShortCircuit::Short(true)
        } else {
            ShortCircuit::Long(BoolShortCircuitFailure(()))
        }
    }
    
    fn logical_or(_: Self::Intermediate, rhs: Rhs) -> Self::Output {
        rhs
    }
}

Here since ShortCircuitBool is zero-sized and can only be made by bool, we can force short-circuit behavior and make the second step more efficient/easier to optimize by eliding checks.

For comparison, here is the implementation for bool with the current LogicalOr

impl LogicalOr for bool {
    type Output = bool;
    
    fn short_circuit_or(self) -> ShortCircuit<Self::Output, Self> {
        if self {
            ShortCircuit::Short(true)
        } else {
            ShortCircuit::Long(false)
        }
    }
    
    fn logical_or(self, rhs: Rhs) -> Self::Output {
        assert!(!self, "Failure to use `short_circuit_or` before calling `logical_or` is a bug");
        rhs
    }
}

The assert is technically unnecessary, but will catch bugs. But if we used an intermediate type, we can statically prevent these kinds of bugs and elide these checks.

# Guide-level explanation
[guide-level-explanation]: #guide-level-explanation

This proposal starts with an enum definition and trait definitions for each of the operators:

This comment has been minimized.

Copy link
@scottmcm

scottmcm Jul 12, 2019

Member

I don't think this is the appropriate start for a guide-level explanation. I think this section should look substantially more like the ? description in the book: describe a common need, describe how it can be done manually with if let, then describe how it's handled using the ||/&& operators to be more concise. I think this section should also emphasize the parallel with booleans -- how foo() && bar() is if foo() { true } else { bar() } and how that's the same pattern in the if lets seen here.

Some examples of the parallel are in IRLO. Maybe use an example about how you can just say i < v.len() && v[i] > 0 instead of if i < v.len() { false } else { v[i] > 0 }.

(It would probably not mention the trait definitions at all.)

/// Complete the *logical or* in case it did not short-circuit.
/// Normally this would just return `rhs`.
fn logical_or(self, rhs: Rhs) -> Self::Output;

This comment has been minimized.

Copy link
@scottmcm

scottmcm Jul 12, 2019

Member

These definitions seem to discard information, so seem like they'd be less than optimal when writing an impl.

For example, if short_circuit_or returns Short for an Ok, then logical_or still gets passed the whole Result, and would need to do unimplemented!() or something in the Ok arm instead of only being passed the Err part.

If it were, instead, -> ShortCircuit<Self::Short, Self::Long>, then it could be logical_or(Self::Long, Rhs). But of course then short_circuit becomes exactly the same as Try::into_result...

This comment has been minimized.

Copy link
@Nokel81

Nokel81 Jul 15, 2019

Author Contributor

If we have a distinct long type (intermediate) then the short_circuit for Result would still just be rhs.

@comex

This comment has been minimized.

Copy link

commented Jul 12, 2019

Well, I was thinking that determining whether to short circuit might require performing some expensive calculation, which might involve generating intermediate values which could be reused in determining the final output value.

...One might argue that that use case is uncompelling because having || perform an expensive calculation is a code smell anyway.

But given that the design has an intermediate value, it's a bit strange to require it to be the same type as Self for no real reason. Especially with @KrishnaSannasi's version that doesn't require a whole other trait to allow the customization.

2. Could lead to similarities to C++'s Operator bool() which => truthiness and is undesirable.

# Rationale and alternatives
[rationale-and-alternatives]: #rationale-and-alternatives

This comment has been minimized.

Copy link
@scottmcm

scottmcm Jul 12, 2019

Member

Two more things I'd like to see discussed in here

  • Why the method to allow combining the sides, vs just something like -> Option<Rhs>?

  • Why two traits, vs using one method that splits into the two parts, one kept on && and the other kept on ||

This comment has been minimized.

Copy link
@Nokel81

Nokel81 Jul 15, 2019

Author Contributor

I don't understand the two traits question. Is it really necessary to discuss why each operator should have its own trait?

@Centril

This comment has been minimized.

Copy link
Member

commented Jul 12, 2019

How is thing RFC going to be compatible with rust-lang/rust#53667 given that you want to desugar && to a match and then dispatch to traits? Presumably this will interfere with the compiler's ability to understand if let Some(x) = foo() && bar(x) { as introducing bindings in x. In particular, the proposal here is to lower && to match which means that you cannot make semantic choices such as for let ps = e based on type information (e.g. bool) of the LHS and RHS. Moreover, using match + dispatch to trait methods seems like the sort of thing that would regress compile time performance non-trivially.


(I also think that allowing Some(2) || 1 is rather semantically strange.)

@Centril

This comment has been minimized.

Copy link
Member

commented Jul 12, 2019

Another more meta point...

...How does this fit into the roadmap?

@scottmcm

This comment has been minimized.

Copy link
Member

commented Jul 12, 2019

(I also think that allowing Some(2) || 1 is rather semantically strange.)

@Centril In the sense of "nobody would ever write that" or in the sense of "I don't think || 0 is a good way to provide a default value for an Option<i32>"?

As for #53667, doesn't it also understand the scoping of bindings by desugaring the control flow? Is there an indication that this control flow desugar would be different from that one? (Also, the compiler can special-case && for bool the same way it special-cases + for i32 and [] for arrays.)

@KrishnaSannasi

This comment has been minimized.

Copy link

commented Jul 12, 2019

@Centril I thought that

if let Some(x) = foo() && bar(x) {

would desugar to something like

if let Some(x) = foo() {
    if bar(x) {
    }
}

Because of the let ... binding's desugaring would take precedence over the normal && desugaring.

Moreover, using match + dispatch to trait methods seems like the sort of thing that would regress compile time performance non-trivially.

Like @scottmcm said, we could special case certain types (bool, maybe others) to improve compile times, so I don't see this as a big issue.

(I also think that allowing Some(2) || 1 is rather semantically strange.)

I find this just as strange as allowing true || false, so I'm curious as to why you think that it is strange.

@Nokel81

This comment has been minimized.

Copy link
Contributor Author

commented Jul 12, 2019

Also, shouldn't we follow precedent of Option::and and Option::or to allow Option<T> && Option<U> and similarly for Result<T, E>

Option<T>::or does not allow calling with Option<U>. However, since and does, then yes we should

@Nokel81

This comment has been minimized.

Copy link
Contributor Author

commented Jul 12, 2019

@KrishnaSannasi As for the having a "short circuit" trait and then relying on the & and | does not follow from the rest of the operator precidents. Add does not automatically imply AddAssign.

@Nokel81

This comment has been minimized.

Copy link
Contributor Author

commented Jul 12, 2019

How is thing RFC going to be compatible with rust-lang/rust#53667 given that you want to desugar && to a match and then dispatch to traits? Presumably this will interfere with the compiler's ability to understand if let Some(x) = foo() && bar(x) { as introducing bindings in x. In particular, the proposal here is to lower && to match which means that you cannot make semantic choices such as for let ps = e based on type information (e.g. bool) of the LHS and RHS. Moreover, using match + dispatch to trait methods seems like the sort of thing that would regress compile time performance non-trivially.

(I also think that allowing Some(2) || 1 is rather semantically strange.)

This being strange because it is an odd way to provide a default value?

Update text/0000-overload-logic-operators.md
Co-Authored-By: kennytm <kennytm@gmail.com>
@kennytm

This comment has been minimized.

Copy link
Member

commented Jul 12, 2019

(I also think that allowing Some(2) || 1 is rather semantically strange.)

I find this just as strange as allowing true || false, so I'm curious as to why you think that it is strange.

The issue is that having both (Option<T> || Option<T>) → Option<T> and (Option<T> || T) → T is semantically strange. I don't see how true || false ((bool || bool) → bool) is relevant.

@KrishnaSannasi

This comment has been minimized.

Copy link

commented Jul 12, 2019

As for the having a "short circuit" trait and then relying on the & and | does not follow from the rest of the operator precidents. Add does not automatically imply AddAssign.

That's not what I meant, I meant that if someone wanted to not use short-circuit behavior they should use & and |, and not use && and ||.

@Centril

This comment has been minimized.

Copy link
Member

commented Jul 12, 2019

@Centril In the sense of "nobody would ever write that" or in the sense of "I don't think || 0 is a good way to provide a default value for an Option<i32>"?

Neither of those reasons really.

I find this just as strange as allowing true || false, so I'm curious as to why you think that it is strange.

I think that a binary operator like this taking an expression of a different type is peculiar and surprising. I'm aware that + et. al allows Rhs to be differently typed but that's mostly to allow similar-ish types and not something entirely different like T as compared to Option<T>. @kennytm also echoes my sentiments here.

Also, the compiler can special-case && for bool the same way it special-cases + for i32 and [] for arrays.)

Like @scottmcm said, we could special case certain types (bool, maybe others) to improve compile times, so I don't see this as a big issue.

This special casing would need to happen after you have type information. Right now, it is simply assumed in fn check_binop that each sides of the operands are coercible to bool rather than using overloading. However, type checking match also happens in the same phase of the compiler. If you want to use match then you'll need to do a desugaring in fn lower_expr which is before the type information you need is available. To get around this you would need to avoid lowering to match and instead insert special logic into the type checker instead to handle it as the other overloaded operators are. This would presumably be substantially more complicated.

As for #53667, doesn't it also understand the scoping of bindings by desugaring the control flow? Is there an indication that this control flow desugar would be different from that one?

@Centril I thought that

if let Some(x) = foo() && bar(x) {

would desugar to something like

if let Some(x) = foo() {
    if bar(x) {
    }
}

Because of the let ... binding's desugaring would take precedence over the normal && desugaring.

Currently the compiler has an ast::ExprKind::Let to encode let ps = e syntactically. However, this is not really the issue.

Rather, the problem here is drop order. More specifically, an expression a && b && c associates as (a && b) && c but we drop temporaries here as b c a and not a b c. Moreover, if $cond { ... } and while $cond { ... } have the particular semantics that they make the $cond a terminating scope. Because of these things combined, if you take something like if a && b and lower it to match as above then you will alter the drop order of existing things to a b c instead (which is breaking) which I currently believe is necessary to have bindings work. If you instead desugar (a && b) && c as desugar(desugar(a && b) && c) I think you would instead not have bindings work. (Here is a few examples wrt. what happens with the drop orders, https://play.rust-lang.org/?version=nightly&mode=debug&edition=2018&gist=1170f09002025c40d77cd017717e1424) My current theory is therefore that if + let + && are not actually implementable with match + DropTemps and that hir::ExprKind::{Let, If} is needed. (Sorry about the dense implementation oriented description, but I don't have the time right now to elaborate in a more high-level fashion.)

At minimum I think the implementation of let_chains needs to be finished before starting and accepting any work on this RFC.

@burdges

This comment has been minimized.

Copy link

commented Jul 20, 2019

We already create bugs when people intermix, migrate, etc. bool and Result/Option in interfaces. We should expect more bugs from anything that makes the tooling for bool less visually distinct from Result/Option, including bugs due to refactoring.

As an example, if an fn changes from -> bool to -> Option<&'static str>, then you must reevaluate which &'static str get handled and how, but this proposal aborts type aided refactoring prematurely. These sort of messes are why Perl is considered a write once language.

@timvermeulen

This comment has been minimized.

Copy link

commented Jul 20, 2019

Result and Option already have lots of APIs in common. Is refactoring between the two already a common cause of bugs? I honestly don't know, but if it's not, then what makes this different?

Claiming that similar APIs may lead to confusion seems to me like a poor argument not to make abstractions.

@burdges

This comment has been minimized.

Copy link

commented Jul 21, 2019

Result and Option have many methods with truely identical semantics, like map or unwrap_or, but often their types differ, like map_or_else or unwrap_or_else or and_then, and the names differ when appropriate, like is_*, ok*, map_err, etc.

I never considered that && and || for Result and Option would create errors when refactoring between Result and Option, but now they you mention it yes they'd would cause such errors.

@Pauan

This comment has been minimized.

Copy link
Member

commented Jul 21, 2019

@burdges Any time the type of a function changes, all callers of that function need to check to make sure that they are still correct with regard to the new type.

That has nothing at all to do with bool, Option, Result, &&, or ||, it's just a general principle. The same thing happens with many other types, and many other operations.

For example, if a function returns u32 but then later changes to return u8, all callers must manually verify that they are correct, because a caller might have used +, which wouldn't have overflowed with u32 but now overflows with u8.

Rust is great for refactoring, but it cannot magically find all refactoring hazards (or find all bugs). That burden still ultimately falls on the programmer.

So why do you consider Option <-> bool worse than, say, u32 <-> u8? If anything I would consider it better, since Option and bool share fewer methods, so there's much less risk of confusion.

@gnzlbg

This comment has been minimized.

Copy link
Contributor

commented Jul 23, 2019

This would be useful for SIMD vectors as well, where we currently need to write x.and(y) . These do never short circuit, but the proposal does not appear to allow encoding that at the type level, e.g. by using ! as the short argument to ShortCircuit.

@Nokel81

This comment has been minimized.

Copy link
Contributor Author

commented Jul 23, 2019

@gnzlbg That would probably be equivalent to x & y which is currently possible using BitAnd

@nox

This comment has been minimized.

Copy link
Contributor

commented Jul 29, 2019

This proposal is mostly about reducing the mental strain when chaining Option<T>'s and Result<T, E>'s. But has a very nice side effect of allowing users-of-rust the ability to overload these operators.

I would have way way more of a mental strain if && and || also applied to non-boolean types.

@Nokel81

This comment has been minimized.

Copy link
Contributor Author

commented Jul 29, 2019

@nox I disagree but given that maybe people seem to think so it should not be ignored.

I would like to ask, however, if any sort of short circuit operator would be okay by you?

Such as the following on Option<T>:

let a = get_arg::<i32>("a") ?? 0;

Given fn get_arg<T>(name: &str) -> Option<T>

@nox

This comment has been minimized.

Copy link
Contributor

commented Jul 30, 2019

This is ok by me:

let a = get_arg::<i32>("a").unwrap_or(0);
@Nokel81

This comment has been minimized.

Copy link
Contributor Author

commented Jul 30, 2019

I see. However, it is obvious (to me at least) that some people find that too verbose and annoying that we either have to pre-compute, use a closure (so no control flow changing), or use an if-let or some hand made macro.

I also don't think that Rust should limit the operators it allows to overload.

@OvermindDL1

This comment has been minimized.

Copy link

commented Jul 30, 2019

@nox In your example, what if the unwrap_or needed to take an expression that performs side effects, or is costly to compute? In your example it takes a value, so that would have to be performed every time. What if you wanted to early-out of the entire parent function with a return or so? For these considerations you can't use a value and you can't use a closure, so what would you use that would fit reasonably on a single line? I think that's the main issue that people are trying to solve here.

@H2CO3

This comment has been minimized.

Copy link

commented Jul 30, 2019

that some people find that too verbose and annoying

With something as simple as .unwrap_or(default_value), calling the construct "too verbose and annoying" seems almost absurd to me.

I also don't think that Rust should limit the operators it allows to overload.

There are great reasons for not allowing operators to be overloaded – and very often "leads to unpleasant surprises" is one of them. For example, . is not overloadable either – how would one access the direct fields of a struct that overloaded ., for example? And just how does one define overloading an operator that takes a compile-time identifier as one of its arguments anyway?

Operator overloading can help with writing (and sometimes reading) convenience as long as it's kept to trivial stuff. But excess operator overloading is historically known to be a huge source of confusion in languages that don't impose any or enough restrictions on it (or even allow programmers to define their own sigils, which is a whole other can of worms on its own).

@H2CO3

This comment has been minimized.

Copy link

commented Jul 30, 2019

@nox In your example, what if the unwrap_or needed to take an expression that performs side effects, or is costly to compute? In your example it takes a value, so that would have to be performed every time. What if you wanted to early-out of the entire parent function with a return or so? For these considerations you can't use a value and you can't use a closure, so what would you use that would fit reasonably on a single line? I think that's the main issue that people are trying to solve here.

Great, but that's the minority. I literally never encountered a single case during my Rust programming career where .unwrap_or_else impeded my ability to perform an operation on an Option or a Result.

If your logic is so complicated that it requires multiple layers of control flow, you will be better of typing it out explicitly or extracting it into a separate function anyway.

@OvermindDL1

This comment has been minimized.

Copy link

commented Jul 30, 2019

<off-topic>

There are great reasons for not allowing operators to be overloaded – and very often "leads to unpleasant surprises" is one of them. For example, . is not overloadable either – how would one access the direct fields of a struct that overloaded ., for example? And just how does one define overloading an operator that takes a compile-time identifier as one of its arguments anyway?

Isn't that what traits like AsRef and DeRef and so forth essentially do?
</off-topic>

Operator overloading can help with writing (and sometimes reading) convenience as long as it's kept to trivial stuff. But excess operator overloading is historically known to be a huge source of confusion in languages that don't impose any or enough restrictions on it (or even allow programmers to define their own sigils, which is a whole other can of worms on its own).

Coming from the C++/OCaml/Perl/Python worlds, operator overloading for short-circuiting operators has never been a confusing thing, the types make it especially obvious since it is a strongly-typed language. Although OCaml's scoped opens make using them as DSEL's significantly more simple, though Rust macro-by-example's and procedural macros can replace that functionality, though a bit heavy handed.

Great, but that's the minority. I literally never encountered a single case during my Rust programming career where .unwrap_or_else impeded my ability to perform an operation on an Option or a Result.

That's surprising then, I'm quite used to performing side-effecting and early return operations as short circuiting operations in quite a variety of languages.

If your logic is so complicated that it requires multiple layers of control flow, you will be better of typing it out explicitly or extracting it into a separate function anyway.

Not multiple layers, rather it's the different between a simple conditional and return a value or jump out of the function, or a Go-like multi-line error handling block for trivial repetition. For my uses this is primarily in other API's and not things like 'Result', but even in Result/Option || is just as clear if not more so (shorter to read and mentally 'parse' while following the same pattern) than unwrap_or_else, compare let _ = thing || calculate_expensive_default(); to let _ = thing.unwrap_or_else(calculate_expensive_default);. The latter being far more 'dense' for the same information, harder to read, etc...

@H2CO3

This comment has been minimized.

Copy link

commented Jul 30, 2019

Isn't that what traits like AsRef and DeRef [sic!] and so forth essentially do?

No. AsRef doesn't overload anything (it's literally just a library-defined trait), while Deref overloads prefix unary *. Deref coercions are applied at certain points in the code, but that only works because (and where) autoref and auto-deref are involved in e.g. method and field resolution. Therefore you can't do arbitrarily clever stuff with Deref this way (for example, you can't return a different type for different field names), and even the more clever of what you can do (for instance emulating "inheritance") is strongly discouraged and considered non-idiomatic (thank goodness).

Coming from the C++/OCaml/Perl/Python worlds, operator overloading for short-circuiting operators has never been a confusing thing, the types make it especially obvious since it is a strongly-typed language.

Let's call it a draw, then. I've been programming in C++ for almost a decade now and I'm highly proficient in it, but I always cringe when someone invents yet another use case for the almighty << operator instead of making it a method with a descriptive name. In Python, I think it's even worse, because operators aren't even overloaded separately, type-by-type (speaking of the RHS); instead of just reading the declarations, you literally have to read the implementation of the corresponding magic method in order to figure out what the possible types of the RHS are. (Incidentally, what do you mean by Python fitting into the strongly typed family of C++ and OCaml?)

That's surprising then, I'm quite used to performing side-effecting and early return operations

I also use early returns all the time. I find that e.g. handling errors this way is nice because it allows the main code path to follow the "happy case". What I don't find is that I would need overloaded && or || for this.

compare let _ = thing || calculate_expensive_default(); to let _ = thing.unwrap_or_else(calculate_expensive_default);. The latter being far more 'dense' for the same information, harder to read

Then we simply seem to disagree here. I'm very well used to working with combinators and higher-order functions in general; sometimes (but not very often) I even actively refactor my code so that it builds up a pipeline or tree of higher-order functions acting as a "decorator" because it seems more elegant to separate the part of the code that does one single thing from the part that slightly modifies how it does that thing.

@Pauan

This comment has been minimized.

Copy link
Member

commented Jul 30, 2019

@H2CO3 But excess operator overloading is historically known to be a huge source of confusion in languages that don't impose any or enough restrictions on it (or even allow programmers to define their own sigils, which is a whole other can of worms on its own).

Rust already has a long list of operators which can be overloaded, overloading operators is not a new or unusual thing in Rust, it's quite normal.

There's very few operators which can't be overloaded, and there's been discussions about adding in more overloads (e.g. DerefMove).

Regardless of your personal preference (or other language's experiences), I think the overloading ship has sailed, so that's not a great argument.

And just how does one define overloading an operator that takes a compile-time identifier as one of its arguments anyway?

There's an RFC for that (which is for safely projecting through Pin, but could be used for other purposes).

Having the ability to treat fields as a first-class (or second-class) value is pretty useful in general, because Rust's borrow checker allows disjoint borrows on fields.

@OvermindDL1

This comment has been minimized.

Copy link

commented Jul 30, 2019

No. AsRef doesn't overload anything (it's literally just a library-defined trait), while Deref overloads prefix unary *.

Yes, but I meant 'conceptually', "essentially" was the wrong word to use. Conceptually it gives similar capabilities.

Let's call it a draw, then. I've been programming in C++ for almost a decade now and I'm highly proficient in it, but I always cringe when someone invents yet another use case for the almighty << operator instead of making it a method with a descriptive name.

For about 25 years here as well (only picked up Rust within the past year).
Honestly most of the cases of operator overloading like that would easily be fixed by a pipe operator, general pipe operator usage is something like (not necessarily these 'operators' themselves, but the concept):

something
|> a_function(42)
|> another_fun(16, _)
etc...

Essentially a pipe operator, like |> transforms the above code to become:

another_fun(16, a_function(something, 42))

Most uses of operator overloading could easily be put into a more readable piping style, and with Rust's ability for traits to add . fields to essentially anything else, the . in Rust basically 'becomes' the pipe operator (although without arbitrary placement via a placeholder like _ above).

However, this pattern doesn't work for short-circuiting operators, which are in a unique class entirely.

(Incidentally, what do you mean by Python fitting into the strongly typed family of C++ and OCaml?)

Python is a strongly typed language, it is not statically typed, but it is strongly typed. However, I wasn't listing it as that for that purpose, just rather a language that I've used a lot in the past 30+ years.

But operator overloading in it does allow for quite a variety of useful and readable DSEL's to be created (which again Rust doesn't necessarily need). The Python community, like the OCaml community (less so about C++) tend to forgo operator overloading unless it conveys a domain-specific readability advantage, especially in math, Python is famously used for its scientific data processing libraries, heavily because of the operator overloading used in that domain, which it makes sense to use.

I also use early returns all the time. I find that e.g. handling errors this way is nice because it allows the main code path to follow the "happy case". What I don't find is that I would need overloaded && or || for this.

It keeps a lot of escape paths from polluting the 'Happy path', where a branched conditional (if/match/etc) cause multiple lines to take effect. This is the main reason Python has the inverted if, even though it does invert the failure case to be first in many situations (which is why I'm not particularly a fan of it, though it does increase readability in most cases).

Then we simply seem to disagree here. I'm very well used to working with combinators and higher-order functions in general; sometimes (but not very often) I even actively refactor my code so that it builds up a pipeline or tree of higher-order functions acting as a "decorator" because it seems more elegant to separate the part of the code that does one single thing from the part that slightly modifies how it does that thing.

Same patterns here, especially with my ocaml work, but that does not mean it is less noisy than the short circuit operators in many situations. For non-short circuit functionality, such as passing in functions, I will use the pipe operator (built in to OCaml's Pervasives) for most functionality, but that still fails in short circuiting cases without needing to make potentially a multitude of functions to pass in alternate conditional cases, which significantly clutters the code.

But I do entirely agree, when you have a chainable operation such as |> or Rust's . then most operator overloading is quite worthless (again, until you start getting into Domain specific fields, especially in regards to the sciences and maths), but short circuiting is still a different thing altogether.

@OvermindDL1

This comment has been minimized.

Copy link

commented Jul 30, 2019

Honestly, if Rust had a shortened Closure definition type, such as able to define a function as taking a lazy argument, then the argument expression is passed in as a closure instead automatically, would resolve this short circuiting issue as well. Perhaps given a function like:

fn some_func(p: i32, i: lazy i32) { // Maybe `lazy i32` should be something like `Lazy() -> i32`?
  if p >= 0 {
    dbg!(i); // Maybe `i` should be `i()`?
  }
}

Then all of these could/would work:

some_func(12, 42);
some_func(-16, 42);
some_func(4, potentially_something_expensive());
some_func(-86, potentially_something_expensive());
some_func(67, {
  printf!("I'm logging if I'm called");
  a_function()
});
some_func(-18, {
  printf!("I'm logging if I'm called, which is not happening this time");
  a_function()
});
some_func(14, normal_lambda);

Then the want for short circuiting operators vanishes as well. This would be easy to optimize, and there is precedence in a variety of languages, the most recent mainstream language would be Kotlin, which has functionality just like this (with the ability to pass in arguments to the passed in closure, or 'block' as kotlin calls it).

If there is a PR that adds such a capability, I would see no need to have this PR any further.

Essentially I'd imagine some_func being compiled the same as something like this nowadays:

fn some_func<L>(p: i32, i: L)
    where L : Fn() -> i32 {
  if p >= 0 {
    dbg!(i());
  }
}

Can have variants that take arguments as well. But essentially when called it could be called with a lambda, or with an expression that gets packaged into a lambda, so even for the most simple some_func(12, 42); case it would essentially become some_func(12, || 42);.

But as with both (potential) PR's they are of course just ease-of-use fluff, and entirely optional. I'm not really invested in any of them.

@H2CO3

This comment has been minimized.

Copy link

commented Jul 30, 2019

@Puan I wasn't arguing for completely abandoning operator overloading or for removing it from the language. Please read my comment more carefully and refrain from putting words in my mouth.

@burdges

This comment has been minimized.

Copy link

commented Jul 30, 2019

We should discuss your concerns about closures @Nokel81

If I write .and_then(|| ...) then rust should inline both the and_then as well as the closure. Any borrows the closure makes should end when the .and_then ends.

If I write let f = || ...; foo().or_else(f) then again rust should inline both the or_else and f. And borrows made by f should again end when the .or_else ends.

I'm fairly sure NLL can drop borrows within individual statements including closures. And NLL should be fixed/extended otherwise.

I've actually never written code like

let w = RefCell::new(w)
let x = || .. w.borrow_mut() ..;
let y = || .. w.borrow_mut() ..;
let z = || .. w.borrow_mut() ..;
.. logic with x y z ..;
let w = w.into_inner();

but if I did, and do not reborrow the closures x,y,z, then I'd expect rustc to optimize away the borrow flag from the RefCell because the closures lack arguments.

Also, rust's closure notation || .. is already more compact than say Haskell's \x -> .. @OvermindDL1

@Nokel81

This comment has been minimized.

Copy link
Contributor Author

commented Jul 30, 2019

My main concern is that I don't know if they will be inlined. And it feels wrong because it seems like such a large thing to grab and use when all I want is conditional execution.

Yes I feel that it can get wordy but it is also not possible to control the control flow of the enclosing function from within the closure. That seems to me to be an even bigger problem with the current solution.

@burdges

This comment has been minimized.

Copy link

commented Jul 30, 2019

Those are both comments about rust drawing heavily on functional languages.

I think foo() && { .. break; .. } && bar() would normally be quite poor style.

@nox

This comment has been minimized.

Copy link
Contributor

commented Jul 31, 2019

Yes I feel that it can get wordy but it is also not possible to control the control flow of the enclosing function from within the closure.

That's a feature, I don't want to navigate code bases where I need to wonder what's the return type of a simple && or || expression, and I don't want to have to visually scan its right-hand side for control flow.

@phaylon

This comment has been minimized.

Copy link

commented Jul 31, 2019

I would be against adding the ability to overload logical operators.

Looking at std::ops as it looks currently, the traits in there can be categorized as:

  • Math-operations: Add, AddAssign, Div, DivAssign, Mul, MulAssign, Neg, Rem,
    RemAssign, Sub, SubAssign
  • Bit-operations: BitAnd, BitAndAssign, BitOr, BitOrAssign, BitXor, BitXorAssign
  • Shift-operations: Shl, ShlAssign, Shr, ShrAssign
  • Smart-pointers: Deref, DerefMut
  • Destructors: Drop
  • Closures: Fn, FnMut, FnOnce
  • Indexed element access: Index, IndexMut
  • Negation: Not
  • Range abstraction: RangeBounds
  • Related traits that aren't used directly: CoerceUnsized, DispatchFromDyn
  • I have no idea what's std::ops about this: Generator
  • Propagation conversion: Try

In general, this matches my preferences. Overloaded operations are there to bring
custom user types in line with standard or builtin types or to provide an abstract
interface for operations when there are no fundamentally inherent types to the operation.

I would argue that this is not true for an overloaded ||. It would be in line if instead there
was a Truth trait that allowed overloading truthiness itself, which I would also consider a
bad idea.

I believe that logical operations should (at least strive to) uphold certain properties. For
example:

if a {
  if b {
    c()
  }
}

and its relation to

if a && b {
  c()
}

which would be broken by this RFC. I have similar feelings towards the math operations and
consider overloading + for "putting things together" a mistake.

Since this change wouldn't allow overloading the truthiness of values, but only their usage
with certain logical operations, it would turn a set of purely logical operations into general
control flow operations that no longer relate to boolean logic. As was noted earlier in the thread,
I'd agree that things like some_condition || return are less clear than their full if based
counterparts and should be avoided. It seems to me that the change proposed in this RFC would
instead encourage coding like that. In this sense, .or_else(|| ...) avoiding control flow
alterations can be seen as a feature.

There is a std::ops::Not, but that doesn't really deal with boolean logic by itself. It just
happens to also work with bools. To be honest, I never used ! outside of booleans. I'd probably
consider using ! outside of a boolean context as misleading, and would .not() instead.

I'd also like to note that I consider (paraphrased) "The std::ops module is already big"
not an argument for making it bigger. This sentiment to me looks like embracing the slippery
slope full-on. It's becoming a more commonly seen argument these days and I wish we'd stay clear
of it. I'm also not sure it even works in this case, as std::ops contains a bit more than just overloadable operator traits.

In general it is my view that overloading operations always makes code less obvious and logic
less evident, except in the cases where we bring new types into a family of types. Like being
able to give big integer implementations the ability to do usual math operations.

I do want to say that I understand the general sentiment of wanting a feature like this. I just
consider making existing logical operations more "vague" to be an unfit solution. I believe
something like postfix macros would be a much better fit for this functionality.

@Nokel81

This comment has been minimized.

Copy link
Contributor Author

commented Jul 31, 2019

@phaylon That is a very well thought out argument. And yes, I do appreciate your understanding for wanting a feature like this. I do agree that postfix macros would fix this because then we could do a.or!(b).or!(c). But even then we would need some traits to make it generic enough to be useful (instead of a.opt_or!(b).opt_or!(c)).

@nox

This comment has been minimized.

Copy link
Contributor

commented Jul 31, 2019

But even then we would need some traits to make it generic enough to be useful (instead of a.opt_or!(b).opt_or!(c)).

There is nothing wrong with making traits for combinators around Option<T> and Result<T, E>, just don't make those traits overload boolean operators.

@OvermindDL1

This comment has been minimized.

Copy link

commented Aug 1, 2019

Short circuit operators at the very least still should be added for at the very least custom types. Making a library to perform external operations, say BLAS or so, being forced to use something like .or(|| ...) is not at all remotely clean when the math and language of it dictates short-circuiting &&/|| and so forth. Whether people use them for other purposes should be left up to them and their own decision abilities.

@nox

This comment has been minimized.

Copy link
Contributor

commented Aug 1, 2019

Short circuit operators at the very least still should be added for at the very least custom types. Making a library to perform external operations, say BLAS or so, being forced to use something like .or(|| ...) is not at all remotely clean when the math and language of it dictates short-circuiting &&/|| and so forth. Whether people use them for other purposes should be left up to them and their own decision abilities.

You can just be making your own local macros for whatever control flow structures you want to implement BLAS, that sounds like a bad argument to me, and the vast majority of people out there are not reimplementing BLAS anyway.

By that argument, I should have gotten a let … else expression years ago given how much I could use that in Servo.

@OvermindDL1

This comment has been minimized.

Copy link

commented Aug 1, 2019

By that argument, I should have gotten a let … else expression years ago given how much I could use that in Servo.

I'm guessing for matching failures? Wouldn't if let ... else ... be functionally the same, or do you mean for some other features.

In addition, this isn't for implementing new syntax, it's just for allowing existing syntax to work on custom types instead of being exceedingly specific enough to be near useless for all but the trivial cases.

@matthiasbeyer

This comment has been minimized.

Copy link

commented Aug 18, 2019

After just reading the title and not the whole discussion on this RFC, I'd vote against that feature.

This results in even more cognitive overhead when using the language. That is exactly the overhead I tried to avoid when going for Rust instead of C++. The language has enough complexity already, this just allows the user to increase congnitive load when reading and writing Rust and does not help with readability and easy of use.


Edit: After reading the RFC more carefully, I can only say this even louder: I'm against this change in the language because it does increase the cognitive load and the "special-character/alphabet-character" ratio (which is undersirable). Having named functions for these tasks makes the language more expressive and more readable (as in 'I can read the code like english'). Changing this therefore will (IMO) result in less readability, more "having to keep things in mind"yiness and more confusion for beginners and maybe even intermediate Rust programmers.

Long story short: Please don't do this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
You can’t perform that action at this time.