Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Champion "Discriminated Unions" #113

Open
gafter opened this issue Feb 15, 2017 · 56 comments

Comments

@gafter
Copy link
Member

@gafter gafter commented Feb 15, 2017

  • Proposal added
  • Discussed in LDM
  • Decision in LDM
  • Finalized (done, rejected, inactive)
  • Spec'ed

See

@gafter gafter self-assigned this Feb 15, 2017
@gafter

This comment has been minimized.

Copy link
Member Author

@gafter gafter commented Feb 15, 2017

I wouldn't expect progress on this feature to precede records.

@gafter gafter changed the title Discriminated Unions Champion Discriminated Unions Feb 15, 2017
@gafter gafter added the Proposal label Feb 15, 2017
@DavidArno

This comment has been minimized.

Copy link

@DavidArno DavidArno commented Feb 20, 2017

@gafter,

I've started writing a draft proposal around #75. Would you like me to continue with this, or did you have a proposal planned yourself?

@gafter

This comment has been minimized.

Copy link
Member Author

@gafter gafter commented Feb 20, 2017

@DavidArno I do not expect to invest any significant effort on this until we make progress on records.

@DavidArno

This comment has been minimized.

Copy link

@DavidArno DavidArno commented Feb 20, 2017

@gafter,

OK, that's good to know. I'll carry on with my proposal then, but will take time over it as there's no rush.

@gafter

This comment has been minimized.

Copy link
Member Author

@gafter gafter commented Feb 21, 2017

/cc @agocke

@agocke

This comment has been minimized.

Copy link
Contributor

@agocke agocke commented Feb 21, 2017

I will be moving my proposal of "closed" type hierarchies from Roslyn to this repo shortly. I also think we should explore "real" discriminated unions and have some thoughts on that, but it's still much more of an exploratory phase for me.

However, I think I'm close to a reasonably complete proposal for closed and an analyzer-based prototype. I'll happily champion this feature, as well.

@gafter gafter changed the title Champion Discriminated Unions Champion "Discriminated Unions" Feb 21, 2017
@gafter gafter modified the milestone: X.0 candidate Mar 17, 2017
@Richiban

This comment has been minimized.

Copy link

@Richiban Richiban commented Apr 26, 2017

One question that I think we need to ask (and I haven't seen anyone ask elsewhere) is whether the case classes can be used as types.

Allow me to illustrate with the example of the Option type:

public enum class Option<T>
{
	None,
	Some(T Value)
}

Obviously Option<T> is a type (an abstract base class), but are None and Some types? Since, in an OO language like C#, ADTs are probably actually implemented as type hierarchies one might be tempted to answer "yes", but I'm not sure it makes much sense.

public void MyMethod(Some<string> someString) // Is this allowed? It doesn't make much sense
{
	// ...
}

I think of ADTs as functioning more like enums, however they're actually implemented. So using each case as a type doesn't make sense, any more than this makes sense:

public enum Colours
{
	Red, Green, Blue
}

public void MyMethod(Blue colour)
{
	// ...
}
@alrz

This comment has been minimized.

Copy link
Contributor

@alrz alrz commented Apr 5, 2018

whether the case classes can be used as types.

I think it shouldn't be the case with enum class but with another "expanded" syntax which could represent more complex type hierarchies like an AST

class SyntaxNode {
  case class Statement { } // implicitly inherit from SyntaxNode, as in, a "case" of the said type
  case class Expression {
     case enum class Literal { Numeric, String }
  }
}
@mcintyre321

This comment has been minimized.

Copy link

@mcintyre321 mcintyre321 commented May 15, 2018

I think that feature can be added fairly simply using a custom type, in the same way Tuple<T0, ..., TN> was added.

I maintain a library, OneOf, which adds a OneOf<T0, ..., TN> type which has .Match<TOut>(Func<T0, ..., TOut>, ..., Func<T0, ..., TOut> methods. By using implicit operators to create the OneOf instances from values, the syntax is very terse and comprehensible. Also, the allocations are low because it's a struct and doesn't create intermediate 'builder' objects, unlike some other solutions.

The OneOf<T0, .., TN> type also provides .Switch and .TryGetTX(out TX value, out TRemainder remainer) methods.

Example of using a OneOf as a return value:

public OneOf<User, InvalidName, NameTaken> CreateUser(string username)
{
    if (!IsValid(username)) return new InvalidName();
    var user = _repo.FindByUsername(username);
    if(user != null) return new NameTaken();
    var user = new User(username);
    _repo.Save(user);
    return user;
}

example of Matching

    OneOf<string, ColorName, Color> backgroundColor = "Red"; //uses implicit casts to reduce overhead
    Color c = backgroundColor.Match(
        str => CssHelper.GetColorFromString(str),
        name => new Color(name),
        col => col
   );
    

As new types are added to the OneOf definition, compiler errors are generated wherever the union is Matched or Switched, as the methods are required to have the correct number of lambda parameters.

This can be included in the BCL without language changes, although I'm sure some syntactical sugar could be sprinkled.


this proposal was originally made at dotnet/roslyn#14208 and at #1524 . Sorry!

@Richiban

This comment has been minimized.

Copy link

@Richiban Richiban commented May 15, 2018

@mcintyre321 Your OneOf type can be described as equivalent to the Either type or Choice type such as that found in F#. However, the Either type is not an alternative to discriminated unions, in fact it is build on top of discriminated unions:

type Choice<'a, 'b> = Choice1Of2 of 'a | Choice2Of2 of 'b
@HaloFour

This comment has been minimized.

Copy link
Contributor

@HaloFour HaloFour commented May 15, 2018

@mcintyre321

While your library does accomplish providing a single discriminated union it also demonstrates the degree of boilerplate required to do so which is what this proposal seeks to reduce. Your types also don't work with C#'s recursive pattern matching which will make it much more efficient and much more capable to match over such a type:

var backgroundColor = ...;

// no delegate invocation required
Color c = backgroundColor switch {
    string str => CssHelper.GetColorFromString(str),
    ColorName name => new Color(name),
    Color col => col
};
@mcintyre321

This comment has been minimized.

Copy link

@mcintyre321 mcintyre321 commented May 15, 2018

@Richiban OneOf<T0, ..., TN> has up up to 33 parameters, so is more useful as a general return object than Either or Choice.

@HaloFour I agree it would be good to have switch and match support built in to the language,. butI would have thought that the delegate calls will be JITted. I'm not sure what the boilerplate you refer to is.

@HaloFour

This comment has been minimized.

Copy link
Contributor

@HaloFour HaloFour commented May 15, 2018

@mcintyre321

I'm not sure what the boilerplate you refer to is.

public enum class OneOf<T1, T2>
{
	First(T1 value),
	Second(T2 value)
}

vs. this*

* Yes, I know that you have all of the arities crammed into one, but the file is too large to link to a specific line.

@Richiban

This comment has been minimized.

Copy link

@Richiban Richiban commented May 15, 2018

@mcintyre321 I don't doubt it's usefulness (or the fact that it's better than Either at those situations).

My point was that discriminated unions are a much more general tool that can also solve the problem that Either solves.

I'm not sure how you would propose to implement the equivalent of this using an Either type?

type FavouriteColour =
    | Red
    | Green
    | Blue
    | Other of string
@mcintyre321

This comment has been minimized.

Copy link

@mcintyre321 mcintyre321 commented May 15, 2018

@Richiban The abilities to naming a DU is useful, but you still get a lot of value with anonymous DUs. Is it a show-stopper to not have named unions (initially at least)?

That said, there are some things that can be done.

A OneOf, as implemented, is a struct, but I think that in F# (by default) they are classes. So you could make OneOf a class too*, and have class FavouriteColour : OneOf<Red, Green, Blue, string> { }. One problem with this is that implicit operators aren't inherited, although I think maybe I saw a proposal suggesting that was coming.

Another alternative for naming is to use a Record type e.g. class FavouriteColour(OneOf<Red, Green, Blue, string> Value).

And you can always use an alias: using FavouriteColour = OneOf<Red, Green, Blue, string>; if it's just the code bloat that's a problem (rather than the lack of a named Type ).

I appreciate none of this is quite as nice as the F# approach, but perhaps the language could be extended to fix some of this. E.g. defining a union class FavouriteColour : OneOf<Red | Green | Blue | string> { } could cause the compiler to output the required implicit operators.

TBH I'm happy with any solution where

  • you can do ad-hoc definition of DUs in method signatures and member declarations
  • concrete types from any library can be used
  • exhaustive matching with compile errors after a change in parameters (obv!)

@HaloFour cramming it into one file is along the lines of https://referencesource.microsoft.com/#mscorlib/system/tuple.cs , although I admit OneOf.cs has become somewhat larger!

*There's a class-based OneOfBase in the library, but the name isn't great IMO.

@gafter gafter added this to X.0 Candidate in Language Version Planning Mar 6, 2019
@christiannagel

This comment has been minimized.

Copy link

@christiannagel christiannagel commented Mar 19, 2019

Adding nullability to generics, and allowing the generic type T to be either a struct or a class, but returning T? results in the compilation error "error CS8627: A nullable type parameter must be known to be a value type or non-nullable reference type."

    public interface IItemViewModel<out T>
        where T : class
    {
        T? Item { get; }
    }

A workaround is to either opt out of nullable with these generic types, ore duplicate the code for structs and classes.
As the runtime needs changes for records and discriminated unions, probably discriminated unions could be used to help with this scenario, e.g.

    public interface IItemViewModel<out T>
        where T : class | struct
    {
        T? Item { get; }
    }
@masonwheeler

This comment has been minimized.

Copy link

@masonwheeler masonwheeler commented May 1, 2019

@gafter

I do not expect to invest any significant effort on this until we make progress on records.

What's the rationale there, if I might ask? I don't see any obvious reason why records should be a dependency of DUs. Is there something non-obvious there, or is it simply a question of priorities?

@HaloFour

This comment has been minimized.

Copy link
Contributor

@HaloFour HaloFour commented May 1, 2019

@masonwheeler

I'd expect DUs to behave like an enum of record types, so to me it makes sense to nail down the record behavior first, or at least at the same time.

@YairHalberstadt

This comment has been minimized.

Copy link
Contributor

@YairHalberstadt YairHalberstadt commented May 1, 2019

@HaloFour.
Why just record types? Why not permit arbitrary types?

@HaloFour

This comment has been minimized.

Copy link
Contributor

@HaloFour HaloFour commented May 1, 2019

@YairHalberstadt

I didn't mean exclusively record types, but I think the two would work well together and should be a part of the same conversation.

@YairHalberstadt

This comment has been minimized.

Copy link
Contributor

@YairHalberstadt YairHalberstadt commented Jul 21, 2019

Niko Matsakis, who is part of the Rust language design team, has an excellent series of blogs discussing the benefits and limitations of enums (Rusts name for DUs), and proposing a way to combine the benefits of classes with that of DUs. I think it's well worth the read!

http://smallcultfollowing.com/babysteps/blog/2015/05/05/where-rusts-enum-shines/
http://smallcultfollowing.com/babysteps/blog/2015/05/29/classes-strike-back/
http://smallcultfollowing.com/babysteps/blog/2015/08/20/virtual-structs-part-3-bringing-enums-and-structs-together/
http://smallcultfollowing.com/babysteps/blog/2015/10/08/virtual-structs-part-4-extended-enums-and-thin-traits/

I very much hope the LDM considers his ideas in their design for DUs in C#.

@agocke

This comment has been minimized.

Copy link
Contributor

@agocke agocke commented Aug 6, 2019

@YairHalberstadt Yes! This is an amazing series of posts which I read at the time, although I never saw the last one, so I'll have to catch up.

I was very happy to read these because I think they mirror my general idea of what discriminated unions would look like in C# via an enum struct and enum class approach.

The enum struct would be like Rust enums, where the size of each type is at least the size of the largest type (probably more because the CLR won't allow us to overlap reference and value types), while the enum class would define an inheritance-based tree, with the main difference from inheritance graphs you define today being that enum class would be sealed to the current compilation, meaning that the object graph is closed and we can do exhaustiveness checking during switching.

This does imply a third type of enum, enum interface, which seems interesting as allowing a closed set of interfaces.

@hez2010

This comment has been minimized.

Copy link

@hez2010 hez2010 commented Aug 7, 2019

In my opinion the pattern matching is "incomplete" if there's no discriminated unions. Could we expect to see this feature in C# 9 or 10? :)

@jnm2

This comment has been minimized.

Copy link
Contributor

@jnm2 jnm2 commented Aug 7, 2019

@hez2010 In https://nodogmapodcast.bryanhogan.net/124-mads-torgersen-c-8/, Mads mentions that the two features they definitely want C# 9 to include are record types and discriminated unions.

@gafter

This comment has been minimized.

Copy link
Member Author

@gafter gafter commented Aug 28, 2019

Targeting for C# 9.0, along with records. It is not clear if we will do records first without this and add discriminated unions later, or do both at the same time. Either way, we want to do enough of the design work during C# 9.0 that we are confident in our design decisions on records (that they have a path to adding discriminated unions either at the same time or later).

@ielcoro

This comment has been minimized.

Copy link

@ielcoro ielcoro commented Aug 29, 2019

Why are discriminated uniones restricted to records? Wouldn't this feature provide much more value if supports regular classes, structs etc?

Also, why are we inventing a new syntax and not considering proben examples from F# or even Typescript:

type Shape = Square | Rectangle

And also directly in the declaration site:

function foo(shape: Square | Rectangle

Looks like this would fit nicely in C#:

class Shape = Square | Rectangle;

public void Foo (Square | Rectangle shape) { }

Just adding so that the team can consider if it proves valuable.

@orthoxerox

This comment has been minimized.

Copy link

@orthoxerox orthoxerox commented Aug 29, 2019

A bucket list of my feature requests for DUs:

  1. exhaustiveness checking
  2. deconstruction via property or deconstructor pattern
  3. common fields/properties
  4. support for both class-based and struct-based DUs
  5. multi-level DUs (with flattened representation)
  6. non-generic options inside generic DUs
@jnm2

This comment has been minimized.

Copy link
Contributor

@jnm2 jnm2 commented Aug 29, 2019

@ielcoro I really want that too. That's not a tagged union though. See #399.

@hez2010

This comment has been minimized.

Copy link

@hez2010 hez2010 commented Sep 20, 2019

It's really useful for WebApi, you no longer need message and code properties for all your models but simply:

public (ErrorResultModel | MyResultModel) GetXXX()
{
    if (......) return new ErrorResultModel { code = 401, message = "unauth" };
    ....
    return new MyResultModel { result = "xxxx" };
} 

And also if you want to append properties in the other model to your model, you don't have to create a new class or return an anonymous object to combine them, but simply:

public (MyModel1 & MyModel2) GetXXXBoth()
{
    return new (MyModel1 & MyModel2) { ...... };
}

Furthermore, typescript has discriminated unions, so it will make it easier to proceed data between front-end and back-end.

@HaloFour

This comment has been minimized.

Copy link
Contributor

@HaloFour HaloFour commented Sep 20, 2019

@SamPruden @hez2010

I believe that you are both referring to the functionality as described in this proposal: #399

Discriminated unions are a bit different. They represent a closed hierarchy of types, but they are their own type hierarchy and you'd refer to them by that base interface or abstract class.

@SamPruden

This comment has been minimized.

Copy link

@SamPruden SamPruden commented Sep 20, 2019

I believe that you are both referring to the functionality as described in this proposal: #399

Discriminated unions are a bit different. They represent a closed hierarchy of types, but they are their own type hierarchy and you'd refer to them by that base interface or abstract class.

You're right, sorry. I had both tabs open and am apparently not a very careful github submitter. I'll move it over. Sorry for the clutter!

@gafter

This comment has been minimized.

Copy link
Member Author

@gafter gafter commented Oct 29, 2019

Prioritization of runtime support for efficient switch on a discriminated union is being tracked internally at https://devdiv.visualstudio.com/DevDiv/_workitems/edit/1009897 and the work is being tracked at dotnet/coreclr#23241

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.