Функциональное программирование на JavaScript

Функциональное программирование (часто сокращенно FP), это просто еще одна парадигма программирования, такая же как объектно-ориентированное программирование и процедурное программирование. Это означает, что это способ мышления о создании программного обеспечения с новыми концепциями, методами и принципами, которым необходимо следовать. Конечно, мы собираемся изучить их позже в этом посте. Но пока не позволяйте всем новым концепциям отпугнуть вас.

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

Есть языки, которые имеют сильную ориентацию на функциональное программирование, такие как Clojure и Haskell. Однако Javascript не является полностью функциональным языком, ориентированным на программирование. Тем не менее, в этом посте я покажу вам, как мы можем использовать FP в JavaScript, и, самое главное, покажу преимущества, которые он представляет.

Действительно, JavaScript — это язык с несколькими парадигмами. Да, я знаю, JavaScript великолепен.

Кроме того, FP — горячая тема, так как вам нужно ее понимать, если вы действительно хотите освоить такие библиотеки, как React.js и Redux.js.

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

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

Функции высокого порядка

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

var numbers = [1,2,3,4];
var calculatedNumbers = numbers.map(number => number *2);
setTimeOut(function(){
 console.log(calculatedNumbers);
});

Функции map и setTimeOut являются функциями высокого порядка, обе принимают функцию в качестве аргумента и что-то делают с ней внутри. То же самое относится к тем функциям, которые возвращают функцию. Важной особенностью функций высокого порядка является то, что они являются универсальными, поскольку в JavaScript есть первоклассные функции, что позволяет нам обращаться с функциями как с данными. Мы можем передать функцию в качестве аргумента, внутренней реализацией может быть весь мир, если мы захотим. Этот метод способствует реализации дженериков, поскольку он не тесно связан с конкретной структурой данных.

Функциональная композиция

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

Обычный способ решить это:

function getHtmlSpanInUpperCase(str){
     return `<span>${str.trim().toUpperCase()}</span>`;
 }

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

var trim = str => str.trim();
var toUpperCase = str => str.toUpperCase();
var wrapIntoSpan = str => `<span>${str}</span>`;

console.log(wrapIntoSpan(toUpperCase(trim("JavaScript"))));

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

Создание труб

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

Итак, чтобы правильно работать с композицией функций, в JavaScript есть популярная библиотека, которая может нам помочь. Lodash — это современная служебная библиотека JavaScript, она чрезвычайно мощная, и я настоятельно рекомендую вам ознакомиться с ее документацией позже.

В Lodash есть функция с именем pipe, которая позволяет нам очень легко создавать композицию функций. Соответственно, для нашего последнего примера решение с использованием Lодаш будет таким:

import {pipe} from 'lodash';
const transform = pipe(trim, toUpperCase, wrapInSpan);
transform("JavaScript");

Очень просто, очень организованно, очень красиво. Когда мы используем функцию конвейера Lodash. Порядок выполнения читается слева направо, что означает, что сначала будет вызываться trim, а затем toUpperCase, и, наконец, wrapInSpan. Кроме того, аргумент, передаваемый в toUpperCase, является возвращаемым значением trim, а аргумент, передаваемый в wrapInSpan, является возвращаемым значением toUpperCase и так далее. Аргумент, переданный trim, — это тот, который мы передали при первом вызове функции преобразования. Конечно, если вы измените порядок композиции, вывод изменится.

В функциональном составе важен порядок операций.

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

карри

Это не имеет ничего общего с едой, это про Haskell Curry. Мы обязаны ему этой техникой, называемой каррированием. Пока что все наши функции принимают только один аргумент, а возвращаемый результат функции передается в качестве аргумента следующей функции в конвейере. Но что произойдет, если у вас есть функции, которые принимают nаргументов?

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

Используя тот же пример ниже, скажем, теперь нам нужно обернуть строку в любой HTML-тег, а не только в тег span, но не будет ли это причудливым подходом, если мы создадим функцию для каждого HTML-тега, верно? Вместо этого мы можем создать функцию, которая принимает имя тега в качестве аргумента!

var wrapInHtmlTag = (str, tagname) => `<${tagname}>${str}</${tagname}>`;

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

Следовательно, мы должны указать эти аргументы явно:

import {pipe} from 'lodash';
const transform = pipe(trim, toUpperCase, wrapInHtmlTag("div"));
transform("JavaScript");

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

Итак, как мы можем это решить? Вместо этого верните функцию! Но как? Ну, мы используем карри. Чтобы этот конвейер работал правильно, нам нужно каррировать функцию wrapInHtmlTag. Позвольте мне показать, как:

var wrapInHtmlTag = str => tagname =>`<${tagname}>${str}</${tagname}>`;

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

function wrapInHtmlTag(str){
    return function(tagName){
        return `<${tagName}>${str}</${tagName}>`;
    }
 }

Итак, все, что я сделал, это вернул функцию в моем текущем wrapInHtmlTag, и каждая внутренняя функция может принимать только один аргумент, и, наконец, использовать все вложенные аргументы в последней определенной функции для вычисления чего-либо, это проще чем это звучит правильно? Я хочу, чтобы вы обратили внимание на то, что произошло после того, как мы применили каррирование:

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

Помните, я говорил вам ранее, что каррирование позволяет нам передавать аргументы, разделенные круглыми скобками, а не запятыми? Это именно то, что происходит при вызове wrapInHtmlTag("div") в функции канала, которую мы объявили ранее. Потому что теперь мы можем сделать что-то вроде этого:

console.log(wrapInHtmlTag("javascript")("span"));

Ты видишь? Мы не вызываем функцию, как обычно, аргументы передаются через круглые скобки. Под капотом, когда выполняется console.log, элемент управления вызывает wrapInHtmlTag только с «javascript» в качестве аргумента, и сразу же, когда это выполнение заканчивается, элемент управления вызывает возвращенную функцию с «span» как аргумент, приводящий к результату. Тот же механизм происходит внутри конвейеров с использованием функции Lodash pipe. Если вы ниндзя-разработчик, вы заметили, что каррирование — это комбинация функций высокого порядка и композиции функций.

Чистые функции

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

function add2(number){
	return number +2;
}
add2(2); // 4
add2(10); // 12

В любом случае, если мы пропустим 2, мы всегда получим в результате 4. Неважно, как мы вызываем эту функцию, всегда один и тот же аргумент будет давать один и тот же результат.

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

Побочные эффекты

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

  • Изменение любой внешней переменной или свойства объекта (например, глобальной переменной или переменной в цепочке областей видимости родительской функции)
  • Запись в файл
  • Запуск сетевой операции
  • Запуск любого внешнего процесса
  • Вызов любых других функций с побочными эффектами

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

Преимущества чистых функций

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

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

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

  1. Те же аргументы, тот же результат. Внутри мы не можем использовать случайные значения, текущую дату, глобальные значения или, более конкретно, значения, которые могут легко измениться во время выполнения. Например, если у вас есть функция, которая вычисляет разницу в миллисекундах между определенной датой и текущей датой. Текущая дата всегда будет разной. Поэтому результат всегда будет отличаться от одного и того же аргумента.
  2. Чистые функции самодокументируются
  3. Чистые функции не могут изменять аргументы, они должны остаться чистыми
  4. Чистые функции просты, максимально просты.
  5. Чистые функции не имеют побочных эффектов в отношении внешнего состояния.
  6. Все необходимые данные для чистой функции для вычисления чего-либо должны быть переданы в качестве аргументов, когда не выходите за пределы функции, чтобы получить эти данные.

Если вам интересно получить более глубокое и подробное объяснение чистой функции, я рекомендую вам прочитать эту статью на Medium.

Изменяемые и неизменяемые данные

Эта концепция идет рука об руку с чистыми функциями. Мы должны гарантировать, что аргументы, переданные в pure, останутся в своем начальном состоянии.

Поскольку JavaScript не является полностью функциональным языком программирования, у нас есть изменяемые структуры, подобные массивам и объектам. И что я имею в виду под изменяемым? Хорошо взгляните на этот код:

var str = "JavaScript IS great";
var loweredCaseStr = str.toLowerCase();
console.log(A- `${str} B - ${loweredCaseStr}`); // A - JavaScript IS great B - javascript is great

Обратите внимание, что переменная str остается с исходным значением формата. Даже после того, как мы использовали его для инициализации переменной loweredCaseStr. Ну, строки в JavaScript, как и во многих других языках, неизменяемы. Это означает, что данные не могут быть изменены после создания. Таким образом, каждый раз, когда неизменяемый тип претерпевает изменения, под капотом JavaScript (используя движок V8 в качестве ссылки) создает новое выделение стека памяти с новым значением и заменяет старую связанную ссылку на память стека. Этот механизм позволяет нам изменить значение неизменяемого типа данных, но на самом деле происходит то, что под капотом оно полностью заменяется новым значением и ссылкой на память.

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

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

Давайте посмотрим, как изменяемые типы работают в JavaScript:

var customer = {
    name: "John"
}
function addLastNameToCustomer(customer){
    customer.lastName = "Carter";
}
addLastNameToCustomer(customer);
console.log(customer); //{ name: 'John', lastName: 'Carter' }

Поскольку объекты в JavaScript изменяемы. Когда функция addLastNameToCustomer принимает клиента в качестве аргумента, под капотом JavaScript использует стек для получения указателя кучи и изменения значения, изменяя объект в том же выделении памяти. Это прекрасно, но с точки зрения чисто функций это недопустимо, потому что аргументы не могут видоизменяться. Так как же нам работать с изменяемыми типами и чистыми функциями в JavaScript?

Есть несколько способов работы с изменяемыми типами в JavaScript. Если вы хотите, чтобы решения с внешними зависимостями были сведены к минимуму, вам следует использовать встроенный способ сделать это, используя подходы оператор расширения или Object.assing(). Я не буду писать о них в этом посте. Однако, если вам интересно, я призываю вас прочитать этот пост. Он дает полное объяснение оператора распространения, Object.assing() и мелкой копии.

Но, если вы не жалуетесь на использование внешних зависимостей, как я, позвольте мне показать вам Immutable.js и Immer.js. Обе библиотеки предназначены для работы с изменяемыми данными и призваны решить множество головных болей при работе с изменяемыми данными в JavaScript.

Immutable.js создан Facebook, отлично работает, требует небольшого обучения и более популярен, чем immer. Однако, по сравнению с Immer.js, Immutable.js, на мой взгляд, слишком многословен. Я люблю Immer.js, потому что его использование проще и позволяет мне более естественно работать с изменяемыми данными.

Итак, соответственно тому же примеру ниже. Это был бы правильный способ сделать нашу функцию addLastNameToCustomer чистой функцией с использованием Immer.js:

import { produce } from "immer";
 function addLastNameToCustomer(customer){
     return produce(customer, draft =>{
        draft.lastName = "Carter";
     });
}

Функция produce из Immer.js выполняет всю грязную работу. Первый аргумент — это наш источник, а второй — функция, выполняемая Immer для внесения необходимых изменений. Внутри Immer.js создает новый объект и предотвращает мутацию исходного объекта. Под капотом это новое распределение памяти. В большинстве случаев мы будем работать с нашими изменяемыми объектами с подходом.

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

Но есть и темная сторона. Основной проблемой, связанной с неизменностью, является выделение памяти, как я упоминал ранее, для этого требуется новый стек или выделение стека и кучи. Работа с большими неизменяемыми наборами данных может привести наше приложение к накладным расходам памяти и утечкам памяти. Я поддерживаю подход Immutable.js или Immer.js, потому что внутри они реализуют технику под названием Структурное совместное использование, чтобы уменьшить пространство для этих последствий темной стороны и улучшить использование памяти.

Const не является неизменностью

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

Если вы хотите создать неизменяемый объект, вам следует использовать метод Object.freeze():

const lang = Object.freeze({
  name: 'JavaScript'
});
lang.name = 'Csharp';
// Error: Cannot assign to read only property 'name' of object Object

Реализация заморозки объекта для создания неизменяемых данных в JS

Однако замороженные объекты внешне неизменяемы. Потому что примитивные свойства верхнего уровня замороженного объекта не могут измениться, но любое свойство, которое также является объектом (включая массивы и т. д.), все еще может быть изменено. Таким образом, даже замороженные объекты не являются неизменяемыми, если вы не пройдете все дерево объектов и не заморозите каждое свойство объекта. Например:

const lang = Object.freeze({
  name: 'JavaScript', 
  versions: Object.freeze({
  	id: 1
  })
});

Общие состояния

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

const a = {
    amount: 3
}
const actionA = () => a.amount += 1;
const actionB = () => a.amount *= a.amount;
actionA();
actionB();
console.log(a); // 16

Здесь actionA и actionB имеют внутреннюю ссылку на a.amount, они изменяют свойство напрямую (одна и та же точка памяти), это пример общего состояния. Проблема с этой реализацией заключается в том, что изменение порядка вызова функций может привести к каскаду сбоев, поскольку функции, которые воздействуют на общее состояние, зависят от времени. Посмотрим, то же самое определение, но теперь поменяв порядок выполнения:

const a = {
    amount: 3
}
const actionA = () => a.amount += 1;
const actionB = () => a.amount *= a.amount;
actionB();
actionA();
console.log(a); // 10

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

const a = {
    amount: 3
  };
  
  const actionA = x => Object.assign({}, x, { amount: x.amount + 1});
  
  const actionB = x => Object.assign({}, x, { amount: x.amount * x.amount});
  
  console.log(actionA(actionB(a)).amount); // 10
  actionB(a);
  console.log(a); // {amount: 3}
  actionA(a);
  console.log(actionA(actionB(a)).amount); // 10

Это немного отличается, и здесь произошло несколько вещей, давайте рассмотрим их шаг за шагом:

  1. actionA и actionB теперь являются чистыми функциями, мы удалили общее состояние, предотвратив изменение аргумента. Мы применили принцип неизменности, избегая принципа общего состояния и одних и тех же аргументов, одного и того же принципа результата.
  2. Теперь мы можем вызывать функции в другом порядке и получать тот же результат. Взгляните перед console.log(a), я сначала вызвал actionB. Когда вы избегаете общего состояния, время и порядок вызовов функций не меняют результат вызова функции.
  3. Мы создаем правильную композицию функций, потому что все составляющие функции являются чистыми.
  4. Мы убрали побочные эффекты, что делает вызовы функций полностью независимыми от других вызовов функций, что может радикально упростить изменения и рефакторинг.

Вывод

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

  • Функции высокого порядка.
  • Чистые функции.
  • Композиция функций над императивным управлением потоком. Помните о каррировании функций, которые принимают более одного аргумента.
  • Избегайте общего состояния.
  • Избегайте мутирующего состояния.
  • Избегайте побочных эффектов.
  • FP — это парадигма декларативного программирования, она отдает предпочтение тому, что делать, а не тому, как это делать.

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

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

Имея это в виду, я надеюсь, что вы узнали, как работает FP и как применять его с помощью JavaScript.

Надеюсь, вам понравилось читать этот пост. Спасибо за чтение, и пусть шансы будут на вашей стороне,