Draco-lang / Language-suggestions

Collecting ideas for a new .NET language that could replace C#
75 stars 5 forks source link

Operator overloading #72

Open LPeter1997 opened 2 years ago

LPeter1997 commented 2 years ago

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:

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:

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.

thinker227 commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

@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 commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

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 commented 2 weeks ago

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.