Как красиво избавиться от switch-case посредством перечисления
Привет, Хабр! Применение switch-case в коде — давняя тема холиваров на форумах на предмет чистоты кода. Лично я склоняюсь к простому мнению: инструмент необходимо использовать по назначению.
Сегодня хотелось бы рассмотреть несколько простых кейсов, где switch-case является не лучшим выбором и предложить красивое и удобное решение проблемы.
Итак, непосредственно, кейс: от значения одной переменной зависит значение другой переменной.
Исходные данные: программа, имитирующая зоопарк. Она содержит несколько животных, представленных в виде перечисления Animal, и пару работников, описанных интерфейсом ZooWorker.
public enum Animal {
HIPPO,
PENGUIN,
MONKEY,
OWL;
}
public interface ZooWorker {
void feed(Animal animal);
}
Задача: научить работников кормить животных (по сути — реализовать интерфейс ZooWorker). Алгоритм действия очень прост — необходимо определить название корма, которым питается животное, и вывести в консоль сообщение о том, что животное покормлено и чем именно оно покормлено.
Первый вариант написан на java 11. Он является самым громоздким и выглядит следующим образом:
public class Java11Worker implements ZooWorker {
@Override
public void feed(Animal animal) {
String foodName;
switch (animal) {
case OWL:
foodName = "Mouse";
break;
case HIPPO:
foodName = "Grass";
break;
case PENGUIN:
foodName = "Fish";
break;
case MONKEY:
foodName = "Banana";
break;
default:
throw new IllegalArgumentException("Unknown animal!");
}
System.out.printf("%s eat: %s%n", animal, foodName);
}
}
Данное решение имеет ряд проблем:
В случае добавления животного в перечисление, требуется дописать и вышеуказанный код.
Разработчику никто не напомнит, что это нужно сделать. Т.е., если зоопарк разрастётся до сотен тысяч строк, вполне можно забыть, что при добавлении животного в перечисление необходимо еще и дописать код. В конце концов, это приведет к ошибке (и хорошо, если определено поведение default, в этом случае, по крайней мере, есть возможность быстро определить проблемное место).
При большом количестве животных switch-case сильно разрастётся.
Ну, и известная проблема switch-case в java 11 — бесконечный break, который легко пропустить.
Существует возможность немного отрефакторить вышеописанный пример и избавиться от проблемы №4 следующим образом:
public class Java11Worker implements ZooWorker {
@Override
public void feed(Animal animal) {
String foodName = getFoodName(animal);
System.out.printf("%s eat: %s%n", animal, foodName);
}
private String getFoodName(Animal animal) {
switch (animal) {
case OWL:
return "Mouse";
case HIPPO:
return "Grass";
case PENGUIN:
return "Fish";
case MONKEY:
return "Banana";
default:
throw new IllegalArgumentException("Unknown animal!");
}
}
}
Выглядит лучше, однако, другие проблемы остаются.
Начиная с java 14 и выше появилась возможность использовать более удобный формат switch-case. Представленное выше решение в новом формате будет выглядеть следующим образом:
public class Java17Worker implements ZooWorker {
@Override
public void feed(Animal animal, int animalCount) {
String foodName = switch (animal) {
case OWL -> "Mouse";
case HIPPO -> "Grass";
case PENGUIN -> "Fish";
case MONKEY -> "Banana";
};
System.out.printf("%s eat: %s%n", animal, foodName);
}
}
Помимо избавления от break, решилась проблема №2: если разработчик добавит в перечисление животное, но не определит необходимое поведение в switch-case, код просто не скомпилируется. Уже лучше, однако, третья и первая проблемы все ещё остаются.
В последнем решении перенесём зависимость переменных непосредственно в перечисление. Для этого немного изменим его:
public enum Animal {
HIPPO("Grass"),
PENGUIN("Fish"),
MONKEY("Banana"),
OWL("Mouse");
@Getter
private final String foodName;
Animal(String foodName) {
this.foodName = foodName;
}
}
Теперь можно реализовать работника всего в одну строку:
public class EasyWorker implements ZooWorker {
@Override
public void feed(Animal animal) {
System.out.printf("%s eat: %s%n", animal, animal.getFoodName());
}
}
В представленном решении, при добавлении элемента в перечисление, нет необходимости дописывать код, и разработчик точно не забудет нигде ничего дописать. К тому же, код не будет превращаться в лапшу при увеличении количества элементов в перечислении.
Попробуем немного расширить и усложнить наш кейс: теперь от значения одной переменной зависит не только значение другой, но и последующий алгоритм действий.
Задача остается той же — научить работников кормить животных. Однако, теперь для того, чтобы покормить животное, необходимо не просто определить требуемый корм, но и рассчитать его объем. Для этого изменим интерфейс ZooWorker:
public interface ZooWorker {
void feed(Animal animal, int animalCount);
}
Для большей наглядности представим, что расчёт корма не везде производится простым умножением на коэффициент, а используется некая формула:
Для бегемотов: [количество бегемотов^2]
Для филинов: [количество филинов * 3]
Для пингвинов: [(количество пингвинов ^ 3)/2]
Для обезьян: [количество обезьян * 10]
Ниже представлены решения по уже используемым шаблонам:
Решение switch-case на java 11
public class Java11Worker implements ZooWorker {
@Override
public void feed(Animal animal, int animalCount) {
String foodName;
int foodQuantity;
switch (animal) {
case OWL:
foodName = "Mouse";
foodQuantity = animalCount * 3;
break;
case HIPPO:
foodName = "Grass";
foodQuantity = (int) Math.pow(animalCount, 2);
break;
case MONKEY:
foodName = "Banana";
foodQuantity = animalCount * 10;
break;
case PENGUIN:
foodName = "Fish";
foodQuantity = (int) (Math.pow(animalCount, 3)/2);
break;
default:
throw new IllegalArgumentException("Unknown animal!");
}
System.out.printf("%s eat: %d %s", animal, foodQuantity, foodName);
}
}
Немного переработанный код будет выглядеть следующим образом:
public class Java11Worker implements ZooWorker {
@Override
public void feed(Animal animal, int animalCount) {
String foodName = getFoodName(animal);
int foodQuantity = getfoodQuantity(animal, animalCount);
System.out.printf("%s eat: %d %s", animal, foodQuantity, foodName);
}
private String getFoodName(Animal animal) {
switch (animal) {
case OWL:
return "Mouse";
case HIPPO:
return "Grass";
case MONKEY:
return "Banana";
case PENGUIN:
return "Fish";
default:
throw new IllegalArgumentException("Unknown animal!");
}
}
private int getfoodQuantity(Animal animal, int animalCount) {
switch (animal) {
case OWL:
return animalCount * 3;
case HIPPO:
return (int) Math.pow(animalCount, 2);
case MONKEY:
return animalCount * 10;
case PENGUIN:
return (int) (Math.pow(animalCount, 3)/2);
default:
throw new IllegalArgumentException("Unknown animal!");
}
}
}
Решение switch-case на java 17
public class Java17Worker implements ZooWorker {
@Override
public void feed(Animal animal, int animalCount) {
String foodName = switch (animal) {
case OWL -> "Mouse";
case HIPPO -> "Grass";
case PENGUIN -> "Fish";
case MONKEY -> "Banana";
};
int foodQuantity = switch (animal) {
case OWL -> animalCount * 3;
case HIPPO -> (int) Math.pow(animalCount, 2);
case PENGUIN -> (int) (Math.pow(animalCount, 3) / 2);
case MONKEY -> animalCount * 10;
};
System.out.printf("%s eat: %d %s", animal, foodQuantity, foodName);
}
}
Для решения через enum требуется доработать перечисление:
public enum Animal {
HIPPO("Grass", animalCount -> (int) Math.pow(animalCount, 2)),
PENGUIN("Fish", animalCount -> (int) (Math.pow(animalCount, 3) / 2)),
MONKEY("Banana", animalCount -> animalCount * 10),
OWL("Mouse", animalCount -> animalCount * 3);
@Getter
private final String foodName;
@Getter
private final IntFunction foodCalculation;
Animal(String foodName, IntFunction foodCalculation) {
this.foodName = foodName;
this.foodCalculation = foodCalculation;
}
}
И, собственно, сам работник:
public class EasyWorker implements ZooWorker {
@Override
public void feed(Animal animal, int animalCount) {
System.out.printf("%s eat: %d %s",
animal,
animal.getFoodCalculation().apply(animalCount),
animal.getFoodName()
);
}
}
Перейдем к заключительному примеру. Предположим, расчёт корма представляет собой сложную логику, которую не получится передать в качестве лямбды.
В этом случае для каждого животного создадим отдельного сотрудника, который будет кормить только его.
Реализованные работники
public class HippoWorker implements ZooWorker {
private final String foodName;
public HippoWorker(String foodName) {
this.foodName = foodName;
}
@Override
public void feed(int animalCount) {
//Сложная логика
int foodQuantity = (int) Math.pow(animalCount, 2);
System.out.printf("Hippo eat: %d %s", , foodQuantity, foodName);
}
}
public class MonkeyWorker implements ZooWorker {
private final String foodName;
public MonkeyWorker(String foodName) {
this.foodName = foodName;
}
@Override
public void feed(int animalCount) {
//Сложная логика
int foodQuantity = animalCount * 10;
System.out.printf("Monkey eat: %d %s", foodQuantity, foodName);
}
}
public class OwlWorker implements ZooWorker {
private final String foodName;
public OwlWorker(String foodName) {
this.foodName = foodName;
}
@Override
public void feed(int animalCount) {
//Сложная логика
int foodQuantity = animalCount * 3;
System.out.printf("Owl eat: %d %s", foodQuantity, foodName);
}
}
public class PenguinWorker implements ZooWorker {
private final String foodName;
public PenguinWorker(String foodName) {
this.foodName = foodName;
}
@Override
public void feed(int animalCount) {
//Сложная логика
int foodQuantity = (int) (Math.pow(animalCount, 3) / 2);
System.out.printf("Penguin eat: %d %s", foodQuantity, foodName);
}
}
Представим, как решить задачу «покормить всех животных» «в лоб»: например, в вызывающем классе собираем список (множество) всех работников, и получаем возможность по очереди вызвать метод feed у элементов списка. Вышло бы что-то вроде этого:
public class Feeder {
private final List workerList;
public Feeder(List workerList) {
this.workerList = workerList;
}
public void feedAll(int animalCount) {
workerList.forEach(zooWorker -> zooWorker.feed(animalCount));
}
}
Выглядит неплохо, однако, что делать, если потребуется отдельный метод для каждого животного, например, public void feedHippo(int animalCount)
, или универсальный public void feedAnimal(Animal animal, int animalCount)
? На данном этапе возникнут проблемы. Решением может быть создание мапы, содержащей всех работников. Но тогда необходимо хранить ключи к ней (или хардкодить). Можно сделать ключом непосредственно значение перечисления, но все равно придется где-то собирать мапу. Другим решением может стать внедрение работников в качестве полей, но их [работников] может быть много, а feedAnimal
будет опять работать на громоздком switch-case. И все эти варианты нужно поддерживать, а при добавлении нового животного придется искать по коду, где отрабатывает логика кормления.
Однако, если изменим перечисление следующим образом:
public enum Animal {
HIPPO(new HippoWorker("Grass")),
PENGUIN(new PenguinWorker("Fish")),
MONKEY(new MonkeyWorker("Banana")),
OWL(new OwlWorker("Mouse"));
private final ZooWorker worker;
Animal(ZooWorker worker) {
this.worker = worker;
}
public void feed(int animalCount) {
worker.feed(animalCount);
}
}
Все становится настолько простым:
public class Feeder {
public void feedAll(int animalCount) {
Arrays.stream(Animal.values())
.forEach(animal -> animal.feed(animalCount));
}
public void feedHippo(int animalCount) {
Animal.HIPPO.feed(animalCount);
}
public void feedAnimal(Animal animal, int animalCount) {
animal.feed(animalCount);
}
}
Подведем итоги.
Мы рассмотрели 3 варианта с нарастающей сложностью, где можно красиво применить перечисление вместо switch-case. Предложенные решения задач просты в реализации и более поддерживаемы и расширяемы по сравнению с решением «в лоб» с использованием switch-case.