Mastodon

Dungeon 3

por Fran Iglesias

En esta sesión, hablaremos de dos problemas: mantener el foco y como introducir un buen diseño

Como mantener el foco en el objetivo del proyecto

Uno cuestión que te puedes estar planteando al hablar de desarrollo iterativo e incremental es cómo mantener el foco en el objetivo que quieres conseguir, a la vez que te ocupas de decidir qué hacer en este momento.

Es importante definir claramente cuál es ese objetivo. En nuestro ejemplo, queremos desarrollar un juego conversacional cuyo tema es escapar de una mazmorra laberíntica, en la que te puedes encontrar distintos obstáculos y problemas.

Un aspecto importante de este objetivo es que no nos conviene que sea enormemente detallado. Me explico. Cuanto más en detalle describas este objetivo, sobre todo si bajas a cuestiones de implementación, más rígido se vuelve el proyecto. Es crucial describir el proyecto como un problema que las usuarias quieren resolver, o una necesidad que satisfacer.

La forma concreta de satisfacer esa necesidad puede cambiar. Por ejemplo, cuando comenté el inicio de este proyecto en Twitter, una persona sugirió que podría manejarse desde Telegram. No es una mala idea, pero ya implica una determinada manera de solucionar el problema de jugar con este juego. La cuestión es que para poder jugar vía Telegram es necesario tener primero un juego al que jugar.

La meta es pasar un rato entretenido escapando de una mazmorra en una aventura conversacional. Hacerlo de una manera u otra es un detalle de implementación.

Y la forma más segura de mantener ese objetivo en mente es tener a la usuaria del producto cerca. Como desarrolladoras podemos estar centradas en el paso a paso, pero a la usuaria no le preocupan esos problemas concretos, sino en el producto que puede usar y disfrutar.

En cualquier caso, la usuaria puede dar tanto feedback muy específico (necesito que se vea el comando que he introducido), como muy genérico (quiero poder encontrarme con enemigos y luchar con ellos).

En el primer caso, suele ser fácil realizar el cambio adecuado en el código. De hecho, ese feedback suele referirse detalles que se perciben como defectos o bugs. A veces podemos necesitar concretar mejor los detalles porque el defecto a veces se presenta bajo ciertas condiciones que no conocemos bien.

En cambio, en el segundo caso, necesitamos conversar con la usuaria para averiguar cómo se concreta ese feedback. ¿Qué quiere decir con enemigos? ¿Y qué es luchar? ¿Qué consecuencias tendría ganar o peder? Y un largo etcétera. Esto es básicamente lo que compone una historia de usuario.

La cuestión entonces es cómo organizar el trabajo sobre esta historia de usuario para implementarla completamente. Para ello podemos aplicar técnicas de rebanado en vertical de historias, de tal modo que cada iteración implique la entrega de una nueva prestación que las usuarias puedan descubrir y utilizar.

Por ejemplo. Una forma de rebanar esta historia acerca de introducir enemigos podría ser:

  • Al pasar por alguna habitación puedo ver que hay un enemigo (pero no interactuamos)
  • Puedo hablar con el enemigo y me responde (speak enemy)
  • El enemigo me ataca, puedo luchar (fight enemy) pero siempre gano
  • Cuando lucho pierdo energía
  • Si pierdo demasiada energía muero

Cada paso añade una prestación que me coloca más cerca del objetivo de la historia de usuario, de tal forma que cada una es relativamente fácil de implementar y poner en producción.

Pero bueno, antes de llegar a esto necesitamos algunas cosas que aún no tenemos. Y eso nos lleva al problema del diseño.

Como introducir el diseño

El problema del diseño es que nos influyen dos fuerzas:

  • En un extremo, la necesidad de poner en producción nuestro desarrollo, para lo cual buscamos la forma más sencilla de conseguirlo.
  • En el otro extremo, la necesidad de tener un código bien diseñado que pueda evolucionar de forma sencilla y mantener nuestra capacidad de entrega.

La primera nos permite entregar pronto ahora, pero el código puede volverse difícil de mantener y cambiar en el futuro.

La segunda nos ralentiza ahora, aunque nos facilite la vida en el futuro. Por otro lado, es posible que un diseño prematuro nos penalice en el futuro si nuestras necesidades cambian.

Por tanto, hay que definir un compromiso entre mantener el sistema lo más sencillo posible, pero manteniendo una alta flexibilidad.

Un buen punto de partida cuando los proyectos están en los primeros pasos es aplicar las reglas de Object Calisthenics. Estas reglas nos ayudan a acercarnos a buenos diseños de código o, al menos, a diseños que son fáciles de mantener y hacer evolucionar.

Por ejemplo, hay dos que son particularmente potentes en este sentido:

  • Envuelve todas las primitivas en objetos
  • Envuelve todas las colecciones en objetos

Básicamente, estas reglas nos dicen que cualquier tipo de datos que circule en nuestro programa debería ser un objeto. La potencia que nos aportan estas reglas es que todo el comportamiento asociado a estos datos se puede encapsular dentro de estos objetos. De este modo, su implementación puede evolucionar sin alterar la relación con otros objetos a través de su interfaz.

Vamos a ver un ejemplo. Nuestro objetivo inmediato es mejorar el output del juego mostrando un eco de la orden dada por la jugadora.

Una forma muy simple de lograrlo sería esta:

from dungeon.dungeon import Dungeon


class Game:
    def __init__(self):
        self.dungeon = None

    def start(self):
        self.dungeon = Dungeon()

    def execute(self, instruction):
        result = "I don't understand"
        command, argument = instruction.split(" ", 1)
        if command == "go":
            print("You said: {c} {a}".format(c=command, a=argument))
            result = self.dungeon.go(argument)
        if command == "look":
            print("You said: {c} {a}".format(c=command, a=argument))
            result = self.dungeon.look(argument)
        return result

Lo cierto es que podríamos hacer un commit con esta solución y desplegar. La demo muestra mucho mejor cómo funcionaría el juego:

Welcome to the Dungeon
-
You said: look around
North: There is a door
East: There is a wall
South: There is a wall
West: There is a wall
That's all

-
You said: go south
You hit a wall
-
You said: look around
North: There is a door
East: There is a wall
South: There is a wall
West: There is a wall
That's all

-
You said: go north
Congrats. You're out
-

La solución es simple, pero se puede ver que es problemática. Tenemos una repetición de la línea print("You said: {c} {a}".format(c=command, a=argument)). La alternativa sería algo así como:

class Game:
    def __init__(self):
        self.dungeon = None

    def start(self):
        self.dungeon = Dungeon()

    def execute(self, instruction):
        result = "I don't understand"
        command, argument = instruction.split(" ", 1)
        if command == "go":
            result = self.dungeon.go(argument)
        if command == "look":
            result = self.dungeon.look(argument)
        if command == "go" or command == "look":
            print("You said: {c} {a}".format(c=command, a=argument))
        return result

Esto tampoco es muy bonito y se ve claramente que a medida que aumenten los comandos se hará más problemático y propenso a errores. La raíz del problema es que en ningún momento tenemos una garantía de que command (y su argument) representan acciones válidas. Solo lo sabemos cuando identificamos una. Por eso, tenemos que verificar con if command == "go" or command == "look": para saber que estamos lidiando con un comando que podemos ejecutar.

Una posibilidad es hacer lo siguiente:

class Game:
    def __init__(self):
        self.dungeon = None

    def start(self):
        self.dungeon = Dungeon()

    def execute(self, instruction):
        command, argument = instruction.split(" ", 1)
        if command != "go" and command != "look":
            return "I don't understand"
        
        result = ""
        
        if command == "go":
            result = self.dungeon.go(argument)
        if command == "look":
            result = self.dungeon.look(argument)

        print("You said: {c} {a}".format(c=command, a=argument))
        return result

Están apareciendo muchos code smells aquí, que nos anticipan problemas:

  • command y argument son un Data Clump, son datos que siempre tienen que ir en grupo.
  • result es una variable temporal que hemos introducido para poder devolver un resultado.
  • execute comienza a ser un método largo, señal de que podría estar ocupándose de demasiadas cosas.

Es un buen momento para refactorizar.

De hecho, execute se está encargando de varias cosas. Al menos cuatro:

  • Identificar la orden de la jugadora
  • Decidir cómo se tiene que ejecutar
  • Devolver un resultado
  • Imprimir el eco de la orden ejecutada

De hecho, los mensajes que puede ver la jugadora se ponen en pantalla en dos lugares diferentes. A veces se devuelven y es otra parte del programa la encargada de visualizarlos y, otras veces, se imprimen directamente a la consola.

¡Casi tenemos más problemas que líneas en este método!

Vayamos por partes.

En primer lugar, recordemos la regla de Calisthenics mencionada antes: envolver todos los datos primitivos en objetos. Vamos a empezar a aplicarla. De entrada, nos ayuda a resolver el problema del data clump al unir las dos variables.

class Command:

    def __init__(self, command, argument):
        self._argument = argument
        self._command = command

Y podemos empezar a usarlo. Primero, simplemente lo instanciamos:

class Game:
    def __init__(self):
        self.dungeon = None

    def start(self):
        self.dungeon = Dungeon()

    def execute(self, instruction):
        command, argument = instruction.split(" ", 1)
        if command != "go" and command != "look":
            return "I don't understand"

        c = Command(command, argument)

        result = ""

        if command == "go":
            result = self.dungeon.go(argument)
        if command == "look":
            result = self.dungeon.look(argument)

        print("You said: {c} {a}".format(c=command, a=argument))
        return result

Si nos fijamos, podemos ver varios bloques en el código, que voy a marcar con comentarios:

class Game:
    def __init__(self):
        self.dungeon = None

    def start(self):
        self.dungeon = Dungeon()

    def execute(self, instruction):
        # obtain a valid command from player input
        command, argument = instruction.split(" ", 1)
        if command != "go" and command != "look":
            return "I don't understand"

        c = Command(command, argument)

        # execute the action
        result = ""

        if command == "go":
            result = self.dungeon.go(argument)
        if command == "look":
            result = self.dungeon.look(argument)

        # show the result of the action
        print("You said: {c} {a}".format(c=command, a=argument))
        return result

De momento, podemos identificar tres responsabilidades aquí.

  • Obtención del comando: responsabilidad que podría corresponder a una factoría.
  • Ejecutar la acción: responsabilidad que claramente pertenece al objeto Command, que es el que conoce la información necesaria.
  • Mostrar el resultado: responsabilidad que tendremos que analizar con más detalle, porque estas líneas esconden varios problemas.

Voy a ocuparme primero de la segunda responsabilidad y moverla de forma segura a Command. Primero, creo el método do(dungeon) en Command:

class Command:

    def __init__(self, command, argument):
        self._argument = argument
        self._command = command

    def do(self, dungeon):
        pass

A continuación, copio, pego y adapto el bloque de código que me interesa:

class Command:

    def __init__(self, command, argument):
        self._argument = argument
        self._command = command

    def do(self, dungeon):
        result = ""

        if self._command == "go":
            result = dungeon.go(self._argument)
        if self._command == "look":
            result = dungeon.look(self._argument)
            
        return result

Fíjate que no estoy usando este código, por lo tanto, ni los tests dejan de pasar, ni nada se rompe. No me hace falta crear tests para este código porque los que ya existen serán suficientes para verificar que el comportamiento del juego se mantiene.

Finalmente, reemplazo el bloque de código con una llamada a c.do(dungeon):

class Game:
    def __init__(self):
        self.dungeon = None

    def start(self):
        self.dungeon = Dungeon()

    def execute(self, instruction):
        # obtain a valid command from player input
        command, argument = instruction.split(" ", 1)
        if command != "go" and command != "look":
            return "I don't understand"

        c = Command(command, argument)

        # execute the action
        result = c.do(self.dungeon)

        # show the result of the action
        print("You said: {c} {a}".format(c=command, a=argument))
        return result

Como era de esperar, los tests siguen pasando. Por supuesto, esto se merece otro commit.

Este commit no supone una entrega de valor. El sistema no hace nada que no estuviese haciendo ya, como certifican los tests. Es un refactor que necesitamos para prepararnos para lo que venga en el futuro.

Para poder volcarnos en extraer la factoría de comandos, vamos a necesitar tocar la línea print("You said: {c} {a}".format(c=command, a=argument)), dado que usa la información que es propiedad de Command. Tiene sentido que la clase se encargue de generar una representación textual.

En este punto disponemos de varias opciones. Lenguajes como Python permiten sobreescribir métodos mágicos que se invocan automáticamente en ciertas situaciones. Por ejemplo, si un objeto se usa en un lugar en que se espera un string, se invocará el método __str__.

class Command:

    def __init__(self, command, argument):
        self._argument = argument
        self._command = command

    def do(self, dungeon):
        result = ""

        if self._command == "go":
            result = dungeon.go(self._argument)
        if self._command == "look":
            result = dungeon.look(self._argument)

        return result

    def __str__(self) -> str:
        return "You said: {} {}".format(self._command, self._argument)

Esto puede ser suficiente para nuestro ejemplo, y podemos reemplazarlo así:

class Game:
    def __init__(self):
        self.dungeon = None

    def start(self):
        self.dungeon = Dungeon()

    def execute(self, instruction):
        # obtain a valid command from player input
        command, argument = instruction.split(" ", 1)
        if command != "go" and command != "look":
            return "I don't understand"

        c = Command(command, argument)

        result = c.do(self.dungeon)

        print(c)
        return result

Todo sigue funcionando, aunque esta parte específica del código no es ejecutada por los tests. Podríamos incluir un test para asegurar esto si quisiéramos, pero ahora mismo no me preocupa demasiado.

El paso final es tener una factoría de Command. La voy a implementar como método factoría estático en la propia clase, que también he visto denominado como named constructor. En otros lenguajes usaría sobrecarga de constructores.

Si no tienes mucha complejidad en la construcción, ni necesitas servicios extra, normalmente este método será suficiente. En otro caso, usa un objeto factoría.

Como hicimos antes, introducimos el método y copiamos el código adaptándolo. De este modo, los tests no pueden romperse.

class Command:

    def __init__(self, command, argument):
        self._argument = argument
        self._command = command

    @staticmethod
    def from_user_input(user_input):
        command, argument = user_input.split(" ", 1)
        if command != "go" and command != "look":
            return "I don't understand"

        return Command(command, argument)
        
    def do(self, dungeon):
        result = ""

        if self._command == "go":
            result = dungeon.go(self._argument)
        if self._command == "look":
            result = dungeon.look(self._argument)

        return result

    def __str__(self) -> str:
        return "You said: {} {}".format(self._command, self._argument)

Tenemos un problemilla aquí:

    @staticmethod
    def from_user_input(user_input):
        command, argument = user_input.split(" ", 1)
        if command != "go" and command != "look":
            return "I don't understand"

        return Command(command, argument)

En caso de que no podamos identificar el comando se devuelve un mensaje. Vamos a reemplazar el código y ver qué pasa, aunque si miras los tests, sabrás cual va a ser el problema.

class Game:
    def __init__(self):
        self.dungeon = None

    def start(self):
        self.dungeon = Dungeon()

    def execute(self, instruction):
        c = Command.from_user_input(instruction)

        result = c.do(self.dungeon)

        print(c)
        return result

Al ejecutar los tests, observamos un error:

Ran 5 tests in 0.011s

FAILED (errors=1)

Error
Traceback (most recent call last):
  File "/Users/frankie/Projects/dungeon/dungeon/tests/test_minimum_game.py", line 21, in test_unknown_command
    self.assertEqual("I don't understand", self.game.execute("foo bar"))
  File "/Users/frankie/Projects/dungeon/dungeon/game.py", line 43, in execute
    result = c.do(self.dungeon)
AttributeError: 'str' object has no attribute 'do'

Efectivamente, la factoría devuelve un error si el comando no es reconocido. ¿Qué podemos hacer?

  • Lanzar un error
  • Usar un patrón Null Object

Me quedo con la segunda y crearé un objeto InvalidCommand, descendiente de Command que no hace nada más que devolver el mensaje de “no entiendo”.

class InvalidCommand(Command):
    def __init__(self, user_input):
        self._user_input = user_input

    def do(self, dungeon):
        return "I don't understand"

    def __str__(self) -> str:
        return "You said: {}".format(self._user_input)

Y el método factoría queda así:

    @staticmethod
    def from_user_input(user_input):
        command, argument = user_input.split(" ", 1)
        if command != "go" and command != "look":
            return InvalidCommand(user_input)

        return Command(command, argument)

Los tests vuelven a pasar. La mayor parte de las responsabilidades están separadas. Todavía queda el tema de devolver la respuesta, pero lo voy a dejar para otra ocasión.

Hasta ahora he estado introduciendo estas clases en el mismo archivo dungeon/game.py, pero voy a reorganizar el código antes de hacer el último commit de esta sesión.

.
├── Pipfile
├── dungeon
│   ├── __init__.py
│   ├── __main__.py
│   ├── command
│   │   ├── __init__.py
│   │   └── command.py
│   ├── dungeon.py
│   ├── game.py
│   ├── room.py
│   ├── tests
│   │   ├── __init__.py
│   │   └── test_minimum_game.py
│   └── wall.py
├── dungeon.iml
└── setup.py

También queda un detallito en la forma en que se construye InvalidCommand y cómo queda la jerarquía de herencia. Como no afecta a la funcionalidad, lo veremos en otra ocasión.

Próximos pasos

Ahora que la demo demuestra cómo funciona la interacción del juego, necesitamos implementar esa interacción. Será nuestra próxima historia de usuario: quiero poder introducir comandos para sacar a mi personaje de la mazmorra.

Y quizá tengamos que hablar del refactor preparatorio.

Siguiente paso

November 11, 2022

Etiquetas: python   good-practices   dungeon  

Temas

good-practices php refactoring testing tdd blogtober19 design-principles python bdd misc legacy design-patterns tips dungeon tools ddd bbdd soft-skills golang ruby javascript books api sql ethics swift oop java