Por lo general, los artículos sobre diseño de tipos contienen ejemplos en lenguajes funcionales: Haskell, F # y otros. Puede parecer que este concepto no se aplica a los lenguajes orientados a objetos, pero no lo es.
En este artículo, traduciré ejemplos de un artículo de Scott Vlaschin Type Design: Cómo hacer que los estados inválidos sean inexpresables en idiomática C #. También intentaré mostrar que este enfoque es aplicable no solo como un experimento, sino también en el código de trabajo.
Crear tipos de dominio
Primero debe portar los tipos del artículo anterior de la serie , que se utilizan en los ejemplos en F #.
Ajustar tipos primitivos en el dominio
Los ejemplos de F # usan tipos de dominio en lugar de primitivas para la dirección de correo electrónico, el código postal de EE. UU. Y el código de estado. Intentemos envolver un tipo primitivo 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);
Moví la validación de la dirección de la función de fábrica al constructor, ya que dicha implementación es más típica para C #. También tuvimos que implementar una comparación y conversión a una cadena, que en F # sería realizada por el compilador.
Por un lado, la implementación parece bastante voluminosa. Por otro lado, la especificidad de la dirección de correo electrónico se expresa aquí solo mediante verificaciones en el constructor y, posiblemente, por la lógica de comparación. La mayor parte de esto es código de infraestructura, que, además, es poco probable que cambie. Por lo tanto, puede hacer una plantilla o, en el peor de los casos, copiar el código general de una clase a otra.
Cabe señalar que la creación de tipos de dominio a partir de valores primitivos no es la especificidad de la programación funcional. Por el contrario, el uso de tipos primitivos se considera un signo de código incorrecto en OOP . Puede ver ejemplos de dichos contenedores, por ejemplo, en NLog y NBitcoin , y el tipo estándar de TimeSpan es, de hecho, un contenedor sobre el número de ticks.
Crear objetos de valor
Ahora necesitamos crear un análogo de la entrada :
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")}"; }
Nuevamente, tomó más código que F #, pero la mayor parte del trabajo se puede hacer refactorizando en el IDE .
Al igual que EmailAddress
, EmailContactInfo
es un objeto de valor (en el sentido de DDD , no tipos de valor en .NET ), conocido y utilizado en el modelado de objetos.
Otros tipos: StateCode
, ZipCode
, PostalAddress
y PersonalName
PostalAddress
a C # de manera similar.
Crear contacto
Por lo tanto, el código debe expresar la regla "El contacto debe contener una dirección de correo electrónico o una dirección postal (o ambas direcciones)". Es necesario expresar esta regla para que la corrección del estado sea visible desde la definición del tipo y sea verificada por el compilador.
Expresar varios estados de contacto
Esto significa que un contacto es un objeto que contiene el nombre de la persona y una dirección de correo electrónico, una dirección postal o ambas. Obviamente, una clase no puede contener tres conjuntos diferentes de propiedades; por lo tanto, se deben definir tres clases diferentes. Las tres clases deben contener el nombre del contacto y, al mismo tiempo, debería ser posible procesar contactos de diferentes tipos de la misma manera, sin saber qué direcciones contiene el contacto. Por lo tanto, el contacto estará representado por una clase base abstracta que contiene el nombre del contacto y tres implementaciones con un conjunto diferente de campos.
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; } }
Puede argumentar que debe usar composición , no herencia, y generalmente necesita heredar el comportamiento, no los datos. Los comentarios son justos, pero, en mi opinión, el uso de la jerarquía de clases se justifica aquí. Primero, las subclases no solo representan casos especiales de la clase base, sino que toda la jerarquía es un concepto: contacto. Las implementaciones de tres contactos reflejan con mucha precisión los tres casos estipulados por la regla de negocios. En segundo lugar, la relación de la clase base y sus herederos, la división de responsabilidades entre ellos se puede rastrear fácilmente. En tercer lugar, si la jerarquía realmente se convierte en un problema, puede separar el estado del contacto en una jerarquía separada, como se hizo en el ejemplo original. En F #, la herencia de registros es imposible, pero los nuevos tipos se declaran de manera bastante simple, por lo que la división se realizó de inmediato. En C #, una solución más natural sería colocar los campos de Nombre en la clase base.
Crear contacto
Crear un contacto es bastante 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 la dirección de correo electrónico es incorrecta, este código arrojará una excepción, que puede considerarse un análogo de la devolución None
en el ejemplo original.
Actualización de contacto
Actualizar un contacto también es sencillo: solo necesita agregar un método abstracto al tipo 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);
Al igual que con la opción. Valor en F #, es posible lanzar una excepción de los constructores si la dirección de correo electrónico, el código postal o el estado son incorrectos, pero para C # es una práctica común. Por supuesto, el código de excepción se debe proporcionar en el código de trabajo aquí o en algún lugar del código de llamada.
Manejo de contactos fuera de la jerarquía
Es lógico colocar la lógica para actualizar un contacto en la propia jerarquía de Contact
. Pero, ¿qué pasa si quieres lograr algo que no encaja en su área de responsabilidad? Suponga que desea mostrar contactos en la interfaz de usuario.
Por supuesto, puede agregar el método abstracto a la clase base nuevamente y continuar agregando un nuevo método cada vez que necesite procesar contactos de alguna manera. Pero luego se violará el principio de responsabilidad exclusiva , la jerarquía de Contact
estará desordenada y la lógica de procesamiento se verá borrosa entre las implementaciones de Contact
y los lugares responsables de procesar los contactos. No hubo tal problema en F #, ¡me gustaría que el código C # no fuera peor!
El equivalente más cercano a la coincidencia de patrones en C # es la construcción del interruptor. Podríamos agregar una propiedad de tipo enumerada a Contact
que nos permitiría determinar el tipo real de contacto y realizar la conversión. También sería posible usar las funciones más nuevas de C # y realizar un cambio como una instancia de Contact
. Pero queríamos que el compilador se avisara cuando se agregaron nuevos estados de Contact
correctos, donde no hay suficiente procesamiento de nuevos casos, y el interruptor no garantiza el procesamiento de todos los casos posibles.
Pero OOP también tiene un mecanismo más conveniente para elegir la lógica según el tipo, y simplemente lo usamos al actualizar un contacto. Y dado que ahora la elección depende del tipo de llamada, también debe ser polimórfica. La solución es la plantilla de visitante. Le permite seleccionar un controlador en función de la implementación del Contact
, desvincula los métodos de procesamiento de contactos de su jerarquía y, si se agrega un nuevo tipo de contacto, y, en consecuencia, un nuevo método en la interfaz de visitante, deberá escribirlo en todas las implementaciones de la interfaz. Se cumplen todos los requisitos!
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_); } }
Ahora puede escribir código para mostrar contactos. Por simplicidad, usaré la interfaz de la consola.
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);
Mejoras adicionales
Si Contact
declara en la biblioteca y la aparición de nuevos herederos en los clientes de la biblioteca no es deseable, puede cambiar el alcance del constructor Contact
a internal
, o incluso hacer que sus herederos sean clases anidadas, declarar la visibilidad de las implementaciones y el constructor private
, y crear instancias a través de métodos de fábrica estáticos.
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); }
Por lo tanto, es posible reproducir la no extensibilidad de la suma de tipos, aunque, por regla general, esto no es obligatorio.
Conclusión
Espero haber podido mostrar cómo limitar el estado correcto de la lógica empresarial utilizando tipos con herramientas OOP. El código resultó ser más voluminoso que en F #. En algún lugar, esto se debe a la relativa complejidad de las decisiones de OOP, en algún lugar debido a la verbosidad del lenguaje, pero las soluciones no se pueden llamar poco prácticas.
Curiosamente, comenzando con una solución puramente funcional, se nos ocurrió la recomendación de programación orientada a temas y patrones OOP. De hecho, esto no es sorprendente, ya que la similitud de las sumas de tipos y el patrón Visitor se conoce desde hace bastante tiempo . El propósito de este artículo era mostrar no tanto un truco concreto como demostrar la aplicabilidad de las ideas de la "torre de marfil" en la programación imperativa. Por supuesto, no todo se puede transferir tan fácilmente, pero con el advenimiento de más y más funcionalidades en los lenguajes de programación convencionales, los límites de lo aplicable se expandirán.
→ El código de muestra está disponible en GitHub