Entwurf nach Typen: So machen Sie ungültige Zustände in C # unaussprechlich

In der Regel enthalten Artikel zum Schriftdesign Beispiele in funktionalen Sprachen - Haskell, F # und andere. Dieses Konzept scheint möglicherweise nicht auf objektorientierte Sprachen anwendbar zu sein, ist es aber nicht.


In diesem Artikel werde ich Beispiele aus einem Artikel von Scott Vlaschin Type Design übersetzen: Wie man ungültige Zustände in idiomatischem C # unaussprechlich macht . Ich werde auch versuchen zu zeigen, dass dieser Ansatz nicht nur als Experiment, sondern auch im Arbeitscode anwendbar ist.


Erstellen Sie Domänentypen


Zuerst müssen Sie die Typen aus dem vorherigen Artikel der Serie portieren, die in den Beispielen in F # verwendet werden.


Primitive Typen in Domäne einschließen


In den F # -Beispielen werden Domänentypen anstelle von Grundelementen für E-Mail-Adresse, US-Postleitzahl und Statuscode verwendet. Versuchen wir, einen primitiven Typ in C # zu verpacken:


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); 

Ich habe die Adressvalidierung von der Factory-Funktion in den Konstruktor verschoben, da eine solche Implementierung eher für C # typisch ist. Wir mussten auch einen Vergleich und eine Konvertierung in einen String implementieren, was auf F # vom Compiler durchgeführt werden würde.


Einerseits sieht die Implementierung recht umfangreich aus. Andererseits wird die Spezifität der E-Mail-Adresse hier nur durch Überprüfungen im Konstruktor und möglicherweise durch die Vergleichslogik ausgedrückt. Das meiste davon ist Infrastrukturcode, der sich darüber hinaus wahrscheinlich nicht ändern wird. Sie können also entweder eine Vorlage erstellen oder im schlimmsten Fall den allgemeinen Code von Klasse zu Klasse kopieren.


Es ist zu beachten, dass die Erstellung von Domänentypen aus primitiven Werten nicht die Spezifität der funktionalen Programmierung ist. Im Gegenteil, die Verwendung primitiver Typen wird in OOP als Zeichen für schlechten Code angesehen . Sie können Beispiele für solche Wrapper beispielsweise in NLog und NBitcoin sehen , und der Standardtyp von TimeSpan ist in der Tat ein Wrapper über die Anzahl der Ticks.


Wertobjekte erstellen


Jetzt müssen wir ein Analogon des Eintrags erstellen:


 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")}"; } 

Auch hier war mehr Code als F # erforderlich, aber der größte Teil der Arbeit kann durch Refactoring in der IDE erledigt werden.


EmailContactInfo ist wie EmailAddress ein Wertobjekt (im Sinne von DDD , keine EmailContactInfo in .NET ), das seit langem bekannt ist und in der Objektmodellierung verwendet wird.


Andere Typen - StateCode , ZipCode , PostalAddress und PersonalName auf ähnliche Weise nach C # portiert.


Kontakt erstellen


Der Code sollte daher die Regel "Der Kontakt muss eine E-Mail-Adresse oder eine Postanschrift (oder beide Adressen) enthalten" ausdrücken. Diese Regel muss ausgedrückt werden, damit die Richtigkeit des Status in der Typdefinition sichtbar ist und vom Compiler überprüft wird.


Drücken Sie verschiedene Kontaktzustände aus


Dies bedeutet, dass ein Kontakt ein Objekt ist, das den Namen der Person und entweder eine E-Mail-Adresse oder eine Postanschrift oder beides enthält. Offensichtlich kann eine Klasse nicht drei verschiedene Sätze von Eigenschaften enthalten, daher müssen drei verschiedene Klassen definiert werden. Alle drei Klassen müssen den Namen des Kontakts enthalten und gleichzeitig sollte es möglich sein, Kontakte unterschiedlichen Typs auf dieselbe Weise zu verarbeiten, ohne zu wissen, welche Adressen der Kontakt enthält. Daher wird der Kontakt durch eine abstrakte Basisklasse mit dem Namen des Kontakts und drei Implementierungen mit unterschiedlichen Feldern dargestellt.


 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; } } 

Sie können argumentieren, dass Sie Komposition und nicht Vererbung verwenden müssen, und im Allgemeinen müssen Sie Verhalten und nicht Daten erben. Die Bemerkungen sind fair, aber meiner Meinung nach ist die Verwendung der Klassenhierarchie hier gerechtfertigt. Erstens stellen Unterklassen nicht nur Sonderfälle der Basisklasse dar, die gesamte Hierarchie ist ein Konzept - Kontakt. Drei Kontaktimplementierungen spiegeln sehr genau die drei Fälle wider, die in der Geschäftsregel festgelegt sind. Zweitens ist das Verhältnis der Basisklasse und ihrer Erben, die Aufteilung der Verantwortlichkeiten zwischen ihnen leicht nachzuvollziehen. Drittens, wenn die Hierarchie wirklich zu einem Problem wird, können Sie den Kontaktstatus in eine separate Hierarchie aufteilen, wie im ursprünglichen Beispiel. In F # ist die Vererbung von Datensätzen nicht möglich, aber neue Typen werden ganz einfach deklariert, sodass die Aufteilung sofort durchgeführt wurde. In C # wäre eine natürlichere Lösung, die Namensfelder in der Basisklasse zu platzieren.


Kontakt erstellen


Das Erstellen eines Kontakts ist ganz einfach.


 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"); 

Wenn die E-Mail-Adresse falsch ist, löst dieser Code eine Ausnahme aus, die im ursprünglichen Beispiel als Analogie zur Rückgabe None .


Kontakt Update


Das Aktualisieren eines Kontakts ist ebenfalls unkompliziert. Sie müssen dem Contact lediglich eine abstrakte Methode hinzufügen.


 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); 

Wie bei option.Value in F # ist das Auslösen einer Ausnahme von den Konstruktoren möglich, wenn die E-Mail-Adresse, die Postleitzahl oder der Status falsch sind. Für C # ist dies jedoch gängige Praxis. Natürlich muss der Ausnahmecode hier oder irgendwo im aufrufenden Code im Arbeitscode angegeben werden.


Umgang mit Kontakten außerhalb der Hierarchie


Es ist logisch, die Logik zum Aktualisieren eines Kontakts in der Kontakthierarchie selbst zu platzieren. Aber was ist, wenn Sie etwas erreichen wollen, das nicht in ihren Verantwortungsbereich passt? Angenommen, Sie möchten Kontakte auf der Benutzeroberfläche anzeigen.


Sie können die abstrakte Methode natürlich erneut zur Basisklasse hinzufügen und jedes Mal eine neue Methode hinzufügen, wenn Sie Kontakte irgendwie verarbeiten müssen. Dann wird jedoch das Prinzip der alleinigen Verantwortung verletzt, die Contact wird unübersichtlich und die Verarbeitungslogik wird zwischen den Contact und den Stellen, die tatsächlich für die Verarbeitung von Kontakten verantwortlich sind, verwischt. Es gab kein solches Problem in F #, ich möchte, dass der C # -Code nicht schlechter wird!


Das dem Mustervergleich in C # am nächsten kommende Äquivalent ist das Schalterkonstrukt. Wir könnten Contact eine Aufzählungseigenschaft hinzufügen, mit der wir den tatsächlichen Kontakttyp bestimmen und die Konvertierung durchführen können. Es wäre auch möglich, die neueren Funktionen von C # zu verwenden und einen Wechsel als Instanz von Contact durchzuführen. Wir wollten jedoch, dass der Compiler sich selbst auffordert, wenn neue korrekte Contact hinzugefügt werden, bei denen nicht genügend neue Fälle verarbeitet werden und switch nicht die Verarbeitung aller möglichen Fälle garantiert.


OOP bietet jedoch auch einen bequemeren Mechanismus für die Auswahl der Logik je nach Typ, den wir nur beim Aktualisieren eines Kontakts verwendet haben. Und da die Auswahl jetzt vom aufrufenden Typ abhängt, muss sie auch polymorph sein. Die Lösung ist die Besuchervorlage. Sie können einen Handler abhängig von der Contact auswählen, die Kontaktverarbeitungsmethoden von ihrer Hierarchie trennen und wenn ein neuer Kontakttyp hinzugefügt wird, und dementsprechend eine neue Methode in der Besucherschnittstelle, müssen Sie diese in allen Implementierungen der Schnittstelle schreiben. Alle Anforderungen sind erfüllt!


 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_); } } 

Jetzt können Sie Code schreiben, um Kontakte anzuzeigen. Der Einfachheit halber werde ich die Konsolenschnittstelle verwenden.


 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); 

Weitere Verbesserungen


Wenn Contact in der Bibliothek deklariert Contact und das Auftreten neuer Erben in den Bibliotheksclients unerwünscht ist, können Sie den Bereich des Contact Konstruktors in internal ändern oder sogar seine verschachtelten Klassen für Nachkommen festlegen, die Sichtbarkeit der Implementierungen und des Konstruktors als private deklarieren und Instanzen nur mit statischen Factory-Methoden erstellen.


 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); } 

Somit ist es möglich, die Nichterweiterbarkeit der Typensumme zu reproduzieren, obwohl dies in der Regel nicht erforderlich ist.


Fazit


Ich hoffe, ich konnte zeigen, wie der korrekte Status der Geschäftslogik mithilfe von Typen mit OOP-Tools eingeschränkt werden kann. Der Code erwies sich als umfangreicher als bei F #. Irgendwo ist dies auf die relative Umständlichkeit von OOP-Entscheidungen zurückzuführen, irgendwo auf die Ausführlichkeit der Sprache, aber Lösungen können nicht als unpraktisch bezeichnet werden.


Interessanterweise haben wir ausgehend von einer rein funktionalen Lösung die Empfehlung einer themenorientierten Programmierung und von OOP-Mustern ausgearbeitet. In der Tat ist dies nicht überraschend, da die Ähnlichkeit von Typensummen und dem Besuchermuster seit geraumer Zeit bekannt ist . Der Zweck dieses Artikels war es, nicht so sehr einen konkreten Trick aufzuzeigen, als vielmehr die Anwendbarkeit von Ideen aus dem „Elfenbeinturm“ in der imperativen Programmierung zu demonstrieren. Natürlich kann nicht alles so einfach übertragen werden, aber mit dem Aufkommen von immer mehr Funktionen in den gängigen Programmiersprachen werden sich die Grenzen des Anwendbaren erweitern.




→ Beispielcode ist auf GitHub verfügbar

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


All Articles