# Efektywne Discriminated Unions w C#: Analiza rozwiązań i własna implementacja
Programiści C# napotykają ograniczenia przy implementacji Discriminated Unions (DU) — kluczowej konstrukcji do typobezpiecznego przetwarzania wielu wariantów danych. W przeciwieństwie do F# lub Rust, gdzie DU są wbudowane na poziomie języka, w C# trzeba korzystać z kompromisowych rozwiązań. Ten artykuł analizuje istniejące biblioteki, ich wady w zakresie wydajności i wsparcia, oraz pokazuje tworzenie zoptymalizowanego generatora DU za pomocą Source Generator.
Główne podejścia do implementacji DU
Kluczowy problem DU w C# to równowaga między pamięcią, prędkością i wygodą. Analiza języków z natywnym wsparciem DU ujawnia dwa fundamentalne podejścia:
Styl C z jawnym nakładaniem pamięci
Użycie union z tagiem-dyspozytorem daje minimalny narzut. W C# próba odtworzenia tego wzorca za pomocą StructLayout(LayoutKind.Explicit) kończy się niepowodzeniem z powodu ograniczeń CLR: nie można mieszać typów referencyjnych i wartościowych w jednym obszarze pamięci bez ryzyka TypeLoadException.
Styl F# z dziedziczeniem
Tutaj każdy wariant DU to osobna klasa dziedzicząca. Gwarantuje to obsługę wszystkich przypadków za pomocą match, ale prowadzi do alokacji w stercie. Dla systemów o wysokim obciążeniu (np. gier Unity lub mikrousług) takie podejście jest nieakceptowalne.
Kompozycja jako kompromis
Przechowywanie wszystkich wariantów kolejno w strukturze rozwiązuje problem typobezpieczeństwa, ale pogarsza zużycie pamięci. Na przykład struktura z trzema wariantami (double, string, krotka) zajmie rozmiar równy sumie wszystkich pól, nawet jeśli używany jest tylko jeden wariant. To krytyczne dla efektywności pamięci podręcznej w scenariuszach high-load.
Krytyczna analiza popularnych bibliotek
OneOf (mcintyre321)
Biblioteka używa podejścia kompozycyjnego z ustalonym zestawem szablonów (OneOf<T0...T7>). Jej zalety:
- Gotowe metody
Match/Switchz sprawdzaniem obsługi wszystkich przypadków - Obsługa niejawnych konwersji
- Minimalny boilerplate
Jednak w rzeczywistych projektach ujawniają się poważne wady:
- Straty pamięci: struktura przechowuje wszystkie pola jednocześnie
- Brak nazwanych dostępów: trzeba używać
AsT0,AsT1 - Brak wsparcia dla serializacji: krytyczne dla API i trwałości
- Stała liczba wariantów (maksimum 32 z rozszerzeniami)
DuNet (domn1995)
Rozwiązanie oparte na typach record i Source Generator. Generuje kod za pomocą atrybutu [Union], zapewniając elegancką składnię:
[Union]
partial record Shape {
partial record Circle(double Radius);
}
Zalety:
- Obsługa nazwanych wariantów
- Wbudowana serializacja JSON
- Elastyczna konfiguracja za pomocą typów partial
Wady:
- Obowiązkowe alokacje (dziedziczenie od record)
- Nie można używać zewnętrznych typów bezpośrednio
- Wydajność niższa o 20-30% od podejścia kompozycyjnego
Dlaczego standardowe rozwiązania nie działają w produkcji
Analiza benchmarków dla operacji dostępu do wartości DU pokazuje:
| Rozwiązanie | Czas (ns) | Pamięć (bajty) |
|---------------|-----------|----------------|
| OneOf | 8.2 | 48 |
| DuNet | 12.7 | 32 |
| Styl C | 1.3 | 16 |
| Nasz generator| 1.9 | 18 |
Kluczowe problemy:
- Jednakowy rozmiar pól: kompozycja OneOf rezerwuje pamięć pod wszystkie warianty
- Ukryte alokacje: DuNet tworzy obiekty nawet dla prostych typów
- Brak kontroli layoutu: nie można optymalizować pod konkretne scenariusze
Tworzenie wysokowydajnego generatora DU
Rozwiązania architektoniczne
Generator oferuje wybór strategii przechowywania za pomocą parametru OneOfLayoutKind:
- ExplicitUnion: nakładanie typów wartościowych (analog C), referencyjne rzutowane na object
- Boxing: uniwersalne rzutowanie na object (minimum kodu, ale możliwe alokacje)
- Composition: klasyczna kompozycja (maksymalna kompatybilność)
- Hybrid: kombinacja do optymalizacji pod mieszanki typów
Przykład deklaracji:
[GenerateOneOf(["Number", "Text"], Layout = OneOfLayoutKind.Hybrid)]
public readonly partial struct Numeric : IOneOf<double, string>
Krytyczne optymalizacje
Obsługa typów Void
Puste struktury oznaczane są atrybutem [VoidType], co eliminuje alokację pamięci:
[VoidType]
public readonly struct Success {}
Metadane bez refleksji
Generator tworzy statyczną klasę Info wewnątrz struktury z typobezpiecznym dostępem do metadanych:
Numeric.Info.VariantCount // 2
Numeric.Info.GetVariantName(0) // "Number"
Integracja z serializatorami
Zaimplementowane konwertery dla:
- System.Text.Json
- MemoryPack
- Newtonsoft.Json
- MessagePack
Co ważne
- Wybór layoutu jest kluczowy dla wydajności: tryb Hybrid zmniejsza zużycie pamięci o 40% w porównaniu z OneOf
- Source Generator zastępuje sprawdzania w runtime gwarancjami kompilatora: sprawdzanie obsługi wszystkich przypadków odbywa się na etapie kompilacji
- Obsługa typów Void eliminuje zerowe alokacje: ważne dla programowania zdarzeniowego
- Serializacja przez generator unika refleksji: prędkość deserializacji na poziomie kodu ręcznego
- Kompatybilność z Unity: brak dynamicznych metod pozwala na użycie w IL2CPP
Praktyczne zastosowanie
Scenariusz: obsługa odpowiedzi API
[GenerateOneOf(["Success", "ValidationError", "NetworkError"], Layout = OneOfLayoutKind.Hybrid)]
public readonly partial struct ApiResponse : IOneOf<Success, ValidationError, NetworkError>
Zalety nad OneOf:
- Rozmiar struktury 24 bajty (vs 56 w OneOf)
- Deserializacja przez MemoryPack o 35% szybsza
- Brak alokacji w hot path
Porównanie z przyszłą natywną implementacją
Microsoft zapowiedziała DU w C# 14 z składnią:
public union ApiResponse(Success, ValidationError, NetworkError);
Nasze rozwiązanie już teraz oferuje:
- Obsługę .NET Standard 2.1 (Unity, stare projekty)
- Elastyczność wyboru layoutu
- Integrację z istniejącymi narzędziami
— Editorial Team
Brak komentarzy.