Técnicas de Refactoring usando Katas

Disciplina de refactor paso a paso tomando como ejemplo un code kata

Técnicas de Refactoring usando Katas

Según Martin Fowler en su libro de Refactoring.

Refactoring es el proceso de cambiar un software de tal forma a que no se altere el comportamiento externo y al mismo tiempo se mejore la estructura interna.

En el mismo libro Martin entra en profundidad en diversas técnicas de refactoring, y una de las mejores formas de aprenderlas es con la práctica enfocada, haciendo ejercicios para asimilar en forma de hábito al programar.

En artes marciales se usa "Katas" para entrenar las técnicas, haciendo una coreografía de los movimientos sin la presencia de un oponente. Es lo que usó Daniel LaRusso para ganar el torneo del All-Valley justo después que le dijeran que su Karate es una broma (estás en el grupo de riesgo si entendiste esto).

El mismo concepto se ha llevado a la programación con la presencia de repositorios de "code kata" en Github, que tienen programas con código desordenado de manera intencional y sirven para practicar técnicas de refactoring fuera un ambiente de producción, "sin oponente".

Para este post vamos a tomar un ejemplo de Tennis-Kata y explorar algunas opciones para mejorarlo.

https://github.com/emilybache/Tennis-Refactoring-Kata

Background del código

Es una aplicación que te permite hacerle ganar un punto de tenis a uno de dos jugadores. Y la aplicación va manteniendo el score.

   @Test
    public void simpleTennisGame1() {
        TennisGame1 game = new TennisGame1("player1", "player2");
        game.wonPoint("player1");
        assertEquals(game.getScore(), "Fifteen-Love");
    }

En el test anterior el jugador 1 ganó un punto y el marcador es 15-0 o como se dice en inglés "Fifteen-Love".

Método getScore()

Presentamos el código y luego hablaremos un poco sobre el mismo.

public String getScore() {
        String score = "";
        int tempScore=0;
        if (m_score1==m_score2)
        {
            switch (m_score1)
            {
                case 0:
                        score = "Love-All";
                    break;
                case 1:
                        score = "Fifteen-All";
                    break;
                case 2:
                        score = "Thirty-All";
                    break;
                default:
                        score = "Deuce";
                    break;
                
            }
        }
        else if (m_score1>=4 || m_score2>=4)
        {
            int minusResult = m_score1-m_score2;
            if (minusResult==1) score ="Advantage player1";
            else if (minusResult ==-1) score ="Advantage player2";
            else if (minusResult>=2) score = "Win for player1";
            else score ="Win for player2";
        }
        else
        {
            for (int i=1; i<3; i++)
            {
                if (i==1) tempScore = m_score1;
                else { score+="-"; tempScore = m_score2;}
                switch(tempScore)
                {
                    case 0:
                        score+="Love";
                        break;
                    case 1:
                        score+="Fifteen";
                        break;
                    case 2:
                        score+="Thirty";
                        break;
                    case 3:
                        score+="Forty";
                        break;
                }
            }
        }
        return score;
    }

El método tiene 3 ramas condicionales para computar el marcador:

  1. Los iguales de 15, 30 y 40
  2. Las ventajas y ganancias de game
  3. Los casos que no son los anteriores, donde un jugador lleva un solo punto arriba de su oponente, ejemplo: 15-0 o "Fifteen-Love".

Nos enfocaremos en el bloque 3 y para empezar vamos a ordernar levemente el código antes de hacer el refactor pesado. Movemos la variable tempScore y agregamos llaves a los if para hacerlos más legibles.

            int tempScore = 0;

            for (int i=1; i<3; i++)
            {
                if (i==1) {
                    tempScore = m_score1;
                } else {
                    score+="-";
                    tempScore = m_score2;
                }
                switch(tempScore)
                {
                    case 0:
                        score+="Love";
                        break;
                    case 1:
                        score+="Fifteen";
                        break;
                    case 2:
                        score+="Thirty";
                        break;
                    case 3:
                        score+="Forty";
                        break;
                }
            }
MOVER VARIABLE CERCA DE DECLARACIÓN

Con IntelliJ mover tempScore es fácil haciendo Alt-Enter para mover la declaración de una variable cerca de su uso, que es también una buena práctica de refactor.

Prerrequisitos para un refactor

Luego de cada paso de los mencionados a continuación, hay que ejecutar los tests unitarios que vienen con el repositorio. Tener un buen coverage de código es requerimiento no negociable al momento de hacer cualquier tipo de refactor. Si no se tienen tests se los debe escribir a la par que se haga el refactor.

Iniciando el refactor

Como primer paso trataremos de entender lo que está pasando dentro de ese for-loop. Se hace una iteración para cada jugador, es decir 2 iteraciones. Siendo que en Tenis no se espera tener más de 2 oponentes o equipos, podemos con tranquilidad deshacernos del for y apuntar a repetir el cuerpo del for dos veces, una por cada jugador.

Técnica Extract Method

Para apuntar a eliminar el loop, primero aplicamos el extract-method para la parte común a ambas iteraciones, que sería el bloque que contiene el switch statement. En IntelliJ con Ctrl+Alt+M lo hacemos de forma sencilla.

        else {
            int tempScore = 0;

            for (int i=1; i<3; i++) {
                if (i == 1) {
                        tempScore = m_score1;
                } else {
                        score+="-";
                        tempScore = m_score2;
                }
                score = computeScore(score, tempScore);
            }
        }
        return score;
    }

    private String computeScore(String score, int points) {
        switch(points)
        {
            case 0:
                score+="Love";
                break;
            case 1:
                score+="Fifteen";
                break;
            case 2:
                score+="Thirty";
                break;
            case 3:
                score+="Forty";
                break;
        }
        return score;
    }
EXTRACT METHOD

Evitar loops y condiciones

Estamos teniendo algo más compacto pero todavía no del todo pulcro al introducir computeScore que recibe un parámetro, lo modifica y luego vuelve a asignar el resultado a la misma variable score. Esto es un code smell conocido como mutable-data y es algo que hay que evitar tener en cualquier code base. Más adelante veremos cómo eliminarlo.

Luego movemos computeScore dentro de cada rama del if/else

            for (int i=1; i<3; i++) {
                if (i == 1) {
                  tempScore = m_score1;
                  score = computeScore(score, tempScore);
                } else {
                  score+="-";
                  tempScore = m_score2;
                  score = computeScore(score, tempScore);
                }
            }

Y con eso podemos liberarnos del for-loop y también borrar los if-else.

            int tempScore = 0;
            tempScore = m_score1;
            score = computeScore(score, tempScore);
            score+="-";
            tempScore = m_score2;
            score = computeScore(score, tempScore);

EVITAR CICLOS Y CONDICIONES

Si ejecutamos los tests, todo sigue funcionando.

Técnica de Inline Variable

Luego hacemos un inline-variable para deshacernos de tempScore (Ctrl+Alt+N en IntelliJ). El objetivo es apuntar a tener la menor cantidad de variables locales posibles.

    score = computeScore(score, m_score1);
    score+="-";
    score = computeScore(score, m_score2);

Volvemos a correr los test unitarios para asegurarnos de no haber afectado el comportamiento externo del método que estamos modificando.

El problema de Mutable Data

Todavía tenemos el problema mencionado del mutable-data, primero en el método computeScore mutamos el parámetro y luego al retornar de este método estamos mutando de vuelta score. Resolveremos esos dos puntos explorando algunas opciones en base a lo que sabemos hasta ahora del código.

Idealmente el método computeScore debería solamente recibir los puntos marcados por un jugador y retornarnos cuál es el score de esos puntos en formato string. Exploremos crear un método similar a computeScore y modificándolo de la siguiente forma.

   private String getScoreByPoints(int points) {
        switch(points)
        {
            case 0:
                return "Love";
            case 1:
                return "Fifteen";
            case 2:
                return "Thirty";
            case 3:
                return "Forty";
        }
        return "";
    }

Y luego podemos usar este nuevo método en lugar de computeScore de la siguiente forma:

  return getScoreByPoints(m_score1) + "-" + getScoreByPoints(m_score2);

Agregando un return tempranero para no tener que estar asignando el resultado a ninguna otra variable.

Lo anterior no fue ninguna técnica documentada de refactor sino como Batman nos lanzamos hacia el peligro basándonos en el entendimiento que tenemos de cómo funciona el código, que es algo que suele pasar en cualquier actividad de refactor.

Último ajuste Bono 🎁

Lo que tenemos hasta ahora nos deja bastante contentos, código mucho más compacto, legible que antes y por sobre todas las cosas, este código será más fácil de extender en un futuro. Pero yo le haría un último ajuste a ese switch statement.

Lo que normalmente se recomienda para los switch es reemplazarlos por clases una jerarquía de clases con la técnica replace-conditional-with-polymorphism donde por cada case se crea una clase y luego se instancian de forma condicional.

Pero haremos algo más sencillo, que es aprovechar la potencialidad que tienen los enums en el lenguaje Java y el hecho de que cada constante en un enum tiene un número ordinal empezando desde cero.

enum Score {
   Love, Fifteen, Thirty, Forty
}

Luego getScoreByPoints se convierte en

 private String getScoreByPoints(int points) {
    return Score.values()[points].name();
 }

Esto queda un poco "hacky" y no tan legible e incluso propenso a errores, pero una mejora sería encapsularlo dentro del propio enum, de nuevo aprovechando lo geniales que son estas estructuras en Java y que podemos básicamente tratarlas como clases.

   private String getScoreByPoints(int points) {
       return Score.nameFromPoints(points);
    }

    enum Score {
        Love, Fifteen, Thirty, Forty;

        public static String nameFromPoints(int points) {
            return values()[points].name();
        }
    }

Y como último toque nos vamos a donde originalmente llamamos a getScoreByPoints y hacemos un Inline-method con Ctrl+Alt+N para obtener algo como esto:

return Score.nameFromPoints(m_score1) + "-" + Score.nameFromPoints(m_score2);

Resumiendo

El refactoring es una disciplina que tiene que formar parte de nuestro día a día, y para eso se recomienda practicar con ejercicios como los Code Kata disponibles en Github. La actividad de refactor se realiza con pasos pequeños, moviendo la menor cantidad de líneas y modificando lo mínimo posible.

Es crítico tener un buen coverage de test unitarios antes de empezar cualquier refactor y ejecutar los tests en cada modificación que hacemos para no cambiar el comportamiento externo de nuestro código.

El Kit de Supervivencia de cualquier programador durante un refactor son las técnicas de Extract Variable o Method y su contraparte Inline Variable o Method. Es también importante saber cómo usar tu editor para que te pueda ayudar a hacer el refactor.

Ahora dejo el desafío de hacer el refactor del resto del código no contemplado en este artículo.

Tu Karate es una broma,

o quizás no...