Как красиво избавиться от switch-case посредством перечисления

ee4c6c79c255d1d18d2a281651f1ef6c

Привет, Хабр! Применение 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);
    }
}

Данное решение имеет ряд проблем:

  1. В случае добавления животного в перечисление, требуется дописать и вышеуказанный код.

  2. Разработчику никто не напомнит, что это нужно сделать. Т.е., если зоопарк разрастётся до сотен тысяч строк, вполне можно забыть, что при добавлении животного в перечисление необходимо еще и дописать код. В конце концов, это приведет к ошибке (и хорошо, если определено поведение default, в этом случае, по крайней мере, есть возможность быстро определить проблемное место).

  3. При большом количестве животных switch-case сильно разрастётся.

  4. Ну, и известная проблема 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.

© Habrahabr.ru