Qué es la refactorización?
Refactorizar es el proceso de cambiar el código de un software de manera que no altere su comportamiento externo, sino mejorando su estructura interna. Se trata de una disciplinada forma de limpiar el código, minimizando las probabilidades de introducir bugs.
En esencia, cuando refactorizas estás mejorando el diseño del código después de que haya sido escrito.
Generalmente consideramos que en el desarrollo de software primero va el diseño y después el código, y esto es cierto, pero sabemos que con el tiempo el código se modificará, y su estructura respecto al diseño se desvanece gradualmente. Lentamente el código se hunde desde la ingeniería hasta el hacking.
La refactorización es lo opuesto a esto último; con la refactorización podés tomar un mal diseño, caos incluso, y volver a transformarlo en un código bien diseñado.
Cada paso es simple, incluso simplista.
Movés una propiedad de una clase a otra, sacás código de un método para convertirlo en su propio método, movés código de arriba a abajo, etc.
Cada pequeño paso puede mejorar seriamente el diseño.
La refactorización ocurre continuamente durante el desarrollo, construyendo el programa aprendés cómo mejorar el diseño. El resultado de esto, lleva a un programa que se mantiene limpio durante su desarrollo.
Ejemplos
Martin Fowler nos propone que la mejor forma de poder entender el proceso es mediante ejemplos.
Es fácil identificar un código mal diseñado si se sabe sobre el tema, pero es difícil saber cómo implementar ese conocimiento.
Hagamos un algoritmo sencillo, utilizando un código real. Necesitamos construir un cifrado de vigenère. No voy a entrar en detalles, tienen más información en wikipedia.
Se trata de un cifrado por sustitución polialfabético, o bueno, reemplazar un caracter por otro del abecedario.
Para resolver el problema, he escrito la siguiente clase en Javascript.
¿Cuáles son tus primeras impresiones al ver este algoritmo?
El autor nos da un alivio y dice
Para programas simples como este, realmente no importa el diseño. No hay nada de malo en un código simple y sucio.
El peor código es el que no existe.
Pero supongamos que representa un fragmento de un sistema mucho más complejo, ahí si que tendríamos un problema real con este código.
A simple vista podemos decir que no tiene un buen diseño; la clase es enorme, tiene código duplicado y no cumple con los requisitos de estar escrito en Typescript o estar basado en funciones como aclaramos al principio.
Aún así, este algoritmo funciona. ¿No es esto sólo un juicio estético, una crítica al código feo?
Al compilador no le importa si el código es feo o limpio, pero cuando queramos cambiar el algoritmo, habrá un humano involucrado, y a los humanos sí nos importa. Un sistema mal diseñado es difícil de cambiar, es difícil de averiguar dónde se necesitan los cambios o qué cosa cambiar, existe una gran posibilidad de que el programador cometa un error e introduzca bugs.
En este caso, lo primero que podemos hacer es romper la clase, podemos convertir cada método en una función.
Aunque aún seguiría habiendo código repetido, podemos extraer esa parte a una nueva función.
Probablemente estés tentado a realizar la menor cantidad de cambios en el código, después de todo funciona bien.
Recordemos el viejo dicho de la ingeniería: “Si no está roto, no lo arregles”. Puede que el programa no esté roto, pero duele. Te hace la vida más difícil, porque resulta más difícil realizar los cambios que pide el usuario. Acá es donde entra en juego la refactorización.
Martin nos da el siguiente tip:
Cuando tengas que agregar una funcionalidad a un programa, y el código no está bien estructurado, primero refactorizá el programa, y luego agregá la funcionalidad.
Primer paso en la refactorización.
Cuando refactorizamos, el primer paso siempre es el mismo. Necesitamos hacer tests sólidos para esa sección del código.
Los test son esenciales, porque aunque refactoricemos para evitar introducir bugs, seguimos siendo humanos y seguimos cometiendo errores. Por eso necesitamos pruebas sólidas.
Antes de empezar a refactorizar, asegurate de tener entorno sólido de pruebas y de cubrir cada parte de la funcionalidad.
Descomponiendo y redistribuyendo la clase
Lo más evidente en el código del cifrado está en la extensión de la clase. Vamos a dividirlo en distintas funciones independientes.
Cuando extraemos una pieza de código, como en todo refactor, debemos saber qué puede salir mal. Si hacemos una mala extracción, podemos introducir bugs.
Primero revisamos en la pieza de código por cualquier variable que sea local en su scope, cualquier variable que esté fuera del scope muy probablemente lo podamos recibir como parámetros de la función. Además, podemos acceder a constantes que se encuentren en un scope superior o componer funciones para lograr tener acceso a ellas.
La técnica que usemos dependerá del contexto, pero vayamos un paso a la vez.
Necesitamos tener especial cuidado en aquellas variables que sufran modificaciones; si es solo una, podemos retornarla con alguna otra función. Siguiendo un principio de la programación funcional, evitaremos al máximo la mutabilidad.
Extraemos los método como funciones.
Eliminamos this
, ya que no tenemos una clase a la cual apuntar. Además, necesitamos declarar las propiedades de la clase, en este caso como constantes globales.
Perfecto, renombro las llamadas a los métodos en mis test para llamar a las nuevas funciones, corro los test y listo.
Primer paso terminado.
Ahora, este código aún presenta varias irregularidades.
Nuestro segundo paso es pasar nuestro código a typescript, vamos a añadir tipado ya que así será más seguro seguir rompiendo el código en pedacitos.
Perfecto, en el resto de las variables se infiere el tipo, así que no tendremos que preocuparnos por ello.
Compilamos y vemos que no hay errores.
Al refactorizar, cambias el programa en pequeños pasos. Si cometes un un error, será fácil encontrar el bug.
Reutilizando código
Ahora que tenemos un entorno un poco mas seguro, veamos las funciones encode
y decode
. Ambas comparten una porción de código muy similar. La única diferencia está en la forma en la que calculamos el índice de la letra abecedario.
Para reutilizar la mayor parte de código, comenzaremos identificando todos los valores constantes.
En nuestro caso, todos los valores son dependientes del string a encriptar o desencriptar. Sin embargo, el procedimiento es exactamente el mismo.
Una mejor técnica sería identificar las diferencias. Como hemos dicho, la diferencia se encuentra en la ecuación para reemplazar el caracter.
Otro aspecto importante que descubrí ahora durante la refactorización, es que el día en el que escribí el cipher cometí un pequeño error que Javascript pudo tolerar.
En la función de decode
se puede ver la siguiente asignación doble:if((ci-ki) < 0) di = di = (ci — ki + l) % l;
Es interesante encontrar estos defectos en el código, ¿cómo es posible que eso haya llegado ahí?
Una doble asignación completamente innecesaria, producto del despiste de repetir código.
Propongo la siguiente solución, pasemosle a la secuencia de pasos la ecuación de encriptar o desencriptar, junto al string a tratar.
Comencemos declarando las ecuaciones correspondientes al algoritmo.
Para la ecuación de cifrado decimos:
const encode_char = (Xi:number, Ki:number, L:number) => (Xi + Ki) % L;
Para la ecuación de descifrado decimos:
const decode_char = (Ci:number, Ki:number, L:number) => (Ci — Ki) >= 0 ? (Ci — Ki) % L : (Ci — Ki + L) % L;
Sí, puede verse como chino, y el nombre los parametros es inentendible, pero el que conoce la ecuación y el algoritmo de cifrado puede interpretar fácilmente de qué se trata.
Martin Fowler nos regala la popular y mítica frase que todo desarrollador debe recordar.
Cualquier tonto puede escribir código que una computadora pueda entender. Los buenos programadores escriben código que los humanos puedan entender.
Comprender un código no solo se trata de experiencia, también se trata del contexto de negocio y de los principios a los que te adhieras.
En nuestro caso, se requiere que comprender el cifrado de Vigenère y los principios del paradigma funcional.
Volviendo a nuestras ecuaciones, las dos ecuaciones siguen teniendo cosas en común, y es que los parámetros y el valor de retorno siguen siendo los mismos. Creemos un contrato en común para ambas ecuaciones, lo utilizaremos para restringir el tipo de parámetro que recibirá la función del algoritmo.
type cipher_equation = (Xi:number, Ki:number, L:number) => number;
Creemos una función llamada como el algoritmo, vigenere_cipher, y reescribamos los pasos repetidos del encode y decoder.
function vigenere_cipher(str:string,equation:cipher_equation):string {
const key = repeat_key(str.length);
return str
.split(“”)
.map((C, i) => {
const Ci = alphabet.indexOf(C);
const K = key[i];
const Ki = alphabet.indexOf(K); if(Ci === -1) return C; const Ri = equation(Ci, Ki, L); return alphabet[Ri];
})
.join(“”);
}
En nuestro callback de .map
he decidido llamar C
de Char al parámetro, y Ri
de Index del reemplazo (debido a que retornamos el caracter del alfabeto que reemplaza al recibido).
Ahora reescribiremos las funciones de encode
y decode
, mediante una técnica llamada currificación.
La currificación consiste en reducir los parámetros de una función, reemplazando algunos argumentos algunos por constantes. En nuestro caso pasando las ecuaciones.
const encode = (str:string) => vigenere_cipher(str, encode_char)
const decode = (str:string) => vigenere_cipher(str, decode_char)
Como hemos visto, la interfaz de las funciones encode
y decode
no ha cambiado, sigue correspondiendo a (str:string) => string
, por lo que los test seguirán intactos.
Ahora nuestro programa se ve así:
Probemos… ¡Funciona!
Cambiando la sentencia while
Sigamos profundizando en el paradigma funcional, quiero que prestemos atención a la función repeat_key
, la cual tiene varias violaciones al paradigma.
Primero, el uso de while como tal, y segundo la mutabilidad.
Creo que todos hemos oído hablar de la temida recursión. Esta técnica encaja a la perfección con nuestra problemática.
function repeat_key(to_length:number, new_key = key):string {
if(new_key.length < to_length) {
return repeat_key(to_length, new_key.repeat(2));
} else {
return new_key.slice(0, to_length + 1);
}
}
Es cierto que tenemos un muy evidente condicional, que podríamos evitar con una expresión ternaria, pero por motivos didácticos vamos a dejarlo así.
La recursividad es una técnica que sacrifica legibilidad, no es buena idea seguir dificultándolo más.
Corremos los test y bien, otra vez hemos hecho un buen refactor.
Nuevos requerimientos
Nuestro código tuvo tal calidad que nos permitió movernos en el mercado rápidamente. En poco tiempo decenas de clientes nos han pedido nuestro software de cifrado.
En nuestro nuestro diseño actual, nuestro algoritmo utiliza siempre el mismo alfabeto y la misma clave secreta.
Esto significa, que solo podemos encriptar textos en alfabeto latino y sin ñ.
Además, todos los clientes utilizarían la misma clave.
¿Qué pasa si a un cliente se le filtra la clave? ¡Todos nuestros clientes se verían comprometidos!
Es un gran hueco de seguridad que no podemos permitir en nuestra nueva startup de cifrado.
¿Qué podemos hacer para convertir nuestro programa en un módulo altamente reutilizable?
Refactoricemos.
Composición de funciones
En programación orientada a objetos tenemos muchas técnicas para reutilizar código. En programación funcional, la mejor técnica es la composición de funciones.
Ya hemos visto el caso de la ecuación, hemos pasado una pequeña función a una función más grande, alterando su comportamiento. También utilizamos la currificación, redujimos de dos a un parámetro a la función vigenere_cipher
.
Ahora, utilizaremos los closures.
Definiremos un closure como una función que adopta el scope de una función de orden superior. Esto quiere decir, que tendrá a su alcance todas las variables que existan en su función madre.
Veamos, para este ejercicio debemos comenzar identificando las constantes y a las funciones que las utilizan desde fuera de su scope.
Identifico a las constantes key
, alphabet
y L
.
Busquemos quienes dependen de estas constantes.encode_char
y decode_char
solo acceden a sus propios parámetros, bien.encode
y decode
acceden a su propio parámetro también.
La función repeat_key
utiliza la constante key
para inicializar su parámetro new_key
.
La función vigenere_cipher
accede a las constantes alphabet
y L
, y a su vez es la única función utiliza la función a repeat_key
, por ende podemos decir que también es dependiente de la constante key
.
Como dijimos anteriormente, queremos cambiar el abecedario y la clave del cifrado. Todo aquello que “cambie” implica mutabilidad, algo que no queremos. Podríamos convertir las constantes en parámetros de la función vigenere_cipher
y retornar la porción de código que tenemos actualmente.
Si modificamos la función, violamos el principio Open/Closed (abierto a extensión, pero cerrado al cambio).
Siempre tenemos que preguntarnos por qué refactorizamos y qué alternativas tenemos para evitar hacerlo si no hay nada roto.
Debido al mal diseño que teníamos anteriormente, me veo obligado a refactorizar destructivamente.
Borraremos las constantes ya que no son de nuestro interés.
Refactorizando:
Corremos las pruebas y… ¡Error!
Afortunadamente tengo mis tests que me protegen de cualquier problema antes de publicar el código.
¿Qué pasó? Olvidé por completo que la función repeat_key
dependía de la contante key
para inicializar el valor de su parámetro.
Vamos a corregir esta y el nombre de algunos parámetros, para hacerlos más descriptivos y genéricos. Otra cosa que haremos será crear un tipo para la función de vigenere_cipher
, así será más reutilizable y descriptivo el contrato.
Este es el resultado final.
¡Lo logramos! Ahora tenemos un código muy limpio, en un lenguaje de programación más robusto, respetando al máximo el paradigma funcional, e infinitamente reutilizable.
A lo mejor aún estés pensando en más cosas que podamos refactorizar, como la función de sustitución dentro del map, e incluso estés pensando en técnicas de optimización.
Te invito a pulir este código y a añadir la documentación necesaria.
En este momento, nuestros clientes están contentos y nuestros programadores también. Nuestra startup de cifrado es un auténtico éxito.
Estas son las enseñanzas que Martin Fowler nos deja en su primer capítulo de Refactoring.
En próximas entradas profundizaremos en técnicas de optimización.