Bem, parece que seu domínio semântico tem um relacionamento IS-A, mas você é um pouco cauteloso ao usar subtipos / herança para modelar isso - principalmente por causa da reflexão do tipo de tempo de execução. Porém, acho que você tem medo da coisa errada - a subtipagem realmente traz perigos, mas o fato de você estar consultando um objeto em tempo de execução não é o problema. Você verá o que eu quero dizer.
A programação orientada a objetos se apoiou bastante na noção de relacionamentos IS-A, sem dúvida se apoiou demais nela, levando a dois conceitos críticos famosos:
Mas acho que há outra maneira, mais baseada em programação funcional, de analisar os relacionamentos IS-A que talvez não tenham essas dificuldades. Primeiro, queremos modelar cavalos e unicórnios em nosso programa, portanto, teremos um Horse
e um Unicorn
tipo. Quais são os valores desses tipos? Bem, eu diria o seguinte:
- Os valores desses tipos são representações ou descrições de cavalos e unicórnios (respectivamente);
- São representações ou descrições esquematizadas - não são de forma livre, são construídas de acordo com regras muito estritas.
Isso pode parecer óbvio, mas acho que uma das maneiras pelas quais as pessoas se envolvem em questões como o problema da elipse circular é não se importar com esses pontos com cuidado suficiente. Todo círculo é uma elipse, mas isso não significa que toda descrição esquematizada de um círculo é automaticamente uma descrição esquematizada de uma elipse de acordo com um esquema diferente. Em outras palavras, apenas porque um círculo é uma elipse não significa que a Circle
é um Ellipse
, por assim dizer. Mas isso significa que:
- Existe uma função total que converte qualquer
Circle
(descrição do círculo esquematizado) em um Ellipse
(tipo diferente de descrição) que descreve os mesmos círculos;
- Existe uma função parcial que pega um
Ellipse
e, se descreve um círculo, retorna o correspondente Circle
.
Portanto, em termos de programação funcional, seu Unicorn
tipo não precisa ser um subtipo Horse
, você só precisa de operações como estas:
-- Convert any unicorn-description of into a horse-description that
-- describes the same unicorns.
toHorse :: Unicorn -> Horse
-- If the horse described by the given horse-description is a unicorn,
-- then return a unicorn-description of that unicorn, otherwise return
-- nothing.
toUnicorn :: Horse -> Maybe Unicorn
E toUnicorn
precisa ser o inverso correto de toHorse
:
toUnicorn (toHorse x) = Just x
O Maybe
tipo de Haskell é o que outros idiomas chamam de "opção". Por exemplo, o Optional<Unicorn>
tipo Java 8 é um Unicorn
ou nada. Observe que duas de suas alternativas - lançando uma exceção ou retornando um "valor padrão ou mágico" - são muito semelhantes aos tipos de opção.
Então, basicamente, o que eu fiz aqui é reconstruir o conceito de relação IS-A em termos de tipos e funções, sem usar subtipos ou herança. O que eu tiraria disso é:
- Seu modelo precisa ter um
Horse
tipo;
- O
Horse
tipo precisa codificar informações suficientes para determinar sem ambiguidade se algum valor descreve um unicórnio;
- Algumas operações do
Horse
tipo precisam expor essas informações para que os clientes do tipo possam observar se um dado Horse
é um unicórnio;
- Os clientes do
Horse
tipo terão que usar essas últimas operações em tempo de execução para discriminar entre unicórnios e cavalos.
Portanto, este é fundamentalmente um modelo "pergunte a todos Horse
se é um unicórnio". Você é cauteloso com esse modelo, mas acho errado. Se eu lhe der uma lista de Horse
s, tudo o que o tipo garante é que as coisas que os itens da lista descrevem são cavalos - então você inevitavelmente precisará fazer algo em tempo de execução para dizer quais deles são unicórnios. Portanto, não há como contornar isso, eu acho - você precisa implementar operações que farão isso por você.
Na programação orientada a objetos, a maneira familiar de fazer isso é a seguinte:
- Tenha um
Horse
tipo;
- Ter
Unicorn
como um subtipo de Horse
;
- Use a reflexão do tipo de tempo de execução como a operação acessível ao cliente que discerne se um dado
Horse
é um Unicorn
.
Isso tem uma grande fraqueza, quando você olha para isso do ângulo "coisa versus descrição" que apresentei acima:
- E se você tiver uma
Horse
instância que descreva um unicórnio, mas não uma Unicorn
instância?
Voltando ao início, acho que é a parte mais assustadora do uso de subtipagem e downcasts para modelar esse relacionamento IS-A - não o fato de que você precisa fazer uma verificação de tempo de execução. Abusar um pouco da tipografia, perguntar Horse
se é uma Unicorn
instância não é sinônimo de perguntar Horse
se é um unicórnio (se é uma Horse
descrição de um cavalo que também é um unicórnio). A menos que seu programa tenha se esforçado bastante para encapsular o código que constrói, de Horses
modo que toda vez que um cliente tenta construir um Horse
que descreva um unicórnio, a Unicorn
classe é instanciada. Na minha experiência, raramente os programadores fazem isso com cuidado.
Então, eu iria com a abordagem em que há uma operação explícita e não downcast que converte Horse
s em Unicorn
s. Este poderia ser um método do Horse
tipo:
interface Horse {
// ...
Optional<Unicorn> toUnicorn();
}
... ou pode ser um objeto externo (seu "objeto separado em um cavalo que informa se o cavalo é um unicórnio ou não"):
class HorseToUnicornCoercion {
Optional<Unicorn> convert(Horse horse) {
// ...
}
}
A escolha entre eles é uma questão de como o seu programa está organizado - nos dois casos, você tem o equivalente da minha Horse -> Maybe Unicorn
operação acima, apenas o empacotando de maneiras diferentes (que reconhecidamente terão efeitos negativos em quais operações o Horse
tipo precisa expor a seus clientes).