Draco-lang / Language-suggestions

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

[WIP] Record types #41

Open LPeter1997 opened 2 years ago

LPeter1997 commented 2 years ago

Goal of the document

This document aims to describe the primary user-defined datatype for the language (what class/struct/record is in C#). While traits/typeclasses or DUs will not be outlined here, they will be briefly mentioned, as they still affect the design of the datatype we want to end up with.

What we want from the datatype

I believe that we can (or should) get away with a single construct for datatypes, even if we decide to add syntax sugar/metadata/annotations to help some of the uses. The two main ideas that should be merged are classes/structs and records.

A possible design and syntax

A few points I'd like to enforce (despite not laying out all components here, these are important for later design choices):

For an initial design we could straight up grab what Kotlin has with its data classes:

type Person(val Name: string, var Age: int);

Which would be equivalent to the following in C# (I'm intentionally not using C# records here so all behavior is explicitly shown here):

sealed class Person : IEquatable<Person>
{
    public string Name { get; }     // val
    public int Age { get; set; }    // var

    public Person(string name, int age)
    {
        this.Name = name;
        this.Age = age;
    }

    public override string ToString() =>
        $"Person({this.Name}, {this.Age})";

    public override bool Equals(object? other) =>
        this.Equals(other as Person);

    public bool Equals(Person? other) =>
           other is not null
        && this.Name.Equals(other.Name)
        && this.Age.Equals(other.Age);

    public override int GetHashCode() =>
        HashCode.Combine(this.Name, this.Age);
}

Which means that by default a Fresh record would:

Constructors

The members listed after the type name form the parameter for the primary constructor. The idea is that the primary constructor is the only actual constructor for the type, any other constructor would be implemented as static factory functions. The rationale for only having a single "true" constructor is that it simplifies semantics for both the language and for users: every other constructor would have to call the primary constructor (which is a sensible rule brought in by other languages). With factory functions, they have no way to ever get to an instance without calling into the primary constructor. They also serve an important step in factoring out error handling into functions rather than constructors.

For example, implementing a factory function that constructs a Person from a JSON file could look something like:

impl Person {
    func from_json_file(path: string): Person {
        // ...
    }
}

Since there are compatibility reasons to still have multiple constructors defined on CIL level - like for serializers -, these factory functions could be marked later with an attribute to notify the compiler to generate a constructor from the function in CIL. Attributes and metadata are not defined yet, but they could look something like:

impl Person {
    #[Constructor]
    func from_json_file(path: string): Person {
        // ...
    }
}

Which would be equivalent to this in C#:

class Person
{
    // ...

    public Person(string path) { /* ... */ }
}

Calling the constructor

Calling the primary constructor would be like calling a function:

func main() {
    val person = Person("Anne", 27);
}

Calling a factory-function is the same as calling a static function, it's scoped to a type:

func main() {
    val person = Person.from_json_file("person.json");
}

Defining additional members

Additional members would go into the implementation block of the type. For example, if we want to have a function that wishes a happy birthday to our Person type, we would have it as such:

impl Person {
    func birthday(this) {
        this.Age += 1;
        Console.WriteLine("Happy birthday to " + this.Name + " who is " + this.Age + " years old!");
    }
}

A few things to point out:

Inheritance (mainly for external types)

While I'm not a fan of having inheritance among types defined in Fresh, we need to support it for types coming from external packages to have a better shot at C# compatibility. Inheritance would be similar to how I imagine trait implementation, which would be another kind of impl block: impl <Trait/Base> for <Target-Type> { ... }. Additionally, the type would be marked open (as opposed to marking it sealed, when not wanting inheritance).

For example, let's say we want to extend Foo with our own type, Bar and override nothing:

impl Foo for Bar {
    // We override nothing, nothing to put here
}

Any overriden member should be in that impl block, and only inheritance-related members can go in there. Any unrelated operation should go into another impl block.

For example, if we want our Person type to inherit from Entity and override int GetId(), the Person type would have the following code (keeping the old functionality):

open type Person(val Name: string, var Age: int);

impl Person {
    func birthday(this) {
        this.Age += 1;
        Console.WriteLine("Happy birthday to " + this.Name + " who is " + this.Age + " years old!");
    }
}

impl Entity for Person {
    func GetId(this): int = this.Age * 123; // Why not?
}
333fred commented 2 years ago

The type implements value-equality instead of referential equality

I'd caution against this. Most reference types should not have value equality, particularly mutable reference types. Instead, I would highly suggest referential equality by default, with an easy way to opt in to value equality.

For that opt in, you will also need to consider commutativity. C# solves the (a == b) == (b == a) problem by having an EqualityContract property, which ensures that both types have the same notion of what equality means. While you did mention you will discourage inheritance, it still exists, and you'll still want to think about how to handle it.

LPeter1997 commented 2 years ago

Fair, it was a poor decision on my part. Since we will likely have a derive-like mechanism (that auto-implements certain traits), we could let that implement value-based equality.

Binto86 commented 2 years ago

Sence we can have multiple implementation blocs, would/should it be possible to add implementation in other file? or maybe in another module?

LPeter1997 commented 2 years ago

Yes, similarly to Rust for example.