Explorando los Tipos Unión en C# 15

¡Grandes noticias para la comunidad de C#! C# 15, disponible desde .NET 11 Preview 2, introduce la esperada palabra clave union. Este nuevo tipo permite que un valor sea exactamente uno de un conjunto fijo de tipos, con matching de patrones exhaustivo forzado por el compilador. Si estás familiarizado con las uniones discriminadas en F# o características similares, te sentirás cómodo. Las uniones de C# están diseñadas para una experiencia nativa, componiendo tipos existentes y funcionando sin problemas con el pattern matching y el resto del lenguaje.

¿Qué son los tipos unión?

Antes de C# 15, manejar métodos que devolvían uno de varios tipos presentaba desafíos. Usar object carecía de restricciones, exigiendo lógica defensiva. Las interfaces marcador y las clases base abstractas eran mejores, pero no «cerradas» (cualquiera podía implementarlas/derivarlas), impidiendo que el compilador considerara el conjunto completo. Además, ambos requerían un ancestro común, inviable para unir tipos no relacionados como string y Exception, o int y IEnumerable<T>.

Los tipos unión resuelven estos problemas. Declaran un conjunto cerrado de tipos de caso, que no necesitan estar relacionados. El compilador garantiza que las expresiones switch para una unión son exhaustivas, cubriendo cada tipo de caso sin un _ de descarte o rama por defecto. Esto va más allá de la exhaustividad: las uniones permiten diseñar contratos combinando tipos existentes, con verificación en tiempo de compilación.

Una declaración simple es:

public record class Cat(string Name);
public record class Dog(string Name);
public record class Bird(string Name);

public union Pet(Cat, Dog, Bird);

Pet es un nuevo tipo que puede contener un Cat, Dog o Bird. El compilador ofrece conversiones implícitas desde cada tipo de caso, permitiendo asignaciones directas:

Pet pet = new Dog("Rex");
Console.WriteLine(pet.Value); // Dog { Name = Rex }

Pet pet2 = new Cat("Whiskers");
Console.WriteLine(pet2.Value); // Cat { Name = Whiskers }

El compilador reporta un error si se asigna un tipo no incluido en la unión.

Para una instancia de union no nula, el compilador conoce todos los tipos de caso, haciendo que un switch sea exhaustivo sin necesidad de descarte:

string name = pet switch
{
    Dog d => d.Name,
    Cat c => c.Name,
    Bird b => b.Name,
};

Si los tipos de caso son no anulables y la variable unión es no nula, no se necesita verificar null. Sin embargo, si algún tipo de caso fuera anulable (ej. int?, Bird?), se requeriría una rama null. Un beneficio clave es que el compilador detecta casos faltantes en tiempo de construcción si se añade un nuevo tipo de caso a la unión, evitando errores en tiempo de ejecución.

Los patrones actúan sobre la propiedad Value de la unión (desempaquetado automático). Excepciones son var y _, que aplican a la unión completa. El patrón null verifica si Value es nulo. El valor default de una estructura unión tiene un Value nulo:

Pet pet = default;

var description = pet switch
{
    Dog d => d.Name,
    Cat c => c.Name,
    Bird b => b.Name,
    null => "no pet",
};
// description is "no pet"

Exploremos un escenario práctico.

OneOrMore<T> — un valor o una colección

Las APIs a menudo manejan un solo elemento o una colección. Una unión con cuerpo permite añadir miembros auxiliares, como el método AsEnumerable() en OneOrMore<T>:

public union OneOrMore<T>(T, IEnumerable<T>)
{
    public IEnumerable<T> AsEnumerable() => Value switch
    {
        T single => [single],
        IEnumerable<T> multiple => multiple,
        null => []
    };
}

AsEnumerable debe manejar el caso donde Value es null, ya que el estado nulo por defecto de Value es maybe-null. Esto es crucial para advertencias correctas en arrays de uniones o instancias predeterminadas.

Los llamadores pasan la forma que prefieran, y AsEnumerable() la normaliza:

OneOrMore<string> tags = "dotnet";
OneOrMore<string> moreTags = new[] { "csharp", "unions", "preview" };

foreach (var tag in tags.AsEnumerable())
    Console.Write($"[{tag}] ");
// [dotnet]

foreach (var tag in moreTags.AsEnumerable())
    Console.Write($"[{tag}] ");
// [csharp] [unions] [preview]

Uniones personalizadas para librerías

La declaración union es una taquigrafía; el compilador genera una estructura con constructores para cada tipo de caso y una propiedad Value de tipo object? (que «boxea» tipos de valor). Esto cubre la mayoría de los usos.

Librerías existentes con tipos similares a uniones pueden beneficiarse de C# 15 sin cambiar de sintaxis. Cualquier clase o estructura con el atributo [System.Runtime.CompilerServices.Union] se reconoce como unión si tiene constructores públicos de un parámetro (tipos de caso) y una propiedad pública Value.

Para escenarios críticos de rendimiento con tipos de valor, las librerías pueden implementar un patrón de acceso sin «boxing» añadiendo una propiedad HasValue y métodos TryGetValue, permitiendo al compilador hacer matching sin «boxing». Para más detalles, consulta la referencia del lenguaje.

Propuestas relacionadas: exhaustividad completa

Los tipos unión complementan otras propuestas para una historia de exhaustividad integral en C#:

  • Jerarquías cerradas: El modificador closed en una clase restringe clases derivadas al mismo ensamblado, permitiendo matching exhaustivo en jerarquías selladas.
  • Enums cerrados: Un closed enum evita valores no declarados, asegurando matching exhaustivo.

Estas tres características son clave para la exhaustividad:

  • Tipos unión — matching exhaustivo sobre un conjunto cerrado de tipos.
  • Jerarquías cerradas — matching exhaustivo sobre una jerarquía de clases sellada.
  • Enums cerrados — matching exhaustivo sobre un conjunto fijo de valores de enumeración.

Las propuestas de jerarquías y enums están activas. ¡Únete a la discusión!

Pruébalo tú mismo

Los tipos unión están disponibles con .NET 11 Preview 2. Para empezar:

  1. Instala el SDK de .NET 11 Preview.
  2. Crea/actualiza un proyecto con target net11.0.
  3. Añade <LangVersion>preview</LangVersion> en tu archivo de proyecto.

El soporte IDE llegará en futuras builds. En .NET 11 Preview 2, debes declarar manualmente UnionAttribute e IUnion en tu proyecto (conocido como «polyfill»):

namespace System.Runtime.CompilerServices
{
    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct,
        AllowMultiple = false)]
    public sealed class UnionAttribute : Attribute;

    public interface IUnion
    {
        object? Value { get; }
    }
}

Luego, puedes usar uniones:

public record class Cat(string Name);
public record class Dog(string Name);

public union Pet(Cat, Dog);

Pet pet = new Cat("Whiskers");
Console.WriteLine(pet switch
{
    Cat c => $"Cat: {c.Name}",
    Dog d => $"Dog: {d.Name}",
});

Algunas características completas de la especificación, como los proveedores de miembros de unión, se implementarán en futuras previews.

Comparte tus comentarios

Los tipos unión están en preview, y tus comentarios son cruciales para el diseño final. Pruébalos, explora casos extremos y comparte tu experiencia.

Únete a la discusión en GitHub

Recursos adicionales:

Author: Enagora

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *