Polimorfismo

Donde abordamos el problema de lidiar con variaciones de comportamiento de un objeto basadas en su tipo o, en general, de un aspecto de su estado.

Notas de la segunda edición

Este capítulo no estaba en la edición original, pero hemos reunido aquí elementos procedentes de otros capítulos.

Gestionar variantes de comportamiento basadas en tipo es fácil si sabes cómo

Recordemos el problema del nombre de una persona que usamos para trabajar la introducción de conceptos formados por varios componentes. En este caso, el nombre de una persona puede estar formado por un nombre y un apellido.

 1 class SimpleName
 2 {
 3     private string $name;
 4     private string $lastName;
 5 
 6     public function __construct(string $name, string $lastName)
 7     {
 8         $this->name = $name;
 9         $this->lastName = $lastName;
10     }
11 
12     public function __toString(): string
13     {
14         return $this->name . ' ' . $this->lastName;
15     }
16 }

Un problema que te podrías encontrar es la diversidad de formatos de nombre que puedes encontrar en el mundo. Los nombres coreanos comienzan por el apellido. En USA es frecuente la inicial entre el nombre y el apellido. En España lo común es tener dos apellidos, mientras que en otros países solo se tiene uno. Una aplicación que pueda dar soporte a toda esta casuística debería tener un diseño que permita adaptarse a estos cambios.

¿Y qué ocurre si necesitamos representar el nombre coreano? Efectivamente, podemos crear un objeto KoreanName que se adapte a las necesidades de este caso:

 1 class KoreanName
 2 {
 3     private string $lastName;
 4     private string $name;
 5 
 6     public function __construct(string $lastName, string $name)
 7     {
 8         $this->lastName = $lastName;
 9         $this->name = $name;
10     }
11 
12     public function __toString(): string
13     {
14         return $this->lastName . ' ' . $this->name;
15     }
16 }

Para que los objetos sean intercambiables entre sí, deberían declarar una interfaz común. Por ejemplo, PersonName e implementar los métodos necesarios para que puedan ser usados de forma intercambiable. En este caso, es __toString():

1 interface PersonName
2 {
3     public function __toString(): string;
4 }

Modificamos SimpleName y KoreanName para que implementen la interfaz:

 1 class SimpleName implements PersonName
 2 {
 3     private string $name;
 4     private string $lastName;
 5 
 6     public function __construct(string $name, string $lastName)
 7     {
 8         $this->name = $name;
 9         $this->lastName = $lastName;
10     }
11 
12     public function __toString(): string
13     {
14         return $this->name . ' ' . $this->lastName;
15     }
16 }
 1 class KoreanName implements PersonName
 2 {
 3     private string $lastName;
 4     private string $name;
 5 
 6     public function __construct(string $lastName, string $name)
 7     {
 8         $this->lastName = $lastName;
 9         $this->name = $name;
10     }
11 
12     public function __toString(): string
13     {
14         return $this->lastName . ' ' . $this->name;
15     }
16 }

Y de este modo, podemos introducir cualquier variante de nombre que necesitemos en nuestra aplicación.

Polimorfismo

Recordemos el calculador de superficies que imaginamos en un artículo anterior, que dejamos así después de aplicar el principio Tell, Don’t Ask:

 1 class AreaCalculator
 2 {
 3     public function calculate($shape)
 4     {
 5         if ($shape instanceof Square) {
 6             return $shape->area();
 7         }
 8         
 9         if ($shape instanceof Triangle) {
10             return $shape->area();
11         }
12         
13         if ($shape instanceof Circle) {
14             return $shape->area();
15         }
16     }
17 }

Sin embargo, seguimos preguntándole cosas a las figuras geométricas. En este caso, preguntamos si son instancias de Square, Triangle o Circle, que es básicamente preguntarles por una propiedad, y es obvio que la sucesión de if es redundante porque en último les estamos pidiendo que hagan lo mismo, aunque cada una lo resuelva de forma diferente. Dado que cada figura expone un método area, podemos asumir que todas ellas son capaces de responder al mismo mensaje. Por tanto, simplifiquemos AreaCalculator:

1 class AreaCalculator
2 {
3     public function calculate($shape)
4     {
5         return $shape->area();
6     }
7 }

Y aquí podemos ver el principio Tell, Don’t Ask en acción. En lugar de preguntar a las figuras geométricas por su estado y calcular el área en otro lugar, les pedimos que lo hagan ellas mismas independientemente de su tipo. Y lo que antes era un código confuso con montones de líneas, ahora se queda en una sola.

Esta solución es posible gracias al polimorfismo. El polimorfismo es una característica de los lenguajes orientados a objetos gracias a la cual podemos enviar el mismo mensaje a diferentes objetos, para que cada uno de ellos haga lo que le pedimos a su manera particular, que desconocemos.

El problema, en nuestro caso, es que al principio teníamos objetos anémicos a los que no podíamos enviar ningún mensaje. Una vez que hemos aplicado Tell, don’t ask y hemos movido el comportamiento a los objetos, nos hemos dado cuenta de que podríamos beneficiarnos del polimorfismo.

Por otro lado, nos hemos aprovechado de la posibilidad de PHP, y de otros lenguajes, de hacer “duck typing”, gracias a lo cual el método calculate no requiere tipado. En lugar de preguntar por el tipo de objeto, simplemente le pedimos que haga algo. Si el objeto sabe hacerlo, lo hará. Si no, lanzará una excepción. En algunos lenguajes de programación tendríamos que haber declarado el tipo de $shape en el método calculate para saber que se puede llamar a area.

Examinando las regularidades de las clases Square, Triangle y Circle, podemos ver que todas ellas tienen un método area que devuelve un valor numérico. Por tanto, podemos definir una interfaz común para todas ellas:

1 interface Shape
2 {
3     public function area(): float;
4 }

Gracias a esto, AreaCalculator puede confiar en que cualquier objeto que implemente la interfaz Shape tendrá un método area que devolverá un valor numérico. En caso de que el objeto recibido no implemente la interfaz Shape, PHP lanzará una excepción antes incluso de intentar ejecutar el método calculate.

1 class AreaCalculator
2 {
3     public function calculate(Shape $shape): float
4     {
5         return $shape->area();
6     }
7 }

Una ventaja extra, como se puede ver, es que ahora AreaCalculator no tiene que preocuparse por los detalles de cada figura geométrica. Solo necesita saber que el objeto que recibe implementa la interfaz Shape y que, por tanto, tiene un método area que puede llamar. Eso hace innecesario acceder directamente a las propiedades internas de cada figura.

 1 class Square implements Shape
 2 {
 3     private $side;
 4 
 5     public function __construct($side)
 6     {
 7         $this->side = $side;
 8     }
 9 
10     public function area(): float
11     {
12         return $this->side**2;
13     }
14 }
 1 class Triangle implements Shape
 2 {
 3     private $base;
 4     private $height;
 5 
 6     public function __construct($base, $height)
 7     {
 8         $this->base = $base;
 9         $this->height = $height;
10     }
11 
12     public function area(): float
13     {
14         return ($this->base * $this->height) / 2;
15     }
16 }
 1 class Circle implements Shape
 2 {
 3     private $radius;
 4 
 5     public function __construct($radius)
 6     {
 7         $this->radius = $radius;
 8     }
 9 
10     public function area(): float
11     {
12         return pi() * $this->radius**2;
13     }
14 }

Otros escondites del polimorfismo

El tipo muchas veces viene dado por la clase de los objetos, que es lo que ocurre en nuestro ejemplo, pero en otros casos es una propiedad de una única clase.

Imagina que, en lugar del AreaCalculator del ejemplo anterior, tuviésemos algo así. Expresamos el concepto de forma geométrica mediante una clase Shape que puede decirnos su superficie. Sin embargo, cada vez que se lo pedimos tiene que preguntarse “¿de qué tipo soy?”, para saber cómo calcularla. No solo, sino que además tiene que mantener datos internos que no le corresponden, como el lado de un cuadrado, la base y la altura de un triángulo o el radio de un círculo.

 1 class Shape
 2 {
 3     private $type;
 4     private $data;
 5 
 6     public function __construct($type, $data)
 7     {
 8         $this->type = $type;
 9         $this->data = $data;
10     }
11 
12     public function area(): float
13     {
14         switch ($this->type) {
15             case 'square':
16                 return $this->data['side'] ** 2;
17             case 'triangle':
18                 return ($this->data['base'] * $this->data['height']) / \
19 2;
20             case 'circle':
21                 return pi() * $this->data['radius'] ** 2;
22             default:
23                 throw new Exception("Invalid shape type");
24         }
25     }
26 }

Se trata de un caso similar al anterior, pero en lugar de preguntar por el tipo de objeto, que es siempre el mismo, preguntamos por una propiedad interna del objeto. En este caso, el polimorfismo se esconde en la propiedad type de la clase Shape. La solución es la misma: mover el comportamiento a objetos especializados y dejar que ellos se encarguen de calcular su área.

Para hacer este cambio de manera progresiva podríamos empezar por introducir un método factoría. A continuación podríamos reemplazar los usos de new Shape() con Shape::create().

 1 class Shape
 2 {
 3     private $type;
 4     private $data;
 5 
 6     private function __construct($type, $data)
 7     {
 8         $this->type = $type;
 9         $this->data = $data;
10     }
11 
12     public static function create($type, $data): Shape
13     {
14         return new self($type, $data);
15     }
16     
17      public function area(): float
18     {
19         switch ($this->type) {
20             case 'square':
21                 return $this->data['side'] ** 2;
22             case 'triangle':
23                 return ($this->data['base'] * $this->data['height']) / \
24 2;
25             case 'circle':
26                 return pi() * $this->data['radius'] ** 2;
27             default:
28                 throw new Exception("Invalid shape type");
29         }
30     }
31 }

A continuación, podríamos crear una clase para cada tipo de forma geométrica y mover el cálculo del área a cada una de ellas. Estas clases heredan extienden Shape y sobrescriben el método area. En este caso, podemos considerar que cada una de ellas es una especialización, pero podríamos hacerlo también con una interfaz común.

 1 class Square extends Shape
 2 {
 3     private $side;
 4 
 5     public function __construct($side)
 6     {
 7         $this->side = $side;
 8     }
 9 
10     public function area(): float
11     {
12         return $this->side**2;
13     }
14 }
 1 class Triangle extends Shape
 2 {
 3     private $base;
 4     private $height;
 5 
 6     public function __construct($base, $height)
 7     {
 8         $this->base = $base;
 9         $this->height = $height;
10     }
11 
12     public function area(): float
13     {
14         return ($this->base * $this->height) / 2;
15     }
16 }
 1 class Circle extends Shape
 2 {
 3     private $radius;
 4 
 5     public function __construct($radius)
 6     {
 7         $this->radius = $radius;
 8     }
 9 
10     public function area(): float
11     {
12         return pi() * $this->radius**2;
13     }
14 }

Ahora, en el constructor estático create de Shape, podemos decidir qué tipo de objeto devolver en función del tipo de forma geométrica que queremos crear.

 1 class Shape
 2 {
 3     public static function create($type, $data): Shape
 4     {
 5         switch ($type) {
 6             case 'square':
 7                 return new Square($data['side']);
 8             case 'triangle':
 9                 return new Triangle($data['base'], $data['height']);
10             case 'circle':
11                 return new Circle($data['radius']);
12             default:
13                 throw new Exception("Invalid shape type");
14         }
15     }
16     
17     public function area(): float
18     {
19         throw new Exception("Not implemented");
20     }
21 }

Con este cambio, el código que consume Shape, recibirá un objeto del tipo correcto, y no tendrá que preocuparse por la forma geométrica que está manejando. Solo tendrá que llamar al método area y el objeto se encargará de calcularla.

Resumen del capítulo

En este capítulo hemos visto de qué manera el polimorfismo nos permite manejar de forma sencilla las variantes de comportamiento relacionadas con el tipo de los objetos, centrando nuestra atención en los Roles que desempeñan, más que en el tipo de objeto de que se trate.

Esto lleva a mover las decisiones de comportamiento de los objetos fuera de los objetos y hacia las factorías. La factoría nos proporciona el objeto con el que debemos hablar y nos garantiza que el objeto que recibimos sabe cómo responder a los mensajes que le enviamos.