Conception par types: comment rendre les états invalides inexprimables en C #

En règle générale, les articles sur la conception de types contiennent des exemples dans les langages fonctionnels - Haskell, F # et autres. Ce concept peut ne pas sembler s'appliquer aux langages orientés objet, mais il ne l'est pas.


Dans cet article, je vais traduire des exemples d'un article de Scott Vlaschin Type Design: comment rendre des états invalides inexprimables en C # idiomatique . J'essaierai également de montrer que cette approche est applicable non seulement en tant qu'expérience, mais aussi en code de travail.


Créer des types de domaine


Vous devez d'abord porter les types de l' article précédent de la série , qui sont utilisés dans les exemples en F #.


Envelopper les types primitifs dans le domaine


Les exemples F # utilisent des types de domaine au lieu de primitives pour l'adresse e-mail, le code postal américain et le code d'état. Essayons d'encapsuler un type primitif en C #:


public sealed class EmailAddress { public string Value { get; } public EmailAddress(string value) { if (value == null) { throw new ArgumentNullException(nameof(value)); } if (!Regex.IsMatch(value, @"^\S+@\S+\.\S+$")) { throw new ArgumentException("Email address must contain an @ sign"); } Value = value; } public override string ToString() => Value; public override bool Equals(object obj) => obj is EmailAddress otherEmailAddress && Value.Equals(otherEmailAddress.Value); public override int GetHashCode() => Value.GetHashCode(); public static implicit operator string(EmailAddress address) => address?.Value; } 

 var a = new EmailAddress("a@example.com"); var b = new EmailAddress("b@example.com"); var receiverList = String.Join(";", a, b); 

J'ai déplacé la validation d'adresse de la fonction d'usine vers le constructeur, car une telle implémentation est plus typique pour C #. Nous avons également dû implémenter une comparaison et une conversion en chaîne, ce qui sur F # serait fait par le compilateur.


D'une part, la mise en œuvre semble assez volumineuse. En revanche, la spécificité de l'adresse e-mail ne s'exprime ici que par des vérifications dans le constructeur et, éventuellement, par la logique de comparaison. Il s'agit en grande partie de code d'infrastructure, qui, en outre, est peu susceptible de changer. Ainsi, vous pouvez soit créer un modèle , soit, au pire, copier le code général de classe en classe.


Il convient de noter que la création de types de domaine à partir de valeurs primitives n'est pas la spécificité de la programmation fonctionnelle. Au contraire, l'utilisation de types primitifs est considérée comme un signe de mauvais code en POO . Vous pouvez voir des exemples de tels wrappers, par exemple, dans NLog et NBitcoin , et le type standard de TimeSpan est, en fait, un wrapper sur le nombre de ticks.


Création d'objets de valeur


Maintenant, nous devons créer un analogue de l' entrée :


 public sealed class EmailContactInfo { public EmailAddress EmailAddress { get; } public bool IsEmailVerified { get; } public EmailContactInfo(EmailAddress emailAddress, bool isEmailVerified) { if (emailAddress == null) { throw new ArgumentNullException(nameof(emailAddress)); } EmailAddress = emailAddress; IsEmailVerified = isEmailVerified; } public override string ToString() => $"{EmailAddress}, {(IsEmailVerified ? "verified" : "not verified")}"; } 

Encore une fois, il a fallu plus de code que F #, mais la plupart du travail peut être effectué par une refactorisation dans l'EDI .


Comme EmailAddress , EmailContactInfo est un objet de valeur (au sens de DDD , pas des types de valeur dans .NET ), connu depuis longtemps et utilisé dans la modélisation d'objet.


D'autres types - StateCode , StateCode , PostalAddress et PersonalName portés en C # de la même manière.


Créer un contact


Ainsi, le code doit exprimer la règle "Le contact doit contenir une adresse e-mail ou une adresse postale (ou les deux adresses)." Il est nécessaire d'exprimer cette règle afin que l'exactitude de l'état soit visible à partir de la définition de type et vérifiée par le compilateur.


Exprimer divers états de contact


Cela signifie qu'un contact est un objet contenant le nom de la personne et soit une adresse e-mail, soit une adresse postale, ou les deux. De toute évidence, une classe ne peut pas contenir trois ensembles de propriétés différents; par conséquent, trois classes différentes doivent être définies. Les trois classes doivent contenir le nom du contact et en même temps, il devrait être possible de traiter les contacts de différents types de la même manière, sans savoir quelles adresses contient le contact. Par conséquent, le contact sera représenté par une classe de base abstraite contenant le nom du contact et trois implémentations avec un ensemble de champs différent.


 public abstract class Contact { public PersonalName Name { get; } protected Contact(PersonalName name) { if (name == null) { throw new ArgumentNullException(nameof(name)); } Name = name; } } public sealed class PostOnlyContact : Contact { private readonly PostalContactInfo post_; public PostOnlyContact(PersonalName name, PostalContactInfo post) : base(name) { if (post == null) { throw new ArgumentNullException(nameof(post)); } post_ = post; } } public sealed class EmailOnlyContact : Contact { private readonly EmailContactInfo email_; public EmailOnlyContact(PersonalName name, EmailContactInfo email) : base(name) { if (email == null) { throw new ArgumentNullException(nameof(email)); } email_ = email; } } public sealed class EmailAndPostContact : Contact { private readonly EmailContactInfo email_; private readonly PostalContactInfo post_; public EmailAndPostContact(PersonalName name, EmailContactInfo email, PostalContactInfo post) : base(name) { if (email == null) { throw new ArgumentNullException(nameof(email)); } if (post == null) { throw new ArgumentNullException(nameof(post)); } email_ = email; post_ = post; } } 

Vous pouvez affirmer que vous devez utiliser la composition , pas l'héritage, et généralement vous devez hériter du comportement, pas des données. Les remarques sont justes, mais, à mon avis, l'utilisation de la hiérarchie des classes est justifiée ici. Premièrement, les sous-classes ne représentent pas seulement des cas particuliers de la classe de base, la hiérarchie entière est un concept - le contact. Trois implémentations de contact reflètent très précisément les trois cas stipulés par la règle métier. Deuxièmement, la relation entre la classe de base et ses héritiers, la répartition des responsabilités entre eux est facilement identifiable. Troisièmement, si la hiérarchie devient vraiment un problème, vous pouvez séparer l'état du contact en une hiérarchie distincte, comme cela a été fait dans l'exemple d'origine. En F #, l'héritage des enregistrements est impossible, mais les nouveaux types sont déclarés tout simplement, donc le fractionnement a été effectué immédiatement. En C #, une solution plus naturelle serait de placer les champs Nom dans la classe de base.


Créer un contact


Créer un contact est assez simple.


 public abstract class Contact { public static Contact FromEmail(PersonalName name, string emailStr) { var email = new EmailAddress(emailStr); var emailContactInfo = new EmailContactInfo(email, false); return new EmailOnlyContact(name, emailContactInfo); } } 

 var name = new PersonalName("A", null, "Smith"); var contact = Contact.FromEmail(name, "abc@example.com"); 

Si l'adresse e-mail est incorrecte, ce code lèvera une exception, qui peut être considérée comme un analogue du retour None dans l'exemple d'origine.


Mise à jour des contacts


La mise à jour d'un contact est également simple - il vous suffit d'ajouter une méthode abstraite au type de Contact .


 public abstract class Contact { public abstract Contact UpdatePostalAddress(PostalContactInfo newPostalAddress); } public sealed class EmailOnlyContact : Contact { public override Contact UpdatePostalAddress(PostalContactInfo newPostalAddress) => new EmailAndPostContact(Name, email_, newPostalAddress); } public sealed class PostOnlyContact : Contact { public override Contact UpdatePostalAddress(PostalContactInfo newPostalAddress) => new PostOnlyContact(Name, newPostalAddress); } public sealed class EmailAndPostContact : Contact { public override Contact UpdatePostalAddress(PostalContactInfo newPostalAddress) => new EmailAndPostContact(Name, email_, newPostalAddress); } 

 var state = new StateCode("CA"); var zip = new ZipCode("97210"); var newPostalAddress = new PostalAddress("123 Main", "", "Beverly Hills", state, zip); var newPostalContactInfo = new PostalContactInfo(newPostalAddress, false); var newContact = contact.UpdatePostalAddress(newPostalContactInfo); 

Comme avec option.Value en F #, lever une exception des constructeurs est possible si l'adresse e-mail, le code postal ou l'état est incorrect, mais pour C # c'est une pratique courante. Bien sûr, le code d'exception doit être fourni dans le code de travail ici ou quelque part dans le code appelant.


Gérer les contacts en dehors de la hiérarchie


Il est logique de placer la logique de mise à jour d'un contact dans la hiérarchie des Contact elle-même. Mais que faire si vous voulez accomplir quelque chose qui ne rentre pas dans son domaine de responsabilité? Supposons que vous souhaitiez afficher les contacts sur l'interface utilisateur.


Vous pouvez bien sûr ajouter à nouveau la méthode abstraite à la classe de base et continuer à ajouter une nouvelle méthode chaque fois que vous avez besoin de traiter des contacts d'une manière ou d'une autre. Mais alors le principe de la responsabilité exclusive sera violé, la hiérarchie des Contact sera encombrée et la logique de traitement sera floue entre les implémentations des Contact et les lieux responsables, en fait, du traitement des contacts. Il n'y avait pas un tel problème en F #, j'aimerais que le code C # ne soit pas pire!


L'équivalent le plus proche de la correspondance de motifs en C # est la construction switch. Nous pourrions ajouter une propriété de type énuméré à Contact qui nous permettrait de déterminer le type réel de contact et d'effectuer la conversion. Il serait également possible d'utiliser les nouvelles fonctionnalités de C # et d'effectuer un basculement en tant qu'instance de Contact . Mais nous voulions que le compilateur se demande quand de nouveaux états de Contact corrects ont été ajoutés, où le traitement des nouveaux cas n'est pas suffisant et le commutateur ne garantit pas le traitement de tous les cas possibles.


Mais la POO dispose également d'un mécanisme plus pratique pour choisir la logique en fonction du type, et nous l'avons simplement utilisé lors de la mise à jour d'un contact. Et puisque maintenant le choix dépend du type d'appel, il doit également être polymorphe. La solution est le modèle Visitor. Il vous permet de sélectionner un gestionnaire en fonction de l'implémentation de Contact , dissocie les méthodes de traitement des contacts de leur hiérarchie, et si un nouveau type de contact est ajouté, et, en conséquence, une nouvelle méthode dans l'interface Visiteur, vous devrez l'écrire dans toutes les implémentations de l'interface. Toutes les exigences sont remplies!


 public abstract class Contact { public abstract void AcceptVisitor(IContactVisitor visitor); } public interface IContactVisitor { void Visit(PersonalName name, EmailContactInfo email); void Visit(PersonalName name, PostalContactInfo post); void Visit(PersonalName name, EmailContactInfo email, PostalContactInfo post); } public sealed class EmailOnlyContact : Contact { public override void AcceptVisitor(IContactVisitor visitor) { if (visitor == null) { throw new ArgumentNullException(nameof(visitor)); } visitor.Visit(Name, email_); } } public sealed class PostOnlyContact : Contact { public override void AcceptVisitor(IContactVisitor visitor) { if (visitor == null) { throw new ArgumentNullException(nameof(visitor)); } visitor.Visit(Name, post_); } } public sealed class EmailAndPostContact : Contact { public override void AcceptVisitor(IContactVisitor visitor) { if (visitor == null) { throw new ArgumentNullException(nameof(visitor)); } visitor.Visit(Name, email_, post_); } } 

Vous pouvez maintenant écrire du code pour afficher les contacts. Pour plus de simplicité, je vais utiliser l'interface de la console.


 public sealed class ContactUi { private sealed class Visitor : IContactVisitor { void IContactVisitor.Visit(PersonalName name, EmailContactInfo email) { Console.WriteLine(name); Console.WriteLine("* Email: {0}", email); } void IContactVisitor.Visit(PersonalName name, PostalContactInfo post) { Console.WriteLine(name); Console.WriteLine("* Postal address: {0}", post); } void IContactVisitor.Visit(PersonalName name, EmailContactInfo email, PostalContactInfo post) { Console.WriteLine(name); Console.WriteLine("* Email: {0}", email); Console.WriteLine("* Postal address: {0}", post); } } public void Display(Contact contact) => contact.AcceptVisitor(new Visitor()); } 

 var ui = new ContactUi(); ui.Display(newContact); 

Améliorations supplémentaires


Si Contact déclaré dans la bibliothèque et que l'apparition de nouveaux héritiers dans les clients de la bibliothèque n'est pas souhaitable, vous pouvez modifier la portée du constructeur Contact en internal , ou même rendre ses classes imbriquées héritières, déclarer la visibilité des implémentations et du constructeur private , et créer des instances à l'aide de méthodes d'usine statiques uniquement.


 public abstract class Contact { private sealed class EmailOnlyContact : Contact { public EmailOnlyContact(PersonalName name, EmailContactInfo email) : base(name) { } } private Contact(PersonalName name) { } public static Contact EmailOnly(PersonalName name, EmailContactInfo email) => new EmailOnlyContact(name, email); } 

Ainsi, il est possible de reproduire la non-extensibilité de la somme de types, bien que, en règle générale, cela ne soit pas requis.


Conclusion


J'espère que j'ai pu montrer comment limiter l'état correct de la logique métier à l'aide de types avec des outils OOP. Le code s'est avéré plus volumineux que sur F #. Quelque part, cela est dû à la lourdeur relative des décisions de la POO, quelque part à cause de la verbosité de la langue, mais les solutions ne peuvent pas être qualifiées d'impraticables.


Fait intéressant, à partir d'une solution purement fonctionnelle, nous sommes arrivés à la recommandation d'une programmation orientée sujet et de modèles de POO. En fait, cela n'est pas surprenant, car la similitude des sommes de type et du profil des visiteurs est connue depuis un certain temps . Le but de cet article était de montrer non pas tant une astuce concrète que de démontrer l'applicabilité des idées de la «tour d'ivoire» dans la programmation impérative. Bien sûr, tout ne peut pas être transféré aussi facilement, mais avec l'avènement de plus en plus de fonctionnalités dans les langages de programmation traditionnels, les limites de l'applicable s'élargiront.




→ Un exemple de code est disponible sur GitHub

Source: https://habr.com/ru/post/fr431186/


All Articles