Skip to content

[Proposal]: Extension interface implementation #9319

Open
@agocke

Description

@agocke

Extension interface implementation

Summary

An implementable model for extension interface implementation has not yet been proposed. Previous proposals were also intimately tied to roles/shapes. This proposal includes an ECMA-335 design that allows extension interface implementation at the CLR level, as well as a proposal for integration into C#.

Currently, the set of interfaces implemented by a type is controlled by the type definition (and IDynamicInterfaceCastable, which will not be covered in this proposal). All interfaces implemented by a type must appear in that type's definition. This contains two significant weaknesses:

  • It prevents adding interfaces to external types, particularly types that appear in the core libraries. It is often desirable for libraries (like serializers, loggers, eventing libraries, etc) to provide new interfaces that represent domain-specific object models, and then implement those object models on the core types of .NET (primitive types, arrays, lists, etc). Because interface implementations must always occur on type definitions and users do not control the definition of primitive types, this makes these augmentations impossible.
  • It does not support "conditional implementation." Some interfaces require all members to implement the interface before the containing type can implement it. For example, deep equality may require all members to implement a deep equality interface. This is problematic for generic types because there is no way to provide a "conditional" constraint. Using the same example, a List<T> should only be deep equal if T implements deep equal. However, List<T> should not require T to be deep equal in all cases.

The proposal is to relax this restriction and allow interface implementations to appear outside of the type definition in an extension interface implementation. While extension interface implementations do not have to appear inside the definition of the implementing type, they do have restrictions of their own:

  • They must appear in the same compilation unit as either the implementing type or the implemented interface
  • Two implementations must not overlap. That is, there must be a single best implementation for any given type-interface pair.

The preceding rules guarantee that,

  1. There is either one unique implementation or no implementation for any given type-interface pair.
  2. This implementation (or its nonexistence) can be determined by examining only the compilation unit containing the type definition and the compilation unit containing the implemented interface.
  3. Checking for interface implementation is idempotent. After an implementation for a given type-interface pair is found (or not found), no operations, including running or loading new code, can change the result.

C#

Extension interfaces are declared similar to other extensions:

interface IPrint
{
    void Print();
}

static class IntExt
{
    extension(int) : IPrint
    {
        void IPrint.Print() { Console.WriteLine(this); }
    }

    extension<T>(List<T>) : IPrint
        where T : IPrint
    {
        void IPrint.Print()
        {
            foreach (var x in this)
            {
                  x.Print();
            }
        }
    }
}

The compiler would be responsible for determining that no two implementations overlap. More precisely, an extension implementation with extended type T1 and interface I1 conflicts with any implementation (direct or extension) with type T2 and interface I2 if T1 is reference-convertible to T2 and I1 is reference-convertible to I2.

  • To do: The above definition is almost certainly not sufficient to capture all possible overlapping implementations.

Motivating example

It seems like the restrictions around foreign types are very strict, but they still allow useful patterns.

Consider "deep" equality. In .NET, collections implement shallow equality, meaning that two collections are considered equivalent if and only if they are reference-equal. Sometimes this is OK, but other times it would be very useful to compare collections element-by-element instead. For example, in unit testing it would be nice if Assert.Equal could use deep equality instead of shallow equality.

Unfortunately, deep equality is almost impossible to implement in a general manner in .NET. The problem is that deep equality fundamentally requires adding an equality implementation conditionally for an entire type heirarchy. With extension interface implementations, things could get much easier. A unit testing library might define

interface IDeepEqual<T>
{ 
    bool Equal(T other);
}

to represent deep equality, analogous to the existing IEquatable<T> interface. Then, they could define a method to use the interface,

bool Assert.Equal<T>(T expected, T actual) where T : IDeepEqual<T>
{
    if (!expected.Equal(actual))
    {
         throw ...
    }
}

Normally this method would be very hard to use because it wouldn't work for things like int or string. You could define overloads, but then there's List<string> and Dictionary<string, string>. The set of required overloads is infinite.

By using extension interface implementation, we can use the compiler/runtime to effectively implement the interface, on demand. Because the unit testing library defines the IDeepEqual<T> interface itself, it can define extensions on other types. It would extend primitive types directly:

static class PrimExt
{
    extension(string) : IDeepEqual<string>
    {
         public bool Equal(string other) => this == other;
    }
    extension(int) : IDeepEqual<int>
    {
        public bool Equal(int other) => this == other;
    }
}

It can extend collection types:

static class CollectExt
{
    extension<T>(T[]) : IDeepEqual<T[]> where T : IDeepEqual
    {
        public bool Equal(T[] other)
        {
            if (this.Length != other.Length) return false;
            for (int i = 0; i < this.Length; i++)
            {
                   if (!this[i].Equal(other[i])) return false;
            }
            return true;
        }
     }
}

Note that even if you controlled the definition of array, you could not write the above today, because you cannot conditionally implement an interface depending on whether a constraint is satisfied.

You could even provide an implementation for all types that implement an interface:

static class IListExt
{
    extension<T, TList>(TList) : IDeepEqual<TList>
        where T : IDeepEqual<T>
        where TList : IList<T>
    {
        public bool Equal(TList other)
        {
             if (this.Length != other.Length) return false;
            for (int i = 0; i < this.Length; i++)
            {
                   if (!this[i].Equal(other[i])) return false;
            }
            return true;
        }
    }
}

In each case the runtime/compiler will "wire up" the interface implementations so that each type in the chain will recursively query for an available implementation until all requests are satisfied.

The main downside of the foreign type restriction seems to be that libraries might have to define their own interfaces to take advantage. This could lead to many libraries have duplicate definitions. Overall, this doesn't seem very bad. A few duplicate interfaces does not seem like a significant practical problem. In the case that a particular definition is truly so pervasive, it seems likely that the community can settle on a single library to provide that abstraction, or even get the interface added to the core framework.

ECMA-335

ExtInterfaceImpl

Explicit interface implementations are currently recorded in the InterfaceImpl table as a map from entries in the TypeDef table to interface (via TypeSpec). Extension interface implementations will be recorded in a new table, ExtInterfaceImpl.

The ExtInterfaceImpl table has the following columns:

  • Type, an entry into the TypeSpec table
  • Interface, an entry into the TypeSpec table
  • Impl, a coded TypeDefOrRef index

A single row represents attaching an interface implementation (Impl) for an interface (Interface) to a type (Type).

An interface implementation here is represented as a reference to a custom implementing class. For each abstract member in Interface, Impl must contain a corresponding MethodImpl entry which matches the signature with one exception: to allow specifying the correct receiver type, all instance abstract members in Interface will be represented by a static member in Impl with the first parameter's type matching the entry in the _Type_ column.

For example, the above IPrint interface could be implemented on the int type through an implementing class __Impl as follows:

static class __Impl
{
    public static void IPrint.Print(int @this) { ... }
}

Type and Interface entries can also make use of generic parameters. The definitions of generic parameters will come from the GenericParam entries attached to Impl. For instance, if the TypeSpec inside a Type entry pointed to the signature blob List<!0>, !0 would refer to the first generic parameter in Impl.

For example, an extension implementation of an IEnumerable<T> interface on a MyList<T> type when T is a reference type (where T : class) would include:

  • An Impl type class __Impl<T> where T : class that contains the appropriate implementation methods
  • An entry in ExtInterfaceImpl as follows where Type is List<!0>, Interface is IEnumerable<!0>, and Impl is the TypeDefOrRef __Impl<T>

Interface implementation rules

There will be a new rule for interface implementation logic that takes advantage of the ExtInterfaceImpl table. When testing if a given type T implements an interface I, the existing checks for direct implementation on the type T will be followed. Only if the check fails will the new rules take effect (i.e., existing successful resolution is unaffected). Otherwise, each row R in ExtInterfaceImpl is scanned. The given row R is considered to provide a successful implementation if:

  • The interface entry Interface in R is compatible with I, and
  • The type entry Type in R is compatible with T

For any given x and y, x is compatible with y if:

  • x contains no type parameters and x is convertible to y, or
  • The open type of x and the open type of y are identical and, if all type parameters t_i in x were replaced with the corresponding type argument t_i' in y, all constraints of t_i would be satisfied by t_i'.

Design meetings

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status

    Language/design

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions