Tipos de soma vs polimorfismo

10

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 orrelacionamentos 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 nullvalores. Obviamente, o orrelacionamento pode ser modelado por um tipo com um nullvalor e um sem nullvalor, mas isso significa nulltestes 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.

Patrick D
fonte
3
"um bom projeto de OO pode minimizar a ramificação através do polimorfismo" : move a ramificação da lógica de negócios real para o código de inicialização / configuração. O benefício geralmente é que a "inicialização e configuração" acontece muito menos (como nas ocorrências no código, não em termos de "execução") do que seria necessária uma ramificação explícita na lógica de negócios. A desvantagem é que não há espaço para ou dependendo do alvo Type objetos dentro da lógica de negócios ...
Timothy Truckle
3
Isso pode ser do seu interesse (basicamente, o autor modela um tipo de soma como uma hierarquia, com vários métodos substituídos nas subclasses como uma maneira de modelar a correspondência de padrões); Além disso, no OO, as verificações nulas podem ser evitadas usando o Null Object Pattern (apenas um objeto que não faz nada para uma dada operação polimórfica).
Filip Milovanović
O padrão composto pode valer uma leitura.
11288 Candied_orange #
1
Você pode dar um exemplo do tipo de coisa que deseja melhorar?
21418 JimmyJames
@ TimothyTruckle Boa explicação, mas nem sempre é "inicialização / configuração". A ramificação ocorre quando você invoca o método, invisivelmente, mas uma linguagem dinâmica pode permitir que você adicione classes dinamicamente; nesse caso, a ramificação também está mudando dinamicamente.
precisa

Respostas:

15

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 nullcheques. 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".

VisualMelon
fonte
4
relacionado ao penúltimo parágrafo: en.wikipedia.org/wiki/Expression_problem
jk.
"uma interface define algumas coisas que o tipo precisa implementar e a união define algumas coisas que o consumidor deve considerar" - você não precisa olhar as interfaces dessa maneira. Um componente pode definir uma interface necessária - o que algum outro componente precisa implementar; e uma interface fornecida - que um componente consumidor deve considerar (ou seja, ser programado).
Filip Milovanović
@ FilipMilovanović sim, eu não fui muito preciso lá. Eu estava tentando evitar entrar no 'triângulo' de dependências com interfaces (consumidor -> interface <- implementador / tipo), em vez das dependências 'lineares' com uma união (consumidor -> união -> tipos), porque eu sou realmente apenas tentando expressar onde a tomada de 'decisão' está acontecendo (por exemplo, onde é que vamos definir o que fazer se somos apresentados a este tipo)
VisualMelon
3

Existe uma maneira bastante "padrão" de codificar tipos de soma em uma linguagem orientada a objetos.

Aqui estão dois exemplos:

type Either<'a, 'b> = Left of 'a | Right of 'b

Em C #, poderíamos renderizar isso como:

interface Either<A, B> {
    C Match<C>(Func<A, C> left, Func<B, C> right);
}

class Left<A, B> : Either<A, B> {
    private readonly A a;
    public Left(A a) { this.a = a; }
    public C Match<C>(Func<A, C> left, Func<B, C> right) {
        return left(a);
    }
}

class Right<A, B> : Either<A, B> {
    private readonly B b;
    public Right(B b) { this.b = b; }
    public C Match<C>(Func<A, C> left, Func<B, C> right) {
        return right(b);
    }
}

F # novamente:

type List<'a> = Nil | Cons of 'a * List<'a>

C # novamente:

interface List<A> {
    B Match<B>(B nil, Func<A, List<A>, B> cons);
}

class Nil<A> : List<A> {
    public Nil() {}
    public B Match<B>(B nil, Func<A, List<A>, B> cons) {
        return nil;
    }
}

class Cons<A> : List<A> {
    private readonly A head;
    private readonly List<A> tail;
    public Cons(A head, List<A> tail) {
        this.head = head;
        this.tail = tail;
    }
    public B Match<B>(B nil, Func<A, List<A>, B> cons) {
        return cons(head, tail);
    }
}

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 Matchjuntos 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 tipo Option/ Maybeé como um padrão de objeto nulo genérico. O padrão composto é semelhante à codificação type 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 Matchmé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 usando enume switch. Dito isto, essa codificação é um pouco mais flexível que a original. Por exemplo, podemos adicionar um Appendimplementador Listque 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 Matchestá 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.

Derek Elkins deixou o SE
fonte
1

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.

Ryathal
fonte
(Un?) Ironicamente, esses são dois exemplos da maneira "padrão" de codificar tipos de união discriminados no OOP.
Derek Elkins saiu de SE