Уменьшаем сложность кода с помощью гвардов

Использование гвардов при рефакторинге кода

Гвард (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.