Enum – ograniczenia i możliwości

Enum to specyficzna klasa, która w języku Java reprezentuje specjalny typ wyliczeniowy. Pierwszy raz pojawił się w Javie 5 i został wprowadzony, aby umożliwić programistom lepszą reprezentację zbioru stałych wartości. Posiada zarówno wiele ciekawych właściwości, jak i możliwości do rozwoju, co z kolei powoduje, że może stać się bardzo przydatną klasą.

Prosta implementacja klasy Enum

Zacznijmy od przykładu, który ilustruje najprostsze, a jednocześnie chyba najczęściej spotykane wykorzystanie klasy Enum w codziennej pracy:

public enum TicketType {

BUG,
TASK,
SUGGESTION;

}

Zgodnie z konwencją, poszczególne wartości zapisujemy wielkimi literami, oddzielając je przecinkami, a po ostatniej wartości dodajemy średnik.

Mamy teraz zbiór trzech wartości z przykładowego systemu zarządzania projektem, w którym są między innymi taski, bugi i sugestie.

Możemy teraz po prostu utworzyć zmienną typu TicketType w naszej klasie testowej, w metodzie main:

TicketType ticketType = TicketType.TASK;
System.out.println(ticketType);

i wypisać wybraną wartość na standardowym wyjściu. Otrzymamy wartość: TASK.


Jeśli chcesz prześledzić zmiany krok po kroku w formie wideo, to zapraszamy na nasz kanał na YouTube.


Ukryte właściwości i ograniczenia klasy Enum

Zanim zaczniemy nieco rozbudowywać klasę TicketType z naszego przykładu, warto byłoby bliżej przyjrzeć się samej klasie Enum. Czy ma jakieś ukryte właściwości? Jakie są jej ograniczenia?

Zaczniemy od podstawowej rzeczy, czyli definicji klasy typu Enum:

public abstract class Enum<E extends Enum<E>>
implements Comparable<E>, Serializable

Każda klasa typu Enum będzie niejawnie rozszerzała klasę Enum (Enum.java z paczki java.lang) oraz implementowała interfejsy Comparable oraz Serializable. Więc mamy tutaj od razu domyślnie zapewnione sortowanie oraz serializację.

Poza tym nie można utworzyć instancji tej klasy. Jeśli będziemy chcieli zapisać na przykład TicketType tType = new TicketType();, to otrzymamy błąd kompilacji. Nie będziemy w stanie również w środku klasy typu Enum utworzyć konstruktora publicznego lub typu protected, ponieważ język Java tego nie przewiduje. Jeśli natomiast utworzymy konstruktor z widocznością domyślną, to później i tak zostanie on zamieniony na konstruktor prywatny.

Tworzy nam się teraz dość jasny obraz klasy typu Enum. W skrócie: nie można tworzyć instancji tej klasy i nie może ona rozszerzać żadnej innej klasy. Można natomiast implementować inne interfejsy, o czym przekonamy się w dalszej części tego wpisu. W enumie możemy również definiować metody oraz dodawać do niego pola.

Ważne jest natomiast to, w jaki sposób tworzone są instancje danego enuma. Otóż działa to w ten sposób, że na każdą wartość umieszczoną w klasie typu Enum, zostanie utworzona jedna instancja enuma, która będzie przechowywać właśnie tę wartość. Enum danego typu nie będzie miał innych instancji poza tymi, które są zdefiniowane przez jego wartości. Oprócz tego zostały dodane dodatkowe mechanizmy zabezpieczające:

  • metoda clone jest typu final, aby nikt nie mógł sklonować danej instancji enuma,
  • tworzenie instancji enuma za pomocą refleksji jest niemożliwe,
  • mechanizm deserializacji dodaje zabezpieczenia, aby nie można było utworzyć kolejnych instancji enumów przy procesie deserializacji.

Sortowanie i kolejność enumów

Oprócz tego, enumowe wartości są domyślnie posortowane według kolejności, w jakiej są umieszczone w klasie typu Enum. Dzieje się tak, ponieważ wewnętrznie każda wartość posiada liczbę integer, korespondującą z jej miejscem w danym enumie, licząc od 0. Na bazie naszego przykładu:

enum dev foundry blog programowanie java spring kursy
Wizualizacja enuma TicketType wraz z wartościami i korespondującymi cyframi ordinal

Wartość BUG została umieszczone w TicketType jako pierwsza, więc jej ukryty numer oznaczający kolejność to 0, TASK to 1, a SUGGESTION to 2. I tak dalej…

Aby wypisać numery kolejności wartości  z danego enuma, wystarczy wywołać metodę ordinal:

for (TicketType ticketType : TicketType.values()) {
    System.out.println(ticketType.ordinal());
}

Przy okazji: aby iterować po wartościach danego enuma, dobrze jest to robić z pomocą metody values (lub za pomocą kolekcji, o czym później).

Singleton

Poznaliśmy już pewne ograniczenia i właściwości klasy typu Enum. Dość jasnym staje się, że twórcy języka starali się, aby uniemożliwić użytkownikowi tworzenie więcej niż jednej instancji danego enuma. Brzmi jak idealny przykład wzorca projektowego Singleton, gdzie potrzebna jest tylko i wyłącznie jedna instancja danej klasy. I faktycznie, możemy użyć enuma do utworzenia Singletona:

public enum SingletonExample {

INSTANCE;

}

Wystarczy umieścić w takim enumie jedną wartość – konwencja mówi, żeby nazwać ją INSTANCE i już. Oczywiście możemy dalej rozbudowywać takiego enuma o dodatkowe pola oraz metody, aby zwiększyć jego możliwości. Jednak już tutaj widać, że jest on znacznie bardziej zaskakujący, niż mogłoby się wydawać na pierwszy rzut oka.

Pola w klasie Enum

Do naszego prostego enuma spróbujemy teraz dodać jakieś pole, na przykład priority:

public enum TicketType {

    BUG("high"),
    TASK("medium"),
    SUGGESTION("low");

    private String priority;

    TicketType(String priority) {
        this.priority = priority;
    }

    public String getPriority() {
        return priority;
    }

    public void setPriority(String priority) {
        this.priority = priority;
    }

}

Jak widać co nieco się tutaj pozmieniało. Dodaliśmy pole prywatne typu String, konstruktor z domyślnym modyfikatorem dostępu oraz getter dla nowego pola.

Ponadto przy wartościach pojawiły się również odpowiedniki pola priority umieszczone w nawiasach. Właśnie w ten sposób deklaruje się pola w klasach typu enum.

Ważna uwaga: Konstruktor w klasie enum nie może być publiczny ani typu protected. Jeśli natomiast dodamy konstruktor z domyślną widocznością, to i tak zostanie on zamieniony na typ prywatny. O tym dlaczego tak się dzieje powiem później.

Teraz w klasie testowej możemy wypisać priorytet poszczególnych wartości. Tym razem zrobimy to w pętli, korzystając z dostępnej w klasie Enum metody values():

for (TicketType ticketType : TicketType.values()) {
    System.out.println(ticketType.getPriority());
}

Na wyjściu otrzymamy:

high
medium
low

Wartości pól można ustawić nie tylko przy pomocy konstruktora – w naszym przykładzie mamy również dostęp do settera pola priority. Dzięki temu możemy zmienić wartość tego pola, nawet jeśli została już wcześniej ustawiona przez konstruktor:

TicketType task = TicketType.TASK;
task.setPriority("very high");

System.out.println(task.getPriority());

I w wyniku otrzymamy oczywiście komunikat: very high.

Metody w klasie Enum

Poza deklaracją pól, w klasach typu Enum możemy również definiować metody. Stwórzmy sobie zatem metodę isAssigned, która jest typowo pokazowa (wiadomo, że samego typu nie będziemy nigdzie przypisywać):

public boolean isAssigned() {
    return false;
}

Przy wypisaniu w pętli powyższej metody, otrzymamy komunikat:

false
false
false

Nie jest to zbyt użyteczne, na szczęście dla wszystkich lub też tylko wybranych wartości możemy nadpisać tę metodę. Zapisuje się to w następujący sposób:

BUG("high"),
TASK("medium"){
    @Override
    public boolean isAssigned() {
        return true;
    }
},
SUGGESTION("low");

I teraz w wyniku otrzymamy:

false
true
false

W enumach możemy również deklarować metody abstrakcyjne, jednak wtedy wszystkie wartości muszą tę metodę implementować.

Implementowanie interfejsów

Ze względu na swoje ograniczenia, klasy typu Enum mogą w zasadzie jedynie implementować inne interfejsy. Utwórzmy zatem na potrzeby naszego przykładu interfejs Commented, który będzie zawierał deklarację jednej metody – comment:

public interface Commented {
    
    void comment();

}

I teraz zaimplementujmy powyższą metodę w TicketType. Cała klasa po zmianach prezentuje się następująco:

public enum TicketType implements Commented {

    BUG("high"),
    TASK("medium"){
        @Override
        public boolean isAssigned() {
            return true;
        }
    },
    SUGGESTION("low");

    private String priority;

    TicketType(String priority) {
        this.priority = priority;
    }

    public boolean isAssigned() {
        return false;
    }

    public void comment() {
        System.out.println("Standard comment");
    }

    public String getPriority() {
        return priority;
    }
    
}

W przypadku metody z interfejsu (podobnie jak w przypadku zwykłych metod), mamy możliwość:

  • implementacji, z której korzystać będą wszystkie wartości enuma,
  • implementacji w wartościach, które nas interesują – w tym wypadku trzeba będzie jednak nadpisać metodę comment.

Skorzystamy z tego drugiego przypadku – nadpiszemy metodę comment dla wartości TASK, aby pokazać jak wygląda składnia, gdy nadpisujemy dwie lub więcej metod:

BUG("high"),
TASK("medium"){
    @Override
    public boolean isAssigned() {
        return true;
    }
    @Override
    public void comment() {
        System.out.println("Comment for a task");
    }
},
SUGGESTION("low");

Metoda System.out.println wypisze nam wówczas:

Standard comment
Comment for a task
Standard comment

Porównywanie enumów

Jak najlepiej porównać dwie wartości z tej samej klasy Enum lub też dwie wartości z różnych enumów?

Java jasno określa sposób porównywania dwóch wartości typu Enum. Wykonuje się to poprzez podwójny znak równości:

TicketType bug = TicketType.BUG;
TicketType suggestion = TicketType.SUGGESTION;

System.out.println(bug == suggestion);

Oczywiście w tym wypadku otrzymamy w wyniku: false. Gdybyśmy jednak uparli się (być może natchnieni moim poprzednim wpisem), aby użyć tutaj metody equals:

System.out.println(bug.equals(suggestion));

to w odpowiedzi otrzymamy również: false, jednak po przejściu do implementacji metody equals w tym wypadku znajdziemy się w klasie Enum.java, w której zobaczymy:

public final boolean equals(Object other) {
    return this==other;
}

Jak widać więc, nie ma co kombinować tutaj z metodą equals i dobrą praktyką jest aby w przypadku enumów porównywać je ze sobą za pomocą podwójnego znaku równości, zgodnie z założeniami języka Java.

Co natomiast z porównywaniem różnych typów enumów? Tutaj sprawa jest jeszcze prostsza, ponieważ kompilator nie pozwoli nam na zastosowanie podobnego zapisu:

TicketType ticketType = TicketType.BUG;
OtherType otherType = OtherType.FIRST;

System.out.println(ticketType == otherType);

Otrzymamy błąd kompilacji mówiący o tym, że mamy do czynienia z niekompatybilnymi typami.

EnumSet i EnumMap

Istnieją dwie kolekcje, które są powiązane z enumami: EnumSet oraz EnumMap.

EnumSet

EnumSet to wyspecjalizowana implementacja zbioru w Javie, która jest specjalnie przystosowana do działania na enumach, dzięki czemu jest bardzo wydajna.

Aby utworzyć zbiór wartości enumowych, możemy skorzystać z kilku metod klasy EnumSet, na przykład:

Set<TicketType> ticketTypeEnumSet = EnumSet.of(TicketType.SUGGESTION, TicketType.BUG);

Wykorzystanie metody of jest przydatne, gdy chcemy utworzyć zbiór składający się niekoniecznie ze wszystkich wartości danego enuma.

Jeśli jednak chcielibyśmy utworzyć zbiór, który faktycznie ma zawierać wszystkie elementy danej klasy Enum, to możemy użyć metody allOf:

Set<TicketType> ticketTypeEnumSet2 = EnumSet.allOf(TicketType.class);

ticketTypeEnumSet2.forEach(System.out::println);

Jak widać, musimy tutaj określić typ enuma z rozszerzeniem .class.

Dzięki umieszczeniu wartości enumowych w kolekcji tego typu, możemy również skorzystać z dobrodziejstw Javy 8, by w prosty sposób iterować po elementach zbioru.

EnumSet implementuje interfejs Set, więc mamy tu do dyspozycji wiele przydatnych metod, takich jak: add, remove, contains, etc.

Istotną informacją jest również to, że EnumSet to klasa abstrakcyjna, która posiada dwie implementacje: RegularEnumSet oraz JumboEnumSet. To, która konkretna klasa zostanie wybrana, jest ustalane przez wirtualną maszynę Javy podczas tworzenia instancji enumów, w zależności od tego ile wartości posiada dana klasa Enum. Jeśli wartości będzie więcej niż 64, to użyty zostanie JumboEnumSet, w przeciwnym wypadku – i najczęściej – będziemy mieli do czynienia z RegularEnumSetem.

EnumMap

Podobnie jak w przypadku EnumSeta, EnumMap jest to kolekcja stworzona specjalnie do pracy z enumami. Ma zapewniać lepszą wydajność i być optymalnym wyborem dla programisty, który potrzebuje przechować wartości enumowe w jakiejś kolekcji.

W tym przypadku dokumentacja Javy mówi o tym, że EnumMap ma przyjmować jako klucz wartości Enum jednego typu, czyli pochodzące z tej samej klasy Enum. Wartości mapy mogą być dowolnego typu. Popatrzmy na prosty przykład:

EnumMap<TicketType, String> enumMap = new EnumMap<TicketType, String>(TicketType.class);

enumMap.put(TicketType.TASK, "Dawid Nowak");
enumMap.put(TicketType.BUG, "Paweł Cwik");

System.out.println(enumMap.get(TicketType.BUG));

Działa to identycznie jak HashMapa, do której jesteśmy przyzwyczajeni. Jedyną ciekawostką jest to, że podczas deklaracji tej mapy musimy w konstruktorze podać typ Enuma, który będzie użyty jako klucz, podając również rozszerzenie .class.

Podsumowanie

Jak widać klasa Enum posiada sporo ciekawych właściwości oraz ukrytych tajemnic, które – mam nadzieję – teraz stały się już nieco jaśniejsze. To dobry przykład na to, że możemy użyć tej klasy w jej najmniejszej, minimalistycznej formie, jako sposób na przechowywanie stałych wartości. Jednak jeśli mamy taką możliwość lub potrzebę, to enum oferuje nam również możliwość implementowania interfejsów, definiowania metod i pól, a nawet pozwala na utworzenie klasy typu Singleton.

Kod z przykładami do powyższego wpisu znajdziesz na githubie.


Podziel się tym wpisem:

2 komentarze do wpisu „Enum – ograniczenia i możliwości

  1. Do tej pory wydawało mi się że Enum za dużo w sobie nie kryje i właściwie to głównie tylko typy danych można dzięki niemu narzucić… 😉

    • I prawdę mówiąc to pewnie w większości przypadków tak wygląda jego wykorzystanie w projektach. Jednak zdarzyło mi się już widzieć użycie zarówno zmiennych, jak i metod w enumach. Zawsze lepiej wiedzieć więcej i być przygotowanym na wszystko 😀

Dodaj komentarz