Explora los Tipos Unión en C# 15

C# 15 introduce una característica largamente solicitada: los tipos unión, disponibles a partir de .NET 11 Preview 2. Con la nueva palabra clave union, los desarrolladores de C# ahora pueden declarar que un valor es exactamente uno de un conjunto fijo de tipos, con la garantía de coincidencia de patrones exhaustiva impuesta por el compilador. Si estás familiarizado con las uniones discriminadas en F# o características similares en otros lenguajes, te sentirás como en casa, pero las uniones de C# están diseñadas para una experiencia nativa de C#. Permiten componer tipos existentes, se integran con el patrón de coincidencia que ya conoces y funcionan sin problemas con el resto del lenguaje.

¿Qué son los tipos unión?

Antes de C# 15, cuando un método necesitaba devolver uno de varios tipos posibles, las opciones eran imperfectas. Usar object no imponía restricciones sobre qué tipos se almacenaban realmente, lo que obligaba al llamador a escribir lógica defensiva. Las interfaces marcador y las clases base abstractas eran mejores al restringir el conjunto de tipos, pero no podían ser “cerradas”; cualquiera podía implementarlas o derivar de ellas, por lo que el compilador nunca podía considerar el conjunto completo. Además, ambos enfoques requerían que los tipos compartieran un ancestro común, lo que no funcionaba para uniones de tipos no relacionados como string y Exception, o int e IEnumerable<T>.

Los tipos unión resuelven estos problemas al declarar un conjunto *cerrado* de tipos de caso. Estos tipos no necesitan estar relacionados entre sí y no se pueden añadir otros tipos. El compilador garantiza que las expresiones switch que manejan la unión sean exhaustivas, cubriendo cada tipo de caso sin necesidad de un descarte _ o una rama por defecto. Más allá de la exhaustividad, las uniones permiten diseños que las jerarquías tradicionales no pueden expresar, componiendo cualquier combinación de tipos existentes en un contrato único y verificado por el compilador.

Declaración y Uso Básico

La declaración más simple es concisa:

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

public union Pet(Cat, Dog, Bird);

Esta línea única declara Pet como un nuevo tipo cuyas variables pueden contener un Cat, un Dog o un Bird. El compilador proporciona 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 emitirá un error si intentas asignar una instancia de un tipo que no sea uno de los tipos de caso a un objeto Pet.

Cuando utilizas una instancia de un tipo union que se sabe que no es nula, el compilador conoce el conjunto completo de tipos de caso. Esto hace que una expresión switch que cubre todos ellos sea exhaustiva, sin necesidad de un descarte:

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

Si alguno de los tipos de caso fuera anulable (por ejemplo, int? o Bird?), todas las expresiones switch para una instancia de Pet necesitarían una rama null para ser exhaustivas. Un valor central de las uniones es que el compilador detecta los casos faltantes en tiempo de compilación, no en tiempo de ejecución. Los patrones se aplican a la propiedad Value de la unión, no a la estructura de la unión en sí. Este “desenvolvimiento” es automático. Las excepciones son var y _, que se aplican al valor de la unión completa.

Para los tipos union, el patrón null verifica si Value es nulo. El valor por defecto de una estructura de 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"

Escenario del Mundo Real: OneOrMore<T>

Las APIs a menudo aceptan un solo elemento o una colección. Una unión con un cuerpo permite añadir miembros auxiliares junto con los tipos de caso. La declaración OneOrMore<T> puede incluir un método AsEnumerable() directamente en el cuerpo de la unión:

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

Es importante notar que el método AsEnumerable debe manejar el caso donde Value es null, ya que el estado nulo por defecto de la propiedad Value es *tal vez nulo*. Esto es necesario para proporcionar advertencias adecuadas para arrays de un tipo unión o instancias del valor por defecto de la estructura union. Los llamadores pueden pasar la forma que les sea conveniente, 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 Existentes

La declaración union es una abreviatura predefinida. El compilador genera una estructura con un constructor para cada tipo de caso y una propiedad Value de tipo object? que contiene el valor subyacente. Esto permite conversiones implícitas y maneja la mayoría de los casos de uso limpiamente, aunque implica boxing para tipos de valor.

Sin embargo, muchas librerías de la comunidad ya ofrecen tipos similares a las uniones con sus propias estrategias de almacenamiento. Estas librerías no necesitan cambiar a la sintaxis union para beneficiarse de C# 15. Cualquier clase o estructura con el atributo [System.Runtime.CompilerServices.Union] es reconocida como un tipo unión, siempre que siga el patrón básico: uno o más constructores públicos de un solo parámetro (definiendo los tipos de caso) y una propiedad Value pública.

Para escenarios sensibles al rendimiento donde los tipos de caso incluyen tipos de valor, las librerías pueden implementar un patrón de acceso sin boxing añadiendo una propiedad HasValue y métodos TryGetValue. Esto permite al compilador implementar la coincidencia de patrones sin boxing.

Propuestas Relacionadas: La Historia de la Exhaustividad

Los tipos unión te ofrecen un tipo que contiene uno de un conjunto cerrado de tipos. Dos características propuestas proporcionan funcionalidades relacionadas para jerarquías de tipos y enumeraciones, ofreciendo una historia completa de exhaustividad en C#:

  • Jerarquías Cerradas (Closed hierarchies): El modificador closed en una clase impide que se declaren clases derivadas fuera del ensamblado que la define.
  • Enumeraciones Cerradas (Closed enums): Un closed enum evita la creación de valores distintos de los miembros declarados.

En conjunto, estas tres características proporcionan:

  • Tipos Unión — coincidencia exhaustiva sobre un conjunto cerrado de tipos.
  • Jerarquías Cerradas — coincidencia exhaustiva sobre una jerarquía de clases sellada.
  • Enumeraciones Cerradas — coincidencia exhaustiva sobre un conjunto fijo de valores de enumeración.

Los tipos unión están disponibles en vista previa. Estas propuestas relacionadas están activas, pero aún no están comprometidas con un lanzamiento final. Tu participación en la discusión ayuda a dar forma a su diseño e implementación.

Pruébalos Tú Mismo

Para empezar con los tipos unión en .NET 11 Preview 2:

  1. Instala el SDK de .NET 11 Preview.
  2. Crea o actualiza un proyecto que tenga como objetivo net11.0.
  3. Establece <LangVersion>preview</LangVersion> en tu archivo de proyecto.

El soporte IDE en Visual Studio estará disponible en la próxima versión de Visual Studio Insiders, y ya está incluido en la última versión de C# DevKit Insiders.

Vista Previa Temprana: Declara los Tipos de Tiempo de Ejecución Tú Mismo

En .NET 11 Preview 2, el UnionAttribute y la interfaz IUnion aún no están incluidos en el runtime. Debes declararlos en tu proyecto. Las versiones posteriores de la vista previa incluirán estos tipos en el runtime.

Añade lo siguiente a tu proyecto (o toma RuntimePolyfill.cs del repositorio de documentación):

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

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

Una vez que estén en su lugar, puedes declarar y usar tipos unión:

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 de la especificación completa de la propuesta, como los proveedores de miembros de unión, aún no están implementadas, pero llegarán en futuras vistas previas.

Comparte tus Comentarios

Los tipos unión están en vista previa, y tus comentarios dan forma directamente al diseño final. Pruébalos en tus proyectos, explora casos extremos y cuéntanos qué funciona y qué no. Únete a la discusión sobre uniones en GitHub.

Para saber más:

Author: Enagora

Deja una respuesta

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