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

Operator overloading #72

Open
LPeter1997 opened this issue Jul 21, 2022 · 8 comments
Open

Operator overloading #72

LPeter1997 opened this issue Jul 21, 2022 · 8 comments
Labels
Design document This one came out from an idea but considers many cases and tries to prove the usabity Type system This issue is about type system

Comments

@LPeter1997
Copy link
Member

LPeter1997 commented Jul 21, 2022

Introduction

This issue talks about user-define operators on existing operator symbols (also known as operator overloading), not defining new, custom symbols as operators. This is the same mechanism as C# allows defining operators on user-defined types. As usual, we will go through a couple of designs and finally propose something for Fresh.

How C++ does it

Cppreference docs.

C++ does operator overloading in the simplest manner. Each individual operator can be overloaded for operand types - as long as one type is user-defined. For example, defining an addition for a 2D vector:

static vec2 operator+(vec2 const& a, vec2 const& b) {
    return vec2(a.x + b.x, a.y + b.y);
}

The existence of + does not imply +=, which has to be implemented separately. The same is true between all relational operators, meaning that for a type that has total ordering, 6 operator overloads (<, <=, >, >=, ==, !=) have to be implemented. This gives many opportunities to make a lot of repetition and typos in the many related operators. The existence of == does not require the existence of != or vice versa, which does not respect the usual properties we expect from equuatable types (in case the user forgets to implement it).

Note, that the situation has been somewhat eased with the spaceship operator for relational operators.

How C# does it

Official docs.

C# slightly improves the situation in 2 ways:

  • Compound operators are implemented using their binary equivalents. If you implement +, you automatically get += and there is no way to implement += yourself. This helps correctness and reduces the amount of boilerplate.

  • Some operators are required to be overloaded in pairs, like == and !=, or > and <. This improves in the sense that the operations are more predictable, but sadly this mechanism does not eliminate duplicate logic caused by this. This is due to the fact that we usually implement IEquatable or IComparable, and then let the IDE implement the operators based on that.

How F# does it

Official docs.

F# seems to be a step back from C#. It essentially goes back to being the C++ way, making you define each operator yourself. It requires no operators to be defined in pairs/groups and implements no operators for you, not even the compound ones.

This isn't necessarily a step back in every aspect, as the user has more control over each operator, but I'd argue the value of keeping the properties of some of the operators and reducing duplication can be more valuable in most cases.

How Rust does it

Rust by example docs.

Rust decided to define a trait for each overloadable operator in std::ops. The compiler recognizes these trait implementations and allows the operator syntax for the implementors. The vector example from C++ rewritten in Rust:

impl std::ops::Add for Vec2 {
    fn add(self, rhs: Vec2) -> Vec2 {
        Vec2{ self.x + rhs.x, self.y + rhs.y }
    }
}

The existence of + does not assume the existence of any other operator and does not provide +=.

I believe this trait-way has four major advantages:

  • There is no need for an operator syntax. Anything you can do with functions/traits, you can do with operators.
  • Every tool for traits/functions is implicitly accessible for operators. For example, the derive mechanism in Rust or even generic operators, that are straight up impossible in C#.
  • Traits that allow default implementations could reduce code duplication by auto-implementing operator pairs, but they would still allow you to override them for maximum control.
  • They can be used for generic constraints, which means that things like generic math is essentially free.

Proposal for Fresh

See the traits issue for a full(er) context.

I believe we should follow the footsteps of Rust here again. It's a very clean and beautiful design, opening up more possibilities than the other designs. What I propose here is not exact, it's more like the motivation and idea to why we should go with this way.

We could define traits for all atomic operations:

trait Add {
    func add(this, other: This): This;
}
trait AddAssign {
    func add_assign(ref this, other: This): This;
}
// Things like -x
trait Negate {
    func negate(this): This;
}
// ...

This would allow the user to have the most control and have very fine-grained over everything. This would also be used by the compiler to recognize that the given type can be used with the given operator syntax.

We could also define higher level constructs that allow the user to define higher level mathematical constructs, like fields. Example (please note that I'm by no means a mathematician):

trait Field impl Add, AddAssign, ... {
    const AdditiveUnit: This;

    // We provide defaults based on the other operations
    func add_assign(ref this, other: This): This {
        this = this + other;
        return this;
    }

    // Negation and addition can make up subtraction
    func subtract(this, other: This): This =
        this + (-other);

    // ...
}

We could even define a trait that defines all 6 relational operators based on a single compare-like function (for types with total ordering), eliminating the need for something like a spaceship operator.

Since traits can be used as generic constraints, we would get things like generic math essentially for free.

Why not custom operator symbols?

I strongly believe that custom operator symbols are a mistake in all cases. They completely diverge from natural, standard mathematical notation and create incompatible DSLs. I believe providing infix function call syntax should cover most cases while being more readable than the custom option.

@LPeter1997 LPeter1997 added Design document This one came out from an idea but considers many cases and tries to prove the usabity Type system This issue is about type system labels Jul 21, 2022
@LPeter1997 LPeter1997 changed the title User-defined operators Operator overloading Jul 21, 2022
@thinker227
Copy link
Contributor

What is the benefit of separating + and +=? I get it provides "maximum control", but for an implementation of a numeric type it seems rather arduous for something a lot of users will likely not benefit greatly from (have you ever felt the need for a custom += implementation in C#?).

If we will support a "minimal complete definition" for traits (alike typeclasses in Haskell) (probably using an attribute), then my suggestion would be that the minimal complete definition for Add and the like would not require a definition for += since it can be easily derived from +.

@LPeter1997
Copy link
Member Author

Sure, the Add trait can simply define the default implementation the user can override. The provided trait defs are not a complete design by any means.

@jl0pd
Copy link

jl0pd commented Jul 22, 2022

I don't like idea of splitting of + and += either. For example in python list behaves differently based on applied operator:

a = []
b = a # copy reference to list
a = a + [1] # call '__add__' to concat lists and make new one, then assign to 'a'
print(a, b) # [1], []. Original list wasn't mutated, new one was created and assigned to 'a'

a = []
b = a # copy reference to list
a += [1] # calls '__iadd__', which calls 'extend' (addRange)
print(a, b) # [1], [1]. Original list was mutated

@eatdrinksleepcode
Copy link

@thinker227 @jl0pd the mutable list scenario is exactly why + and += should be separated. If I know I have a mutable list and write +=, I expect that to mutate the list. But there is no way to implement that in terms of +.

Kotlin recognizes this potential ambiguity in the meaning of += (add via mutation or copy and add) and explicitly disallows += when + is also in scope and the variable (not the object) in question is mutable. See docs and explanation.

@jl0pd
Copy link

jl0pd commented Jul 22, 2022

I mean that it's bad for language to have meaning for a += b other than a = a + b. If I want to mutate list, I would make it explicit with .add or .addRange

@svick
Copy link

svick commented Jul 22, 2022

Isn't a lot of this duplicating .Net 7 generic math? For example, is it really a good idea to have both trait Add and interface IAdditionOperators?

@LPeter1997
Copy link
Member Author

It is related, yes. But since we are providing our own BCL, why not ease it a bit, like removing the need for CRTP. For interop we can implement these interfaces.

@WalkerCodeRanger
Copy link

One downside of the Rust approach is that it assigns a semantic meaning to operators that may not be correct. For example, when you use + to concatenate two strings, it isn't really Add. Likewise, there are times when operators are overloaded in natural ways that aren't the standard. For example, creating a library for BNF that overloads | to be the alternation operator of BNF.

I'm also not sure I agree that supporting arbitrary operator overloading is a bad thing. I agree it can easily be abused, but there are a lot of common math operators out there that our languages don't support out of the box, and it would be reasonable to allow overloading given that languages now support Unicode.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Design document This one came out from an idea but considers many cases and tries to prove the usabity Type system This issue is about type system
Projects
None yet
Development

No branches or pull requests

6 participants