Что такое функтор? Функциональное программирование

2 года назад·5 мин. на чтение

В этой статье на простых и доступных примерах рассмотрим одну из концепций функционального программирования - Функтор.

Это серия статей о функциональном программировании:
  1. Парадигмы программирования
  2. Композиция
  3. Функторы (рассматривается в этой статье)
  4. Каррирование
  5. Чистые функции
  6. Функции первого класса

Что такое функтор?

Функтор (functor) это:
  • обертка над значением,
  • предоставляет интерфейс для преобразование (map),
  • подчиняется законам функтора (поговорим о них позже).

Примеры функторов

  • Массив (Array),
  • Промис (Promise).

Почему массив - функтор?

Вспомним определение функтора:
  • обертка над списком значений,
  • предоставляет интерфейс для преобразования - метод map,
  • подчиняется законам функтора.
[1, 2, 3]      // обернутое значение
  .map(        // интерфейс для преобразования значения
    x => x * 2
  )

Почему промис - функтор?

Промис это:
  • обертка над любым значением из JavaSctipt типов,
  • предоставляет интерфейс для преобразования - метод then,
  • подчиняется законам функтора.
const promise = new Promise((resolve, reject) => {
  resolve(
    { data: "value" } // обернутое значение, в данном случае объект
  )
});
 
promise
  .then(              // интерфейс для преобразования значения
    response => console.log(response)
  );

Что объединяет массив (или промис) и функтор?

Функтор - это паттерн проектирования, а Array и Promise - типы данных, которые основаны на этом паттерне.

Почему мы говорим, что массив и промис - функторы?

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

Где использовать функторы?

Немного поговорив о функторах и связав их с нашим повседневным использованием, было бы разумно рассмотреть их подробнее. Чтобы лучше понять идею функтора, создадим свои собственные функторы. Для начала рассмотрим такую задачу. Предположим, есть следующий кусочек данных.
{
  products: [
    {
      name: "All about JavaScript",
      type: "book",
      price: 22,
      discount: 20
    }
  ]
}

Постановка задачи

Найти финальную цену первого товара с учетом скидки. Если по какой-либо причине будут переданы неправильные данные, вывести строку "No data".

Шаги алгоритма

  1. Найти первый продукт со скидкой,
  2. Применить скидку,
  3. Продолжать проверку данных на валидность. Если данные не валидны - вернуть "No data".

Традиционное решение

const isProductWithDiscount = product => {
  return !isNaN(product.discount)
}
const findFirstDiscounted = products => {
  products.find(isProductWithDiscount)
}
 
const calcPriceAfterDiscount = product => {
  return product.price - product.discount
}
 
const findFinalPrice = (data, fallbackValue) => {
  if(!data || !data.products) return fallbackValue
 
  const discountedProduct = findFirstDiscounted(data.products)
  if(!discountedProduct) return fallbackValue
 
  return calcPriceAfterDiscount(discountedProduct)
}
 
findFinalPrice(data, "No data")

Комментарии к традиционному решению

Достоинства:
  • Атомарные логические единицы (isProductWithDiscount, findFirstDiscounted и calcPriceAfterDiscount),
  • Логику защищена от невалидных данных.
Что можно улучшить:
  • Cлишком много защитных проверок. (Защитное программирование (Defensive programming) является обязательным в любом отказоустойчивом программном обеспечении. Однако, в нашем коде 50% тела функции findFinalPrice — проверка на валидность данных. Это слишком много).
  • fallbackValue почти везде.

Почему нас волнуют эти улучшения?

Потому что данный код заставляет слишком в него вникать. Это негативно влияет на DX (Developer Experience) - уровень удовлетворенности разработчика от работы с кодом. Проанализируем код, чтобы прийти к лучшему решению. Части, которые мы стремимся улучшить, формируют паттерны (защита (defence) и откат (fallback)). Хорошо то, что эти части на самом деле цельные и атомарные. Мы должны иметь возможность абстрагировать этот паттерн в оболочку, которая могла бы обрабатывать эти крайние случаи вместо нас. Обертка позаботится о крайних случаях, а нам останется позаботиться только о бизнес-логике.

Функтор Maybe

Как мы обсуждали ранее, нам нужна только обертка, которая абстрагируется от обработки данных. Итак, роль функтора Maybe состоит в том, чтобы обернуть наши данные (потенциально невалидные данные) и обработать для нас крайние случаи.

Имплементация функтора Maybe

function Maybe(value) {
  const isNothing = () => {
    return value === null || value === undefined
  }
  
  const map = (fn) => {
    return isNothing() ? Maybe() : Maybe(fn(value))
  }
 
  const getValueOrFallback = {
    return (fallbackValue) => isNothing() ? fallbackValue : value;
  }
 
  return {
    map,
    getValueOrFallback,
  };
}

Пояснения к имплементации

  • isNothing проверяет валидно ли обернутое в функтор Maybe значение
  • map - интерфейс для преобразования обернутого значения, с помощью которого мы применяем функции с бизнес логикой к обернутому значению. map возвращает новое значение в другом экземпляре Maybe. Таким образом, мы можем сделать цепочку вызовов map - .map().map().map....
  • getValueOrFallback возвращает обернутое значение или запасное значение fallbackValue.

Как использовать функтор Maybe?

С валидными данными:
Maybe('Hello')
  .map(x => x.substring(1))
  .getValueOrFallback('fallback') // 'ello'
С невалидными данными:
Maybe(null)
  .map(x => x.substring(1))       // функция не будет запущена
  .getValueOrFallback('fallback') // 'fallback'
Функтор Maybe обработал крайние случаи вместо нас и не запустил функцию с невалидными данными. Нам нужно лишь позаботиться о бизнес логике. Таким образом, мы внедрили улучшение, о котором говорили в традиционном решении. Внедрим это решение в задачу.

Решение задачи с функтором Maybe

const isProductWithDiscount = product => {
  return !isNaN(product.discount)
}
const findFirstDiscounted = products => {
  return products.find(isProductWithDiscount)
}
const calcPriceAfterDiscount = product => {
  return product.price - product.discount
}
 
Maybe(data)
 .map((x) => x.products)
 .map(findFirstDiscounted)
 .map(calcPriceAfterDiscount)
 .getValueOrFallback("No data")

Комментарии к решению с функтором Maybe

Мы смогли улучшить традиционное решение при помощи функтора Maybe:
  • мы не защищаем код сами, вместо нас это делает функтор Maybe,
  • мы указали fallbackValue только один раз.
Как функтор Maybe соответствует определению функтора? Функтор Maybe это:
  1. обертка над любым значением из JavaScript типов,
  2. предоставляет интерфейс для преобразования - метод map,
  3. подчиняется законам функтора.

Законы функторов

Закон идентичности (Identity law)

Если при выполнении операции преобразования, значения в функторе преобразовываются сами на себя, результатом будет немодифицированный функтор.
const m1 = Maybe(value)
const m2 = Maybe(value).map(v => v)
// m1 и m2 эквивалентны

Закон композиции (Composition law)

Если две последовательные операции преобразования выполняются одна за другой с использованием двух функций, результат должен быть таким же, как и при одной операции отображения с одной функцией, что эквивалентно применению первой функции к результату второй.
const m1 = Maybe(value).map(v => f(g(v)))
const m2 = Maybe(value).map(v => g(v)).map(v => f(v))
// m1 и m2 эквивалентны

Зачем использовать функторы?

  • Абстракция над применением функции,
  • Усиление композиции функций,
  • Уменьшение количества защитного кода (как в функторе Maybe),
  • Более чистая структура кода,
  • Переменные более явно указывают на то, что мы ожидаем (что Maybe моделирует значение, которое может присутствовать, а может и не присутствовать).

Что означает Абстракция над применением функции?

То, что мы передаем функцию (т.е. x => x.products) в интерфейс преобразования (т.е. map) обертки (т.е. Maybe), и она знает, как позаботиться о себе (посредством своей внутренней реализации). Нас не интересуют детали реализации оболочки, которые она содержит (детали реализации скрыты), и тем не менее мы знаем, как использовать обертку (Array или Promise), используя их интерфейсы преобразования (map). И это на самом деле крайне важно в мире программирования. Это снижает планку того, как много мы, как программисты, должны понимать, чтобы иметь возможность что-то сделать. Функторы могут быть реализованы на любом языке, поддерживающем функции высшего порядка (а таких в наши дни большинство).

Почему функторы не используются повсеместно?

Просто потому, что мы к ним не привыкли. До .map.then) мы мутировали массивы или перебирали их значения вручную. Но как только мы обнаружили .map, мы начали адаптировать его в качестве нового инструмента преобразования. Я надеюсь, что, поняв ценность функторов, мы начнем чаще внедрять их в наши ежедневные задачи как привычный инструмент. Функтор Maybe - лишь пример функтора. Существует множество функторов, которые выполняет различные задачи. В этой статье мы рассмотрели самый простой из них, чтобы понять саму идею функторов.

Итоги

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

Хуки useTransition и useDeferredValue в ReactJS 18

2 года назад·3 мин. на чтение

В React 18, релиз которого произошел в марте 2022, появилось много новых инструментов для написания производительных и отзывчивых приложений. Одним из заметных изменений является механизм рендеринга с новой ключевой концепцией: конкурентный рендеринг (concurrent rendering).

В этой статье повнимательнее рассмотрим два новых хука: useTransition() и useDeferredValue(). Эти два хука дают возможность определять приоритет обновления состояния, или, скорее, указывать, является ли обновление менее важным, чем другие, и откладывать его в пользу более срочных.
Какое обновление можно считать срочным, а какое обычным?
  • Срочные обновления: отражают прямое взаимодействие, такое как набор текста, клики, нажатия и т. д., т.е. то с чем взаимодействует пользователь. Когда вы вводите текст в поле ввода, вы хотите сразу увидеть введенный вами текст. В противном случае UI будет казаться медленным и подлагивать. Поэтому мы хотим сделать такие обновления приоритетным.
  • Обычные обновления: переход пользовательского интерфейса из одного вида в другой. Пользователи знают, что представление должно измениться или обновиться (например, когда ожидается ответ на запрос данных). Даже если есть небольшая задержка, это можно рассматривать как ожидаемое поведение, и это не будет восприниматься как медлительность приложения.
Итак, теперь подробнее рассмотрим эти два новых хука, объясним, когда их можно использовать, и посмотрим на конкретные примеры того, как их реализовать.

Хук useTransition() и функция startTransition()

До React 18 все обновления состояния помечались как "срочные". Это означает, что все обновления состояния обрабатывались одинаково с одинаковым приоритетом. С помощью useTransition() теперь можно пометить некоторые обновления состояния как несрочные.

Когда использовать useTransition() ?

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

Как использовать useTransition() ?

function App() {
 const [isPending, startTransition] = useTransition();
 const [searchQuery, setSearchQuery] = useState('');
 
 // запрос данных, который занимает некоторое время
 const filteredResults = getProducts(searchQuery);
 
 function handleQueryChange(event) {
   startTransition(() => {
     // оборачивая setSearchQuery() в startTransition(),
     // мы помечаем эти обновления как менее важные
     setSearchQuery(event.target.value);
   });
 }
 
 return (
   <div>
     <input type="text" onChange={handleQueryChange} />
 
     {isPending && <span>Loading...</span>}
     <ProductsList results={filteredResults} />
   </div>
 );
}

Хук useDeferredValue()

useDeferredValue() очень похож на useTransition() в том, что он позволяет отложить несрочное обновление состояния, но применяется его к части дерева. Это похоже методы debounce и throttle, которые мы часто используем для отложенных обновлений. React будет работать с такими обновлениями, как только завершатся срочные обновления.

Когда использовать useDeferredValue()?

С помощью useTransition() вы сами решаете, когда конкретное обновление состояния может быть помечено как менее срочное. Но иногда такой возможности может и не быть, например, если фрагмент кода находится в сторонней библиотеке. В таких случаях можно воспользоваться хуком useDeferredValue(). С помощью useDeferredValue() вы можете обернуть значение и пометить его изменения как менее важные и, следовательно, отложить повторный рендеринг. useDeferredValue() будет возвращать предыдущее значение до тех пор, пока есть более срочные обновления для завершения и отображения дерева с обновленным значением.

Как использовать useDeferredValue() ?

function ProductsList({ results }) {
 // deferredResults получат обновленные данные
 // когда завершатся срочные обновления
 const deferredResults = useDeferredValue(results);
 
 return (
   <ul>
     {deferredResults.map((product) => (
       <li key={product.id}>{product.title}</li>
     ))}
   </ul>
 );
}

Итоги

Эти два новых хука позволяют сделать интерфейсы максимально отзывчивыми, даже в сложных приложениях с большим количеством повторных рендерингов, отдавая приоритет обновлениям, которые имеют решающее значение для взаимодействия с пользователем, и помечая некоторые другие как менее важные. Это не означает, что нужно оборачивать все состояния этими хуками. Их следует использовать в крайнем случае, если приложение или компоненты не могут быть оптимизированы другими способами (например, при помощи lazy loading’а, пагинации, веб-воркеров и т. д.).