Перечисления появились в пятой версии Java и с тех пор крепко обосновались в наших приложениях. Работа с перечислениями почти не отличается от работы с любыми другими классами в Java. Но есть несколько особенностей, которые вызывают удивление. Каждый раз сталкиваясь с ними, хочется спросить: "Почему так?".


Давайте попробуем разобраться.


Порядок инициализации


В отличие от некоторых других языков программирования в Java перечисления являются полноценными классами. Конечно, есть некоторые особенности, например:


  • нельзя наследоваться от классов (но можно реализовывать интерфейсы);
  • нельзя объявлять класс финальным или абстрактным;
  • нельзя создавать конструкторы с модификаторами public или protected;
  • множество других ограничений (полный список можно найти в документации).

Хорошо, смирились с запретами. Но можем ли мы ожидать, что остальные языковые конструкции работают так же, как в остальной Java? Например, порядок инициализации объектов.


Давайте проверим. Для этого напишем такое перечисление:


public enum Pine {
    FIR, CEDAR;

    static {
        System.out.println("Static block");
    }

    {
        System.out.println("Code block");
    }

    Pine() {
        System.out.println("Constructor");
    }
}

Остановитесь на секунду и попробуйте предположить что выведется в консоль при обращении к любому из значений перечисления.


var fir = Pine.Fir;

В обычных классах при инициализации первого объекта кодовые блоки выполняются в следующем порядке:


Статический блок -> Кодовый блок -> Конструктор 

Для перечисления же мы увидим в консоли следующее:


> Code block
> Constructor
> Code block
> Constructor
> Static block

Как же так? Почему статический блок был вызван последним?


Для ответа на этот вопрос давайте прогоним скомпилированный класс через Java Class File Disassembler и вручную переведем дизассемблированный код в java код. Дизассемблинг выполняется командой:


$ javap -c Pine

Для самых любопытных привожу результат исполнения команды.


Результат исполнения команды
public final class dev.boiarshinov.enumsinjava.initorder.Pine extends java.lang.Enum<dev.boiarshinov.enumsinjava.initorder.Pine> {
  public static final dev.boiarshinov.enumsinjava.initorder.Pine FIR;

  public static final dev.boiarshinov.enumsinjava.initorder.Pine CEDAR;

  public static dev.boiarshinov.enumsinjava.initorder.Pine[] values();
    Code:
       0: getstatic     #1                  // Field $VALUES:[Ldev/boiarshinov/enumsinjava/initorder/Pine;
       3: invokevirtual #2                  // Method "[Ldev/boiarshinov/enumsinjava/initorder/Pine;".clone:()Ljava/lang/Object;
       6: checkcast     #3                  // class "[Ldev/boiarshinov/enumsinjava/initorder/Pine;"
       9: areturn

  public static dev.boiarshinov.enumsinjava.initorder.Pine valueOf(java.lang.String);
    Code:
       0: ldc           #4                  // class dev/boiarshinov/enumsinjava/initorder/Pine
       2: aload_0
       3: invokestatic  #5                  // Method java/lang/Enum.valueOf:(Ljava/lang/Class;Ljava/lang/String;)Ljava/lang/Enum;
       6: checkcast     #4                  // class dev/boiarshinov/enumsinjava/initorder/Pine
       9: areturn

  static {};
    Code:
       0: new           #4                  // class dev/boiarshinov/enumsinjava/initorder/Pine
       3: dup
       4: ldc           #11                 // String FIR
       6: iconst_0
       7: invokespecial #12                 // Method "<init>":(Ljava/lang/String;I)V
      10: putstatic     #13                 // Field FIR:Ldev/boiarshinov/enumsinjava/initorder/Pine;
      13: new           #4                  // class dev/boiarshinov/enumsinjava/initorder/Pine
      16: dup
      17: ldc           #14                 // String CEDAR
      19: iconst_1
      20: invokespecial #12                 // Method "<init>":(Ljava/lang/String;I)V
      23: putstatic     #15                 // Field CEDAR:Ldev/boiarshinov/enumsinjava/initorder/Pine;
      26: iconst_2
      27: anewarray     #4                  // class dev/boiarshinov/enumsinjava/initorder/Pine
      30: dup
      31: iconst_0
      32: getstatic     #13                 // Field FIR:Ldev/boiarshinov/enumsinjava/initorder/Pine;
      35: aastore
      36: dup
      37: iconst_1
      38: getstatic     #15                 // Field CEDAR:Ldev/boiarshinov/enumsinjava/initorder/Pine;
      41: aastore
      42: putstatic     #1                  // Field $VALUES:[Ldev/boiarshinov/enumsinjava/initorder/Pine;
      45: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
      48: ldc           #16                 // String Static block
      50: invokevirtual #9                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      53: return
}

После ручного перевода в Java код получим следующее (не имеющий отношения к рассматриваемой теме код опущен):


public class PineIsNotEnum extends Enum<PineIsNotEnum> {

    public static final PineIsNotEnum FIR;
    public static final PineIsNotEnum CEDAR;

    protected PineIsNotEnum(String name, int ordinal) {
        super(name, ordinal);
        System.out.println("Code block");
        System.out.println("Constructor");
    }

    static {
        FIR = new PineIsNotEnum("FIR", 0);
        CEDAR = new PineIsNotEnum("CEDAR", 1);
        System.out.println("Static block");
    }
}

Что же мы видим? Значения перечисления превратились в статические финальные поля. Выражения из кодового блока и конструктора переехали в конструктор. Выражения из статического блока остались в статическом блоке, но до их вызова добавился код создания экземпляров.


Получается, что при первом обращении к классу перечисления первым делом исполняется статический блок. Все как в обычных классах. Первое, что делается в статическом блоке — инициализируются финальные поля класса. Это влечет за собой вызов кода, объявленного в конструкторе, и в консоль дважды выводится


> Code block
> Constructor

И только после этого исполняется код из статического блока оригинального класса.


> Static block

Теперь мы знаем в какой класс преобразуется enum при компиляции, и порядок исполнения кодовых блоков становится понятен.
Для того чтобы не держать в памяти последовательность преобразований, приводящую к странному поведению, предлагаю запомнить следующее:


Значения, объявленные в перечислении — это статические финальные поля того же типа, что и класс. Инициализация этих полей происходит в статическом блоке до всех остальных статических выражений.

Отсутствующие методы


Все перечисления неявно унаследованы от абстрактного класса Enum. Если заглянуть в javadoc на этот класс, то можно увидеть следующие методы:


String name() { /* ... */ }
int ordinal() { /* ... */ }
Class<E> getDeclaringClass() { /* ... */ }
int compareTo(E o) { /* ... */ }
static <T extends Enum<T>> T valueOf(Class<T> enumType, String name) { /* ... */ }
/* Методы класса Object */

Чего-то не хватает.


Если попробовать в IDE написать любое перечисление, поставить точку и вызвать автодополнение, то он предложит еще два метода:


Pine[] values = Pine.values();
Pine cedar = Pine.valueOf("CEDAR");

В исходниках класса Enum таких методов нет, но они как-то появляются в каждом перечислении.


Чтобы разобраться, обратимся к документации. Из нее мы узнаем, что два этих метода объявлены неявно. Почему неявно? Дело в том, что в отличие от других методов класса Enum эти методы не получается реализовать в абстрактном классе. Метод values() возвращает массив со всеми значениями перечисления, а класс Enum о них ничего не знает. Метод valueOf(String) возвращает конкретное значение перечисления по его названию. Можно было бы в нем вызвать метод valueOf(Class, String):


public static E valueOf(String name) {
    return valueOf(E, name);
}

Но ничего не выходит из-за того, что класс E невозможно извлечь в статическом контексте.


Почему же нельзя было объявить эти методы абстрактными в Enum, чтобы разработчики могли хотя бы ознакомиться с их контрактом в javadoc? Это невозможно из-за того, что методы не могут быть одновременно статическими и абстрактными. Компилятор не поймет. А методы valueOf(String) и values() по своей природе статические.


Теперь мы понимаем, что данные методы генерируются компилятором. Но какая же у них реализация? В JLS она не приведена, и в исходниках JDK ее тоже не найти.


Здесь нам поможет тот же трюк с дизассемблированием. В первой части статьи я сознательно не стал транслировать дизассемблированный код в Java-код полностью, чтобы не отвлекать внимание от инициализации. Если же пристальнее взглянуть на фрагмент под спойлером, то можно увидеть в дополнение к константам, описывающим значения перечисления, еще одну — VALUES. Она содержит в себе все значения перечисления в виде массива. Массив заполняется сразу после инициализации значений. Этот же массив возвращается при вызове метода values():


/* ... */    
private static final PineIsNotEnum[] VALUES;

static {
    FIR = new PineIsNotEnum("FIR", 0);
    CEDAR = new PineIsNotEnum("CEDAR", 1);
    VALUES = new PineIsNotEnum[] {FIR, CEDAR};
}

public static PineIsNotEnum[] values() {
    return VALUES.clone();
}

Метод valueOf(String) реализуется с помощью вызова тезки:


public static PineIsNotEnum valueOf(String name) {
    return Enum.valueOf(PineIsNotEnum.class, name);
}

Обобщая знания о неявных методах и порядке инициализации, давайте запишем как может быть представлено перечисление Pine из начала статьи в виде обычного класса:


public class PineIsNotEnum extends Enum<PineIsNotEnum> {

    public static final PineIsNotEnum FIR;
    public static final PineIsNotEnum CEDAR;
    private static final PineIsNotEnum[] VALUES;

    protected PineIsNotEnum(String name, int ordinal) {
        super(name, ordinal);
        System.out.println("Code block");
        System.out.println("Constructor");
    }

    static {
        FIR = new PineIsNotEnum("FIR", 0);
        CEDAR = new PineIsNotEnum("CEDAR", 1);
        VALUES = new PineIsNotEnum[] {FIR, CEDAR};
        System.out.println("Static block");
    }

    public static PineIsNotEnum[] values() {
        return VALUES.clone();
    }

    public static PineIsNotEnum valueOf(String name) {
        return Enum.valueOf(PineIsNotEnum.class, name);
    }
}

Заключение


Странности в перечислениях вызваны архитектурными решениями и ограничениями, выбранными разработчиками Java. С помощью дизассемблирования нам удалось узнать, как перечисления инициализируются, и как в них реализованы неявные методы.


Надеюсь, что теперь, столкнувшись с необычным поведением перечислений, вы сможете мысленно преобразовать перечисление в обычный класс и разобраться.


Источники


Комментарии (17)


  1. quaer
    29.08.2021 23:52

    Это невозможно из-за того, что методы не могут быть одновременно статическими и абстрактными.

    Но ведь статический метод может быть перекрыт таким же статическим методом наследника.


    1. aleksandy
      30.08.2021 07:04
      +7

      Не может. Статический метод не наследуется, т.к. привязан к конкретному классу. В классе-наследнике можно определить статический метод с такой же сигнатурой, но этот метод не будет иметь ничего общего с предком.

      Собственно, именно поэтому вызов статического метода на экземпляре объекта вместо класса считается говнокодом и должно быть заменено на вызов через имя класса.


      1. quaer
        30.08.2021 14:43

        Таким образом можно было бы показать его реализацию


        1. PROgrammer_JARvis
          30.08.2021 16:30
          +1

          Что значит "показать реализацию"?

          В пределах Enum его нельзя реализовать, потому что для него этот метод не имеет смысла. И его наличие там никакой пользы не принесёт, т.к. обращение непосредственно к нему - бессмысленно, а обращение к его тёзке в конкретном enum'е отношения к нему иметь никакого не будет.


          1. quaer
            30.08.2021 16:37

            Автор желается ознакомиться с их контрактом. Можно было бы их объявить, заполнив фиктивным содержимым, объясняющим работу как-то.

            В конкретном enum-e метод был бы перекрыт.


            1. PROgrammer_JARvis
              30.08.2021 16:48
              +1

              Так, ещё раз, а какой в этом смысл?

              Зачем добавлять метод, который никогда не должен использоваться и не имеет смысла? Причём, более того, который даже не является примером для разработчика, как писать подобный, потому что:

              • его даже в общих чертах нельзя описать в Enum

              • разработчик итак никогда его не должен (и не сможет) реализовать, потому что вручную от Enum наследоваться нельзя

              В итоге наличие такого метода вводило бы лишь в заблуждение.


              1. quaer
                30.08.2021 17:11

                Так, ещё раз, а какой в этом смысл?

                Практического - никакого. Автор этой статьи смог бы ознакомиться с контрактом.


    1. PROgrammer_JARvis
      30.08.2021 08:18
      +1

      Но какой в этом смысл? Для самого Enum его нельзя корректно определить (= он бессмысленен); а при "перекрытии" у нас просто будет идентичный по названию и сигнатуре, но, в общем, никак не связанный с предыдущим, другой метод
      .


    1. Serge1001
      30.08.2021 08:27
      +2

      Именно, что "перекрыт". Статические методы не полиморфны в java.


  1. Naf2000
    30.08.2021 07:10
    +1

    В .net ограниченно пытаются завести static abstract для интерфейсов: https://habr.com/ru/post/572902/


  1. Serge1001
    30.08.2021 08:18
    +1

    Ещё можно добавить, что enum (учитывая его особенность инициализации) часто используют для реализации потокобезопасного синглтона.


    1. boiarshinov Автор
      30.08.2021 08:26
      +1

      Ага, еще Джошуа Блох писал, что

      ... single-element enum type is often the best way to implement a singleton.

      Правда я не уверен, что кто-либо хочет видеть в своих синглтоновых классах методы valueOf(), values(), compareTo() , ordinal() и т.д.
      Ради интереса я даже пытался найти хотя бы один синглтон на основе enum в стандартной библиотеке Java, но не нашел.

      Кстати, в Котлине не стали пользоваться рекомендацией Блоха и у них object разворачивается в обычный класс с одной внутренней статической константой. Сам класс от `java.lang.Enum` не наследуется


  1. alexdoublesmile
    30.08.2021 10:43
    +4

    ничего нового, но статья хорошая. Спасибо


  1. Sanctarium
    31.08.2021 21:15
    +1

    Информативная статья. Большое спасибо!


  1. aleksandy
    28.09.2021 11:06

    Но какая же у них реализация? В JLS она не приведена, и в исходниках JDK ее тоже не найти.

    Нужно только захотеть и можно отыскать эти методы.


    1. boiarshinov Автор
      30.09.2021 11:00

      Обратите внимание, что по первой ссылке приведен метод valueOf() с двумя аргументами, а не с одним. В статье же речь шла про valueOf() с одним аргументом. О существовании valueOf() с двумя аргументами было написано чуть выше процитированного отрывка.


      По второй ссылке также приведен не тот метод, про который написано в статье - getEnumConstants() вместо values().


  1. tsypanov
    20.10.2021 11:17

    К слову, получить массив всех членов перечисления можно и с помощью ParticularEnum.class.getEnumConstants(), правда, этот способ и длиннее, и немного медленнее.