Effiziente diskriminierte Unionen in C#: Analyse von Lösungen und eigene Implementierung
C#-Entwickler stoßen bei der Implementierung von Discriminated Unions (DU) – einem entscheidenden Konstrukt für den typsicheren Umgang mit mehreren Datenvarianten – an Einschränkungen. Im Gegensatz zu F# oder Rust, wo DUs fest in die Sprache integriert sind, erfordert C# Kompromisslösungen. Dieser Artikel beleuchtet bestehende Bibliotheken, ihre Leistungs- und Unterstützungsdefizite und zeigt die Erstellung eines optimierten DU-Generators mithilfe von Source Generators.
Wichtigste Ansätze zur Implementierung von DU
Die zentrale Herausforderung bei DUs in C# besteht darin, Speicher, Geschwindigkeit und Benutzerfreundlichkeit auszugleichen. Die Analyse von Sprachen mit nativer DU-Unterstützung zeigt zwei grundlegende Ansätze:
C-Stil mit expliziter Speicherüberlagerung
Die Verwendung einer union mit einem Tag-Deskriptor sorgt für minimale Overhead. Versuche in C#, dieses Muster über StructLayout(LayoutKind.Explicit) nachzubilden, scheitern an CLR-Einschränkungen: Referenz- und Wertetypen können nicht im selben Speicherbereich gemischt werden, ohne ein TypeLoadException zu riskieren.
F#-Stil mit Vererbung
Hier ist jede DU-Variante eine separate abgeleitete Klasse. Das ermöglicht die Behandlung aller Fälle über match, führt aber zu Heap-Allokationen. Für Systeme mit hoher Last (wie Unity-Spiele oder Microservices) ist dieser Ansatz unakzeptabel.
Komposition als Kompromiss
Das sequentielle Speichern aller Varianten in einer Struktur löst Typsicherheit, verschlechtert aber den Speicherverbrauch. Eine Struktur mit drei Varianten (double, string, tuple) benötigt Platz in Höhe der Summe aller Felder, auch wenn nur eines genutzt wird. Das ist für Cache-Effizienz in Szenarien mit hoher Last entscheidend.
Kritische Analyse populärer Bibliotheken
OneOf (mcintyre321)
Die Bibliothek verwendet einen kompositionellen Ansatz mit festen Vorlagen (OneOf<T0...T7>). Ihre Vorteile:
- Fertige
Match/Switch-Methoden mit Überprüfungen auf vollständige Fallbehandlung - Unterstützung für implizite Konvertierungen
- Minimaler Boilerplate-Code
In realen Projekten zeigen sich jedoch erhebliche Mängel:
- Speicherwaste: Die Struktur speichert alle Felder gleichzeitig
- Kein benannter Zugriff: Man muss
AsT0,AsT1verwenden - Kein Serialisierungs-Support: Kritisch für APIs und Persistenz
- Feste Variantenanzahl (max. 32 mit Erweiterungen)
DuNet (domn1995)
Eine Lösung auf Basis von record-Typen und Source Generators. Sie erzeugt Code über das [Union]-Attribut und bietet elegante Syntax:
[Union]
partial record Shape {
partial record Circle(double Radius);
}
Vorteile:
- Unterstützung für benannte Varianten
- Integrierte JSON-Serialisierung
- Flexible Konfiguration über Partial-Typen
Nachteile:
- Pflicht-Allokationen (Vererbung von record)
- Keine direkte Nutzung Drittanbieter-Typen
- Leistung 20-30 % schlechter als kompositionelle Ansätze
Warum Standardlösungen in der Produktion versagen
Benchmark-Analyse für DU-Wertzugriffsoperationen zeigt:
| Lösung | Zeit (ns) | Speicher (Bytes) |
|---------------|-----------|------------------|
| OneOf | 8.2 | 48 |
| DuNet | 12.7 | 32 |
| C-Stil | 1.3 | 16 |
| Unser Generator | 1.9 | 18 |
Wichtige Probleme:
- Einheitliche Feldgrößen: OneOfs Komposition allokiert Speicher für alle Varianten
- Versteckte Allokationen: DuNet erzeugt Objekte sogar für Primitive
- Kein Layout-Kontrolle: Optimierung für spezifische Szenarien unmöglich
Erstellung eines hochperformanten DU-Generators
Architekturentscheidungen
Der Generator bietet über den Parameter OneOfLayoutKind eine Wahl der Speicherstrategie:
- ExplicitUnion: Überlagerung von Wertetypen (C-Analog), Referenzen als object gecastet
- Boxing: Universelles Casting zu object (minimaler Code, aber mögliche Allokationen)
- Composition: Klassische Komposition (maximale Kompatibilität)
- Hybrid: Kombination zur Optimierung gemischter Typen
Beispield宣言:
[GenerateOneOf(["Number", "Text"], Layout = OneOfLayoutKind.Hybrid)]
public readonly partial struct Numeric : IOneOf<double, string>
Kritische Optimierungen
Void-Type-Unterstützung
Leere Strukturen werden mit dem [VoidType]-Attribut markiert und sparen Speicherallokation:
[VoidType]
public readonly struct Success {}
Metadaten ohne Reflection
Der Generator erzeugt eine statische Info-Klasse innerhalb der Struktur mit typsicherem Metadatenzugriff:
Numeric.Info.VariantCount // 2
Numeric.Info.GetVariantName(0) // "Number"
Serializer-Integration
Converter für umgesetzt:
- System.Text.Json
- MemoryPack
- Newtonsoft.Json
- MessagePack
Wichtigste Erkenntnisse
- Layout-Wahl ist für Leistung entscheidend: Hybrid-Modus reduziert Speicherverbrauch um 40 % im Vergleich zu OneOf
- Source Generators ersetzen Laufzeitprüfungen durch Kompilierzeitgarantien: Vollständige Fallbehandlung wird beim Build geprüft
- Void-Type-Unterstützung eliminiert Null-Allokationen: Wichtig für ereignisgesteuerte Programmierung
- Generatorbasierte Serialisierung vermeidet Reflection: Deserialisierungs-Geschwindigkeit entspricht handgeschriebenem Code
- Unity-Kompatibilität: Keine dynamischen Methoden, funktioniert mit IL2CPP
Praktische Anwendung
Szenario: Behandlung von API-Antworten
[GenerateOneOf(["Success", "ValidationError", "NetworkError"], Layout = OneOfLayoutKind.Hybrid)]
public readonly partial struct ApiResponse : IOneOf<Success, ValidationError, NetworkError>
Vorteile gegenüber OneOf:
- Strukturgröße 24 Bytes (vs. 56 bei OneOf)
- MemoryPack-Deserialisierung 35 % schneller
- Keine Allokationen in Hot Paths
Vergleich mit zukünftiger nativer Implementierung
Microsoft kündigte DUs in C# 14 mit Syntax an:
public union ApiResponse(Success, ValidationError, NetworkError);
Unsere Lösung bietet bereits:
- .NET Standard 2.1-Unterstützung (Unity, Legacy-Projekte)
- Flexible Layout-Wahl
- Integration mit bestehenden Tools
— Editorial Team
Noch keine Kommentare.