Apuntes DAM
Volver al inicio

Refactoriza un código con malos olores: los gastos de envío

Ejercicio de JavaMedioUnos 60 minutos

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

  1. Refactoriza: una enum Zona con 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.
  2. 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), y Total: N pedidos enviados, X € de envío (1 pedido enviado).
  3. 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.
  4. Añade las zonas canarias y ceuta-melilla, que nunca tienen envío gratis ni urgente. Un pedido urgente a una de ellas: Pedido N: zona no tiene envío urgente.
  5. 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 es urgente, un número no es válido, el peso no es mayor que 0 o el importe es negativo; Pedido N: zona desconocida «x»; y Pedido 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

Tarifas por zona
ZonaBasePor kg por encima de 2Gratis desdeRecargo urgente
peninsula4,90 €0,60 €50 €5,00 €
baleares7,90 €0,90 €80 €8,00 €
portugal6,90 €0,80 €60 €6,00 €
canarias12,90 €1,50 €nuncano hay
ceuta-melilla14,90 €1,80 €nuncano 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.

java
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.

☕JavaRefactoriza un código con malos olores: los gastos de envíoMedio

Ejemplo

Entrada (lo que se escribe por teclado)
peninsula 3.5 45
peninsula 1 60 urgente
baleares 2 80
baleares 5,2 30 urgente
portugal 12 59.99
Salida esperada
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
⏳
Test oculto #3
⏳
Test oculto #4
0/4 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
java
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 coste antes de seguir refactorizando.
  • Añade un descuento del 50 % en el envío los fines de semana sin tocar la enum.

Dónde se explica