1 Punkte von GN⁺ 2024-08-09 | 1 Kommentare | Auf WhatsApp teilen
  • Der Vorschlag führt Type Unions (discriminated unions) in C# ein, um auszudrücken, dass eine Variable oder ein Parameter genau einen von mehreren begrenzten Typen enthalten kann. Der Status ist derzeit Proposed; Prototype, Implementation und Specification sind Not Started.
  • Bestehende Vererbungshierarchien oder object-basierte Implementierungen können eine geschlossene Typmenge, Kombinationen nicht verwandter Typen, das Speichern von Werten ohne Wrapper und das Vermeiden von Allokationen nur schwer gleichzeitig erfüllen; daher wird in vier Union-Kategorien unterschieden.
  • Der Vorschlag unterscheidet union class, union struct, ad hoc union und custom union; jede Variante hat unterschiedliche Einschränkungen bei Deklaration, Allokation und Wiederverwendung bestehender Typen.
  • Wenn in switch und Pattern Matching alle Member-Typen behandelt werden, bietet dies Exhaustiveness ohne erforderlichen default-Fall. Bei union structs kann default jedoch keinem deklarierten Member entsprechen, daher sind Warnungen und die explizite Angabe eines default-Members nötig.
  • Ad hoc unions bündeln bestehende Typen mit der Syntax (A or B or C) und werden über Erasure sowie Laufzeitprüfungen implementiert; sie haben Einschränkungen wie Boxing von Werttypen, keine ref types und kein echtes Runtime Overloading.

Ziel und Motivation des Vorschlags

  • Type unions for C# ist ein Vorschlag, Type Unions, also discriminated unions, in C# einzuführen
    • Der Dokumentstatus ist Proposed
    • Prototype, Implementation und Specification sind jeweils als Not Started markiert
  • In der Softwareentwicklung gibt es Situationen, in denen eine Variable nicht immer dieselbe Art von Wert enthalten soll, sondern einen von mehreren begrenzten, zusammengehörigen Typen
    • Ein Beispiel sind Customer und Supplier, die nur einige Eigenschaften gemeinsam haben, bei denen aber je nach Unterschied ähnliche Operationen ausgeführt werden müssen
  • Eine Umsetzung über gemeinsame abstrakte Methoden oder Interfaces, bei der die Implementierung auf die jeweiligen Typen verteilt wird, ist geeignet, wenn der Typ für diese Operation existiert oder die Operation ein wesentlicher Bestandteil des Typs ist
    • Hat ein Typ einen breiteren Zweck, kann es unerwünscht sein, solche Methoden hinzuzufügen
  • Man könnte per Vererbung einen gemeinsamen Basistyp wie Contact erstellen, doch in folgenden Fällen ist das schwierig oder unpassend
    • Wenn man die Typdefinitionen nicht besitzt
    • Wenn es viele ähnliche Situationen gibt und sich per Vererbung nur eine davon lösen lässt
    • Wenn man Anforderungen einer bestimmten Operation nicht in die Datendefinition durchsickern lassen möchte
  • object kann zwar funktionieren, doch die Garantie, dass nur gültige Werte enthalten sind, muss über Dokumentation und Kommentare verwaltet werden
    • Man kann dies auch mit einer Wrapper-Hierarchie oder einem eigenen Aggregattyp absichern, doch bei vielen situationsabhängigen Typmengen ist das zeitaufwendig und umständlich
  • Ziel ist, C# deklarativ ausdrücken zu lassen, dass an derselben Stelle einer von mehreren begrenzten Typen gespeichert wird, und den Schutz der Variablen der Sprache zu überlassen

Vier Union-Kategorien

  • Da es schwierig ist, alle Use Cases mit einer einzigen Implementierung abzudecken, wird in vier Kategorien unterteilt
  • Standard – union classes

    • Wird verwendet, wenn Union und Member gemeinsam definiert werden können und die Member wie eigenständige Klassen genutzt werden sollen
    • Zielt auf Fälle ab, in denen Klassenallokationen kein Problem sind
    • Beispiele:
      • Protokolle, Serialisierung, Datentransfer-Typen
      • UI-Datenmodelle (XAML)
      • syntax tree
      • Zustände von State Machines, die sich nicht häufig ändern
      • andere polymorphe Datenmodelle
      • Werte, die wie Felder oder Properties länger in Union-Form gehalten werden
  • Specialized – union structs

    • Wird verwendet, wenn Allokationen vermieden werden müssen oder spezielle Typen nötig sind und man dafür gewisse Einschränkungen akzeptieren kann
    • Beispiele:
      • Werte, die in zusammenhängenden Arrays abgelegt werden
      • Werte, die auf Speicherblöcke gemappt werden (Interop)
      • Zustände von State Machines, die sich häufig ändern
      • Werte, die wie Argumente oder Rückgabewerte nur kurz in Union-Form gehalten werden
      • Bibliothekstypen mit möglicher Spezialnutzung
  • Ad Hoc – ad hoc unions

    • Wird verwendet, wenn eine Union aus bereits existierenden Typen gebildet werden muss, die auch nicht miteinander verwandt sein können
    • Unions, die mit denselben Member-Typen deklariert sind, sollen untereinander austauschbar sein
  • Custom unions

    • Eine Variante für Fälle, die nicht gut in die anderen Kategorien passen
    • Beispiele:
      • bestehende Typen und Hierarchien, die sich nicht einfach neu definieren lassen
      • eigenes Speicherlayout
      • eigene API-Form und eigenes Verhalten

Standard – union classes

  • Eine union class ist eine named type union, die alle Member-Typen in einer einzigen self-contained Deklaration enthält
  • Die Deklaration ähnelt einem enum, unterscheidet sich aber darin, dass jedes Member ein Typ ist, der Zustand über eine oder mehrere Zustandsvariablen haben kann
union U
{
    A(int x, string y);
    B(int z);
    C;
}
  • Für jedes Member können nur ein Name und eine Liste von Zustandsvariablen angegeben werden
  • Die Erzeugung erfolgt durch Allokation eines Member-Typs
U u = new A(10, "ten");
  • Der Typ des erzeugten Members ist A und wird bei der Zuweisung an die Variable u in U konvertiert
  • Die Zerlegung erfolgt über Typtests und Pattern Matching
if (u is A a) { ... }

if (u is A(var x, var y)) { ... }

if (u is A { y: var y }) { ... }
  • Eine union class gilt als exhaustive
    • Wenn in einer switch expression oder einem statement alle Member-Typen behandelt werden, ist kein default case erforderlich
var x = u switch {
    A a => a.x,
    B b => b.z,
    C c => 0
    };
  • null kann über die normale Nullable-Schreibweise einbezogen werden
U? u = null;
  • Die Implementierung wird als abstract record class mit nested derived record classes dargestellt
[Closed]
abstract record U
{
    public record A(int x, string y) : U;
    public record B(int z) : U;
    public record C : U { public static C Singleton = new C(); };
}
  • Das Attribut Closed lässt die Sprache verstehen, dass es sich um eine geschlossene Hierarchie handelt, bei der außerhalb des Moduls des Basistyps keine Subtypen deklariert werden

Specialized – union structs

  • Eine union struct ist ebenfalls eine named type union, die alle Member-Typen in einer einzigen self-contained Deklaration enthält
    • Sowohl die Union als auch die Member-Typen sind structs und können ohne heap allocation verwendet werden
  • Die Deklaration ähnelt der union class, ergänzt aber das Schlüsselwort struct
union struct U
{
    A(int x, string y);
    B(int z);
    C;
}
  • Erzeugung, Zerlegung, exhaustive switch und Nullable-Schreibweise entsprechen der union class
U u = new A(10, "ten");

if (u is A a) { ... }

U? u = null;
  • Eine union struct kann nicht zugewiesen sein oder durch Zuweisung von default in einen undefined state geraten
    • Dieser Zustand entspricht keinem der deklarierten Member-Typen
    • Bei einem switch, der sich auf Exhaustiveness verlässt, kann zur Laufzeit eine Exception auftreten
U u = default;

var x = u switch
{
    A a => a.x,
    B b => b.z,
    C c => 0
}
  • Der Compiler erzeugt eine Warnung, wenn einer struct union default zugewiesen wird
// warning: default not a valid state
U u = default;
  • Um die Warnung zu vermeiden, kann in der union struct ein default-Zustand deklariert und mit einem bestimmten Member-Typ verknüpft werden
union struct U
{
    A(int x, string y);
    B(int z);
    C = default;
}
  • Die Implementierung wird als struct dargestellt, die geschachtelte record struct-Member-Typen sowie APIs zum Konvertieren zwischen Member-Typen und dem aggregierten Union-struct besitzt
    • Das interne Layout wird vom Compiler so gewählt, dass die Daten der möglichen Member-Typen effizient gespeichert werden
    • Auch den Trade-off zwischen Geschwindigkeit und Größe wählt der Compiler
[Union]
struct U
{
    public record struct A(int x, string y);
    public record struct B(int z);
    public record struct C { public static C Singleton = default; };

    public static implicit operator U(A value) {...};
    public static implicit operator U(B value) {...};
    public static implicit operator U(C value) {...};

    public static explicit operator A(U union) {...};
    public static explicit operator B(U union) {...};
    public static explicit operator C(U union) {...};

    public bool TryGetA(out A value) {...};
    public bool TryGetB(out B value) {...};
    public bool TryGetC(out C value) {...};

    public enum UnionKind { A = 1, B = 2, C = 3 };
    public UnionKind Kind => {...};
}
  • Das Union-Attribut kennzeichnet, dass der betreffende Typ ein Union-struct ist
  • Ein Union-struct mit default-Zustand deklariert den entsprechenden UnionKind als 0
  • Die vollständige Erzeugungs-API für Union-structs ist in der Dokumentation noch nicht dargestellt

Typprüfung, Boxing und Reflection von Union-structs

  • Bei einer Typprüfung für ein bekanntes Union-struct wird nicht der Typ des struct selbst geprüft, sondern die Union-struct-API aufgerufen
u is A a
  • Der obige Ausdruck wird wie folgt umgewandelt
u.TryGetA(out var a)
  • Auch switch expressions werden in eine Form umgewandelt, die Aufrufe von Kind und TryGetX verwendet
u.Kind switch {
   U.UnionKind.A when u.TryGetA(out var a) => a.x,
   U.UnionKind.B when u.TryGetB(out var b) => b.z,
   U.UnionKind.C when u.TryGetC(out var c) => 0,
   _ => throw ...;
}
  • Bei einem boxed Union-struct ist nicht der Wert des Member-Typs boxed, sondern das Union-struct selbst ist boxed
    • Der wichtigste Anwendungsfall für Union-structs ist zwar das Vermeiden von Boxing, gelegentlich kann Boxing aber erforderlich sein
  • Da bekannt ist, dass Union-struct-Typ und Member miteinander zusammenhängen, kann ein boxed Union-struct per Typprüfung als Member-Typ getestet und unboxed werden
U u = ...;
object value = u;

if (value is A a) {...}
  • Der obige Code wird wie folgt umgewandelt
if (value is A a || (value is U u && u.TryGetA(out a))) {...}
  • Umgekehrt kann auch ein boxed Member-Typ als Union-struct getestet und unboxed werden
A a = ...;
object value = a;

if (value is U u) {...}
  • Wenn statisch nicht erkennbar ist, dass beide Seiten der Typprüfung mit dem Union-struct zusammenhängen, schlägt die Typprüfung fehl
bool IsType(object value) => value is T;

U u = new A(...);

if (IsType(u)) {...}
  • Bei Verwendung von Reflection kann es erforderlich sein, einen boxed Member-Typ A in ein boxed Union-struct U umzuwandeln
  • Die struct-Union-Funktion stellt zur Laufzeit Utility-Methoden bereit, um zwischen boxed Union-struct und boxed Member-Typ zu konvertieren
public static class TypeUnion
{
    public bool TryConvert(Type unionType, object value, out object? boxedUnion);
    public bool TryConvert(object value, out TUnion union);
    public object? GetValue(object? boxedUnion);
}
  • Union-class und Ad-hoc-Union haben bei Verwendung von Reflection bereits die richtige Form, daher ist keine Konvertierung nötig

Ref-Union-structs

  • Ein Union-struct mit ref-Modifier kann refs oder ref structs als Zustandsvariablen enthalten
ref union struct U
{
    A(ref int x);
    B(ReadOnlySpan y);
    C;
}
  • In diesem Fall werden die Union-Implementierung und Member-Typen mit ref-struct-Werten in ref structs umgewandelt
ref struct U
{
    public ref struct A { public ref int x; public A(ref int x) {...}; }
    public ref struct B { public ReadOnlySpan y; public B(ReadOnlySpan y) {...} }
    public record struct C { public static C Singleton = default; }
    ...
}
  • Wenn C# einen ref record struct-Typ hinzufügt, können die betroffenen Member-Typen weiterhin record structs bleiben

Ad Hoc – Ad-hoc-Unions

  • Eine Ad-hoc-Union ist eine anonyme Union, die aus andernorts deklarierten Typen gebildet wird
  • Die Syntax verwendet Klammern und die or-Pattern-Syntax
(A or B or C)
  • Um sie unter einem gemeinsamen Namen zu referenzieren, verwendet man einen file- oder global-using-Alias
global using U = (A or B or C);
  • Die Erzeugung erfolgt, indem man einer Variable vom Ad-hoc-Union-Typ eine Instanz eines der Union-Member-Typen zuweist
record A(int x, string y);
record B(int z);
record C() { public static C Singleton = new C(); };

(A or B or C) u = new A(10, "ten");
  • Die Zerlegung erfolgt über Typprüfungen und Pattern Matching
if (u is A a) {...}

if (u is A(var x, var y)) { ... }
  • Auch eine Ad-hoc-Union gilt als exhaustive; wenn alle Member-Typen behandelt werden, ist kein default case nötig
  • null kann über Nullable-Notation eingeschlossen werden
(A or B)? x = null;
  • Ad-hoc-Unions mit denselben Member-Typen versteht der Compiler unabhängig von der Reihenfolge als denselben Typ
(A or B) x = new A(10, "ten");
(B or A) y = x;

Zuweisung, Austauschbarkeit und Inferenz von Ad-hoc-Unions

  • Ad-hoc-Unions, die denselben Typ haben oder ein Subset sind, können ohne Laufzeitprüfung einer Superset-Ad-hoc-Union zugewiesen werden
(A or B) x = new A(10, "ten");
(A or B or C) y = x;
  • Um eine Superset-Ad-hoc-Union einer Subset-Ad-hoc-Union zuzuweisen, sind explizite Coercion und eine Laufzeitprüfung erforderlich
(A or B or C) x = new A(10, "ten");
var y = (A or B)x;
  • Wenn alle Member-Typen der Source-Union mit mindestens einem Member der Target-Union identisch oder ein Subtype davon sind, ist eine implicit coercion ohne Laufzeitprüfung möglich
(Chihuahua or Siamese) pet = ...;
(Cat or Dog) animal = pet;
  • Auch wenn das nicht der Fall ist, sind explizite Coercion und Laufzeitprüfung möglich, wenn mindestens einer der Source-Member-Typen ein Subtype eines der Target-Member-Typen ist
(Cat or Chihuahua) mostlyCats = ...;
(Dog or Siamese) mostlyDogs = (Dog or Siamese)mostlyCats;
  • Auch Typen, die keine ad hoc union sind, können bei der Beurteilung der Zuweisbarkeit wie eine ad hoc union mit einem einzelnen Typ betrachtet werden
    • Diese Regel funktioniert auch für implementierte Interfaces
  • Auch generalized coercions werden definiert
    • Wenn es von einem Typ eine implicit coercion zu einem der Member-Typen einer Union gibt, kann ein Wert dieses Typs per implicit coercion in den Union-Typ umgewandelt werden
    • Wenn alle Member-Typen einer Union per implicit coercion in einen bestimmten Typ umgewandelt werden können, kann ein Union-Wert per implicit coercion in diesen Typ umgewandelt werden
    • Wenn einer der Member-Typen einer Union per coercion in einen bestimmten Typ umgewandelt werden kann, kann ein Union-Wert per explicit coercion in diesen Typ umgewandelt werden
    • Wenn alle Member-Typen der Source-Union per implicit coercion in einen der Member der Target-Union umgewandelt werden können, ist eine implicit coercion zwischen Unions möglich
    • Wenn mindestens einer der Member der Source-Union per explicit coercion in einen der Member der Target-Union umgewandelt werden kann, ist eine explicit coercion zwischen Unions möglich
  • Es braucht noch Regeln dafür, welche coercion ausgewählt wird, wenn mehrere möglich sind
  • Diese Assignability-Beziehung ist keine Subtyping-Beziehung
    • Eine ad hoc union ist kein Subtype einer anderen ad hoc union
  • ad hoc unions mit denselben Member-Typen sind über generics und array elements austauschbar
(T1 or T2)[] F(T1 v1, T2 v2) => new (T1 or T2)[] { v1, v2 };

(Dog or Cat)[] pets = F(rufus, petunia);
  • Als generic type arguments verwendete ad hoc unions können für covariance und contravariance genutzt werden, wenn alle Member-Typen der beiden beteiligten Unions jeweils in einer Subtype-Beziehung zum entsprechenden Gegen-Member stehen
    • Statt konkreter Regeln ist noch die Notiz „Have Mads write this part“ hinterlassen
  • ad hoc unions verhalten sich beim Pattern Matching ähnlich wie or-Patterns und können zusammen mit Variablendeklarationen auftreten
if (u is Dog or Cat) { ... }

if (u is (Dog or Cat)) { ... }

if (u is (Dog or Cat) pet) {...}
  • Bei der Zuweisung an eine ad hoc union-Variable kann Boxing von Value Types auftreten
  • Conditional expressions und switch expressions können aus ihren Bestandteilen einen ad hoc union result type ableiten
Dog rufus = ...;
Cat petunia = ...;
Bird polly = ...;

var u =
      x == 1 ? rufus
    : x == 2 ? petunia
    : polly;
  • Auch der Rückgabetyp einer lambda expression kann als ad hoc union abgeleitet werden, die aus den Rückgabetypen des lambda body gebildet wird
    • Auch in diesem Fall kann Boxing von Value Types auftreten

Implementierung und Einschränkungen von ad hoc unions

  • ad hoc unions werden per erasure und Laufzeitprüfungen implementiert
(A or B) ab = new A(10, "ten");
  • Der obige Code wird wie folgt umgewandelt
object ab = new A(10, "ten");
  • Assignments, deren Korrektheit nicht statisch bekannt ist, benötigen eine Laufzeitprüfung
    • Der Compiler erzeugt für jede im Modul verwendete eindeutige ad hoc union eine custom method
object value = ...;
var ab = (A or B)value;
  • Ein Umwandlungsbeispiel sieht so aus
object value = ...;
object ab = (value);

object (object? value) =>
    value is A or B ? value : throw ...;
  • Am method entry werden parameters nicht geprüft
  • In metadata werden ad hoc union-Typen über custom attributes codiert
void M((A or B) x);
  • Ein Umwandlungsbeispiel sieht so aus
void M([AdHocUnion([typeof(A), typeof(B)])] object x);
  • Details des attributes sind noch nicht spezifiziert
  • Da alle ad hoc unions auf denselben Typ erased werden, ist true runtime overloading von Methoden mit ad hoc union-Parameter nicht möglich
public void Wash((Cat or Dog) pet) { ... }
public void Wash((Compact or Sedan) car) { ... }
  • Overloading ist noch ein offener Diskussionsbereich

Custom unions

  • Wenn Verhalten benötigt wird, das sich mit der Syntax für union class oder union struct nicht festlegen lässt, kann man selbst eine custom class oder struct deklarieren und C# diese als custom union type erkennen lassen
  • Wenn eine Union über eine class hierarchy implementiert ist, erhält man mit dem Closed attribute dasselbe Exhaustiveness-Verhalten wie bei einer union class
[Closed]
public class U { ... }
public class A(int x, string y) : U { ... }
public class B(int z) : U { ... }
  • Wenn eine Union als struct wrapper mit specialized storage rules implementiert ist, wird sie funktional einer union struct gleichwertig, wenn man das Union attribute anbringt und eine API bereitstellt, die dem Union-Pattern folgt
[Union]
public struct U
{
    public record struct A(int x, string y);
    public record struct B(int z);

    public bool TryGetA(out var A a) { ... }
    public bool TryGetB(out var B b) { ... }
}
  • Wenn die Union keinen member type enthält oder ein anderes API pattern verwendet, kann die vom Compiler erwartete API über extensions bereitgestellt werden
  • Das vollständige API pattern für union struct ist noch nicht spezifiziert
  • Bei ad hoc unions lässt sich das Verhalten nicht customizen, außer indem man das Verhalten der einzelnen member types ändert

Common unions: Option und Result

  • Option ist eine struct union, ähnlich wie gleichnamige oder gleich intendierte Typen in anderen Sprachen
    • Sie drückt aus, dass ein Wert vorhanden sein kann oder auch nicht
public union struct Option
{
    Some(TValue value);
    None = default;
}
  • Ein Verwendungsbeispiel sieht so aus
Option x = new Some("text");
Option y = None;

if (x is Some(var value)) {...}

var v = x is Some(var value) ? value : 0;
  • Der Typ Option ist noch nicht vollständig spezifiziert
  • Auch Result ist eine struct union, ähnlich wie gleichnamige oder gleich intendierte Typen in anderen Sprachen
    • Sie wird verwendet, um aus einer Funktion entweder ein erfolgreiches Ergebnis oder einen Fehler zurückzugeben
public union struct Result
{
    Success(TValue value);
    Failure(TError error);
}
  • Ein Verwendungsbeispiel sieht so aus
Result x = Success("hurray!");
Result y = Failure("boo");

switch (x)
{
    case Success(var value): ...;
    case Failure(var error): ...;
}
  • Auch der Typ Result ist nicht vollständig spezifiziert

Verwandte Vorschläge

  • Dieser Vorschlag enthält Vorschläge, deren Existenz angenommen wird, oder Features, die erst noch vorgeschlagen werden
  • Closed Hierarchies

    • Wenn das Attribut Closed auf einen abstract base type angewendet wird, werden alle subtypes innerhalb des deklarierenden Moduls als geschlossene Menge von subtypes deklariert
    • Wird außerhalb des deklarierenden Moduls ein subtype deklariert, entsteht ein Compilerfehler
    • Eine closed hierarchy wird vom Compiler als exhaustive behandelt; wenn alle subtypes in einem switch behandelt werden, ist kein default case erforderlich
  • Singleton values

    • Ein singleton-Typ mit statischer Singleton-Property kann in einem non-type context implizit auf diese Property zugreifen und sie wie einen Wert verwenden
var x = U.C.Singleton;
  • Der obige Code kann so geschrieben werden
var x = U.C;
  • Nested Member Shorthand

    • Ein ungebundener Name kann an ein static member oder einen nested type des target type gebunden werden
Color color = Color.Red;
  • Der obige Code kann so geschrieben werden
Color color = Red;
U u = new U.A(10, "ten");
  • Der obige Code kann so geschrieben werden
U u = new A(10, "ten");

In Q&A genannte Designentscheidungen und Einschränkungen

  • Eine union class ist nicht zwingend nötig, wenn sich eine nested record hierarchy direkt einfach deklarieren lässt; ihre Vorteile sind jedoch die knappe Syntax und die einfache Umstellung auf eine union struct mit nur einem struct-Modifier
  • Eine union struct verursacht weniger Allokationen und kann mehr Arten von Typen verwenden, ist aber nicht in jedem Fall besser geeignet
    • Auch ohne eigene Allocation ist sie nicht zwangsläufig schneller
    • Ihr Stack-Footprint ist größer, und bei assignment, pass und return wird sie in der Regel kopiert
    • Für anonyme ad hoc unions ist sie nicht gut geeignet, da sie sich nicht leicht austauschen lässt
    • Im boxed Zustand oder wenn sie statisch als generic type parameter repräsentiert wird, gibt es Probleme mit type tests, casts und pattern matching
  • Eine union struct ist intern eine tagged union und zugleich eine type union
    • Intern kann sie eine enum property als Tag offenlegen, damit vom Compiler erzeugter Code schneller sein kann
    • Auf der Sprachoberfläche erscheint sie als type union, sodass sie mit vertrauten Mechanismen wie type tests, casts und pattern matching behandelt werden kann
  • Der Compiler kann optimieren, wenn ein union-struct-member-type unmittelbar einer union-struct-Variable zugewiesen wird, indem die Erzeugung des member type übersprungen wird
  • Beim direkten Zerlegen einer union in Variablen wird erwartet, dass auch das Kopieren einer union-struct-Statusvariable in den member type optimiert und übersprungen werden kann
  • Eine union struct ist nicht wie eine union class der tatsächliche base type des member type
    • structs erlauben keine echte Vererbung
    • Logisch verhält sie sich durch automatische Konvertierungen wie ein base type, aber diese Beziehung erstreckt sich nicht auf das gesamte type system und die runtime
  • Eine ad hoc union kann derzeit nicht direkt mit einem Namen deklariert werden
    • Um lange Wiederholungen von unions zu vermeiden oder einen beschreibenden Namen zu benötigen, muss ein global using alias verwendet werden
  • Eine ad hoc union boxt value types
    • Wenn Boxing vermieden werden muss, sollte eine union struct verwendet werden
  • Eine ad hoc union kann keine ref types enthalten
    • Wenn ref types benötigt werden, sollte eine union struct verwendet werden
  • Der Grund, warum eine ad hoc union zu object erased wird, ist, dass damit die heute von Entwicklern häufig genutzten object-basierten Lösungen durch compile-time type safety und generated validation checks verbessert werden
  • Auf gemeinsame Properties oder Methoden einer ad hoc union kann nicht direkt zugegriffen werden, ohne die einzelnen Type-Cases zu behandeln
    • Auf den Wert kann erst zugegriffen werden, nachdem er erfolgreich in den jeweiligen Typ konvertiert wurde
  • Die union types von F# entsprechen der union class und union struct in dieser Spezifikation; dieser Vorschlag behandelt Member jedoch als Typen der Sprache und nicht als tag state und associated state variables
    • Ad hoc unions ähneln den type unions von TypeScript
  • Option ist möglicherweise nicht nötig, wenn sich derselbe Zweck mit null und nullable reference types erreichen lässt
    • Einige Entwickler bevorzugen option types, weil sie eine stärkere Erzwingung bieten als die nullable types von C#
  • C# enthält derzeit keine monadic behaviors für Option und Result, wie sie in F# möglich sind
  • Viele Anwendungsfälle von Result lassen sich durch Exception Handling in C# lösen
    • Wenn Fehler zur Laufzeit jedoch erwartet werden und häufig auftreten, möchte man möglicherweise Exception Handling vermeiden und Aufrufer Fehler explizit behandeln lassen
  • Typen ähnlich Option und Result gibt es bereits in Drittanbieterbibliotheken; mehrere Entwickler haben jedoch darum gebeten, standardisierte Typen in die Runtime aufzunehmen, um Interoperability zwischen Bibliotheken zu ermöglichen

1 Kommentare

 
GN⁺ 2024-08-09
Meinungen auf Hacker News
  • Nachdem ich Discriminated Unions in F# einige Jahre verwendet hatte, dachte ich, dass C# sie inzwischen selbstverständlich auch haben würde.
    Es ist sicher kein Feature, das alle lieben, aber in einer typisierten Sprache ist es wirklich schwer, zu einer Sprache zurückzukehren, die in irgendeiner Form keine algebraischen Datentypen (ADT) hat. Ich verwende derzeit Java und komme damit im Großen und Ganzen gut zurecht, aber es nervt ziemlich, wenn man Dinge, die in F# drei Zeilen wären, über Wrapper-Klassen umgehen muss.

    • Wenn man sich einmal an F# gewöhnt hat, ist es sehr schwer, wieder zurückzugehen. Ich wünschte, Microsoft würde es besser unterstützen und stärker vorantreiben.
      Es ist beim Entwickeln angenehm, weil es einen sehr guten Sweet Spot trifft: Man nimmt viele Vorteile funktionaler Programmierung mit, ohne an Einschränkungen wie reine Funktionen gebunden zu sein. Stattdessen wird C# sehr langsam in Richtung F# umgebaut, und das zu beobachten ist auch irgendwie seltsam.
    • Die gute Nachricht ist, dass das heutzutage auch in Java mit sealed interfaces und Records mit relativ wenig Boilerplate möglich ist.
      Jedenfalls für Java-Verhältnisse.
    • Als ich 2020 aufgehört habe, F# und .NET zu verwenden, gab es in der CLR nur Vererbung, und auch die Enums von F# waren tatsächlich über Vererbung implementiert.
      Zum Beispiel waren Some und None abgeleitete Klassen der Klasse Option, was man sehen konnte, wenn man eine F#-Assembly mit einem Decompiler betrachtete. Ich weiß nicht, ob das heute noch genauso ist, aber dieser C#-Vorschlag wirkt so, als ließe er sich, einschließlich anonymer Enums mit A or B-Syntax, nur schwer allein als syntaktischer Zucker über Vererbung umsetzen. Damit das funktioniert, müsste die CLR Enums vermutlich als First-Class-Konzept unterstützen.
    • Zur Einordnung: Java hat seit Version 16 Tagged-Union-Typen.
      Pattern Matching gibt es ebenfalls.
    • Java hat schon seit einigen Jahren ADTs, daher klingt die Aussage, man müsse „Dinge, die in drei Zeilen möglich wären, über Wrapper-Klassen umgehen“, etwas seltsam.
      Records https://en.wikipedia.org/wiki/Java_version_history#Java_16
      Sealed Classes https://en.wikipedia.org/wiki/Java_version_history#Java_17
      Und seit fast einem Jahr gibt es auch Pattern Matching https://en.wikipedia.org/wiki/Java_version_history#Java_21
      Der Freude am Entwickeln mit F# stimme ich allerdings sehr zu.
  • Auf diesen Vorschlag freue ich mich wirklich. Es war das größte fehlende Feature, das ich immer entschuldigend hinzufügen musste, wenn ich über die Stärken von C# gesprochen habe.
    Abgesehen davon fällt mir kaum ein großes Sprachfeature ein, das C# fehlt. Ich freue mich auch schon darauf, dass C# selbst nach der Aufnahme dieses Features auf HN weiterhin so behandelt werden wird, als wäre es dieselbe Sprache wie vor zehn Jahren.

    • Ordentliche Typaliase wären auch schön, aber C# zu schreiben macht heutzutage wirklich Spaß.
    • Ob gut oder schlecht: Das Designziel von C# scheint zu sein, möglichst jedes Feature aufzunehmen, und das auch noch in mehreren Varianten.
      Dadurch kann es sich an fast alle anpassen und stößt Leute nicht so ab wie eine Sprache mit sehr starken Meinungen, aber für Anfänger kann es etwas schwieriger werden.
  • Kann jemand erklären, warum das type unions genannt wird? Diese Bezeichnung habe ich noch nie gehört.
    Es wirkt nicht wie eine Union zwischen Typen wie in ALGOL68, sondern eher wie eine Tagged Union aus Sprachen der ML-Familie. Ich frage mich, ob das ein weiterer Fall ist, in dem C#-Entwickler statt etablierter Begriffe eigene Namen erfinden. So wie bei SelectMany, IEnumerable und Ähnlichem.

    • „Tagged“ klingt nach einem Implementierungsdetail, nämlich dass es intern einen Tag gibt, der den Typ unterscheidet.
      Vermutlich wurde „type“ vor „union“ gesetzt, um klarzumachen, dass es um Typen geht. In der Dokumentation scheint die Syntax selbst einfach union zu sein. In der FAQ steht dazu:
      Q: Why are there no tagged unions?
      A: Union structs are both tagged unions and type unions. Under the hood, a union struct is a tagged union, even exposing an enum property that is the tag to enable faster compiler generated code, but in the language it is presented as a type union to allow you to interact with it in familiar ways, like type tests, casts and pattern matching.
    • Weil dieser Vorschlag mehrere Arten von Unions behandelt.
      Dazu gehören geschlossene Hierarchien von Referenztypen, die der Compiler zur Build-Zeit prüft; Tagged Unions als Werttypen, die der Compiler so behandelt, dass sie sich wie geschlossene Hierarchien verhalten; benutzerdefinierte Typen mit beliebiger Implementierung, die dennoch an denselben Compiler-Mechanismus angebunden werden können; sowie Ad-hoc-Unions bestehender Typen.
  • Farbmetaphern wie red/blue/white/black pill habe ich zwar alle verpasst, aber eine Union mit exhaustivem Pattern Matching ist eine der Sprachfunktionen, ohne die man am schwersten leben kann, sobald man sie einmal kennt.
    Ich hatte nie das Gefühl, die Implikationen des expression problem vollständig verstanden zu haben, aber meine aktuelle Hypothese ist diese: Die Art, Erweiterungspunkte über traditionelle Polymorphie bereitzustellen, eignet sich, wenn zukünftige Clients, die ich nicht kenne, den Code erweitern. Eine Union mit exhaustivem Pattern Matching passt besser zu Code, der mir oder meinem Team gehört. Meist will man diesen Code nicht von außen erweitern, sondern die zentralen Datenstrukturen an ein verändertes Verständnis der Business-Domäne anpassen und danach vom Compiler möglichst viele Stellen als Fehler gemeldet bekommen, an denen imperativer Code nicht mehr zur Domänenstruktur passt.

    • Ich habe diesen Artikel geschrieben, um den Unterschied zu verstehen.
      https://deliberate-software.com/christmas-f-number-polymorph...
    • Meiner Ansicht nach lässt sich der Kern des expression problem als Gegensatz dazu erklären, wie Objektorientierung und funktionale Programmierung Erweiterbarkeit von Code erreichen.
      Beim Interface-/Vererbungsansatz der Objektorientierung ist es leicht, neue Typvarianten einer Basisklasse oder eines Interfaces hinzuzufügen, aber schwer, neue Funktionalität hinzuzufügen, weil sie für alle bestehenden Typen implementiert werden muss. Beim funktionalen Ansatz mit discriminated unions ist es leicht, neue Funktionen hinzuzufügen, indem man eine neue Funktion schreibt und über die Union matcht, wobei der Compiler die Behandlung aller Fälle sicherstellt. Fügt man aber eine neue Typvariante hinzu, muss man das exhaustive Pattern Matching in der gesamten Codebase aktualisieren. Kotlin unterstützt beide Ansätze ziemlich gut und ist daher ein gutes Beispiel.
  • Ist die Terminologie nicht etwas verrutscht? Soweit ich weiß, hat TypeScript union types.
    Das hier sieht aber eher aus wie eine discriminated union, wie man sie aus F# oder Haskell kennt. Der Unterschied bei discriminated unions ist aus meiner Sicht, dass es benannte Case-Konstruktoren gibt.

    • TypeScript hat „union types“, und dieser Vorschlag scheint sie ad hoc unions zu nennen.
      „type union“ ist zumindest kein offizieller Begriff, den ich je gehört hätte. Es wirkt wie eine Formulierung, die auf der C#-Seite geprägt wurde, um sum types zu erklären.
    • Wenn man von C# zu TypeScripts type union wechselt, fühlte sich das für mich immer merkwürdig an.
      Es kann gute Gründe gegeben haben, warum man das bis jetzt aufgeschoben hat. Man muss schließlich die Zielnutzer kennen. Mit der Zeit gewöhnt man sich zwar daran, aber wenn man zu oft A|B|undefined sieht, wird das Lesen anstrengend. Außerdem entsteht die Bequemlichkeit, irgendetwas entgegenzunehmen und grob Kombinationen aus drei oder mehr Dingen zurückzugeben, und je weiter man im Call Stack nach oben geht, desto verwirrender wird es.
      Mir gefällt, dass C# das auf eingeschränkte Weise angeht. Wenn es allerdings eine überzeugende Argumentation auf Seiten derjenigen gibt, die dieses Feature erwarten, würde ich sie gern hören.
  • Ich nutze C# seit Langem, habe aber das Gefühl, bei diesem Vorschlag etwas zu übersehen. Die Use Cases wirken nicht gut definiert; kann jemand ein realistisches Beispiel nennen?
    Die Beispiele im Vorschlag scheinen sich umsetzen zu lassen, indem man ein leeres Interface deklariert und ein paar record-Klassen dieses „implementieren“ lässt. Mir ist nicht klar, was man dadurch verliert.

    • Eine solche Hierarchie ist eine offene Hierarchie in dem Sinn, dass man bei der Verarbeitung nicht wissen kann, ob alle Fälle abgedeckt sind.
      Jeder kann eine neue Implementierung des Interfaces erstellen, sogar außerhalb meines Codes. Außerdem können die Alternativen überhaupt keine gemeinsame Oberfläche teilen, sodass das „Interface“ aller Typen leer wäre, was in der Objektorientierung etwas unnatürlich ist. Intern ist es am Ende zwar eine Typhierarchie, aber der zentrale Unterschied zur manuellen Implementierung ist, dass die Erweiterung geschlossen ist und es einen Compile-Time-Fehler gibt, wenn Code beim Verwenden der Cases einen Fall auslässt.
    • Die realistischsten Beispiele sind ASTs oder Datenprotokolle.
      Bei JSON könnte man statt einer einzigen JsonValue-Klasse, die die Daten enthält, einen Union-Typ daraus machen, der entweder ein String, ein Boolean, eine Zahl, ein Array aus Werten dieses Union-Typs oder eine Map von String-Schlüsseln auf Werte dieses Union-Typs ist. Auch Ergebnistypen wie Rusts Result lassen sich implementieren, sodass eine API als Rückgabe entweder einen Wert oder einen Fehler definieren kann. Ich habe nicht gesehen, ob der Vorschlag Generics behandelt. In der Welt der funktionalen Programmierung nennt man das meist algebraic data type, und wenn man sich an diese Art der Typmodellierung gewöhnt hat, vermisst man sie in Sprachen ohne Unterstützung wirklich.
      [1] https://en.m.wikipedia.org/wiki/Algebraic_data_type
    • Diese Bibliothek implementiert discriminated unions im F#-Stil.
      [0] https://github.com/mcintyre321/OneOf
      Ich habe sie verwendet, wenn äußere Schichten einen Result-Typ zurückgeben sollten, der DTOs, Fehler usw. in eins kapselt. Da man über das Ergebnis pattern-matchen kann, wird Fehlerbehandlung einfacher, und das Modell lässt sich im Laufe der Zeit weiterentwickeln, ohne die Funktionssignatur an der Grenze zu ändern.
    • Ich imitiere F#-discriminated-unions seit über fünf Jahren mit leeren Interfaces, und das hat sehr gut funktioniert.
      Das Problem „im switch wurden nicht alle Cases behandelt“ trat nur sehr selten auf, grob einmal pro 50.000 Codezeilen, und wurde normalerweise nach dem ersten Smoke-Test-Lauf behoben. Es war also kein großes Problem. Ich denke, dass das .NET-Team discriminated unions noch nicht hinzugefügt hat, liegt auch daran, dass man sie mit Interfaces effektiv nachahmen kann.
    • Ein einfaches Beispiel ist Result, das entweder Ok(T value) oder Error(string message) sein kann.
      Um den Wert zu erhalten, muss man über beide Fälle switchen, wodurch man gezwungen wird, den Fehlerfall direkt an Ort und Stelle zu behandeln.
  • Unter dem Abschnitt zu covariance / contravariance steht „Note: Have Mads write this part.“ Das hat mich zum Lachen gebracht :)

    • Mit Mads ist hier Mads Torgersen gemeint, der Lead Designer von C#.
  • Es gibt eine Passage: „Das interne Layout einer union struct wird vom Compiler als Abwägung zwischen Geschwindigkeit und Größe gewählt, um die Daten der möglichen Member-Typen effizient zu speichern.“
    Aus der Perspektive von jemandem, der früher mit FieldOffset zu viel C#-Union-Schwarzmagie ausprobiert und sich dabei ordentlich die Finger verbrannt hat, gibt es hier ein bedauerliches Problem. Das Aliasing von Pointer-/ref-Werten und Werttypen ist UB. Das heißt, eine Struct-Union aus u64 und object braucht für beide separate Felder, wodurch 8 Byte verschwendet werden. Zumindest solange ryujit/GC nicht aktualisiert werden, um davon zu wissen.

    • Es ist nicht UB, sondern illegal. Laut ECMA 335, II.10.7 kann man Felder zwar auf diese Weise überlappen lassen, aber der Offset, den eine Objektreferenz belegt, darf sich nicht mit einem Offset überlappen, der von einem eingebauten Werttyp oder einem Teil einer anderen Objektreferenz belegt wird.
      Code, der in .NET 8.0 bei FieldOffset(0) sowohl UInt64 Foo als auch Object Bar platziert, kompiliert zwar, wirft beim Laden aber eine System.TypeLoadException. Die Meldung besagt, dass das object-Feld bei Offset 0 falsch ausgerichtet ist oder sich mit einem non-object-Feld überlappt. Interessanterweise warnt der AOT-Compiler, dass diese Methode immer throw ausführt, der C#-Compiler gibt jedoch keinerlei Warnung aus.
  • Ich finde es schade, dass C# offenbar nicht zu einer besseren objektorientierten Sprache wird, sondern weiter versucht, ein hässlicheres F# zu werden.
    Warum ist zum Beispiel die Syntax für Multiple Dispatch immer noch so unbeholfen? Ich verstehe schon: Pseudo-OO hat die Welt erobert, die Leute haben dagegen reagiert, und statt tatsächlich gutes OO zu machen, war es wohl einfacher, relevant zu bleiben, indem man zu „einer leicht funktionalen Sprache mit geschweiften Klammern“ wird.

    • Von der anderen Seite gibt es dieselbe Beschwerde. Mainstream-Sprachen hatten früher weitreichend veränderlichen Zustand und Seiteneffekte, und jetzt sind eben noch Lambdas dazugekommen.
      Aber was fehlt auf der objektorientierten Seite? Was bräuchte C# oder eine andere Sprache, um den Zustand zu erreichen, „Objektorientierung gut zu machen“?
    • Was ist hier mit „besserer Objektorientierung“ gemeint?
      Ich würde gern wissen, an welche Konzepte oder Features dabei gedacht wird.
  • Derzeit verwende ich verschachtelte Records mit privaten Konstruktoren zusammen mit dem NuGet-Paket https://github.com/shuebner/ClosedTypeHierarchyDiagnosticSup..., um sicherzustellen, dass bei switch-Typen kein _-Case nötig ist.
    Im Grunde ist das eine desugarte Version der „Union Classes“ aus diesem Vorschlag, und sie funktioniert schon ziemlich gut. Trotzdem wäre es schön, kein NuGet-Paket mehr zu brauchen und auch syntaktischen Zucker zu bekommen; deshalb gefällt mir dieser Vorschlag.

    • Man sollte beachten, dass Record-Typen auch mit privatem Konstruktor nicht geschlossen sind.
      Der Compiler erzeugt einen protected Konstruktor für Kopieroperationen, und von diesem kann geerbt werden. Um das zu verhindern, muss man selbst einen protected Konstruktor definieren und zur Laufzeit eine Ausnahme werfen lassen, wenn es kein gültiger Case ist.