La kata de Gilded Rose en Clojure

Gilded Rose es una kata pensada para practicar técnicas de refactorización. La primera vez que oí hablar de ella fue a través de Modesto San Juan (cómo no), y recientemente me he vuelto a cruzar con ella en twitter. Aprovechando que se acaba el año y apetece leer cosas fáciles, he decido jugar a implementarla con Clojure y ver si podemos sacar algo interesante del proceso.

La kata de Gilded Rose

La descripción original de la kata es la siguiente:

Hi and welcome to team Gilded Rose. As you know, we are a small inn with a prime location in a prominent city ran by a friendly innkeeper named Allison. We also buy and sell only the finest goods. Unfortunately, our goods are constantly degrading in quality as they approach their sell by date. We have a system in place that updates our inventory for us. It was developed by a no-nonsense type named Leeroy, who has moved on to new adventures. Your task is to add the new feature to our system so that we can begin selling a new category of items.



First an introduction to our system:

  • All items have a SellIn value which denotes the number of days we have to sell the item
  • All items have a Quality value which denotes how valuable the item is
  • At the end of each day our system lowers both values for every item

Pretty simple, right? Well this is where it gets interesting:

  • Once the sell by date has passed, Quality degrades twice as fast
  • The Quality of an item is never negative
  • “Aged Brie” actually increases in Quality the older it gets
  • The Quality of an item is never more than 50
  • “Sulfuras”, being a legendary item, never has to be sold or decreases in Quality
  • “Backstage passes”, like aged brie, increases in Quality as it’s SellIn value approaches; Quality increases by 2 when there are 10 days or less and by 3 when there are 5 days or less but Quality drops to 0 after the concert

We have recently signed a supplier of conjured items. This requires an update to our system:

  • “Conjured” items degrade in Quality twice as fast as normal items

Feel free to make any changes to the UpdateQuality method and add any new code as long as everything still works correctly. However, do not alter the Item class or Items property as those belong to the goblin in the corner who will insta-rage and one-shot you as he doesn’t believe in shared code ownership (you can make the UpdateQuality method and Items property static if you like, we’ll cover for you).



Just for clarification, an item can never have its Quality increase above 50, however “Sulfuras” is a legendary item and as such its Quality is 80 and it never alters.

Esta descripción viene acompañada de una espantosa implementación que es la que tenemos que refactorizar para poder implementar el nuevo requisito (el de los Conjured items). Básicamente, la miga está en el método UpdateQuality que actualiza el valor de Quality en cada Item en base a las reglas expuestas anteriormente.

La implementación original es en C#, pero es fácil encontrar versiones en otros lenguajes. En mi caso, y como casi siempre que hago cosas por placer, recurriré a Clojure, que además me va a permitir usar un enfoque diferente a las soluciones OOP más habituales y a la estrategia para refactorizar el código.

Refactorizando a otro lenguaje

La definición de refactorizar es hacer cambio en el código sin cambiar el comportamiento. Normalmente, estos cambios son pequeños e incrementales, y se apoyan en tests que nos garanticen que no estamos rompiendo nada. En este caso, está claro que lo de cambios pequeños no va a ser posible puesto que quiero cambiar completamente de lenguaje. ¿Cómo podemos afrontar esto?

Una opción es partir de las especificaciones, que para eso están. En el texto de la kata se detalla perfectamente (¿de verdad?) lo que hay que implementar, así que “bastaría” con convertir esas especificaciones en tests y partir de ellas. Solo hay un problema: las especificaciones escritas no suelen ser exactamente iguales que la realidad. Y, de hecho, en este caso hay alguna ambigüedad que veremos al final.

Como no me fío de las especificaciones, y además, para qué engañarnos, me da un poco de pereza escribir todos esos tests, he preferido usar otro enfoque. En lugar de testear por separado cada especificación, he escrito un test de caracterización que defina como es el comportamiento actual real del sistema.

Para ello, he añadido un par de métodos al proyecto de C# que me permitan volcar el estado de los Item que forman parte del inventario a formato EDN comprensible por Clojure. De esa forma, he simulado lo que sería ejecutar el método UpdateQuality varias veces y he ido guardando el estado resultante después de cada iteración.

Con eso, puedo escribir el siguiente test:

(deftest approval
  (testing "Original data - excluding conjured items"
    (let [updates (iterate update-items items)]
      (doall (map #(is (= (butlast %1) (butlast %2))) updates expected)))))

En él, comparo el resultado de ejecutar mi propia función para actualizar items, update-items, con los resultados generados por el programa original que tengo almacenados en expected. Puesto que el código original todavía no implementa la lógica necesaria para actualizar Conjured items, en el test evito verificar el estado de esos items (de ahí el uso de butlast).

Para cubrir el caso de los Conjured items, que es la parte nueva, he añadido un test específico:

(deftest conjured-items
  (testing "Conjured items degrade twice as fast"
    (let [conjured {:name CONJURED :quality 5 :sell-in 10}]
      (is (= 3 (:quality (update-item conjured)))))
    (let [conjured {:name CONJURED :quality 5 :sell-in 0}]
      (is (= 1 (:quality (update-item conjured)))))
    (let [conjured {:name CONJURED :quality 5 :sell-in -1}]
      (is (= 1 (:quality (update-item conjured)))))))

En este enlace podéis consultar el código completo de los tests.

La implementación

Una de las restricciones de la kata original en C# es que no se puede cambiar el api del método UpdateQuality ni de la clase Item, que no es más que un contenedor de datos sin lógica. Eso hace que las mayoría de las soluciones OOP que he visto acaben creando algún tipo de decorador, estrategia, adapter o similar para poder tratar de forma polimórfica distintos tipos de items y variar la lógica aplicada a cada uno.

Al cambiar de lenguaje no hay mucha api que mantener, pero en aras de simular esa restricción, quiero conservar los datos de Item tan simples como sea posible, sin lógica añadida. Eso además encaja perfectamente con la filosofía de Clojure y puedo representarlo sin problemas como un sencillo map:

(def some-item
  {:name "+5 Dexterity Vest" :quality 20 :sell-in 10})

Teniendo esto, nuestro inventario será una secuencia de items y nuestra función para actualizarlos es trivial:

(defn update-items [items]
  (map update-item items))

Toda la parte interesante queda en la función update-item que trabaja con un único item. Hay muchas alternativas para implementarla, pero una forma muy limpia y fácil de mantener en Clojure es utilizar multimétodos para conseguir el polimorfismo que necesitamos.

Básicamente, un multimétodo consta de una definición en la que indicamos el nombre del método y cuál será la función usada para hacer el dispatch hacia las distintas implementaciones:

(defmulti update-item :name)

Esto quiere decir que el método update-item invocará una función, en este caso obtendrá el valor de la clave :name dentro del map que representa el item, y en base al valor obtenido ejecutará una versión u otra del método.

El caso más sencillo, el de los items normales, sería así:

;; Helper para calcular la velocidad de variación de calidad
;; dependiendo de los días que queden para vender el item
(defn default-quality-delta [sell-in]
  (if (pos? sell-in) 1 2))

(defmethod update-item :default [{:keys [name quality sell-in]}]
  {:name name
   :quality (max 0 (- quality (default-quality-delta sell-in)))
   :sell-in (dec sell-in)})

Con (defmethod update-item :default ... indicamos que esa es la implementación del multimétodo que queremos usar si no hay otra implementación mejor. En el cuerpo de la función, lo único que hacemos es crear un nuevo map con el nuevo valor de calidad ayudándonos de una pequeña función que reutilizaremos más adelante.

Para casos algo más complicados, la cosa es similar:

(def AGED_BRIE "Aged Brie")

(defmethod update-item AGED_BRIE [{:keys [name quality sell-in]}]
  {:name name
   :quality (min 50 (+ quality (default-quality-delta sell-in)))
   :sell-in (dec sell-in)})

Simplemente instalamos una nueva implementación del multimétodo que actúe sobre los items con el nombre apropiado, y que ejecute la lógica necesaria, como en el ejemplo anterior para el AGED_BRIE.

El resto de casos se tratan de forma análoga, como podéis ver en el código completo.

Flexibilidad de la solución

La solución completa es bastante simple y no requiere mucho código, pero ¿cómo de flexible es? ¿Sería fácil añadir nuevos tipos de objetos?

Lo cierto es que la implementación basada en multimétodos facilita mucho introducir nuevas reglas o cambiar las existentes, siempre que la lógica de actualizar cada item dependa únicamente de él mismo. Si apareciese un nuevo tipo de item, sólo necesitaríamos lo siguiente:

(def TWINKLING_RING "Twinkling Ring")

(defmethod update-item TWINKLING_RING [{:keys [name quality sell-in]}]
  ;; lógica para calcular su nueva calidad y fecha de venta
)

La ventaja de los multimétodos es que no necesitamos conocer a priori todas sus posibles implementaciones (igual que ocurriría si usáramos interfaces en OOP), y que no necesitamos crear tipos específicos para poder crear nuevas implementaciones (a diferencia de los interfaces de OOP).

Un posible problema de esta implementación es que todo el comportamiento está harcodeado y no es muy reutilizable. Otra posible aproximación, seguramente más flexible pero indudablemente más compleja, sería crear reglas parametrizables (dirección del cambio de calidad, velocidad del cambio antes y después de caducar, umbrales máximos y mínimos, etc.), y componer con ellas la configuración de cada objeto. Si alguien se anima y lo implementa, será un placer echarle un ojo y aprender de ello.

Conclusiones

Además de para pasar el rato, esta solución a la kata puede servirnos para recordar un par de ideas interesantes.

Por una parte, merece la pena pensar la estrategia de testing que vamos a seguir para asegurarnos de que es rentable. En este caso, usar tests de caracterización para fijar el comportamiento real del sistema antes de cambiarlo nos ahorra parte trabajo y aporta una seguridad adicional. Sin ellos, y partiendo sólo de las especificaciones, es complicado darse cuenta de que el Aged Brie duplica la velocidad a la que se incrementa su calidad una vez pasada su fecha de venta. Sin embargo, con los tests de aprobación que hemos montado es trivial verlo.

Por otra, los multimétodos ofrecen una solución elegante para el problema de expresión, facilitando añadir comportamiento al sistema para lidiar con distintos tipos de datos. En este caso concreto, separar los datos de la lógica, algo tan anti-OOP, da lugar a una solución que requiere menos infraestructura y menos código que las típicas soluciones OOP basadas en patrones de diseño varios. Eso hace que sea más fácil no sólo implementar, sino también entender la lógica de negocio necesaria para resolver el problema.


Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos necesarios están marcados *

*

Puedes usar las siguientes etiquetas y atributos HTML: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>