Замена кода типа на класс
В предыдущей части мы рассмотрели, почему switch
- case
может быть сложно поддерживать. В этой части речь пойдет о самом простом сценарии: когда type-code влияет только на данные, а не на поведение. Мы сделаем это, моделируя пиццерию.
- Часть 1: Темная сторона Switch-Case
- Часть 2: этот пост
Исходное решение
В нашей пиццерии, когда клиенты размещают заказ, они могут выбрать размер и вид начинки, которую они хотят. Цена пиццы зависит только от ее размера. Для простоты (а также потому, что наш слоган гласит: «Вы мечтаете, мы делаем это»), мы не хотим ограничивать выбор начинки для клиентов. Поэтому мы будем моделировать начинки как список строк. Итак, наш класс пиццы будет выглядеть следующим образом:
class Pizza { static final int SIZE_SMALL = 0; static final int SIZE_NORMAL = 1; static final int SIZE_LARGE = 2; List<String> toppings; int size; Pizza(List<String> toppings, int size) { this.toppings = toppings; this.size = size; } int price() { switch (size) { case SIZE_SMALL: return 2; case SIZE_NORMAL: return 3; case SIZE_LARGE: return 4; default: throw new IllegalStateException("The field 'size' has an invalid value"); } } }
Использование этого класса довольно просто. Предполагая, что мы хотим рассчитать цену нашей новой любимой пиццы с кокосовой мятой и кокосовой мятой нормального размера, мы напишем:
Pizza pizza = new Pizza(List.of("coconut", "catnip"), Pizza.SIZE_NORMAL); int price = pizza.price();
До этого мы всегда давали пять пицц нашему доставщику на каждом шагу, потому что он не мог нести больше пиццы. Но что, если мы получим заказ на десять маленьких пицц? Было бы обидно поставить его за два раунда. Ведь две маленькие пиццы весят меньше, чем одна большая.
Итак, чтобы оптимизировать наш процесс, мы хотим рассчитать вес каждой пиццы. Опять же, для простоты, мы предполагаем, что вес пиццы зависит только от ее размера. Итак, мы добавляем этот метод в наш класс:
double weight() { switch (size) { case SIZE_SMALL: return 0.5; case SIZE_NORMAL: return 0.75; case SIZE_LARGE: return 1.25; default: throw new IllegalStateException("The field 'size' has an invalid value"); } }
Это было так же просто, как скопировать предыдущий метод и изменить тип результата, имя метода и возвращаемые значения.
Что произойдет, если нам понадобится еще один метод, в зависимости от размера? Скажем, метод toString()
. Мы делали новые копии снова и снова. Те, кому не нравится такое дублирование кода, поднимите руки! Ну, если ты не поднял руку, стыдно тебе! Но не волнуйтесь; Я убедю вас, что это нехорошо.
Расширение параметров размера
Допустим, наша пиццерия настолько популярна, что мы хотим представить пиццу огромных размеров. Это довольно просто; мы должны создать новую константу для нового размера и новое предложение case
для каждого оператора switch
. Наш новый код:
class Pizza { static final int SIZE_SMALL = 0; static final int SIZE_NORMAL = 1; static final int SIZE_LARGE = 2; static final int SIZE_MONSTER = 3; List<String> toppings; int size; Pizza(List<String> toppings, int size) { this.toppings = toppings; this.size = size; } int price() { switch (size) { case SIZE_SMALL: return 2; case SIZE_NORMAL: return 3; case SIZE_LARGE: return 4; default: throw new IllegalStateException("The field 'size' has an invalid value"); } } double weight() { switch (size) { case SIZE_SMALL: return 0.5; case SIZE_NORMAL: return 0.75; case SIZE_LARGE: return 1.25; case SIZE_MONSTER: return 2; default: throw new IllegalStateException("The field 'size' has an invalid value"); } } }
Мы уже чувствуем запах. Нам пришлось модифицировать метод, чтобы расширить его возможности. Это нарушает принцип открытости/закрытости.
К сожалению, у нас есть большие проблемы, когда мы хотим его использовать:
Pizza pizza = new Pizza(List.of("coconut", "catnip"), Pizza.SIZE_MONSTER); int price = pizza.price();
Получаем IllegalStateException
. Почему?
Конечно, мы просто забыли вставить новый оператор case
в наш метод price()
. Вы, наверное, уже это заметили, но это элементарный пример. Если у вас была более сложная ситуация, гораздо сложнее помнить каждые switch
-case
с, чтобы измениться. Я почти не осмеливаюсь упоминать, насколько это сложнее, если ваши switch
- case
не находятся в простом классе, а разбросаны по всей вашей кодовой базе.
Все еще не убеждены? Не волнуйся; есть больше.
Забудем на минуту, что мы реализовали этот класс. Давайте посмотрим на нашу единственную подпись конструктора:
Pizza(List<String>, int)
Что это говорит нам? Не так много. И, конечно же, в классе нет никакого JavaDoc. Не беспокойтесь, мы разберемся, как его использовать. Наша первая попытка:
Pizza pizza = new Pizza(null, 100); int price = pizza.price();
И мы получаем IllegalStateException
во второй строке. Почти уверен, что это потому, что мы использовали null в качестве первого аргумента, верно?
Конечно, мы знаем, что проблема не в этом: мы указали недопустимое значение размера. Да, было бы лучше, если бы мы сделали проверку аргументов в конструкторе, но суть не в этом. Если бы мы каким-то образом изменили наше поле размера внутри и оно оказалось бы недействительным, проблема была бы той же. Неважно, как сильно мы пытаемся избежать этих сценариев; возможность остается.
Было бы намного лучше, если бы мы решили основную причину проблемы, и наше поле вообще не могло иметь недопустимое значение. Точнее, если бы проверка значения происходила во время компиляции, а не во время выполнения.
Что? Проверка во время компиляции? Конечно! Мы называем это статической типизацией.
Знакомство с перечислением
Давайте попробуем новый подход. Вместо констант int
мы объявляем перечисление Size
:
class Pizza { enum Size { SMALL, NORMAL, LARGE, MONSTER } List<String> toppings; Size size; Pizza(List<String> toppings, Size size) { this.toppings = toppings; this.size = size; } int price() { switch (size) { case Size.SMALL: return 2; case Size.NORMAL: return 3; case Size.LARGE: return 4; case Size.MONSTER: return 6; default: throw new IllegalStateException("The field 'size' has an invalid value"); } } double weight() { switch (size) { case Size.SMALL: return 0.5; case Size.NORMAL: return 0.75; case Size.LARGE: return 1.25; case Size.MONSTER: return 2; default: throw new IllegalStateException("The field 'size' has an invalid value"); } } }
Создание:
Pizza pizza = new Pizza(List.of("coconut", "catnip"), Pizza.Size.MONSTER);
Из-за статической типизации в Java поле размера всегда будет иметь допустимое значение (за исключением случаев, когда мы передаем null
, но давайте оставим эту проблему на другой день). Но switch
- case
все еще там. Каждый раз, когда мы создаем новый размер, все операторы switch
-case
, которые мы используем с размером в качестве условия, должны обновляться. Мы, вероятно, забудем один или два.
Избавление от Switch-Case
Давайте изменим перечисление Size
на класс. Классы могут иметь поля, поэтому нам больше не нужно использовать оператор switch
- case
:
class Pizza { static class Size { int price; double weight; private Size(int price, double weight) { this.price = price; this.weight = weight; } int getPrice() { return price; } int getWeight() { return weight; } } static final Size SIZE_SMALL = new Size(2, 0.5); static final Size SIZE_NORMAL = new Size(3, 0.75); static final Size SIZE_LARGE = new Size(4, 1.25); static final Size SIZE_MONSTER = new Size(6, 2); List<String> toppings; Size size; Pizza(List<String> toppings, Size size) { this.toppings = toppings; this.size = size; } int price() { return size.getPrice(); } double weight() { return size.getWeight(); } }
Используй это:
Pizza pizza = new Pizza(List.of("coconut", "catnip"), Pizza.SIZE_MONSTER);
Почему лучше? У нас несколько причин:
- Мы не можем передать недопустимый
Size
. КонструкторSize
является закрытым, что означает, что его нельзя вызывать извне. Можно использовать только предопределенные константы размера. - Если мы хотим поддерживать новый размер, мы создаем новый экземпляр. Компилятор выдаст ошибку, если мы забудем установить требуемое значение, поскольку в конструкторе отсутствует аргумент.
- Из-за отсутствия оператора
switch
-case
наш код стал намного чище. - Если мы хотим, чтобы у пиццы появилось новое свойство, зависящее от размера, нам достаточно добавить новое поле в класс
Size
, сделать его обязательным в конструкторе и написать новый геттер. Компилятор отметит все ошибки; мы готовы пойти после их исправления.
Так это окончательное решение? Ну, почти. Способ enum был немного чище, потому что объявлял все свои константы. Посмотрим, как мы сможем вернуться к ним.
Месть перечислений
Во-первых, давайте реорганизуем наш код и переместим объявления констант в класс Size
:
class Pizza { static class Size { static final Size SMALL = new Size(2, 0.5); static final Size NORMAL = new Size(3, 0.75); static final Size LARGE = new Size(4, 1.25); static final Size MONSTER = new Size(6, 2); int price; double weight; private Size(int price, double weight) { this.price = price; this.weight = weight; } int getPrice() { return price; } int getWeight() { return weight; } } List<String> toppings; Size size; Pizza(List<String> toppings, Size size) { this.toppings = toppings; this.size = size; } int price() { return size.getPrice(); } double weight() { return size.getWeight(); } }
Использование:
Pizza pizza = new Pizza(List.of("coconut", "catnip"), Pizza.Size.MONSTER);
Но таким образом мы можем создавать новые экземпляры Size
и в классе Pizza
, что может сбивать с толку. Мы могли бы переместить класс Size
в его файл. Из-за приватного конструктора его нельзя было вызвать извне.
Существует другое решение, которое снова включает перечисления. Но мы уже рассмотрели эту часть; что нового могут дать перечисления? Давайте рассмотрим, что такое перечисления и как они работают в Java.
Понимание перечислений
В C (и большинстве языков) enum — это удобный способ объявления автоматически увеличивающихся int
констант. Если бы мы хотели, мы могли бы установить значение некоторых констант вручную или даже повторить значения:
typedef enum pizza_size { SMALL, NORMAL, LARGE = 3, EXTRA_LARGE, MONSTER = EXTRA_LARGE }; enum pizza_size size = NORMAL;
Таким образом, SMALL
, NORMAL
, LARGE
и MONSTER
будут иметь значения 0
, 1
, 3
, 4
и 4
соответственно. Но в основном это всего лишь int
константы. Переменные Enum могут иметь любое значение. C++ и C# также используют аналогичный подход.
Но не Ява. Конечно, можно сказать, что Java всегда выбирает свой путь. Не судите быстро, потому что перечисление Java очень крутое.
Перечисления Java похожи на класс Size
, который мы только что реализовали. Это классы (статические, если они объявлены как внутренние типы) с частным конструктором. Единственные экземпляры, которые они могут иметь, это те, которые мы объявили.
У такой конструкции есть пара преимуществ:
- Мы можем сравнивать переменные перечисления с
==
, напримерint
s. Если содержащиеся константы различаются, мы сравниваем ссылку двух разных экземпляров, которая возвращает false. Если они имеют одинаковое значение, они будут представлены одним и тем же объектом, поэтому ссылки будут одинаковыми. Следовательно, сравнение вернет true. Это означает удобное, быстрое и удобочитаемое сравнение. - Поскольку они являются классами, они могут иметь свойства, методы и конструкторы. Единственное ограничение, которое у нас есть, — это видимость конструкторов: они всегда приватные.
- Мы не можем случайно их создать (из-за приватного конструктора). Создание экземпляра всегда происходит как объявление новой константы.
- Их всегда заказывают, что иногда может быть довольно ценным. Порядок — это порядок объявления, и мы можем запросить порядковый номер (начиная с 0) с помощью неявного метода
int ordinal()
. - Точно так же мы можем получить доступ к объявленному имени с помощью метода
String name()
или преобразовать имя в константу с помощью методаstatic T valueOf(String)
. - Экземпляров всегда
public static final
, а конструкторовprivate
. Нам не нужно указывать эти ключевые слова (на самом деле мы получаем ошибку, если используем другую видимость). - Из-за приватного конструктора мы не можем расширять перечисления (есть ли в этом смысл?).
Применение умных перечислений
Из-за вышеизложенного мы можем переписать класс Size
в перечисление:
class Pizza { enum Size { SMALL(2, 0.5), NORMAL(3, 0.75), LARGE(4, 1.25), MONSTER(6, 2); int price; double weight; Size(int price, double weight) { this.price = price; this.weight = weight; } int getPrice() { return price; } int getWeight() { return weight; } } List<String> toppings; Size size; Pizza(List<String> toppings, Size size) { this.toppings = toppings; this.size = size; } int price() { return size.getPrice(); } double weight() { return size.getWeight(); } }
Он очень похож на наш предыдущий пример, но с меньшим количеством шаблонного кода. Сладкий.
Работа с пользовательским вводом
До сих пор мы определяли значения в зависимости от размера. Но этот размер исходит от пользователя. Если у нас есть веб-приложение, оно, скорее всего, происходит из раскрывающегося списка или переключателя:
<select name="size"> <option value="SMALL">Small</option> <option value="MEDIUM">Medium</option> <option value="LARGE">Large</option> <option value="MONSTER">Monster</option> </select>
И здесь размеры — это строки. Как мы можем преобразовать их в экземпляры Size
?
В Java это довольно просто с решением перечисления:
String sizeName; Pizza.Size size = Pizza.Size.valueOf(sizeName);
Но что, если нам нужно работать с другим языком, скажем, с JavaScript? Мы всегда могли ввести switch
- case
, но теперь мы знаем лучше.
Лучшим решением является использование таблиц поиска. Каждый язык имеет для него реализацию с разными именами: карта, словарь, таблица; вы называете это.
Например, в JavaScript самым простым решением является использование объекта:
const sizes = { SMALL: Pizza.Size.SMALL, MEDIUM: Pizza.Size.MEDIUM, LARGE: Pizza.Size.LARGE, MONSTER: Pizza.Size.MONSTER, };
Использование простое:
let sizeName; let size = sizes[sizeName];
Если мы хотим, мы можем даже встроить объявления констант:
const sizes = { SMALL: new Pizza.Size(2, 0.5), NORMAL: new Pizza.Size(3, 0.75), LARGE: new Pizza.Size(4, 1.25), MONSTER: new Pizza.Size(6, 2), };
Поскольку таблицы поиска в определенных обстоятельствах более гибкие, это тоже хорошее решение.
Заключение
В этом посте нашим кодом типа был размер пиццы. Мы можем эффективно использовать вышеуказанные решения, если:
- Несколько значений атрибутов зависят от кода типа
- Поведение методов всегда одинаково (они не делают разных вещей, если меняется код типа. Возврат разных значений — это одно и то же поведение. Поедание пиццы или ношение ее как шляпы — нет).
Мы представили несколько возможных способов рефакторинга кода и избавления от switch
- case
:
- Представляем класс с фиксированным набором константных экземпляров
- В Java использование перечислений для достижения того же
- Использование таблиц поиска
Если поведение нашего класса также зависит от кода типа, мы должны использовать разные подходы. Мы рассмотрим их в следующих частях.
Первоначально опубликовано на https://rockit.zone.