Abstração em excesso, dificultando a extensão do código

9

Estou enfrentando problemas com o que considero muita abstração na base de código (ou pelo menos lidando com isso). A maioria dos métodos na base de código foi abstraída para incluir o pai mais alto A na base de código, mas o filho B desse pai tem um novo atributo que afeta a lógica de alguns desses métodos. O problema é que esses atributos não podem ser verificados nesses métodos porque a entrada é abstraída para A e, é claro, A não possui esse atributo. Se eu tentar criar um novo método para manipular B de maneira diferente, ele será chamado para duplicação de código. A sugestão do meu líder técnico é criar um método compartilhado que aceite parâmetros booleanos, mas o problema é que algumas pessoas veem isso como "fluxo de controle oculto", onde o método compartilhado tem uma lógica que pode não ser aparente para futuros desenvolvedores , e também esse método compartilhado se tornará excessivamente complexo / complicado uma vez se atributos futuros precisarem ser adicionados, mesmo que sejam divididos em métodos compartilhados menores. Isso também aumenta o acoplamento, diminui a coesão e viola o princípio da responsabilidade única, que alguém da minha equipe apontou.

Essencialmente, grande parte da abstração nessa base de código ajuda a reduzir a duplicação de código, mas dificulta a extensão / alteração de métodos quando eles são feitos para obter a abstração mais alta. O que devo fazer em uma situação como esta? Eu estou no centro da culpa, mesmo que todo mundo não possa concordar com o que eles consideram bom, então isso está me machucando no final.

YamizGers
fonte
10
adicionar um exemplo de código para analfabetos o "problema" "" ajudaria a compreender a situação muito mais
Seabizkit
Eu acho que existem dois princípios do SOLID quebrados aqui. Responsabilidade única - se você passar um booleano para uma função que supostamente controla o comportamento, a função não terá mais uma única responsabilidade. O outro é o princípio da substituição de Liskov. Imagine que existe uma função que aceita a classe A como parâmetro. Se você passar na classe B em vez de A, a funcionalidade dessa função será interrompida?
Bobek
Eu suspeito que o método A é bastante longo e faz mais de uma coisa. É esse o caso?
Rad80 27/09/19

Respostas:

27

Se eu tentar criar um novo método para manipular B de maneira diferente, ele será chamado para duplicação de código.

Nem toda duplicação de código é criada da mesma forma.

Digamos que você tenha um método que use dois parâmetros e os adicione juntos, chamados total(). Digamos que você tenha outro chamado add(). Suas implementações parecem completamente idênticas. Eles devem ser mesclados em um método? NÃO!!!

O princípio de não repetir a si mesmo ou DRY não se refere à repetição de código. Trata-se de espalhar uma decisão, uma idéia, para que, se você mudar de idéia, tenha que reescrever todos os lugares em que espalhar essa idéia. Blegh. Isso é terrível. Não faça isso. Em vez disso, use DRY para ajudá-lo a tomar decisões em um só lugar .

O princípio DRY (não se repita) afirma:

Todo conhecimento deve ter uma representação única, inequívoca e autoritativa dentro de um sistema.

wiki.c2.com - Não se repita

Porém, o DRY pode ser corrompido com o hábito de digitalizar código, procurando uma implementação semelhante que parece ser uma cópia e colagem de outro lugar. Esta é a forma de morte cerebral do DRY. Inferno, você poderia fazer isso com uma ferramenta de análise estática. Isso não ajuda porque ignora o ponto de DRY, que é manter o código flexível.

Se meus requisitos totais mudarem, talvez seja necessário alterar minha totalimplementação. Isso não significa que eu preciso alterar minha addimplementação. Se algum goober os juntou em um único método, agora estou sentindo um pouco de dor desnecessária.

Quanta dor? Certamente eu poderia apenas copiar o código e criar um novo método quando necessário. Então, não é grande coisa, certo? Malarky! Se nada mais me custar um bom nome! É difícil encontrar bons nomes e não responde bem quando você brinca com o significado deles. Bons nomes, que tornam clara a intenção, são mais importantes do que o risco de você copiar um bug que, francamente, é mais fácil de corrigir quando o método tem o nome correto.

Portanto, meu conselho é parar de deixar reações bruscas a códigos semelhantes amarrar sua base de código em nós. Não estou dizendo que você é livre para ignorar o fato de que existem métodos e, em vez disso, copie e cole de maneira indesejada. Não, cada método deve ter um nome muito bom que apóie a única ideia de que se trata. Se sua implementação coincidir com a implementação de alguma outra boa idéia, agora, hoje, quem se importa?

Por outro lado, se você tiver um sum()método que tenha uma implementação idêntica ou até mesmo diferente total(), e mesmo assim, sempre que seus requisitos totais mudarem, você precisará mudar, sum()então há uma boa chance de que sejam a mesma idéia sob dois nomes diferentes. O código não apenas seria mais flexível se fossem mesclados, como seria menos confuso de usar.

Quanto aos parâmetros booleanos, sim, é um cheiro desagradável de código. O fluxo de controle não apenas é um problema, mas também mostra que você cortou uma abstração em um ponto ruim. As abstrações devem tornar as coisas mais simples de usar, não mais complicadas. Passar bools para um método para controlar seu comportamento é como criar uma linguagem secreta que decide qual método você está realmente chamando. Ow! Não faça isso comigo. Dê a cada método seu próprio nome, a menos que você tenha alguma honestidade quanto ao polimorfismo .

Agora, você parece esgotado com a abstração. Isso é muito ruim, porque a abstração é uma coisa maravilhosa quando bem feita. Você usa muito sem pensar nisso. Toda vez que você dirige um carro sem ter que entender o sistema de pinhão e cremalheira, toda vez que usa um comando de impressão sem pensar nas interrupções do sistema operacional e toda vez que escova os dentes sem pensar em cada cerda individual.

Não, o problema que você parece estar enfrentando é uma abstração ruim. Abstração criada para servir a um propósito diferente das suas necessidades. Você precisa de interfaces simples em objetos complexos que permitem solicitar que suas necessidades sejam atendidas sem precisar entender esses objetos.

Ao escrever o código do cliente que usa outro objeto, você sabe quais são suas necessidades e o que você precisa desse objeto. Não faz. É por isso que o código do cliente possui a interface. Quando você é o cliente, nada lhe diz quais são suas necessidades, exceto você. Você cria uma interface que mostra quais são suas necessidades e exige que tudo o que lhe é entregue atenda a essas necessidades.

Isso é abstração. Como cliente, nem sei com o que estou falando. Eu apenas sei o que preciso disso. Se isso significa que você precisa encerrar algo para alterar sua interface antes de me entregar bem. Eu não ligo Apenas faça o que eu preciso fazer. Pare de complicar.

Se eu tiver que olhar dentro de uma abstração para entender como usá-la, a abstração falhou. Eu não deveria saber como isso funciona. Apenas isso funciona. Dê um bom nome e, se eu olhar para dentro, não ficarei surpreso com o que acho. Não me faça continuar olhando para dentro para lembrar como usá-lo.

Quando você insiste que a abstração funciona dessa maneira, o número de níveis por trás dela não importa. Contanto que você não esteja olhando para trás da abstração. Você está insistindo que a abstração está de acordo com suas necessidades, não se adaptando às suas. Para que isso funcione, deve ser fácil de usar, ter um bom nome e não vazar .

Essa é a atitude que gerou a injeção de dependência (ou apenas passe de referência, se você é da velha escola como eu). Funciona bem com prefere composição e delegação sobre herança . A atitude tem muitos nomes. O meu favorito é dizer, não pergunte .

Eu poderia afogar você em princípios o dia todo. E parece que seus colegas de trabalho já são. Mas eis o seguinte: ao contrário de outros campos de engenharia, esse software tem menos de 100 anos. Ainda estamos todos descobrindo. Portanto, não deixe que alguém com muitos livros intimidadores aprenda você a escrever códigos difíceis de ler. Ouça-os, mas insista em que fazem sentido. Não tome nada com fé. Pessoas que codificam de alguma maneira só porque lhes disseram que é assim, sem saber por que fazem as maiores bagunças de todas.

candied_orange
fonte
Eu concordo plenamente. DRY é um acrônimo de três letras para o slogan de três palavras Não se repita, que por sua vez é um artigo de 14 páginas no wiki . Se tudo o que você faz é murmurar cegamente essas três letras sem ler e entender o artigo de 14 páginas, você terá problemas. Também está intimamente relacionado ao Once And Only Once (OAOO) e mais vagamente relacionado ao Single Point Of Truth (SPOT) / Única fonte de verdade (SSOT) .
Jörg W Mittag
"Suas implementações parecem completamente idênticas. Devem ser mescladas em um método? NÃO !!!" - O inverso também é verdadeiro: apenas porque dois pedaços de código são diferentes não significa que eles não são duplicados. Há uma ótima citação de Ron Jeffries na página wiki do OAOO : "Uma vez vi Beck declarar dois patches de código quase completamente diferente como" duplicação ", alterá-los para que fossem duplicação e remover a duplicação recém-inserida para aparecer com algo obviamente melhor. "
Jörg W Mittag
@ JörgWMittag, é claro. O essencial é a ideia. Se você está duplicando a ideia com um código diferente, ainda está violando a seco.
Candied_orange 22/09/19
Eu tenho que imaginar um artigo de 14 páginas sobre não se repetir tenderia a se repetir bastante.
Chuck Adams
7

O ditado usual de que todos lemos aqui e existe:

Todos os problemas podem ser resolvidos adicionando outra camada de abstração.

Bem, isso não é verdade! Seu exemplo mostra isso. Portanto, eu proporia a declaração levemente modificada (sinta-se à vontade para reutilizar ;-)):

Todo problema pode ser resolvido usando o nível correto de abstração.

Existem dois problemas diferentes no seu caso:

  • a super generalização causada pela adição de todos os métodos no nível abstrato;
  • a fragmentação dos comportamentos concretos que levam à impressão de não ter uma visão geral e se sentir perdido. Um pouco como em um loop de eventos do Windows.

Ambos são correlacionados:

  • se você abstrair um método em que cada especialização o faça de maneira diferente, tudo estará bem. Ninguém tem um problema de entender que um Shapepode calcular isso de surface()uma maneira especializada.
  • Se você abstrair alguma operação em que exista um padrão comportamental geral comum, você terá duas opções:

    • ou você repetirá o comportamento comum em todas as especializações: isso é muito redundante; e difícil de manter, especialmente para garantir que a parte comum fique alinhada nas especializações:
    • você usa algum tipo de variante do padrão do método de modelo : isso permite levar em consideração o comportamento comum usando métodos abstratos adicionais que podem ser facilmente especializados. É menos redundante, mas os comportamentos adicionais tendem a se tornar extremamente divididos. Demais significaria que talvez seja abstrato demais.

Além disso, essa abordagem pode resultar em um efeito de acoplamento abstrato no nível do design. Toda vez que você quiser adicionar algum tipo de novo comportamento especializado, será necessário abstraí-lo, alterar o pai abstrato e atualizar todas as outras classes. Esse não é o tipo de propagação de mudanças que se pode desejar. E não está realmente no espírito das abstrações, não depende da especialização (pelo menos no design).

Não conheço o seu design e não posso ajudar mais. Talvez seja realmente um problema muito complexo e abstrato e não exista uma maneira melhor. Mas quais são as chances? Os sintomas da generalização excessiva estão aqui. Pode ser que esteja na hora de analisar novamente e considerar a composição em vez da generalização ?

Christophe
fonte
5

Sempre que vejo um método em que o comportamento alterna com o tipo de parâmetro, considero imediatamente primeiro se esse método realmente pertence ao parâmetro method. Por exemplo, em vez de ter um método como:

public void sort(List values) {
    if (values instanceof LinkedList) {
        // do efficient linked list sort
    } else { // ArrayList
        // do efficient array list sort
    }
}

Eu faria isso:

values.sort();

// ...

class ArrayList {
    public void sort() {
        // do efficient array list sort
    }
}

class LinkedList {
    public void sort() {
        // do efficient linked list sort
    }
}

Mudamos o comportamento para o lugar que sabe quando usá-lo. Criamos uma abstração real na qual você não precisa conhecer os tipos ou os detalhes da implementação. Para sua situação, pode fazer mais sentido mover esse método da classe original (que chamarei O) para digitar Ae substituí-lo no tipo B. Se o método for chamado doItem algum objeto, vá doItpara Ae substitua pelo comportamento diferente em B. Se houver bits de dados de onde doIté originalmente chamado ou se o método for usado em locais suficientes, você poderá deixar o método original e delegar:

class O {
    int x;
    int y;

    public void doIt(A a) {
        a.doIt(this.x, this.y);
    }
}

No entanto, podemos mergulhar um pouco mais fundo. Vamos dar uma olhada na sugestão de usar um parâmetro booleano e ver o que podemos aprender sobre a maneira como seu colega de trabalho está pensando. Sua proposta é fazer:

public void doIt(A a, boolean isTypeB) {
    if (isTypeB) {
        // do B stuff
    } else { 
        // do A stuff
    }
}

Isso se parece muito com o instanceofque usei no meu primeiro exemplo, exceto pelo fato de estarmos externalizando essa verificação. Isso significa que teríamos que chamá-lo de duas maneiras:

o.doIt(a, a instanceof B);

ou:

o.doIt(a, true); //or false

De primeira, o ponto de chamada não tem idéia de que tipo Atem. Portanto, devemos passar os booleanos por todo o caminho? Esse é realmente um padrão que queremos em toda a base de código? O que acontece se houver um terceiro tipo que precisamos levar em conta? Se é assim que o método é chamado, devemos movê-lo para o tipo e deixar o sistema escolher a implementação para nós polimorficamente.

Da segunda maneira, já devemos saber o tipo de aponto de chamada. Geralmente, isso significa que estamos criando a instância lá ou pegando uma instância desse tipo como parâmetro. Criar um método Oque leva um Baqui funcionaria. O compilador saberia qual método escolher. Quando estamos conduzindo mudanças como essa, duplicar é melhor do que criar a abstração errada , pelo menos até descobrirmos para onde estamos realmente indo. Obviamente, estou sugerindo que realmente não terminamos, não importa o que mudamos até esse ponto.

Precisamos olhar mais de perto a relação entre Ae B. Geralmente, nos dizem que devemos favorecer a composição sobre a herança . Isso não é verdade em todos os casos, mas é verdade em um número surpreendente de casos, uma vez que investigamos. BHerda de A, o que significa que acreditamos Bser um A. Bdeve ser usado da mesma maneira A, exceto que funciona um pouco diferente. Mas quais são essas diferenças? Podemos dar às diferenças um nome mais concreto? Não Bé um A, mas realmente Atem um Xque poderia ser A'ou B'? Como seria nosso código se fizéssemos isso?

Se mudarmos o método para Ao sugerido anteriormente, poderemos injetar uma instância de Xem Ae delegar esse método para X:

class A {
    X x;
    A(X x) {
        this.x = x;
    }

    public void doIt(int x, int y) {
        x.doIt(x, y);
    }
}

Podemos implementar A'e nos B'livrar B. Melhoramos o código, dando um nome a um conceito que poderia ter sido mais implícito, e nos permitimos definir esse comportamento em tempo de execução, em vez de em tempo de compilação. Atornou-se menos abstrato também. Em vez de um relacionamento de herança estendido, ele está chamando métodos em um objeto delegado. Esse objeto é abstrato, mas mais focado apenas nas diferenças de implementação.

Há uma última coisa a se considerar. Vamos reverter a proposta do seu colega de trabalho. Se em todos os sites de chamadas soubermos explicitamente o tipo de Aque possuímos, deveríamos fazer chamadas como:

B b = new B();
o.doIt(b, true);

Assumimos anteriormente ao compor que Atem um Xque é A'ou B'. Mas talvez até essa suposição não esteja correta. É este o único lugar em que essa diferença entre Ae Bimporta? Se for, talvez possamos adotar uma abordagem um pouco diferente. Ainda temos um Xque é um A'ou outro B', mas não pertence A. Só se O.doItpreocupa com isso, então vamos transmiti-lo para O.doIt:

class O {
    int x;
    int y;

    public void doIt(A a, X x) {
        x.doIt(a, x, y);
    }
}

Agora, nosso site de chamadas se parece com:

A a = new A();
o.doIt(a, new B'());

Mais uma vez, Bdesaparece e a abstração se move para o mais focado X. Desta vez, porém, Aé ainda mais simples sabendo menos. É ainda menos abstrato.

É importante reduzir a duplicação em uma base de código, mas devemos considerar por que a duplicação ocorre em primeiro lugar. A duplicação pode ser um sinal de abstrações mais profundas que estão tentando sair.

cbojar
fonte
11
Parece-me que o código "ruim" de exemplo que você está dando aqui é semelhante ao que eu estaria inclinado a fazer em uma linguagem não OO. Gostaria de saber se eles aprenderam as lições erradas e as trouxeram ao mundo OO como a maneira como codificam?
Baldrickk
11
@Baldrickk Cada paradigma traz seus próprios modos de pensar, com suas vantagens e desvantagens únicas. No Haskell funcional, a correspondência de padrões seria a melhor abordagem. Embora em um idioma como esse, alguns aspectos do problema original também não seriam possíveis.
Cbojar # 24/19
11
Essa é a resposta correta. Um método que altera a implementação com base no tipo em que opera deve ser um método desse tipo.
Roman Reiner
0

A abstração por herança pode se tornar bastante feia. Hierarquias de classes paralelas com fábricas típicas. A refatoração pode se tornar uma dor de cabeça. E também o desenvolvimento posterior, o local em que você está.

Existe uma alternativa: pontos de extensão , abstrações estritas e personalização em camadas. Diga uma personalização de clientes governamentais, com base nessa personalização para uma cidade específica.

Um aviso: Infelizmente, isso funciona melhor quando todas (ou a maioria) das classes são ampliadas. Nenhuma opção para você, talvez em pequenas.

Essa extensibilidade funciona com uma classe base de objeto extensível que contém extensões:

void f(CreditorBO creditor) {
    creditor.as(AllowedCreditorBO.class).ifPresent(allowedCreditor -> ...);
}

Internamente, existe um mapeamento lento de objeto para objetos estendidos por classe de extensão.

Para classes e componentes da GUI, a mesma extensibilidade, em parte com herança. Adicionando botões e tal.

No seu caso, uma validação deve verificar se foi estendida e se validar contra as extensões. A introdução de pontos de extensão apenas para um caso adiciona código incompreensível, nada bom.

Portanto, não há solução, a não ser tentar trabalhar no contexto atual.

Joop Eggen
fonte
0

'controle de fluxo oculto' parece muito ondulado para mim.
Qualquer construção ou elemento retirado do contexto pode ter essa característica.

Abstrações são boas. Eu os tempero com duas diretrizes:

  • Melhor não abstrair cedo demais. Aguarde mais exemplos de padrões antes de abstrair. É claro que "mais" é subjetivo e específico para a situação difícil.

  • Evite muitos níveis de abstração apenas porque a abstração é boa. Um programador terá que manter esses níveis em mente para obter códigos novos ou alterados à medida que eles exploram a base de código e avançam 12 níveis. O desejo de um código bem abstrato pode levar a tantos níveis que dificilmente serão seguidos por muitas pessoas. Isso também leva a bases de código 'ninja mantidas apenas'.

Nos dois casos, 'mais e' muitos 'não são números fixos. Depende. É isso que dificulta.

Também gosto deste artigo de Sandi Metz

https://www.sandimetz.com/blog/2016/1/20/the-wrong-abstraction

duplicação é muito mais barata que a abstração errada
e
prefere duplicação à abstração errada

Michael Durrant
fonte