Cada vez que veo un método en el que el comportamiento activa el tipo de su parámetro, inmediatamente considero primero si ese método realmente pertenece al parámetro del método. Por ejemplo, en lugar de tener un método como:
public void sort(List values) {
if (values instanceof LinkedList) {
// do efficient linked list sort
} else { // ArrayList
// do efficient array list sort
}
}
Yo haría esto:
values.sort();
// ...
class ArrayList {
public void sort() {
// do efficient array list sort
}
}
class LinkedList {
public void sort() {
// do efficient linked list sort
}
}
Llevamos el comportamiento al lugar que sabe cuándo usarlo. Creamos una abstracción real donde no necesita conocer los tipos o los detalles de la implementación. Para su situación, podría tener más sentido mover este método desde la clase original (que llamaré O
) para escribir A
y anularlo en tipo B
. Si el método se llama doIt
en algún objeto, mover doIt
a A
y anulación con el diferente comportamiento en B
. Si hay bits de datos desde donde doIt
se llama originalmente, o si el método se usa en suficientes lugares, puede dejar el método original y delegar:
class O {
int x;
int y;
public void doIt(A a) {
a.doIt(this.x, this.y);
}
}
Sin embargo, podemos sumergirnos un poco más. Veamos la sugerencia de usar un parámetro booleano y veamos qué podemos aprender sobre la forma en que piensa su compañero de trabajo. Su propuesta es hacer:
public void doIt(A a, boolean isTypeB) {
if (isTypeB) {
// do B stuff
} else {
// do A stuff
}
}
Esto se parece mucho al instanceof
que usé en mi primer ejemplo, excepto que estamos externalizando esa verificación. Esto significa que tendríamos que llamarlo de dos maneras:
o.doIt(a, a instanceof B);
o:
o.doIt(a, true); //or false
En la primera forma, el punto de llamada no tiene idea de qué tipo A
tiene. Por lo tanto, ¿deberíamos pasar booleanos hasta el fondo? ¿Es realmente un patrón que queremos en toda la base de código? ¿Qué sucede si hay un tercer tipo que debemos tener en cuenta? Si así es como se llama el método, deberíamos moverlo al tipo y dejar que el sistema elija la implementación para nosotros polimórficamente.
En la segunda forma, ya debemos saber el tipo de a
en el punto de llamada. Por lo general, eso significa que estamos creando la instancia allí o tomando una instancia de ese tipo como parámetro. Crear un método O
que tome un B
aquí funcionaría. El compilador sabría qué método elegir. Cuando estamos manejando cambios como este, la duplicación es mejor que crear la abstracción incorrecta , al menos hasta que descubramos a dónde vamos realmente. Por supuesto, sugiero que no hayamos terminado realmente sin importar lo que hayamos cambiado hasta este punto.
Necesitamos mirar más de cerca la relación entre A
y B
. En general, se nos dice que debemos favorecer la composición sobre la herencia . Esto no es cierto en todos los casos, pero es cierto en un sorprendente número de casos una vez que profundizamos. B
Hereda de A
, lo que significa que creemos que B
es un A
. B
debe usarse igual que A
, excepto que funciona un poco diferente. ¿Pero cuáles son esas diferencias? ¿Podemos dar a las diferencias un nombre más concreto? ¿No B
es un A
, pero realmente A
tiene un X
que podría ser A'
o B'
? ¿Cómo sería nuestro código si hiciéramos eso?
Si nos trasladamos en el método A
como se sugirió anteriormente, podríamos inyectar una instancia de X
dentro A
, y delegar ese método para la 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'
y B'
deshacernos B
. Hemos mejorado el código dando un nombre a un concepto que podría haber sido más implícito, y nos permitimos establecer ese comportamiento en tiempo de ejecución en lugar de tiempo de compilación. A
en realidad se ha vuelto menos abstracto también. En lugar de una relación de herencia extendida, está llamando a métodos en un objeto delegado. Ese objeto es abstracto, pero está más enfocado solo en las diferencias en la implementación.
Sin embargo, hay una última cosa para mirar. Volvamos a la propuesta de su compañero de trabajo. Si en todos los sitios de llamadas conocemos explícitamente el tipo A
que tenemos, entonces deberíamos hacer llamadas como:
B b = new B();
o.doIt(b, true);
Asumimos anteriormente al componer que A
tiene un X
que es A'
o B'
. Pero tal vez incluso esta suposición no es correcta. ¿Es este el único lugar donde esta diferencia entre A
y B
importa? Si es así, entonces quizás podamos adoptar un enfoque ligeramente diferente. Todavía tenemos uno X
que es A'
o B'
, pero no pertenece A
. Solo O.doIt
le importa, así que solo pasémoslo a O.doIt
:
class O {
int x;
int y;
public void doIt(A a, X x) {
x.doIt(a, x, y);
}
}
Ahora nuestro sitio de llamadas se ve así:
A a = new A();
o.doIt(a, new B'());
Una vez más, B
desaparece, y la abstracción se mueve hacia lo más enfocado X
. Esta vez, sin embargo, A
es aún más simple al saber menos. Es aún menos abstracto.
Es importante reducir la duplicación en una base de código, pero debemos considerar por qué la duplicación ocurre en primer lugar. La duplicación puede ser un signo de abstracciones más profundas que están tratando de salir.