А у нас тут книга по рекомендации самого Дяди Боба.
В каждой кодовой базе есть ошибки и слабые места, которые нужно найти и исправить. Правильный рефакторинг сделает ваш код элегантным, удобным для чтения и простым в обслуживании.
Познакомьтесь с уникальным подходом, позволяющим реализовать любой метод в пяти строках кода. И не забывайте про тайну, хорошо известную большинству senior-разработчиков: иногда проще ухудшить код и вернуться к его исправлению позже.
«Пять строк кода» — это свежий взгляд на рефакторинг для разработчиков любого уровня. Вы узнаете, когда проводить рефакторинг, как использовать паттерны, а также научитесь определять признаки, которые говорят о том, что код необходимо удалить
Для разработчиков всех уровней. В примерах используется доступный и понятный синтаксис TypeScript, который позволяет перейти к любому языку высокого уровня.
Пусть код типа работает
В конце предыдущей главы мы просто ввели функцию 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. До
|
Листинг 4.4. После
|
Запах
Это правило относится к раннему связыванию, которое является запахом. Когда мы компилируем программу, то поведение, подобное решениям 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. Заметьте: эти методы временные, в чем мы позже убедимся.
3. Переименуем перечисление в RawInput, после этого компилятор будет выдавать ошибку во всех местах, где используется это перечисление.
Листинг 4.7. До
|
Листинг 4.8. После (1/3)
|
5. Исправим последние ошибки внесением изменений.
Листинг 4.11. До
|
Листинг 4.12. После (3/3)
|
Листинг 4.13. До
|
Листинг 4.14. После
|
В шаблоне «Замена кода типа классами» включаем процесс создания перечислений в классы.
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. До
|
Листинг 4.17. После
|
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. До
|
Листинг 4.22. После (1/4)
|
Листинг 4.23. До
|
Листинг 4.24. После (2/4)
|
Листинг 4.25. До
|
Листинг 4.26. После (3/4)
|
Листинг 4.27. До
|
Листинг 4.28. После (4/4)
|
Более подробно с книгой можно ознакомиться на сайте издательства:
» Оглавление
» Отрывок
По факту оплаты бумажной версии книги на e-mail высылается электронная книга.
Для Хаброжителей скидка 25% по купону — Мартин
Комментарии (21)
blood_develop
10.11.2022 21:40+2Я могу быть неправ, но мое мнение об этом всем такое:
Да, логику фильтрации/валидации нужно отделять от процессов обработки
Да, енумы лучше констант инт/стринг, потому что не позволит просто взять и присвоить недопустимое значение (для языков с типизацией: можно, но придется извратиться, для языков без типизации - не знаю, не уверен)
Как всегда, красивая теория над простыми примерами. В жизни все немного (много) сложнее. Код с отложенной реализацей сложнее отлаживать, хоть и проще читать, если не нужно вникать в саму реализацию. Но это, опять же, на простых примерах. На энтерпрайзе у меня пока не было такой практики, при которой условия со свичами были бы переписаны на объектную модель и при этом это и легко читается, и легко дополняется, и легко отлаживается. Это как с тем треугольником консистентности прям.
Racheengel
10.11.2022 23:36+11Как мне кажется, в итоге только хуже стало...
Вот ради чего свитч менять на классы? Чтобы что?
progchip666
11.11.2022 00:03+4А потом мы удивляемся почему простенький код стал поглощать такое огромное количество памяти...
По мне свичи действительно много удобнее, чем нагромождение if else, но менять их на классы тоже кажется перебором...
Busla
12.11.2022 12:30с else if снаружи понятно, что исполняется только одна ветка, со switch нужно смотреть тело: есть ли там break
aegoroff
13.11.2022 10:02Ответ как всегда - зависит. В случае когда свитч используется для преобразования одного значения в другое, и в этом случае в каждом варианте только одна строчка - это действительно перебор.
Однако, очень часто, свитч сигнализирует о проблемах с неправильно спроектированным наследованием (нарушен принцип подстановки Лисков) - это когда вы в метод (функцию) передаете объект базового класса, а внутри, с помощью свитча пытаетесь привести тип к производному, и что-то с ним сделать. В этом случае - это сигнал к переписыванию всей иерархии классов.
progchip666
13.11.2022 12:08Всё так, вот только большинство программ я пишу для микроконтроллеров с малым размером памяти программ. Приходится писать на "классическом" С. А там классов нет как таковых...
aegoroff
13.11.2022 13:00Ну микроконтроллеры это отдельная планета, и к ним БОЛЬШИНСТВО рекомендаций по архитектуре неприменимы, точнее, там свои архитектурные принципы, не укладывающиеся в парадигмы ООП.
Часто там нельзя использовать даже динамическое выделение памяти (ибо это недетерминированная вещь), поскольку есть требования жесткого реального времени. А именно на динамическом выделении памяти, основаны все эти модные, молодежные концепции :)
dedmagic
11.11.2022 07:34+2Из этой статьи, конечно, непонятно -- потому что это только первый этап преобразования, дальнейшие улучшения здесь отсутствуют:
Важно отметить, что большинство методов is являются временными и существуют недолго — в примере мы избавимся от некоторых из них в текущей главе и от многих других в главе 5.
Но вообще есть такой рефакторинг -- "замена условия полиморфизмом", смысл его в следующем.
Вот у вас естьenum
, и вы обрабатываете его с помощьюswitch
-- при этом при добавлении нового элемента перечисления вам нужно найти все свитчи и внести в них изменения. Это явное нарушение OCP.
Если же у вас вместо енума набор классов и для принятия решений вместо свитча используется полиморфизм, то при добавлении нового элемента вы просто пишите новый класс, клиентский код при этом не затрагивается. OCP радуется :).progchip666
11.11.2022 08:48Разумом я понимаю что вы правы в данном вопросе.
Кроме этого понятно, что код в статье демонстрационный и параметры, по которым производятся изначальный if - else на самом деле могут быть весьма сложно вычисляемые или получаемые извне. В таком случае применение отдельных классов уже не выглядит таким нерациональным действием.
У меня просто своя специфика - большая часть программирования для меня так называемые "встраиваемые системы". Причём их "младшая" часть - микроконтроллеры. Несмотря на то, что уже лет 10 исключительно на ARM делаю устройства, часто приходится оптимизировать изделие по стоимости комплектации, а производители любят ограничивать размер как RAM так и FLASH памяти в наиболее дешёвых чипах в линейке.
По этой причине роскошью иногда выглядит даже переход с C на С++. До последнего времени компиляторы плюсов генерили существенно более "тяжёлый" код...
dedmagic
11.11.2022 09:42+3Тогда эти рекомендации не для Вас :)
Качественный код (читабельный, сопровождаемый и т.д.) и производительный код -- понятия чаще всего взаимоисключающие. Если Вы пишите код для встраиваемых систем и экономите байты -- о каком полиморфизме может идти речь?Racheengel
11.11.2022 13:43Производительный код тоже может быть качественным, если его писать "по уму" и снабжать комментариями по мере необходимости.
А наворачиванием шаблонов качество можно только ухудшить. И с точки зрения производительности, и с точки зрения читабельности. Имхо, банальный свитч читается гораздо проще, чем полиморфный обработчик того же самого.
Обычно выручает метод SPIFE - Stable, Performant, Intuitive, Functional, Extendable - и именно в таком порядке.
dedmagic
11.11.2022 14:10+1Производительный код тоже может быть качественным, если его писать "по уму" и снабжать комментариями по мере необходимости.
Вот как раз качественный код комментариев не требует, их необходимость указывает на то, что код сам по себе нечитабельный, приходится подпорки ставить в виде комментариев.
Производительный код может быть качественным, но это большая редкость. Чаще всего для того, чтобы сделать код производительным, приходится сознательно ухудшать такие его характеристики, как читабельность, расширяемость, сопровождаемость и т.д., в т.ч. избегать использования шаблонов и идти на сознательное нарушение принципов (SOLID, GRASP, KISS и т.д.).
А наворачиванием шаблонов качество можно только ухудшить.
Конечно, можно. В кривых руках даже при отличном инструменте результат будет кривой.
И точно так же качество можно ухудшить, всячески избегая использования шаблонов.
Как применение, так и неприменение чего бы то ни было требует осознанности :).Racheengel
12.11.2022 14:12Как когда-то давно говорил один мой преподаватель, "пишите код для компьютера, а комментарии для людей. И не перепутайте!"
Конечно, производительный код может быть write only, особенно если речь об интринсиках. Там не то что комментарии, а картинку надо прикладывать.
Или обратный пример из жизни - пришёл на новый проект на с++ бывший ява-программист. И решил известный ему ява-фреймворк на с++ повторить, потому что по другому он работать не умел, видимо. А когда он уволился, оказалось, что то, что он наваял, не то что поддерживать, даже отлаживать было почти не возможно - паттерн на паттерне, 100500 классов на каждую мелочь, методы по 2-3 строчки... В общем когда это чудо выпилили, проект стал чудесным образом раза в 3 быстрее работать)
JSmitty
11.11.2022 22:32+2GC при новом классе огорчается. Производительность огорчается. Особенно если через этот свитч проезжаем тысячи/десятки тысяч раз в секунду.
На этом же примере (игровом) видно, какое тут качество проектирования. Вся нарисованная гибкость очевидно идет в мусорку при расширении условий до, скажем, 3d перемещений. 2д случай с диагональными перемещениями тоже что так, что этак потребует переписывание "клиентского" кода. Контрольный выстрел - добавить хэндлинг "fire" в игру.
И да, мне кажется, первые же 2-3 года опыта любого программиста скажут, что намеченный путь развития и расширения "не внося изменений в написанное ранее" - инфантилизм нездоровый. Бизнес всегда найдет, чем удивить.
ЗЫ в конкретном примере fsm очевидно самая подходящая абстракция.
Zoolander
11.11.2022 10:34+2я думал, будут рецепты от гуру, как сделать код еще короче
вот этот многослойный иф легко превращается в однострочник, если описать входные данные и толкаемые в input в декларативной форме объекта.const keyMap = { "a" : Input.LEFT, "w" : Input.UP, "d" : Input.RIGHT, "s" : Input.DOWN, } window.addEventListener("keydown", e => { const input = keyMap[e.key]; if(input) inputs.push(input); });
ixamilion
11.11.2022 11:11А если, например, добавим на Shift присесть, будет работать?
playermet
11.11.2022 11:38+1Для того чтобы обеспечить управление современных игр, с их двойными нажатиями, удержаниями, комбинациями клавиш, контекстно-зависимыми действиями, взаимодействием с UI и инвентарем, мультиплатформенностью, кастомизацией, и прочим, в любом случае понадобится гораздо более многословное решение. Например стейт-машина.
Zoolander
12.11.2022 09:44допишите в объект
"Shift":Input.Shift,
window.addEventListener("keydown") в JavaScript обрабатывает и Shift
а вместо значений Input в поля объекта можно сразу писать функции,
сократив код сразу до целевой логики
SadOcean
12.11.2022 16:49Мне кажется, что многие рекомендации спорные.
В частности замена свича на объект оправдана не всегда, а если в коде есть несколько логически связанных действий с этим свичом.
Многие такие преобразования на самом деле имеют свою цену в терминах сложности и поэтому замена не всегда оправданна.
Source
Чего только люди не придумают лишь бы удобные языки с паттерн-матчингом и гардами прямо в сигнатуре функций не использовать)