Readit News logoReadit News
tombert · a year ago
Huh, I did F# for years with discriminated unions, and I guess I just assumed C# would have had them by now.

I know not everyone likes them, but for typed languages I find it extremely hard to go back to languages without ADTs of some kind. I do Java for my current job, and Java is generally fine enough, but it's a little annoying when I have to do whacky workarounds with wrapper classes to get something that would be done in three lines of F#.

Eji1700 · a year ago
F# is so hard to walk back from. I wish Microsoft would support it better and actually push it, because it's such a perfect sweet spot. Most of the functional advantages without being shackled to pure functions and the like is so easy to develop in.

Instead they've been, very slowly, turning C# into F#, which is even weirder to watch.

tombert · a year ago
I agree; F# is probably my favorite of the "compromise" functional languages [1], where you can drop into the "wrong" way when necessary. F# pushes a more or less pure approach, but in some cases, like when mutation will be a bit easier and/or faster, it's easy to do that as well. I also think that even F#'s OOP is actually really pleasant...If nothing else, it's a lot more terse than C#'s.

I miss writing it; I haven't had a job using it in a few years but I really enjoyed writing code in it when I did. Most of my personal projects haven't really been able to use .NET for awhile, so I never seem to have an excuse to play with F# in my personal time.

I never got into C#, so I can't say that I really feel the pain of the C# transition into F#.

[1] At least in the typed world. I'm also really partial to Clojure.

electroly · a year ago
I'm glad they're simply turning C# into F#. They're coming to us where we are, rather than forcing us to make a jump. I'm a relatively "blue collar" developer, I know a little about FP (not enough to be productive in F#) but I have tons of C# experience. I appreciate them taking the good stuff from F# and making it understandable to C# developers. It's been a delightful progression; no big jumps, no paradigm shifts, just the steady addition of useful new features that I can understand.
kriiuuu · a year ago
I feel the same about Scala. I use Scala3 daily and almost every other language is such a step back. I have looked at F# and it looks like one of the only other languages I would enjoy as much as Scala. Haskell is nice too, but the ecosystem is just not quite there. F# being able to tap into the rich C# ecosystem and Scala being able to tap into the Java ecosystem is such a win and makes them feel a lot less niche when you use them.
ctenb · a year ago
It makes sense from a language adoption standpoint, especially if you look at typescript for example, which is also by Microsoft. Though I agree with you personally
kbgart · a year ago
Looking at the syntax in the type union proposal, I hope they take a bit more from F#. It really needs some work.
kriiuuu · a year ago
Good news is that you can achieve this with relatively little boilerplate in Java with sealed interfaces and records now. Relative to Java that is
karmakaze · a year ago
Java can't do the ad-hoc mixing of types from different libraries, which is something I've wanted/needed for some ORM+expressions work I was doing. I really like where C# is going with this. Hopefully it will accelerate continuation of the evolution of Java.

I'm almost of the mind to use F# or C# instead of waiting.

tombert · a year ago
Yeah, I know, and I have started using those, though that doesn't undo the last 15 years of Java code that I've written where that wasn't really an option.

Still, good to see Java joining the 21st century at least.

Arnavion · a year ago
As of 2020 (when I stopped using F# and .Net in general), the CLR only had inheritance and F#'s enums were actually implemented using inheritance. Eg Some and None were derived classes of Option class. You would see it if you inspected an F# assembly in a decompiler. I don't know if it's still the same today. This C# proposal has anonymous enums using `A or B` syntax which would be hard to make work as sugar over inheritance, so I guess the CLR would support enums first-class for this to work (if it doesn't already).
Lanayx · a year ago
This is only partially true. Both Some and None were always instances of single FSharpOption class (None is actually not an instance, but null), however custom DUs are indeed compiled into classes with inheritance
tkubacki · a year ago
what's is your .NET replacement ?
bad_user · a year ago
FYI, Java has tagged union types (since version 16).

Pattern matching too.

sideeffffect · a year ago
> Java is generally fine enough, but it's a little annoying when I have to do whacky workarounds with wrapper classes to get something that would be done in three lines

This is a weird take, given that Java has had ADTs for years

Records https://en.wikipedia.org/wiki/Java_version_history#Java_16

Sealed classes https://en.wikipedia.org/wiki/Java_version_history#Java_17

And pattern matching for almost a year https://en.wikipedia.org/wiki/Java_version_history#Java_21

Otherwise big agree on how delightful to work with F# is.

tombert · a year ago
Most of my jobs until recently have been using Java 11, so I didn’t get to use these things.
johnzabroski · a year ago
Hmm. You find it hard to go back to languages without ADTs yet you use Java for your day job. Okay.
kkukshtel · a year ago
Really excited for this proposal as it's been the main thing I have to apoligize for when extolling the values of C#. Outside of this it's hard to think of other major features C# lacks for a language.

Additionally, excited to now watch this go in and people on HN still act like C# is the same it was 10 years ago.

codr7 · a year ago
Proper type aliases would be nice, but I have to say I really enjoy coding C# these days.
kkukshtel · a year ago
Alias-ing any type was added in C# 12 - do you mean something more than this? https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/cs...
neonsunset · a year ago
Why hello again :) I really appreciate your projects and noticed the web server one. There's an ask - please prefer built-in containers unless you have to use custom ones, at least for now. It is also not necessary to define aliases for types accessible directly: `using Namespace.AnotherNamespace` is enough to access them. There is no need to re-define the alias for `AnotherNamespace` if it matches either. When you have time, please look at sharpl PR which simplifies the implementation and makes it more compiler-friendly: https://github.com/codr7/sharpl/pull/2

In any case, welcome to C# and thanks!

madeofpalk · a year ago
> it's hard to think of other major features C# lacks for a language

Heh yeah well, for better or worse, it seems to be C#’s design goal to have every single feature possible, with multiple variations of each of them. It means it’s able to cater to everyone and doesn’t turn people off the way strongly opinionated languages can. But it can make it a bit tricky to pick up.

kkukshtel · a year ago
> Every single feature possible, with multiple variations of each of them

I'd really push back against this - where do you see API duplication? IMO the C# team is _incredibly_ conservative with new language features and only add in things that fit a missing part of the language, and when doing so try to also weave in existing C# features to build upon.

I think if anything you may be referring to syntax options? There are a few syntactical ways to do the same things for sure, but I don't think that's bad, as each has their own use. But for bigger tentpole features like records or Span<T> or whatever, they are directly addressing a need and aren't just iterative versions of other things.

tubthumper8 · a year ago
Can anyone explain why this is called "type unions"? I've never heard it named that before. It's a bit weird, because it's not a union across types (like ALGOL68), it appears to be a tagged union like in ML-family languages.

Is this just a case of "C# developers like to make up different names than established terminology"? (see: SelectMany, IEnumerable, etc.)

kgeist · a year ago
"Tagged" sounds like an implementation detail to me (that it has a "tag" internally to tell between types). I suspect they added "type" to "union" to make it clear what is about (about types). The syntax itself has just "union", judging by the document.

UPD. They have this in the FAQ:

  Q: Why are there no tagged unions?
  A: Union structs are both tagged unions and type unions. Under the hood, a union struct is a tagged union, even exposing an enum property that is the tag to enable faster compiler generated code, but in the language it is presented as a type union to allow you to interact with it in familiar ways, like type tests, casts and pattern matching.

tubthumper8 · a year ago
I don't think tag is an implementation detail generally, for example:

    enum Option<T> {
        None,
        Some(T),
    }
The tags are "None" and "Some" which are definitely user facing.

But I see what they mean a bit with the C# example:

    union U 
    {
        A(int x, string y);
        B(int z);
        C;
    }
So it seems like "A", "B", and "C" are tags but also their own distinct types, with a implicit conversions between those and the overall union type

orthoxerox · a year ago
Because the proposal is about several kinds of unions:

- closed hierarchies of reference types that the compiler will verify at build time

- value types that use compiler tricks to behave like closed hierarchies (tagged unions)

- custom types that can have any implementation but can hook into the same compiler mechanisms to behave like a union of types

- ad-hoc unions of existing types

arwhatever · a year ago
I’ve lost track of all of the red/blue/white/black pill color metaphors, but unions with exhaustive pattern matching is one of the toughest of all programming language features to live without once you become aware of it.

I’ve never felt like I’ve fully understood the implications of the expression problem https://en.wikipedia.org/wiki/Expression_problem but my best/latest personal hypothesis is that providing extension points via conventional polymorphism might be best suited for unknown/future clients who might extend the code, but unions with exhaustive pattern matching seem better suited for code that I or my team owns. I don’t typically want to extend that code. More often, I instead want to update my core data structures to reflect ongoing changes in my understanding of the business domain, more often than not using these Union-type relationships, and then lean on the compiler errors maximally to get feedback about where the rest of the imperative code now no longer matches the business domain data structures.

JackMorgan · a year ago
I wrote this to help me understand the differences.

https://deliberate-software.com/christmas-f-number-polymorph...

peheje · a year ago
Maybe I'm off, but to me the gist of the expression problem can be explained by contrasting how code extensibility is achieved in OOP/FP.

OOP Approach with interface/inheritance:

Easy: Adding new types (variants) of a base class/interface. Hard: Adding new functionality to the base class/interface, as it requires implementing it in all existing types.

FP Approach with Discriminated Unions:

Easy: Adding new functions. Create a function and match on the DU; the compiler ensures all cases are handled. Hard: Adding new types to the DU, as it requires updating all existing exhaustive pattern matches throughout the codebase.

Here's some Kotlin code. Kotlin is great because it can do both really well.

  // Object-Oriented Approach
  interface Shape {
      fun area(): Double
      fun perimeter(): Double
  }

  class Circle(val radius: Double) : Shape {
      override fun area() = Math.PI \* radius \* radius
      override fun perimeter() = 2 \* Math.PI \* radius
  }

  class Rectangle(val width: Double, val height: Double) : Shape {
      override fun area() = width \* height
      override fun perimeter() = 2 \* (width + height)
  }

  // Easy to add new shape
  class Triangle(val a: Double, val b: Double, val c: Double) : Shape {
      override fun area(): Double {
          val s = (a + b + c) / 2
          return Math.sqrt(s \* (s - a) \* (s - b) \* (s - c))
      }
      override fun perimeter() = a + b + c
  }

  // Hard to add new function (need to modify all existing shapes)
  // interface Shape {
  //     fun area(): Double
  //     fun perimeter(): Double
  //     fun draw(): String  // New function
  // }

  // Functional Approach
  sealed class ShapeFP {
      data class CircleFP(val radius: Double) : ShapeFP()
      data class RectangleFP(val width: Double, val height: Double) : ShapeFP()
  }

  fun area(shape: ShapeFP): Double = when (shape) {
      is ShapeFP.CircleFP -> Math.PI \* shape.radius \* shape.radius
      is ShapeFP.RectangleFP -> shape.width \* shape.height
  }

  fun perimeter(shape: ShapeFP): Double = when (shape) {
      is ShapeFP.CircleFP -> 2 \* Math.PI \* shape.radius
      is ShapeFP.RectangleFP -> 2 \* (shape.width + shape.height)
  }

  // Easy to add new function
  fun draw(shape: ShapeFP): String = when (shape) {
      is ShapeFP.CircleFP -> "O"
      is ShapeFP.RectangleFP -> "[]"
  }

  // Hard to add new shape (need to update all existing functions)
  // sealed class ShapeFP {
  //     data class CircleFP(val radius: Double) : ShapeFP()
  //     data class RectangleFP(val width: Double, val height: Double) : ShapeFP()
  //     data class TriangleFP(val a: Double, val b: Double, val c: Double) : ShapeFP()
  // }

swlabrtyr · a year ago
When statement will be exhaustive thanks to the sealed class, so compiler catches this, one of the great things about Kotlin.
arwhatever · a year ago
I think you've nailed the definitions.

However the definitions make the 2 choices (adding new types vs adding new functions/operations) sound like a toss-up.

It is the fact that I tend to find the FP/DU approach so much more frequently useful for my/my team's own code that makes me wonder if I'm missing something.

Perhaps the important distinction I've been missing is in Wikipedia's definition:

"The goal is to define a data abstraction that is extensible both in its representations and its behaviors, where one can add new representations and new behaviors to the data abstraction, without recompiling existing code, and while retaining static type safety (e.g., no casts)."

... but when I'm working on my own/team's code, it is perfectly sensible to recompile the code constantly.

orra · a year ago
Is the terminology slightly off? AIUI TypeScript has type unions.

But this looks like a discriminated union which I'd recognise from F# or Haskell. The distinction I'd draw is that for a DU there are named case constructors. Yes?

wk_end · a year ago
TypeScript has "union types". This proposal refers to them as "ad hoc unions". I don't think "type union" is a term of the art - at least I haven't heard it before. It seems to be something the C# people are making up to describe sum types.
SideburnsOfDoom · a year ago
> I don't think "type union" is a term of the art

"sum type" is the term of art in Computer Science theory, but "union type" is also used.

See

https://en.wikipedia.org/wiki/Type_theory#Sum_type

https://en.wikipedia.org/wiki/Tagged_union

textlapse · a year ago
Going from C# to Typescript with type unions it always felt weird. Maybe that’s a good reason to have held off on this for this long? Know your audience and all that?

It does get used to but still reading the too many A|B|undefined gets tiring after a while. It also adds to laziness as you could take one thing and be fuzzy about returning a combo of three or more things. And as you go up the stack this gets more and more confusing.

C# forces you to deal with this in a constrained way which I like.

But perhaps there are folks who are excited by this that have a convincing argument? Please educate me.

arnath · a year ago
As a long time C# dev, I feel like I’m missing something about this proposal. The use case for this doesn’t seem well defined to me. Could someone give me a real world example?

I’m pretty sure you can implement the example in this proposal by declaring an empty interface and having some record classes that “implement” it. Does that lose something that I’m not seeing?

alkonaut · a year ago
That type of hierarchy is “open” in the sense that when you handle them you can’t know whether you handled all of them. Anyone can make a new implementation of your interface, potentially in code that isn’t yours.

Also, the options may not share any surface area at all, so the “interface” for all the types may be empty which is a bit unnatural in OO.

But under the hood, it’s just this: a type hierarchy. But the hierarchy is closed for extension, and (this is the key part you’d not get if doing this “manually” as you describe) when code uses the cases, failing to account for a case will cause an error at compile time.

larusso · a year ago
I think the most down to earth example is the AST or data protocol examples. Take Jason for example. Instead of having a JsonValue class to hold the data you would have a Union type that is either string, bool, number, array of your union type or map type which uses a string as key and the same union type as value. It would also allow to implement result types like rust has them. So you could define an API that returns a value or an error. I didn’t see in the proposal if they talked about generics though. In functional programming world this is mainly known as algebraic data types. I can say that if you get used to model types like this you really start to miss it in languages that don’t support it.

[1] https://en.m.wikipedia.org/wiki/Algebraic_data_type

conor- · a year ago
This[0] lib implements F#-style discriminated unions. The way I've used it is to make my outer layers return "Result" types that wrap the DTO, error, etc. under one umbrella. It allows for pattern matching your results which makes error handling easier and also allows your models to evolve over time without changing your function signatures at the edge.

[0] https://github.com/mcintyre321/OneOf

hand2note · a year ago
We have been using empty interfaces to simulate F# discriminated unions for the last 5+ years. It works like a charm. The problem "not all cases are handled in the switch" occurs very rarely (once per 50k lines of code) and, usually, we fix it after the first run of a smoke test. So, it's not a problem at all.

I think,.net team haven't added discriminited unions yet because it can be effectively simulated with interfaces.

sbergot · a year ago
A simple example is the Result<T> which could have either Ok(T value) or Error(string message). In order to get the value you need to switch over both cases, forcing you to handle the error case locally.
bazoom42 · a year ago
Like an enum, a union have a closed set of elements so it can be statically checked all cases are handled.

With an interface, someone could add a new implementation, causing codee to fail at runtime.

Renaud · a year ago
I found this video quite useful in showing the new proposal in action.

https://youtu.be/aksjZkCbIWA

LandR · a year ago
Ha! Under covariance / contra variance

> Note: Have Mads write this part.

:)

lyu07282 · a year ago
Mads Torgersen, lead designer of C#, just to clarify lol
zamalek · a year ago
> The interior layout of the union struct is chosen to allow for efficient storage of the data found within the different possible member types with tradeoffs between speed and size chosen by the compiler.

Having _attempted_ (and being bitten by) far too much black magic with C# unions in the past (using FieldOffset), there is an unfortunate situation here: aliasing a pointer/ref value and value is UB. This means that a struct union of a u64 and an object would need separate fields for each, wasting 8 bytes. That is, unless the ryujit/gc is updated with knowledge about this.

jasomill · a year ago
Not UB, illegal. Per ECMA 335, II.10.7:

It is possible to overlap fields in this way, though offsets occupied by an object reference shall not overlap with offsets occupied by a built-in value type or a part of another object reference.

Per .NET 8.0:

  using System.Runtime.InteropServices;
  var s = new S { Bar = "Baz" };
  [StructLayout(LayoutKind.Explicit)]
  public struct S {
      [FieldOffset(0)] public UInt64 Foo;
      [FieldOffset(0)] public Object Bar;
  }
compiles, but throws

System.TypeLoadException: Could not load type 'S' from assembly 'foo, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' because it contains an object field at offset 0 that is incorrectly aligned or overlapped by a non-object field.

Interestingly, this code elicits a warning

ILC: Method '[foo]Program.<Main>$(string[])' will always throw because: Failed to load type 'S' from assembly 'foo, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' because of field offset '0'

from the AOT compiler, but none from the C# compiler.

jaredpar · a year ago
> from the AOT compiler, but none from the C# compiler.

The C# compiler has very little knowledge of `[FieldOffset]`. It's expected that developers understand the runtime implications of this.