Архитектура для начинающих или почему не нужно вставлять флажок в человек-меч
Аннотация:
- Пример реализации нового функционала в классе через добавление «флажка».
- Последствия.
- Альтернативный подход и сравнение результатов.
- Как избежать ситуации: «Архитектурный оверкилл»?
- Момент, когда приходит время всё менять.
Перед тем как начать, пара замечаний:
Допустим, я — рядовой программист-программист на проекте. Проект представляет собой игру, в которой единственный Герой (aka Hero) идёт по идеально горизонтальной прямой слева направо. Этому замечательному путешествию мешают монстры. По команде пользователя Герой лихо рубит их мечом в капусту и в ус не дует. В проекте уже 100К строк, и «нужно больше строк фич!» Посмотрим же на нашего Героя:
class Hero {
func strike() {
// некий код 1
}
// ещё больше кода
}
Предположим также, что мой личный тимлид Василий тоже может в отпуск. Внезапно. Кто бы мог подумать? Далее ситуация развивается стандартно: прилетает Сова и требует срочно, вот прям вчера, сделать возможность выбирать перед началом игры: играть за Героя с дубиной или с мечом.
Надо очень быстро. И да, естественно, он сам был программистом больше лет, чем я умею ходить, поэтому он знает, что задача — на пять минут. Но так и быть, оценим на 2 часа. Надо только добавить флажок и if-чик.
Замечание: if-чик в значении @!^%$#@^%&@!11$ его #$%@^%&!@!!! в &!^%#$^%!1 if-чик!
Мои мысли: «Ха! Два часа! Готово!»:
class Hero {
enum WeaponTypes {
case sword:
case club:
}
var weaponType: WeaponTypes?
func strike() {
guard let weaponType = weaponType else {
assertionFailure()
return
}
// Я крут: switch в Swift лучше if-ов - предупреждает о необработанных кейсах!
switch (weaponType) {
case .sword: // некий код обработки удара мечом
case .club: // некий код обработки удара дубиной
}
}
// больше кода
}
Если Вы узнали в моём решении своё, то, увы: у меня для Вас две новости:
- Как бы хорошая: мы оба доставляем. Доставляем — от слова deliver value или от слова смешной (сквозь слёзы) код.
- И плохая: без Василия проекту капец.
Итак, что же случилось? Казалось бы, пока ничего. Но давайте посмотрим, что случится дальше (пока мы всеми силами удерживаем Василия в отпуске). А дальше будет вот что: отдел QA обратит внимание на вес нашего Героя. И нет, это не потому, что Герою пора присесть на диету, а вот почему:
var weight: Stone {
return meatbagWeight + pantsWeight + helmetWeight + swordWeight
}
Забыл исправить расчёт веса. Ну подумаешь, ошибочка, с кем не бывает?! Тяп-ляп, трах-тибидох, готово:
var weight: Stone {
// пропустим код guard let weaponType
let weightWithoutWeapon = meatbagWeight + pantsWeight + helmetWeight
switch (weaponType) {
case .sword: return weightWithoutWeapon + swordWeight
case .club: return weightWithoutWeapon + clubWeight
}
}
Но быстро же исправил! Герой-то теперь прыгает с учетом веса зато. Работа ориентированная на результат! Это вам не пупочки разглядывать, астронавты архитектурные.
Ну правда, потом ещё немного поправил. В списке заклинаний, пришлось сделать так:
var spells: [Spells] {
// пропустим код guard let weaponType и
// код, чтобы подготовить let spellsWithoutWeapon: [Spells]
switch (weaponType) {
case .sword:
// подготовка let swordSpells: [Spells]
return spellsWithoutWeapon + swordSpells
case .club:
// подготовка let clubSpells: [Spells]
return spellsWithoutWeapon + clubSpells
}
}
А потом пришёл Петя и всё сломал. Ну правда, у нас есть такой джун на проекте. Невнимательный.
Ему всего-то надо было добавить понятие «Уровень оружия» в формулы расчёта силы удара и веса Героя. А Петя пропустил один из четырех случаев. Но ничего! Я всё поправил:
func strike() {
//guard let weaponType
switch (weaponType) {
case .sword: // некий код обработки удара мечом с учетом weaponGrade
case .club: // некий код обработки удара дубиной с учетом weaponGrade
}
}
var weight: Stone {
// guard let weaponType
let weightWithoutWeapon = meatbagWeight + pantsWeight + helmetWeight
switch (weaponType) {
case .sword: return weightWithoutWeapon + swordWeight / grade
case .club: return weightWithoutWeapon + pow(clubWeight, 1 / grade)
}
}
var spells: [Spells] {
// А тут ничего не поменялось, ура!
// Правда, разросшийся метод увеличил объем класса. Искать внутри Героя код, имеющий отношение к задаче, стало чуть сложнее. Но это ничего!
}
Стоит ли говорить, что когда (внезапно!) понадобилось добавить лук / обновить формулы, опять были забытые кейсы, баги и вот это вот всё.
Что же пошло не так? Где я был неправ, и что (кроме матов) сказал Василий, когда вернулся из отпуска?
Тут можно не читать историю дальше, а, например, подумать о вечном, об архитектуре.
А с теми, кто всё же решил читать, продолжим.
Итак, обратимся к классикам:
Преждевременная оптимизация — корень всех зол!
А… э… это не то. Вот то:
В ООП-языках (например в Swift) есть три основных способа расширить возможности класса:
- Первый способ — «наивный». Мы видели его только что. Добавление флажка. Добавление ответственности. Разбухание класса.
- Второй способ — наследование. Всем известный мощный механизм переиспользования кода. Можно было бы, например:
- Отнаследовать новых ГерояСЛуком и ГерояСДубиной от Героя (который с мечом, но это сейчас не отражено в названии класса Hero). И затем в наследниках переопределить изменившиеся методы. Этот путь очень плох (просто поверьте мне).
- Сделать базовый класс (или протокол) Герой, а все особенности связанные с конкретным типом оружия убрать в наследников:
ГеройСМечом: Герой,
ГеройСЛуком: Герой,
ГеройСДубиной: Герой.
Это лучше, но ведь сами имена этих классов смотрят на нас как-то недовольно, свирепо и в то же время грустно и с недоумением. Если на кого-то они так не смотрят, то постараюсь написать ещё статью, где помимо скучного маскулинного Героя будут они…
- Третий способ — сепарирование ответственности через инъекцию зависимости. Это может быть зависимость закрытая протоколом или замыкание (как бы закрытое сигнатурой), что угодно. Главное, чтобы реализации новых ответственностей ушли из основного класса.
Как это может выглядеть в нашем случае? Например, так (решение от Василия):
class Hero {
let weapon: Weapon // зависимость класса Герой, т.е. Герой зависит от оружия
init (_ weapon: Weapon) { // точка инъекции или внедрения зависимости
self.weapon = weapon
}
func strike() {
weapon.strike()
}
var weight: Stone {
return meatbagWeight + pantsWeight + helmetWeight + weapon.weight
}
var spells: [Spells] {
// подготовка как раньше
return spellsWithoutWeapon + weapon.spells
}
}
Что нужно, чтобы так было? Ниндзютсу — protocol:
protocol Weapon {
func strike()
var weight: Stone {get}
var spells: [Spells] {get}
}
Пример реализации протокола:
class Sword: Weapon {
func strike() {
// то, что раньше валялось в Hero в switch внутри кейса .sword
}
var weight: Stone {
// то, что раньше валялось в Hero в switch внутри кейса .sword
}
var spells: [Spells] {
// то, что раньше валялось в Hero в switch внутри кейса .sword
}
}
Аналогично Sword-у пишутся классы для: Club, Bow, Pike, etc. «Легко видеть» ©, что в новой архитектуре весь код, который относится к каждому конкретному типу оружия, сгруппирован в соответствующем классе, а не размазан по Герою вместе с остальными типами оружия. Это облегчает чтение и понимания Героя и любого конкретного оружия. Плюс, благодаря требованиям накладываемым протоколом гораздо проще отследить все методы, которые нужно реализовать при добавлении нового типа оружия или при добавлении новой фичи к оружию (например, у оружия может появиться метод расчёта цены).
Тут можно заметить, что инъекция зависимости усложнила создание объектов класса Hero. То, что раньше делалось как просто:
let lastHero = Hero()
теперь превратилось в набор инструкций, который будет дублироваться везде, где необходимо создать Героя. Впрочем, Василий позаботился и об этом:
class HeroFactory {
static func makeSwordsman() -> Hero { // хотя, насчет static - это неточно
let weapon = Sword(/* аргументы */)
return Hero(weapon)
}
static func makeClubman() -> Hero {
let weapon = Club(/* аргументы */)
return Hero(weapon)
}
}
Понятно, что Василию пришлось попотеть, чтобы раскидать кучу, которую навалили спроектировали Петя (и я).
Конечно, глядя на последнее решение, может возникнуть следующая мысль:
Ок, получилось, норм. Удобно читать и расширять, но ведь все эти фабрики / протоколы / зависимости — это куча оверхеда? Код, который ничего не даёт с точки зрения фич, а существует только для организации кода. «Код для организации кода», мгм. Неужели нужно городить этот огород всегда и везде?
Честный ответ на первый вопрос будет таким:
Да, это оверхэд к фичам, которые так любит бизнес.
А на вопрос «когда?» отвечает раздел:
Философия человека-меча или «когда же надо было править?»
Вначале был человек-меч. В системе смыслов старого кода это было вполне нормально. Пока был один Герой и одно оружие, не было необходимости и различать их — всё равно ничего другого для героя не было. И монолитный код своим текстом утверждал этот факт.
Человек-меч — это даже звучит не так плохо.
А к чему привели первые «наивные» правки? К чему привело добавление if-чика?
Добавление if-чика привело к возникновению… мутанта! Мутанта, т.е. мутабельного объекта, который может мутировать между «человекомеч» и «человекодубина». При этом, если немного ошибиться в реализации мутанта, то возникает состояние «человекомечедубина». Не надо так! Не надо «человекодубины» вообще.
Надо:
- Человек + зависимость от меча (потребность в мече);
- Человек + зависимость от дубины (потребность в дубине).
Не всякая зависимость — зло! Это зависимость от алкоголя — зло, а от протокола — добро. Да даже зависимость от объекта лучше, чем «человекодубина»!
Когда произошло превращение в мутанта? Превращение в мутанта произошло в момент добавления флажка: монолитный код так и остался монолитным, но при изменении (мутации) флажка поведение одного и того же объекта стало существенно меняться.
Василий выделил бы здесь две стадии мутации:
- Добавление флага и самого первого «if» (или «switch», или другого механизма ветвления) по флагу. Ситуация угрожающая, но терпимая: Героя поливают радиоактивными отходами, но он превозмогает.
- Появление в классе более одного «if» по данному флагу, особенно в разных методах класса. Всё. Героя уже нет — перед нами мутант. Мутант, у которого постоянно что-то отваливается из-за багов.
Таким образом, в рассмотренной ситуации для того, чтобы не допускать возникновения технического долга, имеет смысл выстраивать архитектуру до прохождения первой стадии, в самом худшем случае — до второй.
Что именно сделал Василий, чтобы полечить мутанта?
С технической точки зрения — применил инъекцию зависимости закрытой протоколом.
С философской — разделил ответственность.
Все особенности работы класса, которые могут взаимозаменяться друг с другом (а значит — альтернативны между собой), были вынесены из Героя в зависимости. Новый, альтернативный функционал — реализация работы меча, реализация работы дубины — по факту своего появления стал различен между собой и отличен от остального по прежнему безальтернативного кода Героя. Так уже в «наивном» коде появилось нечто новое, отличное по своему альтернативному поведению от безальтернативной части Героя. Так в «наивном» коде возникло неявное, размазанное по Герою описание новых бизнес-сущностей: меча и дубины. Для того, чтобы было удобно оперировать новыми бизнес-сущностями, стало необходимо выделить их как отдельные сущности кода, обладающие собственными именами. Так произошло разделение ответственности.
P.S. TL; DR;
- Видишь флажок?
- Будь мужиком, блин! Сотри его!
- Инъекция зависимости
- …
- Profit!11