Skip to content

Latest commit

 

History

History
565 lines (426 loc) · 40.8 KB

10-conditions.md

File metadata and controls

565 lines (426 loc) · 40.8 KB

Условия и сложность кода

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

Логика сложных приложений часто запутана и содержит много условий. Условия делают код полезным: они описывают поведение программы в разных ситуациях. Но они же делают код сложнее.

В этой главе мы поговорим о том, как распутывать сложные условия, зачем «выпрямлять» код и какие паттерны могут в этом помочь.

Цикломатическая и когнитивная сложность

Чтобы понять, как именно условия делают код сложнее, сперва попробуем определить, что такое «сложность» в принципе.

Одна из эвристик в поиске сложного кода — обратить внимание на его вложенность. Чем она выше, тем код сложнее. Глубоко вложенный код даже не требует вчитываться в него, чтобы оценить его сложность. Мы можем понять что нас ждёт, едва взглянув на фрагмент:

function doSomething() {
__while (...) {
____if (...) {
______for (...) {
________if (...) {
__________break;
________}
________for (...) {
__________if (...) {
____________continue
__________}
________}
______}
____}
__}
}

Пустое пространство с левой стороны текста показывает, сколько информации содержит этот фрагмент. Чем больше пространство, тем больше деталей придётся держать в голове, тем сложнее код.

К слову 🫙
В книге “Your Code as a Crime Scene” Адам Торнхилл называет такое пространство «отрицательным».1

При чтении кода мы строим в голове модель его работы. Эта модель описывает, что происходит с данными и в каком порядке выполняются инструкции.

Каждое условие и цикл добавляют в ментальную модель новый способ «пройти» от начала до конца. Чем больше разных «путей», тем сложнее нам одновременно держать их в голове. Количество «путей», по которым можно пройти от начала функции до её конца, называется цикломатической сложностью (Cyclomatic Complexity)2 функции.

Мы можем визуализировать количество «путей», если представим функцию в виде графа.34 Каждое новое условие или цикл добавляет новую «ветку», что делает граф и функцию сложнее.

Фрагмент кода с подписанными строками и инструкциями слева; граф с рёбрами и вершинами, подписанными теми же номерами, справа

Граф функции со сложностью 3. Мы можем посчитать сложность как разницу между узлами и рёбрами графа, либо как количество регионов на нём

Чем больше «веток» и сложнее граф, тем труднее нам работать с функцией.

К слову 🧠
Кроме цикломатической сложности существует ещё когнитивная (Cognitive Complexity).5
Разница между ними в том, что они считают. Цикломатическая учитывает количество «разных путей выполнения» функции, когнитивная — количество «прерываний линейного выполнения».
Из-за этого говорят, что цикломатическая сложность показывает, насколько тяжело функцию тестировать (сколько веток надо покрыть в тестах), а когнитивная — насколько тяжело её понимать.
Мы не будем заострять внимание на их отличиях, потому что для примеров и техник из этой книги они не будут существенны. Далее по тексту мы будем использовать термин «сложность», как синоним для обеих характеристик.

Важная особенность подобных характеристик в том, что их можно измерить. Для измеряемых характеристик мы можем подобрать лимиты, а их проверку — автоматизировать. Например, мы можем настроить IDE и линтер, чтобы они подсказывали, когда сложность кода превышает выбранный лимит:

Фрагмент кода с подчёркнутой красным функцией; сверху модальное окно, в котором описана суть ошибки линтера

Линтер ругается на код с цикломатической сложностью, превышающей лимит

Конкретное число зависит от характеристики, проекта, языка и команды. Для цикломатической сложности Марк Симанн в “Code That Fits in Your Head” предлагает использовать число 7. Википедия предлагает ориентироваться на число 10.62 Я в своих проектах обычно использую число 10, потому что оно «круглое», но это не принципиально.

Плоское лучше вложенного

Отрицательное пространство и большая вложенность — следствие сложности кода. Чтобы сделать код проще, мы при рефакторинге можем придерживаться эвристики:


❗️ Сделать условия плоскими. ...А потом подумать, как ещё упростить код


Плоские условия «выпрямляют» исполнение кода. Они избавляют нас от необходимости одновременно учитывать много разных вариантов работы функции и помогают увидеть закономерности в коде, которые могут упростить его ещё сильнее. Чем площе код, тем проще увидеть общую картину и суть работы функции или модуля.

К слову 🐍
Наши цели совпадают с одним из принципов дзена Python: «Плоское лучше вложенного».7 На мой взгляд, это подтверждает, что мы на правильном пути.

Ранний возврат

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

Для примера посмотрим на функцию usersController, которая создаёт нового пользователя по переданному имени и адресу почты. Условия в этой функции заставляют держать в голове все свои ветки до самого конца, потому что полезные действия находятся в каждой из них:

function usersController(req, res) {
  if (req.body) {
    const { name, email } = req.body;
    if (name && email) {
      const user = createUser({ name, email });
      if (user) {
        res.json({ user });
      } else {
        res.status(500);
      }
    } else {
      res.status(400).json({ error: "Name and email required" });
    }
  } else {
    res.status(400).json({ error: "Invalid request payload." });
  }
}

Можно заметить, однако, что код внутри блоков else обрабатывает «крайние случаи» — разные ошибки и невалидные данные. Мы можем упростить функцию, «вывернув» условие и сперва обработав их, оставив на конец лишь работу с “happy path”:

function usersController(req, res) {
  if (!req.body) {
    return res.status(400).json({ error: "Invalid request payload." });
  }

  const { name, email } = req.body;
  if (!name || !email) {
    return res.status(400).json({ error: "Name and email required" });
  }

  const user = createUser({ name, email });
  if (!user) return res.status(500);

  res.json({ user });
}

Общее количество информации и ситуаций, которые функция обрабатывает, не изменилось. Но мы уменьшили количество веток, за которыми надо следить одновременно.

Мы по очереди проверяем крайние случаи и завершаем работу функции, если наткнулись на один из них. Если функция продолжает работу, значит этих ситуаций не случилось. Так мы будто «отсеиваем» условия по одному за раз, поэтому проверенные ветки не отнимают внимание и рабочую память.

Сверху запутанная схема выполнения кода, снизу схема попроще с одной главной линией и упорядоченными ответвлениями

Условие становится проще воспринимать, потому что нам не нужно держать в памяти много деталей

Однако 🛡
Ранний возврат может не подойти для «защитного» программирования: когда мы намеренно и подробно описываем каждую ветку условия.
В моём опыте проектов, где требовалось защитное программирование, было не очень много. По умолчанию я использую ранний возврат, а к защитному программированию перехожу в исключительных случаях.

Рендер компонентов

Ранний возврат может быть полезен для упрощения рендера React-компонентов. Например, код компонента Cover из-за взаимозависимых условий прочесть сложно:

function Cover({ error, isLoading, data }) {
  if (!error && !isLoading) {
    const image = data.image ?? DEFAULT_COVER_IMAGE;
    return <Picture src={image} />;
  } else if (isLoading) {
    return <Loader />;
  } else {
    return <Error message={error} />;
  }
}

Мы можем упростить его, «вывернув» условие и сперва обработав состояния загрузки и ошибки:

function Cover({ error, isLoading, data }) {
  if (isLoading) return <Loader />;
  if (error) return <Error message={error} />;

  const image = data.image ?? DEFAULT_COVER_IMAGE;
  return <Picture src={image} />;
}

Мы помним, что количество веток в условии после применения раннего возврата остаётся таким же. Это значит, что сложность кода может всё ещё оставаться высокой. Нам стоит посмотреть на метрики и узнать, стал ли код проще.

Если сложность функции всё ещё высокая, то стоит проверить, нет ли в ней проблем с абстракцией или разделением ответственности. Вероятно, мы заметим, что функцию можно разбить на несколько:

// Например, мы можем разделить «рендер картинки»
// и «решение, какой компонент показывать».
//
// Для этого вынесем рендер картинки в `CoverPicture`
// и будем использовать его, когда данные загрузились,
// а ошибок точно нет.

function CoverPicture(image) {
  const source = image ?? DEFAULT_COVER_IMAGE;
  return <Picture src={source} />;
}

// Решение о том, какой компонент показать,
// останется в компоненте `Cover`.
// Он будет определять, что показывать:
// - `Loader`;
// - `Error`;
// - или `CoverPicture`.

function Cover({ error, isLoading, data }) {
  if (isLoading) return <Loader />;
  if (error) return <Error message={error} />;
  return <CoverPicture image={data.image} />;
}

После подобного рефакторинга нам стоит обратить внимание на имена изменённых функций и компонентов. Старое имя может оказаться неточным после того, как мы чётче разделили ответственность между сущностями. В примере выше нам стоит подумать, насколько адекватно имена Cover и CoverPicture отражают суть этих компонентов.

К слову 🤖
В целом, для работы с UI больше подходят конечные автоматы,8 чем ранний возврат. Но если в нашем проекте их нет, а внедрить их по каким-либо причинам нельзя, то ранний возврат может сильно упростить код.

Идейно ранний возврат в рендере похож на валидацию данных перед началом бизнес-процесса. Просто здесь вместо невалидных данных мы отсеиваем «неправильные» состояния UI.

Переменные, предикаты и булева алгебра

Не каждое условие получится «вывернуть». Если ветки условия тесно переплетены, мы можем не найти, откуда начать его распутывать. Чтобы упростить такие условия, в них надо найти закономерности и паттерны.

Первым делом нам стоит вынести повторяющиеся части условий в переменные. Названия переменных помогут абстрагировать детали и сосредоточиться на смысле условия. Это поможет увидеть, можно ли упростить условие с помощью логических правил.

Законы де Моргана

Законы де Моргана — это набор правил, связывающих пары логических операций через отрицание.9 Они помогают «разворачивать» скобки в условиях:

!(A && B) === !A || !B;
!(A || B) === !A && !B;

Мы можем использовать законы де Моргана, что сделать условие менее шумным. Например, в условии ниже слишком много деталей, «за буквами» сложно увидеть его смысл:

if (user.score >= 50) {
  if (user.armor < 50 || user.resistance !== 1) {
  }
} else if (user.score < 50) {
  if (user.resistance === 1 && user.armor >= 50) {
  }
}

В качестве первого шага мы можем вынести повторяющиеся части выражений в переменные:

const hasHighScore = user.score >= 50;
const hasHeavyArmor = user.armor >= 50;
const hasResistance = user.resistance === 1;

...Тогда условие превратится в сочетания этих переменных:

if (hasHighScore) {
  if (!hasHeavyArmor || !hasResistance) {
  }
} else if (!hasHighScore) {
  if (hasResistance && hasHeavyArmor) {
  }
}

В таком условии гораздо проще заметить паттерны внутри блоков if и упростить их. В частности, мы можем применить первый закон де Моргана, чтобы упростить сочетания переменных hasHeavyArmor и hasResistance:

// Вынесем 2-е вложенное условие в переменную:
const hasAdvantage = hasHeavyArmor && hasResistance;

// Заметим, что по первому закону де Моргана
// 1-е вложенное условие превратится в `!hasAdvantage`:
//
// !A || !B === !(A && B)
//
// A -> hasHeavyArmor
// B -> hasResistance
//
// !hasHeavyArmor || !hasResistance
//    === !(hasHeavyArmor && hasResistance)
//    === !hasAdvantage

// Тогда всё условие станет выглядеть так:
if (hasHighScore && !hasAdvantage) {
} else if (!hasHighScore && hasAdvantage) {
}

Предикаты

Не все условия можно вынести в переменную. Например, это сделать сложнее, если условие само зависит от других переменных или изменяющихся данных.

Для таких случаев мы можем использовать предикаты — функции, которые принимают произвольное количество аргументов и возвращают true или false. Функция isAdult из фрагмента ниже — пример предиката:

const isAdult = (user) => user.age >= 21;

isAdult({ age: 25 }); // true
isAdult({ age: 15 }); // false

В предикатах можно хранить «схему» условия. То есть описать принцип, по которому мы будем проводить сравнение разных значений:

// Условие в примере ниже сравнивает разные переменные
// с одинаковым эталонным значением — 21:
if (user1.age >= 21) {
} else if (user2.age < 21) {
}

// Мы можем вынести «схему» этого сравнения в предикат `isAdult`
// и использовать его с разными переменными:
if (isAdult(user1)) {
} else if (!isAdult(user2)) {
}

Названия предикатов описывают смысл проверки, которую проводит условие. Это помогает чинить «протекающие» абстракции и делать код менее шумным. Например, во фрагменте ниже сложно сходу понять, что проверяет условие:

if (user.account < totalPrice(cart.products) - cart.discount) {
  throw new Error("Not enough money.");
}

Если мы вынесем сравнение в предикат hasEnoughMoney, то по названию функции нам будет проще понять его смысл:

function hasEnoughMoney(user, cart) {
  return user.account >= totalPrice(cart.products) - cart.discount;
}

if (!hasEnoughMoney(user, cart)) {
  throw new Error("Not enough money.");
}

Кроме этого предикаты, как и переменные, помогают замечать закономерности в сложных условиях и упрощать их с помощью булевой логики.

Примитивный паттерн-матчинг

Отдельно при рефакторинге нам стоит обращать внимание на множественные повторения else-if. Если такие условия занимаются выбором какого-то значения, их можно заменить более декларативными и безопасными способами.

Например, посмотрим на функцию showErrorMessage, которая сопоставляет тип ошибки валидации и сообщение для неё:

type ValidationMessage = string;
type ValidationError = "MissingEmail" | "MissingPassword" | "TooShortPassword";

function showErrorMessage(errorType: ValidationError): ValidationMessage {
  let message = "";

  if (errorType === "MissingEmail") {
    message = "Email is required.";
  } else if (errorType === "MissingPassword") {
    message = "Password is required.";
  }

  return message;
}

По задумке функция должна проверить все возможные варианты ValidationError и выбрать сообщение. В функции, однако, пропущен вариант ошибки для TooShortPassword, а компилятор TypeScript этого не заметил. Без специальных проверок (Exhaustiveness Check10) во множественных условиях легко пропустить вариант и не заметить этого.

Множественные условия можно заменить на более декларативные конструкции. Например, мы можем собрать все сообщения об ошибках в словарь, где ключами будут типы ошибок, а значениями — сообщения. Выбор ошибки по такому словарю может оказаться надёжнее, потому что компилятор будет следить за отсутствующими ключами:

function showErrorMessage(errorType: ValidationError): ValidationMessage {
  // В типе переменной `messages` явно укажем,
  // что в ней должны быть перечислены все возможные ошибки:
  const messages: Record<ValidationError, ValidationMessage> = {
    MissingEmail: "Email is required.",
    MissingPassword: "Password is required.",

    // Если какого-то ключа будет не хватать, компилятор об этом скажет:
    // “Property 'TooShortPassword' is missing in type...”
  };

  return messages[errorType];
}

Идейно это похоже на паттерн-матчинг.11 Мы сопоставляем errorType с ключами объекта messages и выбираем по нему подходящее значение.

Такой выбор чем-то похож на работу switch, только проверка типов в этом случае не требует дополнительных действий (например, Exhaustiveness Check). В TypeScript это, пожалуй, самый дешёвый способ имитировать типобезопасный «паттерн-матчинг» без использования сторонних библиотек.

К слову 🔍
Стоит отметить, что эта техника больше подходит для простых сопоставлений и выглядит не так красиво, как настоящий паттерн-матчинг в функциональных языках.12
Нативный синтаксис для паттерн-матчинга в JS пока находится в Stage 1.13 Для более сложных сопоставлений можно использовать сторонние библиотеки типа ts-pattern.1415

В коде на JavaScript подобной проверки типов нет, но декларативность подхода делает и ручной поиск ошибок проще. Чем более явно мы сопоставляем ключ и значение, тем проще заметить несоответствия.

Подробнее 👓
О декларативности мы детально поговорим в одной из следующих глав.

Стратегия

Множественные else-if условия могут выбирать не просто значение, а дальнейшее поведение программы. Если условие выбирает поведение среди однотипных вариантов, это может говорить о смешении ответственности или недостаточном полиморфизме.16

Один из вариантов решения этой проблемы похож на техники рефакторинга из предыдущего раздела.

Для примера посмотрим на функцию notifyUser. Она показывает пользователю сообщение одним из трёх возможных способов. Выбор конкретного способа зависит от условий внутри этой функции:

function notifyUser(message) {
  if (method === methods.popup) {
    const popup = document.querySelector(".popup");
    popup.innerText = message;
    popup.style.display = "block";
  } else if (method === methods.remote) {
    notificationService.setup(TOKEN);
    notificationService.sendMessage(message);
  } else if (method === methods.hidden) {
    console.log(message);
  }
}

Проблема функции в том, что она смешивает разные задачи. Она занимается и выбором способа отправки сообщения, и самой отправкой. Это значит, что добавление нового способа или изменение существующего затронет всю функцию целиком. Причём даже те части, которые к изменениями отношения не имеют.

Из-за этого использовать отдельно один из способов отправки сообщений в другом коде не получится. Протестировать разные способы изолированно друг от друга тоже не выйдет. А при добавлении новых способов, функция notifyUser станет заметно объёмнее и сложнее.

Вместо этого мы можем разделить выбор действия и непосредственно действие:

// Выделим каждый способ отправки сообщений в отдельную функцию:
function showPopupMessage(message) {
  const popup = document.querySelector(".popup");
  popup.innerText = message;
  popup.style.display = "block";
}

// Если сигнатура функции отправки не совпадает с остальными:
function notifyUser(token, message) {
  notificationService.setup(token);
  notificationService.sendMessage(message);
}

// ...Мы можем её адаптировать:
const showNotificationMessage = (message) => notifyUser(TOKEN, message);

// Далее подготовим набор функций отправки,
// по одной на каждый вариант из `methods`:
const notifiers = {
  [methods.popup]: showPopupMessage,
  [methods.hidden]: console.log,
  [methods.remote]: showNotificationMessage,
};

// При использовании выбираем поведение
// в зависимости от параметра `method`:
function notifyUser(message) {
  const notifier = notifiers[method] ?? defaultNotifier;
  notifier(message);
}

// Выбрать функцию можно и заранее,
// если `method` известен до исполнения функции.

Такая реализация упростит добавление и удаление вариантов отправки сообщений:

// Чтобы добавить новый способ,
// достаточно добавить новую функцию...
function showAlertMessage(message) {
  window.alert(message);
}

const notifiers = {
  // ...
  // ...И указать её, как вариант выбора:
  [methods.browser]: showAlertMessage,
};

// Остальной код останется неизменным.

Изменения отдельно взятой функции не выйдут за её пределы и не повлияют на notifyUser или другие варианты отправки сообщений. Тестировать и использовать такие функции независимо друг от друга гораздо проще.

Разделение выбора и действия — это по сути паттерн «Стратегия».17 Его может быть трудно увидеть в коде без классов, потому что примеры этого паттерна чаще всего показывают в парадигме ООП, но это он. Просто если в ООП каждый вариант поведения — это класс, то в нашем примере — это функции.

Null-объект

Лишние условия и проверки также могут появляться из-за однотипной, но слегка отличающейся функциональности в приложении.

Для примера представим, что мы пишем мобильное приложение под iOS, Android и веб. В версии для мобильных платформ мы хотим добавить виджеты. У iOS и Android есть нативные API для обновления виджетов, для которых у нас есть JS-адаптеры.

Функциональность адаптеров описана в интерфейсе Device. Они содержат идентификатор платформы и метод для обновления виджета:

interface Device {
  platform: Platform;
  updateWidget(data: WidgetData);
}

В вебе виджетов нет, поэтому их функциональность для веба мы решаем отключить. Один из путей это сделать — определять платформу и включать фичу по условию:

const iosDevice: Device = {
  platform: Platform.Ios,
  updateWidget: (data) => {}, // Логика с нативными iOS API...
};

const androidDevice: Device = {
  platform: Platform.Android,
  updateWidget: (data) => {}, // Логика с нативными Android API...
};

function update(device: Device, data: WidgetData) {
  if (
    device.platform === Platform.Android ||
    device.platform === Platform.Ios
  ) {
    // Вызываем `updateWidget` только для iOS и Android:
    device.updateWidget(data);
  }
}

Пока таких проверок одна или две, они нам вряд ли помешают. Но чем больше фич мы будем так проверять, тем больше условий в коде появится. Если мы видим, что проверки приходится делать часто, мы можем воспользоваться вторым вариантом решения проблемы.

Добавим объект-«пустышку» для веба, который будет реализовывать интерфейс Device, но в ответ на вызов updateWidget ничего не будет делать:

const webDevice: Device = {
  platform: Platform.Web,
  updateWidget() {},
};

Такие «пустышки» называются null-объектами.18 Как правило, их добавляют в места, где нужен вызов метода, но не нужна реализация этого вызова.

При использовании null-объекта нам больше не нужно проверять тип устройства — достаточно просто вызвать метод. Если текущий девайс — это веб-браузер, то вызов метода ни к чему не приведёт:

function update(device: Device, data: WidgetData) {
  device.updateWidget(data);

  // webDevice.updateWidget();
  // void;

  // Условие больше не нужно.
}

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

Footnotes

  1. “Your Code As a Crime Scene” by Adam Tornhill, https://www.goodreads.com/book/show/23627482-your-code-as-a-crime-scene

  2. Цикломатическая сложность, Википедия, https://ru.wikipedia.org/wiki/Цикломатическая_сложность 2

  3. Граф потока управления, Википедия, https://ru.wikipedia.org/wiki/Граф_потока_управления

  4. Control flow graph & cyclomatic complexity for following procedure, Stackoverflow, https://stackoverflow.com/a/2670135/3141337

  5. “Cognitive Complexity. A new way of measuring understandability” by G. Ann Campbell, SonarSource SA, https://www.sonarsource.com/docs/CognitiveComplexity.pdf

  6. “Code That Fits in Your Head” by Mark Seemann, https://www.goodreads.com/book/show/57345272-code-that-fits-in-your-head

  7. The Zen of Python, https://peps.python.org/pep-0020/#the-zen-of-python

  8. Управление состоянием приложения с помощью конечного автомата, https://bespoyasov.ru/blog/fsm-to-the-rescue/

  9. Законы де Моргана, Википедия, https://ru.wikipedia.org/wiki/Законы_де_Моргана

  10. switch-exhaustiveness-check, ES Lint TypeScript, https://typescript-eslint.io/rules/switch-exhaustiveness-check/

  11. Сопоставление с образцом, Википедия, https://ru.wikipedia.org/wiki/Сопоставление_с_образцом

  12. Pattern matching in Haskell, Learn You Haskell, http://learnyouahaskell.com/syntax-in-functions#pattern-matching

  13. ECMAScript Pattern Matching Proposal, https://github.com/tc39/proposal-pattern-matching

  14. ts-pattern, Library for Pattern Matching in TypeScript, https://github.com/gvergnaud/ts-pattern

  15. “Bringing Pattern Matching to TypeScript” by Gabriel Vergnaud, https://dev.to/gvergnaud/bringing-pattern-matching-to-typescript-introducing-ts-pattern-v3-0-o1k

  16. «Полиморфизм простыми словами» Sergey Ufocoder, https://medium.com/devschacht/polymorphism-207d9f9cd78

  17. Strategy Pattern, Refactoring Guru, https://refactoring.guru/design-patterns/strategy

  18. Introduce Null Object, Refactoring Guru, https://refactoring.guru/introduce-null-object

  19. Null object pattern, Criticism, Wikipedia, https://en.wikipedia.org/wiki/Null_object_pattern#Criticism