Draco-lang / Language-suggestions

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

Nominal and structural typing #17

Open LPeter1997 opened 2 years ago

LPeter1997 commented 2 years ago

Introduction

Since most popular languages expose nominal typing or duck typing, it's worth to talk about the two concepts and their uses.

Nominal typing

Nominal typing is about naming your concepts and being able to refer to them uniquely. Languages like C, C++, C#, Java, Rust and Swift are nominally typed. For example, if you want to define an interface that users can implement, you give the interface a name, and document what concept it encapsulates:

// Implementing this interface means that your type is serializable to a text format.
interface ISerializable
{
    public void Serialize(StringBuilder sb);
}

When you implement this interface for your type, you know exactly what concept it encapsulates. When you write a type, it is not enough to simply implement the members of the interface, you have to explicitly state that you implement it. For example, the following will not compile, because the interface implementation is not stated explicitly:

class Person
{
    public string Name { get; init; }
    public int Age { get; init; }

    // This is not ISerializable.Serialize, because the class does not implement ISerializable
    public void Serialize(StringBuilder sb) =>
        sb.AppendLine($"Name: {Name}, Age: {Age}");
}

static void SerializeToFile(ISerializable s) { ... }
SerializeToFile(new Person() { ... }); // ERROR: Person does not implement ISerializable

The key takeaway is that in nominal typing you attach concepts to names/entities and not to the shape/implemented methods of a type.

Structural typing

Structural typing on the other hand does not care about explicitly stated concepts, it cares about how types look, what fields or methods they have. This is similar to C++ templates, where you do not state constraints for template variables, the substitution simply fails, if a type can not be used for a template:

// No explicit constraints on the type to ensure '+' exists for two types of T
template <typename T>
T add(T x, T y) {
    return x + y;
}

add(1, 2); // Ok, substituting integers into 'add' is fine
add(true, "hello"); // Error, substitution causes an error, no proper overload found

This means that constraints on types can mostly be expressed as a set of required fields and methods we expect from a type. For example, if we want to expect a generic type in Haxe to be hashable, we can write something like:

function compareHashes<T: { function hashCode():Int; }>(a:T, b:T):Bool {
    retrun a.hashCode() == b.hashCode();
}

This does not mean that we can't name structural components, we can of course alias them. But the important thing is that for nominal typing, two different interfaces with the same set of methods will still mean different things, for structural typing two different aliases with the same set of constraints will be perfectly equivalent.

Notable examples

Thoughts for the new language

To me it looks like most languages prefer primarily nominal typing. Its advantage is also it's disadvantage: You have to explicitly implement the concept for your type. This ensures that it's not just an accidental signature match, but an explicit statement that you do indeed implement the contract. This can be a major disadvantage in languages like C#, where you can't implement an interface externally for a type.

Some languages like C# turn to structural typing because constraint-based generics can be limiting, if there are not enough constraints to express the requirements. Languages like Rust allow for external trait implementations and their generic constraints are more sophisticated, allowing to require the existence of operators and such.

I'd say that a primarily nominal typing system could be desirable as long as we don't fall into the traps mentioned above. Allowing external trait/interface implementations could already improve a lot. Even if we do allow structural typing, I really wouldn't want to have a separate concept for that. I'd either have something like Haxe anonymous structures or allow to mark an interface constraint to mean structural equality, something like:

static void SerializeToFile<T>(T s)
    where T : structural ISerializable
{
    ...
}

Edit: One thing I forgot to mention in favor of nominal typing is that it can resolve name collisions between different interfaces. If two interfaces define a member with the same signature - which can be surprisingly common -, nominal typing can give a tool to disambiguate them. In languages like C# you can just write an explicit implementation, in Rust you can just use a fully qualified syntax on invocation. For structural typing you'd require very specific names, which can be tiring, verbose and still not avoid the problem 100%.