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


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

    Animal(String foodName, IntFunction<Integer> 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<ZooWorker> workerList;

    public Feeder(List<ZooWorker> 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.




Комментарии (24):

  1. Pavel_nobranch
    /#24598410 / +12

    В приведенном прмере идет чистый key - value. Такое-то животное, ест такой-то корм. Поэтому оптимально использовать Dictionary. Если нужна возможность расширить список кормов, то валуем нужно сделать лист<>. Куда добавлять корма. А если в валуй загнать обьект класса, то можно реализовать любую логику.

    • AlexZaharow
      /#24599476

      В C# есть кортежи. Нет ли в Java чего-то подобного?

      • isden
        /#24599802 / +1

        Records?
        Еще есть третьесторонние либы, типа javatuples.

      • Pavel_nobranch
        /#24599914

        Пишу на C#. Про java знаю что он идентичен. Писал на java/fx давно чуть-чуть, перешел на c#/wpf. Поэтому не эксперт.

  2. whalemare
    /#24598436 / +1

    Полиморфизм это супер, тоже стараюсь использовать его, когда это не оверхед

  3. Hardcoin
    /#24598462 / +10

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

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

    • PapaKarlo787
      /#24601270

      Вот, да! А ещё не понятно почему все указанные параметры это не прерогатива животных их определять?

  4. panzerfaust
    /#24598502 / +15

    Связывание типа животного с относящейся к нему бизнес-логикой в рамках енам-класса - это уже нарушение SRP и смешение уровней абстракции.

    Кроме того, финальный вариант из статьи вовсе не финальный. Что если с каждым животным потребуется связать не только тип работника, но и, например, тип вольера, тип поставщика кормов, режим дня? А что если работник нужен не в одном экземпляре (с мышом справится один человек, а на бегемота нужны двое-трое)? Енам будет обрастать новыми private final полями и дополнительной бизнес-логикой, а проблема нарушения SRP будет только усугубляться.

    Я б использовал абстрактную фабрику.

    Hidden text

    Как это часто бывает, GOF спешит на помощь даже спустя 30 лет

    AnimalFactory factory = animalFactory.findFactory(animal)
    List<Workers> workers = factory.getWorkers()
    List<FoodItem> food = factory.getFood()
    ...
    AnimalFactory findFactory(Animal animal) {
    	switch(animal)
      	case HIPPO -> new HippoFactory()
        ...
    }

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

    Короче, лучше вернутся к исчерпывающим свитчам из 14 джавы, чем накручивать енам левой логикой. Разрастающийся свитч будет локализован внутри фабрики. Ну а что потребуется дописывать код - тут уж извините, работа у нас такая. Плюс не забываем о TDD. Кейс добавления нового типа животного должен сразу появляться в тестах, что также минимизирует вероятность что-то забыть.

    • brutfooorcer
      /#24598696

      Если животное обрастает зависимостями, как в вашем примере - можно связать его [животное] с конкретной фабрикой, и тогда и не придется нагружать енам логикой, и избавимся от всех перечисленных в статье проблем в методе findFactory().

      Естественно, расширять енам кучей полей/логики - плохая идея. Но в тех случаях, когда это оправдано - почему нет? Приведенный зоопарк - лишь наглядный пример. Также, я не согласен, что примеры нарушают SRP: енам имеет единственную причину для изменений - добавление нового животного.

      Ну и, на самом деле, свичи из 14 джавы действительно уже хороши и уже являются не плохой альтернативой - я не считаю, что нужно везде все срочно переделывать на перечисления. Проблема в том, что далеко не все проекты используют джава 14, и некоторые кейсы с перечислениями точно будут выглядеть лучше)

    • isden
      /#24598836 / +1

      Мне кажется, тут лучше стратегия подойдет, а не фабрика.

    • iamkisly
      /#24598952 / +1

      Совершенно верно, у рабоника может быть список пермишнов (орнитолог не может чесать пузико киту) поэтому этим должна заниматься фабрика.

  5. Nialpe
    /#24598658 / +6

    По-моему термин Worker внутри термина Animal не совсем уместен и вносит путаницу. Перечисление Animal должно содержать информацию о животном в соответствии с так любимой буквой S из магического слова SOLID. И тогда вместо Worker в Animal можно поместить информацию о рационе животного, да и то достаточно условно т.к. можно еще накрутить, что детеныши и взрослые особи питаются по-разному и т.п.(погружаться можно до бесконечности).

    А далее, если речь о зоопарке. Есть список работников-ухаживающих за животными, есть понимание кто из работников на рабочем месте (не заболел, не в отпуске), кто умеет кормить тот или иной животного и назначать дневную нагрузку на рабочего, а рабочий лезет в перечисление Animal, получает рацион, берет со склада корм и кормит животного.

    В разработке о правильности и неправильности абстракций спорить можно бесконечно. И мое предложение вполне вероятно не идеал. Но мыслями поделиться хотелось с вами и с публикой.

  6. vesper-bot
    /#24599000 / -1

    Что обидно, на низком уровне вы все равно никуда не денетесь от GOTO. Весь этот сахар нужен только чтобы набирать код было удобнее, а мест, где человек может ошибиться, меньше. Но и только.

    • datacompboy
      /#24600522 / +4

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

    • Hardcoin
      /#24601458

      Не низком уровне не goto, а электроны. Избавиться от них не выйдет, но какая разница? Код пишут для людей, а не только для машин (если это не личный проект для самого себя).

    • Sergey_zx
      /#24603024

      Как уже выше писали, делаем список обьектов и к нему придефайненый енум таким образом, что бы его числовое значение было индексом соответствующего объекта в списке. И никаких if-case-switch-goto.

      Ну а далее уже в зависимости от задачи. Или список-енум статически линкованный, или инициализируемый при старте, или динамически изменяемый.

  7. VadimZ76
    /#24599762 / -1

    System.out.printf("Hippo eat: %d %s", , foodQuantity, foodName);

    Две запятые? Я программировал на java, но не помню такого? Это опечатка?
    Иначе хотелось бы понять, что за магия?

  8. feoktant
    /#24600012 / +3

    По курсу Скалы, было такое эмперическое правило:
    - Если собираетесь увеличивать число операцией над +/- стабильными сущностями - используйте ФП подход, switch/case
    - Если собираетесь увеличивать количество сущностей, а операции +\- стабильны - используйте ООП подход

    И свитч-кейс, и ООП вполне имеют право на существование. А с приходом рекордов в Джаве станет еще больше опций для выбора

  9. ystal
    /#24600538

    import lombok.Getter;
    
    import java.util.List;
    import java.util.function.BiConsumer;
    import java.util.function.BiFunction;
    import java.util.function.Consumer;
    
    class Scratch {
    
        public enum Animal {
            HIPPO("Grass", BaseWorker::feedHippo),
            PENGUIN("Fish", BaseWorker::feedPenguin),
            MONKEY("Banana", BaseWorker::feedMonkey),
            OWL("Mouse", BaseWorker::feedOwl);
    
            @Getter
            private final String foodName;
            @Getter
            private final BiConsumer<BaseWorker, Integer> foodCalculation;
    
            Animal(String foodName, BiConsumer<BaseWorker, Integer> foodCalculation) {
                this.foodName = foodName;
                this.foodCalculation = foodCalculation;
            }
        }
    
        //@Component - воркер может быть под управлением DI контейнера
        static class BaseWorker {
            //@Autowired - с инжектом внешних зависимостей.
            MenuRepository menuRepository;
    
            public void feedHippo(int animalCount) {
                //Сложная логика
                int foodQuantity = (int) Math.pow(animalCount, 2);
                System.out.printf("Hippo eat: %d %s", foodQuantity, menuRepository.findMenuFor("Hippo"));
            }
    
            void feedPenguin(int animalCount) {
                //Сложная логика
                int foodQuantity = (int) (Math.pow(animalCount, 3) / 2);
                System.out.printf("Penguin eat: %d %s", foodQuantity, menuRepository.findMenuFor("Penguin"));
            }
    
            void feedMonkey(int animalCount) {
                //Сложная логика
                int foodQuantity = animalCount * 10;
                System.out.printf("Penguin eat: %d %s", foodQuantity, menuRepository.findMenuFor("Monkey"));
            }
    
            void feedOwl(int animalCount) {
                //Сложная логика
                int foodQuantity = animalCount * 3;
                System.out.printf("Penguin eat: %d %s", foodQuantity, menuRepository.findMenuFor("Owl"));
            }
        }
    }

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

    • MyraJKee
      /#24605402

      Чето как-то не очень? А если кол-во наименований животных будет постоянно увеличиваться?

  10. Throwable
    /#24600976 / +3

    Идея интересная, однако не стоит так делать повсеместно. Здесь вы инкапсулируете в класс животного свойства, которые относятся больше к компетенции ZooWorker-а, и не имеют большого смысла в отрыве от него. Например вот у нас в зоопарке открылся ветеринарный отдел, и теперь снова будем нагружать бедных животных новыми свойствами и всей логикой, относящейся к ветеринарной службе? В итоге когда предметная область вырастет, ваши животные превратятся в пухлых монстров.

  11. fransua
    /#24602864

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

  12. souls_arch
    /#24604468 / -1

    Хороший велосипед ты изобрел. Перейдем к читабельности/ поддерживаемости кода в рамках не микросервиса(да даже в нем) и монолита.

    Я только учус. Имхо, автор вообще не понимает, как работает jvm и что скрывается за реализацей енум. Спасибо за мнение, придете в команду, не забудьте навязать! Отпишитесь - похохочем!

  13. sargon5000
    /#24605568

    Что красивого в том, чтобы захардкодить этот зоопарк? И при любом пополнении или смерти животного переписывать текст программы? Может, я кощунственные слова скажу, но я б загнал зоопарк – людей, зверей, пищу, зарплатные ведомости – в простенькую БД. И ни в одной строке программы не будут упомянуты "Monkey" или "Сторож дядя Петя".