La paradoja de las colecciones

Capítulo que trata sobre encapsular estructuras de datos que representan colecciones de elementos en objetos que, paradójicamente, no tienen que ser colecciones en sentido estricto.

Las reglas de Object Calisthenics nos piden encapsular todas las estructuras de datos que representan alguna colección en objetos. Pero, en contra de lo que nos sugiere el nombre, estos objetos no tienen por qué ser en sí mismos colecciones. De hecho, no es especialmente recomendable que lo sean. Más bien, al contrario.

Notas de la segunda edición

Este capítulo es nuevo es esta edición.

Abstraer las estructuras de datos

Creo que fue leyendo el libro de Sandi Metz, “Practical Object-Oriented Design in Ruby”, que me llamó la atención la idea de ocultar las estructuras de datos incluso dentro de la misma clase. Es decir, si una clase tiene un atributo que es un array, no deberíamos acceder a su contenido directamente desde dentro de la clase, sino que deberíamos hacerlo a través de métodos.

 1 class Order
 2 {
 3     private $items = [];
 4 
 5     public function addItem(Item $item): void
 6     {
 7         $this->items[] = $item;
 8     }
 9     
10     public function total(): float
11     {
12         $total = 0;
13         foreach ($this->items as $item) {
14             $total += $item->price();
15         }
16         return $total;
17     }
18     
19     private function item($position int): float
20     {
21         return $this->items[$position];
22     }
23    
24 }

Esta idea es contra-intuitiva, porque nos parece lógico acceder directamente a los atributos de una clase desde dentro de ella. Pero, si lo pensamos bien, hacerlo tiene algunos problemas:

  • Para usar la estructura de datos tenemos que saber cosas sobre ella, como por ejemplo qué tipo de estructura es. Aquí es un array, pero podría ser un diccionario, una lista enlazada, un conjunto, etc. Si accedemos a la estructura de datos directamente, estamos acoplando el código que la usa a la estructura de datos concreta. Es un caso de Inappropriate Intimacy.
  • Si cambiamos la estructura de datos, tendremos que cambiar el código que la usa, lo que puede suponer un caso de Shotgun Surgery, ya que probablemente la estamos usando en muchos lugares y necesitaremos modificar todos esos usos.

Podemos evitar ambos problemas encapsulando la estructura de datos en métodos. De esta forma, si usamos otra, solo tenemos que cambiar los métodos que la encapsulan, y no el código que la usa. Lo mejor es que el objeto envoltorio puede ser cualquier cosa, no tiene por qué ser una colección. Nos basta con que responda a los mensajes que necesitemos y no es necesario implementar cada uno de los métodos que caracterizan a la mayor parte de estructuras de datos. Fíjate en el ejemplo a continuación como el método averagePrice() no tiene ni idea de la estructura subyacente.

 1 class Items
 2 {
 3     private $items = [];
 4 
 5     public function addItem(Item $item): void
 6     {
 7         $this->items[] = $item;
 8     }
 9     
10     public function averagePrice(): float
11     {
12         return $this->total() / $this->count();
13     }
14     
15     public function total(): float
16     {
17         $total = 0;
18         foreach ($this->items() as $item) {
19             $total += $item->price();
20         }
21         return $total;
22     }
23     
24     private function item($position int): float
25     {
26         return $this->items[$position];
27     }
28     
29     private function items(): array
30     {
31         return $this->items;
32     }
33     
34     private function count(): int
35     {
36         return count($this->items);
37     }
38 }

Tanto es así que los consumidores de Items no tienen ni por qué saber que se trata de una colección. Es tan solo un objeto al que podemos enviarle el mensaje total() y nos devolverá el total de los elementos que contiene.

 1 class Order
 2 {
 3     private Items $items;
 4 
 5     public function __construct(Items $items)
 6     {
 7         $this->items = $items;
 8     }
 9     
10     public function total(): float
11     {
12         return $this->items->total();
13     }
14 }   

Además del beneficio de darnos libertad para cambiar la estructura primitiva de datos sin afectar a los otros objetos consumidores, también podemos encapsular comportamiento en ese objeto. Es el caso del cálculo del total de los elementos de la colección, que hemos movido a la clase Items.

Esto ejemplifica que la clase consumidora de Items, que en nuestro ejemplo es Order, no necesita acceder a sus elementos individualmente, porque solo está interesada en agregaciones de sus datos, así que no necesita que sea una colección. En consecuencia, se simplifica el desarrollo de Order, permitiéndonos pensar en un nivel de abstracción más alto y despreocuparnos de muchos detalles. Un buen ejemplo de Tell, don’t ask, un principio de diseño que desarrollaremos en un capítulo posterior.

Por qué las colecciones no tienen que ser colecciones

Los lenguajes de programación ofrecen diversas estructuras de datos con las que representar colecciones de elementos, tales como arrays, listas, diccionarios, conjuntos, colas, pilas, etc. Cada una de estas estructuras es adecuada para distintas finalidades, y cada una tiene sus propias propiedades y comportamientos. Por ejemplo, una pila es una estructura en la que el último elemento que ha entrado es el primero en salir, mientras que en una cola es el primero en entrar el primero en salir. Un array, típicamente nos permite acceder a sus elementos por su posición, mientras que en un diccionario podemos acceder a ellos por una clave.

Ante un caso de uso concreto deberíamos utilizar la estructura que mejor se ajuste a nuestras necesidades. Puesto que esto no siempre está claro desde el principio, a veces necesitamos cambiar esta estructura. Por otro lado, cada estructura nos presenta una serie de comportamientos que no tienen la semántica adecuada a nuestro caso de uso, ni vamos a utilizar todos ellos. Esto conlleva un grado de acoplamiento entre la estructura de datos y el código que la usa, que puede ser problemático, pero que podemos evitar encapsulando la colección en un objeto con una interfaz más adecuada.

Vamos a entenderlo mejor con un ejemplo. Supongamos una aplicación de seguimiento de tareas. Podríamos tener una clase Task, como esta:

 1 class Task
 2 {
 3     private $status;
 4     private $startDate;
 5     private $doneDate;
 6 
 7     public function __construct($status, $startDate, $doneDate = null)
 8     {
 9         $this->status = $status;
10         $this->startDate = $startDate;
11         $this->doneDate = $doneDate;
12     }
13 
14     public function status()
15     {
16         return $this->status;
17     }
18 
19     public function start()
20     {
21         $this->status = 'in progress';
22         $this->startDate = new DateTime();
23     }
24 
25     public function started()
26     {
27         return $this->startDate;
28     }
29     
30     public function finish()
31     {
32         $this->status = 'done';
33         $this->doneDate = new DateTime();
34     }
35 }

Ahora, imaginemos este servicio que gestiona la colección de tareas, en este caso para decirnos las tareas que están pendientes de terminar:

 1 class TaskService
 2 {
 3     private $tasks;
 4 
 5     public function __construct(array $tasks)
 6     {
 7         $this->tasks = $tasks;
 8     }
 9 
10     public function getPendingTasks(): array
11     {
12         $pendingTasks = [];
13         foreach ($this->tasks as $task) {
14             if ($task->status() === 'in progress') {
15                 $pendingTasks[] = $task;
16             }
17         }
18         return $pendingTasks;
19     }
20 }

¿Eres capaz de ver el problema? Tal como está programado, TaskService tiene que saber muchas cosas acerca de la estructura de datos que representa la colección de tareas: que es un array, que tiene que recorrerlo entero, etc. De hecho, tiene que saber cosas de Task, y ese es otro problema de acoplamiento. Si cambiamos la estructura de datos, o la forma de acceder a los elementos, o la forma de filtrarlos, tendríamos que cambiar TaskService, lo que sería un caso de Shotgun Surgery.

Podríamos cambiar la implementación para adoptar un método más funcional, usando array_filter, encapsulando la lógica de filtrado en una función anónima. El resultado es un código más conciso. Pero, aun así, TaskService sigue teniendo el mismo acoplamiento y se ve obligado a aportarle comportamiento a la estructura de datos y a mantener conocimiento sobre ella.

 1 class TaskService
 2 {
 3     private array $tasks;
 4 
 5     public function __construct(array $tasks)
 6     {
 7         $this->tasks = $tasks;
 8     }
 9 
10     public function getPendingTasks(): array
11     {
12         return array_filter($this->tasks, function ($task) {
13             return $task->status() === 'in progress';
14         });
15     }
16 }

Nosotras lo que queremos es que TaskService sepa lo mínimo necesario. Así, lo único que debería saber TaskService sobre $tasks es que es un objeto al que le puede preguntar por las tareas pendientes. No debería saber que están organizadas en un array, ni la forma en que se filtran las tareas pendientes. De hecho, no debería saber ni siquiera que es una colección. Solo debería saber que es un objeto que responde a un mensaje pendingTasks() con otra colección que solo incluye las tareas en progreso.

 1 class TaskCollection
 2 {
 3     private array $tasks;
 4 
 5     public function __construct(array $tasks)
 6     {
 7         $this->tasks = $tasks;
 8     }
 9 
10     public function pendingTasks(): TaskCollection
11     {
12         $inProgress = array_filter($this->tasks, function ($task) {
13             return $task->status() === 'in progress';
14         });
15         
16         return new TaskCollection($inProgress) ;
17     }
18 }

Ahora, TaskService solo tiene que saber que TaskCollection tiene un método pendingTasks(). Si cambiásemos la estructura de datos subyacente por un hash, o cambiamos la forma de filtrar las tareas pendientes, o cualquier otra cosa, TaskService seguiría funcionando igualmente. El resultado es que TaskService se simplifica hasta el punto de tener una lógica trivial. La complejidad de la lógica de filtrado se ha trasladado a TaskCollection, que es donde debería estar.

 1 class TaskService
 2 {
 3     private TaskCollection $tasks;
 4 
 5     public function __construct(TaskCollection $tasks)
 6     {
 7         $this->tasks = $tasks;
 8     }
 9 
10     public function pendingTasks(): TaskCollection
11     {
12         return $this->tasks->pendingTasks();
13     }
14 }

Como puedes ver TaskCollection representa una colección, pero en sí misma no es una colección. En lugar de exponer comportamientos genéricos de una colección, lo que nos presenta son métodos adecuados al dominio de la aplicación. En este caso, pendingTasks(), que es un concepto importante en una aplicación de gestión de tareas. A esto nos referíamos al principio cuando decíamos que las colecciones no tienen por qué ser colecciones, aunque se implementen usando estructuras de datos de colección.

Beneficios de la encapsulación de colecciones

Ahora, imaginemos que queremos implementar una funcionalidad pickNext. La idea es que nos entrega la siguiente tarea que deberíamos estar realizando, para evitarnos tener que decidir. Al principio podríamos implementarlo simplemente definiendo la próxima tarea como la que haya entrado primero en la lista. Esto nos sugiere usar una cola. En PHP no tenemos colas nativas, pero podemos usar un array como cola. La función array_shift sirve para sacar el primer elemento de la cola.

 1 class TaskCollection
 2 {
 3     private array $tasks;
 4 
 5     public function __construct(array $tasks)
 6     {
 7         $this->tasks = $tasks;
 8     }
 9 
10     public function pendingTasks(): TaskCollection
11     {
12         $inProgress = array_filter($this->tasks, function ($task) {
13             return $task->status() === 'in progress';
14         });
15         
16         return new TaskCollection($inProgress) ;
17     }
18     
19     public function pickNext(): Task
20     {
21         return array_shift($this->tasks);
22     }
23 }

Ahora bien, más adelante queremos añadir una prestación con la que asignar prioridades a las tareas y, por tanto, cambiar el modo en que se seleccionan. Esto introduce nuevas reglas de negocio, que podríamos definir:

  • La próxima tarea será la de prioridad más alta
  • En casa de que haya varias tareas con la misma prioridad, se escogerá la que lleve más tiempo

Así que tenemos dos reglas que no cooperan mucho entre ellas. Ya no nos basta con utilizar una estructura de cola. Pero no es problema: al estar encapsulado, el código consumidor solo tiene que saber que el mensaje pickNext devolverá la Task adecuada según las nuevas reglas. En el siguiente ejemplo, hemos usado usort para ordenar las tareas según las reglas de negocio que hemos definido.

 1 class TaskCollection
 2 {
 3     private array $tasks;
 4 
 5     public function __construct(array $tasks)
 6     {
 7         $this->tasks = $tasks;
 8     }
 9 
10     public function pendingTasks(): TaskCollection
11     {
12         $inProgress = array_filter($this->tasks, function ($task) {
13             return $task->status() === 'in progress';
14         });
15         
16         return new TaskCollection($inProgress) ;
17     }
18     
19     public function pickNext(): Task
20     {
21         usort($this->tasks, function ($a, $b) {
22             if ($a->priority() === $b->priority()) {
23                 return $a->added() <=> $b->added();
24             }
25             return $a->priority() <=> $b->priority();
26         });
27         
28         return array_shift($this->tasks);
29     }
30 }

Resumen

Encapsular colecciones en objetos nos permite ocultar los detalles de implementación y las estructuras de datos, exponiendo solo comportamientos que sean importantes para los consumidores de esos objetos. Esto nos da libertad para cambiar la estructura de datos sin afectar a los objetos consumidores, y nos permite encapsular comportamiento en esos objetos, simplificando el desarrollo y reduciendo la fuerza del acoplamiento.