tl;dr: No, por ningún motivo.
Existe un prejuicio común de que utilizar if
s en un programa empeora la calidad del código, este
mismo prejuicio sucede con los switch
s, enum
s y operaciones como el operador instanceof
o
el método getClass()
(ambos de Java).
Existen varios argumentos en contra como que hace el código más ineficiente o menos extensible,
estos argumentos están errados(ish).
Las operaciones de control de flujo como if
son esenciales para programar (no estarían en todos
los lenguajes de programación si no fuera así), el problema está em usarlos de forma equivocada.
Cuando unx diseña software siempre tiene que tener una forma de justificar su diseño, este argumento puede ser desde un punto de vista de eficiencia, de extensibilidad, legibilidad u otra dependiendo de cual sea el objetivo de su programa. Por ejemplo, si estamos trabajando en una aplicación que va a ser desarrollada por un equipo (como será en la mayoría de los casos) vamos a querer que nuestro código sea extensible y legible, pero si estamos trabajando en una aplicación que debe tener alta precisión es posible que tengamos que sacrificar lo anterior en pos de la eficiencia.
Volviendo al punto inicial, veamos algunos ejemplos de uso correctos e incorrectos.
Un caso típico es cuando queremos implementar una funcionalidad que será distinta dependiendo del tipo de objeto. Un ejemplo de libro es un programa que represente algunas figuras geométricas, una primera forma de implementar esta funcionalidad sería:
public enum FigureType {
CIRCLE,
SQUARE,
RECTANGLE,
TRIANGLE
}
public class Figure {
private FigureType type;
private double radius;
private double side;
private double height;
public Figure(FigureType type, double radius, double side, double height) {
this.type = type;
this.radius = radius;
this.side = side;
this.height = height;
}
public double getArea() {
switch (type) {
case CIRCLE:
return Math.PI * radius * radius;
case SQUARE:
return side * side;
case RECTANGLE:
return side * height;
case TRIANGLE:
return side * height / 2;
default:
return 0;
}
}
}
En este ejemplo, el código es legible, pero no extensible, pero el problema no es el uso de
switch
o enum
, el problema es que tenemos una clase Figure
que tiene la responsabilidad
de saber cómo calcular el área de todos los tipos de figuras geométricas, esto rompe el primer
principio SOLID.
Esto se soluciona separando las responsabilidades:
public interface Figure {
double getArea();
}
public class Circle implements Figure {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
public double getArea() {
return Math.PI * radius * radius;
}
}
public class Square implements Figure {
private double side;
public Square(double side) {
this.side = side;
}
public double getArea() {
return side * side;
}
}
public class Rectangle implements Figure {
private double side;
private double height;
public Rectangle(double side, double height) {
this.side = side;
this.height = height;
}
public double getArea() {
return side * height;
}
}
public class Triangle implements Figure {
private double side;
private double height;
public Triangle(double side, double height) {
this.side = side;
this.height = height;
}
public double getArea() {
return side * height / 2;
}
}
Este código es más extenso, pero mantiene la legibilidad y es más extensible que el anterior ya que ahora para agregar un nuevo tipo de figura no es necesario modificar el código ya existente.
Tomando como base el problema anterior, el error surge de tratar de representar tipos "complejos"
con un enum
(que como dice el nombre, es una enumeración).
Podemos pensar un enum
como una "extensión" de los tipos booleanos, de hecho, en C los
booleanos suelen representarse como una enumeración:
enum Boolean {
FALSE,
TRUE
}
Esto está bien porque true y false son valores simples que no tienen "funcionalidades" asociadas. Esta misma idea podríamos usar para representar el estado de un proceso, por ejemplo:
public enum ProcessState {
RUNNING,
FINISHED,
ERROR
}
public class Process {
private ProcessState state;
private int exitCode;
public Process(ProcessState state, int exitCode) {
this.state = state;
this.exitCode = exitCode;
}
public void run() {
switch (state) {
case RUNNING:
System.out.println("The process is already running");
break;
case FINISHED:
state = ProcessState.RUNNING;
break;
case ERROR:
System.out.println("The process ended with error code " + exitCode);
break;
default:
break;
}
}
}
Aquí está bien usar switch
s y enum
s, ya que los estados del proceso no tienen comportamientos
específicos que justifiquen crear una clase para representarlos.
instanceof
y getClass
son operaciones que tienen pocos casos de uso, y por lo mismo,
debieran evitarse en la mayoría de los casos.
instanceof
es una operación que se usa para verificar si un objeto es una instancia de una
clase particular, y getClass
es una operación que se usa para obtener la clase de un objeto.
El error más común es utilizarlos para tomar decisiones en base a la clase de un objeto, por ejemplo:
public class Figure {
public double getArea() {
if (this instanceof Circle) { // o (getClass() == Circle.class)
return Math.PI * radius * radius;
} else if (this instanceof Square) { // o (getClass() == Square.class)
return side * side;
} else if (this instanceof Rectangle) { // o (getClass() == Rectangle.class)
return side * height;
} else if (this instanceof Triangle) { // o (getClass() == Triangle.class)
return side * height / 2;
} else {
return 0;
}
}
}
Este ejemplo anterior puede ser engañoso, ya que haciendo la separación de clases correcta, pero estamos haciendo que cada figura tenga que "saber" cómo se calcula el área de las otras. Nuevamente, estamos rompiendo el primer principio SOLID.
Como dije, los casos de uso de ambas operaciones son poco frecuentes, pero voy a dar algunos.
public class Circle {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
public boolean equals(Object other) {
if (other instanceof Circle) {
Circle otherCircle = (Circle) other;
return radius == otherCircle.radius;
} else {
return false;
}
}
}
Aquí el uso de instanceof
es correcto y cumple 2 objetivos:
- Verificar que ambos objetos sean instancias de la misma clase, ya que si no lo son, son distintos.
- Asegurar que el otro objeto (el que se pasa como parámetro) puede ser casteado correctamente. (Noten que hacer casting es necesario para poder acceder a los atributos del otro objeto).
En el ejemplo anterior también podríamos haber usado getClass()
, pero no es recomendado ya que,
como veremos en el siguiente ejemplo, el método getClass()
está pensado para otro tipo de
uso.
Hay veces en las que vamos a querer obtener la meta-información de una clase, como el nombre, el número de atributos, sus métodos, etc.
Figure figure = new Circle(5);
Class<?> clazz = figure.getClass();
System.out.println(clazz.getName());
System.out.println(clazz.getPackage());
System.out.println(Arrays.toString(clazz.getDeclaredMethods()));
System.out.println(Arrays.toString(clazz.getDeclaredFields()));
Esto es un ejemplo de juguete, pero lo que tienen que entender es que estamos trabajando con información de bajo nivel a la que usualmente solo accede el compilador. ¿Cuándo usaríamos esto? Cuando necesitemos realizar operaciones que ocurran durante la compilación del programa ^1, esto es un tópico bastante avanzado.
Ya vimos algunas maneras en las que no debemos utilizar instanceof
y getClass
, pero podemos
hacerlo incluso peor.
Vamos viendo con un ejemplo.
Diseñemos dos clases, una para Pokémon tipo agua y otra para Pokémon tipo fuego. Cuando un tipo agua ataca a un tipo fuego, el ataque es efectivo, pero cuando un tipo fuego ataca a un tipo agua, el ataque es inefectivo.
// Sintaxis de Kotlin
class WaterPokemon : Pokemon {
override fun attack(other: Pokemon) {
if (other is FirePokemon) { // equivalente a ``other instanceof FirePokemon``
println("The attack is effective!")
} else {
println("Normal attack")
}
}
}
class FirePokemon : Pokemon {
override fun attack(other: Pokemon) {
if (other is WaterPokemon) { // equivalente a ``other instanceof WaterPokemon``
println("The attack is ineffective!")
} else {
println("Normal attack")
}
}
}
Agreguemos ahora el tipo planta a nuestro programa. Cuando un Pokémon planta ataca a un Pokémon agua, el ataque es efectivo, mientras que cuando un Pokémon agua ataca a un Pokémon planta, el ataque es inefectivo. Adicionalmente, cuando un Pokémon planta ataca a un Pokémon fuego, el ataque es inefectivo, mientras que cuando un Pokémon fuego ataca a un Pokémon planta, el ataque es efectivo.
class WaterPokemon : Pokemon {
override fun attack(other: Pokemon) {
if (other is FirePokemon) {
println("The attack is effective!")
} else if (other is GrassPokemon) {
println("The attack is ineffective!")
} else {
println("Normal attack")
}
}
}
class FirePokemon : Pokemon {
override fun attack(other: Pokemon) {
if (other is WaterPokemon) {
println("The attack is ineffective!")
} else if (other is GrassPokemon) {
println("The attack is effective!")
} else {
println("Normal attack")
}
}
}
class GrassPokemon : Pokemon {
override fun attack(other: Pokemon) {
if (other is FirePokemon) {
println("The attack is ineffective!")
} else if (other is WaterPokemon) {
println("The attack is effective!")
} else {
println("Normal attack")
}
}
}
Podemos ver fácilmente que esto es bastante ineficiente, ya que cada vez que un Pokémon ataca a
otro, se tiene que verificar si es un tipo agua, fuego, o planta.
Esto, nuevamente, rompe el Single-Responsibility Principle, además de que con cada tipo que
agreguemos tendremos que asegurarnos de agregar las ramas adicionales al if-else
para cada
clase.
Como ya hemos visto, esto es problemático. Hay más de una forma de resolver este problema, pero una bastante recomendable ya que aprovecha el polimorfismo de los objetos es utilizar Double Dispatch. Esto se implementaría de la siguiente forma:
class WaterPokemon : Pokemon {
override fun attack(other: Pokemon) {
other.receiveWaterAttack(this)
}
override fun receiveWaterAttack(other: WaterPokemon) {
println("The attack is effective!")
}
override fun receiveFireAttack(other: FirePokemon) {
println("The attack is ineffective!")
}
override fun receiveGrassAttack(other: GrassPokemon) {
println("Normal attack")
}
}
class FirePokemon : Pokemon {
override fun attack(other: Pokemon) {
other.receiveFireAttack(this)
}
override fun receiveWaterAttack(other: WaterPokemon) {
println("The attack is ineffective!")
}
override fun receiveFireAttack(other: FirePokemon) {
println("The attack is ineffective!")
}
override fun receiveGrassAttack(other: GrassPokemon) {
println("The attack is effective!")
}
}
class GrassPokemon : Pokemon {
override fun attack(other: Pokemon) {
other.receiveGrassAttack(this)
}
override fun receiveWaterAttack(other: WaterPokemon) {
println("The attack is effective!")
}
override fun receiveFireAttack(other: FirePokemon) {
println("The attack is ineffective!")
}
override fun receiveGrassAttack(other: GrassPokemon) {
println("The attack is ineffective!")
}
}
Esto a primera vista podría parecer más complejo que la solución original, pero esta solución resulta ser mucho más extensible y flexible a lo largo del tiempo.
Me gustó mucho el gist, pero creo que te faltó considerar el Expression Problem. La solución con double dispatch es extensible en cuanto a la representación, pero no al comportamiento (si quiero agregar una nueva operación para los pokemon, debo agregar un método por cada tipo de Pokemon). En cambio, la versión con un enum es extensible en cuanto al comportamiento, pero no en su representación (si quiero agregar una nueva operación solo necesito agregar un método).
Esta es la razón por la que el ejemplo de los estados está bien, dificilmente van a haber más estados, pero si es probable que hayan más comportamientos que usen esos estados.
Respecto al último ejemplo difiero en que sea más extensible y flexible, porque sufre de los dos problemas. Si hay n tipos de pokemon y m comportamientos, para agregar un nuevo tipo de pokemon voy a tener que implementar 2 * n + m métodos. En cambio si quiero agregar un comportamiento nuevo a los pokemones voy a tener que implementar n métodos.
En mi opinion una solución preferible al último ejemplo sería: