[Proposal]: Roles and extensions #5496
Replies: 31 comments 126 replies
-
It's awesome to see some movement in this space. I have some concerns of the loose typing with roles. I understand that it's intentional but it feels like it could very easily lead to situations where you're treating an instance as the incorrect role with undefined results. To me it also begs for a way to pattern match the underlying type to a given role, perhaps through a conditional member declared on that role. Otherwise how could you ever tell that a I'm also curious as to where roles/extensions fit into the conversation around shapes and structural typing. Assuming that interfaces remain the mechanism through which you define the constraint and this feature does hit the stretch goal of supporting extension implementation does it require manually writing a bridge/witness from an arbitrary type to that interface or can the compiler provide one given the appropriate extensions are in scope? I recall there was also a conversation about runtime changes necessary in order to avoid boxing the witness? |
Beta Was this translation helpful? Give feedback.
-
I'm glad to see this thread (structural typing) being picked up again. A few initial impressions:
|
Beta Was this translation helpful? Give feedback.
-
I believe that was covered in the previous roles discussion that detailed the "Comparison to previous proposals".
I chalk it up to being spaghetti thrown at the wall. |
Beta Was this translation helpful? Give feedback.
-
The following two design aims seem difficult to reconcile, both semantics wise and implementation wise:
Here's a couple of examples where this doesn't make sense: Example 1Since there is an identity conversion between This leads to the following unexpected behavior: var dataObjects = new List<DataObject>{do1, do2};
var customers = (List<Customer>)new List<DataObject>();
Console.WriteLine(customers.GetType()); // prints "System.Collections.Generic.List`1[DataObject]"
Console.WriteLine(((object)customers) is List<Customer>); // prints "False" Meanwhile if I use a role to fulfill a constraint this can't work through erasure. So if I have the following: public class PersonList<T> : List<T> where T : IPerson
var customers = new PersonList<Customer>{d0, d1};
Console.WriteLine(customers.GetType()); // prints "PersonList`1[Customer]"
Console.WriteLine(((object)customers) is List<Customer>); // prints "True" So there's this weird situation where the runtime type of a constructed type of a role/interface sometimes reflects that it's the role/interface and sometimes doesn't which I expect to be highly confusing. Example 2Consider the following: class A {}
class NamedWrapper<T> where T : INamed
{
public void PrintName() => Console.WriteLine(T.Name);
}
interface INamed
{
public static string Name { get; }
}
namespace N1
{
extension E1 : A, INamed
{
public static string Name => "Monty";
}
}
namespace N2
{
extension E2 : A, INamed
{
public static string Name => "Python";
}
}
namespace N3
{
using N1;
public NamedWrapper<A> GetNamedWrapper()
{
var namedWrapper = new NamedWrapper<A>();
namedWrapper.PrintName() // prints "Monty"
return namedWrapper
}
}
namespace N4
{
using N2;
using N3;
public static class Program
{
public static void Main()
{
var namedWrapper = GetNamedWrapper();
namedWrapper.PrintName(); // prints "Monty"
var namedWrapper2 = (NamedWrapper<E2>)namedWrapper; // identity conversion
namedWrapper2.PrintName(); // Should print "Python", but will in fact print "Monty" because it's an identity conversion
}
}
} I think that the idea of having identity conversions between constructed types to constructed types of roles and interfaces just isn't really doable. |
Beta Was this translation helpful? Give feedback.
-
I agree with @MgSam about the ability to implement interfaces through type extension. I believe this should be the main goal. Because then it will allow decorating any type (and this js like type too) with static typing anyway (just declare an interface and extend that DataObject or dictionary or anything). But not vice versa. |
Beta Was this translation helpful? Give feedback.
-
I disagree with this statement. The hypothetical DataObject has a dictionary of some sort behind the scenes. In the typical deserialization scenario, be it XML, JSON or binary, properties will be added to the dictionary. This incurs the standard dictionary costs: key lookup and behind the scenes allocations, as needed; plus the overhead during get operations. On the other hand, a properly implemented deserializer which creates an optimized type-specific method (using something like LINQ compilation or IL emit) can output code which sets target properties directly - no dictionary overhead; and the result is a strongly typed DTO, whose properties can be accessed natively with no overhead. I think it's a bad motivational example for the roles proposal. |
Beta Was this translation helpful? Give feedback.
-
Looking at this more, it looks like the |
Beta Was this translation helpful? Give feedback.
-
I really like it but I do feel like it should be a single concept instead of having Here is an example of what I mean. // Customer.cs
namespace Data;
public extension Customer : DataObject // Wrapper type
{
public string Name => this["Name"].AsString();
public string Address => this["Address"].AsString();
public IEnumerable<Order> Orders => this["Orders"].AsEnumerable();
}
// JsonDataObject.cs
namespace Data;
using JsonLibrary;
public extension JsonDataObject : DataObject // Extension type
{
public string ToJson() { … this … }
public static T FromJson<T>(string json) { … }
}
// Program.cs / Main method
using Data;
using Data.JsonDataObject; // Importing a specific extension type
using Data.*; // Importing all extensions types in the namespace Data
var customer = customer.FromJson<Customer>(args[0]);
WriteLine(customer.ToJson()); |
Beta Was this translation helpful? Give feedback.
-
I don't see why you wouldn't be able to. a Customer is a DataObject (both in terms of type hierarchy, and in terms of actual raw erased value). So all of JsonDataObject would apply to it. |
Beta Was this translation helpful? Give feedback.
-
@CyrusNajmabadi Okay thank you but still I think and feel like |
Beta Was this translation helpful? Give feedback.
-
@eyalalonn We just had an LDM meeting on htis. Trust me that that sentiment is understood and we're definitely going to be thinking heavily about these concepts and hte best way to expose them :) |
Beta Was this translation helpful? Give feedback.
-
Sorry everyone, I pressed convert on the wrong github issue. I converted this discussion back to a full issue, but it didn't move the comments back. The new issue is #5497. |
Beta Was this translation helpful? Give feedback.
-
it's way better than declaring static extension methods, naming is hard |
Beta Was this translation helpful? Give feedback.
-
with role appearing, openxml could be reimplemented, with data being simple xelements and no more overhead. |
Beta Was this translation helpful? Give feedback.
-
role and extenaion are basically the same, shall we make a way to allow them inter transformable? |
Beta Was this translation helpful? Give feedback.
-
Maybe roles (not extensions) can enable something like constrained types, where instances of the role are just instances of the underlying type but guaranteed to fulfill a condition. An example: role EvenInteger : int
where this % 2 == 0
{
public int Halve() => this / 2;
} Any cast int x = 4;
EvenInteger evenX = (EvenInteger)x; // works
int y = 3;
EvenInteger evenY = (EvenInteger)y; // throws Other examples: role NumericString : string
where this.All(c => char.IsDigit(c))
{
public int ParseAsInt() => int.Parse(this);
}
role ValidEnum<T> : T
where T : struct, Enum
where Enum.IsDefined(this)
{
} |
Beta Was this translation helpful? Give feedback.
-
I'm not sure I understand this. But I think I'm hearing: Currently, if I have object A, I can make instances of it act like X by
But doesn't
Mostly, I see a lot of infrastructure required to manage rules and assets around this. Without the right tooling, current extensions can seem like "Ghost Modifiers" - Class instance A in File1 does this. But an instance of the same class in File2 acts differently. Sure, we already deal with this, but for fun try writing a fair-sized app using only extensions. They become difficult to manage. (Pasta) One reason is they don't naturally align with the &$%@# "DirectoryPath = namespace" approach we default to. Personally, I think this is solving a problem that shouldn't exist. We commonly treat objects as (relational) data and not classes or objects (in the classic OO sense). So we don't get the benefits of OO. Even there, the Data-Procedure OO fork we mostly use is outdated. The State-Message fork (coincidentally championed by the same people designing ARPANet) is better suited to modern async/distributed/container-component applications. Parts of this seen to entrench this more, and lead to Ghost Classes (static class extensions). (Which I really want even though I know better lol) In some ways I'm starting to identify with people that feel the language is getting too "big". I like new features. I can choose to use them or not. But I don't like ones that break things. Or ones make the language internally inconsistent. Try to build an app where your classes don't have a parameterless constructor. Whole sections of the environment won't work - "...requires a parameterless constructor". K. Added. "...not nullable field must be initialized before leaving constructor." |
Beta Was this translation helpful? Give feedback.
-
This is one of the most needed/brilliant ideas I've seen for C#. It's an efficient/natural way to avoid having to "wrap an existing Class in another class to make it implement an interface (even if the existing object already complies with the interface). I agree with the idea of both having roles and extensions -- as one is done explicitly vs. the other done implicitly. I can think of instances where I would want it to be explicit vs. implicit, so it's good that the spec is aiming to provide us with both. I love that we'll be able to add class static method extensions as well. As it puts the static method right where it makes the most sense. For example, if I wanted to create a UI Button factory method -- I don't have to invent a whole other class name to contain the factory (e.g. ButtonFactory.cs). Instead I could just add factory methods to button like this:
Which is clear, and doesn't require the introduction of another artificial class name to add this Create(...) method. I think we could nick-name this feature "Extensions done right". |
Beta Was this translation helpful? Give feedback.
-
Did this topic ever get any discussion in a LDM? I saw it was on the docket to get discussed back on March 2, but that note got removed and I didn't see anywhere if it was discussed or not. I really hope for this feature to get some traction, so I figured I'd at least see if anyone had discussed it! Cheers |
Beta Was this translation helpful? Give feedback.
-
https://github.com/dotnet/csharplang/blob/main/meetings/working-groups/roles/roles-2022-11-10.md |
Beta Was this translation helpful? Give feedback.
-
I've discussed about the roles in another discussion #6782 (reply in thread) with @333fred // Validation
role Even for int where this % 2 is 0 { }
role Odd for int where this % 2 is not 0 { }
// Type differentiation
role TempCelcius for double
{
public TempKelvin AsKelvin => (TempKelvin)(double)this + 273.15;
}
role TempKelvin for double
{
public TempCelcius AsCelcius=> (TempCelcius)(double)this - 273.15;
}
var c = (TempCelcius)5;
var k = (TempKelvin)5;
c != k; // definitely not the same temperature
// and if I want to compare the underlying value
(double)c == (double)k; |
Beta Was this translation helpful? Give feedback.
-
I don't know what was the original motivation/influence for this feature, but here are my 2 cents, when roles are being mentioned I think of "traits" and DCI. I think there is a BIG potential to bring a new programming paradigm to C# with roles - DCI (Data Context Interaction) it's purpose is to improve code readability and shift developer focus on the use case (rather than classes). To summarise dci - objects are dumb data (D), when objects are in a context (C) they interact with each other (I). Their interaction are described by roles, any object can play any role (obv constrains in a strongly typed language) There is prior art that existed for a long time starting in small talk (c# version over 10 years now), a programming language called Marvin (C# with DCI capability) and it's Java extended counter part trygve Example of a use case implementation for transferring money between two accounts in Marvin. (source)
As an Engineer my 2 big points with DCI are:
as the proposal currently stands roles and extensions are decorators on steroids, which sounds like boiler plate code reduction rather than something of substance.
I think this is a step in the right direction, but it could be better focusing on interactions between objects rather than hierarchies and composition of classes, what OOP was supposed to be about. |
Beta Was this translation helpful? Give feedback.
This comment was marked as off-topic.
This comment was marked as off-topic.
-
How can we use this pattern for extending static classes (not members) like File or Path in C#? Could you please elaborate on it? |
Beta Was this translation helpful? Give feedback.
-
In case you missed it:
(That was a long journey to follow from #2505 → #1711 → #5485 → #5497) 😅 |
Beta Was this translation helpful? Give feedback.
-
Will this feature allow us to add extension members (such as operators) to certain generic instantiations of types? For example, I'd like an Option<int> a = new(), b = new();
Option<int> c = a + b; // Works Option<Person> a = new(), b = new();
Option<Person> c = a + b; // Compiler error |
Beta Was this translation helpful? Give feedback.
-
Saw this upcoming feature in the .NET blog: https://devblogs.microsoft.com/dotnet/dotnet-build-2024-announcements/#extension-types Implicit extensions look great and will be useful for both extending types across assemblies and organising them within assemblies. I think implicit extensions offer significant benefits over e.g. using the I am interested to watch the upcoming talk to learn about the benefits of explicit extensions. My immediate feeling is they look a bit like a back-door way of doing inheritance (with slightly different rules such as no extra state) but am interested to understand the motivation more. |
Beta Was this translation helpful? Give feedback.
-
I recently came across a video introducing this feature. And,can extensions implement interfaces? Can extensions be inherited? |
Beta Was this translation helpful? Give feedback.
-
I was just looking at the new proposal for anonymous extension declarations which looks good. However, would it be worth using the syntax public static class MyExtensions
{
extension MyClass : IMyNewInterface
{
// Add implementation of IMyNewInterface
}
} |
Beta Was this translation helpful? Give feedback.
-
Roles and extensions in C#
Motivation
In C# today, one assembly can use extension methods to augment types of another assembly, effectively adding new instance methods to it. This proposal expands on that principle so that:
Common to these is that they don’t require involvement of the underlying type, and don’t intrude upon its inherent semantics, thus facilitating new degrees of decoupling and separation of concerns.
The main purpose of the proposal at this point is to lay out the motivating scenarios and a comprehensive set of ideas for addressing them. The terms, concepts and syntax are all up for discussion. From a language design perspective there is a lot of new ground here, so it's ok - even necessary - for us to spend some time and effort making sure we end up giving it the best embodiment that we can.
Syntax
Acknowledging the design latitude, here’s the style of syntax I’ll use in the following:
Extensions are just roles that apply automatically to their underlying type when brought into scope with a using directive. Roles and extensions have a name, can be generic, can implement interfaces and can declare members that don’t introduce instance state. Unlike base types elsewhere, the underlying type can be pretty much anything, including value types, type parameters, pointer types etc.
A much more detailed description of the syntax and semantics follows under "Detailed design" later on.
Motivating examples
These examples are meant to present motivating "archetypical" uses of roles and extensions. There are some bigger examples near the end of the document.
Throughout these examples I will make use of a simple
DataObject
type meant to represent weakly typed, semi-structured data, e.g., wire data:None of the examples modify the type itself, but instead enhance it in various ways with roles and extensions.
Layering with extensions
API layering often conflicts with discoverability. We want to create APIs with only a one-way dependency, but members relating to the depending layer would be most naturally found in the depended-on layer. With extensions the depending layer can add members to types in the depended-on layer:
Here the
DataObject
type is extended by a Json conversion API to have a static and an instance method, converting to and from strings representing Json.The instance method of the extension can be considered a new and better syntax for the extension methods we already have in C# today. Bodies of instance extension members may refer to the extended object with the
this
keyword.The name of the extension can be used for disambiguation purposes, similar to the static class containing extension methods today. We might consider allowing extension declarations without names.
Extensions can be brought into scope the same way as extension methods today: With a
using static
directive on the extension type or ausing
on its namespace:Lightweight typing
Wire data is most naturally represented with weakly-typed dictionary-like types such as
DataObject
, but those don’t lend themselves well to helpful tooling and elegant program logic. However, conversion to strong types is expensive and lossy, and all-out wrappers still introduce a level of indirection. Roles can provide strongly typed augmentation with a lighter touch:Roles are transparent wrappers. They are considered identical to their underlying types and have implicit conversions in both directions. The conversions are free at runtime, because the underlying representation of the object doesn’t change. The
Order
is theDataObject
. As theOrders
property shows, even constructed types over roles convert freely. So now strongly typed, IntelliSense-aided code can be written without clunky indirection or runtime penalty:It is easy to imagine roles for wire data being automatically generated, e.g. by a source generator based on a schema or sample. This would address a similar scenario to F# type providers, but with named types and generated source code you can debug through.
While there would be nothing technically difficult about allowing identity conversion between two different roles with the same underlying type, I think it would be useful to disallow it – or at least warn about it – making people explicitly take the indirect route through the underlying type. That way the roles provide some protection against accidentally converting things across the role hierarchy.
Adaptation to interfaces
We can imagine using roles and extensions to implement additional interfaces on values of the underlying types.
Say there’s some framework that you plug into by implementing interfaces such as this:
We would like some of our person-like roles over
DataObject
to plug in to this framework. We can do this by expanding our declaration ofCustomer
to:The declaration of
Customer
explicitly mapsFullName
toName
, but does not need to contain an implementation ofID
because it “inherits” a suitable one from the underlyingDataObject
type.Now the
Customer
role can be used to treatDataObject
instances as implementingIPerson
! It will satisfyIPerson
as a generic constraint, and there would be an implicit conversion – likely a boxing conversion – fromCustomer
toIPerson
.Combining roles and extensions
In the previous example, the developer (or source generator!) who declared
Customer
may not be aware of the person framework or interested in integrating with it. Again, this can be solved with an extension. TheCustomer
role itself can be extended to fulfill the interface!Whoever imports this extension can then use unsuspecting
Customer
s – who are themselves unsuspectingDataObject
s – asIPerson
s!Generalized aliases
Simple roles without bodies can serve as a better form of aliases, essentially rendering
using
aliases obsolete:Unlike using aliases, such a "role alias" can be public (or any other accessibility), can be generic, can participate in the file's declaration space of types, can be recursive, etc. They are real type declarations, but still work as if they are just synonyms for the underlying type.
Detailed design
Role declarations are a new form of type declaration:
A role is in some ways like a struct and in some ways like a class. Like a struct it cannot be derived from, and therefore cannot be
abstract
orsealed
. It also cannot have virtual or overriding members (except perhaps allowing it to override the members ofobject
similar to structs). It can implement interfaces, using a combination of its own members and ones inherited from the underlying type. Just like a struct, the role has a boxing conversion to those interfaces (though not directly toobject
).Somewhat like a class, however, a role specifies a kind of "base type" which we call the underlying type. Unlike a base type the underlying type is mandatory. On the other hand it can be any type, not just a class type. (We need to think about syntactic ambiguities in this position for tuples, pointer types etc.) At compile time the role inherits the members of the underlying type, just as a derived class inherits from its base class.
(Open design question: A role could be allowed to specify additional base roles. They would need to be applicable to the specified underlying type, and would provide a way to combine roles through a form of "multiple inheritance".)
The role transparently "wraps" the underlying type in such a way that values of the role and of the underlying type have identical runtime representation. For this reason a
struct_member_declaration
in a role body is prohibited from declaring additional instance state, either as a field, an auto-implemented property or a field-like event, on top of what's inherited from the underlying type.The underlying type of a role can be any type, even an interface, a value type, an array type, a type parameter, the
dynamic
type, a pointer type or another role. Thus a role can be used to enhance types that cannot normally be extended with new functionality. Certain kinds of underlying types are subject to restrictions that limit how the role can be defined or used. For instance, a role on a ref struct cannot specify interfaces, and does not have a boxing conversion. A role on a pointer type or ref struct cannot be used as a type argument or an array element type.In general, operations on a role are resolved exactly like those on a derived class: try to use the declarations in the role first, and if those aren't found, use the underlying type. The concrete mechanics of this for each operation needs to be specified in detail, but should generally be as similar as possible to how the corresponding operations are handled in a derived class.
The identical runtime representation of a role and its underlying type enables identity conversions to exist both ways between them. This in turn leads to the existence of identity conversions between constructed types where roles and their underlying types respectively are used as type arguments, as well as between array types where the element types are roles and their underlying types respectively.
Identity conversions in C# are generally transitive, which would allow them to apply not just to and from the roles and their underlying types but also between roles that have the same underlying type. However, it might nevertheless be reasonable to discourage direct conversion between unrelated roles on the same type, e.g. with warnings or even errors, so as to enable the enforcement of a "shadow type hierarchy" of roles. We have precedence for such diagnostics on identity conversions between tuples using different element names.
In addition, there is an implicit boxing conversion between a role and each of the interfaces it implements. This means that a new object is created whenever the role is converted to the interface, and the result will thus not be reference-equal to the underlying object, even if that is a reference type.
Any reference conversions, implicit and explicit, to and from the underlying type, are inherited by the role, although its own identity and boxing conversions take precedence, along with any user-defined conversions declared in the role.
The identity, reference and boxing conversions on a role can be used to satisfy generic constraints when the role is used as a type argument. This is the source of much of the feature's expressiveness, because roles can be used to "bridge" or "adapt" an existing class or struct (or other type) to an existing interface without modifying either, in such a way that values of the existing class or struct can be passed to generic methods using the existing interface as a constraint.
An extension declaration is simply a role declaration with a different keyword. In addition to all of the above, when an extension is in scope - either directly or via being brought in through a
using
orusing static
directive - it automatically applies as a "fallback" role for the underlying type. Similar to existing extension methods in C#, if a lookup fails on the underlying type, it is tried again against the extension. If multiple extensions apply, ambiguities between applicable members are resolved in the same manner as for extension methods.In addition to member lookups, extensions can apply when the underlying type is used as a type argument, allowing interfaces implemented by the extension to help satisfy constraints on the corresponding type parameter.
Implementation strategies
It is a cornerstone of the proposal that there is an identity conversion between a role and its underlying type, and that just like other identity conversions in the language it is essentially free – there is no representation change at runtime.
One implementation strategy is to use “erasure”, just as we do with
dynamic
vsobject
, with tuple names and with nullability annotations. Here, in the IL we generate, we really do use the same type (the underlying type in this case) and then use other metadata (attributes etc.) to communicate the extra information about which language-level "embodiment" of the type is meant here. In this way, at runtime, the value doesn’t change its type at all and the identity conversion is trivially “free”. Even for constructed types, e.g. arrays, the conversion is free because the underlying type is the same:Of course the added members still need to be encoded somewhere; it is likely that we would generate some form of type declaration under the hood to hold them.
Unfortunately, and erasure strategy will come up short for the scenario of roles and extensions implementing interfaces. Consider this generic data structure:
Assuming
Customer
implementsIPerson
, we should be able to create aRolodex<Customer>
:We can’t just “really” pass
DataObject
as the type argument because it doesn’t satisfy the constraint. There needs to be a realCustomer
type at runtime so that the constraint can be satisfied and the call toperson.FullName
in the body of the class can be resolved. Values of theCustomer
type must have identical runtime representation to their underlyingDataObject
, and the runtime must be able to understand that it can freely convert between them as a no-op, using the same bits. Essentially it needs to be like a wrapper struct: The bits are the same, the type is different.Furthermore, the returned
Rolodex<Customer>
implementsIEnumerable<Customer>
andDataObject
is identity convertible toCustomer
. SoRolodex<Customer>
must be implicitly reference-convertible toIEnumerable<Customer>
! The runtime needs to understand and allow this conversion:In short, there are things the runtime will need to understand about roles if we embrace their ability to implement interfaces.
More examples
Implementing
IEnumerable<T>
with an extensionSay we want to view a
ulong
as anIEnumerable<byte>
. We can do that with an extension implementing that interface:Now, wherever the extension is brought in you can iterate over the bytes in a ulong:
You can also use its boxing conversion to
IEnumerable<byte>
to access LINQ functionality:Implementing
IComparable<T>
IComparable<T>
is most commonly used as a constraint on generic types or methods that need to compare several values of the same type.With extension interfaces we can make types
IComparable<T>
that wouldn't normally be. For instance, if we have an enumWe can make it comparable with the extension declaration:
We can also extend only certain instantiations of generic types with an interface implementation. For instance, let's make all
IEnumerable<T>
s comparable with lexical ordering, as long as their elements are comparable:Now we can use an
IEnumerable<T>
as anIComparable<IEnumerable<T>>
wherever this extension is in force, but only as long as the givenT
is anIComparable<T>
itself. For instance, anIEnumerable<string>
would be comparable to otherIEnumerable<string>
s becausestring
is comparable, whereas anIEnumerable<object>
would not, becauseobject
is not.So with these two extensions in force, we can now compare two arrays of
Level
s:Because of the
LevelCompare
extension onLevel
it satisfies the constraint on theEnumerableCompare
extension onIEnumerable<Level>
, which therefore in turns makes theLevel[]
s comparable!Conclusion
Roles and extensions support proper separation of concerns, lightweight typing and adaptation, reducing dependencies and the costs of wrapping and conversion. There's a good amount of design work left to do, and in their full generality they are probably a fairly big feature to implement, requiring the participation of the runtime.
Beta Was this translation helpful? Give feedback.
All reactions