Разлагающаяся композиция

Функциональные библиотеки, такие как Ramda.js, великолепны и дают нам очень мощную, полезную и простую функциональность. Но они из тех вещей, о которых вы можете не знать, что они вам нужны, если только вы не знаете, что они вам нужны.

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

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

Определение проблемы

Возьмем абсурдный пример, перевернув строку. Это урок, который мы видим во всех видах вводных уроков, и шаги довольно просты:

  1. превратить строку в массив букв,
  2. перевернуть массив букв,
  3. воссоединить перевернутый массив обратно в строку,
  4. вернуть перевернутую (преобразованную) строку.

Легко следовать и легко писать. Идеальное введение в методы основных объектов в JavaScript.

Шаг 1

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

расточительно почему? Из-за цепочки методов. Когда мы вызываем String.prototype.split(), это возвращает массив, и мы можем напрямую связать его. Array.prototype.reverse() действует на массив и изменяет его на месте, возвращая тот же массив, а Array.prototype.join() возвращает строку, которую мы возвращаем. Таким образом, мы можем вызывать каждый из этих методов для возвращаемого результата, не прибегая к промежуточным переменным.

Шаг 2

И это делает все четыре шага в одной строке. Красота! Обратите внимание на порядок вызываемых там функций — split строка, reverse массив, join массив.

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

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

Предшаг 3

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

Шаблон того, что мы собираемся сделать, в математическом смысле может выглядеть примерно так:

// given functions f, g, and h, and a data point x:
return f( g( h( x ) ) )

Мы берем значение x, помещаем его в функцию h (получаем «h из x»), а затем берем из нее возвращенное значение и вставляем его в g (получаем «g из h из x»), а затем берем вернул оценку из этого и поместил ее в f (получив «f из g из h из x»).

Это имеет смысл, но мне больно думать, что f и g, и h, и x причиняют боль моей маленькой головке. Давайте сделаем это немного более конкретным.

Шаг 3

В этом немного больше мяса, и потребуется некоторое объяснение, чтобы полностью понять, что происходит.

Во-первых, прежде чем мы сделаем reverseString, мы хотим превратить эти методы Array или String в составные функции. Мы сделаем некоторые каррированные функции, потому что кто не любит абстракцию?

  • splitOn — это абстрактная оболочка для метода String.prototype.split, принимающая в качестве первого параметра строку, на которую мы будем разбивать.
  • joinWith — это абстрактная оболочка для метода Array.protoype.join, принимающая в качестве первого параметра строку, которую мы будем использовать для нашего соединения.
  • reverse не принимает никаких параметров, но сама по себе превращает Array.prototype.reverse в компонуемую функцию. Обратите внимание, что нам нужно сделать копию массива, так как Array.prototype.reverse() — это изменение на месте.

Теперь, в нашем reverseString, первым шагом будет частичное применение этих двух абстрактных функций. Мы говорим split, что это ссылка на splitOn(''), мы говорим join, что это ссылка на join.with(''), и тогда у нас есть все части, необходимые для объединения трех функций в один вызов.

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

// given string, call split, then call reverse, then call join
return string.split('').reverse().join('');

В функциональных кругах это считается «трубчатым» порядком. Этот термин пришел из мира Unix/Linux и ведет к еще одной «кроличьей норе».

Наш последний код читается не слева направо, а изнутри наружу:

return join(
         reverse(
           split(
             string
           )
         )
       )

Поэтому, если мы читаем их в том же порядке слева направо, join, reverse, split, мы выполняем их точно назад от этого. Это будет считаться «составленным» порядком, и теперь мы собираемся отправиться в составную-функциональную-страну!

Предварительный шаг 4

Вот тут-то и начинается веселье. Первое, что нужно запомнить: функции в JavaScript — это просто еще один вид данных — и спасибо, Дэну Абрамову, за ментальные модели из JustJavascript!. В JavaScript мы можем передавать их, мы можем хранить их в массивах или объектах, мы можем манипулировать ими интересными и увлекательными способами… и мы можем их комбинировать. И это как раз то, что мы будем делать.

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

Шаг 4

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

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

Это именно то, куда мы идем. Мы берем массив инструкций, используя workingValue в качестве начального «аккумулятора» этого массива, и сводим workingValue к окончательной оценке каждой из этих инструкций, применяя workingValue каждый раз. Именно для этого и предназначен Array.prototype.reduce, и он прекрасно работает. Давай туда дальше!

Шаг 5

Здесь мы взяли этот императивный цикл for и сделали его декларативным оператором reduce. Мы просто говорим JavaScript: «уменьшите исходное workingValue, применяя к нему по очереди все instruction. Это гораздо более структурированный способ кодирования, и если мы хотим, мы всегда можем добавить, изменить, переставить instructions, не нарушая работу вызова функции reduce. Он просто видит инструкции и выполняет инструкции. Красивая вещь.

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

Предварительный шаг 6

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

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

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

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

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

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

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

Если вы посмотрите на эти две функции, то увидите, что единственная разница между ними заключается в том, что pipe использует fns.reduce(), а compose использует fns.reduceRight(). В остальном ничего не изменилось. Мы могли бы легко протестировать их, если бы захотели:

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

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

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

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

Резюме

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

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

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

Создавайте компонуемые веб-приложения

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

Перенесите свою команду в Bit Cloud, чтобы совместно размещать и совместно работать над компонентами, а также значительно ускорить, масштабировать и стандартизировать разработку в команде. Начните с компонуемых интерфейсов, таких как Design System или Micro Frontends, или исследуйте компонуемый сервер. Попробуйте →

Узнать больше