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

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

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 eDate date), deliverPay (Employee eMoney 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, то стоит призадуматься.


Источник

Report Page