Refactorización en pasos pequeños
En resumen: La refactorización cambia la estructura del código sin cambiar su comportamiento, en pasos pequeños con nombre como Rename y Extract Method, con las pruebas en verde después de cada uno.
Problema
La factura que nadie quiere tocar
La tienda online imprime las facturas con la función inv. Lleva dos años funcionando:
def inv(n, street, city, zip, items, type): t = 0 for i in items: t = t + i.price * i.qty d = 0 if type == "gold": d = t * 0.05 elif type == "company": if t >= 500: d = t * 0.10 s = "Factura: " + n + "\n" s = s + street + ", " + zip + " " + city + "\n" s = s + f"Importe: {t:.2f} €\n" s = s + f"Descuento: {d:.2f} €\n" s = s + f"Total con IVA: {after_discount(t, d) * 1.20:.2f} €" return sdef after_discount(t, d): return t - d¿Qué es t? ¿Y d? Seis parámetros seguidos, cuatro de ellos texto: quien cambie de sitio la ciudad y el código postal obtiene una factura con «Madrid 28013» en el lugar equivocado, y el compilador no dice nada. La cadena de if según el tipo de cliente sigue creciendo: el mes que viene llegan los socios con un 3 %. Y after_discount hace una sola resta, pero te obliga a saltar a otro sitio para verla.
Quieres ordenarla. Pero las facturas van a contabilidad, y un error en ellas es un error en los impuestos.
La idea
Refactorización: la forma cambia, el comportamiento no
Refactorizar es cambiar la forma del código manteniendo el comportamiento: las mismas entradas dan las mismas salidas. No es «reescribir». Tampoco es «ya que estaba, arreglé un error». Esos son otros cambios, que se hacen por separado.
Se hace en pasos pequeños con nombre. Cada paso es tan pequeño que apenas puede salir mal, y después de cada uno ejecutas las pruebas (la red de seguridad de la lección anterior). Los nombres vienen del catálogo de Martin Fowler y son los mismos en cualquier lenguaje y cualquier IDE: Rename, Extract Method, Inline, Introduce Parameter Object, Replace Conditional with Polymorphism.
Primero va la red. Cuatro pruebas fijan los cuatro tipos de factura que la tienda imprime hoy: un cliente normal, un cliente oro, una empresa por encima y por debajo de 500 €.
COFFEE = [Item("Café en grano", 12.50, 2), Item("Taza", 8.00, 1)]MORE_COFFEE = [Item("Café en grano", 12.50, 4)]FLOUR = [Item("Harina 25 kg", 30.00, 20)]
class InvoiceTest(unittest.TestCase): def test_regular(self): self.assertEqual( inv("María Torres", "Calle Mayor 5", "Madrid", "28013", COFFEE, "regular"), "Factura: María Torres\nCalle Mayor 5, 28013 Madrid\nImporte: 33.00 €\nDescuento: 0.00 €\nTotal con IVA: 39.60 €", )
def test_gold(self): self.assertEqual( inv("Lucía Navarro", "Calle Alcalá 12", "Sevilla", "41001", MORE_COFFEE, "gold"), "Factura: Lucía Navarro\nCalle Alcalá 12, 41001 Sevilla\nImporte: 50.00 €\nDescuento: 2.50 €\nTotal con IVA: 57.00 €", )
def test_company_from_500(self): self.assertEqual( inv("Panadería Amanecer S.L.", "Avenida del Puerto 80", "Valencia", "46001", FLOUR, "company"), "Factura: Panadería Amanecer S.L.\nAvenida del Puerto 80, 46001 Valencia\nImporte: 600.00 €\nDescuento: 60.00 €\nTotal con IVA: 648.00 €", )
def test_company_under_500(self): self.assertEqual( inv("Panadería Amanecer S.L.", "Avenida del Puerto 80", "Valencia", "46001", COFFEE, "company"), "Factura: Panadería Amanecer S.L.\nAvenida del Puerto 80, 46001 Valencia\nImporte: 33.00 €\nDescuento: 0.00 €\nTotal con IVA: 39.60 €", )OK test_company_from_500OK test_company_under_500OK test_goldOK test_regularPruebas: 4, pasaron: 4, fallaron: 0Míralo
Paso 1: Rename
Los nombres de una letra pasan a ser palabras: t es subtotal, d es discount, s es text, inv es invoice. zip y type también son nombres de funciones integradas de Python, que los parámetros ocultan. Pasan a ser postcode y customer_type.
def invoice(name, street, city, postcode, items, customer_type): subtotal = 0 for item in items: subtotal = subtotal + item.price * item.qty discount = 0 if customer_type == "gold": discount = subtotal * 0.05 elif customer_type == "company": if subtotal >= 500: discount = subtotal * 0.10 text = "Factura: " + name + "\n" text = text + street + ", " + postcode + " " + city + "\n" text = text + f"Importe: {subtotal:.2f} €\n" text = text + f"Descuento: {discount:.2f} €\n" text = text + f"Total con IVA: {after_discount(subtotal, discount) * 1.20:.2f} €" return textOK test_company_from_500OK test_company_under_500OK test_goldOK test_regularPruebas: 4, pasaron: 4, fallaron: 0Rename también cambia todos los sitios que llaman a la función, incluidas las pruebas. Las facturas esperadas no se tocan.
En PyCharm es Mayús+F6, en VS Code es F2. Sin tipos, sin embargo, el IDE a veces adivina: un nombre dentro de una cadena,getattr(obj, "name") o una clave de diccionario no son vínculos que pueda ver. Por eso en Python, después de un cambio de nombre, las pruebas te dicen más que en Java o C#.Míralo
Paso 2: Extract Method
La función hace dos trabajos distintos que no tienen nada que ver con el texto de la factura: el total de las líneas y el descuento. Cada uno pasa a una función propia, con un nombre que dice qué devuelve. Eso es Extract Method:
def invoice(name, street, city, postcode, items, customer_type): subtotal = items_total(items) discount = discount_for(customer_type, subtotal) text = "Factura: " + name + "\n" text = text + street + ", " + postcode + " " + city + "\n" text = text + f"Importe: {subtotal:.2f} €\n" text = text + f"Descuento: {discount:.2f} €\n" text = text + f"Total con IVA: {after_discount(subtotal, discount) * 1.20:.2f} €" return textdef items_total(items): total = 0 for item in items: total = total + item.price * item.qty return total
def discount_for(customer_type, subtotal): discount = 0 if customer_type == "gold": discount = subtotal * 0.05 elif customer_type == "company": if subtotal >= 500: discount = subtotal * 0.10 return discountOK test_company_from_500OK test_company_under_500OK test_goldOK test_regularPruebas: 4, pasaron: 4, fallaron: 0Ahora invoice se lee de arriba abajo como una lista: cuánto es, cuánto es el descuento, cuál es el texto. Los detalles están a un salto de distancia, solo si los necesitas.
El IDE mira qué variables entran en las líneas seleccionadas y cuáles salen, y crea los parámetros y el valor de retorno. Si haces el paso a mano, es justo ahí donde se tuerce.
Pruébalo
Predice la salida
Alguien hace Extract Method a mano. El descuento pasa a una función que lo guarda en su propio parámetro en lugar de devolverlo:
def invoice(name, street, city, postcode, items, customer_type): subtotal = items_total(items) discount = 0 apply_discount(customer_type, subtotal, discount) text = "Factura: " + name + "\n" text = text + street + ", " + postcode + " " + city + "\n" text = text + f"Importe: {subtotal:.2f} €\n" text = text + f"Descuento: {discount:.2f} €\n" text = text + f"Total con IVA: {after_discount(subtotal, discount) * 1.20:.2f} €" return text
def apply_discount(customer_type, subtotal, discount): if customer_type == "gold": discount = subtotal * 0.05 elif customer_type == "company": if subtotal >= 500: discount = subtotal * 0.10Más precisamente, para curiosos
FALLA test_company_from_500FALLA test_goldOK test_company_under_500OK test_regularPruebas: 4, pasaron: 2, fallaron: 2discount dentro de apply_discount es una copia del valor. El valor nuevo se queda allí dentro y desaparece cuando termina la función, y fuera discount sigue siendo 0. Donde el descuento ya es 0 de todos modos (un cliente normal, una empresa por debajo de 500 €), las pruebas pasan. Por eso la red tiene una prueba para cada rama del if. El código extraído tiene que devolver el valor, como lo hace el IDE.
Míralo
Paso 3: Inline
Lo contrario de Extract Method. after_discount hace una resta, y su nombre no dice nada más que subtotal - discount. Se usa en un solo sitio. El cuerpo va donde está la llamada, y la función desaparece. Eso es Inline:
def after_discount(subtotal, discount): return subtotal - discountdef invoice(name, street, city, postcode, items, customer_type): subtotal = items_total(items) discount = discount_for(customer_type, subtotal) text = "Factura: " + name + "\n" text = text + street + ", " + postcode + " " + city + "\n" text = text + f"Importe: {subtotal:.2f} €\n" text = text + f"Descuento: {discount:.2f} €\n" text = text + f"Total con IVA: {(subtotal - discount) * 1.20:.2f} €" return textOK test_company_from_500OK test_company_under_500OK test_goldOK test_regularPruebas: 4, pasaron: 4, fallaron: 0Extract Method e Inline no se contradicen. Extraes cuando el nombre explica algo que el código no dice (items_total). Lo devuelves a su sitio cuando el nombre es solo un salto (after_discount). En PyCharm es Ctrl+Alt+N.
Míralo
Paso 4: Introduce Parameter Object
El nombre, la calle, la ciudad y el código postal siempre viajan juntos, y sin embargo son cuatro parámetros seguidos. Un intercambio no se nota:
invoice("María Torres", "Calle Mayor 5", "28013", "Madrid", COFFEE, "regular")Factura: María TorresCalle Mayor 5, Madrid 28013Importe: 33.00 €Descuento: 0.00 €Total con IVA: 39.60 €Los cuatro pasan a ser un objeto: el cliente. Eso es Introduce Parameter Object.
@dataclass escribe __init__ y la comparación:
@dataclass(frozen=True)class Customer: name: str street: str city: str postcode: strdef invoice(customer, items, customer_type): subtotal = items_total(items) discount = discount_for(customer_type, subtotal) text = "Factura: " + customer.name + "\n" text = text + customer.street + ", " + customer.postcode + " " + customer.city + "\n" text = text + f"Importe: {subtotal:.2f} €\n" text = text + f"Descuento: {discount:.2f} €\n" text = text + f"Total con IVA: {(subtotal - discount) * 1.20:.2f} €" return textOK test_company_from_500OK test_company_under_500OK test_goldOK test_regularPruebas: 4, pasaron: 4, fallaron: 0Las llamadas en las pruebas cambian con la forma, y las facturas esperadas se quedan igual. Aquí está una llamada a través de los pasos:
inv("Lucía Navarro", "Calle Alcalá 12", "Sevilla", "41001", MORE_COFFEE, "gold")invoice("Lucía Navarro", "Calle Alcalá 12", "Sevilla", "41001", MORE_COFFEE, "gold")invoice(Customer("Lucía Navarro", "Calle Alcalá 12", "Sevilla", "41001"), MORE_COFFEE, "gold")invoice(Customer("Lucía Navarro", "Calle Alcalá 12", "Sevilla", "41001"), MORE_COFFEE, Gold())Customer(name="...", city="...", ...). Entonces el orden no importa.Míralo
Paso 5: Replace Conditional with Polymorphism
Lo que queda es la cadena de if según el tipo de cliente en discount_for. Cada tipo nuevo es una rama nueva aquí, y mañana también en otros sitios (el correo, el informe). Cada tipo pasa a ser una clase con un método discount, y conoce su propio descuento:
class Regular: def discount(self, subtotal): return 0
class Gold: def discount(self, subtotal): return subtotal * 0.05
class Company: def discount(self, subtotal): if subtotal >= 500: return subtotal * 0.10 return 0def invoice(customer, items, customer_type): subtotal = items_total(items) discount = customer_type.discount(subtotal) text = "Factura: " + customer.name + "\n" text = text + customer.street + ", " + customer.postcode + " " + customer.city + "\n" text = text + f"Importe: {subtotal:.2f} €\n" text = text + f"Descuento: {discount:.2f} €\n" text = text + f"Total con IVA: {(subtotal - discount) * 1.20:.2f} €" return textOK test_company_from_500OK test_company_under_500OK test_goldOK test_regularPruebas: 4, pasaron: 4, fallaron: 0discount, y la factura lo llama (duck typing). Si la regla es un único cálculo, basta con una función en una tabla, como en la lección de Strategy.Esto es Strategy del módulo de patrones: la regla es un objeto aparte y la factura solo pregunta. Si el tipo de cliente cambia mientras el programa se ejecuta (pasa a ser oro tras el décimo pedido), el mismo movimiento lleva a State. El texto "gold" de la base de datos se convierte en un objeto en un solo sitio, a la entrada, con una tabla como en la lección de Strategy.
@dataclass(frozen=True)class Customer: name: str street: str city: str postcode: str
def invoice(customer, items, customer_type): subtotal = items_total(items) discount = customer_type.discount(subtotal) text = "Factura: " + customer.name + "\n" text = text + customer.street + ", " + customer.postcode + " " + customer.city + "\n" text = text + f"Importe: {subtotal:.2f} €\n" text = text + f"Descuento: {discount:.2f} €\n" text = text + f"Total con IVA: {(subtotal - discount) * 1.20:.2f} €" return textdef inv(n, street, city, zip, items, type): t = 0 for i in items: t = t + i.price * i.qty d = 0 if type == "gold": d = t * 0.05 elif type == "company": if t >= 500: d = t * 0.10 s = "Factura: " + n + "\n" s = s + street + ", " + zip + " " + city + "\n" s = s + f"Importe: {t:.2f} €\n" s = s + f"Descuento: {d:.2f} €\n" s = s + f"Total con IVA: {after_discount(t, d) * 1.20:.2f} €" return sdef invoice(name, street, city, postcode, items, customer_type): subtotal = 0 for item in items: subtotal = subtotal + item.price * item.qty discount = 0 if customer_type == "gold": discount = subtotal * 0.05 elif customer_type == "company": if subtotal >= 500: discount = subtotal * 0.10 text = "Factura: " + name + "\n" text = text + street + ", " + postcode + " " + city + "\n" text = text + f"Importe: {subtotal:.2f} €\n" text = text + f"Descuento: {discount:.2f} €\n" text = text + f"Total con IVA: {after_discount(subtotal, discount) * 1.20:.2f} €" return textdef invoice(name, street, city, postcode, items, customer_type): subtotal = items_total(items) discount = discount_for(customer_type, subtotal) text = "Factura: " + name + "\n" text = text + street + ", " + postcode + " " + city + "\n" text = text + f"Importe: {subtotal:.2f} €\n" text = text + f"Descuento: {discount:.2f} €\n" text = text + f"Total con IVA: {after_discount(subtotal, discount) * 1.20:.2f} €" return textdef invoice(name, street, city, postcode, items, customer_type): subtotal = items_total(items) discount = discount_for(customer_type, subtotal) text = "Factura: " + name + "\n" text = text + street + ", " + postcode + " " + city + "\n" text = text + f"Importe: {subtotal:.2f} €\n" text = text + f"Descuento: {discount:.2f} €\n" text = text + f"Total con IVA: {(subtotal - discount) * 1.20:.2f} €" return text@dataclass(frozen=True)class Customer: name: str street: str city: str postcode: str
def invoice(customer, items, customer_type): subtotal = items_total(items) discount = discount_for(customer_type, subtotal) text = "Factura: " + customer.name + "\n" text = text + customer.street + ", " + customer.postcode + " " + customer.city + "\n" text = text + f"Importe: {subtotal:.2f} €\n" text = text + f"Descuento: {discount:.2f} €\n" text = text + f"Total con IVA: {(subtotal - discount) * 1.20:.2f} €" return text@dataclass(frozen=True)class Customer: name: str street: str city: str postcode: str
def invoice(customer, items, customer_type): subtotal = items_total(items) discount = customer_type.discount(subtotal) text = "Factura: " + customer.name + "\n" text = text + customer.street + ", " + customer.postcode + " " + customer.city + "\n" text = text + f"Importe: {subtotal:.2f} €\n" text = text + f"Descuento: {discount:.2f} €\n" text = text + f"Total con IVA: {(subtotal - discount) * 1.20:.2f} €" return textPaso a paso, en palabras
- La función antes del primer paso: 15 líneas en un solo bloque. Abajo están las cuatro pruebas y los totales de las cuatro facturas.
- Rename: t pasa a ser subtotal, d pasa a ser discount, s pasa a ser text. Las pruebas están en verde, los totales son los mismos.
- Extract Method: el bucle va a items_total, la cadena de if a discount_for. En su lugar queda una línea para cada uno.
- Inline: after_discount solo restaba. Su cuerpo vuelve a la línea y la función desaparece.
- Introduce Parameter Object: nombre, calle, ciudad y código postal pasan a ser un solo cliente.
- Replace Conditional with Polymorphism: el if desaparece. Cada tipo de cliente es una clase, y la función llama a customer_type.discount(subtotal).
- Cinco pasos. La forma de invoice es otra, pero las cuatro pruebas y las cuatro facturas son las mismas que al principio.
El programa que imprime las cuatro facturas se ejecutó con el código de antes del primer paso y con el código después del quinto. La salida es la misma:
Factura: María TorresCalle Mayor 5, 28013 MadridImporte: 33.00 €Descuento: 0.00 €Total con IVA: 39.60 €
Factura: Lucía NavarroCalle Alcalá 12, 41001 SevillaImporte: 50.00 €Descuento: 2.50 €Total con IVA: 57.00 €
Factura: Panadería Amanecer S.L.Avenida del Puerto 80, 46001 ValenciaImporte: 600.00 €Descuento: 60.00 €Total con IVA: 648.00 €
Factura: Panadería Amanecer S.L.Avenida del Puerto 80, 46001 ValenciaImporte: 33.00 €Descuento: 0.00 €Total con IVA: 39.60 €Pruébalo
Encuentra el error
Alguien escribe así la clase para una empresa:
class Company: def discount(self, subtotal): return subtotal * 0.10Una empresa pide café por 33 €:
invoice(Customer("Panadería Amanecer S.L.", "Avenida del Puerto 80", "Valencia", "46001"), COFFEE, Company())Factura: Panadería Amanecer S.L.Avenida del Puerto 80, 46001 ValenciaImporte: 33.00 €Descuento: 3.30 €Total con IVA: 35.64 €¿En qué línea está el error?
Elige la línea con el error.
Cuando la rama de la empresa pasó del if a una clase, se quedó fuera la comprobación subtotal >= 500. Una empresa por debajo de 500 € recibe un 10 %. La prueba de una empresa por debajo de 500 € falla enseguida: test_company_under_500. Sin ella, las empresas con pedidos pequeños habrían pagado menos durante meses.
Más precisamente, para curiosos
FALLA test_company_under_500OK test_company_from_500OK test_goldOK test_regularPruebas: 4, pasaron: 3, fallaron: 1La idea
Cuándo parar
La refactorización no tiene fin si no le pones uno. Paras cuando el código es lo bastante claro para el cambio que viene. ¿La tarea es añadir socios con un 3 %? El paso 5 lo convierte en una clase nueva. ¿La tarea es otra? Quizá no necesites el paso 5.
No merece la pena cuando:
- nadie toca ni lee el código. El código feo que funciona y no cambia no es urgente;
- hay dos variantes y no van a crecer. Un
ifes más claro que una interfaz y dos clases; - no hay red debajo del código. Entonces el primer paso es la red, no la refactorización.
Y una regla: no mezclar. Cambias la forma o el comportamiento, nunca los dos en un mismo paso. Kent Beck lo llama «los dos sombreros»: con uno reorganizas y las pruebas no se tocan, con el otro añades y escribes una prueba nueva. Cambias de sombrero a propósito.
Ejercicio
Socios con un 3 %
Resuelve la tarea en tu editor.
Socios. La tienda añade el tipo de cliente «socio» con un 3 % de descuento en cada pedido. Empieza con el código después del paso 5. Primero escribe una prueba para un socio con el pedido de 33 €, luego la clase Partner. Las demás clases y invoice no se tocan.
Factura: María TorresCalle Mayor 5, 28013 MadridImporte: 33.00 €Descuento: 0.99 €Total con IVA: 38.41 €Más tareas
- Extract Method para el texto. Extrae la línea de la dirección a una función address_line(customer). Ejecuta las pruebas después del paso.
- El otro orden. Haz los cinco pasos en otro orden: primero Introduce Parameter Object y luego Rename. ¿Pasan las pruebas después de cada uno? ¿Qué paso era más fácil antes?
- El tipo desde la base de datos. El tipo de cliente llega como texto (
"gold"). Escribe la tabla que lo convierte en un objeto y decide qué pasa con un tipo desconocido. El código de hoy da un descuento de 0 para un tipo desconocido: mantenlo con una prueba.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Nombres en el código · Funciones: una tarea, pocos parámetros, sin efectos secundarios ocultos · Code smells: método largo, clase grande, duplicación, primitivos en vez de tipos, cadenas largas de if · Las pruebas como red de seguridad
- Continúa en: SOLID sin dogma · Ejercicio: una clase desordenada, limpiada paso a paso
- Ejercicios sobre el tema: Código limpio y refactorización