Когда стоит использовать перечисления в Java?

Многие считают перечисления “кодом с запашком” и антипаттерном в ООП. Это мнение прослеживается и в некоторых книгах, например в “Внедрение зависимостей в . Net” Марка Симана:

“ВНИМАНИЕ! По общему правилу перечисления являются кодом с запашком, и их необходимо преобразовывать в полиморфные классы.

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

Краткий обзор перечислений

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

enum Houses {
GRYFFINDOR,
HUFFLEPUFF,
RAVENCLAW,
SLYTHERIN
}

private Houses house1 = Houses.GRYFFINDOR
private Houses house2 = Houses.HUFFLEPUFF

Они также нередко применяются в инструкциях switch для проверки соответствующих значений:

switch (house) {
case GRYFFINDOR:
System.out.println("Welcome to Gryffindor Tower");
break;
case HUFFLEPUFF:
System.out.println("Welcome to Hufflepuff Basement");
break;
case RAVENCLAW:
System.out.println("Welcome to Ravenclaw Tower");
break;
case SLYTHERIN:
System.out.println("Welcome to Slytherin Dungeon");
break;
}

Перечисления  —  это просто список строк?

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

По сути, перечисления реализуются как классы, а их значения представляют экземпляры таких классов. Вот пример перечисления Houses в виде класса:

public final class Houses {
private Houses() {}
public static final Houses GRYFFINDOR = new Houses();
public static final Houses HUFFLEPUFF = new Houses();
public static final Houses RAVENCLAW = new Houses();
public static final Houses SLYTHERIN = new Houses();
}

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

public enum Planet {
MERCURY (3.303e+23, 2.4397e6),
VENUS (4.869e+24, 6.0518e6),
EARTH (5.976e+24, 6.37814e6),
MARS (6.421e+23, 3.3972e6),
JUPITER (1.9e+27, 7.1492e7),
SATURN (5.688e+26, 6.0268e7),
URANUS (8.686e+25, 2.5559e7),
NEPTUNE (1.024e+26, 2.4746e7);

private final double mass; // в килограммах
private final double radius; // в метрах
Planet(double mass, double radius) {
this.mass = mass;
this.radius = radius;
}

// гравитационная постоянная (m3 kg-1 s-2)
public static final double G = 6.67300E-11;

double surfaceGravity() {
return G * mass / (radius * radius);
}
}

Пример взят здесь.

Все классы перечислений происходят от стандартного класса java.lang.Enum, от которого наследуют ряд потенциально полезных методов.

Из наследуемых методов следует знать следующие: name(), ordinal() и статический values().

Метод name() возвращает имя в точности, как оно определено в значении перечисления. Метод ordinal() возвращает численное значение, отражающее порядок, в котором были объявлены перечисления, начиная с нуля. Вот пример:

Houses house = Houses.GRYFFINDOR;
System.out.println(house.name());
System.out.println(house.ordinal());

// Вернется GRYFFINDOR и 0

Метод values() пригождается чаще. Он возвращает массив из всех значений перечисления и может использоваться для их перебора. Пример:

for (Houses house : Houses.values()) {
System.out.println(house);
}

Когда и почему использовать перечисления

Несмотря на то, что перечисления являются гораздо большим, чем просто списком строк, чаще всего с их помощью представляют именно список констант. Но зачем для этого перечисления? Разве нельзя взять простой массив строк?

Разберем пример. Предположим, что вы создаете игру с таким набором команд ввода:

private static final String[] gameCommands = {
"move", "sprint", "dribble", "pass", "shoot"
};

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

switch (commandWord) {
case "move":
movePlayer(direction);
break;
case "sprint":
sprint();
break;
case "dribble":
dribbleBall();
break;
case "Pass":
pass();
break;
case "shoot":
shoot();
break;
}

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

Безопасность типов

Буква P внутри одного из кейсов была заглавной, и компилятор этого не заметил.

А теперь перепишем то же самое с помощью enum:

enum Commands {
MOVE,
SPRINT,
DRIBBLE,
PASS,
SHOOT
}

switch (commandWord) {
case MOVE:
movePlayer(direction);
break;
case SPRINT:
sprint();
break;
case DRIBBLE:
dribbleBall();
break;
case PASS:
pass();
break;
case SHOOT:
shoot();
break;
}

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

Глобализация

Если вы решите перевести программу в другой язык (скажем, команду move в mouvement), то возникнут ошибки. Если же вы просто измените название команды в массиве, то программа скомпилируется, но функциональность нарушится.

Когда перечисление излишне?

Перечисления вынуждают использовать много кейсов switch

Одна из основных причин, по которым перечисления относят к коду с запашком  —  это то, что они подталкивают к повсеместному использованию в коде инструкций switch. И в чем же тут проблема?

Взглянем на пример из книги “Чистый код. Создание, анализ, рефакторинг“ Роберта К. Мартина:

enum EmployeeType {
ENGINEER,
SALESMAN
}

class EmployeeManager {
public Money calculatePay(Employee e) throws InvalidEmployeeType {
switch (e.type) {
case SALESMAN:
return calculateCommissionedPay(e);
case ENGINEER:
return calculateSalariedPay(e);
default:
throw new InvalidEmployeeType(e.type);
}
}
}

Здесь есть несколько очевидных проблем. Код нарушает ряд лучших практик ООП.

  • При добавлении новых типов сотрудников (employee) функция постепенно разрастается, что автоматически снижает читаемость.
  • Он нарушает принцип единственной ответственности (SRP), так как для его изменения есть не одна причина.
  • Он нарушает принцип открытости/закрытости (OCP), поскольку должен изменяться при каждом добавлении новых типов.
  • Аналогично этой функции, появится неограниченное число других функций, которые будут иметь такую же структуру. isPayday (Employe e, Date date), deliverPay (Employee e, Money pay) или множество других. Добавление в перечисление дополнительного значения означает поиск каждого использования этого типа в коде и потенциальную необходимость добавления для этого значения новой ветки.

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

Используйте предложенное Робертом К. Мартином правило “Одна switch”: 

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

Пример из той же книги “Чистый код. Создание, анализ, рефакторинг”:

public abstract class Employee {
boolean isPayday();
Money calculatePay();
void deliverPay(Money pay);
}
////////////////
public interface EmployeeFactory {
public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType;
}
///////////////
public class EmployeeFactoryImpl implements EmployeeFactory {
public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType {
switch (r.type) {
case ENGINEER:
return new Engineer(r) ;
case SALESMAN:
return new Saleman(r);
default:
throw new InvalidEmployeeType(r.type);
}}
}

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

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

Перечисления снижают качество моделирования

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

Разберем прошлый пример:

enum EmployeeType {
ENGINEER,
SALESMAN
}

class Employee {
private String name;
private Date dateOfJoining;
private EmployeeType employeeType;
}

Предположим, что нужно внести возможность сохранения информации о зарплате. Заработок SALESMAN основан на процентах от продаж, значит нужно внести свойство comissionPercentage. Проще всего это сделать через добавление нового свойства в класс:

class Employee {
private String name;
private Date dateOfJoining;
private EmployeeType employeeType;
private Int commisionPercentage;
}

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

Удачнее спроектировать эту модель можно с помощью подклассов:

class Employee {
private String name;
private Date dateOfJoining;
}
class SalesMan extends Employee {
private Int commisionPercentage;
}
class Engineer extends Employee

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

Вот и все! Таким образом, если вы считаете, что вам известны все возможные значения чего-либо во время компиляции, и они могут быть представлены простым значением, то их можно выразить типом перечисления. Если же вы используете эти перечисления в нескольких кейсах switch, то стоит призадуматься.

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи daleef rahman: When to and When Not to Use Enums in Java

Предыдущая статьяЛенивая загрузка, агрегирование и CQRS
Следующая статьяПолное руководство по тестированию контрактов с помощью PACT и Go