1 puntos por GN⁺ 2024-08-09 | 1 comentarios | Compartir por WhatsApp
  • Es una propuesta para introducir type unions (discriminated unions) en C#, de modo que una variable o parámetro pueda expresar que solo contiene uno entre varios tipos limitados; actualmente está en etapa Proposed y prototype, implementation y specification figuran como Not Started
  • Las implementaciones existentes basadas en jerarquías de herencia o en object difícilmente pueden satisfacer al mismo tiempo un conjunto cerrado de tipos, combinaciones de tipos no relacionados, almacenamiento de valores sin wrappers y evitar asignaciones, por lo que se divide en cuatro categorías de unions
  • La propuesta distingue entre union class, union struct, ad hoc union y custom union, y cada enfoque tiene restricciones distintas en la forma de declaración, la presencia de asignaciones y la posibilidad de reutilizar tipos existentes
  • En switch y pattern matching, si se manejan todos los tipos miembro se ofrece exhaustiveness, por lo que no hace falta un default; sin embargo, en union struct, default puede no corresponder a ningún miembro declarado, así que se requieren advertencias y la posibilidad de indicar explícitamente un miembro default
  • El ad hoc union usa la sintaxis (A or B or C) para agrupar tipos existentes y se implementa con erasure y comprobaciones en tiempo de ejecución, pero tiene limitaciones como boxing de value types, imposibilidad de usar ref types y ausencia de true runtime overloading

Objetivo y motivación de la propuesta

  • Type unions for C# es una propuesta para introducir type unions, es decir, discriminated unions, en C#
    • El estado del documento es Proposed
    • Prototype, Implementation y Specification aparecen todos como Not Started
  • En el desarrollo de software, hay situaciones en las que una sola variable no debe contener siempre la misma clase de valor, sino uno entre varios tipos relacionados y limitados
    • Un ejemplo es cuando hay que realizar tareas parecidas sobre Customer y Supplier, que comparten solo algunas propiedades pero difieren en otros aspectos
  • Distribuir la implementación en cada tipo mediante métodos abstractos comunes o interfaces es adecuado cuando ese tipo existe para esa tarea o cuando dicha tarea es una parte esencial del tipo
    • Si el tipo tiene un propósito más amplio, quizá no sea deseable agregar esos métodos
  • También se puede crear un tipo base común como Contact mediante herencia, pero esto es difícil o inapropiado en los siguientes casos
    • Cuando no se posee la definición de los tipos
    • Cuando hay muchas situaciones parecidas y no se puede resolver todo con una sola jerarquía de herencia
    • Cuando no se quiere filtrar los requisitos de una tarea específica a la definición de los datos
  • Usar object puede funcionar, pero la garantía de que solo entren valores correctos debe mantenerse con documentación y comentarios
    • También se puede proteger con una jerarquía wrapper o un tipo agregado personalizado, pero si hay muchos conjuntos de tipos según cada situación, resulta lento y engorroso
  • El objetivo es que C# permita declarar que una misma ubicación almacena uno entre varios tipos limitados y que el lenguaje se encargue de proteger la variable

Cuatro categorías de union

  • Como es difícil cubrir todos los casos de uso con una sola implementación, se divide en cuatro categorías
  • Standard - union classes

    • Se usan cuando se quiere definir el union y sus miembros juntos, y cuando se pretende usar los miembros como clases independientes
    • Están pensadas para los casos en que la asignación de clases no representa un problema
    • Ejemplos:
      • protocolos, serialización, tipos de transferencia de datos
      • modelos de datos de UI (XAML)
      • syntax tree
      • estados de máquinas de estado que no cambian con frecuencia
      • otros modelos de datos polimórficos
      • valores que se mantienen durante mucho tiempo como union, como en campos o propiedades
  • Specialized - union structs

    • Se usan cuando hay que evitar asignaciones o se requieren tipos especializados, y se pueden aceptar ciertas restricciones a cambio de eso
    • Ejemplos:
      • valores asignados en arreglos contiguos
      • valores mapeados sobre bloques de memoria (interop)
      • estados de máquinas de estado que cambian con frecuencia
      • valores que se mantienen poco tiempo como union, como argumentos o valores de retorno
      • tipos de librería con posible uso especializado
  • Ad Hoc - ad hoc unions

    • Se usan cuando el union debe componerse con tipos ya existentes y que pueden no tener relación entre sí
    • Los unions declarados con los mismos tipos miembro deben poder intercambiarse entre sí
  • Custom unions

    • Es una opción para los casos que no encajan bien en otras categorías
    • Ejemplos:
      • tipos y jerarquías existentes que no pueden redefinirse fácilmente
      • layouts de almacenamiento personalizados
      • formas y comportamientos de API personalizados

Standard - union classes

  • union class es un named type union que coloca todos los tipos miembro dentro de una sola declaración self-contained
  • La declaración es similar a un enum, pero se diferencia en que cada miembro es un tipo que puede tener estado mediante una o más variables de estado
union U
{
    A(int x, string y);
    B(int z);
    C;
}
  • En cada miembro solo se puede indicar un nombre y una lista de variables de estado
  • La creación se realiza asignando el tipo miembro
U u = new A(10, "ten");
  • El tipo del miembro creado es A, y al asignarse a la variable u se convierte a U
  • La deconstrucción se realiza mediante type test y pattern matching
if (u is A a) { ... }

if (u is A(var x, var y)) { ... }

if (u is A { y: var y }) { ... }
  • union class se considera exhaustivo
    • Si en un switch expression o statement se manejan todos los tipos miembro, no hace falta un caso default
var x = u switch {
    A a => a.x,
    B b => b.z,
    C c => 0
    };
  • null puede incluirse con la notación nullable estándar
U? u = null;
  • La implementación se representa con una abstract record class y nested derived record class
[Closed]
abstract record U
{
    public record A(int x, string y) : U;
    public record B(int z) : U;
    public record C : U { public static C Singleton = new C(); };
}
  • El atributo Closed permite que el lenguaje entienda que es una jerarquía cerrada en la que no se declaran subtipos fuera del módulo del tipo base

Specialized - union structs

  • union struct también es un named type union que coloca todos los tipos miembro dentro de una sola declaración self-contained
    • Tanto el union como los tipos miembro son struct, por lo que pueden usarse sin heap allocation
  • La declaración es similar a la de union class, pero añade la palabra clave struct
union struct U
{
    A(int x, string y);
    B(int z);
    C;
}
  • La creación, deconstrucción, exhaustive switch y notación nullable son similares a union class
U u = new A(10, "ten");

if (u is A a) { ... }

U? u = null;
  • union struct puede quedar sin asignar o recibir default, entrando en un undefined state
    • Ese estado no corresponde a ninguno de los tipos miembro declarados
    • En un switch que dependa de exhaustiveness puede producirse una excepción en tiempo de ejecución
U u = default;

var x = u switch
{
    A a => a.x,
    B b => b.z,
    C c => 0
}
  • El compilador genera una advertencia cuando se asigna default a un struct union
// warning: default not a valid state
U u = default;
  • Para evitar la advertencia, se puede declarar un estado default en union struct y vincularlo a un tipo miembro específico
union struct U
{
    A(int x, string y);
    B(int z);
    C = default;
}
  • La implementación se expresa como un struct con tipos miembro record struct anidados y una API para convertir entre los tipos miembro y el aggregate union struct
    • El compilador elige el layout interno para almacenar de forma eficiente los datos de los posibles tipos miembro
    • El compilador también elige el tradeoff entre velocidad y tamaño
[Union]
struct U
{
    public record struct A(int x, string y);
    public record struct B(int z);
    public record struct C { public static C Singleton = default; };

    public static implicit operator U(A value) {...};
    public static implicit operator U(B value) {...};
    public static implicit operator U(C value) {...};

    public static explicit operator A(U union) {...};
    public static explicit operator B(U union) {...};
    public static explicit operator C(U union) {...};

    public bool TryGetA(out A value) {...};
    public bool TryGetB(out B value) {...};
    public bool TryGetC(out C value) {...};

    public enum UnionKind { A = 1, B = 2, C = 3 };
    public UnionKind Kind => {...};
}
  • El atributo Union identifica que ese tipo es un union struct
  • Un union struct con estado default declara el UnionKind correspondiente como 0
  • La API completa de creación del union struct aún no aparece en el documento

Prueba de tipos, boxing y reflection en union struct

  • Si se hace una prueba de tipos sobre un union struct conocido, no se comprueba el tipo del struct en sí, sino que se llama a la API del union struct
u is A a
  • La expresión anterior se transforma así
u.TryGetA(out var a)
  • La switch expression también se transforma a una forma que usa llamadas a Kind y TryGetX
u.Kind switch {
   U.UnionKind.A when u.TryGetA(out var a) => a.x,
   U.UnionKind.B when u.TryGetB(out var b) => b.z,
   U.UnionKind.C when u.TryGetC(out var c) => 0,
   _ => throw ...;
}
  • Un union struct boxed no significa que el valor del tipo miembro haya sido boxed, sino que el union struct en sí es el que fue boxed
    • El caso de uso principal del union struct es evitar boxing, pero en ocasiones puede ser necesario hacer boxing
  • Como se sabe que el tipo union struct y sus miembros están relacionados entre sí, se puede hacer prueba de tipos y unboxing de un union struct boxed hacia un tipo miembro
U u = ...;
object value = u;

if (value is A a) {...}
  • El código anterior se transforma así
if (value is A a || (value is U u && u.TryGetA(out a))) {...}
  • A la inversa, un tipo miembro boxed también puede probarse y desempaquetarse como union struct
A a = ...;
object value = a;

if (value is U u) {...}
  • Si no se puede saber estáticamente que ambos lados de la prueba de tipos están relacionados con el union struct, la prueba de tipos falla
bool IsType(object value) => value is T;

U u = new A(...);

if (IsType(u)) {...}
  • Al usar reflection, puede ser necesario convertir el tipo miembro boxed A en el union struct boxed U
  • La funcionalidad de struct union proporciona métodos utilitarios para convertir en runtime entre boxed union struct y boxed member type
public static class TypeUnion
{
    public bool TryConvert(Type unionType, object value, out object? boxedUnion);
    public bool TryConvert(object value, out TUnion union);
    public object? GetValue(object? boxedUnion);
}
  • union class y ad hoc union ya están en la forma correcta para usar reflection, por lo que no requieren conversión

Ref union structs

  • Un union struct con el modificador ref puede incluir refs o ref structs como variables de estado
ref union struct U
{
    A(ref int x);
    B(ReadOnlySpan y);
    C;
}
  • En ese caso, la implementación de la unión y los tipos miembro que tienen valores ref struct se convierten en ref struct
ref struct U
{
    public ref struct A { public ref int x; public A(ref int x) {...}; }
    public ref struct B { public ReadOnlySpan y; public B(ReadOnlySpan y) {...} }
    public record struct C { public static C Singleton = default; }
    ...
}
  • Si se añade ref record struct type a C#, los tipos miembro afectados podrán seguir siendo record struct

Ad Hoc - ad hoc unions

  • Una ad hoc union es una unión anónima hecha con tipos declarados en otros lugares
  • La sintaxis usa paréntesis y la sintaxis de pattern or
(A or B or C)
  • Para referenciarla con un nombre común, se usa un alias using de archivo o global
global using U = (A or B or C);
  • La creación se hace asignando una instancia de uno de los tipos miembro de la unión a una variable de tipo ad hoc union
record A(int x, string y);
record B(int z);
record C() { public static C Singleton = new C(); };

(A or B or C) u = new A(10, "ten");
  • La deconstrucción se hace con pruebas de tipos y pattern matching
if (u is A a) {...}

if (u is A(var x, var y)) { ... }
  • La ad hoc union también se considera exhaustiva, así que si se manejan todos los tipos miembro no hace falta un caso default
  • null puede incluirse con la notación nullable
(A or B)? x = null;
  • El compilador entiende como el mismo tipo a las ad hoc union con los mismos tipos miembro, sin importar el orden
(A or B) x = new A(10, "ten");
(B or A) y = x;

Asignación, intercambiabilidad e inferencia de ad hoc union

  • Una ad hoc union del mismo tipo o un subconjunto puede asignarse a una ad hoc union superset sin comprobación en runtime
(A or B) x = new A(10, "ten");
(A or B or C) y = x;
  • Para asignar una ad hoc union superset a una ad hoc union subset, se necesita una coerción explícita y una comprobación en runtime
(A or B or C) x = new A(10, "ten");
var y = (A or B)x;
  • La coerción implícita es posible sin comprobación en runtime si todos los tipos miembro de la unión de origen son iguales o subtipos de al menos un miembro de la unión de destino
(Chihuahua or Siamese) pet = ...;
(Cat or Dog) animal = pet;
  • Aun así, si uno o más tipos miembro del source son subtipo de uno de los tipos miembro del target, es posible hacer coerción explícita y verificación en tiempo de ejecución
(Cat or Chihuahua) mostlyCats = ...;
(Dog or Siamese) mostlyDogs = (Dog or Siamese)mostlyCats;
  • Incluso los tipos que no son una unión ad hoc pueden tratarse como una unión ad hoc de un solo tipo al determinar la asignabilidad
    • Esta regla también funciona con las interfaces implementadas
  • También se definen coerciones generalizadas
    • Si desde algún tipo hay una coerción implícita hacia uno de los tipos miembro de la unión, se puede hacer coerción implícita de un valor de ese tipo al tipo unión
    • Si todos los tipos miembro de una unión pueden convertirse implícitamente a algún tipo, el valor de la unión puede convertirse implícitamente a ese tipo
    • Si uno de los tipos miembro de una unión puede convertirse a algún tipo, el valor de la unión puede convertirse explícitamente a ese tipo
    • Si todos los tipos miembro de la unión source pueden convertirse implícitamente a uno de los miembros de la unión target, es posible la coerción implícita entre uniones
    • Si uno o más miembros de la unión source pueden convertirse explícitamente a uno de los miembros de la unión target, es posible la coerción explícita entre uniones
  • Todavía hacen falta reglas para decidir qué coerción elegir cuando hay varias posibles
  • Esta relación de asignabilidad no es una relación de subtipado
    • Una unión ad hoc no es subtipo de otra unión ad hoc
  • Las uniones ad hoc con los mismos tipos miembro pueden intercambiarse a través de genéricos y elementos de arreglos
(T1 or T2)[] F(T1 v1, T2 v2) => new (T1 or T2)[] { v1, v2 };

(Dog or Cat)[] pets = F(rufus, petunia);
  • Las uniones ad hoc usadas como argumentos de tipo genérico pueden usarse con covarianza y contravarianza cuando todos los tipos miembro de las dos uniones relacionadas tienen una relación de subtipo con su miembro correspondiente
    • En lugar de una regla concreta, todavía queda una nota que dice “Have Mads write this part”
  • Las uniones ad hoc se comportan de forma similar al patrón or en pattern matching, y también pueden incluir declaración de variables
if (u is Dog or Cat) { ... }

if (u is (Dog or Cat)) { ... }

if (u is (Dog or Cat) pet) {...}
  • Asignar a una variable de unión ad hoc puede provocar boxing de tipos por valor
  • Las conditional expressions y switch expressions pueden inferir un tipo de resultado de unión ad hoc a partir de las expressions que las componen
Dog rufus = ...;
Cat petunia = ...;
Bird polly = ...;

var u =
      x == 1 ? rufus
    : x == 2 ? petunia
    : polly;
  • El tipo de retorno de una lambda expression también puede inferirse como una unión ad hoc creada a partir de los tipos de retorno del cuerpo de la lambda
    • También en este caso puede producirse boxing de tipos por valor

Implementación y limitaciones de las uniones ad hoc

  • Las uniones ad hoc se implementan con erasure y verificaciones en tiempo de ejecución
(A or B) ab = new A(10, "ten");
  • El código anterior se transforma así
object ab = new A(10, "ten");
  • Las asignaciones cuya corrección no puede conocerse estáticamente requieren verificación en tiempo de ejecución
    • El compilador genera un método personalizado por cada unión ad hoc única usada en el módulo
object value = ...;
var ab = (A or B)value;
  • Un ejemplo de transformación es el siguiente
object value = ...;
object ab = (value);

object (object? value) =>
    value is A or B ? value : throw ...;
  • En la entrada del método no se verifican los parámetros
  • En los metadatos, los tipos de unión ad hoc se codifican usando atributos personalizados
void M((A or B) x);
  • Un ejemplo de transformación es el siguiente
void M([AdHocUnion([typeof(A), typeof(B)])] object x);
  • Los detalles del atributo todavía no están especificados
  • Como todas las uniones ad hoc se borran al mismo tipo, no es posible el true runtime overloading de métodos con parámetros de unión ad hoc
public void Wash((Cat or Dog) pet) { ... }
public void Wash((Compact or Sedan) car) { ... }
  • El overloading sigue siendo un área abierta a discusión

Uniones personalizadas

  • Si se necesita un comportamiento que no puede expresarse con la sintaxis de union class o union struct, se puede declarar manualmente una class o struct personalizada y hacer que C# la reconozca como un tipo de unión personalizado
  • Si la unión se implementa con una jerarquía de clases, se puede usar el atributo Closed para obtener el mismo comportamiento de exhaustividad que una union class
[Closed]
public class U { ... }
public class A(int x, string y) : U { ... }
public class B(int z) : U { ... }
  • Si la unión se implementa con un struct wrapper que tiene reglas de almacenamiento especializadas, al agregar el atributo Union y proporcionar una API que siga el patrón de unión, pasa a ser funcionalmente equivalente a una union struct
[Union]
public struct U
{
    public record struct A(int x, string y);
    public record struct B(int z);

    public bool TryGetA(out var A a) { ... }
    public bool TryGetB(out var B b) { ... }
}
  • Si la unión no incluye los tipos miembro o usa otro patrón de API, se pueden proporcionar mediante extensiones las APIs que espera el compilador
  • El patrón completo de la API de union struct todavía no está especificado
  • Las uniones ad hoc no pueden personalizar su comportamiento, salvo modificando el comportamiento de cada tipo miembro individual

Uniones comunes: Option y Result

  • Option es una struct union similar a tipos de otros lenguajes con el mismo nombre o el mismo propósito
    • Expresa que un valor puede existir o puede no existir
public union struct Option
{
    Some(TValue value);
    None = default;
}
  • Un ejemplo de uso es el siguiente
Option x = new Some("text");
Option y = None;

if (x is Some(var value)) {...}

var v = x is Some(var value) ? value : 0;
  • El tipo Option no está completamente especificado
  • Result también es una struct union similar a tipos de otros lenguajes con el mismo nombre o propósito
    • Se usa para devolver desde una función un resultado exitoso o un error
public union struct Result
{
    Success(TValue value);
    Failure(TError error);
}
  • Un ejemplo de uso es el siguiente
Result x = Success("hurray!");
Result y = Failure("boo");

switch (x)
{
    case Success(var value): ...;
    case Failure(var error): ...;
}
  • El tipo Result tampoco está completamente definido

Propuestas relacionadas

  • Esta propuesta incluye propuestas que se asume que existen o funciones que aún podrían proponerse
  • Closed Hierarchies

    • Si se aplica el atributo Closed a un tipo base abstracto, se declaran todos los subtipos dentro del módulo de declaración como un conjunto cerrado de subtipos
    • Si se declara un subtipo fuera del módulo de declaración, se produce un error del compilador
    • La closed hierarchy es tratada por el compilador como exhaustiva, por lo que si se manejan todos los subtipos en un switch no hace falta un caso default
  • Singleton values

    • Un tipo singleton con una propiedad estática Singleton puede acceder implícitamente a esa propiedad en un contexto no de tipo y usarse como si fuera un valor
var x = U.C.Singleton;
  • El código anterior puede escribirse así
var x = U.C;
  • Nested Member Shorthand

    • Un nombre no enlazado puede vincularse a un miembro estático o a un tipo anidado del tipo de destino
Color color = Color.Red;
  • El código anterior puede escribirse así
Color color = Red;
U u = new U.A(10, "ten");
  • El código anterior puede escribirse así
U u = new A(10, "ten");

Criterios de diseño y limitaciones indicados en la sección de preguntas y respuestas

  • Una union class no es estrictamente necesaria si se puede declarar fácilmente una nested record hierarchy directa, pero tiene como ventaja una sintaxis concisa y la facilidad de cambiarla a union struct con solo añadir el modificador struct
  • Una union struct reduce asignaciones y puede usar más tipos de clase, pero no siempre es la opción más adecuada
    • Aunque no tenga allocation propia, no necesariamente es más rápida
    • Su huella en stack es mayor y normalmente se copia al asignarla, pasarla o devolverla
    • No encaja bien con uniones anónimas ad hoc porque no es fácil intercambiarlas
    • Hay problemas con type tests, casts y pattern matching cuando se representa estáticamente en estado boxed o como generic type parameter
  • Una union struct es internamente tanto una tagged union como una type union
    • Internamente expone una propiedad enum que actúa como tag para acelerar el código generado por el compilador
    • En la superficie del lenguaje se ve como una type union para poder manejarla con formas familiares como type tests, casts y pattern matching
  • El compilador puede aplicar una optimización que omita crear el tipo miembro cuando un tipo miembro de union struct se asigna de inmediato a una variable union struct
  • También se espera una optimización que omita copiar la variable de estado de union struct al tipo miembro cuando se descompone la unión directamente en una variable
  • Una union struct no es, como una union class, el tipo base real de los tipos miembro
    • Un struct no permite herencia real
    • Lógicamente funciona como un tipo base mediante conversiones automáticas, pero esa relación no se extiende a todo el sistema de tipos ni al runtime
  • Una unión ad hoc no puede declararse directamente con un nombre por ahora
    • Si se quiere evitar repetir una unión larga o se necesita un nombre descriptivo, hay que usar un global using alias
  • Una unión ad hoc hace boxing de los tipos por valor
    • Si se debe evitar el boxing, hay que usar una union struct
  • Una unión ad hoc no puede incluir ref types
    • Si se necesitan ref types, hay que usar una union struct
  • La razón por la que una unión ad hoc se borra a object es que mejora con type safety en tiempo de compilación y validation checks generadas la solución basada en object que los desarrolladores usan hoy en día
  • No se puede acceder directamente a propiedades o métodos comunes de una unión ad hoc sin manejar cada caso de tipo
    • Solo se puede acceder al valor después de convertirlo correctamente a un tipo individual
  • Los union types de F# corresponden a la union class y la union struct de esta especificación, pero esta propuesta trata los miembros como tipos del lenguaje, no como estado de tag y variables de estado asociadas
    • La unión ad hoc es similar a las type unions de Typescript
  • Option podría no ser necesario si null y nullable reference types pueden lograr el mismo objetivo
    • Algunos desarrolladores prefieren un option type por una garantía más fuerte que la que ofrecen los nullable types de C#
  • Actualmente C# no incorpora al lenguaje los monadic behaviors posibles en F# para Option y Result
  • Muchos casos de uso de Result pueden resolverse con el exception handling de C#
    • Aun así, cuando los errores se esperan en runtime y ocurren con frecuencia, puede ser deseable evitar exception handling y hacer que quien llama trate el error de forma explícita
  • Ya existen tipos similares a Option y Result en bibliotecas de terceros, pero varios desarrolladores han pedido incluir tipos estandarizados en el runtime para lograr interoperabilidad entre bibliotecas

1 comentarios

 
GN⁺ 2024-08-09
Opiniones en Hacker News
  • Después de usar uniones discriminadas en F# durante años, pensé que a estas alturas C# ya las tendría por default
    Tal vez no sea una función que le guste a todo el mundo, pero en un lenguaje con tipos es realmente difícil volver a uno que no tenga tipos de datos algebraicos (ADT) de alguna forma. Ahora uso Java y en general está bien, pero molesta bastante cuando hay que rodear con clases wrapper algo que en F# serían tres líneas

    • Una vez que te acostumbras a F#, es muy difícil volver atrás. Ojalá Microsoft lo apoyara y lo impulsara mejor
      Es cómodo desarrollar porque logra un equilibrio muy fino: toma muchas ventajas de lo funcional sin atarte a restricciones como la programación puramente funcional. En cambio, están convirtiendo C# en F# muy lentamente, y verlo también se siente raro
    • La buena noticia es que hoy en Java también se puede hacer con relativamente poco boilerplate usando sealed interface y record
      Claro, según los estándares de Java
    • Cuando dejé de usar F# y .NET en 2020, el CLR solo tenía herencia, y los enum de F# en realidad estaban implementados mediante herencia
      Por ejemplo, Some y None eran clases derivadas de la clase Option, y se podía comprobar viendo un ensamblado de F# con un descompilador. No sé si hoy sigue siendo igual, pero esta propuesta para C#, incluido el enum anónimo de la sintaxis A or B, parece difícil de construir solo como azúcar sintáctico sobre herencia. Así que para que esto funcione, parecería que el CLR tendría que soportar enum como ciudadanos de primera clase
    • Como referencia, Java tiene tipos de unión etiquetados desde la versión 16
      También tiene pattern matching
    • Java ya tiene ADT desde hace varios años, así que eso de “rodear con clases wrapper algo que serían tres líneas” suena un poco raro
      Records https://en.wikipedia.org/wiki/Java_version_history#Java_16
      Sealed classes https://en.wikipedia.org/wiki/Java_version_history#Java_17
      Y desde hace casi un año también tiene pattern matching https://en.wikipedia.org/wiki/Java_version_history#Java_21
      Dicho eso, coincido mucho en lo agradable que es desarrollar en F#
  • Esta propuesta me entusiasma mucho. Era la gran función ausente que siempre tenía que mencionar casi disculpándome cuando hablaba de las ventajas de C#
    Aparte de esto, me cuesta pensar en una funcionalidad importante de lenguaje que le falte a C#. Ahora, aunque esto entre, también espero ver cómo en HN siguen tratando a C# como si fuera el mismo lenguaje de hace 10 años

    • También estaría bueno tener alias de tipo de verdad, pero programar en C# hoy es realmente disfrutable
    • Para bien o para mal, el objetivo de diseño de C# parece ser meter todas las funciones posibles, y además en varias variantes
      Por eso puede adaptarse a cualquiera y no aleja a la gente como un lenguaje con opiniones fuertes, pero aprenderlo al principio puede volverse un poco complicado
  • ¿Alguien puede explicar por qué a esto lo llaman type unions? Es la primera vez que escucho ese nombre
    No parece una union entre tipos al estilo ALGOL68, sino más bien una tagged union de lenguajes de la familia ML. Me pregunto si es otro caso de desarrolladores de C# inventando otro nombre en vez de usar el término existente, como con SelectMany, IEnumerable y cosas así

    • “Tagged” suena como un detalle de implementación: que internamente hay una etiqueta para distinguir el tipo
      Supongo que agregaron “type” antes de “union” para dejar claro que se trata de tipos. En la documentación, la sintaxis en sí parece ser simplemente union. En el FAQ dice esto:
      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.
    • Es porque esta propuesta cubre varios tipos de union
      Incluye jerarquías cerradas de tipos de referencia que el compilador verifica en tiempo de build, uniones etiquetadas que son tipos de valor y que el compilador trata como si se comportaran como jerarquías cerradas, tipos definidos por el usuario que pueden tener una implementación arbitraria pero aun así conectarse con el mismo mecanismo del compilador, e incluso unions ad-hoc de tipos existentes
  • Se me pasaron todas las metáforas de colores tipo red/blue/white/black pill, pero una union con pattern matching exhaustivo es una de esas funciones del lenguaje sin las que más cuesta vivir una vez que la conoces.
    Nunca sentí que entendiera por completo las implicaciones del expression problem, pero mi hipótesis actual es esta: la forma de ofrecer puntos de extensión mediante polimorfismo tradicional es adecuada cuando clientes futuros que no conozco van a extender el código, mientras que una union con pattern matching exhaustivo encaja mejor con código que poseemos yo o mi equipo. Normalmente, más que querer que ese código se extienda desde afuera, quiero actualizar las estructuras de datos centrales conforme cambia la comprensión del dominio de negocio y luego hacer que el compilador capture como errores la mayor cantidad posible de puntos donde el código imperativo ya no coincide con la estructura del dominio.

    • Escribí este artículo para intentar entender la diferencia.
      https://deliberate-software.com/christmas-f-number-polymorph...
    • Creo que el núcleo del expression problem puede explicarse como el contraste entre cómo la programación orientada a objetos y la funcional obtienen extensibilidad del código.
      En el enfoque de interfaces/herencia de la orientación a objetos, es fácil agregar nuevas variantes de tipo a una clase base o interfaz, pero agregar nueva funcionalidad es difícil porque hay que implementarla en todos los tipos existentes. En el enfoque funcional de uniones discriminadas, es fácil agregar nueva funcionalidad creando una nueva función y haciendo matching sobre la union, porque el compilador garantiza que se manejen todos los casos; pero agregar una nueva variante de tipo es difícil porque hay que actualizar el pattern matching exhaustivo en toda la base de código. Kotlin soporta bastante bien ambos enfoques, así que sirve como buen ejemplo.
  • ¿No está un poco desalineada la terminología? Tengo entendido que TypeScript tiene union types.
    Pero esto se parece a las discriminated unions que veía en F# o Haskell. Creo que la diferencia de una unión discriminada es que tiene case constructors con nombre.

    • TypeScript tiene “union types”, y esta propuesta parece llamarlas ad hoc unions.
      “type union” no es, al menos hasta donde sé, un término formal que haya escuchado. Parece una expresión creada en el lado de C# para explicar los sum types.
    • Al pasar de C# a los type unions de TypeScript, siempre se me hizo raro.
      Puede que haya habido buenas razones para posponer esto hasta ahora. Hay que conocer al público objetivo. Con el tiempo uno se acostumbra, pero cuando se ve demasiado A|B|undefined, leerlo cansa. También fomenta cierta pereza de recibir una cosa y devolver más o menos una combinación de tres o más opciones, y mientras más arriba subes en el call stack, más confuso se vuelve.
      Me gusta que C# lo trate de una forma restringida. Aun así, si hay un argumento convincente del lado de quienes esperan esta función, me gustaría escucharlo.
  • Llevo mucho tiempo usando C#, pero siento que me estoy perdiendo algo en esta propuesta. Los casos de uso no parecen estar bien definidos; ¿alguien puede dar un ejemplo realista?
    Los ejemplos de la propuesta parecen implementables declarando una interfaz vacía y haciendo que algunas clases record la “implementen”. No tengo claro qué se pierde al hacerlo así.

    • Esa jerarquía es una jerarquía abierta en el sentido de que, al procesarla, no puedes saber si cubriste todos los casos.
      Cualquiera puede crear una nueva implementación de la interfaz, incluso fuera de tu código. Además, las opciones pueden no compartir ninguna superficie común, por lo que la “interfaz” de todos los tipos acaba vacía, algo un tanto antinatural en orientación a objetos. Internamente sigue siendo una jerarquía de tipos, pero la diferencia clave frente a una implementación manual es que la extensión está cerrada y, cuando el código usa cases, si falta algún case se produce un error en tiempo de compilación.
    • El ejemplo más realista es un AST o un protocolo de datos.
      Tomando JSON como ejemplo, en lugar de guardar los datos en una sola clase JsonValue, podrías modelarlo como un tipo union que sea uno de estos: string, boolean, number, un array de valores del tipo union, o un map con claves string y valores del mismo tipo union. También se pueden implementar tipos de resultado como Result de Rust, de modo que una API pueda definirse como que devuelve un valor o un error. No vi si la propuesta trataba los genéricos. En el mundo de la programación funcional se les suele llamar algebraic data types, y cuando te acostumbras a modelar tipos así, realmente los extrañas en lenguajes que no los soportan.
      [1] https://en.m.wikipedia.org/wiki/Algebraic_data_type
    • Esta biblioteca implementa uniones discriminadas al estilo F#.
      [0] https://github.com/mcintyre321/OneOf
      La usé para hacer que en capas externas se devolviera un tipo Result que envolviera DTOs, errores, etc., en una sola cosa. Como se puede hacer pattern matching sobre el resultado, el manejo de errores se vuelve más fácil, y el modelo puede evolucionar con el tiempo sin cambiar las firmas de las funciones en los límites.
    • Durante más de los últimos 5 años he imitado las uniones discriminadas de F# con interfaces vacías, y ha funcionado muy bien.
      El problema de “no se manejaron todos los cases en el switch” ocurrió muy rara vez, aproximadamente una vez cada 50 mil líneas de código, y normalmente se corrigió después de ejecutar la primera smoke test. Así que no fue un gran problema. Creo que la razón por la que el equipo de .NET todavía no ha agregado uniones discriminadas es que se pueden imitar eficazmente con interfaces.
    • Un ejemplo simple es Result, que puede ser Ok(T value) o Error(string message).
      Para obtener el valor tienes que hacer switch sobre ambos casos, así que se te obliga a manejar el caso de error en ese mismo lugar.
  • Me dio risa que bajo la sección de covariance / contravariance diga “Note: Have Mads write this part.” :)

    • Aquí Mads se refiere a Mads Torgersen, el diseñador principal de C#.
  • Hay una parte que dice que “el layout interno de un union struct hace que el compilador elija un compromiso entre velocidad y tamaño para almacenar de forma eficiente los datos dentro de los posibles tipos miembro”.
    Como alguien que en el pasado intentó demasiada magia negra de unions en C# con FieldOffset y salió muy quemado, aquí hay un problema lamentable. Hacer aliasing entre punteros/valores ref y tipos de valor es UB. Es decir, un union struct de u64 y object necesita campos separados para cada uno, así que se desperdician 8 bytes. Eso será así mientras ryujit/GC no se actualicen para saberlo.

    • No es UB, es ilegal. Según ECMA 335, II.10.7, se pueden superponer campos de esa forma, pero los offsets ocupados por referencias a objetos no deben superponerse con offsets ocupados por tipos de valor integrados ni por partes de otras referencias a objetos.
      En .NET 8.0, el código que pone UInt64 Foo y Object Bar juntos con FieldOffset(0) compila, pero al cargarse lanza System.TypeLoadException. El mensaje dice que el campo object en el offset 0 está mal alineado o se superpone con un campo non-object. Curiosamente, el compilador AOT advierte que este método siempre hará throw, pero el compilador de C# no emite ninguna advertencia.
  • Es una lástima que, en vez de convertirse en un mejor lenguaje orientado a objetos, C# parezca seguir intentando convertirse en un F# más feo.
    Por ejemplo, ¿por qué la sintaxis de despacho múltiple sigue siendo tan tosca? Entiendo que el pseudo-OO conquistó el mundo y que la gente reaccionó contra eso, así que era más fácil mantenerse relevante convirtiéndose en “un lenguaje un poco funcional con llaves” que intentando hacer bien la orientación a objetos de verdad.

    • Desde el otro lado existe la misma queja. Los lenguajes mainstream antes tenían un estado mutable amplio y efectos secundarios, y ahora simplemente se les agregaron lambdas.
      Pero ¿qué falta del lado de la orientación a objetos? ¿Qué necesitaría C# u otro lenguaje para llegar a un estado en el que “haga bien la orientación a objetos”?
    • ¿Qué significa aquí “mejor orientación a objetos”?
      Me da curiosidad saber qué conceptos o funcionalidades tienes en mente.
  • Actualmente uso records anidados con constructores privados junto con el paquete NuGet https://github.com/shuebner/ClosedTypeHierarchyDiagnosticSup... para garantizar que los switch de tipos no necesiten un case _.
    En esencia, es una versión desazucarada de las “Union Classes” de esta propuesta, y ya funciona bastante bien. Aun así, me gusta esta propuesta porque sería bueno no necesitar el paquete NuGet y además tener azúcar sintáctico.

    • Hay que tener en cuenta que los tipos record no están cerrados aunque tengan constructores privados.
      El compilador genera un constructor protected para las operaciones de copia, y ese constructor puede heredarse. Para impedirlo, hay que definir manualmente un constructor protected y hacer que lance una excepción en tiempo de ejecución si no es un case válido.