Гвард (guard)— это фрагмент кода в верхней части функции или метода, который делает return, если выполняется некоторое предварительное условие. Я считаю, что правильное их использование может уменьшить как сложность, так и когнитивную нагрузку кода.
Сначала мы рассмотрим несколько версий метода на примере гипотетической биллинговой системы. Предположим, что это код существующей системы. Рассмотрим возможность его рефакторинга для упрощения и облегчения понимания. Первый пример будет достаточно тривиальным, чтобы его было легко понять, но мы будем опираться на него в следующих примерах.
1. Код с традиционным If/Else
function calculateSalePrice(Item $item, $discount = 0) : int { $price = 0; if ($discount == 0) { $price = $item->getPrice(); } else { $price = calculateDiscountedPrice($item->getPrice(), $discount); } return $price; }
Функция calculateSalePrice
вернет цену продажи товара. В этом методе реализация следует правилу «только один return». Первая строка предлагает дефолтное значение, и будет просто ужасно, если функция его вернет. У нас есть некоторая вложенность, которая займет у нас некоторое время, чтобы мысленно разобраться в ней и полностью понять.
Если считать непустые строки, то этот код занимает около 50% шаблона (boilerplate) и синтаксиса. Это означает, что мы тратим много времени на ввод и чтение бесполезного кода. PHP и JavaScript имеют системы управления памятью, которые не требуют единого выхода, так что давайте рассмотрим альтернативные способы написания этого метода.
2. Код с If/Else и досрочным Return
function calculateSalePrice($item, $discount = 0) { if ($discount == 0) { return $item->getPrice(); } else { return calculateDiscountedPrice($item->getPrice, $discount); } }
Безусловно, мы сократили часть шаблона, и потихоньку идём к еще большей миниатюризации. Хорошо, но практически весь наш код вложен на один уровень глубже.
Мы уменьшили некоторую когнитивную нагрузку, удалив дефолтный $price = 0;
. Поскольку возвращать нулевую цену было бы неправильно, разработчик должен потратить немного времени и убедиться, что она таковой не будет. Если несколько разработчиков смотрели на это много раз в течение жизни проекта, затрачиваемое ими время складывается.
3. Код без Else
function calculateSalePrice($item, $discount = 0) { if ($discount == 0) { return $item->getPrice(); } return calculateDiscountedPrice($item->getPrice, $discount); }
Логика идентична первым двум примерам. Однако теперь есть явный оператор возврата, и мы сократили функцию еще на две строки кода.
Мы значительно сократили шаблон первого примера и, в итоге, получили функцию, основная цель которой вложена в if
. При рефакторинге с гвардами мы хотим пропустить их и сразу увидеть главную цель метода. В этом случае основной целью функции является запуск getPrice()
. Мы проведем дальнейший рефакторинг, чтобы убедиться, что четко осознаем свои намерения.
4. Код без Else с инвертированным контролем
function calculateSalePrice($item, $discount = 0) { if ($discount > 0) { return calculateDiscountedPrice($item->getPrice, $discount); } return $item->getPrice(); }
Логика этого противоположна примеру 3. Теперь мы используем правильный гвард. Мы переместили главную цель функции в основную часть без вложенности и обработали менее распространенный случай гвардом.
Переместив нашу главную цель в основную часть функции, мы значительно упростили метод. Мы уменьшили цикломатическую сложность и дали ясно понять, при каких условиях мы не выполняем задачи нашей главной цели.
Кроме того, здесь мне действительно нравится правило Symfony: помещать пустую строку над оператором return. Это ясно показывает, какова основная цель этого метода.
На этом этапе рефакторинга вы можете заметить, что этот код всегда был тернарным. Вы можете прийти к выводу, что нужно просто переписать это как тернар и пойти дальше. Однако, если на время обслуживания вам придется будет добавить второй гвард, то вы будете иметь diff в 100% для метода, когда вы ввели это изменение. Мы бы сохранили несколько байтов кода сейчас для более сложной проверки позже.
Примеры более практичного кода
Следующий пример более правдоподобен и имеет более высокую начальную сложность, чем предыдущий. Приложив немного воображения, вы можете увидеть, как он мог перерасти в свое нынешнее состояние.
В качестве упражнения, пожалуйста, прочитайте код ниже. Проще говоря, при каких условиях сообщение может быть отредактировано?
class Post { public function canEdit(User $user) : bool { $canEdit = false; if (($user->getRole() == 3 && $this->getEditorId() == $user->getId()) || $user->getRole() > 5 || ($this->getAuthorId() == $user->getId() && $this->created_at < time() — 2592000)) { $canEdit = true; } return $canEdit; } }
Сообщение является редактируемым, если:
- Пользователь является редактором этого поста или администратором
- Пользователь является автором, и сообщение было написано в последние тридцать дней.
В следующий раз, когда придется снова работать с этим кодом, то придется много думать. В этой небольшой загадке есть проверка роли редактора со значением 3, администратора со значением 5 и временных меток. Возможно, нам даже понадобится нарисовать таблицу истинности на обратной стороне салфетки, чтобы полностью понять все условия.
Представьте, что вам дается новое требование: «Роль 4 может редактировать сообщения на боковой панели (sidebar)». Единственный if
имеет силу притяжения; он нас затягивает. Скорей всего, увидев бардак, мы сдадимся и вставим еще || ($user->getRole() == 4 && $this->getCreatedOn < time() — 518400), || ($user->getRole() == 4 && $this->getCreatedOn < time() — 518400)
, а затем перейдем к следующему проекту. Подобные сложный условия имеет тенденцию расти со временем.
Давайте сделаем рефакторинг, дабы облегчить будущее обслуживание кода. Мы также можем подумаем, как мы могли бы написать это с самого начала таким образом, чтобы оно могло расти со временем более управляемым образом.
Исходный отрефакторенный код
class Post { public function canEdit(User $user) : bool { $isEditor = $user->getRole() == 3 && $this->getEditorId() == $user->getId(); $isAdmin = $user->getRole() > 5; $isAuthor = $this->getAuthorId() == $user->getId(); $isTooOld = $this->created_at < time()—2592000; if ($isEditor || $isAdmin) { return true; } if ($isAuthor && !$isTooOld) { return true; } return false; } }
Сканируем этот код снизу вверх и сразу видим, что по умолчанию возвращается значение false. Мы можем подумать: «Хорошо, мы в безопасности. Сообщения, как правило, не редактируются». Теперь мы можем свободно просмотреть весь метод, чтобы увидеть условия, при которых сообщение можно редактировать.
Вероятно вы заметили, что строки 12 и 16 могут быть объединены в один оператор OR. Если бы мы решили сделать это, то увеличили бы сложность и уменьшили читабельность. Не думаю, что сохранили бы что-нибудь, кроме количества строк.
Давайте вернемся к новому требованию «Роль 4 может редактировать сообщения на боковой панели (sidebar)». С отрефакторенный кодом это должно быть намного проще. Мы добавим гвард для нового условия и посмотрим на разницу.
Начальный рефакторинг с новым условием
class Post { public function canEdit(User $user) : bool { $isEditor = $user->getRole() == 3 && $this->getEditorId() == $user->getId(); $isAdmin = $user->getRole() > 5; $isAuthor = $this->getAuthorId() == $user->getId(); $isSupervisor = $this->getRole() == 4; $isSidebarPost = $this->getType() == 8; $isTooOld = $this->created_at < time()—2592000; if ($isSupervisor && $isSidebarPost) { return true; } if ($isEditor || $isAdmin) { return true; } if ($isAuthor && !$isTooOld) { return true; } return false; } }
// Git Diff @@ -7,8 +7,14 @@ class Post $isAuthor = $this->getAuthorId() == $user->getId(); + $isSupervisor = $this->getRole() == 4; + $isSidebarPost = $this->getType() == 8; $isTooOld = $this->created_at < time()—2592000; + if ($isSupervisor && $isSidebarPost) { + return true; + } + if ($isEditor || $isAdmin) {
Очень простой diff для ревью. Он не добавит сложности любому существующему коду и полностью автономен. Мы могли бы сделать ревью подобного diff в пул-реквесте и быть уверенны, что другие условия не будут затроны. Кроме того, используя промежуточные переменные, мы задокументировали, что такое речь идет о сообщениях на боковой панели, без всяких комментариев.
Рефакторинг роста путем удаления магических чисел
Представьте, что нам нужно изменить тип, который идентифицирует сообщения на боковой панели. Сообщения боковой панели в настоящее время идентифицируются как 8. Вероятно, это число используется по всему коду в разных местах. Если нам нужно изменить это значение на 11, то наш единственный вариант — сделать поиск по всему коду и уповать на то, что найдем все места его использования. И мы можем только надеяться, что это где-то это не задано как $type = $primaryType + 1;
Мы могли бы избавиться от подобного, просто создав константу в классе Post для представления типа боковой панели. Можно добавить новую const SIDEBAR_TYPE = 8;
а затем использовать её в нашем коде. Затем мы можем преобразовать наши сравнения так: $isSidebarPost = $this->getType() == self::SIDEBAR_TYPE;
Удаление магических чисел — всегда положительное изменение. Если новые требования диктуют изменить тип боковой панели, мы можем сделать это в одном месте. Diff снова будет очень маленьким. Удалив магические числа, мы добавили перспективу и самодокументирование. Тем не менее, мы все еще можем сделать еще лучше.
Давайте представим еще один сценарий роста, в котором мы собираемся добавить второй тип боковой панели. Теперь нам нужно, чтобы 8 и 11 были сообщениями на боковой панели. Простая идея с константой рушится. Наша задача — найти все места в коде, где используется self::SIDEBAR_TYPE
и обновить их, добавив сравнения с двумя разными константами. Вместо того, чтобы менять эту логику по всему коду, мы проведем рефакторинг и упростим.
Добавим новый метод, который будем использовать вместо этих сравнений. Он будет выглядеть как $isSidebarPost = $this->isSidebarPost();
Новый метод объединяет обе проверки в одну и дает место для будущего рефакторинга. Мы включим это в наш последний пример.
Полный пример
class Post { protected $sidebarTypes = [8, 11]; protected $authorEditTimeout = 2592000; ... public function canEdit(User $user) : bool { if ($user->isAdmin()) { return true; } if ($user->isSupervisor() && $this->isSidebarPost()) { return true; } if ($user->isEditor() && $this->isEditedBy($user)) { return true; } if ($this->isAuthoredBy($user) && !$this->isTooOldToEdit()) { return true; } return false; } ... public function isAuthoredBy(User $author) : bool { return $this->author->getId() === $author->getId(); } public function isEditedBy(User $editor) : bool { return $this->editor->getId() === $editor->getId(); } public function isSidebarPost() : bool { return in_array($this->type, self::$sidebarTypes); } public function isTooOldToEdit() : bool { return $this->created_at < (time()—self::$authorEditTimeout); } } class User { const ROLE_EDITOR = 3; const ROLE_SUPERVISOR = 4; const ROLE_ADMIN = 5; public function isEditor() : bool { return $this->role == self::ROLE_EDITOR; } public function isSupervisor() : bool { return $this->role == self::ROLE_SUPERVISOR; } public function isAdmin() : bool { return $this->role == self::ROLE_SUPERVISOR; } }
Безусловно, здесь еще есть о чем подискутировать. Мы создали несколько переменных и добавили несколько строк. Тем не менее, мы значительно упростили код и сделали дальнейшее его обслуживание более простым и менее подверженным ошибкам.
Резюме
В PHP и JavaScript гварды могут значительно сократить шаблонный код (boilerplate) в методе и значительно упростить код. Кроме того, они представляют собой паттерн, который будет более удобен в обслуживании в течение всего жизненного цикла проекта и приведет к уменьшению diff’ов и упрощению кода. В стремлении снизить стоимость обслуживания кода нужно помнить:
- По возможности, уменьшайте сложность кода, используя гварды
- Делайте главную цель функции наиболее очевидный путем выполнения
- Пишите, думая о росте приложения, дабы минимизировать будущие сложности
Вперед к великим делам!
Автор: Craig Davis
Перевод: Алексей Широков
Наш Телеграм-канал — следите за новостями о Laravel.