Polimorfismo fuera de OOP: Multimétodos en clojure

La mayoría de nosotros estamos acostumbrados a usar unos pocos lenguajes de programación en nuestro día a día, y es frecuente que sólo nos sintamos realmente cómodos en (a lo sumo) un par de ellos.

Eso hace que a la hora de plantearnos soluciones a problemas típicos podamos “tirar de recetario” (o de patrones de diseño) y escoger entre unas cuantas soluciones que sabemos que funcionan y de las que conocemos sus puntos fuertes y débiles.

Cuando empiezas a jugar con otros lenguajes, especialmente si se basan en ideas completamente diferentes a los lenguajes con los que sueles trabajar, resulta curioso ver de qué otras formas se pueden afrontar esos problemas típicos aprovechando las características propias de cada lenguaje.

En este post vamos a ver un ejemplo de cómo cambia la forma de resolver un problema cuando cambiamos el lenguaje (y el paradigma de programación).

El problema de ejemplo

Supongamos que tenemos que procesar una serie de mensajes donde todos los mensajes tienen la misma estructura pero el proceso a realizar depende del contenido del mensaje. Por ejemplo, podemos tener clientes con una categoría asociada (Oro, Plata, Bronce) y cada uno de ellos lo procesamos de manera diferente.

Una forma directa de hacer esto en C# sería así:

public void ProcessCustomer(CustomerMessage message)
{
  switch (message.CustomerCategory)
  {
    case Category.Gold: 
      ProcessGoldCustomer(message);
      break;
    case Category.Silver:
      ProcessSilverCustomer(message);
      break;
    case Category.Bronze:
      ProcessBronzeCustomer(message);
      break;
  }
}

Es fácil y sencillo, pero podríamos pensar que tener el switch resulta poco elegante o extensible y que no se ajusta mucho al Open/Closed Principle. Esto es discutible, como podéis ver en los comentarios del post sobre cómo reemplazar tipos enumerados con clases, pero supongamos que hemos decidido que realmente no nos gusta y queremos cambiarlo.

C#: polimorfismo basado en tipos

Si estamos trabajando con un lenguaje orientado a objetos, una idea que se nos viene rápido a la mente es utilizar polimorfismo basado en herencia para resolver el problema:

public interface ICustomerProcessor
{
  void Process(CustomerMessage message);
}

public class GoldCustomerProcessor : ICustomerProcessor {...}
public class SilverCustomerProcessor : ICustomerProcessor {...}
public class BronzeCustomerProcessor : ICustomerProcessor {...}

public class CustomerProcessorFactory
{
  public ICustomerProcessor Create(Category category) {...}
}

public void ProcessCustomer(CustomerMessage message)
{
  var factory = new CustomerProcessorFactory();
  var processor = factory.Create(message.CustomerCategory);
  processor.Process(message);
}

Es una solución habitual, hemos pasado el peso de la decisión a la factoría (que internamente usará un switch, un contenedor IoC, reflection o cualquier otra técnica para crear los ICustomerProcessor), y nuestro código cliente queda aislado de eso.

Otra alternativa, también frecuente, es aplicar el patrón Tester/Doer (algo similar a una cadena de responsabilidad pero sin encadenar implementaciones):

public interface ICustomerProcessor
{
  bool CanProcess(CustomerMessage message);
  void Process(CustomerMessage message);
}

public class GoldCustomerProcessor : ICustomerProcessor
{
  public bool CanProcess(CustomerMessage message)
  {
    return message.CustomerCategory == Category.Gold;
  }
  
  public void Process(CustomerMessage message) {...}
}

public class SilverCustomerProcessor : ICustomerProcessor {...}
public class BronzeCustomerProcessor : ICustomerProcessor {...}

public void ProcessCustomer(CustomerMessage message)
{
  var processor = this.processors.Single(x => x.CanProcess(message));
  processor.Process(message);
}

En este tipo de implementación evitamos la factoría y lo que hacemos en matener en la clase que contiene el método ProcessCustomer una lista de ICustomerProcessor (que, por ejemplo, podría llegarle inyectada por el constructor) y seleccionar aquel que es capaz de procesar el mensaje. Un ejemplo real de este patrón lo podemos ver en los IHandlerSelector de Castle Windsor.

Clojure: Multimétodos

Las soluciones que hemos visto antes son perfectamente válidas para C# y aprovechan las características básicas del lenguaje (polimorfismo basado en tipos, con clases e interfaces), pero cuando vamos a un lenguaje como clojure, aunque podríamos aplicar exactamente las mismas técnicas, resultarían un poco excesivas.

En clojure existe un tipo de construcción para solucionar precisamente este tipo de problema: los multimétodos.

Un multimétodo en clojure es un método que puede tener varias implementaciones y, en tiempo de ejecución, se decide qué implementación utilizar en base al valor devuelto por una función de selección.

Para definir un multimétodo, se indica su nombre y la función que se usará para discriminar cuál de las implementaciones se usará:

(defmulti process-customer 
  (fn [message] (get message :category)))

En este caso, para decidir qué implementación se usará, la función selectora consulta el valor de :category dentro del mensaje.

Si tienes unas nociones básicas de clojure, te habrás dado cuenta de que esto es poco idiomático en clojure porque lo habitual es utilizar directamente la keyword :category como función, pero para este ejemplo he preferido la versión larga por motivos de claridad.

Añadir implementaciones para cada tipo de categoría es fácil:

(defmethod process-customer :gold [message]
  (println "gold customer!"))

(defmethod process-customer :silver [message]
  (println "silver customer"))

(defmethod process-customer :bronze [message]
  (println "bronze customer"))

Para cada implementación debemos indicar el valor sobre el que discriminamos y el cuerpo de la función. Es importante tener en cuenta que podemos añadir nuevas implementaciones sin tener que tocar nada de código existente, por lo que la solución es totalmente extensible.

Conceptualmente, es muy parecido a la implementación basada en una factoría que veíamos en C#, puesto que la función selectora hace las veces de factoría y cada implementación del multimétodo se correspondería con una implementación del interface ICustomerProcessor, pero resulta mucho más tersa porque aprovechamos una construcción del lenguaje para evitar escribir mucho código de apoyo.

Los multimétodos en clojure permiten hacer unas cuantas cosas más, como discriminar sobre varios valores (mediante vectores o listas), establecer implementaciones por defecto, añadir o eliminar dinámicamente implementaciones, o definir jerarquías ad-hoc (jerarquías que sólo existen para ese multimétodo, sin necesidad de expandirlas al resto de la aplicación).

Conclusión

Lo que hemos visto es un ejemplo de cómo distintos lenguajes de programación ofrecen soluciones diferentes a un mismo problema. No se trata de decidir cuál es la mejor, cada una viene con su propio juego de limitaciones y sólo el contexto puede ayudarte a decidir eso, pero sí es un ejercicio interesante conocer otro tipo de enfoques.

Igual que en clojure los multimétodos son una solución natural al problema planteado, si hubiésemos elegido un lenguaje como Haskell o F#, podríamos haber optado por aprovechar características como los tipos suma y el pattern matching para alcanzar otro tipo de solución, con sus propias ventajas e inconvenientes.

Una de las ventajas de aprender (aunque no sea en profundidad) otros lenguajes de programación es justo ésta, aunque no lleguemos a usarlos a diario, nos permiten comprender mejor los problemas cotidianos y tener una perspectiva más amplia para resolverlos.