meetings/working-groups/roles/roles-2022-11-10.md
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.
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).
Here's an outline of the proposed emit strategy in a few scenarios:
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:
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 MyRole2<T> : MyRole<T> where T : Constraint
{
...
}
would be emitted as:
// 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
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:
UnderlyingType /*MyRole*/ role = underlying;
new MyRole(ref role).M();
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
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.
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) ...
}
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;.
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:
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
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)
role MyFunctionPointerRole : delegate*<...> { }
// can't put function pointer in typeof in attribute
delegate*<MyRole> // nowhere to store attribute
Some possible encodings:
modopt)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:
Role<T> M<T>(Role<T> role)
would emit as:
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