Guía de Enums en Java

Guía de Enums en Java

1. Introducción

En este tutorial, aprenderemos qué son los enumerados (enums) en Java, los problemas que resuelven y cómo algunos de sus patrones de diseño pueden utilizarse en la práctica.

Java 5 introdujo por primera vez la palabra clave enum. Esta denota un tipo especial de clase que siempre extiende la clase java.lang.Enum. Para la documentación oficial sobre su uso, podemos dirigirnos a la documentación.

Las constantes definidas de esta manera hacen que el código sea más legible, permiten la verificación en tiempo de compilación, documentan la lista de valores aceptados de antemano y evitan comportamientos inesperados debido a valores no válidos que se pasan.

Aquí hay un ejemplo rápido y simple de un enumerado que define el estado de un pedido de pizza; el estado del pedido puede ser ORDENADO, LISTO o ENTREGADO:

public enum EstadoPizza {
    ORDENADO,
    LISTO,
    ENTREGADO; 
}

Además, los enumerados vienen con muchos métodos útiles que de otra manera tendríamos que escribir si estuviéramos usando constantes tradicionales public static final.

2. Métodos Personalizados en Enumerados

Ahora que tenemos una comprensión básica de qué son los enumerados y cómo podemos usarlos, llevaremos nuestro ejemplo anterior al siguiente nivel definiendo algunos métodos adicionales de la API en el enumerado:

public class Pizza {
    private EstadoPizza estado;
    public enum EstadoPizza {
        ORDENADO,
        LISTO,
        ENTREGADO;
    }

    public boolean esEntregable() {
        return obtenerEstado() == EstadoPizza.LISTO;
    }
    
    // Métodos que establecen y obtienen la variable de estado.
}

3. Comparación de Tipos de Enumerados Usando el Operador "=="

Dado que los tipos de enumerados aseguran que solo exista una instancia de las constantes en la JVM, podemos usar de manera segura el operador "" para comparar dos variables, como hicimos en el ejemplo anterior. Además, el operador "" proporciona seguridad en tiempo de compilación y tiempo de ejecución.

Primero, analizaremos la seguridad en tiempo de ejecución en el siguiente fragmento, donde usaremos el operador "==" para comparar estados. Cualquiera de los valores puede ser null y no obtendremos un NullPointerException. Por el contrario, si usamos el método equals, obtendremos un NullPointerException:

if (testPz.obtenerEstado().equals(Pizza.EstadoPizza.ENTREGADO)); 
if (testPz.obtenerEstado() == Pizza.EstadoPizza.ENTREGADO);

En cuanto a la seguridad en tiempo de compilación, veamos un ejemplo donde determinaremos que un enumerado de un tipo diferente es igual al compararlo usando el método equals. Esto se debe a que los valores del enumerado y el método obtenerEstado coinciden coincidentemente; sin embargo, lógicamente la comparación debería ser falsa. Evitamos este problema usando el operador "==".

El compilador señalará la comparación como un error de incompatibilidad:

if (testPz.obtenerEstado().equals(TestColor.VERDE));
if (testPz.obtenerEstado() == TestColor.VERDE);

4. Uso de Tipos Enumerados en Switch

Podemos usar tipos enumerados en instrucciones switch también:

public int obtenerTiempoEntregaEnDias() {
    switch (estado) {
        case ORDENADO: return 5;
        case LISTO: return 2;
        case ENTREGADO: return 0;
    }
    return 0;
}

5. Campos, Métodos y Constructores en Enumerados

Podemos definir constructores, métodos y campos dentro de tipos enumerados, lo que los hace muy poderosos.

A continuación, ampliemos el ejemplo anterior implementando la transición de una etapa de un pedido de pizza a otra. Veremos cómo podemos eliminar las instrucciones if y switch utilizadas anteriormente:

public class Pizza {

    private EstadoPizza estado;
    public enum EstadoPizza {
        ORDENADO (5) {
            @Override
            public boolean esOrdenado() {
                return true;
            }
        },
        LISTO (2) {
            @Override
            public boolean esListo() {
                return true;
            }
        },
        ENTREGADO (0) {
            @Override
            public boolean esEntregado() {
                return true;
            }
        };

        private int tiempoEntrega;

        public boolean esOrdenado() { return false; }

        public boolean esListo() { return false; }

        public boolean esEntregado() { return false; }

        public int obtenerTiempoEntrega() {
            return tiempoEntrega;
        }

        EstadoPizza (int tiempoEntrega) {
            this.tiempoEntrega = tiempoEntrega;
        }
    }

    public boolean esEntregable() {
        return this.estado.esListo();
    }

    public void imprimirTiempoEntrega() {
        System.out.println("El tiempo de entrega es de " + 
          this.obtenerEstado().obtenerTiempoEntrega() + " días");
    }
    
    // Métodos que establecen y obtienen la variable de estado.
}

El fragmento de prueba a continuación demuestra cómo funciona esto:

@Test
public void dadoPedidoPizza_cuandoListo_entoncesEntregable() {
    Pizza testPz = new Pizza();
    testPz.setEstado(Pizza.EstadoPizza.LISTO);
    assertTrue(testPz.esEntregable());
}

6. EnumSet y EnumMap

6.1. EnumSet

El EnumSet es una implementación especializada de Set destinada a usarse con tipos Enum.

En comparación con un HashSet, es una representación muy eficiente y compacta de un conjunto particular de constantes Enum, gracias a la Representación Interna de Vector de Bits que se utiliza. También proporciona una alternativa segura al tipo basado en int tradicional llamado "bit flags", lo que nos permite escribir código conciso y más legible.

El EnumSet es una clase abstracta que tiene dos implementaciones, RegularEnumSet y JumboEnumSet, una de las cuales se elige según la cantidad de constantes en el enumerado en el momento de la instanciación.

Por lo tanto, es una buena idea usar este conjunto siempre que deseemos trabajar con una colección de constantes Enum en la mayoría de los escenarios (como subconjuntos, adiciones, eliminaciones y operaciones masivas como containsAll y removeAll), y usar Enum.values() si solo queremos iterar sobre todas las constantes posibles.

En el fragmento de código a continuación, podemos ver cómo usar EnumSet para crear un subconjunto de constantes:

public class Pizza {

    private static EnumSet<EstadoPizza> estadosPizzaNoEntregada =
      EnumSet.of(EstadoPizza.ORDENADO, EstadoPizza.LISTO);

    private EstadoPizza estado;

    public enum EstadoPizza {
        // ...
    }

    public boolean esEntregable() {
        return this.estado.esListo();
    }

    public void imprimirTiempoEntrega() {
        System.out.println("El tiempo de entrega es de " + 
          this.obtenerEstado().obtenerTiempoEntrega() + " días");
    }

    public static List<Pizza> obtenerTodasLasPizzasNoEntregadas(List<Pizza> input) {
        return input.stream().filter(
          (s) -> estadosPizzaNoEntregada.contains(s.obtenerEstado()))
            .collect(Collectors.toList());
    }

    public void entregar() { 
        if (esEntregable()) { 
            ConfiguracionSistemaEntregaPizza.obtenerInstancia().obtenerEstrategiaEntrega()
              .entregar(this); 
            this.setEstado(EstadoPizza.ENTREGADO); 
        } 
    }
    
    // Métodos que establecen y obtienen la variable de estado.
}

Ejecutar la siguiente prueba demuestra el poder de la implementación EnumSet de la interfaz Set:

@Test
public void dadoPedidosPizzas_cuandoRecuperandoPizzasNoEntregadas_entoncesRecuperadasCorrectamente() {
    List<Pizza> listaPizzas = new ArrayList<>();
    Pizza pizza1 = new Pizza();
    pizza1.setEstado(Pizza.EstadoPizza.ENTREGADO);

    Pizza pizza2 = new Pizza();
    pizza2.setEstado(Pizza.EstadoPizza.ORDENADO);

    Pizza pizza3 = new Pizza();
    pizza3.setEstado(Pizza.EstadoPizza.ORDENADO);

    Pizza pizza4 = new Pizza();
    pizza4.setEstado(Pizza.EstadoPizza.LISTO);

    listaPizzas.add(pizza1);
    listaPizzas.add(pizza2);
    listaPizzas.add(pizza3);
    listaPizzas.add(pizza4);

    List<Pizza> pizzasNoEntregadas = Pizza.obtenerTodasLasPizzasNoEntregadas(listaPizzas); 
    assertTrue(pizzasNoEntregadas.size() == 3); 
}

6.2. EnumMap

EnumMap es una implementación especializada de Map destinada a usarse con constantes Enum como claves. En comparación con su contraparte HashMap, es una implementación eficiente y compacta que se representa internamente como un array:

EnumMap<Pizza.EstadoPizza, Pizza> mapa;

Veamos un ejemplo de cómo podemos usarlo en la práctica:

public static EnumMap<EstadoPizza, List<Pizza>> agruparPizzasPorEstado(List<Pizza> listaPizzas) {
    EnumMap<EstadoPizza, List<Pizza>> pizzasPorEstado = new EnumMap<EstadoPizza, List<Pizza>>(EstadoPizza.class);
    
    for (Pizza pizza : listaPizzas) {
        EstadoPizza estado = pizza.obtenerEstado();

        if (pizzasPorEstado.containsKey(estado)) {
            pizzasPorEstado.get(estado).add(pizza);
        } else {
            List<Pizza> nuevaListaPizzas = new ArrayList<Pizza>();
            
            nuevaListaPizzas.add(pizza);
            pizzasPorEstado.put(estado, nuevaListaPizzas);
        }
    }
    
    return pizzasPorEstado;
}

Ejecutar la siguiente prueba demuestra el poder de la implementación EnumMap de la interfaz Map:

@Test
public void dadoPedidosPizzas_cuandoLlamamosAgruparPorEstado_entoncesAgrupadosCorrectamente() {
    List<Pizza> listaPizzas = new ArrayList<>();
    Pizza pizza1 = new Pizza();
    pizza1.setEstado(Pizza.EstadoPizza.ENTREGADO);

    Pizza pizza2 = new Pizza();
    pizza2.setEstado(Pizza.EstadoPizza.ORDENADO);

    Pizza pizza3 = new Pizza();
    pizza3.setEstado(Pizza.EstadoPizza.ORDENADO);

    Pizza pizza4 = new Pizza();
    pizza4.setEstado(Pizza.EstadoPizza.LISTO);

    listaPizzas.add(pizza1);
    listaPizzas.add(pizza2);
    listaPizzas.add(pizza3);
    listaPizzas.add(pizza4);

    EnumMap<Pizza.EstadoPizza, List<Pizza>> mapa = Pizza.agruparPizzasPorEstado(listaPizzas);
    assertTrue(mapa.get(Pizza.EstadoPizza.ENTREGADO).size() == 1);
    assertTrue(mapa.get(Pizza.EstadoPizza.ORDENADO).size() == 2);
    assertTrue(mapa.get(Pizza.EstadoPizza.LISTO).size() == 1);
}

7. Implementar Patrones de Diseño Usando Enums

7.1. Patrón Singleton

Normalmente, implementar una clase usando el patrón Singleton es bastante no trivial. Los enums proporcionan una manera rápida y fácil de implementar singletons.

Además, dado que la clase enum implementa la interfaz Serializable bajo el capó, la clase está garantizada como un singleton por la JVM. Esto es a diferencia de la implementación convencional, donde tenemos que asegurarnos de que no se creen nuevas instancias durante la deserialización.

En el fragmento de código a continuación, vemos cómo podemos implementar un patrón singleton:

public enum ConfiguracionSistemaEntregaPizza {
    INSTANCE;
    ConfiguracionSistemaEntregaPizza() {
        // Configuración de inicialización que implica
        // la anulación de los valores predeterminados como la estrategia de entrega
    }

    private EstrategiaEntregaPizza estrategiaEntrega = EstrategiaEntregaPizza.NORMAL;

    public static ConfiguracionSistemaEntregaPizza obtenerInstancia() {
        return INSTANCE;
    }

    public EstrategiaEntregaPizza obtenerEstrategiaEntrega() {
        return estrategiaEntrega;
    }
}

7.2. Patrón de Estrategia

Convencionalmente, el patrón de estrategia (Strategy) se escribe teniendo una interfaz que es implementada por diferentes clases.

Agregar una nueva estrategia significa agregar una nueva clase de implementación. Con enums, podemos lograr esto con menos esfuerzo y agregar una nueva implementación significa simplemente definir otra instancia con alguna implementación.

El fragmento de código a continuación muestra cómo implementar el patrón Strategy:

public enum EstrategiaEntregaPizza {
    EXPRESS {
        @Override
        public void entregar(Pizza pizza) {
            System.out.println("La pizza se entregará en modo express");
        }
    },
    NORMAL {
        @Override
        public void entregar(Pizza pizza) {
            System.out.println("La pizza se entregará en modo normal");
        }
    };

    public abstract void entregar(Pizza pizza);
}

Luego, agregamos el siguiente método a la clase Pizza:

public void entregar() {
    if (esEntregable()) {
        ConfiguracionSistemaEntregaPizza.obtenerInstancia().getEstrategiaEntrega()
          .entregar(this);
        this.setEstado(Pizza.EstadoPizza.ENTREGADO);
    }
}

@Test
public void dadoPedidoPizza_cuandoEntregado_entoncesPizzaSeEntregaYEstadoCambia() {
    Pizza pizza = new Pizza();
    pizza.setEstado(Pizza.EstadoPizza.LISTO);
    pizza.entregar();
    assertTrue(pizza.obtenerEstado() == Pizza.EstadoPizza.ENTREGADO);
}

8. Java 8 y Enums

Podemos reescribir la clase Pizza en Java 8 y ver cómo los métodos obtenerTodasLasPizzasNoEntregadas() y agruparPizzasPorEstado() se vuelven tan concisos con el uso de lambdas y las API de Stream:

public static List<Pizza> obtenerTodasLasPizzasNoEntregadas(List<Pizza> input) {
    return input.stream().filter(
      (s) -> !estadosPizzaEntregada.contains(s.obtenerEstado()))
        .collect(Collectors.toList());
}

public static EnumMap<EstadoPizza, List<Pizza>> 
  agruparPizzasPorEstado(List<Pizza> listaPizzas) {
    EnumMap<EstadoPizza, List<Pizza>> mapa = listaPizzas.stream().collect(
      Collectors.groupingBy(Pizza::obtenerEstado,
      () -> new EnumMap<>(EstadoPizza.class), Collectors.toList()));
    return mapa;
}

9. Representación JSON de Enum

Usando las librerias Jackson, es posible tener una representación JSON de los tipos enum como si fueran POJOs (Plain Old Java Object, es decir, los viejos y confiables objetos planos de Java). En el fragmento de código a continuación, veremos cómo podemos usar las anotaciones de Jackson para lo mismo:

@JsonFormat(shape = JsonFormat.Shape.OBJECT)
public enum EstadoPizza {
    ORDENADO (5) {
        @Override
        public boolean esOrdenado() {
            return true;
        }
    },
    LISTO (2) {
        @Override
        public boolean esListo() {
            return true;
        }
    },
    ENTREGADO (0) {
        @Override
        public boolean esEntregado() {
            return true;
        }
    };

    private int tiempoEntrega;

    public boolean esOrdenado() {return false;}

    public boolean esListo() {return false;}

    public boolean esEntregado(){return false;}

    @JsonProperty("tiempoEntrega")
    public int obtenerTiempoEntrega() {
        return tiempoEntrega;
    }

    private EstadoPizza (int tiempoEntrega) {
        this.tiempoEntrega = tiempoEntrega;
    }
}

Podemos usar la clase Pizza y EstadoPizza de la siguiente manera:

```java
Pizza pizza = new Pizza();
pizza.setEstado(Pizza.EstadoPizza.LISTO);
System.out.println(Pizza.getJsonString(pizza));

Esto generará la siguiente representación JSON del estado de las pizzas:

{
  "estado" : {
    "tiempoEntrega" : 2,
    "listo" : true,
    "ordenado" : false,
    "entregado" : false
  },
  "entregable" : true
}

Para obtener más información sobre Jackson, puedes consultar su documentación.

10. Conclusión

En este artículo, exploramos el enum de Java, desde los conceptos básicos del lenguaje hasta casos de uso del mundo real más avanzados e interesantes.