Back to Csharplang

Roles & extensions working group (2022-11-10)

meetings/working-groups/roles/roles-2022-11-10.md

latest6.8 KB
Original Source

Roles & extensions working group (2022-11-10)

Framing of phase 1 (extension everything)

In the last few months, much of our investigation into roles was focused on scenarios that involve adding an interface implementation to an existing type.
Our thinking was that we needed to figure out those harder scenarios first, so that we wouldn't risk painting ourselves into a design corner.
But we believe using ref structs as part of the emit strategy for roles and extensions would protect this scenario.
So we're going to explore this further in the next few meetings with the intention of carving a phase 1 for roles and extensions.
Phase 1 would allow extending existing types with new members, but interface scenarios would come in phase 2.

Syntax and usage scenarios

The syntax used below has not been thoroughly discussed yet. But it is good enough for discussion.
We will need to revisit in a couple of weeks (definitely before any implementation work starts).

Emit strategy

Here's an outline of the proposed emit strategy in a few scenarios:

Role type itself

csharp
role MyRole<T> : UnderlyingType where T : Constraint 
{
    // Member declarations
    void M()
    {
        ... usage of `this` with type `MyRole` ...
        _ = this.ExistingMethod();
        _ = this.existingField;
        this.M();
    }
}

would be emitted as:

csharp
ref struct MyRole<T> where T : Constraint // possibly with a special base type like `System.Role` or `System.Role<...>` or some other marker
{
    ref UnderlyingType @this;
    MyRole(ref UnderlyingType underlying)
    {
        @this = ref underlying;
    }

    // Member declarations
    void M()
    {
        ... usages of `this` are replaced with `@this` ...
        _ = @this.ExistingMethod();
        _ = @this.existingField;
        this.M();
    }
}

Interfaces would be disallowed until phase 2.

Role on a role

csharp
role MyRole2<T> : MyRole<T> where T : Constraint 
{
  ...
}

would be emitted as:

csharp
// Need to encode relationship to MyRole<T>. Maybe the @this field is sufficient.
ref struct MyRole2<T> where T : Constraint
{
    [Role(typeof(MyRole))] ref UnderlyingType @this;
    // ... members ...
}

Open question: confirm whether the attribute is needed

Invocations on role member

Usages of MyRole.M() on a MyRole local:

UnderlyingType underlying = ...;
ref MyRole role = ref underlying; // conversion creates a ref struct
role.M();

would be emitted as:

c#
UnderlyingType /*MyRole*/ role = underlying;
new MyRole(ref role).M();

What kind of conversion is this?

For List<MyRole> and List<UnderlyingType> to be convertible, we need identity conversion between MyRole and UnderlyingType.

Then the role is only instantiated as part of the invocation and is short-lived.
Open question: need to confirm this design and weight pros/cons.

Open question: How about structs?

UnderlyingStructType underlying = ...;
((MyRole)underlying).M(); or declare a ref local

Inferred type arguments in invocation

This erasure approach may run into some friction with a future phase 2, as phase 2 requires type arguments to use role types to satisfy certain interface constraints.
Whether the role type is erased into the underlying type is observable with type tests like is IEnumerable<int>. In phase 2, the role could add this interface on an underlying that doesn't have it.

cs
MyStruct m = new();
ref MyRole r = ref myStruct;
var x = M(ref r); // T is MyStruct /* MyRole */
var y = M2(ref r); // In phase 2, T would be MyRole, would we change what is emitted for calling M from phase 1?

ref T M<T>([Unscoped] ref T t) => ref t;

ref T M2<T>([Unscoped] ref T t) where T : IEnumerable<int> => ref t;

ref T M3<T>([Unscoped] ref T t)
{
    // problem with erasure
    if (t is IEnumerable<int> i) ...
}

Usage as extension

To turn a role into an extension, its declaration needs to be changed (let's say from role to extension):

class UnderlyingType
{
    void M1() { ... }
    void M2() { ... }
}

namespace MyRoleNamespace
{
    extension MyRole : UnderlyingType 
    { 
        void M2() { } 
        void M3() { }
    }
}

Then to use that extension, it needs to be brought into scope with using MyRoleNamespace;.

Usages of MyRole.M() as an extension on an UnderlyingType local:

using MyRoleNamespace;
UnderlyingType underlying = ...;
underlying.M1();
underlying.M2();
underlying.M3();

would be emitted as:

UnderlyingType underlying = ...;
underlying.M1();
underlying.M2();
new MyRole(ref underlying).M3();

In terms of lookup rules, we would keep the same order as extensions today, namely that instance members win.

In the example, lookup for M1, M2 or M3 would be:

  1. instance members on UnderlyingType
  2. otherwise, fall back to extensions

Role type in signatures

MyRole M(MyRole role)

would be emitted as UnderlyingType with an attribute:

[return: Role(typeof(MyRole))] UnderlyingType M([Role(typeof(MyRole))] UnderlyingType role)

This encoding would allow callers of M to get the right return type (MyRole):

MyRole role1 = ...;
var role2 = M(role1); // var == MyRole

What kinds of types can roles be defined on?

cs
role Role<T> : T {}

// TODO: Confirm whether typeof in attribute can refer to T?
Role<T> M<T>(Role<T> role)
=>
[return: Role(typeof(T))] UnderlyingType M<T>([Role(typeof(T))] UnderlyingType role)
cs
role MyFunctionPointerRole : delegate*<...> { }
// can't put function pointer in typeof in attribute

delegate*<MyRole> // nowhere to store attribute

Encoding of roles in metadata

Some possible encodings:

  1. attributes
  2. Custom modifiers (modopt)
  3. don't erase roles (need runtime support)

Previously, we were thinking of emitting erased roles like tuples or dynamic, ie. using an attribute.
But that encoding scheme doesn't work so well for roles, because we need to encode types. This is not only more verbose, but it runs into some limitations. We're going to explore the next alternative, ie. using modopt.

Use a modopt on return type:

cs
Role<T> M<T>(Role<T> role)

would emit as:

cs
modopt(Role<T>) UnderlyingType M<T>(modopt(Role<T>) UnderlyingType role)

One benefit of this approach is that modopt is allowed anywhere a type is allowed: List<modopt(Role<int>) UnderlyingType>

A downside of this approach is that call sites need to spell out the entire signature, including modopts.
Also, this implies that changing API from underlying type to role is a breaking change.

Finally, this implies that you could overload on role types:

void M(UnderlyingType underlying) { }
void M(MyRole role) { }

Open question: confirm we're okay with such compat behavior

Hiding

Next time

  • Compat behavior of modopt
  • Conversion
  • Repetition at call-site