Новости

28.11.2022

Книга «Пять строк кода. Роберт Мартин рекомендует»

Пусть код типа работает


В конце предыдущей главы мы просто ввели функцию handleInput, для которой не могли использовать «Извлечение метода», потому что не хотели разрывать цепочку else if. К сожалению, handleInput не вписывается в наше основополагающее правило «Пять строк», поэтому оставлять все как есть нельзя.

Вот эта функция.

Листинг 4.1. Начальная форма

function handleInput(input: Input) {
     if (input === Input.LEFT) moveHorizontal(-1);
     else if (input === Input.RIGHT) moveHorizontal(1);
     else if (input === Input.UP) moveVertical(-1);
     else if (input === Input.DOWN) moveVertical(1);
}

 

4.1. РЕФАКТОРИНГ ПРОСТОЙ ИНСТРУКЦИИ IF


Здесь мы немного застряли. Чтобы показать вам, как обрабатывать подобные цепочки else if, я начну с введения нового правила.

4.1.1. Правило «Никогда не использовать if с else»


Утверждение

Никогда не используйте if с else, если только не выполняете проверку в отношении типа данных, который не контролируете.

Объяснение

Принимать решения бывает непросто. В реальной жизни многие люди склонны этого избегать и постоянно откладывают решение на потом. А вот в коде мы используем инструкции if-else активно. Я не стану утверждать, как лучше действовать в жизни, но в коде ожидание определенно является более удачной тактикой. Если мы используем if-else, то фиксируем точку, в которой программа принимает решение. Это снижает гибкость кода, поскольку исключает возможность внесения вариативности после блока if-else.

Конструкции if-else можно рассматривать как жестко закодированные решения. Однако подобно тому, как нам не нравятся жестко прописанные в коде константы, так же не нравятся и жестко прописанные решения.

Лучше никогда не прописывать решение жестко, то есть никогда не использовать if с else. К сожалению, при этом необходимо обращать внимание на то, относительно чего выполняется проверка. Например, с помощью e.key мы проверяем, какая клавиша нажата, здесь у нас используется тип string. Реализацию string мы изменить не можем, значит, не можем избежать и цепочки else if.

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

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

Листинг 4.2. Отображение пользовательского ввода в управляемые типы данных

window.addEventListener("keydown", e => {
     if (e.key === LEFT_KEY || e.key === "a") inputs.push(Input.LEFT);
     else if (e.key === UP_KEY || e.key === "w") inputs.push(Input.UP);
     else if (e.key === RIGHT_KEY || e.key === "d") inputs.push(Input.RIGHT);
     else if (e.key === DOWN_KEY || e.key === "s") inputs.push(Input.DOWN);
});


Мы не имеем контроля над любым из двух типов данных в этих условиях: KeyboardEvent и string. Как и говорилось, эти цепочки else if должны быть напрямую связаны с вводом/выводом, который, в свою очередь, должен быть отделен от остальной части приложения.

Обратите внимание, что мы считаем отдельные if проверками, а if-else — решениями. Это позволяет проводить простую проверку в начале методов, где было бы сложно извлечь ранний возврат return, как в следующем примере. То есть это правило конкретно нацелено на else.

Помимо этого, оно легко проверяется: достаточно просто найти else. Вернемся к более ранней функции, которая получает массив чисел и находит их среднее. Если вызвать предыдущую реализацию с пустым массивом, то мы получим ошибку деления на нуль. В этом есть смысл, потому что мы эту реализацию знаем, но для пользователя такая ошибка окажется бесполезной. Значит, желательно более широко идентифицировать (выбросить) ошибку через throw. Вот два способа исправить это.

Листинг 4.3. До

function average(ar: number[]) {
     if (size(ar) === 0)
          throw "Empty array not allowed";
     else     
          return sum(ar) / size(ar);
}
Листинг 4.4. После

function assertNotEmpty(ar: number[]) {
     if (size(ar) === 0)
          throw "Empty array not allowed";
}
function average(ar: number[]) {
     assertNotEmpty(ar);
     return sum(ar) / size(ar);
}


Запах

Это правило относится к раннему связыванию, которое является запахом. Когда мы компилируем программу, то поведение, подобное решениям if-else, разрешается и фиксируется в нашем приложении, не позволяя внести изменения без повторной компиляции. Противоположным этому является позднее связывание, когда поведение определяется в последний возможный момент уже при выполнении кода.

Раннее связывание не позволяет делать изменение путем добавления, потому что мы можем изменить инструкцию if, только модифицировав ее с последующей компиляцией. В свою очередь, позднее связывание позволяет использовать простое добавление, что намного предпочтительнее. Об этом мы говорили в главе 2.

Намерение

Инструкции if выступают в качестве операторов потока управления. Это означает, что они определяют, какой код должен выполняться далее. Но в объектно-ориентированном программировании есть намного более сильные операторы потока управления: объекты. Если использовать интерфейс с двумя реализациями, то мы сможем при выполнении решить, какой код выполнять, в зависимости от инстанцируемого класса. По сути, это правило вынуждает нас искать способы использовать объекты, которые являются более эффективными и гибкими инструментами управления.

4.1.2. Применение правила


Первым шагом для избавления от if-else в handleInput будет замена перечисления Input интерфейсом Input. После этого значения заменяются классами. В завершение — это самая восхитительная часть — из-за того, что теперь значения являются объектами, становится возможно переместить код внутри if в методы каждого из классов. Но для этого нам предстоит преодолеть несколько разделов книги, так что наберитесь терпения. Мы будем идти к заветной цели шаг за шагом.

1. Введем новый интерфейс с временным именем Input2, содержащий методы для четырех значений в нашем перечислении.

Листинг 4.5. Новый интерфейс

enum Input {
     RIGHT, LEFT, UP, DOWN
}
interface Input2 {
     isRight(): boolean;
     isLeft(): boolean;
     isUp(): boolean;
     isDown(): boolean;
}


2. Создадим четыре класса, соответствующие этим четырем значениям перечисления. Все методы, за исключением соответствующего конкретному классу, должны возвращать false. Заметьте: эти методы временные, в чем мы позже убедимся.

image


3. Переименуем перечисление в RawInput, после этого компилятор будет выдавать ошибку во всех местах, где используется это перечисление.

Листинг 4.7. До

enum Input {
     RIGHT, LEFT, UP, DOWN
}
Листинг 4.8. После (1/3)

enum RawInput {
     RIGHT, LEFT, UP, DOWN
}

4. Изменим типы с Input на Input2 и заменим проверки равенства новыми методами.

image


5. Исправим последние ошибки внесением изменений.

Листинг 4.11. До

Input.RIGHT
Input.LEFT
Input.UP
Input.DOWN
Листинг 4.12. После (3/3)

new Right()
new Left()
new Up()
new Down()

6. В завершение везде переименуем Input2 в Input. На этом этапе код будет выглядеть так.

Листинг 4.13. До

window.addEventListener("keydown", e =>
{
     if (e.key === LEFT_KEY
               || e.key === "a")
          inputs.push(Input.LEFT);
     else if (e.key === UP_KEY
               || e.key === "w")
          inputs.push(Input.UP);
     else if (e.key === RIGHT_KEY
               || e.key === "d")
          inputs.push(Input.RIGHT);
     else if (e.key === DOWN_KEY
               || e.key === "s")
          inputs.push(Input.DOWN);
});

function handleInput(input: Input) {
     if (input === Input.LEFT)
          moveHorizontal(-1);
     else if (input === Input.RIGHT)
          moveHorizontal(1);
     else if (input === Input.UP)
          moveVertical(-1);
     else if (input === Input.DOWN)
          moveVertical(1);
}
Листинг 4.14. После

window.addEventListener("keydown", e =>
{
     if (e.key === LEFT_KEY
               || e.key === "a")
          inputs.push(new Left());
     else if (e.key === UP_KEY
               || e.key === "w")
          inputs.push(new Up());
     else if (e.key === RIGHT_KEY
               || e.key === "d")
          inputs.push(new Right());
     else if (e.key === DOWN_KEY
               || e.key === "s")
     inputs.push(new Down());
});

function handleInput(input: Input) {
     if (input.isLeft())
          moveHorizontal(-1);
     else if (input.isRight())
          moveHorizontal(1);
     else if (input.isUp())
          moveVertical(-1);
     else if (input.isDown())
          moveVertical(1);
}


В шаблоне «Замена кода типа классами» включаем процесс создания перечислений в классы.

4.1.3. Шаблон рефакторинга «Замена кода типа классами»


Описание

Этот шаблон рефакторинга преобразует перечисление в интерфейс, при этом значения перечисления становятся классами. Подобное действие позволяет нам добавлять каждому значению свойства и локализовать функциональность, относящуюся к данному конкретному значению. Совместно с другим шаблоном рефакторинга, который рассмотрим следующим («Перемещение кода в классы», 4.1.5), это дает возможность вносить изменения путем добавления. Дело в том, что зачастую используются перечисления посредством switch или цепочек else if, разбросанных по всему приложению. Инструкция switch определяет, как каждое возможное значение перечисления должно обрабатываться в данном месте.

Когда мы трансформируем значения в классы, получаем возможность вместо этого сгруппировать функциональность, относящуюся к этому значению, без необходимости учитывать какие-либо другие значения перечисления. Этот процесс объединяет функциональность с данными. Он локализует функциональность относительно данных, то есть конкретного значения данных. Добавление нового значения в перечисление означает проверку логики, связанной с этим перечислением, во многих файлах, тогда как добавление нового класса, реализующего интерфейс, требует от нас создания методов только в этом файле — никакой модификации другого кода не требуется (конечно, пока нам не понадобится использовать этот новый класс).

Обратите внимание, что код типа также оформляется иначе, чем перечисления. Любой целочисленный тип или любой тип, поддерживающий проверку тождественности ===, может выступать как код типа. Чаще всего используются int и enum. Вот пример подобного кода типа для размеров футболок.

Листинг 4.15. Начальный

const SMALL = 33;
const MEDIUM = 37;
const LARGE = 42;


В случае с int отслеживать использование кода типа сложнее, потому что при создании кода разработчик мог использовать число без ссылки на центральную константу. Поэтому всегда, встретив код типа, следует преобразовать его в перечисления. Только так получится применить этот шаблон рефакторинга безопасно.

Листинг 4.16. До

const SMALL = 33;
const MEDIUM = 37;
const LARGE = 42;
Листинг 4.17. После

enum TShirtSizes {
SMALL = 33,
MEDIUM = 37,
LARGE = 42
}

Процесс

1. Вводим новый интерфейс с временным именем. Этот интерфейс должен содержать методы для каждого из значений перечисления.
2. Создаем классы, соответствующие каждому значению перечисления. Все методы из этого интерфейса, кроме одного, соответствующего классу, должны делать return false.
3. Переименовываем перечисление. В результате компилятор сообщает об ошибке везде, где оно используется.
4. Изменяем старое имя типа на временное и заменяем проверки тождественности новыми методами.
5. Заменяем оставшиеся ссылки значениями перечислений инстанцированием новых классов.
6. Когда ошибок больше нет, везде переименовываем интерфейс, заменяя его имя постоянным.

Пример

Рассмотрим небольшой пример с перечислением сигналов светофора и функцией для определения момента, когда можно начинать движение.

Листинг 4.18. Начальный

enum TrafficLight {
     RED, YELLOW, GREEN
}
const CYCLE = [TrafficLight.RED, TrafficLight.GREEN, TrafficLight.YELLOW];
function updateCarForLight(current: TrafficLight) {
     if (current === TrafficLight.RED)
          car.stop();
     else
          car.drive();
}


Следуя описанному процессу, мы делаем так.
1. Вводим новый интерфейс с временным именем. Этот интерфейс должен содержать методы для каждого значения перечисления.

Листинг 4.19. Новый интерфейс

interface TrafficLight2 {
     isRed(): boolean;
     isYellow(): boolean;
     isGreen(): boolean;
}


2. Создаем классы, соответствующие каждому значению перечисления. Все методы интерфейса, кроме одного, соответствующего классу, должны осуществлять return false.

Листинг 4.20. Новые классы

class Red implements TrafficLight2 {
     isRed() { return true; }
     isYellow() { return false; }
     isGreen() { return false; }
}
class Yellow implements TrafficLight2 {
     isRed() { return false; }
     isYellow() { return true; }
     isGreen() { return false; }
}
class Green implements TrafficLight2 {
     isRed() { return false; }
     isYellow() { return false; }
     isGreen() { return true; }
}


3. Переименовываем перечисление. В результате компилятор сообщает об ошибках во всех местах использования этого перечисления.

Листинг 4.21. До

enum TrafficLight {
     RED, YELLOW, GREEN
}
Листинг 4.22. После (1/4)

enum RawTrafficLight {
     RED, YELLOW, GREEN
}

4. Изменяем имя типов со старого на временное и заменяем проверки тождественности новыми методами.

Листинг 4.23. До

function updateCarForLight(
     current: TrafficLight)
{
     if (current === TrafficLight.RED)
          car.stop();
     else
          car.drive();
}
Листинг 4.24. После (2/4)

function updateCarForLight(
     current: TrafficLight2)
{
     if (current.isRed())
          car.stop();
     else
          car.drive();
}

5. Вместо оставшихся ссылок на значения перечисления используем инстанцированные новые классы.

Листинг 4.25. До

const CYCLE = [
     TrafficLight.RED,
     TrafficLight.GREEN,
     TrafficLight.YELLOW
];
Листинг 4.26. После (3/4)

const CYCLE = [
     new Red(),
     new Green(),
     new Yellow()
];

6. В завершение, когда ошибок уже нет, везде даем интерфейсу постоянное имя.

Листинг 4.27. До

interface TrafficLight2 {
     // ...
}
Листинг 4.28. После (4/4)

interface TrafficLight {
     // ...
}

Этот шаблон рефакторинга сам по себе не вносит кардинальных улучшений в код, но создает основу для существенных улучшений в дальнейшем. Наличие методов is для всех значений тоже считается запахом, так что пока мы заменили один запах другим. Но эти методы можно затем обработать по одному, тогда как значения перечисления были тесно связаны между собой и обрабатывать их по отдельности было невозможно. Важно отметить, что большинство методов is являются временными и существуют недолго — в примере мы избавимся от некоторых из них в текущей главе и от многих других в главе 5.

Об авторе
image


Подробнее с книгой можно ознакомиться в нашем каталоге.


Комментарии: 0

Пока нет комментариев


Оставить комментарий






CAPTCHAОбновить изображение

Наберите текст, изображённый на картинке

Все поля обязательны к заполнению.

Перед публикацией комментарии проходят модерацию.