Powrót do strony głównej

Discriminated Unions w C#: Analiza i optymalizacja dla production

Analiza istniejących rozwiązań do implementacji Discriminated Unions w C#. Porównanie wydajności OneOf i DuNet, tworzenie zoptymalizowanego Source Generator z obsługą serializacji i Unity. Praktyczne zalecenia dla systemów high-load.

Efektywne Discriminated Unions w C#: jak obejść ograniczenia języka
Advertisement 728x90

# 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

Google AdInline article slot

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.

Google AdInline article slot

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:

Google AdInline article slot
  • Gotowe metody Match/Switch z 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

Advertisement 728x90

Czytaj dalej