Y volver, volver, volver…

En el que se trata un problema que viene del principio de los tiempos de la programación, cuando teníamos cosas como GOTO, números de línea y direcciones de memoria arbitrarias a las que saltar. Pero aún nos quedan algunos hábitos relacionados con esa idea. Aparte de eso, varios otros temas relacionados con el retorno de funciones y métodos.

En el blog ya hemos hablado del patrón clásico Single Exit Point y cómo acabó derivando en single return. También algún momento de esta guía de refactor hemos hablado también del return early. Ahora vamos a retomarlos conjuntamente porque seguramente nos los encontraremos más de una vez.

Notas de la segunda edición

Este capítulo cambia de lugar porque me he dado cuenta de que es un refactor relativamente menor y rompe por la mitad los dos capítulos dedicados a la distribución de responsabilidades.

Hasta este punto del libro estamos hablando de refactorings motivados por problemas o defectos del código que podemos identificar incluso visualmente, sin preocuparnos mucho de qué hace el código relacionado. Pero los capítulos siguientes ya requieren que pensemos en cuáles son los papeles que juegan los distintos objetos en el código.

Lo primero será saber de qué estamos hablando:

Single return

Se trata de que en cada método o función solo tengamos un único return, a pesar de que el código pueda tener diversos caminos que nos permitirían finalizar en otros momentos. Obviamente, si el método solo tiene un camino posible tendrá un solo return.

1 public function isValid(string $luhnCode) : bool
2 {
3     $inverted = strrev($luhnCode);
4 
5     $oddAdded = $this->addOddDigits($inverted);
6     $evenAdded = $this->addEvenDigits($inverted);
7 
8     return ($oddAdded + $evenAdded) % 10 === 0;
9 }

Si el método tiene dos caminos, caben dos posibilidades:

En la primera, uno de los flujos se separa del principal, hace alguna cosa y vuelve de forma natural al tronco para terminar lo que tenga que hacer.

 1 public function forProduct(Client $client, Product $product)
 2 {
 3     $contract = new Contract($product);
 4     
 5     if ($client->hasBenefits()) {
 6         $contract->addBenefits($client->benefits());
 7     }
 8     
 9     $this->mailer->send($client, $contract);
10 }

Uno de los flujos se separa para resolver la tarea de una manera alternativa, por lo que podría devolver el resultado una vez obtenido. Sin embargo, si se sigue el patrón single return, hay que forzar que el flujo vuelva al principal antes de retornar.

1 private function reduceToOneDigit($double) : int
2 {
3     if ($double >= 10) {
4         $double = intdiv($double, 10) + $double % 10;
5     }
6 
7     return $double;
8 }

Si el método tiene más de dos caminos se dará una combinación de las posibilidades anteriores, es decir, algunas ramas volverán de forma natural al flujo principal y otras podrían retornar por su cuenta.

En principio, la ventaja del Single Return es poder controlar con facilidad que se devuelve el tipo de respuesta correcta, algo que sería más difícil si tenemos muchos lugares con return. Pero la verdad es que explicitando return types es algo de lo que ni siquiera tendríamos que preocuparnos.

En cambio, el mayor problema que tiene Single Return es que puede forzar la anidación de condicionales y el uso de else hasta extremos exagerados, lo que provoca que el código sea especialmente difícil de leer. Lo peor es que eso no se justifica por necesidades del algoritmo, sino por la gestión del flujo para conseguir que solo se pueda retornar en un punto.

El origen de esta práctica podría ser una mala interpretación del patrón Single Exit Point de Djkstra, un patrón que era útil en lenguajes que permitían que las llamadas a subrutinas y sus retornos pudieran hacerse a líneas o posiciones de memoria arbitrarias, con la infaustamente famosa sentencia GOTO. El objetivo de este patrón era asegurar que se entrase a una subrutina en su primera línea y se volviese siempre a la línea siguiente a la llamada.

Early return

El patrón early return consiste en salir de una función o método en cuanto sea posible, bien porque se ha detectado un problema (fail fast), bien porque se detecta un caso especial que se maneja fuera del algoritmo general o por otro motivo.

Dentro de este patrón tenemos el caso particular de las cláusulas de guarda, que validan los parámetros recibidos y lanzan una excepción si no son correctos. También se encuentran aquellos casos particulares que necesitan un tratamiento especial, pero que es breve o inmediato.

De este modo, al final nos queda el algoritmo principal ocupando el primer nivel de indentación y sin elementos que nos distraigan.

El mayor inconveniente es la posible inconsistencia que pueda darse en los diferentes returns en cuanto al tipo o formato de los datos, algo que se puede controlar fácilmente forzando un return type.

Por otra parte, ganamos en legibilidad, ya que mantenemos bajo control el anidamiento de condicionales y los niveles de indentación. Además, al tratar primero los casos especiales podemos centrar la atención en el algoritmo principal de ese método.

Hagamos un ejemplo

Este es un código que escribí hace bastantes años para implementar el algoritmo Quicksort. El código visto ahora está un poco pobre, pero me viene muy al pelo para ilustrar como refactorizar retornos y hacer código un poco más fácil de mantener.

 1 class QuickSort
 2 {
 3     public function sort(array $source)
 4     {
 5         $length = count($source);
 6         if ($length > 1) {
 7             $pivot = $this->median($source);
 8             $equal = $less = $greater = [];
 9             for ($i = 0; $i < $length; $i++) {
10                 if ($source[$i] == $pivot) {
11                     $equal[] = $source[$i];
12                 } elseif ($source[$i] < $pivot) {
13                     $less[] = $source[$i];
14                 } else {
15                     $greater[] = $source[$i];
16                 }
17             }
18             $sorted = array_merge($this->sort($less), $equal, $this->so\
19 rt($greater));
20         } else {
21             $sorted = $source;
22         }
23 
24         return $sorted;
25     }
26 
27     private function median($source)
28     {
29         $points = [];
30         for ($i = 0; $i < 3; $i++) {
31             $point = array_splice($source, rand(0, count($source) - 1),\
32  1);
33             $points[] = array_shift($point);
34         }
35 
36         return array_sum($points) - max($points) - min($points);
37     }
38 }

El primer paso es invertir la condicional, para ver la rama más corta en primer lugar. Se aprecia claramente que cada una de las ramas implica una forma diferente de calcular la misma variable, que es lo que se va a devolver al final. El else se introduce porque no queremos que el flujo pase por el bloque grande si $source tiene un único elemento o ninguno, ya que no tendríamos necesidad de ordenarlo.

 1 public function sort(array $source)
 2 {
 3     $length = count($source);
 4     if ($length <= 1) {
 5         $sorted = $source;
 6     } else {
 7         $pivot = $this->median($source);
 8         $equal = $less = $greater = [];
 9         for ($i = 0; $i < $length; $i++) {
10             if ($source[$i] == $pivot) {
11                 $equal[] = $source[$i];
12             } elseif ($source[$i] < $pivot) {
13                 $less[] = $source[$i];
14             } else {
15                 $greater[] = $source[$i];
16             }
17         }
18         $sorted = array_merge($this->sort($less), $equal, $this->sort($\
19 greater));
20     }
21 
22     return $sorted;
23 }

Por esa razón, podríamos simplemente finalizar y retornar el valor de $source como que ya está ordenado cuando solo hay un elemento. Al hacer esto, también podemos eliminar el uso de la variable temporal $sorted que resulta innecesaria y suprimir la cláusula else porque ya hemos retornado en la primera rama.

 1 public function sort(array $source)
 2 {
 3     $length = count($source);
 4     if ($length <= 1) {
 5         return $source;
 6     }
 7 
 8     $pivot = $this->median($source);
 9     $equal = $less = $greater = [];
10     for ($i = 0; $i < $length; $i++) {
11         if ($source[$i] == $pivot) {
12             $equal[] = $source[$i];
13         } elseif ($source[$i] < $pivot) {
14             $less[] = $source[$i];
15         } else {
16             $greater[] = $source[$i];
17         }
18     }
19     
20     return array_merge($this->sort($less), $equal, $this->sort($greater\
21 ));
22 }

Con este arreglo el código ya mejora mucho su legibilidad gracias a que despejamos el terreno tratando el caso especial y dejando el algoritmo principal limpio.

Pero vamos a ir un paso más allá. El bucle for contiene una forma velada de single return en forma de estructura if...else que voy a intentar explicar.

El algoritmo quicksort se basa en hacer pivotar los elementos de la lista en torno a su mediana, es decir, al valor que estaría exactamente en la posición central de la lista ordenada. Para ello, se calcula la mediana de forma aproximada y se van comparando los números para colocarlos en la mitad que les toca: bien por debajo o bien por encima de la mediana.

Para eso se compara cada número con el valor mediano para ver sucesivamente si es igual, menor o mayor, con lo que se añade a la sublista correspondiente y se van ordenando esas sublistas de forma recursiva.

En este caso las cláusulas else tienden a hacer más difícil la lectura y, aunque la semántica es correcta, podemos hacerlo un poco más claro.

Como ya sabrás, podemos forzar la salida de un bucle con continue:

 1 public function sort(array $source)
 2 {
 3     $length = count($source);
 4     if ($length <= 1) {
 5         return $source;
 6     }
 7 
 8     $pivot = $this->median($source);
 9     $equal = $less = $greater = [];
10     for ($i = 0; $i < $length; $i++) {
11         if ($source[$i] == $pivot) {
12             $equal[] = $source[$i];
13             continue;
14         }
15 
16         if ($source[$i] < $pivot) {
17             $less[] = $source[$i];
18             continue;
19         }
20 
21         $greater[] = $source[$i];
22     }
23 
24     return array_merge($this->sort($less), $equal, $this->sort($greater\
25 ));
26 }

Y, aunque en este caso concreto no es especialmente necesario, esta disposición hace que la lectura del bucle sea más cómoda. Incluso es más fácil reordenarlo y que exprese mejor lo que hace:

 1 public function sort(array $source): array
 2 {
 3     $length = count($source);
 4     if ($length <= 1) {
 5         return $source;
 6     }
 7 
 8     $pivot = $this->median($source);
 9     $equal = $less = $greater = [];
10     for ($i = 0; $i < $length; $i++) {
11         if ($source[$i] > $pivot) {
12             $greater[] = $source[$i];
13             continue;
14         }
15 
16         if ($source[$i] < $pivot) {
17             $less[] = $source[$i];
18             continue;
19         }
20 
21         $equal[] = $source[$i];
22     }
23 
24     return array_merge($this->sort($less), $equal, $this->sort($greater\
25 ));
26 }

Cláusulas de guarda y alternativas

En el caso de las cláusulas de guarda, el early return se sustituye por el fail fast En este caso, se trata de validar los parámetros de entrada de un método o función y, si no son correctos, lanzar una excepción o devolver un valor por defecto.

1 public function add(int $a, int $b): int
2 {
3     if ($a < 0 || $b < 0) {
4         throw new InvalidArgumentException('Both numbers must be positi\
5 ve');
6     }
7 
8     return $a + $b;
9 }

Las cláusulas de guarda sirven para garantizar las precondiciones que requerimos para que el algoritmo se pueda ejecutar. Por ejemplo, en el caso anterior podríamos hacer algo como esto:

1 public functon assertPositive(int $a): void
2 {
3     if ($a < 0) {
4         throw new InvalidArgumentException('Number must be positive');
5     }
6 }

Y usarlo en la función add, en lugar de la estructura condicional.

1 public function add(int $a, int $b): int
2 {
3     assertPositive($a);
4     assertPositive($b);
5 
6     return $a + $b;
7 }

Una alternativa, por supuesto, sería usar objetos de valor para los números, que ya incluyan la validación de los valores.

 1 class PositiveNumber
 2 {
 3     private $value;
 4 
 5     public function __construct(int $value)
 6     {
 7         if ($value < 0) {
 8             throw new InvalidArgumentException('Number must be positive\
 9 ');
10         }
11 
12         $this->value = $value;
13     }
14 
15     public function value(): int
16     {
17         return $this->value;
18     }
19     
20     public function add(PositiveNumber $number): PositiveNumber
21     {
22         return new PositiveNumber($this->value() + $number->value());
23     }
24 }

Variables temporales para retornar cosas

Una variable temporal es aquella que usamos para mantener un valor que hemos calculado hasta que le damos uso. En algunos casos, una variable temporal puede ir acumulando cálculos, pero en otros casos simplemente se usa para mantener un valor que se va a devolver inmediatamente. Esta es una situación típica:

1 public function add(int $a, int $b): int
2 {
3     $result = $a + $b;
4 
5     return $result;
6 }

En este caso, la variable $result no aporta nada al código y se puede eliminar sin problemas.

1 public function add(int $a, int $b): int
2 {
3     return $a + $b;
4 }

El único inconveniente que tiene este refactor es que puede hacer que la depuración se complica un poco, pues si pones un punto de detención en la línea del return no podrás ver el valor que se va a devolver. Pero, en general, es un cambio que no afecta al código y que mejora la legibilidad, evitando introducir un símbolo que se desecha inmediatamente.

Resumen del capítulo

El patrón single return es una práctica que consiste en que cada método o función tenga un único punto de salida. Por desgracia, esto puede llevar a anidar condicionales y a hacer el código más difícil de leer.

Como alternativa, podemos refactorizar el código para que retorne lo antes posible, permitiendo que el algoritmo principal esté en el primer nivel de indentación y que los casos especiales se manejen en primer lugar. En conjunto, el código quedará más fácil de leer y de mantener.

En el caso de las cláusulas de guarda, el early return se sustituye por el fail fast En este caso, se trata de validar los parámetros de entrada de un método o función y, si no son correctos, lanzar una excepción o devolver un valor por defecto.