Acondiciona las condicionales

En el que decidimos cómo hacer que las decisiones que el código toma sean más comprensibles y fáciles de mantener en el futuro, porque al fin y a la postre todo en esta vida es decidir. El caso es saber cuando tomar las decisiones y comprender bien sus condiciones y sus consecuencias.

Notas de la segunda edición

En este capítulo, como en otros, hemos mejorado los ejemplos y corregido algunas explicaciones para que sean más claras. Ahora la mayor parte de ejemplos tienen más sentido y ofrecen más contexto. Asímismo hemos incluido algunas propuestas nuevas.

La complejidad de decidir

Es bastante obvio que si hay algo que añade complejidad a un software es la toma de decisiones y, por tanto, las estructuras condicionales con las que la expresamos.

Estas estructuras pueden introducir dificultades de comprensión debido a varias razones:

  • La complejidad de las expresiones evaluadas, sobre todo cuando se combinan mediante operadores lógicos tres o más condiciones, lo que las hace difíciles de procesar para nosotras.
  • La anidación de estructuras condicionales y la concatenación de condicionales mediante else, introduciendo múltiples flujos de ejecución.
  • El desequilibrio entre las ramas en las que una rama tiene unas pocas líneas frente a la otra que esconde su propia complejidad, lo que puede llevar a pasar por alto la rama corta y dificulta la lectura de la rama larga al introducir un nuevo nivel de indentación.

¿Cuándo refactorizar condicionales?

En general, como regla práctica, hay que refactorizar condicionales cuando su lectura no nos deja claro cuál es su significado. Esto se aplica en las dos partes de la estructura:

  • La expresión condicional: qué define lo que tiene que pasar para que el flujo se dirija por una o por otra rama.
  • Las ramas: las diferentes acciones que se deben ejecutar en caso de cumplirse o no la condición.

También podemos aplicar alguna de las reglas de object calisthenics:

Aplanar niveles de indentación: cuanto menos anidamiento en el código, más fácil de leer es porque indica que no estamos mezclando niveles de abstracción. El objetivo sería tener un solo nivel de indentación en cada método o función.

Eliminar else: en muchos casos, es posible eliminar ramas alternativas, bien directamente, bien encapsulando toda la estructura en un método o función.

Vamos a ver como podemos proceder a refactorizar condicionales de una forma sistemática.

La rama corta primero

Si una estructura condicional nos lleva por una rama muy corta en caso de cumplirse y por una muy larga en el caso contrario, se recomienda que la rama corta sea la primera, para evitar que pase desapercibida. Por ejemplo, este fragmento tan feo:

 1 function obtainPaymentMethod(Order $order): PaymentMethod {
 2     $selectedPaymentMethod = $order->getSelectedPaymentMethod();
 3     if ($selectedPaymentMethod === null) {
 4         $logger = Logger::getInstance();
 5         $logger->debug("Medio de pago desconocido");
 6         if ($order->getDestinationCountry() == Country::FRANCE && $orde\
 7 r->id() < 745) {
 8             $paymentMethod = new PaypalPaymentMethod();
 9         } else {
10             $paymentMethod = new DefaultPaymentMethod();
11         }
12     } else {
13         $paymentMethod = $selectedPaymentMethod;
14     }
15     
16     return $paymentMethod;
17 }

Podría reescribirse así y ahora es más fácil ver la rama corta. Esto nos va a encaminar hacia otras refactorizaciones que veremos más adelante:

 1 function obtainPaymentMethod(Order $order): PaymentMethod {
 2     $selectedPaymentMethod = $order->getSelectedPaymentMethod();
 3     if ($selectedPaymentMethod !== null) {
 4         $paymentMethod = $selectedPaymentMethod;
 5     } else {
 6         $logger = Logger::getInstance();
 7         $logger->debug("Medio de pago desconocido");
 8         if ($order->getDestinationCountry() == Country::FRANCE && $orde\
 9 r->id() < 745) {
10             $paymentMethod = new PaypalPaymentMethod();
11         } else {
12             $paymentMethod = new DefaultPaymentMethod();
13         }
14     }
15     
16     return $paymentMethod;
17 }

Return early

Si estamos dentro de una función o método y podemos hacer el retorno desde dentro de una rama es preferible hacerlo. Con eso podemos evitar la cláusula else y hacer que el código vuelva al nivel de indentación anterior, lo que facilitará la lectura. Veámoslo aplicado al ejemplo de arriba:

 1 function obtainPaymentMethod(Order $order): PaymentMethod {
 2     $selectedPaymentMethod = $order->getSelectedPaymentMethod();
 3     if ($selectedPaymentMethod !== null) {
 4         $paymentMethod = $selectedPaymentMethod;
 5         
 6         return $paymentMethod;
 7     } else {
 8         $logger = Logger::getInstance();
 9         $logger->debug("Medio de pago desconocido");
10         if ($order->getDestinationCountry() == Country::FRANCE && $orde\
11 r->id() < 745) {
12             $paymentMethod = new PaypalPaymentMethod();
13         } else {
14             $paymentMethod = new DefaultPaymentMethod();
15         }
16     }
17     
18     return $paymentMethod;
19 }

No hace falta mantener la variable temporal, pues podemos devolver directamente la respuesta obtenida:

 1 function obtainPaymentMethod(Order $order): PaymentMethod {
 2     $selectedPaymentMethod = $order->getSelectedPaymentMethod();
 3     if ($selectedPaymentMethod !== null) {
 4         return $selectedPaymentMethod;
 5     } else {
 6         $logger = Logger::getInstance();
 7         $logger->debug("Medio de pago desconocido");
 8         if ($order->getDestinationCountry() == Country::FRANCE && $orde\
 9 r->id() < 745) {
10             $paymentMethod = new PaypalPaymentMethod();
11         } else {
12             $paymentMethod = new DefaultPaymentMethod();
13         }
14         
15         return $paymentMethod;
16     }
17 }

Y, por último, podemos eliminar la cláusula else y dejarlo así.

 1 function obtainPaymentMethod(Order $order): PaymentMethod {
 2     $selectedPaymentMethod = $order->getSelectedPaymentMethod();
 3     if ($selectedPaymentMethod !== null) {
 4         return $selectedPaymentMethod;
 5     }
 6     
 7     $logger = Logger::getInstance();
 8     $logger->debug("Medio de pago desconocido");
 9     if ($order->getDestinationCountry() == Country::FRANCE && $order->i\
10 d() < 745) {
11         $paymentMethod = new PaypalPaymentMethod();
12     } else {
13         $paymentMethod = new DefaultPaymentMethod();
14     }
15     
16     return $paymentMethod;
17 }

Aplicando el mismo principio, reducimos la complejidad aportada por la condicional final, retornando directamente y eliminando tanto else como las variables temporales:

 1 function obtainPaymentMethod(Order $order): PaymentMethod {
 2     $selectedPaymentMethod = $order->getSelectedPaymentMethod();
 3     if ($selectedPaymentMethod !== null) {
 4         return $selectedPaymentMethod;
 5     }
 6     
 7     $logger = Logger::getInstance();
 8     $logger->debug("Medio de pago desconocido");
 9     
10     if ($order->getDestinationCountry() == Country::FRANCE && $order->i\
11 d() < 745) {
12         return new PaypalPaymentMethod();
13     }
14     
15     return new DefaultPaymentMethod();
16 }

El contexto habitual de esta técnica es la de tratar casos particulares o que sean obvios en los primeros pasos del algoritmo, volviendo al flujo principal cuanto antes, de modo que solo recibe aquellos casos a los que se aplica realmente. Una variante de esta idea consiste en la introducción de las cláusulas de guarda, que veremos a continuación.

Cláusulas de guarda

En muchas ocasiones, cuando los datos tienen que ser validados antes de operar con ellos, podemos encapsular esas condiciones en forma de cláusulas de guarda. Estas cláusulas de guarda, también se conocen como aserciones, o precondiciones. Si los parámetros recibidos no las cumplen, el método o función falla, generalmente, lanzando excepciones.

1 public function applyDiscount($parameter)
2 {
3     if ($parameter > 100 || $parameter < 0) {
4         throw new OutOfRangeException(sprintf('Parameter should be betw\
5 een 0 and 100 (inc), %s provided.', $parameter));
6     }
7     
8     // further processing
9 }

Extraemos toda la estructura a un método privado:

 1 public function applyDiscount($parameter)
 2 {
 3     $this->checkParameterIsInRange($parameter);
 4         
 5     // further processing
 6 }
 7 
 8 private function checkParameterIsInRange($parameter) 
 9 {
10     if ($parameter > 100 || $parameter < 0) {
11         throw new OutOfRangeException(sprintf('Parameter should be betw\
12 een 0 and 100 (inc), %s provided.', $parameter));
13     }
14 }

La lógica bajo este tipo de cláusulas es que si no salta ninguna excepción, quiere decir que $parameter ha superado todas las validaciones y lo puedes usar con confianza. La ventaja es que las reglas de validación definidas con estas técnicas resultan muy expresivas, ocultando los detalles técnicos en los métodos extraídos.

Una alternativa es usar una librería de aserciones, lo que nos permite hacer lo mismo de una forma aún más limpia y reutilizable. Si la aserción no se cumple, se tirará una excepción:

1 public function applyDiscount($parameter)
2 {
3     Assert::betweenExclusive($parameter, 0, 100);
4         
5     // further processing
6 }

Una limitación de las aserciones que debemos tener en cuenta es que no sirven para control de flujo. Esto es, las aserciones fallan con una excepción, interrumpiendo la ejecución del programa, que así puede comunicar al módulo llamante una circunstancia que impide continuar. Por eso, las aserciones son ideales para validar precondiciones.

En el caso de necesitar una alternativa si el parámetro no cumple los requisitos, utilizaremos condicionales. Por ejemplo, si el parámetro excede los límites queremos que se ajuste al límite que ha superado, en vez de fallar:

 1 $parameter = $this->checkTheParameterIsInRange($parameter);
 2 
 3 // further processing
 4 
 5 private function checkTheParameterIsInRange(int $parameter)
 6 {
 7     if ($parameter > 100) {
 8         return 100;
 9     }
10     
11     if ($parameter < 0) {
12         return 0;
13     }
14     
15     return $parameter;
16 }

Finalmente, ten en cuenta que el tipado ya es una guarda en sí misma, por lo que no necesitas verificar el tipo de un parámetro si ya lo has tipado correctamente.

Preferir condiciones afirmativas

Diversos estudios han mostrado que las frases afirmativas son más fáciles de entender que las negativas, por lo que siempre que sea posible deberíamos intentar convertir la condición en afirmativa ya sea invirtiéndola, ya sea encapsulándola de modo que se exprese de manera afirmativa. Con frecuencia, además, la particular sintaxis de la negación puede hacerlas poco visibles:

1 if (!$selectedPaymentMethod) {
2     return $selectedPaymentMethod;
3 } 

En uno de los ejemplos anteriores habíamos llegado a la siguiente construcción invirtiendo la condicional lo que resultó en una doble negación que puede hacerse difícil de leer:

1 if (null !== $selectedPaymentMethod) {
2     return $selectedPaymentMethod;
3 } 

Nosotros lo que queremos es devolver el método de pago en caso de tener uno seleccionado:

1 if ($selectedPaymentMethod) {
2     return $selectedPaymentMethod;
3 } 

Una forma alternativa, si la condición es compleja o simplemente difícil de entender tal cual es encapsularla en un método:

1 if ($this->userHasSelectedAPaymentMethod($selectedPaymentMethod)) {
2     return $selectedPaymentMethod;
3 } 
4 
5 function userHasSelectedAPaymentMethod($selectedPaymentMethod)
6 {
7     return null !== $selectedPaymentMethod;
8 }

Encapsula expresiones complejas en métodos o funciones

La idea es encapsular expresiones condicionales complejas en funciones o métodos, de modo que su nombre describa el significado de la expresión condicional, manteniendo ocultos los detalles escabrosos de la misma. Esto puede hacerse de forma global o por partes.

Justo en el apartado anterior hemos visto un ejemplo de esto mismo, haciendo explícito el significado de una expresión condicional difícil de leer.

Veamos otro caso en el mismo ejemplo: la extraña condicional que selecciona el método de pago en función del país de destino y el número de pedido. Posiblemente, algún apaño para resolver un problema concreto en los primeros pasos de la empresa.

 1 function obtainPaymentMethod(Order $order): PaymentMethod {
 2     $selectedPaymentMethod = $order->getSelectedPaymentMethod();
 3     if ($selectedPaymentMethod !== null) {
 4         return $selectedPaymentMethod;
 5     }
 6     
 7     $logger = Logger::getInstance();
 8     $logger->debug("Medio de pago desconocido");
 9     
10     if ($order->getDestinationCountry() == Country::FRANCE && $order->i\
11 d() < 745) {
12         return new PaypalPaymentMethod();
13     }
14     
15     return new DefaultPaymentMethod();
16 }

Se podría encapsular la condición en un método que sea un poco más explicativo:

 1 function obtainPaymentMethod(Order $order): PaymentMethod {
 2     $selectedPaymentMethod = $order->getSelectedPaymentMethod();
 3     if ($selectedPaymentMethod !== null) {
 4         return $selectedPaymentMethod;
 5     }
 6     
 7     $logger = Logger::getInstance();
 8     $logger->debug("Medio de pago desconocido");
 9     
10     if ($this->isLegacyOrderWithDestinationFrance($order)) {
11         return new PaypalPaymentMethod();
12     }
13     
14     return new DefaultPaymentMethod();
15 }

Y el método encapsulado sería algo así:

1 private function isLegacyOrderWithDestinationFrance($order)
2 {
3     return $order->getDestinationCountry() == Country::FRANCE && $order\
4 ->id() < 745;
5 }

Encapsula ramas en métodos o funciones

Consiste en encapsular todo el bloque de código de cada rama de ejecución en su propio método, de modo que el nombre nos indique qué hace. Esto nos deja las ramas de la estructura condicional al mismo nivel y expresando lo que hacen de manera explícita y global. En los métodos extraídos podemos seguir aplicando refactors progresivos hasta que ya no sea necesario.

Este fragmento de código, que está bastante limpio, podría clarificarse un poco, encapsulando tanto las condiciones como la rama:

 1 if ($productStatus == OrderStatuses::PROVIDER_PENDING ||
 2     $productStatus == OrderStatuses::PENDING ||
 3     $productStatus == OrderStatuses::WAITING_FOR_PAYMENT
 4 ) {
 5     if ($paymentMethod == new BankTransferPaymentMethod()) {
 6         return 'pendiente de transferencia';
 7     }
 8     if ($paymentMethod == new PaypalPaymentMethod() || $paymentMethod =\
 9 = new CreditCardPaymentMethod()) {
10         return 'pago a crédito';
11     }
12     if ($this->paymentMethods->hasSelectedDebitCard()) {
13         return 'pago a débito';
14     }
15     if (!$this->paymentMethods->requiresAuthorization()) {
16         return 'pago no requiere autorización';
17     }
18 }

Veamos como:

 1 if ($this->productIsInPendingStatus($productStatus)) {
 2     return $this->reportForProductInPendingStatus($paymentMethod);
 3 }
 4 
 5 private function productIsInPendingStatus($productStatus)
 6 {
 7     return ($productStatus == OrderStatuses::PROVIDER_PENDING ||
 8     $productStatus == OrderStatuses::PENDING ||
 9     $productStatus == OrderStatuses::WAITING_FOR_PAYMENT);
10 }
11 
12 private function reportForProductInPendingStatus(paymentMethod)
13 {
14     if ($paymentMethod == new BankTransferPaymentMethod()) {
15         return 'pendiente de transferencia';
16     }
17     if ($paymentMethod == new PaypalPaymentMethod() || $paymentMethod =\
18 = new CreditCardPaymentMethod()) {
19         return 'pago a crédito';
20     }
21     if ($this->paymentMethods->hasSelectedDebitCard()) {
22         return 'pago a débito';
23     }
24     if (!$this->paymentMethods->requiresAuthorization()) {
25         return 'pago no requiere autorización';
26     }
27 }

De ese modo, la complejidad queda oculta en los métodos y el cuerpo principal se entiende fácilmente. Ya es cuestión nuestra si necesitamos seguir el refactor dentro de los métodos privados que acabamos de crear.

Equalize branches

Si hacemos esto en todas las ramas de una condicional o de un switch las dejaremos al mismo nivel, lo que facilita su lectura.

Reemplaza if…elseif sucesivos con switch

En muchos casos, sucesiones de if o if…else quedarán mejor expresados mediante una estructura switch o match. Por ejemplo, siguiendo con el ejemplo anterior, este método que hemos extraído:

 1 private function reportForProductInPendingStatus(paymentMethod)
 2 {
 3     if ($paymentMethod == new BankTransferPaymentMethod()) {
 4         return 'pendiente de transferencia';
 5     }
 6     if ($paymentMethod == new PaypalPaymentMethod() || $paymentMethod =\
 7 = new CreditCardPaymentMethod()) {
 8         return 'pago a crédito';
 9     }
10     if ($this->paymentMethods->hasSelectedDebitCard()) {
11         return 'pago a débito';
12     }
13     if (!$this->paymentMethods->requiresAuthorization()) {
14         return 'pago no requiere autorización';
15     }
16 }

Podría convertirse en algo así:

 1 private function reportForProductInPendingStatus(paymentMethod)
 2 {
 3     switch $paymentMethod {
 4         case new BankTransferPaymentMethod():
 5             return 'pendiente de transferencia';
 6         case new PaypalPaymentMethod():
 7         case new CreditCardPaymentMethod():
 8             return 'pago a crédito';
 9     }
10     
11     if ($this->paymentMethods->hasSelectedDebitCard()) {
12         return 'pago a débito';
13     }
14     if (!$this->paymentMethods->requiresAuthorization()) {
15         return 'pago no requiere autorización';
16     }
17 }

Sustituir if por el operador ternario

A veces, aunque suelen ser pocas, un operador ternario puede ser más legible que una condicional:

 1 function selectElement(Criteria $criteria, Desirability $desirability)
 2 {
 3     $found = false;
 4     
 5     $elements = $this->getElements($criteria);
 6     
 7     foreach($elements as $element) {
 8         if (!$found && $this->isDesired($element, $desirability)) {
 9             $result = $element;
10             $found = true;
11         }
12     }
13     if (!$found) {
14         $result = null;
15     }
16     
17     return $result;
18 }

Realmente las últimas líneas pueden expresarse en una sola y queda más claro:

 1 function selectElement(Criteria $criteria, Desirability $desirability)
 2 {
 3     $found = false;
 4     
 5     $elements = $this->getElements($criteria);
 6     
 7     foreach($elements as $element) {
 8         if (!$found && $this->isDesired($element, $desirability)) {
 9             $result = $element;
10             $found = true;
11         }
12     }
13     
14     return $found ? $result : null;
15 }

El operador ternario tiene bastantes problemas, pero, en general, es una buena solución cuando queremos expresar un cálculo que se resuelve de dos maneras según una condición. Eso sí: nunca anides operadores ternarios porque su lectura entonces se complica enormemente.

De todos modos, este ejemplo concreto de código puede mejorar mucho. Si nos fijamos, el primer elemento encontrado ya nos basta como respuesta, por lo que se podría devolver inmediatamente. No necesitamos ninguna variable temporal, ni una doble condición. Este tipo de refactor lo veremos en el capítulo sobre return early.

 1 function selectElement(Criteria $criteria, Desirability $desirability)
 2 { 
 3     foreach($this->getElements($criteria) as $element) {
 4         if ($this->isDesired($element, $desirability)) {
 5             return $element;
 6         }
 7     }
 8     
 9     return null;
10 }

Solo un if por método

Christian Clausen en Five Lines of Code propone un refactor para condicionales que puede ser muy interesante. Con frecuencia, una estructura condicional indica que una función está haciendo varias cosas distintas. Por tanto, lo que propone es que cada método haga solo una cosa, y que, por tanto, solo tenga un if que sería la primera línea. Si hay más de uno, separamos en métodos distintos.

 1 function obtainPaymentMethod(Order $order): PaymentMethod {
 2     $selectedPaymentMethod = $order->getSelectedPaymentMethod();
 3     if ($selectedPaymentMethod !== null) {
 4         return $selectedPaymentMethod;
 5     }
 6     
 7     $logger = Logger::getInstance();
 8     $logger->debug("Medio de pago desconocido");
 9     
10     if ($order->getDestinationCountry() == Country::FRANCE && $order->i\
11 d() < 745) {
12         return new PaypalPaymentMethod();
13     }
14     
15     return new DefaultPaymentMethod();
16 }

Podría quedar más o menos así:

 1 function obtainPaymentMethod(Order $order): PaymentMethod {
 2     $selectedPaymentMethod = $order->getSelectedPaymentMethod();
 3     if ($selectedPaymentMethod !== null) {
 4         return $selectedPaymentMethod;
 5     }
 6     
 7     $logger = Logger::getInstance();
 8     $logger->debug("Medio de pago desconocido");
 9     
10     return obtainDefaultPaymentMethod($order);
11 }
12 
13 function obtainDefaultPaymentMethod(Order $order): PaymentMethod {
14      if ($order->getDestinationCountry() == Country::FRANCE && $order->\
15 id() < 745) {
16         return new PaypalPaymentMethod();
17      }
18     
19     return new DefaultPaymentMethod();
20 }

Resumen del capítulo

Las expresiones y estructuras condicionales pueden hacer que seguir el flujo de un código sea especialmente difícil, particularmente cuando están anidadas o son muy complejas. Mediante técnicas de extracción podemos simplificarlas, aplanarlas y hacerlas más expresivas.

Estas normas generales nos pueden ser útiles:

  • Poner la condición más corta primero.
  • Encapsular las condiciones en métodos o funciones para expresar la intención.
  • Encapsular las ramas en métodos o funciones que expresen la intención y, de paso, simplificar la estructura.
  • Evitar else cuando sea posible.
  • Expresar las condiciones de forma afirmativa.