Многие считают перечисления “кодом с запашком” и антипаттерном в ООП. Это мнение прослеживается и в некоторых книгах, например в “Внедрение зависимостей в . 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
, то стоит призадуматься.
Читайте также:
- Собеседование Java разработчика. Наиболее Часто Задаваемые Вопросы
- Java Hibernate
- Архитектура виртуальной машины Java: объяснение для начинающих
Читайте нас в Telegram, VK и Яндекс.Дзен
Перевод статьи daleef rahman: When to and When Not to Use Enums in Java