Draco-lang / Language-suggestions

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

[WIP] Traits #52

Open LPeter1997 opened 2 years ago

LPeter1997 commented 2 years ago

Note: Traits have a stricter definition than what is used here. For simplicity's sake, we don't explicitly differentiate them from interfaces.

Introduction

Traits - in C# they are called interfaces - are constraints expressed on a type. They are usually defined as a set of members a type (or its instance) has to define. It has various uses in programming languages, the two most notable being:

Note, that the two uses are the same in their essence, they both ensure that the given type/instance will have certain operations.

Set of possible features

Traits in languages allow for a different set of constraints we can put on the type.

1. Nonstatic method/property constraints

The usual C# interfaces have this.

interface IAnimal
{
    public int Age { get; }
    public void Eat();
}

2. Static method/property constraints

This is there Java and C# interfaces (used to) fail, they only concentrate on members, even though there is legitimate need for calls on static methods in a generic context (for example, a factory function).

Rust traits can do this:

trait IntCollection {
    // Static method
    fn new() -> Self;

    // Nonstatic method
    fn add(&mut self, v: i32);
}

C# has static abstracts in preview, that essentially allows for this same thing:

interface IIntCollection<T> where T: IIntCollection<T>
{
    // Static method
    public static T Create();

    // Nonstatic method
    public void Add(int v);
}

Note: That static abstracts will likely increase the use of CRTP in generic C# code, while Rust does not need it because of Self. This will be also mentioned in point 6.

3. Default method/property implementations

C# allows for providing a default implementation, but it's sort of weakened. The defaulted members are only visible when the type is cast exactly to the interface type providing the default:

interface IDog
{
    public void Bark() => Console.WriteLine("björk");
}

// ...
var d = new SomeDog();
// d.Bark(); ERROR
(d as IDog).Bark(); // OK

In my opinion, Rust does this really well: You can just provide a default, meaning you can attach a lot of default behavior based on only a few not implemented methods without needing a base-class. In many cases, you only implement one or two trait methods in Rust, the rest are provided by the default implementations (a perfect example is the Iterator type).

4. Field constraint

Personally, I do not know of a trait system that allows this. Most systems get away with properties that are then backed by an implementor types' field.

5. Associated types

Some trait systems can require a type to define or alias some other type with a certain name. For example, the Iterator trait in Rust can be defined (without the extra methods) as:

trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
}

Which means, anyone implementing Iterator must have a nested type called Item, and a member function called next that returns an optional of Item.

This can improve the readability and organization of traits by a ton, reducing generic argument count.

6. Reference for the implementation type

Sometimes, it would be useful to reference the implementor type in the interface. Rust provides Self, but C# for example has no such thing, it uses CRTP instead.

I believe CRTP is a very noisy pattern, and could be easily hidden by providing something like what Rust has.

7. Associated constants

Alongside associated types, Rust allows for enforcing to define an associated constant value:

trait Identified {
    const ID: u32;
}

Which means that anything that implements Identified has to define a constant named ID of type u32.

Internal vs external implementation

There are two main ways a trait implementation can happen in different languages: internally and externally. Internally essentially means, that the type definition lists all the traits it implements, and there is no way for the consumer to implement more traits on the type. This is how Java or C# implements its traits:

// Interfaces have to be listed at the definition
class Dog : IAnimal, IBark
{
    // ...
}
// Consumer can't implement more interfaces for it

External trait implementation allows for extending a type, even when the extended type is not owned by the consumer. This is like being able to add interface implementations on a type like System.Collections.Generic.List<T> without modifying the BCL source code! This is what Rust does:

// Dog might be defined in another library!

impl Animal for Dog {
    // ...
}

impl Bark for Dog {
    // ...
}

C# has started to go in this direction as well with the roles and extensions proposal.

Compile-time VS run-time features

It is easy to see, that only no. 1 (and no. 3) are actually usable as runtime polymorphism. The rest are all features that are only usable in a generic context, mostly as constraints on a generic parameter. This explains a lot of the initial design choices of Java and C#, because generics might not have been the focus back then.

Personal thoughts

In my opinion the original interfaces of Java and C# that only contain nonstatic members without any possible implementations are really-really weak. Java originally introduced interfaces as a way to avoid the diamond inheritance problem. Still, these interfaces are useful for providing customization points for libraries. They are also useful in generic contexts (as constraints), but the nonstatic-members-only nature of interfaces introduces a big pain point there.

Adding static members to the mix, they already become way more powerful. Allowing static members means static operations - like operator overloads in C# -, allowing for generic math, enforcing factory functions with certain signatures, etc. This is mostly useful, when using the trait as a generic constraint, like mentioned in the previous section.

The rest of the features are mostly convenience and their need depend on how much generic programming the given ecosystem wants to utilize (and of course, how they want to utilize it).

I'd like to point out how the external implementation + defaults allows for a very powerful extension and code-reuse mechanism that both Kotlin and C# are mimicking with extension functions.

Proposal for Fresh

A summary of what each language supports and what I'd like to propose for Fresh:

Feature Java C# Rust Fresh proposed
1. Nonstatic method/property constraints ✔️ ✔️ ✔️ ✔️
2. Static method/property constraints ✔️* ✔️ ✔️
3. Default method/property implementations ✔️ ✔️** ✔️ ✔️
4. Field constraint
5. Associated types ✔️ ✔️
6. Reference for the implementation type ✔️ ✔️
7. Associated constants ✔️

*: Static abstracts are still in preview.

**: Inconvenient, see the section about the feature.

I propose associated types to encourage less type-erasure in generic contexts - for example in collections, where everything that is an ICollection<T> could be enforced to tell the type of its enumerator. Note, that this might also be a motivation to later introduce associated constants (like enforcing a LINQ enumerator to define if it supports size hints, or not). If we can come up with enough motivation for them, we could include them.

Between internal and external implementation, I'd like us to support external implementations. The roles and extensions proposal can greatly help us in that.

Proposed syntax

trait Animal {
    // Associated type, constrained to be also an Animal
    // We could of course have unconstrained associated types
    type MortalEnemy: Animal;

    // Static method constraint
    // 'This' refers to the implementation type
    func make_new(): This;

    // Nonstatic method constraint
    func eat(this);

    // Defaults for both static and nonstatic members
    // Are simply written as a method with implementation
    func make_noise(this) {
        // Eh, we need energy to make some noise don't we
        this.eat();
        WriteLine("*silence*");
    }
}

// Implementing for a type
impl Animal for Dog {
    // Associated types are either defined here, nested like a nested C# class, or aliased (which would be the preferred usually)
    type MortalEnemy = Cat;

    // Writing : Dog would be equivalent, but allowing This makes pasting in trait headers way more comfortable
    func make_new(): This {
        // ...
    }

    func eat(this) {
        // ...
    }

    // We _could_ override make_noise, but we are fine with the default here
    // What, it's not like dogs bark, do they?
}

Proposed internals

I don't have the answers on how we are going to implement all of these in the type-system given by .NET. There is a separate issue for this, discussing the internals.