Skip to content

Latest commit

 

History

History
491 lines (364 loc) · 34.6 KB

16-declarative-style.md

File metadata and controls

491 lines (364 loc) · 34.6 KB

Декларативность

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

В этой главе мы расширим идеи абстракции и поговорим о декларативности. Мы обсудим, что такое декларативный код, как его писать и в чём его польза и преимущество по сравнению с кодом в императивном стиле.

Читаемость

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

// 1.

function keepEvenNumbers(array) {
  const result = [];

  for (const x of array) {
    if (x % 2 === 0) {
      result.push(x);
    }
  }

  return result;
}

// 2.

function keepEvenNumbers(array) {
  return array.filter((x) => x % 2 === 0);
}

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

  • Создать пустой массив result;
  • Проитерировать переданный массив array;
  • Для каждого элемента проверить, чётный ли он;
  • Если да, добавить его в result.

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

В этом и есть разница между императивным и декларативным стилем кода. Декларативный код описывает, что нужно сделать, императивный — как это сделать.

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

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

function validate(user, cart) {
  return (
    !!cart.items.length &&
    user.account >= cart.items.reduce((tally, item) => tally + item.price, 0)
  );
}

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

// cart.js
function isEmpty(cart) {
  return !cart.products.length;
}

function totalPriceOf(cart) {
  return cart.items.reduce((tally, item) => tally + item.price, 0);
}

// user.js
function canAffordSpending(user, amount) {
  return user.account >= amount;
}

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

// order.js

function validate(user, cart) {
  return !isEmpty(cart) && canAffordSpending(user, totalPriceOf(cart));
}
Подробнее 💡
Об уровнях абстракции, переключении между ними и управлении вниманием читателя мы говорили более детально ранее в главе об абстракции.

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

// order.js

function canMakeOrder(user, cart) {
  const orderPrice = totalPriceOf(cart);
  return !isEmpty(cart) && canAffordSpending(user, orderPrice);
}

// The (user) (canMakeOrder) IF the (cart) (!isEmpty)
// AND they (canAffordSpending) (orderPrice) of money.

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

Однако 🐣
В небольших проектах дотошная декомпозиция может оказаться не такой важной. Если кода мало, читатели от него устают меньше. Необходимость декомпозиции будет зависеть оттого, насколько команде просто читать и работать с имеющимся кодом.

Надёжность

Следующий раздел спорный и субъективный, но по моему опыту в императивном коде проще допустить случайные ошибки. От части потому что в нём приходится одновременно думать о «цели» и «способе её достичь», а ещё потому что императивный код зачастую объёмнее и статистически в нём вероятнее допустить ошибку.1

Например, посмотрим на функцию выбора математической операции selectOperation:

function selectOperation(kind) {
  let operation = null;
  switch (kind) {
    case "log":
      operation = (x, base) => Math.log(x) / Math.log(base);
    case "root":
      operation = (x, root) => x ** -root;
    default:
      operation = (x) => x;
  }
  return operation;
}

В каждом блоке case этой функции пропущена инструкция break, поэтому переменная operation всегда будет равна (x) => x. Подобную ошибку относительно просто заметить в небольшой функции, но если кода много, её гораздо легче пропустить.

Мы можем улучшить код, использовав return внутри блоков case, тем самым перестав зависеть от внутренней переменной:

function selectOperation(kind) {
  switch (kind) {
    case "log":
      return (x, base) => Math.log(x) / Math.log(base);
    case "pow":
      return (x, power) => x ** power;
    default:
      return (x) => x;
  }
}

Но это не решит проблему со случайными ошибками, а только замаскирует её. Во фрагменте выше мы, например, можем случайно пропустить return, и функция будет работать неправильно. Избавиться от проблемы можно, сделав выбор декларативным:

const log = (x, base) => Math.log(x) / Math.log(base);
const pow = (x, power) => x ** power;
const id = (x) => x;

function selectOperation(kind) {
  const operations = { log, pow, id };
  return operations[kind] ?? operations.id;
}

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

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

Расширяемость

Расширять императивный код часто сложнее.

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

К слову 🖥
Идею для следующего примера я подсмотрел в лекции Тимура Шемсединова о декларативном стиле и метапрограммировании. Рекомендую к просмотру.2

Для примера сравним две реализации функции parseDuration, которая преобразует форматированную строку с периодом времени в количество миллисекунд в нём. В первой версии алгоритм реализован императивно:

/** @example parseDuration('1h 25m 16s') === 5_116_000 */
function parseDuration(stringRepresentation) {
  const s = stringRepresentation;
  if (typeof s !== "string") return 0;

  const seconds = s.match(/(\d+)s/);
  const minutes = s.match(/(\d+)m/);
  const hours = s.match(/(\d+)h/);

  let duration = 0;
  if (seconds) duration += +seconds[1] * 1000;
  if (minutes) duration += +minutes[1] * 1000 * 60;
  if (hours) duration += +hours[1] * 1000 * 60 * 60;
  return duration;
}

...Во второй версии — декларативно:

// Вся информация о поддерживаемом
// формате строки вынесена в объект:
const MULTIPLIER = {
  s: 1000,
  m: 1000 * 60,
  h: 1000 * 60 * 60,
};

// Шаги алгоритма представлены отдельными функциями:
const sumDurations = (sum, [value, unit]) => sum + value * MULTIPLIER[unit];
const hasValidValue = ([value]) => !Number.isNaN(value);

const parseComponent = (component) => {
  const value = +component.slice(0, -1);
  const unit = component.slice(-1);
  return [value, unit];
};

/** @example parseDuration('1h 25m 16s') === 5_116_000 */
function parseDuration(stringRepresentation) {
  if (typeof stringRepresentation !== "string") return 0;

  const components = stringRepresentation.split(" ");
  return components
    .map(parseComponent)
    .filter(hasValidValue)
    .reduce(sumDurations, 0);
}

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

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

К слову ⛔️
Такое поведение — когда добавить однотипную функциональность можно не меняя существующий код — цель принципа открытости-закрытости из SOLID.3

Расширение формата строки теперь ограничится добавлением новых полей для объекта MULTIPLIER. Код самого алгоритма меняться не будет:

const MULTIPLIER = {
  s: 1000,
  m: 1000 * 60,
  h: 1000 * 60 * 60,

  // Добавили дни и недели:
  d: 1000 * 60 * 60 * 24,
  w: 1000 * 60 * 60 * 24 * 7,
};

// Так как остальной код функции остался прежним,
// вероятность допустить случайную ошибку или опечататься ниже.
// Кроме этого по выделенным в `MULTIPLIER` «настройкам»
// проще автоматически сгенерировать данные для тестов.

В первой реализации нам бы потребовалось менять код всей функции parseDuration:

function parseDuration(stringRepresentation) {
  const s = stringRepresentation;
  if (typeof s !== "string") return 0;

  const seconds = s.match(/(\d+)s/);
  const minutes = s.match(/(\d+)m/);
  const hours = s.match(/(\d+)h/);
  const days = s.match(/(\d+)d/);
  const weeks = s.match(/(\w+)d/);

  let duration = 0;
  if (seconds) duration += +seconds[1] * 1000;
  if (minutes) duration += +minutes[1] * 1000 * 60;
  if (hours) duration += +hours[1] * 1000 * 60 * 60;
  if (days) duration += +days[1] * 1000 * 60 * 60 * 24;
  if (weeks) duration += +weeks[1] * 1000 * 60 * 60 * 24 * 7;
  return duration;
}

// К слову о том, насколько просто в императивном коде допустить случайную ошибку:
// печеньку тем, кто заметил в регулярном выражении для `weeks` опечатку :–)
Однако 👀
Подобное выделение «настроек» хорошо помогает для расширения кода однотипной функциональностью. Если требуется добавить новое поведение в сам алгоритм, это может не помочь.

Стоит помнить, что такое «метапрограммирование» нужно не всегда, потому что обобщённые функции могут быть сложнее.

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

Конфигурируемость

В предыдущем примере мы вынесли «настройки» алгоритма в отдельный объект. Мы мотивировали это тем, что настройки и код меняются с разной скоростью, поэтому их лучше держать разделёнными.

На самом деле это одно из известных правил, которых рекомендуют придерживаться в методологии двенадцати факторов (12-Factor Apps).4 Это правило можно описать как:


❗️ Всегда держать конфиги отдельно от кода


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

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

К слову 🔦
Именно этот признак помогает находить конфиги среди остального кода: если значение переменной зависит от окружения или среды запуска — это точно часть конфигурации.

Посмотрим на антипример. Допустим, базовый URL API в коде ниже должен меняться для разных окружений. В функции fetchUser базовый URL «зашит» прямо в теле функции:

async function fetchUser(id) {
  const response = await fetch(`https://api.our-app.com/v1/users/${id}`);
  const data = await response.json();
  return data.user;
}

// Любой вызов `fetchUser` обратится к конкретной версии API — api.our-app.com.
// Чтобы поменять окружение, нужно изменить код функции.

Выделять конфигурацию удобнее всего, следуя приоритету трансформаций (Transformation Priority Premise, TPP).5 Сперва стоит выделить значения конфигов в локальные переменные, а потом — в переменные окружения или файлы конфигурации.

// Шаг 1: вынести конфиги в локальные переменные.
const baseUrl = "https://api.our-app.com";
const apiVersion = "v1";

async function fetchUser(id) {
  const response = await fetch(`${baseUrl}/${apiVersion}/users/${id}`);
  const data = await response.json();
  return data.user;
}

// Шаг 2: утащить их в настоящий конфиг.
// Это могут быть как переменные окружения, так и отдельные модули.
// Главное, чтобы между кодом и конфигами было _чёткое_ разделение.
import { networkConfig } from "@config";

async function fetchUser(id) {
  const response = await fetch(`${networkConfig.apiRoot}/users/${id}`);
  const data = await response.json();
  return data.user;
}

Автоматное программирование

Как мы упоминали в предыдущей главе, бизнес-логику приложения и UI-логику лучше держать отдельно. Код бизнес-логики должен отвечать за бизнес-процессы и преобразования данных, связанные с ними. Код UI-логики — за рендер пользовательского интерфейса.

Иногда UI-логика бывает сложной. Например, если она описывает поведение взаимозависимых компонентов или рендер динамических кусков интерфейса, зависящих от большого количества условий.

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

Взаимодействие пользователя с UI тогда можно описать, как последовательность таких состояний. Если количество состояний ограничено, то такую последовательность мы можем назвать конечным автоматом (Finite State Machine),6 а способ её запрограммировать — автоматным программированием.

К слову 🤖
Конечный автомат — это математическая концепция, но она хорошо ложится на описание UI как функции от состояния данных.7 Она побуждает разделять данные (состояние) и эффекты (рендер UI) и помогает делать работу с интерфейсом более детерминированной.

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

«Юзкейс оформления заказа»:

Текущее состояние Возможные следующие состояния
OrderPage MainPage, Confirming
Confirming Success, Failure
Success MainPage
Failure MainPage, OrderPage

Мы можем представить такой автомат в виде диаграммы переходов между состояниями:

Направленный граф, в котором вершины — это состояния автомата, а рёбра — переходы между состояниями

Диаграмма переходов между состояниями в интернет-магазине

Но также мы можем представить этот автомат в коде как коллекцию состояний и переходов:

const fsm = createMachine({
  states: {
    main: {}, // Переходы из `MainPage`...
    order: {}, // Из `OrderPage`...
    confirming: {}, // Из `Confirming`...
    success: {}, // Из `Success`...
    failure: {}, // Из `Failure`...
  },
});

Сами состояния можем описать в виде, например, компонентов:

// Отражает состояние `OrderPage`:
const ConfirmOrder = () => (
  <form onSubmit={fsm.to("confirming")}>{/*...*/}</form>
);

// Отражает состояние `Success`:
const OrderConfirmed = () => (
  <>
    Order Confirmed.
    <a href={fsm.to("main")}>Back to main page</a>
  </>
);

// Отражает состояние `Failure`:
const OrderError = () => (
  <>
    Couldn't confirm the order.
    <a href={fsm.to("main")}>Back to main page</a>
    <a href={fsm.to("order")}>Try again</a>
  </>
);

// Отражает состояние `Confirming`:
const Confirming = () => "Loading...";

Тогда входная точка приложения может использовать этот автомат, чтобы решать, что рендерить на экране:

// Выбирает компонент по текущему состоянию интерфейса:
function Checkout() {
  const [state] = useMachine(fsm);

  return state.match
    .with("confirming", Confirming)
    .with("success", OrderConfirmed)
    .with("failure", OrderError)
    .orElse(ConfirmOrder);
}

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

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

Инструменты 🤖
Сам автомат и логика переходов между состояниями может быть описана с использованием различных библиотек. В примере выше я использовал вымышленный инструмент, чтобы не претендовать на «каноничность решения». Но как хороший пример библиотеки для работы с конечными автоматами в UI могу предложить xstate.8

Налог на декларативность

Как мы упоминали выше, у декларативного стиля кода есть недостатки.

Сложность поддержки

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

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

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

Производительность

Императивный код, как правило, производительнее. В тех местах, где производительность важнее читаемости, декларативностью можно пожертвовать.

При этом может быть полезно изолировать «островки императивности» от остального кода. Например, если для работы приложения нам нужен какой-то производительный алгоритм, его реализацию можно описать императивно внутри функции:

function mergeTrees(treeA, treeB) {
  // ...Быстрая императивная реализация.
}

...А остальную часть приложения описать декларативно:

function mergeCompanyDepartments(departmentIdA, departmentIdB) {
  return mergeTrees(
    extractDepartment(departmentIdA),
    extractDepartment(departmentIdB)
  );
}

Так мы «спустим» императивную реализацию «на уровень ниже», изолируем её от остального кода, а имя функции сделаем декларативным описанием всего алгоритма целиком.

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://youtu.be/Bo9y4IxdNRY

  3. The Principles of OOD, Robert C. Martin, http://www.butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod

  4. 12 Factor Apps, https://12factor.net

  5. Transformation Priority Premise, Wikipedia https://en.wikipedia.org/wiki/Transformation_Priority_Premise

  6. Конечный Автомат, Википедия, https://ru.wikipedia.org/wiki/Конечный_автомат

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

  8. JavaScript and TypeScript finite state machines and statecharts, XState, https://github.com/statelyai/xstate