If/then es una de las estructuras de control básicas de la programación, pero su abuso puede multiplicar la complejidad de un algoritmo y la dificultad de mantenerlo. En este artículo exploraremos alguna estrategias para usar los if con cabeza.
Para explicar la idea central de este artículo voy a poner un ejemplo un poco extremo.
Imagina una persona vegana que va a un restaurante a comer. Entra, estudia la carta y hace el pedido. Al rato llega el primer plato: alitas de pollo, así que no lo come (recuerda, es vegana). De segundo, entrecot de ternera, que también devuelve sin tocar. Finalmente, el postre, flan de huevo, es también un plato que una vegana estricta no puede comer. Paga la cuenta y se va.
¿Te parece un patrón razonable? La verdad es que no.
¿Dónde está el problema?
En una primera respuesta diríamos que. para ser una persona vegana, su comportamiento no tiene sentido y lo que hace es tirar el dinero, aunque al no comer ninguno de los platos seleccionados ha sido coherente con sus ideas. En otras palabras: ha hecho lo que cabía esperar, pero ha sido de una forma muy rebuscada.
Otra forma de verlo es decir que esta persona podría haber tomado sus decisiones sobre la comida mucho antes, teniendo en cuenta su preferencia vegana.
Así, de haber escogido un restaurante vegano para empezar, podría haber comido cualquier plato de la carta sin dudar. Incluso, de haber entrado en un restaurante no vegano, podría haber escogido platos aceptables.
Sin embargo es como si no tomase en cuenta su rasgo vegano hasta el último momento, al decidir si ese plato de comida es adecuado para comer o no solo cuando lo tiene delante.
El tipo como antipatrón
En general, esta pauta de actuación la consideraríamos un antipatrón. Al menos, en el entorno social.
Sin embargo, cuando programamos es frecuente que caigamos en él:
//...
$someObject = new Object();
if ($someObject->getType() === 'someType') {
$someObject->doThis();
} else {
$someObject->doThat();
}
//...
De hecho, este antipatrón tiene un refactor y otro
Veamos el problema.
Es frecuente que un concepto tenga tipos. Siguiendo con el ejemplo inicial, las personas podrían clasificarse en varios grupos con respecto a la dieta: omnívoras, vegetarianas, veganas y otros en base a las diversas alergias alimentarias o condiciones médicas que puedan padecer.
Inicialmente, podríamos modelar esto con un campo, suponiendo que Diet es definido como un Value Object:
class Person
{
/** @var Diet **/
private $diet;
public function __construct(..., Diet $diet)
{
//...
$this->diet = $diet;
//...
}
public function diet(): Diet
{
return $this->diet;
}
}
Por supuesto, es una manera de modelarlo, como podría haber otras.
Ahora, imaginemos que algún comportamiento de Person
tiene alguna relación con su diet
, por ejemplo, eat
.
Podemos tratarlo así, como hicimos antes:
// ...
$someFood = new Food('vegetables');
if (($person->diet() === new Diet('vegan') || $person->diet() === new Diet('veggie'))
&& $someFood->equals(new Food('vegetables'))) {
$person->eat($someFood);
} else {
//...
}
Cada nuevo tipo de dieta y alimentos supone introducir nuevas combinaciones de condiciones y si ahora resulta complicado de leer, te puedes imaginar lo que sería este código aplicado, por ejemplo, a una aplicación de gestión de comidas en un colegio o en un hospital. La complejidad ciclomática se dispara porque acabará habiendo decenas de ramas en el árbol de decisión.
En realidad, podríamos hacerlo de otra forma:
// ...
class Person
{
//...
public function eat(Food $food)
{
if (($this->diet() === new Diet('vegan') || $this->diet() === new Diet('veggie'))
&& !$food->equals(new Food('vegetables'))) {
throw new InvalidFood('vegetables');
} else {
//...
}
}
//...
}
$someFood = new Food('vegetables');
$person->eat($someFood);
Hemos movido la toma de decisiones a la clase Person
, lo que hace el código principal más legible y eso es bueno. También nos ayuda un poco a introducir excepciones, lo que también es bueno.
Pero seguimos teniendo el mismo problema de complejidad y la raíz del mismo es que aplazamos hasta el último momento una decisión basada en una propiedad del objeto que se establece desde su creación.
Lo que ocurre es que esta propiedad define tipos de Person
, al menos en relación con la interfaz relativa a eat
, y cada uno de esos tipos tiene un comportamiento diferente (en este caso qué alimentos puede comer o no).
Cuando los tipos son subclases
Una forma de expresar esto en código es mediante herencia.
Por ejemplo:
class Vegan extends Person
{
public function eat(Food $food)
{
if (!$food->equals(new Food('vegatables'))) {
throw new InvalidFood('Vegans only eat vegetables.');
}
parent::eat($food);
}
}
class Celiac extends Person
{
public function eat(Food $food)
{
if ($food->has('gluten')) {
throw new InvalidFood('Celiacs can\'t eat foot containing gluten.');
}
parent::eat($food);
}
}
Esto es, eliminamos la propiedad que define el tipo y creamos subclases de la clase base para representar todas sus variantes de comportamiento.
De este modo, la complejidad de las decisiones se reduce ya que cada subclase lidia con sus propios criterios al respecto de la comida.
Sin embargo, esta solución nos plantea el problema de instanciar el objeto concreto, algo que podemos resolver mediante una factoría o incluso un método factoría:
class Person
{
static public function createFromDiet(Diet $diet, /** extra data **/): Person
{
if ($diet === new Diet('vegan')) {
return new Vegan(/** extra data **/);
}
if ($diet === new Diet('celiac')) {
return new Celiac(/** extra data **/);
}
// ...
}
}
De manera que podríamos usarlo así:
$person = Person::createFromDiet($diet, /** extra data **/);
$person->eat($someFood);
La belleza de la composición
Cuando el concepto con el que estamos trabajando tiene un solo eje de clasificación en tipos la extensión en subclases mediante herencia puede ser una buena solución, aunque tiene inconvenientes. Por ejemplo, la violación del principio de Segregación de Dependencias. Al hacer subclases solo porque necesitamos sobreescribir uno ó dos métodos también nos llevamos todo el resto de comportamiento que posiblemente sea irrelevante en el rasgo concreto de comportamiento que estamos atacando.
Además, podría ocurrir que haya más dimensiones en las que clasificar nuestro concepto, lo que supondría complicar mucho nuestra familia de clases.
Así que planteémoslo de otra manera.
Para empezar, delimitamos con una interfaz la dimensión sobre la que vamos a clasificar nuestro concepto base:
interface CanEat
{
public function eat($food);
}
Esta interfaz será implementada tanto por nuestra clase base como por los decoradores con los que vamos a modelar los diferentes tipos.
class Person implements CanEat
{
//...
public function eat(Food $food)
{
//...
}
}
class Vegan implements CanEat
{
private $person;
public function __construct(Person $person)
{
$this->person = $person;
}
public function eat(Food $food)
{
if (!$food->equals(new Food('vegatables'))) {
throw new InvalidFood('Vegans only eat vegetables.');
}
$this->person->eat($food);
}
}
class Celiac implements CanEat
{
private $person;
public function __construct(Person $person)
{
$this->person = $person;
}
public function eat(Food $food)
{
if ($food->has('gluten')) {
throw new InvalidFood('Celiacs can\'t eat foot containing gluten.');
}
$this->person->eat($food);
}
}
¿Y qué hay de su creación?
class Person implements CanEat
{
static public function createFromDiet(Diet $diet, /** extra data **/): CanEat
{
$person = new static(/** extra data**/);
if ($diet === new Diet('vegan')) {
return new Vegan($person);
}
if ($diet === new Diet('celiac')) {
return new Celiac($person);
}
// ...
}
}
Como resultado, tenemos un objeto que implementa la interfaz CanEat.
$personWithDiet = Person::createFromDiet($diet, /** extra data **/);
$personWithDiet->eat($someFood);
Según nuestras necesidades podríamos hacer que los decoradores implementasen la totalidad de la interfaz de Person, o combinar decoradores según distintas interfaces.
Es posible programar sin if
Bueno, realmente no es posible programar sin if. Todos los programas útiles conllevan algún tipo de toma de decisión, pero cuando se trata de programación orientada a objetos, lo ideal es que esas decisiones vayan implícitas en la naturaleza de cada objeto en la medida de lo posible.
Al hacerlo así, reducimos la complejidad del código, a la vez que se vuelve más fácil de mantener, más extensible y más fácilmente testeable.