Confía en los objetos. Tienen derecho a decidir

"Los verdaderos desarrolladores no usan if". ¿Por qué alguien querría eliminar el if? ¿Qué tiene de malo? Aquí les cuento por qué y cómo lograrlo.

Confía en los objetos. Tienen derecho a decidir

(To read this post in English, follow this link)

Real devs don't use if (los verdaderos desarrolladores no usan if). Este slogan fue mi primer contacto con 10Pines, bastante antes de mi residencia como apprentice. La primera vez que escuché la frase se me metió de lleno en la cabeza... ¿Por qué alguien querría eliminar el if? ¿Qué es lo que tiene de malo?

Mi objetivo con este post es probar por qué un verdadero desarrollador no usaría if. Para hacerlo, voy a explicar uno de los beneficios que obtenemos por usar una alternativa: dejar que el objeto tome las decisiones.

Así como suena. Los objetos son totalmente capaces de manejar la tarea, sólo necesitan que los dejen hacerlo. La clave es sacar provecho de su naturaleza polimórfica para generar mejores colaboraciones.

Un ejemplo

Veamos un ejercicio resuelto en Javascript donde vamos a reemplazar todos los if usando colaboraciones. Paso a paso iremos mejorando nuestro modelo, observando los beneficios de este método.

Esta es nuestra consigna:

Hacer un modelo de los números en POO. Considerar la existencia tanto de Enteros como de Fracciones. Los números deben responder adecuadamente a los mensajes de suma y resta.

Nuestra tarea parece ser sencilla, al fin y al cabo, todos sabemos sumar y restar. Asimismo, vamos a focalizarnos en el modelo más que en la aritmética.

Primer iteración

Analicemos un poco el problema. Nuestro objetivo es hacer un modelo que contemple distintos tipos de números (Enteros y Fracciones) permitiendo operaciones entre ellos. Dicho modelo debe admitir, por ejemplo:

  • 1 + 1 = 2
  • ½ - ⅓ = ⅙
  • 1 - ½ = ½
  • ⅔ + ⅓ = 1

Algo importante a tener en cuenta es que el receptor del mensaje puede ser cualquier tipo de número. Lo mismo sucede con el colaborador externo. Por lo tanto, no podemos usar siempre el mismo algoritmo, sino que debemos adaptarlo para cada caso. Por ejemplo: sumar dos enteros es una operación directa. En cambio, sumar un entero a una fracción no es tan simple. Tenemos que hacer algo extra para resolver esa operación (multiplicar el entero por el denominador de la fracción, etc.). Si operamos entre dos fracciones, debemos buscar común denominador. Como vemos, existen distintos algoritmos para cada combinación, y necesitamos uno por caso.

Teniendo en cuenta todo esto podemos comenzar a modelar. Suena lógico diseñar un diagrama de clases como este:

diagrama-post-migue

La clase Numero es abstracta. Fraccion y Entero deben saber cómo sumar o restar cualquier número. Pero, como mencionamos antes, el mecanismo de esas operaciones va a variar dependiendo del sub-tipo numérico. Una primer iteración podría ser la siguiente:

const responsabilidadDeSubclase = () => {
  throw "Debe ser implementado por la subclase";
};

class Numero {
  constructor(valor) {
    this._valor = valor;
  }
  
  sumar(unSumando) {
    responsabilidadDeSubclase();
  }
  
  restar(unSustraendo) {
    responsabilidadDeSubclase();
  }
}

class Entero extends Numero {
  sumar(unSumando) {
    if (unSumando instanceof Entero) {
      // retornar la suma considerando un entero
    } else if (unSumando instanceof Fraccion) {
      // retornar la suma considerando la fracción
    } else {
      throw "Tipo de numero inesperado";
    }
  }

  restar(unSustraendo) {
    if (unSustraendo instanceof Entero) {
      // retornar la resta de otro entero
    } else if (unSustraendo instanceof Fraccion) {
      // retornar la resta considerando la fracción
    } else {
      throw "Tipo de numero inesperado";
    }
  }
}

class Fraccion extends Numero {
  sumar(unSumando) {
    if (unSumando instanceof Entero) {
      // retornar la suma de otro entero
    } else if (unSumando instanceof Fraccion) {
      // retornar la suma considerando la fracción
    } else {
      throw "Tipo de numero inesperado";
    }
  }

  restar(unSustraendo) {
    if (unSustraendo instanceof Entero) {
      // retornar la resta de otro entero
    } else if (unSustraendo instanceof Fraccion) {
      // retornar la resta considerando la fracción
    } else {
      throw "Tipo de numero inesperado";
    }
  }
}

Como Numero es una clase abstracta, todos sus métodos delegan la responsabilidad de implementar a sus subclases. Todas las operaciones concretas siguen el mismo patrón: preguntan a sus colaboradores por su clase y luego hacen el cálculo correspondiente.

Esto funciona, pero comparémoslo con una solución que evita el uso de if.

Mejorando la legibilidad

El propósito del if en este caso es decidir qué hacer en base a la clase del colaborador. Como veremos, no es la única forma de hacer esto. El objeto en sí mismo no sabe la clase de su colaborador externo, pero lo que sí sabe es su propia clase. Por lo tanto, puede enviar el mensaje correcto a su colaborador con el contexto suficiente para que haga lo que tiene que hacer.
Con esta idea en mente, 1 - ⅓ = ⅔ puede ser resuelto así:

  1. Le pedimos a 1 que reste cierto número.
  2. El objeto 1 no sabe qué tipo de número es el argumento, entonces él le dice: “Hola, soy un entero, por favor restate de mi valor teniendo esto en cuenta”
  3. El ⅓ sabe cómo manejar el caso específico de sustraerse desde un entero, entonces realiza esa operación y el resultado esperado es devuelto.

Veamos cómo funciona:

class Entero extends Numero {
  sumar(unSumando) {
    unSumando.sumarDeUnEntero(this)
  }

  restar(unSustraendo) {
    unSustraendo.restarDeUnEntero(this)
  }
  
  sumarDeUnEntero(unSumando) {
    // retornar la suma de dos enteros
  }
  
  sumarDeUnaFraccion(unSumando) {
    // retornar la suma de un entero y una fraccion
  }
  
  restarDeUnEntero(unMinuendo) {
    // retornar la resta de dos enteros
  }
  
  restarDeUnaFraccion(unMinuendo) {
    // returnar la diferencia de una fraccion menos un entero
  }
}

class Fraccion extends Numero {
  sumar(unSumando) {
    unSumando.sumarDeUnaFraccion(this)
  }

  restar(unSustraendo) {
    unSustraendo.restarDeUnaFraccion(this)
  }
  
  sumarDeUnEntero(unSumando) {
    // retornar la suma de un entero y una fraccion
  }
  
  sumarDeUnaFraccion(unSumando) {
    // retornar la suma de dos fracciones
  }
  
  restarDeUnEntero(unMinuendo) {
    // retornar la diferencia de un entero menos una fraccion
  }
  
  restarDeUnaFraccion(unMinuendo) {
    // returnar la resta de dos fracciones
  }
}

Nuestra clase Numero también debe adaptarse a esta nueva implementación. Definir los métodos en la clase abstracta es muy importante, ya que ayuda a entender el modelo rápidamente facilitando expansiones.

class Numero {
  constructor(valor) {
    this._valor = valor;
  }
  
  sumar(unSumando) {
    responsabilidadDeSubclase();
  }
  
  restar(unSustraendo) {
    responsabilidadDeSubclase();
  }
  
  sumarDeUnEntero(unSumando) {
    responsabilidadDeSubclase();
  }
  
  sumarDeUnaFraccion(unSumando) {
    responsabilidadDeSubclase();
  }
  
  //{...}
}

A primera vista puede parecer difícil de entender, pero si seguimos paso a paso una colaboración sencilla, vamos a obtener lo que estábamos esperando:

new Entero(3).sumar(new Fraccion(1.5))
  1. El mensaje sumar es enviado al objeto 3 con el colaborador externo ⅕.
  2. El 3 envía el mensaje sumarDeUnEntero al ⅕ pasándose a sí mismo como colaborador.
  3. El ⅕ sabe exactamente qué hacer si debe sumarse desde un entero.
  4. El resultado correcto es devuelto.

Podemos seguir este código sin problemas utilizando la funcionalidad de “go to definition” de nuestro IDE favorito. Incluso debuggear se torna más placentero porque es lineal: sólo hay que seguir el valor de las variables, sin saltos locos ni la necesidad de recrear mentalmente las condiciones para encontrar la próxima línea de código a ser ejecutada. Y los objetos están manejando las decisiones solos.

Este último punto es muy importante. Los objetos son capaces de manejar este tipo de situaciones por sí mismos (¡y otras más complejas también!) sin la necesidad de la presencia del programador manifiesta en la forma de un if. Usando el polimorfismo a nuestro favor, podemos crear objetos capaces de hacerlo.

Claro que la dificultad del problema sigue existiendo. Necesitamos hacer las cosas bien para llegar a buen puerto. Por ejemplo, al definir el mensaje restar debemos considerar que la resta no es una operación conmutativa. Los mensajes restarDeUnEntero y restarDeUnaFraccion deben identificar al colaborador externo como minuendo y no como sustraendo. Para evitar estos problemas, el mensaje lo describe explícitamente.

Un modelo flexible

Supongamos que queremos extender el modelo agregando la clase NumeroIrracional. En la versión con if, esto se torna engorroso. Tenemos que revisar cada método en cada clase y agregar el else if correspondiente con el nuevo comportamiento (aparte de escribir la nueva clase). Encima, nada nos asegura que no nos olvidamos algún if en algún lado.

Ahora veremos cómo en la versión polimórfica podemos agregar de manera más sencilla todo el código necesario para que esto funcione, sin necesidad de modificar el código existente y siendo guiados por el propio modelo.

Para empezar, vamos a crear la clase NumeroIrracional que extiende a Numero. En principio, esta clase sólo definirá sus métodos sumar y restar.

class Irracional extends Numero {
  sumar(unSumando) {
    unSumando.sumarDeUnIrracional(this)
  }

  restar(unSustraendo) {
    unSustraendo.restarDeUnIrracional(this)
  }
  
  //{...}
}

Con esa implementación, queda claro que cualquier número debería responder a los mensajes sumarDeUnIrracional y restarDeUnIrracional, por lo cual debemos definirlos en la clase Numero.

class Numero {
 
  //{...}
  
  sumarDeUnIrracional(unSumando) {
    responsabilidadDeSubclase();
  }
  
  restarDeUnIrracional(unMinuendo) {
    responsabilidadDeSubclase();
  }
}

Si ahora corremos cualquier test usando la nueva clase, el resultado será el mismo: “Debe ser implementado por la subclase”. Notemos: el modelo no sólo nos dice qué mensaje deberíamos responder, sino quién debería responderlo. "Un Entero debería implementar el mensaje sumarUnIrracional". El modelo mismo nos guía sobre cómo debemos expandirlo. Con sólo un par de tests, podemos saber exactamente cómo hacerlo.

class Irracional extends Numero {
  sumar(unSumando) {
    unSumando.sumarDeUnIrracional(this)
  }

  restar(unSustraendo) {
    unSustraendo.restarDeUnIrracional(this)
  }
  
  sumarDeUnEntero(unSumando) {
    // retornar la suma de un entero con un irracional
  }
  
  // {...}
  
  sumarDeUnIrracional(unSumando) {
    // retornar la suma de dos irracionales
  }
  
  restarDeUnIrracional(unMinuendo) {
    // retornar la resta de dos irracionales
  }
}

Como consecuencia, todas nuestras clases necesitan saber responder los mensajes sumarDeUnIrracional y restarDeUnIrracional.

La expansión fue sencilla, no tuvimos que tocar el código existente y el modelo nos guió para hacerlo. ¿Qué más podemos pedir?

Conclusiones

Es verdad que, a primera vista, todo este esfuerzo de delegar el trabajo en otro objeto “sólo para extraer un if” parece un poco exagerado. Pero, veamos todo lo que logramos. El modelo final es robusto, sencillo de entender y fácilmente expansible. Hicimos mucho más que “sólo extraer el if”. En nuestro modelo polimórfico, los objetos son responsables de manejar los problemas y de guiarnos a la hora de extender el modelo.
El if no es “malo” en sí mismo. En algunas situaciones es inevitable e “ideal para la tarea”.

Siguiendo el ejemplo pero con el mensaje dividir, es difícil evitar el if que verifica que el divisor no es cero. Para evitar ese if deberíamos reificar el concepto de cero y hacer un mensaje específico, y eso haría lo solución muy compleja. El if en ese caso está completamente justificado.

En mi opinión, prefiero verlo como un “smell”. Cada vez que un if aparece pienso si no estoy perdiendo una oportunidad de utilizar polimorfismo y mejorar mi modelo.

Por último, me gustaría decir algo importante. En este artículo me esforcé en mostrar los beneficios de evitar el if. Pero la solución implementada parecía haber salido de la nada, nunca expliqué qué mecanismo usé para pensarla. Esto abre básicamente dos preguntas:

  • ¿Existe una heurística o algún algoritmo para eliminar el if?
  • ¿Siempre podemos reemplazar el if con polimorfismo? ¿Hay un límite?

Para los interesados en la respuesta, recomiendo ver este webinar, donde Hernán Wilkinson explica esto en profundidad.

Por último, para jugar un poco con el ejemplo propuesto, les dejo a continuación los links al repositorio de Github:

Este post fue adaptado del original por Sergio Minutoli