- Proposition visant à introduire les type unions (discriminated unions) en C#, afin d’exprimer qu’une variable ou un paramètre ne peut contenir qu’un seul type parmi plusieurs types limités ; elle est actuellement au stade Proposed, tandis que prototype, implémentation et spécification sont indiqués comme Not Started.
- Les implémentations existantes fondées sur des hiérarchies d’héritage ou sur
object peinent à satisfaire simultanément un ensemble fermé de types, la combinaison de types sans lien entre eux, le stockage de valeurs sans wrapper et l’évitement des allocations ; elles sont donc réparties en quatre catégories d’unions.
- La proposition distingue union class, union struct, union ad hoc et union personnalisée ; chaque approche impose des contraintes différentes en matière de déclaration, d’allocation et de réutilisation de types existants.
- Dans
switch et le pattern matching, traiter tous les types membres fournit une exhaustiveness sans nécessiter de default, mais une union struct peut avoir un default qui ne correspond à aucun membre déclaré, ce qui exige un avertissement et la désignation explicite d’un membre default.
- Les unions ad hoc regroupent des types existants avec la syntaxe
(A or B or C) et sont implémentées par erasure et vérification à l’exécution ; elles ont des limites comme le boxing des types valeur, l’impossibilité des ref type et l’absence de véritable surcharge à l’exécution.
Objectif et motivation de la proposition
- Type unions for C# est une proposition visant à introduire en C# les type unions, c’est-à-dire les discriminated unions.
- L’état du document est Proposed.
- Prototype, Implementation et Specification sont tous indiqués comme Not Started.
- Dans le développement logiciel, il existe des situations où une variable ne doit pas contenir à chaque fois le même genre de valeur, mais l’un de plusieurs types liés et limités.
- L’exemple donné est celui de
Customer et Supplier, qui ne partagent que certaines propriétés mais pour lesquels il faut effectuer des opérations similaires en fonction de leurs différences.
- Répartir l’implémentation dans chaque type via une méthode abstraite commune ou une interface convient lorsque le type existe pour cette opération, ou lorsque cette opération fait partie de l’essence du type.
- Si le type a un objectif plus large, ajouter une telle méthode peut ne pas être souhaitable.
- On peut aussi créer un type de base commun comme
Contact par héritage, mais cela devient difficile ou inapproprié dans les cas suivants :
- lorsqu’on ne possède pas la définition des types ;
- lorsqu’il existe de nombreuses situations similaires et que l’héritage ne peut en résoudre qu’une seule ;
- lorsqu’on ne veut pas faire fuiter les exigences d’une opération particulière dans la définition des données.
- Utiliser
object peut fonctionner, mais la garantie que seules les bonnes valeurs sont fournies doit alors être gérée par la documentation et les commentaires.
- On peut aussi protéger cela avec une hiérarchie de wrappers ou un type d’agrégation personnalisé, mais si les ensembles de types varient selon de nombreuses situations, cela devient long et fastidieux.
- L’objectif est de permettre à C# de déclarer qu’un même emplacement stocke l’un de plusieurs types limités, et de laisser le langage assurer la protection de la variable.
Quatre catégories d’unions
- Comme il est difficile de satisfaire tous les cas d’usage avec une seule implémentation, la proposition les répartit en quatre catégories.
-
Standard - union classes
- À utiliser lorsqu’on veut définir l’union et ses membres ensemble, et que les membres sont destinés à être utilisés comme des classes indépendantes.
- Vise les cas où l’allocation de classes ne pose pas de problème.
- Exemples :
- protocoles, sérialisation, types de transfert de données ;
- modèle de données d’interface utilisateur (XAML) ;
- syntax tree ;
- états de machine à états qui changent peu souvent ;
- autres modèles de données polymorphes ;
- valeurs conservées longtemps sous forme d’union, comme des champs ou propriétés.
-
Specialized - union structs
- À utiliser lorsqu’il faut éviter les allocations ou employer des types spéciaux, et que l’on peut accepter certaines restrictions pour cela.
- Exemples :
- valeurs allouées dans des tableaux contigus ;
- valeurs mappées sur des blocs mémoire (interop) ;
- états de machine à états qui changent fréquemment ;
- valeurs conservées brièvement sous forme d’union, comme des arguments ou valeurs de retour ;
- types de bibliothèques susceptibles d’avoir des usages spécialisés.
-
Ad Hoc - unions ad hoc
- À utiliser lorsqu’il faut composer une union à partir de types déjà existants et potentiellement sans lien entre eux.
- Les unions déclarées avec les mêmes types membres doivent être interchangeables.
-
Custom unions
- Approche destinée aux cas qui ne correspondent pas bien aux autres catégories.
- Exemples :
- types et hiérarchies existants difficiles à redéfinir ;
- disposition de stockage personnalisée ;
- forme et comportement d’API personnalisés.
Standard - union classes
- Une union class est une named type union qui place tous ses types membres dans une déclaration self-contained.
- La déclaration ressemble à une enum, mais s’en distingue par le fait que chaque membre est un type pouvant avoir un état via une ou plusieurs variables d’état.
union U
{
A(int x, string y);
B(int z);
C;
}
- Pour chaque membre, on ne peut spécifier qu’un nom et une liste de variables d’état.
- La création s’effectue en allouant le type membre.
U u = new A(10, "ten");
- Le type du membre créé est
A, et il est converti en U lorsqu’il est assigné à la variable u.
- La déconstruction s’effectue par test de type et pattern matching.
if (u is A a) { ... }
if (u is A(var x, var y)) { ... }
if (u is A { y: var y }) { ... }
- Une union class est considérée comme exhaustive.
- Dans une expression ou instruction
switch, si tous les types membres sont traités, aucun case default n’est nécessaire.
var x = u switch {
A a => a.x,
B b => b.z,
C c => 0
};
null peut être inclus au moyen de la notation nullable standard.
U? u = null;
- L’implémentation est représentée par une abstract record class et des nested derived record class.
[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(); };
}
- L’attribut
Closed permet au langage de comprendre qu’il s’agit d’une hiérarchie fermée, où aucun sous-type n’est déclaré en dehors du module du type de base.
Specialized - union structs
- Une union struct est elle aussi une named type union qui place tous ses types membres dans une déclaration self-contained.
- L’union et les types membres sont tous des struct et peuvent être utilisés sans heap allocation.
- La déclaration ressemble à celle d’une union class, mais ajoute le mot-clé
struct.
union struct U
{
A(int x, string y);
B(int z);
C;
}
- La création, la déconstruction, le switch exhaustif et la notation nullable sont similaires à ceux d’une union class.
U u = new A(10, "ten");
if (u is A a) { ... }
U? u = null;
- Une union struct peut être non assignée ou recevoir
default, et se retrouver dans un état indéfini.
- Cet état ne correspond à aucun type membre déclaré.
- Une exception à l’exécution peut se produire dans un switch qui s’appuie sur l’exhaustiveness.
U u = default;
var x = u switch
{
A a => a.x,
B b => b.z,
C c => 0
}
- Le compilateur génère un avertissement lorsqu’un
default est assigné à une union struct.
// warning: default not a valid state
U u = default;
- Pour éviter l’avertissement, on peut déclarer un état default dans l’union struct afin de l’associer à un type membre particulier.
union struct U
{
A(int x, string y);
B(int z);
C = default;
}
- L’implémentation est représentée par une
struct ayant des types membres record struct imbriqués et une API permettant de convertir entre les types membres et la struct union agrégée
- La disposition interne est choisie par le compilateur afin de stocker efficacement les données des types membres possibles
- Le compilateur choisit également le compromis entre vitesse et taille
[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 => {...};
}
- L’attribut
Union identifie le type comme une union struct
- Une union struct ayant un état par défaut déclare le
UnionKind correspondant avec la valeur 0
- L’API complète de création d’une union struct n’est pas encore indiquée dans la documentation
Tests de type, boxing et reflection des union structs
- Lorsqu’un test de type est effectué sur une union struct connue, il n’inspecte pas le type de la struct elle-même, mais appelle l’API de l’union struct
u is A a
- L’expression ci-dessus est transformée comme suit
u.TryGetA(out var a)
- Les switch expressions sont également transformées sous une forme utilisant
Kind et des appels à TryGetX
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 ...;
}
- Une union struct boxed n’est pas un boxing de la valeur du type membre, mais le boxing de l’union struct elle-même
- Le principal cas d’usage des union structs est d’éviter le boxing, mais celui-ci peut parfois être nécessaire
- Comme le type union struct et ses membres sont connus comme étant liés, il est possible de tester le type d’une union struct boxed par rapport à un type membre et de l’unboxer
U u = ...;
object value = u;
if (value is A a) {...}
- Le code ci-dessus est transformé comme suit
if (value is A a || (value is U u && u.TryGetA(out a))) {...}
- À l’inverse, un type membre boxed peut aussi être testé et unboxé comme une union struct
A a = ...;
object value = a;
if (value is U u) {...}
- Si l’on ne peut pas savoir statiquement que les deux côtés du test de type sont liés à l’union struct, le test de type échoue
bool IsType(object value) => value is T;
U u = new A(...);
if (IsType(u)) {...}
- Lors de l’utilisation de reflection, il peut être nécessaire de convertir le type membre boxed
A en union struct boxed U
- La fonctionnalité de struct union fournit des méthodes utilitaires pour convertir à l’exécution entre une union struct boxed et un type membre boxed
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);
}
- Les union classes et les ad hoc unions ont déjà la forme correcte pour l’utilisation de reflection ; aucune conversion n’est donc nécessaire
Ref union structs
- Une union struct avec le modificateur
ref peut contenir des refs ou des ref structs comme variables d’état
ref union struct U
{
A(ref int x);
B(ReadOnlySpan y);
C;
}
- Dans ce cas, l’implémentation de l’union et les types membres ayant des valeurs ref struct sont convertis en ref structs
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; }
...
}
- Si un type ref record struct est ajouté à C#, les types membres concernés pourront continuer à être conservés comme record structs
Ad Hoc - ad hoc unions
- Une ad hoc union est une union anonyme composée de types déclarés ailleurs
- La syntaxe utilise des parenthèses et la syntaxe de pattern
or
(A or B or C)
- Pour y faire référence avec un nom commun, on utilise un alias
using au niveau du fichier ou global
global using U = (A or B or C);
- La création s’effectue en assignant une instance de l’un des types membres de l’union à une variable de type ad hoc union
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");
- La déconstruction s’effectue via des tests de type et le pattern matching
if (u is A a) {...}
if (u is A(var x, var y)) { ... }
- Une ad hoc union est également considérée comme exhaustive : si tous les types membres sont traités, aucun cas default n’est nécessaire
null peut être inclus avec la notation nullable
(A or B)? x = null;
- Les ad hoc unions ayant les mêmes types membres sont comprises par le compilateur comme le même type, quel que soit l’ordre
(A or B) x = new A(10, "ten");
(B or A) y = x;
Affectation, interchangeabilité et inférence des ad hoc unions
- Une ad hoc union identique ou constituant un sous-ensemble peut être assignée à une ad hoc union sur-ensemble sans vérification à l’exécution
(A or B) x = new A(10, "ten");
(A or B or C) y = x;
- Pour assigner une ad hoc union sur-ensemble à une ad hoc union sous-ensemble, une coercition explicite et une vérification à l’exécution sont nécessaires
(A or B or C) x = new A(10, "ten");
var y = (A or B)x;
- Si tous les types membres de l’union source sont identiques à au moins un membre de l’union cible, ou en sont des sous-types, une coercition implicite est possible sans vérification à l’exécution
(Chihuahua or Siamese) pet = ...;
(Cat or Dog) animal = pet;
- Même si ce n’est pas le cas, si au moins l’un des types membres source est un sous-type de l’un des types membres cible, une coercition explicite et une vérification à l’exécution sont possibles
(Cat or Chihuahua) mostlyCats = ...;
(Dog or Siamese) mostlyDogs = (Dog or Siamese)mostlyCats;
- Les types qui ne sont pas des unions ad hoc peuvent aussi être considérés, pour déterminer l’assignabilité, comme des unions ad hoc à un seul type
- Cette règle fonctionne aussi pour les interfaces implémentées
- Des coercitions généralisées sont également définies
- S’il existe une coercition implicite d’un type vers l’un des types membres de l’union, une valeur de ce type peut être convertie implicitement vers le type union
- Si tous les types membres d’une union peuvent être convertis implicitement vers un certain type, une valeur de l’union peut être convertie implicitement vers ce type
- Si l’un des types membres d’une union peut être converti vers un certain type, une valeur de l’union peut être convertie explicitement vers ce type
- Si tous les types membres de l’union source peuvent être convertis implicitement vers l’un des membres de l’union cible, une coercition implicite entre unions est possible
- Si au moins l’un des membres de l’union source peut être converti explicitement vers l’un des membres de l’union cible, une coercition explicite entre unions est possible
- Il faut encore définir les règles permettant de choisir quelle coercition utiliser lorsque plusieurs sont possibles
- Cette relation d’assignabilité n’est pas une relation de sous-typage
- Une union ad hoc n’est pas un sous-type d’une autre union ad hoc
- Les unions ad hoc ayant les mêmes types membres sont interchangeables via les generics et les éléments de tableau
(T1 or T2)[] F(T1 v1, T2 v2) => new (T1 or T2)[] { v1, v2 };
(Dog or Cat)[] pets = F(rufus, petunia);
- Les unions ad hoc utilisées comme arguments de type générique peuvent être utilisées avec la covariance et la contravariance lorsque tous les types membres des deux unions concernées ont une relation de sous-typage avec les membres correspondants
- À la place de règles concrètes, il reste une note indiquant « Have Mads write this part »
- Dans le pattern matching, les unions ad hoc se comportent de façon similaire au pattern
or et peuvent être accompagnées d’une déclaration de variable
if (u is Dog or Cat) { ... }
if (u is (Dog or Cat)) { ... }
if (u is (Dog or Cat) pet) {...}
- L’affectation à une variable d’union ad hoc peut entraîner le boxing des types valeur
- Les expressions conditionnelles et les expressions switch peuvent inférer un type de résultat union ad hoc à partir des expressions qui les composent
Dog rufus = ...;
Cat petunia = ...;
Bird polly = ...;
var u =
x == 1 ? rufus
: x == 2 ? petunia
: polly;
- Le type de retour d’une expression lambda peut lui aussi être inféré comme une union ad hoc construite à partir des types de retour du corps de la lambda
- Dans ce cas également, un boxing des types valeur peut se produire
Implémentation et limites des unions ad hoc
- Les unions ad hoc sont implémentées par erasure et vérifications à l’exécution
(A or B) ab = new A(10, "ten");
- Le code ci-dessus est transformé comme suit
object ab = new A(10, "ten");
- Les affectations dont la validité ne peut pas être établie statiquement nécessitent une vérification à l’exécution
- Le compilateur génère une méthode personnalisée pour chaque union ad hoc unique utilisée dans le module
object value = ...;
var ab = (A or B)value;
- Voici un exemple de transformation
object value = ...;
object ab = (value);
object (object? value) =>
value is A or B ? value : throw ...;
- Les paramètres ne sont pas vérifiés à l’entrée des méthodes
- Dans les métadonnées, les types union ad hoc sont encodés au moyen d’attributs personnalisés
void M((A or B) x);
- Voici un exemple de transformation
void M([AdHocUnion([typeof(A), typeof(B)])] object x);
- Les détails de l’attribut ne sont pas encore spécifiés
- Comme toutes les unions ad hoc sont effacées vers le même type, le véritable overloading à l’exécution des méthodes ayant des paramètres union ad hoc est impossible
public void Wash((Cat or Dog) pet) { ... }
public void Wash((Compact or Sedan) car) { ... }
- L’overloading reste un sujet de discussion ouvert
Unions personnalisées
- Si un comportement qui ne peut pas être exprimé avec la syntaxe
union class ou union struct est nécessaire, on peut déclarer directement une classe ou une struct personnalisée et faire en sorte que C# la reconnaisse comme un type union personnalisé
- Lorsqu’une union est implémentée avec une hiérarchie de classes, l’attribut
Closed permet d’obtenir le même comportement d’exhaustivité qu’une union class
[Closed]
public class U { ... }
public class A(int x, string y) : U { ... }
public class B(int z) : U { ... }
- Lorsqu’une union est implémentée comme un wrapper struct avec des règles de stockage spécialisées, lui ajouter l’attribut
Union et fournir une API suivant le pattern union la rend fonctionnellement équivalente à une union struct
[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) { ... }
}
- Si l’union n’inclut pas les types membres ou utilise un autre pattern d’API, l’API attendue par le compilateur peut être fournie au moyen d’extensions
- Le pattern complet de l’API des union structs n’est pas encore spécifié
- Les unions ad hoc ne peuvent pas personnaliser leur comportement, sauf en modifiant celui de chaque type membre
Unions courantes : Option et Result
Option est une union struct similaire aux types du même nom ou au même objectif dans d’autres langages
- Elle exprime le fait qu’une valeur puisse exister ou non
public union struct Option
{
Some(TValue value);
None = default;
}
- Voici un exemple d’utilisation
Option x = new Some("text");
Option y = None;
if (x is Some(var value)) {...}
var v = x is Some(var value) ? value : 0;
- Le type
Option n’est pas entièrement spécifié
Result est lui aussi une union struct similaire aux types du même nom ou au même objectif dans d’autres langages
- Il sert à renvoyer, depuis une fonction, soit un résultat de succès, soit une erreur
public union struct Result
{
Success(TValue value);
Failure(TError error);
}
- Voici un exemple d’utilisation
Result x = Success("hurray!");
Result y = Failure("boo");
switch (x)
{
case Success(var value): ...;
case Failure(var error): ...;
}
- Le type
Result n’est pas non plus entièrement spécifié
Propositions connexes
- Cette proposition inclut des propositions supposées exister, ou des fonctionnalités qui restent à proposer
-
Closed Hierarchies
- L’application de l’attribut
Closed à un type de base abstrait déclare tous les sous-types du module de déclaration comme un ensemble fermé de sous-types
- Déclarer un sous-type en dehors du module de déclaration provoque une erreur du compilateur
- Une hiérarchie fermée est traitée comme exhaustive par le compilateur ; si tous les sous-types sont traités dans un switch, aucun cas par défaut n’est nécessaire
-
Singleton values
- Un type singleton doté d’une propriété statique
Singleton peut accéder implicitement à cette propriété dans un contexte non typé et être utilisé comme une valeur
var x = U.C.Singleton;
- Le code ci-dessus peut s’écrire ainsi
var x = U.C;
-
Nested Member Shorthand
- Un nom non lié peut être lié à un membre statique ou à un type imbriqué du type cible
Color color = Color.Red;
- Le code ci-dessus peut s’écrire ainsi
Color color = Red;
U u = new U.A(10, "ten");
- Le code ci-dessus peut s’écrire ainsi
U u = new A(10, "ten");
Décisions de conception et limites indiquées dans la Q&A
- Une union class n’est pas forcément nécessaire s’il est possible de déclarer facilement et directement une hiérarchie de records imbriqués, mais ses avantages sont une syntaxe concise et la facilité de la convertir en union struct avec un simple modificateur
struct
- Une union struct nécessite moins d’allocations et permet d’utiliser davantage de types, mais elle n’est pas mieux adaptée à tous les cas
- Même sans allocation propre, elle n’est pas forcément plus rapide
- Son empreinte sur la pile est plus grande et elle est généralement copiée lors des affectations, passages et retours
- Elle n’est pas facilement interchangeable avec les unions ad hoc anonymes, ce qui la rend peu adaptée à cet usage
- Lorsqu’elle est boxed ou représentée statiquement comme paramètre de type générique, les tests de type, conversions et pattern matching posent problème
- Une union struct est à la fois une tagged union en interne et une type union
- En interne, elle peut exposer une propriété enum jouant le rôle de tag afin d’accélérer le code généré par le compilateur
- À la surface du langage, elle apparaît comme une type union pour pouvoir être manipulée avec des mécanismes familiers comme les tests de type, les casts et le pattern matching
- Le compilateur peut optimiser le cas où un type membre d’une union struct est immédiatement affecté à une variable union struct en sautant la création du type membre
- Lorsqu’une union est directement déstructurée dans des variables, on s’attend aussi à une optimisation évitant de copier une variable d’état d’union struct vers un type membre
- Une union struct n’est pas le véritable type de base des types membres, contrairement à une union class
- Les structs n’autorisent pas l’héritage réel
- Logiquement, elle fonctionne comme un type de base via des conversions automatiques, mais cette relation ne s’étend pas à l’ensemble du type system ni du runtime
- Une union ad hoc ne peut actuellement pas être déclarée directement avec un nom
- Pour éviter de répéter une longue union ou si un nom descriptif est nécessaire, il faut utiliser un alias global using
- Une union ad hoc boxe les types valeur
- S’il faut éviter le boxing, il faut utiliser une union struct
- Une union ad hoc ne peut pas inclure de ref types
- Si des ref types sont nécessaires, il faut utiliser une union struct
- La raison pour laquelle une union ad hoc est effacée en
object est qu’elle améliore les solutions basées sur object que les développeurs utilisent principalement aujourd’hui, avec une type safety à la compilation et des contrôles de validation générés
- Il n’est pas possible d’accéder directement aux propriétés ou méthodes communes d’une union ad hoc sans traiter chaque case de type
- Les valeurs ne sont accessibles qu’après une conversion réussie vers un type individuel
- Les union types de F# correspondent aux union class et union struct de cette spécification, mais cette proposition traite les membres comme des types du langage plutôt que comme des états de tag et des variables d’état associées
- Les unions ad hoc sont similaires aux type unions de Typescript
Option peut ne pas être nécessaire si le même objectif peut être atteint avec null et les nullable reference types
- Certains développeurs préfèrent un type option pour une contrainte plus forte que les nullable types de C#
- C# n’inclut actuellement pas dans le langage les comportements monadiques possibles en F# pour
Option et Result
- De nombreux cas d’utilisation de
Result peuvent être résolus par la gestion des exceptions de C#
- Toutefois, lorsque les erreurs sont attendues et fréquentes à l’exécution, on peut vouloir éviter la gestion des exceptions et obliger l’appelant à traiter explicitement l’erreur
- Des types similaires à
Option et Result existent déjà dans des bibliothèques tierces, mais plusieurs développeurs demandent l’inclusion de types standardisés dans le runtime pour l’interopérabilité entre bibliothèques
1 commentaires
Avis sur Hacker News
Après avoir utilisé les unions discriminées pendant quelques années en F#, je pensais qu’elles existeraient naturellement déjà en C#
Ce ne sera peut-être pas une fonctionnalité que tout le monde appréciera, mais dans un langage typé, il est vraiment difficile de revenir à un langage qui ne propose pas, sous une forme ou une autre, de types de données algébriques (ADT). Aujourd’hui j’utilise Java et, dans l’ensemble, ça va, mais devoir contourner avec des classes wrapper quelque chose qui prendrait trois lignes en F#, c’est assez agaçant
C’est un point d’équilibre subtil qui rend le développement agréable, en reprenant beaucoup des avantages du fonctionnel sans être contraint par des exigences comme le pur fonctionnel. En revanche, C# est très lentement transformé en quelque chose qui ressemble à F#, et c’est aussi étrange à observer
Enfin, relativement selon les standards de Java
Par exemple,
SomeetNoneétaient des classes dérivées de la classeOption, et on pouvait le vérifier en regardant un assembly F# dans un décompilateur. Je ne sais pas si c’est encore le cas aujourd’hui, mais cette proposition pour C#, avec notamment des enums anonymes en syntaxeA or B, semble difficile à réaliser avec du simple sucre syntaxique par-dessus l’héritage. Pour que cela fonctionne, il me semble donc que le CLR devra prendre les enums en charge comme des entités de première classeIl y a aussi le pattern matching
Records https://en.wikipedia.org/wiki/Java_version_history#Java_16
Sealed classes https://en.wikipedia.org/wiki/Java_version_history#Java_17
Et il y a aussi le pattern matching depuis près d’un an https://en.wikipedia.org/wiki/Java_version_history#Java_21
Cela dit, je suis tout à fait d’accord sur le plaisir de développer en F#
Cette proposition me réjouit vraiment. C’était la plus grosse fonctionnalité manquante qu’il fallait toujours ajouter avec un air d’excuse quand on parlait des qualités de C#
À part ça, j’ai du mal à penser à une grande fonctionnalité de langage qui manque à C#. Et même une fois celle-ci ajoutée, j’ai hâte de voir HN continuer à traiter C# comme si c’était le même langage qu’il y a dix ans
Cela permet de convenir à tout le monde et d’éviter de repousser les gens comme le ferait un langage très prescriptif, mais l’apprentissage initial peut devenir un peu plus délicat
Quelqu’un peut-il expliquer pourquoi on appelle ça des type unions ? Je n’avais jamais entendu ce nom
Cela ressemble à des tagged unions à la manière des langages de la famille ML, pas à une union entre types comme en ALGOL68. Je me demande si c’est un cas où les développeurs C# inventent un autre nom au lieu d’utiliser la terminologie existante, comme avec
SelectMany,IEnumerable, etc.Ils ont probablement ajouté « type » devant « union » pour préciser qu’il s’agit de types. Dans la documentation, la syntaxe elle-même semble simplement être
union. La FAQ dit ceci :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.
Elle inclut les hiérarchies fermées de types référence validées par le compilateur au build, les unions taguées qui sont des types valeur traités par le compilateur pour se comporter comme des hiérarchies fermées, les types définis par l’utilisateur pouvant avoir une implémentation arbitraire tout en se branchant sur le même mécanisme du compilateur, ainsi que les unions ad hoc de types existants
Même si je suis passé à côté de toutes les métaphores de couleur façon red/blue/white/black pill, une union avec un pattern matching exhaustif est l’une des fonctionnalités de langage dont il est le plus difficile de se passer une fois qu’on y a goûté.
Je n’ai jamais eu l’impression de comprendre entièrement les implications de l’expression problem, mais mon hypothèse actuelle est la suivante. La manière traditionnelle d’offrir des points d’extension via le polymorphisme convient quand de futurs clients que je ne connais pas doivent étendre le code ; les unions avec pattern matching exhaustif conviennent mieux au code dont moi ou mon équipe sommes propriétaires. En général, on ne cherche pas tant à permettre une extension externe de ce code qu’à mettre à jour les structures de données centrales à mesure que notre compréhension du domaine métier évolue, puis à faire remonter autant que possible, sous forme d’erreurs du compilateur, les endroits où le code impératif ne correspond plus à la structure du domaine.
https://deliberate-software.com/christmas-f-number-polymorph...
Avec l’approche interfaces/héritage de l’orienté objet, il est facile d’ajouter une nouvelle variante de type à une classe de base ou une interface, mais il est difficile d’ajouter une nouvelle fonctionnalité, car il faut l’implémenter dans tous les types existants. Avec l’approche des unions discriminées en fonctionnel, il est facile d’ajouter une nouvelle fonctionnalité en créant une nouvelle fonction et en faisant du matching sur l’union, le compilateur garantissant que tous les cas sont traités ; en revanche, ajouter une nouvelle variante de type est difficile, car il faut mettre à jour tous les pattern matchings exhaustifs dans toute la base de code. Kotlin prend assez bien en charge les deux approches, ce qui en fait un bon exemple.
La terminologie n’est-elle pas un peu décalée ? Il me semble que TypeScript a des union types.
Or ici, cela ressemble plutôt aux discriminated unions qu’on voit en F# ou en Haskell. Pour moi, la différence des unions discriminées est qu’elles ont des constructeurs de cas nommés.
« type union » n’est pas, à ma connaissance, un terme formel que j’aie déjà entendu. On dirait une expression inventée côté C# pour décrire les sum types.
Il y avait peut-être une bonne raison d’avoir repoussé cela jusqu’ici : il faut connaître le public visé. On finit par s’y habituer avec le temps, mais quand on voit trop de
A|B|undefined, cela devient fatigant à lire. Cela encourage aussi une certaine paresse consistant à prendre une chose et à renvoyer vaguement une combinaison de trois possibilités ou plus, et plus on remonte dans la pile d’appels, plus cela devient confus.J’aime le fait que C# force à traiter cela de manière contrainte. Cela dit, s’il existe un argument convaincant du côté de ceux qui attendent cette fonctionnalité, j’aimerais l’entendre.
J’utilise C# depuis longtemps, mais j’ai l’impression de passer à côté de quelque chose dans cette proposition. Les cas d’usage ne me semblent pas bien définis ; quelqu’un peut-il donner un exemple réaliste ?
Les exemples de la proposition semblent pouvoir être implémentés en déclarant une interface vide et en faisant en sorte que quelques classes record « l’implémentent ». Je ne vois pas très bien ce qu’on perdrait avec cette approche.
N’importe qui peut créer une nouvelle implémentation de l’interface, même en dehors de ton code. De plus, les options peuvent ne partager aucune surface commune, ce qui rendrait l’« interface » de tous les types vide ; c’est assez peu naturel en orienté objet. En interne, cela reste une hiérarchie de types, mais la différence essentielle par rapport à une implémentation manuelle est que l’extension est fermée et que, si du code utilisant des case en oublie un, on obtient une erreur à la compilation.
En prenant JSON comme exemple, au lieu de mettre les données dans une seule classe
JsonValue, on peut en faire un type union qui est soit une chaîne, soit un booléen, soit un nombre, soit un tableau de valeurs du type union, soit une map dont les clés sont des chaînes et les valeurs de ce même type union. On peut aussi implémenter des types de résultat commeResulten Rust, afin de définir qu’une API renvoie soit une valeur, soit une erreur. Je n’ai pas vu si la proposition traitait les génériques. Dans le monde de la programmation fonctionnelle, on appelle surtout cela des algebraic data types, et une fois qu’on s’est habitué à ce type de modélisation, cela manque vraiment dans les langages qui ne le prennent pas en charge.[1] https://en.m.wikipedia.org/wiki/Algebraic_data_type
[0] https://github.com/mcintyre321/OneOf
Je l’ai utilisée pour faire renvoyer aux couches externes un type
Resultqui enveloppe DTO, erreurs, etc. On peut faire du pattern matching sur le résultat, ce qui simplifie la gestion des erreurs, et faire évoluer le modèle au fil du temps sans changer la signature des fonctions aux frontières.Le problème du « switch qui ne traite pas tous les case » s’est produit très rarement, environ une fois toutes les 50 000 lignes de code, et on le corrigeait généralement après le premier smoke test. Ce n’était donc pas un gros problème. Je pense que si l’équipe .NET n’a toujours pas ajouté les unions discriminées, c’est aussi parce qu’on peut les imiter efficacement avec des interfaces.
Ok(T value), soitError(string message).Pour obtenir la valeur, il faut faire un
switchsur les deux cas, ce qui force à traiter le cas d’erreur à cet endroit.Sous la section covariance / contravariance, il y a écrit « Note: Have Mads write this part. », ça m’a fait rire :)
Il y a un passage disant que « la disposition interne d’une
union structest choisie par le compilateur comme un compromis entre vitesse et taille, afin de stocker efficacement les données des types membres possibles ».Pour quelqu’un qui s’est autrefois beaucoup brûlé les ailes à tenter de la magie noire de
unionen C# avecFieldOffset, il y a là un problème regrettable. Faire de l’aliasing entre des pointeurs/valeursrefet des types valeur, c’est de l’UB. Autrement dit, une union de struct entreu64etobjecta besoin de champs séparés pour chacun, ce qui gaspille 8 octets. À moins queryujit/le GC ne soient mis à jour pour en tenir compte.Dans .NET 8.0, du code qui place à la fois
UInt64 FooetObject BarsurFieldOffset(0)compile, mais lance uneSystem.TypeLoadExceptionau chargement. Le message indique que le champ object à l’offset 0 est mal aligné ou chevauche un champ non-object. Fait intéressant, le compilateur AOT avertit que cette méthode lève toujours une exception, mais le compilateur C# n’émet aucun avertissement.Je trouve dommage que C#, au lieu de devenir un meilleur langage orienté objet, semble vouloir continuer à devenir un F# plus laid.
Par exemple, pourquoi la syntaxe du dispatch multiple est-elle encore aussi lourde ? Le pseudo-OO a conquis le monde, les gens ont réagi contre lui, donc je comprends qu’il était plus facile, pour rester pertinent, de devenir « un langage un peu fonctionnel avec des accolades » plutôt que d’essayer réellement de bien faire de l’orienté objet.
Mais que manque-t-il du côté orienté objet ? De quoi C# ou d’autres langages auraient-ils besoin pour « bien faire de l’orienté objet » ?
Je suis curieux de savoir quels concepts ou fonctionnalités tu as en tête.
Pour l’instant, j’utilise des
recordimbriqués avec des constructeurs privés, avec le package NuGet https://github.com/shuebner/ClosedTypeHierarchyDiagnosticSup..., afin de garantir que lesswitchsur le type n’aient pas besoin d’un case_.En substance, c’est une version désucrée des « Union Classes » de cette proposition, et cela fonctionne déjà assez bien. Cela dit, je serais content de ne plus avoir besoin du package NuGet et d’avoir du sucre syntaxique, donc cette proposition me plaît.
recordne sont pas fermés, même avec un constructeur privé.Le compilateur génère un constructeur
protectedpour les opérations de copie, et celui-ci peut être hérité. Pour l’empêcher, il faut définir soi-même un constructeurprotectedet lui faire lancer une exception à l’exécution si ce n’est pas un case valide.