Skip to content

Instantly share code, notes, and snippets.

@r8vnhill
Last active June 5, 2022 21:11
Show Gist options
  • Save r8vnhill/3775922739b259c45c6195761e3dcb88 to your computer and use it in GitHub Desktop.
Save r8vnhill/3775922739b259c45c6195761e3dcb88 to your computer and use it in GitHub Desktop.
¿Son los if malvados?

Table of Contents

¿Son los ifs malvados?

tl;dr: No, por ningún motivo.

Existe un prejuicio común de que utilizar ifs en un programa empeora la calidad del código, este mismo prejuicio sucede con los switchs, enums 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.

Cómo no usar switchs y enums

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.

Cómo usar switchs y enums

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 switchs y enums, ya que los estados del proceso no tienen comportamientos específicos que justifiquen crear una clase para representarlos.

¿Y qué pasa con instanceof y getClass?

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.

¿Para qué son entonces instanceof y getClass?

Como dije, los casos de uso de ambas operaciones son poco frecuentes, pero voy a dar algunos.

Queremos implementar el operador de igualdad de una clase

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:

  1. Verificar que ambos objetos sean instancias de la misma clase, ya que si no lo son, son distintos.
  2. 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.

Obtener información sobre una clase

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.

Aberraciones

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.

@romero-jose
Copy link

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:

enum class Type {
    FIRE, WATER, GRASS
}

fun effectiveness(attacker_type: Type, attacked_type: Type): String {
    return when (attacker_type) {
        Type.WATER -> when (attacked_type) {
           Type.FIRE -> "The attack is effective!"
           Type.WATER -> "The attack is ineffective!"
           else -> "Normal attack"
        }
        Type.FIRE -> when (attacked_type) {
           Type.WATER -> "The attack is ineffective!"
           Type.GRASS -> "The attack is effective!"
           else -> "Normal attack"
        }
        Type.GRASS -> when (attacked_type) {
           Type.FIRE -> "The attack is ineffective!"
           Type.WATER -> "The attack is effective!"
           else -> "Normal attack"
        }
    }
}

class Pokemon(type: Type) {
    var type = type
    fun attack(other: Pokemon) {
        println(effectiveness(this.type, other.type))
    }
}

@r8vnhill
Copy link
Author

r8vnhill commented Jun 5, 2022

@romero-jose tienes un punto bastante importante, siempre que se utilice algún patrón de diseño (en el sentido más general, no solo OOP) hay que tener en cuenta los "costos".
Double Dispatch efectivamente tiene como efecto secundario una explosión en métodos y, si bien le da flexibilidad lo empieza a hacer extremadamente difícil de mantener cuando aumentan los casos.
La gracia de Double Dispatch (a mi parecer) sería que se puede aplicar Visitor Pattern para manejar comportamientos más complejos (y quizás aprovechar lo bien que se lleva con un Composite).
Hay otros lenguajes, pensando en Java, en los cuales tienes otras herramientas que pueden hacer esto de mejor manera.

Algunas ideas que creo que vale la pena evaluar son lenguajes con capacidad de Multiple Dispatch, utilizar programación funcional para poder asociar funciones a conjuntos de tipos, pattern matching (que creo que es lo más similar a lo que haces en tu ejemplo); me da la impresión de que usar generics o templates también podría funcionar.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment