No ano passado, dei um salto e aprendi uma linguagem de programação funcional (F #) e uma das coisas mais interessantes que descobri é como isso afeta a maneira como eu projeto o software OO. As duas coisas que mais sinto falta nos idiomas OO são os tipos de correspondência e soma de padrões. Em todo lugar que olho, vejo situações que seriam trivialmente modeladas com uma união discriminada, mas reluto em colaborar em alguma implementação de OO DU que não parece natural ao paradigma.
Isso geralmente me leva a criar tipos intermediários para lidar com os or
relacionamentos que um tipo de soma trataria para mim. Também parece levar a muitas ramificações. Se eu leio pessoas como Misko Hevery , ele sugere que um bom design de OO pode minimizar a ramificação através do polimorfismo.
Uma das coisas que evito o máximo possível no código OO são tipos com null
valores. Obviamente, o or
relacionamento pode ser modelado por um tipo com um null
valor e um sem null
valor, mas isso significa null
testes em todos os lugares. Existe uma maneira de modelar tipos heterogêneos, mas logicamente associados, polimorficamente? Estratégias ou padrões de design seriam muito úteis, ou simplesmente maneiras de pensar sobre tipos heterogêneos e associados geralmente no paradigma OO.
fonte
Respostas:
Como você, eu gostaria que os sindicatos discriminados fossem mais predominantes; no entanto, o motivo de serem úteis na maioria das linguagens funcionais é que elas fornecem correspondência exaustiva de padrões e, sem isso, são apenas uma sintaxe bastante: não apenas correspondência de padrões: correspondência exaustiva de padrões, para que o código não seja compilado se você não ' • cubra todas as possibilidades: é isso que lhe dá poder.
A única maneira de fazer algo útil com um tipo de soma é decompô-lo e ramificá-lo, dependendo de qual tipo é (por exemplo, pela correspondência de padrões). O melhor das interfaces é que você não se importa com o tipo de algo, porque sabe que pode tratá-lo como um
iface
: nenhuma lógica exclusiva é necessária para cada tipo: sem ramificação.Este não é um "código funcional tem mais ramificação, o código OO tem menos", este é um "'idiomas funcionais' são mais adequados para domínios onde você tem sindicatos - que exigem ramificação - e 'idiomas OO' são mais adequados para codificar onde você pode expor o comportamento comum como uma interface comum - que pode parecer menos ramificada ". A ramificação é uma função do seu design e do domínio. Simplesmente, se seus "tipos heterogêneos, mas logicamente associados" não podem expor uma interface comum, é necessário ramificar / corresponder padrões sobre eles. Este é um problema de domínio / design.
O que Misko pode estar se referindo é a ideia geral de que, se você pode expor seus tipos como uma interface comum, o uso de recursos OO (interfaces / polimorfismo) tornará sua vida melhor, colocando o comportamento específico do tipo no tipo e não no consumo. código.
É importante reconhecer que interfaces e uniões são exatamente o oposto um do outro: uma interface define algumas coisas que o tipo deve implementar e a união define algumas coisas que o consumidor deve considerar. Se você adicionar um método a uma interface, alterou esse contrato e agora todos os tipos que o implementaram anteriormente precisam ser atualizados. Se você adicionar um novo tipo a um sindicato, alterou esse contrato e agora todos os padrões exaustivos correspondentes à união deverão ser atualizados. Eles desempenham funções diferentes e, embora às vezes seja possível implementar um sistema 'de qualquer maneira', o que você escolhe é uma decisão de design: nenhum deles é inerentemente melhor.
Um benefício de acompanhar interfaces / polimorfismo é que o código de consumo é mais extensível: você pode transmitir um tipo que não foi definido no tempo de design, desde que exponha a interface acordada. Por outro lado, com uma união estática, você pode explorar comportamentos que não foram considerados no momento do design escrevendo novas correspondências exaustivas de padrões, desde que cumpram o contrato da união.
Em relação ao 'Padrão de Objeto Nulo': esta não é uma bala de prata e não substitui os
null
cheques. Tudo isso fornece uma maneira de evitar algumas verificações 'nulas' onde o comportamento 'nulo' pode ser exposto atrás de uma interface comum. Se você não pode expor o comportamento "nulo" por trás da interface do tipo, estará pensando "Eu realmente gostaria de poder combinar exaustivamente esse padrão" e acabará executando uma verificação de "ramificação".fonte
Existe uma maneira bastante "padrão" de codificar tipos de soma em uma linguagem orientada a objetos.
Aqui estão dois exemplos:
Em C #, poderíamos renderizar isso como:
F # novamente:
C # novamente:
A codificação é completamente mecânica. Essa codificação produz um resultado com as mesmas vantagens e desvantagens dos tipos de dados algébricos. Você também pode reconhecer isso como uma variação do padrão de visitantes. Poderíamos coletar os parâmetros
Match
juntos em uma interface que poderíamos chamar de Visitante.Do lado das vantagens, isso fornece uma codificação baseada em princípios de tipos de soma. (É a codificação Scott .) Fornece uma "correspondência de padrões" exaustiva, embora apenas uma "camada" de correspondência de cada vez.
Match
é, de certa forma, uma interface "completa" para esses tipos e quaisquer operações adicionais que desejamos podem ser definidas em termos disso. Apresenta uma perspectiva diferente sobre muitos padrões de OO, como o Padrão de Objeto Nulo e o Padrão de Estado, como indiquei na resposta de Ryathal, bem como o Padrão de Visitante e o Padrão de Composição. O tipoOption
/Maybe
é como um padrão de objeto nulo genérico. O padrão composto é semelhante à codificaçãotype Tree<'a> = Leaf of 'a | Children of List<Tree<'a>>
. O padrão de estado é basicamente uma codificação de uma enumeração.Do lado das desvantagens, como escrevi, o
Match
método impõe algumas restrições sobre quais subclasses podem ser adicionadas de maneira significativa, especialmente se queremos manter a propriedade de substituibilidade de Liskov. Por exemplo, aplicar essa codificação a um tipo de enumeração não permitiria estender significativamente a enumeração. Se você quisesse estender a enumeração, teria que alterar todos os chamadores e implementadores em todos os lugares, como se estivesse usandoenum
eswitch
. Dito isto, essa codificação é um pouco mais flexível que a original. Por exemplo, podemos adicionar umAppend
implementadorList
que apenas contém duas listas, fornecendo um anexo de tempo constante. Isso se comportaria como as listas anexadas, mas seria representado de uma maneira diferente.Obviamente, muitos desses problemas têm a ver com o fato de que
Match
está um pouco (conceitual mas intencionalmente) vinculado às subclasses. Se usarmos métodos não tão específicos, obteremos projetos de OO mais tradicionais e recuperaremos a extensibilidade, mas perderemos a "integridade" da interface e, portanto, perderemos a capacidade de definir qualquer operação nesse tipo em termos de interface. Como mencionado em outro lugar, essa é uma manifestação do Problema da Expressão .Pode-se argumentar que projetos como o acima podem ser usados sistematicamente para eliminar completamente a necessidade de ramificação, sempre atingindo um ideal de OO. Smalltalk, por exemplo, usa esse padrão frequentemente incluindo os próprios booleanos. Mas, como sugere a discussão anterior, essa "eliminação da ramificação" é bastante ilusória. Acabamos de implementar a ramificação de uma maneira diferente e ela ainda possui muitas das mesmas propriedades.
fonte
A manipulação de nulo pode ser feita com o padrão de objeto nulo . A idéia é criar uma instância de seus objetos que retorne valores padrão para cada membro e possua métodos que não fazem nada, mas também não apresentam erros. Isso não elimina completamente as verificações nulas, mas significa que você só precisa verificar nulas na criação do objeto e retornar seu objeto nulo.
O padrão de estado é uma maneira de minimizar a ramificação e fornecer alguns dos benefícios da correspondência de padrões. Novamente, empurra a lógica de ramificação para a criação de objetos. Cada estado é uma implementação separada de uma interface base; portanto, todo código de consumo precisa chamar DoStuff () e o método apropriado é chamado. Alguns idiomas também estão adicionando correspondência de padrões como um recurso, C # é um exemplo.
fonte