[Draft Issue] Intersection and union types #4628
Replies: 6 comments 19 replies
-
The use of Would you be able to use |
Beta Was this translation helpful? Give feedback.
-
Should an invoked member's return type be allowed to vary? // aOrB is (A or B)
var id = aOrB.Id;
// id is (int or string)
class A
{
public int Id { get; }
}
class B
{
public string Id { get; }
} |
Beta Was this translation helpful? Give feedback.
-
At first this proposal looks nice. But the problems mentioned in the motivation are really an issue for csharp. In my eyes it would be much better if we could simplify the usage of adapter types which handle all the required logic. Let`s look for example at: using System;
using System.Threading.Tasks;
IAsyncDisposable disp = new DisposableToAsyncDisposableAdapter(new Something());
await disp.DisposeAsync();
struct Something : IDisposable
{
public void Dispose()
{
Console.WriteLine("Dispose");
}
}
struct DisposableToAsyncDisposableAdapter : IAsyncDisposable
{
private readonly IDisposable disp;
public ValueTask DisposeAsync()
{
//what ever
disp.Dispose();
return ValueTask.CompletedTask;
}
public DisposableToAsyncDisposableAdapter(IDisposable disp)
{
this.disp = disp;
}
} It would be nice if we could omit the explicit usage of the adapter class - which could be solved by an "Interface to Interface" conversion like: public static implicit operator IAsyncDisposable(IDisposable disp)
{
return new DisposableToAsyncDisposableAdapter(disp)
} Sadly, such a conversion function is not allowed. In my eyes the main reason to disallow such a conversion was the question "what should be done if multiple conversions/adapters are available?". |
Beta Was this translation helpful? Give feedback.
-
|
Beta Was this translation helpful? Give feedback.
-
The differences between this proposal and #399 I have noticed-
|
Beta Was this translation helpful? Give feedback.
-
This doesn't take generic constraints into account. I created another proposal for generic things in #5085. |
Beta Was this translation helpful? Give feedback.
-
This is a draft issue. Please discuss. Depending on feedback I may open as an actual issue soon.
Union and Intersection Types
Summary
Add union types (which express that an instance is an instance of at least one of a set of types) and intersection types (which express that an instance is an instance of all of a set of types).
See earlier discussion at #399
Motivation
Union Types
It's common to have to work with something which can be one of a number of different types.
Discriminated unions offer one solution to this but they are somewhat heavy. You need to declare a new discriminated union type, you need to give the cases names, you need to wrap/unwrap the instance in a discriminated union. Furthermore the DU is a whole separate instance - there's no reference equality between the instance of a the DU, and the instance of the type it wraps.
Sometimes all of that is desirable, but often you want to work with such data in a more Ad Hoc fashion. For example an IOC container might want to store an array containing both
IDisposables
andIAsyncDisposables
without completely losing type safety:It's also sometimes desirable to call a member on an instance which you know is one of a set of types which all have that method, but you don't know which. For example, this could simplify the current implementation of
IEnumerable.Count()
to:Intersection Types
In other cases you want to work with data which implements 2 separate interfaces. Currently the only way to do that is to constantly cast between the two interfaces to call the separate methods on each interface, losing type safety in the process.
Note that this is already possible in C# using generic constraints - you can constrain a type parameter to multiple types, and the type parameter will act as if it's an intersection type. This is widely used. This proposal suggest allowing that in the general case, not just via generic constraints.
Detailed design
Syntax
I would suggest reusing the
or
andand
keywords. This is because there's no semantic difference between a pattern match which checks if an instance is either anint
, or astring
, and one which checks if an instance is aunion(int, string)
. Therefore we can safely reinterpret the existing patternx is int or string
as pattern matching on the union typeint or string
without changing the semantic meaning of existing patterns. I think the naming also feels intuitive. The same goes forand
.In some cases parenthesis will be required to disambiguate the type. e.g
a and b or c
to((a and b) or c)
or(a and (b or c))
.When pattern matching, to declare a variable for an intersection/union pattern the pattern must be parenthesized, so this is legal:
x is (int or string) intOrString
, but this is not:x is int or string intOrString
.Semantics
Let us call the set of types that make up a union/intersection type the typeparts of that type.
SubTyping
For this purpose a type is both a subtype and a supertype of itself (i.e. we're referring to non strict sub/super types).
As always there is an implicit reference conversion from a subtype of a union/intersection type to the union/intersection type, and from a union/intersection type to a supertype of the unon/intersection type.
Downcasting / pattern matching
The ability to downcast/pattern match to and from a union/intersection type should fall out of existing language rules.
This includes use of casts,
is
andas
.The meaning should also be straightforward. Downcasting to a union/intersection type is an O(n) operation in the number of typeparts.
Switching
A switch expression which handles all typeparts of a union type is considered to be exhaustive and will not warn.
A case which matches on an intersection type, with no when clause, is considered to handle each of the typeparts of the intersection type.
Members
A union type is considered to have any instance member which exists with the exact same signature on all typeparts of a union type.
For this purpose the order in which the typeparts of a union type are declared matters, as if a type is a subtype of multiple of these typeparts, we will call the implementation for the first typepart it is a subtype of.
Calling a member of a union type is an O(n) operation in the number of typeparts.
An intersection type is considered to have all instance members it would have if it inherited/implemented all typeparts. This is meant to be consistent with the rules for a type parameter with multiple constraints. Take a look there for the formal definition of this. In practice this means that if multiple typeparts declare the same member with the same signature, it may be illegal to call that method as it may be ambiguous.
Miscellaneous
Using a struct as a union typepart will involve boxing the struct.
Structs cannot be used as intersection typeparts.
At most one typepart of an intersection type can be a class. The others must all be interfaces.
It is an error to declare a union type where one typepart is a subtype of another typepart.
a and b
is the same type asb and a
, buta or b
is not the same type asb or a
(although there is an identity reference conversion from each one to the other).Note that we are not changing the best common type specification to make the best common type be the union of the types. This is deliberate, as that would mean that if you make a mistake and have some of your expressions return the wrong type, you find out about it much later down the line or not at all. For example, if you did the following:
You would get an error message "x does not contain member SomeMethod" instead of "no best type found for Task and MyType".
CodeGen
Whilst it would be possible to try and implement this via type erasure, there are lots of odd corner cases which wouldn't work without full runtime support. For example you wouldn't be able to assign
IEnumerable<string or char[]>
toIEnumerable<IEnumerable<char>>
despite covariance rule allowing this, as the runtime type would beIEnumerable<object>
. I believe there's enough such gotchas that this simply wouldn't be worth it.Instead we would need the runtime to provide native support for union and intersection types, using the same subtyping rules as mentioned above.
It would have to be decided whether the runtime would be responsible for simulating members on union/intersection types, or the compiler. My bias would be to have the runtime handle intersection type members, but leave it to the compiler for union types as different languages may want to use different rules. For example VB might consider two members to have the same signature if they differ only by casing, whilst C# wouldn't. The compiler would lower member calls on union types to a switch expression which matches on successive typeparts, and calls the member on the first matching typepart.
i.e.
would be lowered to
As discussed above, this means the order in which a union type is declared matters. Note that if you want the opposite order, there is an implicit identity conversion from
(a or b)
to(b or a)
.Since the compiler simulates these members they will not be accessible via reflection.
In cases where the exact same method would be called for all typeparts - i.e. where all typeparts are interfaces which inherit from the same base interface, and you call a method defined by the common base interface, or similiar for classes and a virtual method defined by the common base class, the compiler will simply emit a virtual call to the method, instead of a switch.
Drawbacks
Alternatives
Discriminated Unions can handle some of the use cases of union types, but in a much more heavyweight way.
There's not really much of an alternative to intersection types I can think of at the moment.
Unresolved questions
Should it be an error to declare a union type where one typepart is a subtype of another typepart?
One reason you might want to do this is to access implicitly implemented interface members on a type, whilst still being able to access all the new members it defines itself, e.g.
However that comes at the cost of not being able to access any explicitly implemented members (since they would be ambiguous), so the cases this is actually useful might be rare.
Should members of a union have to have an exactly matching signature on all typeparts?
If we have the following:
There exists a member
M
which is capable of handling astring
and returning anobject
on bothIA
andIB
, so perhaps this should be legal?We could say that if overload resolution would select a single best member from each typepart, and there is a Best Common Type for the return types of each of those members, and the target type of any target typed parameter expressions match for each selected member, then we can access the member on the union.
This would make the feature significantly more powerful, but at the risk of making it extremely tricky for humans to work out what's happening here. We could make this slightly simpler by requiring all the return types to match, rather than using the best common type, and disallowing target typed parameter expressions.
Should
List<a or b>
have an implicit reference conversion toList<b or a>
Since
List<T>
is invariant, anda or b
andb or a
are different types, under current language rules it should be illegal to use aList<a or b>
where aList<b or a>
is expected, or vice versa. However, since they are mutual subtypes of each other, I don't believe this could ever actually cause any issues, and so should be allowed, and we should encourage the runtime to support it.Design meetings
Beta Was this translation helpful? Give feedback.
All reactions