Yet another введение в fp-ts. Часть 1. Эквивалентность и сравнимость

fp-ts построен вокруг классов типов

fp-ts построен вокруг классов типов

Что такое монада? Душнила ответит: «Это моноид в категории эндофункторов», и будет абсолютно прав. Вопросов появляется ещё больше: «Что такое моноид?», «Что такое категория?», «Что такое эндофунктор?». В то же мгновение человека сковывает первородный ужас, сошедший со страниц рассказов Г.Ф. Лавкрафта, инстинкт самосохранения кричит: «Беги!» Интересный собеседник, обладающий эмпатией, приобнимет и скажет: «Бро, это же просто контейнер для какого-то значения, способный соединяться в цепочки таких же контейнеров. Ты же пишешь фронтенд? Или бэк на ноде? В холодильнике пиво, угощайся, а я тебе расскажу про библиотеку fp-ts и разные клёвые штуки из функционального программирования».

Для JavaScript существуют тонны библиотек, поддерживающих функциональный стиль программирования. Lodash, Ramda, Sanctuary — это то что сразу приходит в голову. В эпоху победившего TypeScript писать без типобезопасности уже моветон. Ни Lodash, ни Ramda типобезопасность не обещают, Sanctuary проверяет типы во время выполнения, что требует дополнительных накладных расходов. Fp-ts изначально написан на TypeScript и, помимо возможностей функционального программирования, предлагает изящную проверку типов. Эта интересная библиотека реализует множество концепций из статически типизированных функциональных языков семейства ML, таких так Haskell, Ocaml и Scala. В fp-ts всё прекрасно, кроме документации и практических примеров использования. Моя задача — осветить эти тёмные углы, а также показать несколько практических примеров.

В статье я не буду обсуждать базовые концепции ФП, такие как чистые функции, лямбды, композицию и прочее. Про это написано много статей, а Youtube завален тоннами видеороликов от индиан гаев. Здесь я постараюсь простыми словами на практических примерах объяснить классы типов, какие они бывают и как их использовать в fp-ts.

Примечание

Если вы задаётесь вопросом: «А нужна ли лично мне эта ваша функциональщина?», то мой однозначный ответ: «Нет!». Для лэндингов с формой отправки заявки и кнопкой «Жми!11!» все эти моноиды, функторы, монады явно будут лишними. Функциональный стиль программирования решает пласт задач, с которым редко приходится сталкиваться рядовому разработчику. Однако если в вашем проекте сложность бизнес-логики нарастает как снежный ком, глубина ветвления потока управления сравнима с Марианской впадиной, а в голове приходится держать десятки граничных случаев, то ФП будет глотком свежего воздуха. А fp-ts принесёт ещё и типобезопасность.

Так что же такое класс типов?

Наиболее близкий аналог — это интерфейс. То есть некий контракт, который выполняет сущность, реализующая класс типов. Основное отличие от привычных интерфейсов в том, что type classes описывают не бизнес-логику, а как можно более абстрактные математические свойства наших сущностей. Если пока не понятно — не беда. Примеры ниже расставят всё по своим местам.

Как раз в библиотеке fp-ts классы типов и реализованы через классические интерфейсы TypeScript.

Класс типов Eq — эквивалентность

Класс типов Eq реализует свойство эквивалентности. То есть отвечает на вопрос:»равны ли два элемента множества между собой или не равны? ». Для строк и чисел это выглядит как бессмыслица, и так очевидно, что 1 === 1 или 'cat' === 'cat'. Но что если у нас не числа или строки, а более сложные структуры данных?

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

type TFlat = {
  area: number;
};

const flat1: TFlat = {
  area: 40,
};
const flat2: TFlat = {
  area: 42,
};
const flat3: TFlat = {
  area: 40,
};

flat1.area === flat2.area; // false
flat1.area === flat3.area; // true

Элементарно, Ватсон. Но мы же программисты и любим сами себе создавать проблемы, а потом их с доблестью решать, верно? И статья про fp-ts, так что почему бы нам не заморочиться и это равенство не реализовать с его помощью?

import { Eq } from "fp-ts/Eq";

type TFlat = {
  area: number;
};

const flat1: TFlat = {
  area: 40,
};
const flat2: TFlat = {
  area: 42,
};
const flat3: TFlat = {
  area: 40,
};

const eqFlat: Eq = {
  equals: (firstFlat, secondFlat) => firstFlat.area === secondFlat.area,
}

eqFlat.equals(flat1, flat2); // false
eqFlat.equals(flat1, flat3); // true

Что-то стало душновато… Подожди, не закрывай вкладку, дорогой читатель. С этого момента будет интереснее, ведь как раз сейчас к нам пришёл бизнес-заказчик и сказал: «Нет никакого смысла сравнивать две квартиры по одной лишь жилплощади. Вторичка и первичка — это большая разница. Давай реализуем логику с учётом этого требования?». Ок, давай, рад помочь.

type THouseKind = 'NewHouse' | 'SecondaryHouse'; // первичка или вторичка
type TFlat = {
  area: number;
  kind: THouseKind;
};

const flat1: TFlat = {
  area: 40,
  kind: 'NewHouse',
};
const flat2: TFlat = {
  area: 42,
  kind: 'SecondaryHouse',
};
const flat3: TFlat = {
  area: 40,
  kind: 'SecondaryHouse',
};

flat1.area === flat2.area && flat1.kind === flat2.kind; // false
flat1.area === flat3.area && flat1.kind === flat3.kind; // false

В принципе, код работает, но уже начинает выглядеть сомнительно. Задача решена, но всё же попробуем то же самое сделать с помощью fp-ts.

type THouseKind = 'NewHouse' | 'SecondaryHouse'; // первичка или вторичка
type TFlat = {
  area: number;
  kind: THouseKind;
};

const flat1: TFlat = {
  area: 40,
  kind: 'NewHouse',
};
const flat2: TFlat = {
  area: 42,
  kind: 'SecondaryHouse',
};
const flat3: TFlat = {
  area: 40,
  kind: 'SecondaryHouse',
};

const eqFlat: Eq = {
  equals: (firstFlat, secondFlat) => firstFlat.area === secondFlat.area
    && firstFlat.kind === secondFlat.kind,
}

eqFlat.equals(flat1, flat2); // false
eqFlat.equals(flat1, flat3); // false

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

import { Eq, struct } from "fp-ts/Eq";
import { Eq as numberEq } from "fp-ts/number";
import { Eq as stringEq } from "fp-ts/string";

// Eq из fp-ts/number и Eq из fp-ts/string - это уже
// написанная реализация класса типов Eq для строк и чисел

type THouseKind = "NewHouse" | "SecondaryHouse"; // первичка или вторичка
type TFlat = {
  area: number;
  kind: THouseKind;
};

const flat1: TFlat = {
  area: 40,
  kind: "NewHouse",
};
const flat2: TFlat = {
  area: 42,
  kind: "SecondaryHouse",
};
const flat3: TFlat = {
  area: 40,
  kind: "SecondaryHouse",
};
const flat4: TFlat = {
  area: 40,
  kind: "NewHouse",
};

const eqFlat: Eq = struct({
  area: numberEq,
  kind: stringEq
});

console.log(eqFlat.equals(flat1, flat2)); // false
console.log(eqFlat.equals(flat1, flat3)); // false
console.log(eqFlat.equals(flat1, flat4)); // true

Теперь код выглядит гораздо интереснее и лаконичнее! Да и бизнес пока что доволен нашими результатами. Через неделю бизнес-требования опять меняются: «Дорогой разработчик, а давай мы ещё будем для сравнения квартир использовать расстояние до метро с шагом в 200 метров?». Уже становится срашновато…

type THouseKind = "NewHouse" | "SecondaryHouse"; // первичка или вторичка
type TFlat = {
  area: number;
  kind: THouseKind;
  metroDistance: number;
};

const flat1: TFlat = {
  area: 40,
  kind: "NewHouse",
  metroDistance: 540,
};
const flat2: TFlat = {
  area: 42,
  kind: "SecondaryHouse",
  metroDistance: 320,
};
const flat3: TFlat = {
  area: 40,
  kind: "NewHouse",
  metroDistance: 430,
};
const flat4: TFlat = {
  area: 40,
  kind: "NewHouse",
  metroDistance: 750,
};

const DISTANCE_INTERVAL = 200;

const getDistanceInterval = (distance: number) =>
  Math.floor(distance / DISTANCE_INTERVAL);

const isEqual = (first: TFlat, second: TFlat): boolean =>
  first.area === second.area &&
  first.kind === second.kind &&
  getDistanceInterval(first.metroDistance) ===
  getDistanceInterval(second.metroDistance);

isEqual(flat1, flat2); // false
isEqual(flat1, flat3); // true
isEqual(flat1, flat4); // false

Эта же функциональность на fp-ts выглядит простой как дубина:

Hidden text

import { Eq, struct } from "fp-ts/Eq";
import { Eq as numberEq } from "fp-ts/number";
import { Eq as stringEq } from "fp-ts/string";

// Eq из fp-ts/number и Eq из fp-ts/string - это уже
// написанная реализация класса типов Eq для строк и чисел

type THouseKind = "NewHouse" | "SecondaryHouse"; // первичка или вторичка

type TFlat = {
  area: number;
  kind: THouseKind;
  metroDistance: number;
};

const flat1: TFlat = {
  area: 40,
  kind: "NewHouse",
  metroDistance: 540,
};

const flat2: TFlat = {
  area: 42,
  kind: "SecondaryHouse",
  metroDistance: 320,
};

const flat3: TFlat = {
  area: 40,
  kind: "NewHouse",
  metroDistance: 430,
};

const flat4: TFlat = {
  area: 40,
  kind: "NewHouse",
  metroDistance: 750,
};

/**
 * Реализация Eq для сравнения расстояния с шагом в 200 метров
 */
const eqDistance: Eq = {
  equals: (first, second) => {
    const distanceQuant = 200;

    return (
      Math.floor(first / distanceQuant) === Math.floor(second / distanceQuant)
    );
  }
};

const eqFlat: Eq = struct({
  area: numberEq,
  kind: stringEq,
  metroDistance: eqDistance, // вуаля
});

eqFlat.equals(flat1, flat2); // false
eqFlat.equals(flat1, flat3); // true
eqFlat.equals(flat1, flat4); // false

Ну не красота ли? Аналогично, мы можем гибко подстраивать нашу логику определения равенства объектов квартир под изменяющиеся требования бизнеса (типобезопасность — в довесок), просто реализовав экземпляр Eq.

Класс типов Ord — сравнимость

Класс типов Ord реализует сравнение элементов множества, то есть определяет, что первый элемент больше, меньше или равен второму элементу. Из этого определения «на пальцах» сразу видно, что Ord наследуется от Eq.

Очевидно, что 1000 рублей больше, чем 500. А вот даже сравнение строк становится не такой уж и банальной задачей:

'cat' < 'dog'; // true
'кот' < 'собака'; // true
'кот' < 'Собака'; // false

Уже видно не очень понятное поведение. Да, сравнивать строки нужно в одном регистре, да, существует функция localeCompare, и пример «высосан из пальца», но главное, что совсем не прозрачен бизнес-смысл механизма сравнения (конечно, можно накопать в спецификации ECMAScript точное описание, но всё равно он не так уж и очевиден).

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

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

  • Цена — чем ниже, тем лучше.

  • Площадь — чем больше, тем лучше.

  • Расстояние до метро — чем меньше, тем лучше (с шагом в 200 метров).

  • Этаж. Хуже всего первый и второй этажи, потом идёт последний этаж. Этажи с третьего по предпоследний считаются одинаково хорошими.

Попробуем реализовать эти требования в коде:

Hidden text

import { Eq } from "fp-ts/Eq";
import * as N from "fp-ts/number";
import { pipe } from "fp-ts/function";
import { Ord, max, contramap, getMonoid, reverse } from "fp-ts/Ord";
import { concatAll } from "fp-ts/Monoid";

// так как реально enum распакуется в числа - это будет удобнее сравнивать
enum Floor {
  Low,
  Top,
  Other,
}

type TFlat = {
  price: number,
  area: number;
  metroDistance: number;
  floor: number;
  totalFloors: number;
};

// для сравнения введём тип, где этажи приведены и enum-у
type TDerivedFlat = Omit & {
  floor: Floor,
}

// шаги по 200 метров
const DISTANCE_QUANT = 200;

// отображение TFlat на TDerivedFlat, избавляемся от знания о этажности дома
const mapFlat = (flat: TFlat): TDerivedFlat => {
  const { price, area, metroDistance, floor, totalFloors } = flat;

  let derivedFloor: Floor;

  if (floor < 3) {
    derivedFloor = Floor.Low;
  } else if (floor === totalFloors) {
    derivedFloor = Floor.Top;
  } else {
    derivedFloor = Floor.Other;
  }

  return {
    price,
    area,
    metroDistance,
    floor: derivedFloor,
  }
};

const distanceInterval = (distance: number) =>
  Math.floor(distance / DISTANCE_QUANT);

const eqDistance: Eq = {
  equals: (first, second) =>
    distanceInterval(first) === distanceInterval(second),
};

const ordDistance: Ord = {
  equals: eqDistance.equals,
  compare: (first, second) => {
    const fistInterval = distanceInterval(first);
    const secondInterval = distanceInterval(second);
    return (fistInterval < secondInterval ? -1 : fistInterval > secondInterval ? 1 : 0);
  }
};

// про моноиды - позже, коротко - объединение элементов множества
// нам понадобится для объединения сравнений в одно сложное сравнение
const M = getMonoid();

// сравнение по цене, чем ниже - тем лучше
const ordByPrice: Ord = pipe(N.Ord, reverse, contramap((flat) => flat.price));
// сравнение по площади, чем больше - тем лучше
const ordByArea: Ord = pipe(N.Ord, contramap((flat) => flat.area));
// сравнение по расстоянию до метро, чем меньше - тем лучше
const ordByDistance: Ord = pipe(ordDistance, reverse, contramap((flat) => flat.metroDistance));
// сравнение по этажу
const ordByFloor: Ord = pipe(N.Ord, contramap((flat) => flat.floor));
// комлексное сравнение, использует все параметры
const complexOrd = concatAll(M)([ordByPrice, ordByArea, ordByDistance, ordByFloor]);

const flat1: TFlat = {
  price: 19000000,
  area: 51.6,
  metroDistance: 850,
  floor: 18,
  totalFloors: 18,
};

const flat2: TFlat = {
  price: 19000000,
  area: 51.6,
  metroDistance: 940,
  floor: 4,
  totalFloors: 5,
};

// flat2 дешевле
max(ordByPrice)(mapFlat(flat1), mapFlat(flat2));
// flat1 - вернул первый аргумент, так как равны площади
max(ordByArea)(mapFlat(flat1), mapFlat(flat2));
// flat1 - вернул первый аргумент, так как равны расстояния
max(ordByDistance)(mapFlat(flat1), mapFlat(flat2));
// flat2 - 4-й этаж из 5 лучше 18-го из 18
max(ordByFloor)(mapFlat(flat1), mapFlat(flat2));
// flat1 - сразу сравнились цены, остальные сравнения отбросили
max(complexOrd)(mapFlat(flat1), mapFlat(flat2));

Как и для Eq, здесь мы (почти бесплатно) можем добавлять свойства для сравнения и, благодаря композиции, получать сколь угодно сложные сравнивающие функции. Страшно подумать, как бы это выглядело с обычным императивным кодом. Гибкость, которая достигается благодаря применению класса типов Ord, будет кстати при разработке сложных дашбордов и витрин объявлений.

Вместо заключения

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

  • Полугруппы и моноиды (классы типов Semigroup и Monoid).

  • Вычисления в контексте, функторы и монады (классы типов Option и Either).

  • Работу с асинхронным кодом в рамках fp-ts (классы типов Task и TaskEither).

Спасибо за внимание, stay tuned, так сказать.

© Habrahabr.ru