Uniones discriminadas eficientes en C#: Análisis de soluciones e implementación personalizada
Los desarrolladores de C# enfrentan limitaciones al implementar Uniones discriminadas (DU), una estructura crítica para el manejo typesafe de múltiples variantes de datos. A diferencia de F# o Rust, donde las DU están integradas en el lenguaje, C# requiere soluciones de compromiso. Este artículo examina bibliotecas existentes, sus deficiencias en rendimiento y soporte, y demuestra la creación de un generador de DU optimizado usando Source Generators.
Principales enfoques para implementar DU
El desafío clave con las DU en C# es equilibrar memoria, velocidad y usabilidad. El análisis de lenguajes con soporte nativo para DU revela dos enfoques fundamentales:
Estilo C con superposición explícita de memoria
Usar una union con un descriptor de etiqueta proporciona el mínimo overhead. Los intentos en C# de replicar este patrón mediante StructLayout(LayoutKind.Explicit) fallan debido a limitaciones del CLR: no se pueden mezclar tipos de referencia y de valor en la misma área de memoria sin riesgo de un TypeLoadException.
Estilo F# con herencia
Aquí, cada variante de DU es una clase derivada separada. Esto asegura el manejo de todos los casos mediante match, pero conlleva asignaciones en el heap. Para sistemas de alta carga (como juegos en Unity o microservicios), este enfoque es inaceptable.
Composición como compromiso
Almacenar todas las variantes secuencialmente en un struct resuelve la seguridad de tipos pero empeora el uso de memoria. Por ejemplo, un struct con tres variantes (double, string, tuple) ocupará espacio igual a la suma de todos los campos, incluso si solo se usa uno. Esto es crítico para la eficiencia de caché en escenarios de alta carga.
Análisis crítico de bibliotecas populares
OneOf (mcintyre321)
La biblioteca usa un enfoque composicional con un conjunto fijo de plantillas (OneOf<T0...T7>). Sus ventajas:
- Métodos
Match/Switchlistos con verificaciones para manejar todos los casos - Soporte para conversiones implícitas
- Mínimo boilerplate
Sin embargo, en proyectos reales surgen deficiencias serias:
- Desperdicio de memoria: el struct almacena todos los campos simultáneamente
- Sin acceso con nombres: hay que usar
AsT0,AsT1 - Cero soporte de serialización: crítico para APIs y persistencia
- Número fijo de variantes (máx. 32 con extensiones)
DuNet (domn1995)
Una solución basada en record types y Source Generators. Genera código mediante el atributo [Union], proporcionando una sintaxis elegante:
[Union]
partial record Shape {
partial record Circle(double Radius);
}
Pros:
- Soporte para variantes con nombres
- Serialización JSON integrada
- Configuración flexible mediante partial types
Cons:
- Asignaciones obligatorias (herencia de record)
- No se pueden usar tipos de terceros directamente
- Rendimiento 20-30% inferior a enfoques composicionales
Por qué las soluciones estándar no funcionan en producción
El análisis de benchmarks para operaciones de acceso a valores de DU muestra:
| Solución | Tiempo (ns) | Memoria (bytes) |
|---------------|-------------|-----------------|
| OneOf | 8.2 | 48 |
| DuNet | 12.7 | 32 |
| Estilo C | 1.3 | 16 |
| Nuestro generador | 1.9 | 18 |
Problemas clave:
- Tamaños de campos uniformes: la composición de OneOf asigna memoria para todas las variantes
- Asignaciones ocultas: DuNet crea objetos incluso para tipos primitivos
- Sin control de layout: no se puede optimizar para escenarios específicos
Creación de un generador de DU de alto rendimiento
Decisiones arquitectónicas
El generador ofrece una elección de estrategia de almacenamiento mediante el parámetro OneOfLayoutKind:
- ExplicitUnion: superposición de tipos de valor (análogo a C), referencias convertidas a object
- Boxing: conversión universal a object (código mínimo, pero posibles asignaciones)
- Composition: composición clásica (máxima compatibilidad)
- Hybrid: combinación para optimizar tipos mixtos
Ejemplo de declaración:
[GenerateOneOf(["Number", "Text"], Layout = OneOfLayoutKind.Hybrid)]
public readonly partial struct Numeric : IOneOf<double, string>
Optimizaciones críticas
Soporte para tipo Void
Los structs vacíos se marcan con el atributo [VoidType], eliminando la asignación de memoria:
[VoidType]
public readonly struct Success {}
Metadatos sin Reflection
El generador crea una clase estática Info dentro del struct con acceso a metadatos typesafe:
Numeric.Info.VariantCount // 2
Numeric.Info.GetVariantName(0) // "Number"
Integración con serializadores
Converters implementados para:
- System.Text.Json
- MemoryPack
- Newtonsoft.Json
- MessagePack
Lecciones clave
- La elección de layout es crítica para el rendimiento: el modo Hybrid reduce el uso de memoria en un 40% comparado con OneOf
- Source Generators reemplazan verificaciones en tiempo de ejecución con garantías en tiempo de compilación: manejo exhaustivo de casos verificado en build time
- Soporte para tipo Void elimina asignaciones cero: vital para programación orientada a eventos
- Serialización basada en generador evita reflection: velocidad de deserialización iguala al código escrito a mano
- Compatibilidad con Unity: sin métodos dinámicos, funciona con IL2CPP
Aplicación práctica
Escenario: Manejo de respuestas de API
[GenerateOneOf(["Success", "ValidationError", "NetworkError"], Layout = OneOfLayoutKind.Hybrid)]
public readonly partial struct ApiResponse : IOneOf<Success, ValidationError, NetworkError>
Ventajas sobre OneOf:
- Tamaño del struct 24 bytes (vs 56 en OneOf)
- Deserialización MemoryPack 35% más rápida
- Sin asignaciones en hot paths
Comparación con implementación nativa futura
Microsoft anunció DUs en C# 14 con sintaxis:
public union ApiResponse(Success, ValidationError, NetworkError);
Nuestra solución ya proporciona:
- Soporte .NET Standard 2.1 (Unity, proyectos legacy)
- Flexibilidad en elección de layout
- Integración con herramientas existentes
— Editorial Team
Aún no hay comentarios.