Open LPeter1997 opened 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.
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.
Sence we can have multiple implementation blocs, would/should it be possible to add implementation in other file? or maybe in another module?
Yes, similarly to Rust for example.
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:
Which would be equivalent to the following in C# (I'm intentionally not using C# records here so all behavior is explicitly shown here):
Which means that by default a Fresh record would:
ToString
is defined to be a somewhat human-readable formatConstructors
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: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:
Which would be equivalent to this in C#:
Calling the constructor
Calling the primary constructor would be like calling a function:
Calling a factory-function is the same as calling a static function, it's scoped to a type:
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:A few things to point out:
birthday
takesthis
explicitly, marking it a member function rather than a static one. This means that static functions simply do not takethis
as their first parameter.this
needs no type specification, it is known from the implementation block.this
is not implicitly accessible in the function scope, everything is accessed throughthis.
.impl
blocks defined for a type, which is important for later.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 markedopen
(as opposed to marking itsealed
, when not wanting inheritance).For example, let's say we want to extend
Foo
with our own type,Bar
and override nothing:Any overriden member should be in that
impl
block, and only inheritance-related members can go in there. Any unrelated operation should go into anotherimpl
block.For example, if we want our
Person
type to inherit fromEntity
and overrideint GetId()
, thePerson
type would have the following code (keeping the old functionality):