Troppa astrazione che rende il codice difficile da estendere


9

Sto affrontando problemi con ciò che ritengo sia troppa astrazione nella base di codice (o almeno affrontarla). La maggior parte dei metodi nella base di codice è stata astratta per includere il genitore più alto A nella base di codice, ma il figlio B di questo genitore ha un nuovo attributo che influenza la logica di alcuni di questi metodi. Il problema è che questi attributi non possono essere controllati in quei metodi perché l'input è astratto su A e A ovviamente non ha questo attributo. Se provo a creare un nuovo metodo per gestire B in modo diverso, viene chiamato per la duplicazione del codice. Il suggerimento del mio capo tecnico è di creare un metodo condiviso che includa parametri booleani, ma il problema è che alcune persone lo vedono come "flusso di controllo nascosto", in cui il metodo condiviso ha una logica che potrebbe non essere evidente ai futuri sviluppatori , e anche questo metodo condiviso diventerà eccessivamente complesso / contorto una volta se devono essere aggiunti attributi futuri, anche se è suddiviso in metodi condivisi più piccoli. Ciò aumenta anche l'accoppiamento, diminuisce la coesione e viola il principio della responsabilità singola, che qualcuno del mio team ha sottolineato.

Fondamentalmente, gran parte dell'astrazione in questa base di codice aiuta a ridurre la duplicazione del codice, ma rende più difficili estendere / cambiare i metodi quando vengono fatti per prendere la massima astrazione. Cosa dovrei fare in una situazione come questa? Sono al centro della colpa, anche se tutti gli altri non sono d'accordo su ciò che considerano buono, quindi alla fine mi fa male.


10
aggiungere un esempio di codice per analizzare il "" problema "" aiuterebbe a capire molto di più la situazione
Seabizkit,

Penso che qui ci siano due principi SOLIDI. Responsabilità singola: se passi un booleano a una funzione che dovrebbe controllare il comportamento, la funzione non avrà più una singola responsabilità. L'altro è il principio di sostituzione di Liskov. Immagina che ci sia una funzione che accetta la classe A come parametro. Se passi in classe B invece di A, la funzionalità di quella funzione verrà interrotta?
Bobek,

Sospetto che il metodo A sia piuttosto lungo e faccia più di una cosa. È così?
Rad80,

Risposte:


27

Se provo a creare un nuovo metodo per gestire B in modo diverso, viene chiamato per la duplicazione del codice.

Non tutta la duplicazione del codice è uguale.

Supponi di avere un metodo che accetta due parametri e li somma chiamati insieme total(). Di 'che ne hai chiamato un altro add(). Le loro implementazioni sembrano completamente identiche. Dovrebbero essere uniti in un solo metodo? NO!!!

Il principio Don't Repeat-Yourself o DRY non riguarda la ripetizione del codice. Si tratta di diffondere una decisione, un'idea, in modo che se mai cambi idea devi riscrivere ovunque diffondendo quell'idea. Blegh. È terribile. Non farlo Usa invece DRY per aiutarti a prendere decisioni in un unico posto .

Il principio DRY (Don't Repeat Yourself) afferma:

Ogni pezzo di conoscenza deve avere un'unica, inequivocabile, autorevole rappresentazione all'interno di un sistema.

wiki.c2.com - Non ripetere te stesso

Ma DRY può essere corrotto nell'abitudine di scansionare il codice alla ricerca di un'implementazione simile che sembra una copia e incolla di altrove. Questa è la forma morta del cervello di DRY. Cavolo, potresti farlo con uno strumento di analisi statica. Non aiuta perché ignora il punto di DRY che è quello di mantenere flessibile il codice.

Se i miei requisiti di totalizzazione cambiano, potrei dover cambiare la mia totalimplementazione. Ciò non significa che devo cambiare la mia addimplementazione. Se un po 'di sgarbato li ha trasformati insieme in un metodo, ora provo un po' di dolore inutile.

Quanto dolore? Sicuramente potrei semplicemente copiare il codice e creare un nuovo metodo quando ne ho bisogno. Quindi nessun grosso problema, giusto? Malarky! Se non altro mi costa un buon nome! I buoni nomi sono difficili da trovare e non rispondono bene quando giocherelli con il loro significato. I buoni nomi, che rendono chiaro l'intento, sono più importanti del rischio che tu abbia copiato un bug che, francamente, è più facile da correggere quando il tuo metodo ha il nome giusto.

Quindi il mio consiglio è di smettere di lasciare che le reazioni istintive a un codice simile leghino la tua base di codice in nodi. Non sto dicendo che sei libero di ignorare il fatto che esistono metodi e invece di copiare e incollare volenti o nolenti. No, ogni metodo dovrebbe avere un nome dannatamente buono che supporti l'idea di cui si tratta. Se la sua implementazione sembra corrispondere all'implementazione di qualche altra buona idea, proprio ora, oggi, chi diavolo se ne frega?

D'altra parte se hai un sum()metodo che ha un'implementazione identica o addirittura diversa rispetto a total(), eppure ogni volta che cambiano i tuoi requisiti di totalizzazione devi cambiare, sum()allora c'è una buona probabilità che siano la stessa idea con due nomi diversi. Non solo il codice sarebbe più flessibile se fossero uniti, ma sarebbe meno confuso da usare.

Per quanto riguarda i parametri booleani, sì, è un cattivo odore di codice. Questo flusso di controllo non è solo un problema, ma peggio sta dimostrando che hai tagliato un'astrazione in un punto negativo. Le astrazioni dovrebbero rendere le cose più semplici da usare, non più complicate. Passare bool a un metodo per controllarne il comportamento è come creare un linguaggio segreto che decida quale metodo stai veramente chiamando. Ahi! Non farmi questo. Dai a ogni metodo il suo nome, a meno che tu non abbia un po 'di onestà per fare il polimorfismo in corso.

Ora, sembri stremato dall'astrazione. È un peccato perché l'astrazione è una cosa meravigliosa se eseguita bene. Lo usi molto senza pensarci. Ogni volta che guidi un'auto senza dover comprendere il sistema a pignone e cremagliera, ogni volta che usi un comando di stampa senza pensare agli interruzioni del sistema operativo e ogni volta che ti lavi i denti senza pensare a ogni singola setola.

No, il problema che sembra affrontare è una cattiva astrazione. Astrazione creata per servire uno scopo diverso rispetto alle tue esigenze. Hai bisogno di interfacce semplici in oggetti complessi che ti consentano di soddisfare le tue esigenze senza doverle mai capire.

Quando si scrive il codice client che utilizza un altro oggetto, si conoscono le proprie esigenze e le proprie esigenze da tale oggetto. Non Ecco perché il codice client possiede l'interfaccia. Quando sei il cliente, niente può dirti quali sono le tue esigenze tranne te. Tu crei un'interfaccia che mostra quali sono i tuoi bisogni e chiedi che qualunque cosa ti venga consegnata soddisfi tali bisogni.

Questa è astrazione. Come cliente non so nemmeno con cosa sto parlando. So solo di cosa ho bisogno. Se ciò significa che devi concludere qualcosa per cambiarne l'interfaccia prima di consegnarmelo bene. Non mi interessa. Fai solo quello che mi serve. Smetti di complicarlo.

Se devo guardare dentro un'astrazione per capire come usarla, l'astrazione è fallita. Non dovrei aver bisogno di sapere come funziona. Solo che funziona. Dagli un buon nome e se guardo dentro non dovrei essere sorpreso da quello che trovo. Non farmi continuare a guardare dentro per ricordare come usarlo.

Quando insisti sul fatto che l'astrazione funziona in questo modo, il numero di livelli dietro non ha importanza. Finché non guardi dietro l'astrazione. Stai insistendo sul fatto che l'astrazione è conforme alle tue esigenze non adattandosi alle sue. Perché funzioni, deve essere facile da usare, avere un buon nome e non perdere .

Questo è l'atteggiamento che ha generato Iniezione delle dipendenze (o semplicemente riferimento al passaggio se sei vecchia scuola come me). Funziona bene con preferisce la composizione e la delega rispetto all'eredità . L'atteggiamento ha molti nomi. Il mio preferito è dire, non chiedere .

Potrei affogarti nei principi tutto il giorno. E sembra che i tuoi colleghi lo siano già. Ma ecco la cosa: a differenza di altri campi dell'ingegneria questa cosa del software ha meno di 100 anni. Lo stiamo ancora capendo. Quindi non lasciare che qualcuno con un sacco di libri dal suono intimidatorio che impari a costringerti a scrivere codice difficile da leggere. Ascoltali ma insisti che abbiano un senso. Non prendere nulla sulla fede. Le persone che codificano in qualche modo solo perché gli è stato detto questo è il modo senza sapere perché creare i più grandi pasticci di tutti.


Sono pienamente d'accordo. DRY è un acronimo di tre lettere per la frase di tre parole Don't Repeat Yourself, che a sua volta è un articolo di 14 pagine sul wiki . Se tutto ciò che fai è mormorando ciecamente quelle tre lettere senza leggere e comprendere l'articolo 14 pagina, verrà eseguito nei guai. È anche strettamente correlato a Once And Only Once (OAOO) e più vagamente correlato a Single Point Of Truth (SPOT) / Single Source Of Truth (SSOT) .
Jörg W Mittag,

"Le loro implementazioni sembrano del tutto identiche. Dovrebbero essere unite in un unico metodo? NO !!!" - È vero anche il contrario: solo perché due parti di codice sono diverse non significa che non siano duplicati. C'è una grande citazione di Ron Jeffries sulla pagina wiki di OAOO : "Una volta ho visto Beck dichiarare due patch di codice quasi completamente diverso come" duplicazione ", cambiarle in modo che fossero DUE duplicazioni, quindi rimuovere la duplicazione appena inserita per venire fuori con qualcosa ovviamente migliore ".
Jörg W Mittag,

@ JörgWMittag ovviamente. L'essenziale è l'idea. Se stai duplicando l'idea con un codice dall'aspetto diverso, stai ancora violando Dry.
candied_orange,

Immagino che un articolo di 14 pagine sul non ripetersi tenderebbe a ripetersi molto.
Chuck Adams,

7

Il solito detto che leggiamo tutti qui e c'è:

Tutti i problemi possono essere risolti aggiungendo un altro strato di astrazione.

Bene, questo non è vero! Il tuo esempio lo mostra. Pertanto proporrei la dichiarazione leggermente modificata (sentitevi liberi di riutilizzarli ;-)):

Ogni problema può essere risolto usando il livello di astrazione GIUSTO.

Ci sono due diversi problemi nel tuo caso:

  • l' eccessiva generalizzazione causata dall'aggiunta di ogni metodo a livello astratto;
  • la frammentazione dei comportamenti concreti che portano all'impressione di non ottenere il quadro generale e sentirsi persi. Un po 'come in un loop di eventi di Windows.

Entrambi sono corelati:

  • se si astratta un metodo in cui ogni specializzazione lo fa in modo diverso, tutto va bene. Nessuno ha problemi a capire che una Shapelattina può calcolarla surface()in modo specializzato.
  • Se si astraggono alcune operazioni in cui esiste un modello comportamentale generale comune, sono disponibili due opzioni:

    • o ripeterai il comportamento comune in ogni specializzazione: questo è molto ridondante; e difficile da mantenere, soprattutto per garantire che la parte comune sia in linea tra le specializzazioni:
    • si utilizza una sorta di variante del modello di metodo del modello : ciò consente di tenere conto del comportamento comune utilizzando metodi astratti aggiuntivi che possono essere facilmente specializzati. È meno ridondante, ma i comportamenti aggiuntivi tendono a diventare estremamente divisi. Troppo significherebbe che è forse troppo astratto.

Inoltre, questo approccio potrebbe comportare un effetto di accoppiamento astratto a livello di progettazione. Ogni volta che vuoi aggiungere una sorta di nuovo comportamento specializzato, dovrai astrarlo, cambiare il genitore astratto e aggiornare tutte le altre classi. Questo non è il tipo di propagazione del cambiamento che si potrebbe desiderare. E non è proprio nello spirito delle astrazioni non dipende dalla specializzazione (almeno nel design).

Non conosco il tuo design e non posso fare altro. Forse è davvero un problema molto complesso e astratto e non esiste un modo migliore. Ma quali sono le probabilità? I sintomi di ipergeneralizzazione sono qui. Potrebbe essere il momento di rivederlo e considerare la composizione piuttosto che la generalizzazione ?


5

Ogni volta che vedo un metodo in cui il comportamento attiva il tipo del suo parametro, prendo subito in considerazione prima se quel metodo appartiene effettivamente al parametro del metodo. Ad esempio, invece di avere un metodo come:

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

Vorrei fare questo:

values.sort();

// ...

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

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

Spostiamo il comportamento nel luogo che sa quando usarlo. Creiamo una vera astrazione in cui non è necessario conoscere i tipi o i dettagli dell'implementazione. Per la tua situazione, potrebbe essere più sensato spostare questo metodo dalla classe originale (che chiamerò O) per digitare Ae sovrascriverlo nel tipo B. Se il metodo è chiamato doItsu un oggetto, spostare doItverso Ae sostituzione con il diverso comportamento B. Se sono presenti bit di dati da dove doItviene originariamente chiamato o se il metodo viene utilizzato in posizioni sufficienti, è possibile lasciare il metodo originale e delegare:

class O {
    int x;
    int y;

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

Possiamo immergerci un po 'più a fondo, però. Diamo un'occhiata al suggerimento di utilizzare un parametro booleano invece e vediamo cosa possiamo imparare sul modo in cui il tuo collega sta pensando. La sua proposta è di fare:

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

Questo assomiglia moltissimo a quello che instanceofho usato nel mio primo esempio, tranne per il fatto che stiamo esternalizzando quel controllo. Ciò significa che dovremmo chiamarlo in due modi:

o.doIt(a, a instanceof B);

o:

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

Nel primo modo, il punto di chiamata non ha idea di che tipo Aabbia. Pertanto, dovremmo passare booleani fino in fondo? È davvero un modello che vogliamo in tutta la base di codice? Cosa succede se esiste un terzo tipo di cui dobbiamo tenere conto? Se è così che viene chiamato il metodo, dovremmo spostarlo sul tipo e lasciare che il sistema scelga l'implementazione per noi polimorficamente.

Nel secondo modo, dobbiamo già conoscere il tipo di aal punto di chiamata. Di solito ciò significa che stiamo creando l'istanza lì o prendendo un'istanza di quel tipo come parametro. La creazione di un metodo Oche richiede un Bqui funzionerebbe. Il compilatore saprebbe quale metodo scegliere. Quando stiamo guidando cambiamenti come questo, la duplicazione è meglio che creare l'astrazione sbagliata , almeno fino a quando non scopriamo dove stiamo realmente andando. Certo, sto suggerendo che non abbiamo davvero finito, non importa cosa siamo cambiati a questo punto.

Dobbiamo esaminare più da vicino la relazione tra Ae B. In generale, ci viene detto che dovremmo favorire la composizione rispetto all'eredità . Questo non è vero in ogni caso, ma è vero in un numero sorprendente di casi una volta che scaviamo. BEredita A, nel senso che crediamo che Bsia un A. Bdovrebbe essere usato proprio come A, tranne per il fatto che funziona in modo leggermente diverso. Ma quali sono queste differenze? Possiamo dare alle differenze un nome più concreto? Non Bè un A, ma Aha davvero un Xche potrebbe essere A'o B'? Come sarebbe il nostro codice se lo facessimo?

Se spostassimo il metodo Acome suggerito in precedenza, potremmo iniettare un'istanza di Xin Ae delegare quel metodo a X:

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

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

Possiamo implementare A'e B'liberarcene B. Abbiamo migliorato il codice dando un nome a un concetto che potrebbe essere stato più implicito e ci siamo concessi di impostare quel comportamento in fase di esecuzione anziché in fase di compilazione. Ain realtà è diventato anche meno astratto. Invece di una relazione di ereditarietà estesa, chiama i metodi su un oggetto delegato. Tale oggetto è astratto, ma maggiormente focalizzato solo sulle differenze di implementazione.

C'è un'ultima cosa da considerare però. Torniamo alla proposta del tuo collega. Se in tutti i siti di chiamata conosciamo esplicitamente il nostro tipo A, dovremmo effettuare chiamate come:

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

Abbiamo assunto in precedenza durante la composizione che Aha un valore Xche è A'o B'. Ma forse anche questo assunto non è corretto. È l'unico posto in cui questa differenza tra Ae Bconta? Se lo è, allora forse possiamo adottare un approccio leggermente diverso. Abbiamo ancora uno Xche è o A'o B', ma non appartiene A. Se ne O.doItpreoccupa solo , quindi passiamo solo a O.doIt:

class O {
    int x;
    int y;

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

Ora il nostro sito di chiamate è simile a:

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

Ancora una volta, Bscompare e l'astrazione si sposta nel più focalizzato X. Questa volta, però, Aè ancora più semplice conoscendo meno. È ancora meno astratto.

È importante ridurre la duplicazione in una base di codice, ma dobbiamo prima considerare perché la duplicazione avviene. La duplicazione può essere un segno di astrazioni più profonde che stanno cercando di uscire.


1
Mi sembra che il codice "cattivo" di esempio che stai dando qui sia simile a quello che sarei propenso a fare in un linguaggio non OO. Mi chiedo se hanno imparato le lezioni sbagliate e le hanno portate nel mondo OO come il modo in cui codificano?
Baldrickk,

1
@Baldrickk Ogni paradigma porta i suoi modi di pensare, con i loro vantaggi e svantaggi unici. In Haskell funzionale, la corrispondenza del modello sarebbe l'approccio migliore. Sebbene in una lingua del genere, alcuni aspetti del problema originale non sarebbero neppure possibili.
cbojar,

1
Questa è la risposta corretta Un metodo che modifica l'implementazione in base al tipo su cui opera dovrebbe essere un metodo di quel tipo.
Roman Reiner,

0

L'astrazione per ereditarietà può diventare piuttosto brutta. Gerarchie di classi parallele con fabbriche tipiche. Il refactoring può diventare un mal di testa. E anche lo sviluppo successivo, il punto in cui ti trovi.

Esiste un'alternativa: punti di estensione , di astrazioni rigorose e personalizzazione a più livelli. Pronuncia una personalizzazione dei clienti governativi, basata su tale personalizzazione per una città specifica.

Un avvertimento: purtroppo funziona meglio quando tutte (o la maggior parte) classi sono rese extale. Nessuna opzione per te, forse in piccolo.

Questa estendibilità funziona avendo una classe base di oggetti estensibile che contiene estensioni:

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

Internamente esiste una mappatura pigra dell'oggetto su oggetti estesi per classe di estensione.

Per le classi e i componenti della GUI la stessa estensibilità, in parte con l'ereditarietà. Aggiunta di pulsanti e così via.

Nel tuo caso, una convalida dovrebbe verificare se viene estesa e convalidare se stessa rispetto alle estensioni. L'introduzione di punti di estensione solo per un caso aggiunge un codice incomprensibile, non buono.

Quindi non c'è soluzione se non provare a lavorare nel contesto attuale.


0

il "controllo del flusso nascosto" mi sembra troppo commovente.
Qualsiasi costrutto o elemento rimosso dal contesto può avere quella caratteristica.

Le astrazioni sono buone. Li tempero con due linee guida:

  • Meglio non astrarre troppo presto. Attendere ulteriori esempi di schemi prima di astrarre. "Altro" è ovviamente soggettivo e specifico per la situazione che è difficile.

  • Evita troppi livelli di astrazione solo perché l'astrazione è buona. Un programmatore dovrà tenere questi livelli in testa per il codice nuovo o modificato mentre inseriscono la base di codice e scendono a 12 livelli di profondità. Il desiderio di un codice ben astratto può portare a così tanti livelli che sono difficili da seguire per molte persone. Questo porta anche a basi di codice "ninja mantenute solo".

In entrambi i casi 'più e' troppi 'non sono numeri fissi. Dipende. Questo è ciò che lo rende difficile.

Mi piace anche questo articolo scritto da Sandi Metz

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

la duplicazione è molto più economica dell'astrazione sbagliata
e
preferisce la duplicazione rispetto all'astrazione sbagliata

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.