Normalmente, artigos sobre design de tipos contêm exemplos em linguagens funcionais - Haskell, F # e outros. Esse conceito pode não parecer aplicável a linguagens orientadas a objetos, mas não é.
Neste artigo, traduzirei exemplos de um artigo de Scott Vlaschin Type Design: Como tornar estados inválidos inexprimíveis em C # idiomático . Também tentarei mostrar que essa abordagem é aplicável não apenas como um experimento, mas também no código de trabalho.
Criar tipos de domínio
Primeiro, você precisa portar os tipos do artigo anterior da série , que são usados nos exemplos em F #.
Quebrar tipos primitivos no domínio
Os exemplos de F # usam tipos de domínio em vez de primitivos para endereço de email, CEP dos EUA e código de estado. Vamos tentar agrupar um tipo primitivo em 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);
Mudei a validação de endereço da função de fábrica para o construtor, pois essa implementação é mais típica para C #. Também tivemos que implementar uma comparação e conversão em uma string, que no F # seria feita pelo compilador.
Por um lado, a implementação parece bastante volumosa. Por outro lado, a especificidade do endereço de email é expressa aqui apenas por verificações no construtor e, possivelmente, pela lógica de comparação. A maior parte disso é de código de infraestrutura, que, além disso, é improvável que mude. Portanto, você pode criar um modelo ou, na pior das hipóteses, copiar o código geral de uma classe para outra.
Note-se que a criação de tipos de domínio a partir de valores primitivos não é a especificidade da programação funcional. Pelo contrário, o uso de tipos primitivos é considerado um sinal de código incorreto no OOP . Você pode ver exemplos desses invólucros, por exemplo, no NLog e no NBitcoin , e o tipo padrão de TimeSpan é, de fato, um invólucro sobre o número de ticks.
Criando objetos de valor
Agora precisamos criar um análogo da 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")}"; }
Novamente, foi necessário mais código que F #, mas a maior parte do trabalho pode ser feita através da refatoração no IDE .
Assim como EmailAddress
, EmailContactInfo
é um objeto de valor (no sentido de DDD , não de tipos de valor no .NET ), conhecido e usado na modelagem de objetos.
Outros tipos - StateCode
, ZipCode
, PostalAddress
e PersonalName
portados para C # de maneira semelhante.
Criar contato
Portanto, o código deve expressar a regra "O contato deve conter um endereço de email ou endereço postal (ou ambos os endereços)". É necessário expressar esta regra para que a correção do estado seja visível na definição de tipo e verificada pelo compilador.
Expresse vários estados de contato
Isso significa que um contato é um objeto que contém o nome da pessoa e um endereço de e-mail, endereço postal ou ambos. Obviamente, uma classe não pode conter três conjuntos diferentes de propriedades; portanto, três classes diferentes devem ser definidas. Todas as três classes devem conter o nome do contato e, ao mesmo tempo, deve ser possível processar contatos de tipos diferentes da mesma maneira, sem saber quais endereços o contato contém. Portanto, o contato será representado por uma classe base abstrata contendo o nome do contato e três implementações com um 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; } }
Você pode argumentar que deve usar composição , não herança, e geralmente precisa herdar comportamento, não dados. As observações são justas, mas, na minha opinião, o uso da hierarquia de classes é justificado aqui. Primeiro, as subclasses não representam apenas casos especiais da classe base, mas a hierarquia inteira é um conceito - contato. Três implementações de contato refletem com muita precisão os três casos estipulados pela regra de negócios. Em segundo lugar, o relacionamento da classe base e seus herdeiros, a divisão de responsabilidades entre eles é facilmente rastreada. Em terceiro lugar, se a hierarquia realmente se tornar um problema, você poderá separar o estado do contato em uma hierarquia separada, como foi feito no exemplo original. No F #, a herança de registros é impossível, mas novos tipos são declarados de maneira bastante simples, portanto a divisão foi realizada imediatamente. Em C #, uma solução mais natural seria colocar os campos Nome na classe base.
Criar contato
Criar um contato é bastante simples.
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");
Se o endereço de email estiver incorreto, esse código emitirá uma exceção, que pode ser considerada um análogo do retorno None
no exemplo original.
Atualização de contato
A atualização de um contato também é simples - você só precisa adicionar um método abstrato ao 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);
Como em option.Value no F #, é possível lançar uma exceção dos construtores se o endereço de email, código postal ou estado estiver incorreto, mas para C # isso é prática comum. Obviamente, o código de exceção deve ser fornecido no código de trabalho aqui ou em algum lugar no código de chamada.
Tratamento de contatos fora da hierarquia
É lógico colocar a lógica para atualizar um contato na própria hierarquia de Contact
. Mas e se você quiser realizar algo que não se encaixa na área de responsabilidade dela? Suponha que você queira exibir contatos na interface do usuário.
Obviamente, você pode adicionar o método abstrato à classe base novamente e continuar adicionando um novo método sempre que precisar processar contatos de alguma forma. Porém, o princípio da responsabilidade exclusiva será violado, a hierarquia de Contact
ficará desordenada e a lógica de processamento ficará embaçada entre as implementações de Contact
e os locais responsáveis pelo processamento de contatos. Não havia esse problema no F #, eu gostaria que o código C # não fosse pior!
O equivalente mais próximo da correspondência de padrões em C # é a construção do switch. Poderíamos adicionar uma propriedade de tipo enumerada ao Contact
que nos permitiria determinar o tipo real de contato e realizar a conversão. Também seria possível usar os recursos mais recentes do C # e executar uma opção como uma instância do Contact
. Mas queríamos que o compilador se avisasse quando novos estados de Contact
corretos fossem adicionados, onde não há processamento suficiente de novos casos e a opção não garante o processamento de todos os casos possíveis.
Mas o OOP também possui um mecanismo mais conveniente para escolher a lógica, dependendo do tipo, e apenas o usamos ao atualizar um contato. E como agora a escolha depende do tipo de chamada, ela também deve ser polimórfica. A solução é o modelo de visitante. Ele permite que você selecione um manipulador dependendo da implementação do Contact
, desvincule os métodos de processamento de contato de sua hierarquia e, se um novo tipo de contato for adicionado e, consequentemente, um novo método na interface do visitante, será necessário gravá-lo em todas as implementações da interface. Todos os requisitos são atendidos!
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_); } }
Agora você pode escrever um código para exibir contatos. Para simplificar, usarei a interface do 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);
Melhorias adicionais
Se o Contact
declarado na biblioteca e a aparência de novos herdeiros nos clientes da biblioteca for indesejável, você poderá alterar o escopo do construtor Contact
para internal
, ou até tornar suas classes aninhadas descendentes, declarar a visibilidade das implementações e do construtor private
e criar instâncias usando apenas métodos estáticos de fábrica.
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); }
Assim, é possível reproduzir a não extensibilidade da soma do tipo, embora, como regra, isso não seja necessário.
Conclusão
Espero ter conseguido mostrar como limitar o estado correto da lógica de negócios usando tipos com ferramentas OOP. O código acabou sendo mais volumoso do que em F #. Em algum lugar, isso se deve à relativa dificuldade das decisões de POO, em algum lugar devido à verbosidade da linguagem, mas as soluções não podem ser chamadas de impraticáveis.
Curiosamente, começando com uma solução puramente funcional, criamos a recomendação de programação orientada a assuntos e padrões de POO. De fato, isso não é surpreendente, porque a semelhança entre somas de tipos e o padrão Visitor é conhecida há bastante tempo . O objetivo deste artigo era mostrar não um truque concreto, mas demonstrar a aplicabilidade das idéias da “torre de marfim” na programação imperativa. Obviamente, nem tudo pode ser transferido tão facilmente, mas com o advento de mais e mais funcionalidades nas principais linguagens de programação, os limites do aplicável se expandirão.
→ Código de exemplo está disponível no GitHub