openjdk.ruOpenJDK на русском

JEP 301: Enhanced Enums

Улучшенные перечисления

ОтветственныйMaurizio Cimadamore
ТипFeature
ОбластьSE
СтатусClosed / Withdrawn
Компонентtools / javac
Обсуждениеplatform dash jep dash discuss at openjdk dot java dot net
ТрудоёмкостьM
ДлительностьM
Связан сJEP 286: Local-Variable Type Inference
Создан2016/11/25 11:27
Обновлён2020/09/29 20:27
Задача8170351

Аннотация

Повысить выразительность конструкции enum в языке Java: разрешить переменные типа в перечислениях (обобщённые перечисления) и выполнять более точную проверку типов для констант перечислений.

Цели

Эти два улучшения работают вместе и позволяют константам перечисления нести свою собственную информацию о типе, а также своё собственное состояние и поведение. Во многих ситуациях разработчикам приходится переделывать перечисления в классы, чтобы добиться нужного результата; эти улучшения должны уменьшить такую необходимость.

Следующий пример показывает, как два улучшения работают вместе:

enum Argument<X> { // declares generic enum
   STRING<String>(String.class), 
   INTEGER<Integer>(Integer.class), ... ;

   Class<X> clazz;

   Argument(Class<X> clazz) { this.clazz = clazz; }

   Class<X> getClazz() { return clazz; }
}

Class<String> cs = Argument.STRING.getClazz(); //uses sharper typing of enum constant

Что не является целью

Этот JEP нацелен на конкретные улучшения того, как проверяются типы констант перечислений. Поэтому другие возможности, связанные с перечислениями, например:

  • разрешить наследование от перечислений
  • разрешить перечисления в нестатических контекстах

находятся вне рамок этого JEP.

Мотивация

Перечисления в Java — мощная конструкция. Они позволяют группировать константы, где каждая константа является объектом-синглтоном. Каждая константа может при необходимости объявить тело, с помощью которого можно переопределить поведение базового объявления перечисления. Далее мы попробуем смоделировать набор примитивных типов Java с помощью перечисления. Вот начало:

enum Primitive {
    BYTE,
    SHORT,
    INT,
    FLOAT,
    LONG,
    DOUBLE,
    CHAR,
    BOOLEAN;
}

Как сказано выше, объявление перечисления похоже на класс и может иметь конструкторы; с помощью этой возможности мы можем хранить класс-обёртку и значение по умолчанию для каждого примитивного типа:

enum Primitive {
    BYTE(Byte.class, 0),
    SHORT(Short.class, 0),
    INT(Integer.class, 0),
    FLOAT(Float.class, 0f),
    LONG(Long.class, 0L),
    DOUBLE(Double.class, 0d),
    CHAR(Character.class, 0),
    BOOLEAN(Boolean.class, false);

    final Class<?> boxClass;
    final Object defaultValue;

    Primitive(Class<?> boxClass, Object defaultValue) {
       this.boxClass = boxClass;
       this.defaultValue = defaultValue;
    }

}

Это выглядит неплохо, но есть ряд ограничений: поле boxClass имеет нестрогий тип Class<?>, так как тип поля должен быть совместим со всеми более точными типами, которые используют константы перечисления. В результате любая попытка сделать что-то вроде этого:

Class<Short> cs = SHORT.boxedClass(); //error

Завершится ошибкой компиляции. Хуже того, поле defaultValue имеет тип Object. Этого не избежать, так как поле должно быть общим для нескольких констант, моделирующих разные примитивные типы. Поэтому статическая безопасность теряется: компилятор пропускает код вроде следующего:

String s = (String)INT.defaultValue(); //ok

Теперь попробуем расширить перечисление и добавить несколько операций к константам, моделирующим примитивные типы (для краткости далее мы будем показывать только часть констант):

enum Primitive {
    INT(Integer.class, 0) {
       int mod(int x, int y) { return x % y; }
       int add(int x, int y) { return x + y; }
    },
    FLOAT(Float.class, 0f)  {
       long add(long x, long y) { return x + y; }
    }, ... ;

    final Class<?> boxClass;
    final Object defaultValue;

    Primitive(Class<?> boxClass, Object defaultValue) {
       this.boxClass = boxClass;
       this.defaultValue = defaultValue;
    }

}

Это снова приводит к проблемам, так как нет способа сделать что-то вроде этого:

int seven = INT.add(3, 4); //error

Дело в том, что статический тип INT — это просто Primitive, а у Primitive нет члена с именем add. Поэтому, чтобы добавить операции в наше перечисление, нужно добавить члены в само объявление перечисления, вот так:

enum Primitive {
    INT(Integer.class, 0),
    FLOAT(Float.class, 0f), ... ;

    final Class<?> boxClass;
    final Object defaultValue;

    Primitive(Class<?> boxClass, Object defaultValue) {
       this.boxClass = boxClass;
       this.defaultValue = defaultValue;
    }

    int mod(int x, int y) {
       if (this == INT) {
          return x % y;
       } else {
          throw new IllegalStateException();
       }
    }

    int add(int x, int y) {
        if (this == INT) {
          return x + y;
       } else {
          throw new IllegalStateException();
       }
    }

    long add(float x, float y) {
        if (this == FLOAT) {
          return x + y;
       } else {
          throw new IllegalStateException();
       }
    }
    ...

}

Но у кода выше снова есть несколько проблем. Во-первых, нарушается инкапсуляция: Primitive вдруг получает множество членов, ни один из которых не имеет смысла для всех констант. В результате реализация каждого метода усложняется, так как методы должны проверять, вызваны ли они на правильной константе перечисления. Теряется и типобезопасность, так как компилятор не обнаружит неправильное использование, например:

int zero = FLOAT.mod(50, 2); //ok

Все описанные выше проблемы можно решить, если устранить определённые асимметрии между перечислениями и классами и уточнить способ проверки типов констант перечислений. Точнее:

  • разрешить параметры типа в объявлениях перечислений
  • не стирать преждевременно точную информацию о типе, связанную с константами перечислений

С этими улучшениями перечисление Primitive можно переписать так:

enum Primitive<X> {
    INT<Integer>(Integer.class, 0) {
       int mod(int x, int y) { return x % y; }
       int add(int x, int y) { return x + y; }
    },
    FLOAT<Float>(Float.class, 0f)  {
       long add(long x, long y) { return x + y; }
    }, ... ;

    final Class<X> boxClass;
    final X defaultValue;

    Primitive(Class<X> boxClass, X defaultValue) {
       this.boxClass = boxClass;
       this.defaultValue = defaultValue;
    }
}

Это обобщённое объявление явно выразительнее предыдущего — теперь константа перечисления Primitive.INT имеет более точный параметризованный тип Primitive<Integer>, а значит, её члены тоже точно типизированы:

Class<Short> cs = SHORT.boxedClass(); //ok!

Кроме того, так как информация о типе констант перечисления не стирается преждевременно, компилятор может рассуждать о принадлежности членов константам, как показано ниже:

int zero_int = INT.mod(50, 2); //ok
int zero_float = FLOAT.mod(50, 2); //error

Теперь компилятор может отклонить второй оператор, так как в константе перечисления FLOAT нет члена mod, и это гарантирует дополнительную типобезопасность.

Описание

Обобщённые перечисления

Как обсуждалось в JDK-6408723, важное требование для поддержки обобщений в перечислениях состоит в том, чтобы параметры типа были полностью связаны в объявлении константы перечисления. Это позволяет использовать простую схему трансляции, которая может дополнить существующую сегодня. Например, для объявления перечисления вроде следующего:

enum Foo<X> {
   ONE<String>,
   TWO<Integer>;
}

Соответствующий код после устранения синтаксического сахара будет выглядеть так:

/* enum */ class Foo<X> {
   static Foo<String> ONE = ...
   static Foo<Integer> TWO = ...

   ...
}

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

Возможно, стоит разрешить оператор diamond при инициализации константы перечисления, например:

enum Bar<X> {
   ONE<>(Integer.class),
   TWO<>(String.class);

   Bar(X x) { ... }
}

Если используется синтаксис diamond, требуется особая осторожность, когда у константы перечисления есть тело (т. е. она транслируется в анонимный класс), а выведенный тип невыразим в исходном коде. Как и в случае diamond с анонимными внутренними классами, компилятору придётся отклонять такой случай.

Более точная типизация констант перечислений

По текущим правилам статический тип константы перечисления — это сам тип перечисления. По этим правилам константы Foo.ONE и Foo.TWO выше будут иметь один и тот же тип, а именно Foo. Это нежелательно как минимум по двум причинам:

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

Чтобы обойти это ограничение, типизацию констант перечислений следует переопределить так, чтобы каждая константа перечисления получала собственный тип. Пусть E — объявление перечисления, а C — объявление константы перечисления в E (возможно, обобщённой). Константе C сопоставляется более точный тип, если выполнено одно из следующих условий:

  • C имеет вид C<T1, T2 ... Tn>, но не объявляет тела; более точный тип константы — E<T1, T2 ... Tn>
  • у C есть тело; более точный тип константы — анонимный тип (записывается как E.C), супертипом которого является либо
    • E<T1, T2, ... Tn>, если C имеет вид C<T1, T2, ... Tn> и E — обобщённое перечисление
    • E, если E не является обобщённым

Эти улучшенные правила типизации позволяют статическим типам для Foo.ONE и для Foo.TWO различаться.

Дополнительные соображения

Двоичная совместимость

Предположим, у нас есть следующее перечисление:

enum Test {
   A { void a() { } }
   B { void b() { } }
}

Как мы видели, оно будет транслировано так:

/* enum */ class Test {
   static Test A = new Test() { void a() { } }
   static Test B = new Test() { void b() { } }
}

Если разрешить более точный тип для констант перечисления, при наивном подходе код будет транслирован так:

/* enum */ class Test {
   static Test$1 A = new Test() { void a() { } }
   static Test$2 B = new Test() { void b() { } }
}

Здесь двоичная несовместимость очевидна: тип константы перечисления A после перекомпиляции просто изменился с Test на Test$1. Это изменение сломает неперекомпилированных клиентов, использующих Test.

Чтобы решить эту проблему, лучше использовать подход на основе стирания: хотя статическим типом A может быть более точный тип Test.A, любая ссылка на тип константы стирается до базового типа перечисления Test. Так получается код, двоично совместимый с тем, что было раньше. Но если всё стирается до Test, как реализовать доступ к членам конкретной константы перечисления?

Foo.A.a();

Легко видеть, что если в коде выше символические ссылки на A стираются до Test, вызов метода не будет правильно типизирован (так как у Test нет члена с именем a). Чтобы решить эту проблему, компилятор должен вставить синтетическое приведение типа:

checkcast Test$1
invokevirtual Test$1::a

Это похоже на то, что происходит при доступе к членам типа-пересечения через стирание.

Другое, независимое наблюдение: текущая схема именования классов констант перечислений слишком хрупкая — показанные выше имена Test$1 и Test$2 по сути зависят от порядка, а значит, изменение порядка объявления констант перечисления может привести к проблемам двоичной совместимости. Точнее, если в коде выше поменять местами A и B и перекомпилировать перечисление, байт-код клиента выше не сможет пройти компоновку, так как у Test$1 больше не будет метода с именем a. Это резко расходится с тем, что JLS говорит о двоично совместимом развитии перечислений:

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

Один из способов сохранить двоично совместимое развитие — генерировать имена классов, не зависящие от порядка, например Test$A и Test$B вместо Test$1 и Test$2. Влияние такого изменения на рефлексию и сериализацию обсуждается ниже.

Сериализация

В Java все перечисления неявно сериализуемы, так как Enum реализует Serializable. Мы хотели бы, чтобы предлагаемые здесь изменения были совместимы с сериализацией; они не должны менять сериализованную форму. Спецификация сериализации:

http://docs.oracle.com/javase/6/docs/platform/serialization/spec/serial-arch.html#6469

предусматривает особую обработку перечислений: сериализованная форма константы перечисления — это только её имя, и настроить сериализацию/десериализацию константы перечисления нельзя. (Обратите внимание, что все константы перечисления инициализируются во время <clinit>, а метод Enum.valueOf, который используется при десериализации, вызывает статический метод перечисления values(), что неявно вызывает инициализацию базового класса перечисления (и всех констант)).

Иными словами, проблем совместимости с сериализованной формой нет, так как сериализованная форма уже не зависит от имени класса, сгенерированного компилятором.

Рефлексия

Ещё одно место, где встречаются двоичные имена, — рефлексия. Следующий рефлексивный код совершенно корректен:

Class<?> c = Class.forName("Test$1");
System.err.println(c.getName()); //prints Test$1

Хотя в рефлексии есть ограничения, не позволяющие создать экземпляр константы перечисления рефлексивно, ограничений на исследование членов класса константы перечисления нет. Поэтому существующий код, использующий приведённую выше идиому, перестанет работать, если мы изменим двоичную форму констант перечислений.

Выразимость типа

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

Здесь применимы обычные доводы: с одной стороны, выразимый тип для константы перечисления делает её менее «магической» и позволяет программисту объявлять переменные этого типа. Но есть и недостатки:

  • код может стать менее читаемым (например, A a = A), так как один и тот же идентификатор может обозначать и значение, и тип
  • неясно, получают ли все константы перечисления собственный тип; как быть с константой перечисления, которая не объявляет дополнительных членов? Является ли её тип просто псевдонимом базового типа перечисления?

С другой стороны, если тип константы перечисления невыразим, он становится непрозрачной сущностью, с которой программисты могут взаимодействовать только косвенно (например, через вывод типов). Чтобы смягчить некоторые недостатки невыразимого типа, важно отметить, что предложение добавить вывод типов локальных переменных технически позволило бы программистам объявлять переменные с более точным типом перечисления, хотя он и невыразим (например, var a = A).

Доступность

Есть один граничный случай, связанный с доступностью членов через более точный тип перечисления. Рассмотрим следующий случай:

package a;

public enum Foo {
  A() { 
    public String s = "Hello!";
  };
}

package b;

class Client {
   public static void main(String[] args) {
      String s = Foo.A.s; //IllegalAccessError
   }
}

При выполнении этого кода VM выдаст IllegalAccessError. Проблема в том, что анонимный класс для константы перечисления Foo$A является package-private. Поэтому попытка обратиться к public-полю package-private класса из другого пакета приведёт к ошибке доступа. Чтобы решить эту проблему, класс константы перечисления должен иметь тот же модификатор, что и класс перечисления, в котором он определён.

Совместимость на уровне исходного кода

С точки зрения совместимости на уровне исходного кода, в некоторых случаях более точная типизация может «просочиться» наружу из-за взаимодействия этой возможности с выводом типов. Рассмотрим следующий код:

EnumSet<Test> e = EnumSet.of(Test.A);

Раньше приведённый выше код вёл себя сравнительно просто: статический тип Test.A — это просто Test, поэтому вывести переменную типа для EnumSet.of было несложно, так как оба ограничения указывали на тип Test. Но если изменить способ проверки типов для Test.A, поведение становится интереснее: переменная типа для EnumSet.of получит два конкурирующих ограничения: она должна быть равна Test (из целевого типа) и должна быть супертипом Test.A. К счастью, в такой ситуации вывод типов достаточно умён, чтобы предпочесть более строгое ограничение равенства, и в итоге выводит Test. С учётом всего этого влияние изменения на совместимость на уровне исходного кода не слишком отличается от влияния изменения из JDK-8075793, из-за которого переменные захвата стали появляться в большем числе мест вместо их верхних границ.

Риски и допущения

У этого предложения есть два основных риска, описанных в разделах выше:

  • изменение двоичных имён констант перечисления может привести к проблемам с core reflection
  • изменение типизации констант перечисления может привести к малозаметным изменениям в выводе типов методов, особенно при отсутствии целевого типа

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

Вторая проблема вызывает больше опасений, так как может привести к потенциальным проблемам совместимости на уровне исходного кода. Чтобы определить, насколько часто может возникать описанный выше сценарий несовместимости на уровне исходного кода, мы измерили, сколько раз метод EnumSet.of вызывался с разной арностью; для каждого вызова мы отмечали, происходил ли он в контексте, где был доступен целевой тип. Ниже приведены результаты (измерения проводились по полному лесу репозиториев open JDK).

  • Всего вызовов EnumSet.of: 150
    • вызовов с арностью = 1 : 69
      • из них без целевого типа: 0

Иными словами, описанный выше сценарий нарушения совместимости на уровне исходного кода, по-видимому, не представляет серьёзной угрозы.

Зависимости

Более точный тип, используемый для константы перечисления, не обязательно выразим; такие типы составили бы ещё одну категорию невыразимых типов. Это может взаимодействовать с тем, как невыразимые типы обрабатываются в JEP-286 (Local Variable Type Inference). В зависимости от решений, принятых в JEP-286 относительно невыразимых типов, можно было бы написать:

var a = Argument.String;

и получить для a более точный тип Argument.String, а не более общий тип Argument.