Consertando herança?

Primeiro, houve uma longa introdução sobre como tive uma ideia brilhante (piada, esses são mixins em TS / JS e Policy em C ++ ), à qual o artigo é dedicado. Não vou perder seu tempo, aqui está o herói da comemoração de hoje (cuidado, 5 linhas em JS):



function Extends(clazz) {
    return class extends clazz {
        // ...
    }
}


Deixe-me explicar como funciona. Em vez de herança regular, usamos o mecanismo acima. Em seguida, especificamos a classe base apenas ao criar um objeto:



const Class = Extends(Base)
const object = new Class(...args)


Vou tentar convencê-lo de que este é o filho da amiga da minha mãe para herança de classe e uma maneira de devolver a herança ao título de verdadeira ferramenta OOP (logo após a herança prototípica, é claro).



Quase não offtopic
, , , pet project , pet project'. , .





Vamos combinar os nomes: vou chamar essa técnica de mixin, embora isso ainda signifique um pouco diferente . Antes de me dizerem que esses são mixins do TS / JS, usei o nome LBC (classes de ligação tardia).







Os "problemas" da herança de classes



Todos nós sabemos como "todos" "não gostam" da herança de classes. Quais são seus problemas? Vamos descobrir e ao mesmo tempo entender como os mixins os resolvem.



A herança de implementação quebra o encapsulamento



A principal tarefa do OOP é vincular dados e operações nele (encapsulamento). Quando uma classe herda de outra, essa relação é quebrada: os dados estão em um lugar (pai), as operações em outro (herdeiro). Além disso, o herdeiro pode sobrecarregar a interface pública da classe, de modo que nem o código da classe base, nem o código da classe herdada separadamente podem dizer o que acontecerá com o estado do objeto. Ou seja, as classes são acopladas.



Os mixins, por sua vez, reduzem bastante o acoplamento: do comportamento de qual classe base o herdeiro deve depender, se simplesmente não houver classe base no momento de declarar a classe herdada? No entanto, graças a este limite tardio e à sobrecarga do método, o "problema do Yo-Yo"permanece. Se você usar herança em seu projeto, a partir dele não pode escapar, mas, por exemplo, em palavras open- chave Kotlin e overridedeve facilitar muito a situação (eu não sei, não estou muito familiarizado com Kotlin).



Herdando métodos desnecessários



Um exemplo clássico com uma lista e uma pilha: se você herdar a pilha de uma lista, os métodos da interface da lista entrarão na interface da pilha, o que pode violar a invariante da pilha. Eu não diria que este é um problema de herança, porque, por exemplo, em C ++ há herança privada para isso (e métodos individuais podem ser tornados públicos usando using), então este é um problema de linguagens individuais.



Falta de flexibilidade



  1. , : . , , : , , cohesion . , .
  2. ( ), . , : , .
  3. . - , . , , ? - — , .. .
  4. . - , - . , , .




Se uma classe herda da implementação de outra classe, alterar essa implementação pode interromper a classe herdada. Em este artigo não é uma boa ilustração dos problemas Stacke MonitorableStack.



Com mixins, o programador deve levar em consideração que a classe herdada que ele escreve deve trabalhar não apenas com alguma classe base específica, mas também com outras classes que correspondem à interface da classe base.



Banana, gorila e selva



OOP promete composibilidade, ou seja, a capacidade de reutilizar classes individuais em diferentes situações e até mesmo em diferentes projetos. No entanto, se uma classe herda de outra classe, a fim de reutilizar o herdeiro, você precisa copiar todas as dependências, a classe base e todas as suas dependências e sua classe base…. Essa. queria uma banana e tirou um gorila, e depois a selva. Se o objeto foi criado com o Princípio de Inversão de Dependências em mente, as dependências não são tão ruins - apenas copie suas interfaces. No entanto, isso não pode ser feito com a cadeia de herança.



Os mixins, por sua vez, tornam possível (e obrigatório) o uso de DIPs em relação à herança.



Outras amenidades dos Mixins



As vantagens dos mixins não param por aí. Vamos ver o que mais você pode fazer com eles.



Morte da hierarquia de herança



As classes não dependem mais umas das outras: dependem apenas de interfaces. Essa. a implementação torna-se as folhas do gráfico de dependência. Isso deve tornar a refatoração mais fácil - o modelo de domínio agora é independente de sua implementação.



Morte de classes abstratas



Classes abstratas não são mais necessárias. Vejamos um exemplo do padrão Factory Method em Java emprestado do guru da refatoração :



interface Button {
    void render();
    void onClick();
}

abstract class Dialog {
    void renderWindow() {
        Button okButton = createButton();
        okButton.render();
    }

    abstract Button createButton();
}


Sim, claro, os métodos de fábrica evoluem para padrões de construtor e estratégia. Mas você pode fazer isso com mixins (vamos imaginar por um segundo que Java tenha mixins de primeira classe):



interface Button {
    void render();
    void onClick();
}

interface ButtonFactory {
    Button createButton();
}

class Dialog extends ButtonFactory {
    void renderWindow() {
        Button okButton = createButton();
        okButton.render();
    }
}


Você pode fazer esse truque com quase qualquer classe abstrata. Um exemplo em que não funciona:



abstract class Abstract {
    void method() {
        abstractMethod();
    }

    abstract void abstractMethod();
}

class Concrete extends Abstract {
    private encapsulated = new Encapsulated();

    @Override
    void method() {
        encapsulated.method();
        super.method();
    }

    void abstractMethod() {
        encapsulated.otherMethod();
    }
}


Aqui, o campo é encapsulatednecessário tanto na sobrecarga methodquanto na implementação abstractMethod. Ou seja, sem quebrar o encapsulamento, a classe Concretenão pode ser dividida em filho Abstracte "superclasse" Abstract. Mas não tenho certeza se este é um exemplo de bom design.



Flexibilidade comparável aos tipos



O leitor atento notará que todos são muito semelhantes aos traços de Smalltalk / Rust. Existem duas diferenças:



  1. As instâncias do Mixin podem conter dados que não estavam na classe base;
  2. Mixins não modificam a classe da qual herdam: para usar a funcionalidade do mixin, você precisa criar explicitamente o objeto mixin, não a classe base.


A segunda diferença leva ao fato de que, digamos, mixins agem localmente, em contraste com traits que agem em todas as instâncias da classe base. O quão conveniente é depende do programador e do projeto, não vou dizer que minha solução seja definitivamente melhor.



Essas diferenças aproximam os mixins da herança normal, então essa coisa me parece uma troca engraçada entre herança e características.



Contras de mixins



Oh, se fosse tão simples. Os mixins definitivamente têm um pequeno problema e uma falta de gordura.



Interfaces explodindo



Se você pode herdar apenas da interface, obviamente, haverá mais interfaces no projeto. Claro, se o DIP for observado no projeto, mais algumas interfaces não farão o clima, mas nem todas seguem SÓLIDOS. Este problema pode ser resolvido se, a partir de cada classe, for gerada uma interface contendo todos os métodos públicos e, ao citar o nome da classe, distinguir se a classe se destina a ser uma fábrica de objetos ou uma interface. Algo semelhante é feito no TypeScript, mas por algum motivo os campos e métodos privados são mencionados na interface gerada.



Construtores complexos



Se você usa mixins, a tarefa mais difícil é criar um objeto. Considere duas opções, dependendo se o construtor está incluído na interface da classe base:



  1. , , . , - . , .
  2. , . :



    interface Base {
        new(values: Array<int>)
    }
    
    class Subclass extends Base {
        // ...
    }
    
    class DoesntFit {
        new(values: Array<int>, mode: Mode) {
            // ...
        }
    }
    


    DoesntFit Subclass, - . Subclass DoesntFit, Base .
  3. Na verdade, existe outra opção - passar para o construtor não uma lista de argumentos, mas um dicionário. Isso resolve o problema acima, porque { values: Array<int>, mode: Mode }obviamente se encaixa no padrão { values: Array<int> }, mas leva a uma colisão imprevisível de nomes em tal dicionário: por exemplo, tanto a superclasse Aquanto o herdeiro Busam os mesmos parâmetros, mas esse nome não é especificado na interface da classe base para B.


Em vez de uma conclusão



Tenho certeza de que perdi alguns aspectos dessa ideia. Ou o fato de que este já é um acordeão de botão selvagem e há vinte anos havia uma linguagem que usava essa ideia. Em qualquer caso, estou te esperando nos comentários!



Lista de fontes



neethack.com/2017/04/Why-inheritance-is-bad

www.infoworld.com/article/2073649/why-extends-is-evil.html

www.yegor256.com/2016/09/13/inheritance-is- procedural.html

refactoring.guru/ru/design-patterns/factory-method/java/example

scg.unibe.ch/archive/papers/Scha03aTraits.pdf



All Articles