- "A certain section of the community that I suspect are [long pause] uncommonly aligned with Haskell"
- "I feel like I might be, as [redacted] said earlier, uncommonly aligned with Haskell."
- "This conference room table is too heavy to flip"
The idea of roles and extensions has a long history in C#, under various names, throughout the years. Previous discussions/issues on the topic include (but aren't limited to: these issues have more links themselves):
- https://github.com/MattWindsor91/roslyn/blob/master/concepts/docs/csconcepts.md - Concept C#
- #110 - Type classes
- #164 - Shapes and extensions
- #1711 - Previous roles and extensions exploration post
After some more thinking on the topic, we're back with a proposal in the space. It's early days yet for this proposal: extensive prototyping and exploration of implementation details will be needed to make forward progress. But overall, the LDM is excited about the direction this proposal is taking, and we spent all of today's session discussing the proposal, both from a high level and getting into some implementation ideas.
Broadly, there are 2 different features here, and the feature we choose to pursue will heavily drive our implementation concerns.
There's a smaller, simpler feature where we say that we will never allow implementing interfaces on classes the user doesn't own.
(Not to say that it's a small feature, just that it's small_er_). This feature could be implemented entirely through erasure if
desired, requiring little to no runtime changes, but that would mean it has the commensurate issues erasure brings. Overloading
could be accomplished via modopt
s, but edges would still bleed through in reflection and generic usage.
The larger, much more complex feature is saying that we will, at some point, allow implementing interfaces on classes the user doesn't own. Even if this feature doesn't ship initially, it will impose a much higher burden on the implementation. In such a world, this code (or something similar) should be possible:
// Assume some pre-existing Fraction type from an older library with a constructor that takes an int numerator and an int denominator
role FractionAdditiveIdentity : Fraction, IAdditiveIdentity<Fraction>
{
public static Fraction Zero => new(0, 1);
public static Fraction operator+(Fraction left, Fraction right) => left + right;
}
// With the above adapter, this code should be compilable:
public T Sum<T>(IEnumerable<T> elements) where T : IAdditiveIdentity<T>
{
T result = T.Zero;
foreach (var el in elements)
{
result += el;
}
return result;
}
IEnumerable<FractionAdditiveIdentity> fractions = ...;
Console.WriteLine(Sum(fractions));
However, if that code can compile, then it means a few things:
- FractionAdditiveIdentity needs to be real in metadata. It can't just be erased: there needs to be a real runtime type that
can be used to find the implementation of
T.Zero
. - There needs to be some sort of variance relationship between
IEnumerable<Fraction>
andIEnumerable<FractionAdditiveIdentity>
. Is that an identity conversion somehow? Or a role variance conversion?
Neither of these can be accomplished via erasure, and moreover would be a big breaking change if we were to add them later. It might be possible, with the right set of restrictions, to ship an erasure-based solution now and add this later, but we'll need to fully explore the space and have a good understanding of how the full solution would be implemented before we ship the initial feature.
When it comes to erasure, there are also different stages that types can be erased in. For example, we could have real metadata for roles, but they could be erased in most scenarios by the JIT compiler, as opposed to being erased by Roslyn. This could potentially simplify the language rules, at the expense of making the runtime side more complex.
We also think there are opportunities to leverage our existing variance rules here. List<Fraction>
and
List<FractionAdditiveIdentity>
are hard to relate to each other in a non-erasure-based system, because those are really different
types and need to have different observable results in type tests, reflection, generics, etc. But perhaps we can say that there is
a variance relationship between List<Fraction>
and IList<FractionAdditiveIdentity>
: they are not the same object, but the class
List
type is convertible to an interface via a variant conversion, with automatic boxing conversions inserted by some stage of the
pipeline where necessary.
Overall, the team is interested in trying to go all the way here. An erasure-based system would allow us to ship in the nearer term, but we've been talking about type classes in the language for years. We'd really like to enable the whole deal, rather than cordoning it off forever more. Doing that is going to take both effort and time, but we think it's worth it.
Some other miscellaneous thoughts:
- Can roles reimplement underlying behavior? IE, can they provide their own
ToString
to override the existing one? While this is potentially an attractive feature, it would have some serious consequences on implementation strategies, and it throws some odd questions in for the goal of havingIdentity
conversions from a role to the underlying type and back. - Is there a way we could have automapping of role properties onto an underlying dictionary, enabling an "easy" mode similar to typescript
interfaces? This might be a place where source generators would work well, but if that's the approach we would want to take we'd need
partial
roles. - This feature would resolve a number of alias requests we've had over the years, particularly if we disallow sideways role conversion
(or make it require an explicit cast). This would allow a user to have a
Mile
role and aKilometer
role, both of which extend some numeric type. They could even potentially be a generic role on top ofINumber<T>
, allowingMile<decimal>
to automatically derive all conversion information from a generic implementation.
The LDM is excited to continue exploring this feature, in particular the full interface implementation version. This is a long lead proposal: it's going to take time to explore and implement. But we think we have a promising start to really start prototyping with.