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 A
e sovrascriverlo nel tipo B
. Se il metodo è chiamato doIt
su un oggetto, spostare doIt
verso A
e sostituzione con il diverso comportamento B
. Se sono presenti bit di dati da dove doIt
viene 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 instanceof
ho 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 A
abbia. 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 a
al 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 O
che richiede un B
qui 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 A
e 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. B
Eredita A
, nel senso che crediamo che B
sia un A
. B
dovrebbe 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 A
ha davvero un X
che potrebbe essere A'
o B'
? Come sarebbe il nostro codice se lo facessimo?
Se spostassimo il metodo A
come suggerito in precedenza, potremmo iniettare un'istanza di X
in A
e 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. A
in 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 A
ha un valore X
che è A'
o B'
. Ma forse anche questo assunto non è corretto. È l'unico posto in cui questa differenza tra A
e B
conta? Se lo è, allora forse possiamo adottare un approccio leggermente diverso. Abbiamo ancora uno X
che è o A'
o B'
, ma non appartiene A
. Se ne O.doIt
preoccupa 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, B
scompare 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.