Refactoriza un código con malos olores: los gastos de envío
Te dan un programa que funciona pero huele mal: el mismo bloque copiado para cada zona, números mágicos, nombres de una letra y un contador por zona. Refactorízalo (una enum con la tarifa, métodos extraídos, Javadoc), y verás que salen solos el error escondido en una copia y las dos zonas nuevas.
- Malos olores: código duplicado y números mágicos
- Refactorización: extraer método
- Reemplazar condicionales por una enum
- Renombrar
- EnumMap y merge
- Javadoc
Enunciado
Refactorizar es cambiar la estructura del código sin cambiar lo que hace, para que sea más fácil de leer y de modificar. Los malos olores (code smells) son las señales de que hace falta: código duplicado, números mágicos, métodos larguísimos, nombres que no dicen nada, cadenas de if que crecen con cada caso nuevo.
El código de partida calcula los gastos de envío de una tienda y tiene todos esos olores: el bloque de cada zona está copiado y pegado tres veces con números distintos. Y como siempre que se copia y pega, una de las copias tiene un error: en Baleares el envío gratis se compara con > en lugar de >=. Ahora la tienda quiere enviar también a Canarias y a Ceuta y Melilla: con el código así, serían dos copias más.
Qué tiene que hacer el programa
- Refactoriza: una
enum Zonacon el nombre y la tarifa de cada zona, un método que calcule el coste a partir de la zona, el peso, el importe y si es urgente, constantes con nombre en lugar de números mágicos, nombres que se entiendan y un mapa por zona en lugar de un contador y un total por zona. Documenta con Javadoc la enum y el método del coste. - La salida no cambia para lo que ya funcionaba: cada pedido escribe
Pedido N (zona, P kg, I €): envío E €(P con un decimal; importes con dos y coma) seguido de(gratis),(urgente)o(gratis + urgente)cuando toca; y al final, por cada zona con pedidos enviados y en el orden de la tabla,zona: N pedidos, X € de envío(1 pedido), yTotal: N pedidos enviados, X € de envío(1 pedido enviado). - Coste: si el importe llega al mínimo para envío gratis (
>=), la tarifa es 0; si no, la base más el precio por kg de cada kg (o fracción) por encima de 2 kg. El recargo urgente se suma siempre, también al envío gratis. - Añade las zonas
canariasyceuta-melilla, que nunca tienen envío gratis ni urgente. Un pedido urgente a una de ellas:Pedido N: zona no tiene envío urgente. - Se mantienen los errores:
Línea no válida: línea(sin contar como pedido) si no hay 3 o 4 datos, el cuarto no esurgente, un número no es válido, el peso no es mayor que 0 o el importe es negativo;Pedido N: zona desconocida «x»; yPedido N: no admitido, pesa más de 30 kg, en ese orden de comprobación (zona, peso, urgente).
Entrada
Un pedido por línea: zona kg importe [urgente] (coma o punto decimal).
Datos de referencia
| Zona | Base | Por kg por encima de 2 | Gratis desde | Recargo urgente |
|---|---|---|---|---|
| peninsula | 4,90 € | 0,60 € | 50 € | 5,00 € |
| baleares | 7,90 € | 0,90 € | 80 € | 8,00 € |
| portugal | 6,90 € | 0,80 € | 60 € | 6,00 € |
| canarias | 12,90 € | 1,50 € | nunca | no hay |
| ceuta-melilla | 14,90 € | 1,80 € | nunca | no hay |
Ejemplos de ejecución
Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.
Las tres zonas de siempre
Entrada
peninsula 3.5 45 peninsula 1 60 urgente baleares 2 80 baleares 5,2 30 urgente portugal 12 59.99
Salida por consola
Pedido 1 (peninsula, 3,5 kg, 45,00 €): envío 6,10 € Pedido 2 (peninsula, 1,0 kg, 60,00 €): envío 5,00 € (gratis + urgente) Pedido 3 (baleares, 2,0 kg, 80,00 €): envío 0,00 € (gratis) Pedido 4 (baleares, 5,2 kg, 30,00 €): envío 19,50 € (urgente) Pedido 5 (portugal, 12,0 kg, 59,99 €): envío 14,90 € peninsula: 2 pedidos, 11,10 € de envío baleares: 2 pedidos, 19,50 € de envío portugal: 1 pedido, 14,90 € de envío Total: 5 pedidos enviados, 45,50 € de envío
Zonas nuevas y errores
Entrada
canarias 4 120 ceuta-melilla 2.5 30 canarias 1 10 urgente andorra 2 20 perninsula 2 portugal 31 20 portugal 3 20 rapido baleares -1 10
Salida por consola
Pedido 1 (canarias, 4,0 kg, 120,00 €): envío 15,90 € Pedido 2 (ceuta-melilla, 2,5 kg, 30,00 €): envío 16,70 € Pedido 3: canarias no tiene envío urgente Pedido 4: zona desconocida «andorra» Línea no válida: perninsula 2 Pedido 5: no admitido, pesa más de 30 kg Línea no válida: portugal 3 20 rapido Línea no válida: baleares -1 10 canarias: 1 pedido, 15,90 € de envío ceuta-melilla: 1 pedido, 16,70 € de envío Total: 2 pedidos enviados, 32,60 € de envío
Guía paso a paso
Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.
1. Primero, los datos
Lo único que cambia entre los bloques copiados son cinco números: llévalos a una enum, una constante por zona. Para «nunca gratis» vale Double.POSITIVE_INFINITY (ningún importe llega) y para «sin urgente», un valor especial con nombre.
enum Zona {
PENINSULA("peninsula", 4.90, 0.60, 50, 5.00),
BALEARES("baleares", 7.90, 0.90, 80, 8.00);
// ...
}2. Extraer método
El cálculo del bloque, ya sin números concretos, pasa a coste(zona, kg, importe, urgente). El formato de los euros, que se repite seis veces, a euros(x).
3. Un mapa en vez de tres contadores
EnumMap<Zona, Integer> guarda los pedidos de cada zona en el orden de la enum; merge(zona, 1, Integer::sum) suma uno sin preguntar si ya estaba.
4. Comprueba que nada se rompe
Ejecuta los dos tests antes y después de cada paso: refactorizar con pruebas es la única forma segura. Cuando todo esté en la enum, el error de Baleares desaparece solo y las zonas nuevas son dos líneas.
Resuélvelo aquí
El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 2 casos ocultos que buscan los errores típicos.
Ejemplo
peninsula 3.5 45 peninsula 1 60 urgente baleares 2 80 baleares 5,2 30 urgente portugal 12 59.99
Pedido 1 (peninsula, 3,5 kg, 45,00 €): envío 6,10 € Pedido 2 (peninsula, 1,0 kg, 60,00 €): envío 5,00 € (gratis + urgente) Pedido 3 (baleares, 2,0 kg, 80,00 €): envío 0,00 € (gratis) Pedido 4 (baleares, 5,2 kg, 30,00 €): envío 19,50 € (urgente) Pedido 5 (portugal, 12,0 kg, 59,99 €): envío 14,90 € peninsula: 2 pedidos, 11,10 € de envío baleares: 2 pedidos, 19,50 € de envío portugal: 1 pedido, 14,90 € de envío Total: 5 pedidos enviados, 45,50 € de envío
Solución explicada
Ver la solución completa
1import java.util.EnumMap;
2import java.util.Locale;
3import java.util.Map;
4import java.util.Scanner;
5
6/** Zonas de envío con su tarifa. Añadir una zona es añadir una línea. */
7enum Zona {
8 PENINSULA("peninsula", 4.90, 0.60, 50, 5.00),
9 BALEARES("baleares", 7.90, 0.90, 80, 8.00),
10 PORTUGAL("portugal", 6.90, 0.80, 60, 6.00),
11 CANARIAS("canarias", 12.90, 1.50, Zona.NUNCA, Zona.SIN_URGENTE),
12 CEUTA_MELILLA("ceuta-melilla", 14.90, 1.80, Zona.NUNCA, Zona.SIN_URGENTE);
13
14 static final double NUNCA = Double.POSITIVE_INFINITY;
15 static final double SIN_URGENTE = -1;
16
17 final String nombre;
18 final double base;
19 final double porKgExtra;
20 final double gratisDesde;
21 final double recargoUrgente;
22
23 Zona(String nombre, double base, double porKgExtra, double gratisDesde, double recargoUrgente) {
24 this.nombre = nombre;
25 this.base = base;
26 this.porKgExtra = porKgExtra;
27 this.gratisDesde = gratisDesde;
28 this.recargoUrgente = recargoUrgente;
29 }
30
31 /** @return la zona con ese nombre, o {@code null} si no hay ninguna */
32 static Zona buscar(String nombre) {
33 for (Zona z : values()) if (z.nombre.equals(nombre)) return z;
34 return null;
35 }
36
37 boolean tieneUrgente() {
38 return recargoUrgente != SIN_URGENTE;
39 }
40
41 boolean esGratis(double importe) {
42 return importe >= gratisDesde;
43 }
44}
45
46public class Main {
47 static final double KG_INCLUIDOS = 2;
48 static final double KG_MAXIMOS = 30;
49
50 /**
51 * Coste del envío de un pedido.
52 *
53 * @param zona zona de destino
54 * @param kg peso del paquete; los kg por encima de 2 se cobran enteros
55 * @param importe importe del pedido, para el envío gratis
56 * @param urgente si se pide envío urgente (la zona debe tenerlo)
57 * @return el coste en euros
58 */
59 static double coste(Zona zona, double kg, double importe, boolean urgente) {
60 double coste = zona.esGratis(importe) ? 0 : zona.base + Math.ceil(Math.max(0, kg - KG_INCLUIDOS)) * zona.porKgExtra;
61 return urgente ? coste + zona.recargoUrgente : coste;
62 }
63
64 static String decimal(double x, int decimales) {
65 return String.format(Locale.ROOT, "%." + decimales + "f", x).replace('.', ',');
66 }
67
68 static String euros(double x) {
69 return decimal(x, 2) + " €";
70 }
71
72 static String pedidos(int n) {
73 return n + (n == 1 ? " pedido" : " pedidos");
74 }
75
76 public static void main(String[] args) {
77 Scanner sc = new Scanner(System.in);
78 Map<Zona, Integer> enviados = new EnumMap<>(Zona.class);
79 Map<Zona, Double> facturado = new EnumMap<>(Zona.class);
80 int numero = 0;
81 while (sc.hasNextLine()) {
82 String linea = sc.nextLine().trim();
83 if (linea.isEmpty()) continue;
84 String[] p = linea.split("\\s+");
85 double kg, importe;
86 try {
87 if (p.length < 3 || p.length > 4 || (p.length == 4 && !p[3].equals("urgente"))) throw new NumberFormatException();
88 kg = Double.parseDouble(p[1].replace(',', '.'));
89 importe = Double.parseDouble(p[2].replace(',', '.'));
90 if (kg <= 0 || importe < 0) throw new NumberFormatException();
91 } catch (NumberFormatException e) {
92 System.out.println("Línea no válida: " + linea);
93 continue;
94 }
95 numero++;
96 boolean urgente = p.length == 4;
97 Zona zona = Zona.buscar(p[0]);
98 String error = null;
99 if (zona == null) error = "zona desconocida «" + p[0] + "»";
100 else if (kg > KG_MAXIMOS) error = "no admitido, pesa más de 30 kg";
101 else if (urgente && !zona.tieneUrgente()) error = zona.nombre + " no tiene envío urgente";
102 if (error != null) {
103 System.out.println("Pedido " + numero + ": " + error);
104 continue;
105 }
106 double coste = coste(zona, kg, importe, urgente);
107 String nota = zona.esGratis(importe) ? (urgente ? " (gratis + urgente)" : " (gratis)") : (urgente ? " (urgente)" : "");
108 System.out.println("Pedido " + numero + " (" + zona.nombre + ", " + decimal(kg, 1) + " kg, " + euros(importe) + "): envío " + euros(coste) + nota);
109 enviados.merge(zona, 1, Integer::sum);
110 facturado.merge(zona, coste, Double::sum);
111 }
112 int total = 0;
113 double suma = 0;
114 for (Zona z : enviados.keySet()) {
115 System.out.println(z.nombre + ": " + pedidos(enviados.get(z)) + ", " + euros(facturado.get(z)) + " de envío");
116 total += enviados.get(z);
117 suma += facturado.get(z);
118 }
119 System.out.println("Total: " + pedidos(total) + (total == 1 ? " enviado, " : " enviados, ") + euros(suma) + " de envío");
120 }
121}El error de Baleares es el precio del código duplicado: cada copia es una oportunidad de equivocarse, y un arreglo en una copia no llega a las demás. Con una sola fórmula, o está bien para todas o mal para todas, y se ve enseguida.
Reemplazar la cadena de if por una enum con datos es la refactorización «reemplazar condicional por polimorfismo» en su versión más sencilla: el comportamiento depende de los datos de cada constante, no de una rama del código.
Los números mágicos con nombre (KG_INCLUIDOS, KG_MAXIMOS) cuentan qué significan: kg - 2 obliga a adivinar; kg - KG_INCLUIDOS no.
Analizadores como PMD o SonarLint detectan muchos de estos olores solos: código duplicado, métodos demasiado largos o complejidad ciclomática alta (el main de partida pasa de 20).
Para ir más allá
- Lee las tarifas de un fichero CSV en lugar de tenerlas en la enum.
- Escribe las pruebas JUnit del método
costeantes de seguir refactorizando. - Añade un descuento del 50 % en el envío los fines de semana sin tocar la enum.