Русский
Русский
English
Статистика
Реклама

Fp

Перевод Почему я считаю Haskell хорошим выбором с точки зрения безопасности ПО?

31.05.2021 18:12:13 | Автор: admin


Команда Typeable понимает ценность безопасности. Мы любим Haskell, но стоит ли его выбирать, если ваша цель создание защищенного программного обеспечения? Хотелось бы сказать да, но как и для большинства эмпирических вопросов о разработке ПО, здесь просто нет объективного доказательства, подтверждающего, что Haskell или ещё какой-нибудьй язык программирования обеспечивает большую безопасность, чем любой другой. Нельзя сказать, что выбор языка в Typeable не имеет значения для безопасности, но какое именно значение он имеет, еще нужно подумать.


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


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


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

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


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


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


В таких сервисах обычно есть соблазн записать файл пользователя непосредственно в файловую систему сервера. Однако под каким именем файла? Использовать непосредственно имя файла пользователя верный путь к катастрофе, так как оно может выглядеть как ../../../etc/nginx/nginx.conf, ../../../etc/passwd/ или любые другие файлы, к которым сервер имеет доступ, но не должен их изменять.


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


Использование шкалы


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


В идеале современный инструментарий должен практически полностью устранять чисто технические уязвимости. Например, большинство современных языков, таких как Haskell, C# и Java, по большей части обеспечивают защиту содержимого памяти и в целом предотвращают переполнение буфера, попытки дважды освободить одну и ту же ячейку, а также другие технические проблемы. Однако от правильного инструментария можно получить еще больше пользы. Например, легко представить себе систему, в которой имеется техническая возможность разделить абсолютный и относительный пути к файлу, что упрощает контроль атак с обходом каталога (path traversal), таких как загрузка пользователем файла поверх какого-нибудь важного конфигурационного файла системы.


Haskell нижняя часть шкалы


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


// From imaginary CSRF token protection:if ($tokenHash == $hashFromInternet->{'tokenHash'}) {  echo "200 OK - Request accepted", PHP_EOL;}else { echo "403 DENIED - Bad CSRF token", PHP_EOL;};

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


Аналогичная проблема возникла с Java (и другим языками, см. https://frohoff.github.io/appseccali-marshalling-pickles/). Java предложил исключительно удобный способ сериализации любого объекта на диск и восстановления этого объекта в исходной форме. Единственной досадной проблемой стало отсутствие способа сказать, какой объект вы ждете! Это позволяет злоумышленникам пересылать вам объекты, которые после десериализации в вашей программе превращаются во вредоносный код, сеющий разрушения и крадущий данные.


Это не значит, что вы не можете создать безопасный код на PHP или не можете получить такие же ошибки в Haskell, однако по своей природе Haskell не склонен к таким уязвимостям. Если переложить приведенный выше пример кода на Haskell, он будет выглядеть примерно так:


data Request = Request {csrfToken :: Token, ... other fields}doSomething :: Session -> Request -> Handler ()doSomething session request  | csrfToken session == csrfToken request = ... do something  | otherwise = throwM BadCsrfTokenError

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


Haskell середина шкалы


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


Прежде всего, в Haskell имеется возможность моделировать данные более точно по сравнению с такими языками, как как C, Javascript или даже Java. В основном это обусловлено удобством его синтаксиса и наличием типов-сумм. Точное моделирование данных имеет значение для безопасности, поскольку код домена в основном представляет собой модель некоторого реального явления. Чем меньше ее точность, тем больше возможностей имеют злоумышленники.


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


data SSN = Unknown | Redacted | SSN Text

А теперь сравним моделирование той же идеи с использованием строковых величин "", "<REDACTED>" и "191091C211A". Что произойдет, если пользователь введет "<REDACTED>" в поле ввода SSN? Может ли это в дальнейшем привести к проблеме? В Haskell об этом можно не беспокоиться.


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


storeFileUpload :: Path Abs File -> ByteString -> IO ()storeFileUpload path = ...

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


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


Haskell и ошибки домена


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


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


Однако это все догадки. Сообщество Haskell до сих пор достаточно мало, чтобы не быть объектом атак, а специалисты по Haskell в общем случае еще не так сильно озабочены проблемами безопасности, как разработчики на Javascript или Python.


Заключение


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

Подробнее..

Функциональное программирование на TypeScript полиморфизм родов высших порядков

01.11.2020 20:22:23 | Автор: admin

Привет, Хабр! Меню зовут Юрий Богомолов, и вы (возможно) можете меня знать по моей работе над серией #MonadicMondays в твиттере, по каналу на ютьюбе или статьям на Medium или dev.to. В русскоязычном сегменте интернета очень мало информации по функциональному программированию на TypeScript и одной из лучших экосистем для этого языка библиотеке fp-ts, в экосистему которой я достаточно активно контрибьютил некоторое время назад. Этой статьей я хочу начать рассказ о ФП на TypeScript, и если будет положительный отклик от хабрасообщества, то продолжу серию.


Думаю, ни для кого не станет откровением то, что TypeScript является одним из самых популярных надмножеств JS со строгой типизацией. После включения строгого режима компиляции и настройки линтера на запрет использования any этот язык становится пригодным для промышленной разработки во многих сферах от CMS до банковского и брокерского ПО. Для системы типов TypeScript были даже неофициальные попытки доказательства полноты по Тьюрингу, что позволяет применять продвинутые техники тайп-левел программирования для обеспечения корректности бизнес-логики при помощи техник making illegal states unrepresentable.


Всё вышеперечисленное дало толчок к созданию для TypeScript замечательной библиотеки для функционального программирования fp-ts за авторством итальянского математика Джулио Канти. Одна из первых вещей, с которой сталкивается человек, желающий ее освоить, весьма специфичные определения типов вида Kind<URI, SomeType> или interface SomeKind<F extends URIS> {}. В этой статье я хочу подвести читателя к пониманию всех этих сложностей и показать, что на самом деле всё очень просто и понятно стоит только начать раскручивать этот паззл.


Роды высшего порядка


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


Возьмем всем привычный и хорошо изученный массив. Массив, как и список, это структура данных, выражающая идею недетерминированности: в нем может храниться от 0 до N элементов определенного типа A. При этом, если у нас есть функция вида A -> B, мы можем попросить этот массив применить ее с помощью вызова метода .map(), получив на выходе массив того же размера с элементами типа B, следующими в том же порядке, что и в оригинальном массиве:


const as = [1, 2, 3, 4, 5, 6]; // as :: number[]const f = (a: number): string => a.toString();const bs = as.map(f); // bs :: string[]console.log(bs); // => [ '1', '2', '3', '4', '5', '6' ]

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


interface MappableArray {  readonly map: <A, B>(f: (a: A) => B) => (as: A[]) => B[];}

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


type MapForSet   = <A, B>(f: (a: A) => B) => (as: Set<A>) => Set<B>;type MapForMap   = <A, B>(f: (a: A) => B) => (as: Map<string, A>) => Map<string, B>;type MapForTree  = <A, B>(f: (a: A) => B) => (as: Tree<A>) => Tree<B>;type MapForStack = <A, B>(f: (a: A) => B) => (as: Stack<A>) => Stack<B>;

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


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


interface Mappable<F> {  // Type 'F' is not generic. ts(2315)  readonly map: <A, B>(f: (a: A) => B) => (as: F<A>) => F<B>;}

К сожалению, этот код не скомпилируется, потому что TypeScript не знает, что тип-аргумент F должен быть дженериком. Мы не можем написать как в Scala F<_> или как-либо еще в языке просто нет выразительных средств для этого. Значит ли это, что нужно опустить руки и дублировать код? Нет, на выручку приходит замечательная академическая статья Легковесный полиморфизм родов высшего порядка.


Легковесный полиморфизм родов высшего порядка


Для того, чтобы сэмулировать в TypeScript полиморфизм родов, мы применим технику, которая называется дефункционализация техника перевода программ высшего порядка на язык первого порядка. Проще говоря вызовы функций превращаются в вызов конструкторов данных с аргументами, соответствующими аргументам функций. В дальнейшем такие конструкторы сопоставляются с образцом (pattern-matching) и интерпретируются уже по месту необходимости. Для тех, кто захочет глубже разобраться в теме, советую оригинальную статью Джона Рейнолдса Definitional interpreters for higher-order programming languages, а мы тем временем посмотрим, как эту технику можно применить к эмуляции родов.


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


  1. Тип-переменную F заменим на уникальный идентификатор типа некий строковый литерал, который будет однозначно указывать, какой конструктор типа мы хотим вызвать: 'Array', 'Promise', 'Set', 'Tree' и так далее.
  2. Создадим служебный тип-конструктор Kind<IdF, A>, который будет представлять собой вызов типа F как дженерика с аргументом типа A: Kind<'F', A> ~ F<A>.
  3. Для упрощения интерпретации конструкторов Kind заведем набор типов-словарей, где будут храниться соотношения между идентификатором типа и самим полиморфным типом по одному такому словарю для типов каждой арности.

Посмотрим, как это выглядит на практике:


interface URItoKind<A> {  'Array': Array<A>;} // словарь для типов 1-арности: Array, Set, Tree, Promise, Maybe, Task...interface URItoKind2<A, B> {  'Map': Map<A, B>;} // словарь для типов 2-арности: Map, Either, Bifunctor...type URIS = keyof URItoKind<unknown>; // тип-сумма всех имён типов 1-арностиtype URIS2 = keyof URItoKind2<unknown, unknown>; // все типы 2-арности// и так далее, сколько сочтете нужнымtype Kind<F extends URIS, A> = URItoKind<A>[F];type Kind2<F extends URIS2, A> = URItoKind2<A>[F];// и так далее

Остается дело за малым: дать возможность пополнять словари URItoKindN любым программистам, а не только авторам библиотеки, в которой применяется эта техника. И тут на выручку приходит замечательная возможность TypeScript, которая называется дополнением модулей (module augmentation). Благодаря ней нам достаточно будет разместить код с дефункционализированными родами в основной библиотеке, а из пользовательского кода определение типа высшего порядка будет простым:


type Tree<A> = ...declare module 'my-lib/path/to/uri-dictionaries' {  interface URItoKind<A> {    'Tree': Tree<A>;  }}type Test1 = Kind<'Tree', string> // сразу же выведется в Tree<string>

Обратно к Mappable


Теперь мы можем определить наш тип Mappable по-настоящему полиморфно для любых 1-арных конструкторов, и реализовать его экземпляры для разных структур данных:


interface Mappable<F extends URIS> {  readonly map: <A, B>(f: (a: A) => B) => (as: Kind<F, A>) => Kind<F, B>;}const mappableArray: Mappable<'Array'> = {  // здесь `as` будет иметь тип A[], без какого-либо упоминания служебного конструктора `Kind`:  map: f => as => as.map(f)};const mappableSet: Mappable<'Set'> = {  // немного нечестно  можно сделать эффективнее, перебирая итератор для множества вручную,  // но цель этой статьи не сделать максимально эффективную реализацию, а объяснить концепцию  map: f => as => new Set(Array.from(as).map(f))};// здесь я предположу, что Tree  обычный индуктивный тип с двумя конструкторами: листом и узлом,// в листах хранятся данные, в узлах хранится набор поддеревьев:const mappableTree: Mappable<'Tree'> = {  map: f => as => {    switch (true) {      case as.tag === 'Leaf': return f(as.value);      case as.tag === 'Node': return node(as.children.map(mappableTree.map(f)));    }  }};

Наконец, я могу сорвать маску с типа Mappable и сказать, что он называется Functor. Функтор состоит из типа T и операции fmap, которая позволяет при помощи функции A => B преобразовать T<A> в T<B>. Еще можно сказать, что функтор поднимает функцию A => B в некий вычислительный контекст T (этот взгляд очень пригодится в дальнейшем, когда будем разбирать тройку Reader/Writer/State).


Экосистема fp-ts


Собственно, идея дефункционализации и легковесного полиморфизма родов высшего порядка стала ключевой для библиотеки fp-ts. Джулио написал прагматичный и лаконичный гайд о том, как определять свои типы высшего порядка: https://gcanti.github.io/fp-ts/guides/HKT.html. Поэтому нет нужды каждый раз применять дефункциолизацию в своих программах достаточно подключить fp-ts и положить идентификаторы типов в словари URItoKind/URItoKind2/URItoKind3, находящиеся в модуле fp-ts/lib/HKT.


В экосистеме fp-ts есть много замечательных библиотек:


  • io-ts библиотека для написания рантайм-валидаторов типов с синтаксисом, максимально близким к синтаксису типов TS
  • parser-ts библиотека парсерных комбинаторов, эдакий parsec на минималках
  • monocle-ts библиотека для функциональной оптики, порт скаловской библиотеки monocle на TS
  • remote-data-ts библиотека с контейнерным типом RemoteData, существенно упрощающим безопасную обработку данных на фронте
  • retry-ts библитека с комбинаторами разных стратегий повтора монадических операций
  • elm-ts микро-фреймворк для программирования в духе Elm Architecture на TS
  • waveguide, matechs-effect системы очень мощных алгебраических эффектов для TS, вдохновленных ZIO

Ну и мои библиотеки из ее экосистемы:


  • circuit-breaker-monad паттерн Circuit Breaker с монадическим интерфейсом
  • kleisli-ts библиотека для программирования с помощью стрелок Клейсли, вдохновленная ранним дизайном ZIO
  • fetcher-ts враппер вокруг fetch, поддерживающий валидацию ответа сервера с помощью типов io-ts
  • alga-ts порт замечательной библиотеки для описания алгебраических графов alga на TS



На этом введение я бы хотел завершить. Напишите, пожалуйста, в комментариях, насколько такой материал интересен лично вам. Я сделал уже несколько итераций преподавания этого материала, и каждый раз находил моменты, которые можно улучшить. Учитывая техническую продвинутость аудитории Хабра, возможно, нет смысла объяснять на Mappable/Chainable и т.д., а сразу называть вещи своими именами функтор, монада, аппликатив? Пишите, буду рад пообщаться в комментариях.

Подробнее..

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

28.02.2021 16:06:27 | Автор: admin

Предыдущие статьи цикла:


  1. Полиморфизм родов высших порядков
  2. Паттерн класс типов



В предыдущей статье мы рассмотрели понятие класса типов (type class) и бегло познакомились с классами типов функтор, монада, моноид. В этой статье я обещал подойти к идее алгебраических эффектов, но решил всё-таки написать про работу с nullable-типами и исключительными ситуациями, чтобы дальнейшее изложение было понятнее, когда мы перейдем к работе с задачами (tasks) и эффектами. Поэтому в этой статье, всё еще рассчитанной на начинающих ФП-разработчиков, я хочу поговорить о функциональном подходе к решению некоторых прикладных проблем, с которыми приходится иметь дело каждый день.


Как всегда, я буду иллюстрировать примеры с помощью структур данных из библиотеки fp-ts.


Стало уже некоторым моветоном цитировать Тони Хоара с его ошибкой на миллиард введению в язык ALGOL W понятия нулевого указателя. Эта ошибка, как опухоль, расползлась по другим языкам C, C++, Java, и, наконец, JS. Возможность присвоения переменной любого типа значения null приводит к нежелательным побочным эффектам при попытке доступа по этому указателю среда исполнения выбрасывает исключение, поэтому код приходится обмазывать логикой обработки таких ситуаций. Думаю, вы все встречали (а то и писали) лапшеобразный код вида:


function foo(arg1, arg2, arg3) {  if (!arg1) {    return null;  }  if (!arg2) {    throw new Error("arg2 is required")  }  if (arg3 && arg3.length === 0) {    return null;  }  // наконец-то начинается бизнес-логика, использующая arg1, arg2, arg3}

TypeScript позволяет снять небольшую часть этой проблемы с флагом strictNullChecks компилятор не позволяет присвоить не-nullable переменной значение null, выбрасывая ошибку TS2322. Но при этом из-за того, что тип never является подтипом всех других типов, компилятор никак не ограничивает программиста от выбрасывания исключения в произвольном участке кода. Получается до смешного нелепая ситуация, когда вы видите в публичном API библиотеки функцию add :: (x: number, y: number) => number, но не можете использовать её с уверенностью из-за того, что её реализация может включать выбрасывание исключения в самом неожиданном месте. Более того, если в той же Java метод класса можно пометить ключевым словом throws, что обяжет вызывающую сторону поместить вызов в try-catch или пометить свой метод аналогичной сигнатурой цепочки исключений, то в TypeScript что-то, кроме (полу)бесполезных JSDoc-аннотаций, придумать для типизации выбрасываемых исключений сложно.


Также стоит отметить, что зачастую путают понятия ошибки и исключительной ситуации. Мне импонирует разделение, принятое в JVM-мире: Error (ошибка) это проблема, от которой нет возможности восстановиться (скажем, закончилась память); exception (исключение) это особый случай поток исполнения программы, который необходимо обработать (скажем, произошло переполнение или выход за границы массива). В JS/TS-мире мы выбрасываем не исключения, а ошибки (throw new Error()), что немного запутывает. В последующем изложении я буду говорить именно об исключениях как о сущностях, генерируемых пользовательским кодом и несущими вполне конкретную семантику исключительная ситуация, которую было бы неплохо обработать.

Функциональные подходы к решению этих двух проблем ошибки на миллиард и исключительных ситуаций мы сегодня и будем рассматривать.


Option<A> замена nullable-типам


В современном JS и TS для безопасной работы с nullable-типам есть возможность использовать optional chaining и nullish coalescing. Тем не менее, эти синтаксические возможности не покрывают всех потребностей, с которыми приходится сталкиваться программисту. Вот пример кода, который нельзя переписать с помощью optional chaining только путём монотонной работы с if (a != null) {}, как в Go:


const getNumber = (): number | null => Math.random() > 0.5 ? 42 : null;const add5 = (n: number): number => n + 5;const format = (n: number): string => n.toFixed(2);const app = (): string | null => {  const n = getNumber();  const nPlus5 = n != null ? add5(n) : null;  const formatted = nPlus5 != null ? format(nPlus5) : null;  return formatted;};

Тип Option<A> можно рассматривать как контейнер, который может находиться в одном из двух возможных состояний: None в случае отсутствия значения, и Some в случае наличия значения типа A:


type Option<A> = None | Some<A>;interface None {  readonly _tag: 'None';}interface Some<A> {  readonly _tag: 'Some';  readonly value: A;}

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


import { Monad1 } from 'fp-ts/Monad';const URI = 'Option';type URI = typeof URI;declare module 'fp-ts/HKT' {  interface URItoKind<A> {    readonly [URI]: Option<A>;  }}const none: None = { _tag: 'None' };const some = <A>(value: A) => ({ _tag: 'Some', value });const Monad: Monad1<URI> = {  URI,  // Функтор:  map: <A, B>(optA: Option<A>, f: (a: A) => B): Option<B> => {    switch (optA._tag) {      case 'None': return none;      case 'Some': return some(f(optA.value));    }  },  // Аппликативный функтор:  of: some,  ap: <A, B>(optAB: Option<(a: A) => B>, optA: Option<A>): Option<B> => {    switch (optAB._tag) {      case 'None': return none;      case 'Some': {        switch (optA._tag) {          case 'None': return none;          case 'Some': return some(optAB.value(optA.value));        }      }    }  },  // Монада:  chain: <A, B>(optA: Option<A>, f: (a: A) => Option<B>): Option<B> => {    switch (optA._tag) {      case 'None': return none;      case 'Some': return f(optA.value);    }  }};

Как я писал в предыдущей статье, монада позволяет организовывать последовательные вычисления. Интерфейс монады один и тот же для разных типов высшего порядка это наличие функций chain (она же bind или flatMap в других языках) и of (pure или return).


Если бы в JS/TS был синтаксический сахар для более простой работы с интерфейсом монады, как в Haskell или Scala, то мы единообразно работали бы с nullable-типам, промисами, кодом с исключениями, массивами и много чем еще вместо того, чтобы раздувать язык большим количеством точечных (и, зачастую, частичных) решений частных случаев (Promise/A+, потом async/await, потом optional chaining). К сожалению, подведение под основу языка какой-либо математической базы не является приоритетным направлением работы комитета TC39, поэтому мы работаем с тем, что есть.

Контейнер Option доступен в модуле fp-ts/Option, поэтому я просто импортирую его оттуда, и перепишу императивный пример выше в функциональном стиле:


import { pipe, flow } from 'fp-ts/function';import * as O from 'fp-ts/Option';import Option = O.Option;const getNumber = (): Option<number> => Math.random() > 0.5 ? O.some(42) : O.none;// эти функции модифицировать не нужно!const add5 = (n: number): number => n + 5;const format = (n: number): string => n.toFixed(2);const app = (): Option<string> => pipe(  getNumber(),  O.map(n => add5(n)), // или просто O.map(add5)  O.map(format));

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


const app = (): Option<string> => pipe(  getNumber(),  O.map(flow(add5, format)),);

N.B. В этом крохотном примере не нужно смотреть на конкретную бизнес-логику (она умышленно сделана примитивной), а важно подметить одну вещу касательно функциональной парадигмы в целом: мы не просто использовали функцию по-другому, мы абстрагировали общее поведение для вычислительного контекста контейнера Option (изменение значения в случае его наличия) от бизнес-логики (работа с числами). При этом само вынесенное в функтор/монаду/аппликатив/etc поведение можно переиспользовать в других местах приложения, получив один и тот же предсказуемый порядок вычислений в контексте разной бизнес-логики. Как это сделать мы рассмотрим в последующих статьях, когда будем говорить про Free-монады и паттерн Tagless Final. С моей точки зрения, это одна из сильнейших сторон функциональной парадигмы отделение общих абстрактных вещей с последующим переиспользованием их для композиции в более сложные структуры.

Either<E, A> вычисления, которые могут идти двумя путями


Теперь поговорим про исключения. Как я уже писал выше, исключительная ситуация это нарушение обычного потока исполнения логики программы, на которое как-то необходимо среагировать. При этом выразительных средств в самом языке у нас нет но мы сможем обойтись структурой данных, несколько схожей с Option, которая называется Either:


type Either<E, A> = Left<E> | Right<A>;interface Left<E> {  readonly _tag: 'Left';  readonly left: E;}interface Right<A> {  readonly _tag: 'Right';  readonly right: A;}

Тип Either<E, A> выражает идею вычислений, которые могут пойти по двум путям: левому, завершающемуся значением типа E, или правому, завершающемуся значением типа A. Исторически сложилось соглашение, в котором левый путь считается носителем данных об ошибке, а правый об успешном результате. Для Either точно так же можно реализовать множество классов типов функтор/монаду/альтернативу/бифунктор/etc, и всё это уже есть реализовано в fp-ts/Either. Я же приведу реализацию интерфейса монады для общей справки:


import { Monad2 } from 'fp-ts/Monad';const URI = 'Either';type URI = typeof URI;declare module 'fp-ts/HKT' {  interface URItoKind2<E, A> {    readonly [URI]: Either<E, A>;  }}const left = <E, A>(e: E) => ({ _tag: 'Left', left: e });const right = <E, A>(a: A) => ({ _tag: 'Right', right: a });const Monad: Monad2<URI> = {  URI,  // Функтор:  map: <E, A, B>(eitherEA: Either<E, A>, f: (a: A) => B): Either<E, B> => {    switch (eitherEA._tag) {      case 'Left':  return eitherEA;      case 'Right': return right(f(eitherEA.right));    }  },  // Аппликативный функтор:  of: right,  ap: <E, A, B>(eitherEAB: Either<(a: A) => B>, eitherEA: Either<A>): Either<B> => {    switch (eitherEAB._tag) {      case 'Left': return eitherEAB;      case 'Right': {        switch (eitherEA._tag) {          case 'Left':  return eitherEA;          case 'Right': return right(eitherEAB.right(eitherEA.right));        }      }    }  },  // Монада:  chain: <E, A, B>(eitherEA: Either<E, A>, f: (a: A) => Either<E, B>): Either<E, B> => {    switch (eitherEA._tag) {      case 'Left':  return eitherEA;      case 'Right': return f(eitherEA.right);    }  }};

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


  1. Email содержит знак @;
  2. Email хотя бы символ до знака @;
  3. Email содержит домен после знака @, состоящий из не менее 1 символа до точки, самой точки и не менее 2 символов после точки;
  4. Пароль имеет длину не менее 1 символа.

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


interface Account {  readonly email: string;  readonly password: string;}class AtSignMissingError extends Error { }class LocalPartMissingError extends Error { }class ImproperDomainError extends Error { }class EmptyPasswordError extends Error { }type AppError =  | AtSignMissingError  | LocalPartMissingError  | ImproperDomainError  | EmptyPasswordError;

Императивную реализацию можно представить как-нибудь так:


const validateAtSign = (email: string): string => {  if (!email.includes('@')) {    throw new AtSignMissingError('Email must contain "@" sign');  }  return email;};const validateAddress = (email: string): string => {  if (email.split('@')[0]?.length === 0) {    throw new LocalPartMissingError('Email local-part must be present');  }  return email;};const validateDomain = (email: string): string => {  if (!/\w+\.\w{2,}/ui.test(email.split('@')[1])) {    throw new ImproperDomainError('Email domain must be in form "example.tld"');  }  return email;};const validatePassword = (pwd: string): string => {  if (pwd.length === 0) {    throw new EmptyPasswordError('Password must not be empty');  }  return pwd;};const handler = (email: string, pwd: string): Account => {  const validatedEmail = validateDomain(validateAddress(validateAtSign(email)));  const validatedPwd = validatePassword(pwd);  return {    email: validatedEmail,    password: validatedPwd,  };};

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


import * as E from 'fp-ts/Either';import { pipe } from 'fp-ts/function';import * as A from 'fp-ts/NonEmptyArray';import Either = E.Either;

Переписать императивный код, выбрасывающий исключения, на код с Either'ами достаточно просто в месте, где был оператор throw, пишется возврат левого (Left) значения:


// Было:const validateAtSign = (email: string): string => {  if (!email.includes('@')) {    throw new AtSignMissingError('Email must contain "@" sign');  }  return email;};// Стало:const validateAtSign = (email: string): Either<AtSignMissingError, string> => {  if (!email.includes('@')) {    return E.left(new AtSignMissingError('Email must contain "@" sign'));  }  return E.right(email);};// После упрощения через тернарный оператор и инверсии условия:const validateAtSign = (email: string): Either<AtSignMissingError, string> =>  email.includes('@') ?    E.right(email) :    E.left(new AtSignMissingError('Email must contain "@" sign'));

Аналогичным образом переписываются другие функции:


const validateAddress = (email: string): Either<LocalPartMissingError, string> =>  email.split('@')[0]?.length > 0 ?    E.right(email) :    E.left(new LocalPartMissingError('Email local-part must be present'));const validateDomain = (email: string): Either<ImproperDomainError, string> =>  /\w+\.\w{2,}/ui.test(email.split('@')[1]) ?    E.right(email) :    E.left(new ImproperDomainError('Email domain must be in form "example.tld"'));const validatePassword = (pwd: string): Either<EmptyPasswordError, string> =>  pwd.length > 0 ?     E.right(pwd) :     E.left(new EmptyPasswordError('Password must not be empty'));

Остается теперь собрать всё воедино в функции handler. Для этого я воспользуюсь функцией chainW это функция chain из интерфейса монады, которая умеет делать расширение типов (type widening). Вообще, есть смысл рассказать немного о конвенции именования функций, принятой в fp-ts:


  • Суффикс W означает type Widening расширение типов. Благодаря этому можно в одну цепочку поместить функции, возвращающие разные типы в левых частях Either/TaskEither/ReaderTaskEither и прочих структурах, основанных на типах-суммах:


    // Предположим, есть некие типы A, B, C, D, типы ошибок E1, E2, E3, // и функции foo, bar, baz, работающие с ними:declare const foo: (a: A) => Either<E1, B>declare const bar: (b: B) => Either<E2, C>declare const baz: (c: C) => Either<E3, D>declare const a: A;// Не скомпилируется, потому что chain ожидает мономорфный по типу левой части Either:const willFail = pipe(  foo(a),  E.chain(bar),  E.chain(baz));// Скомпилируется корректно:const willSucceed = pipe(  foo(a),  E.chainW(bar),  E.chainW(baz));
    

  • Суффикс T может означать две вещи либо Tuple (например, как в функции sequenceT), либо монадные трансформеры (как в модулях EitherT, OptionT и тому подобное).
  • Суффикс S означает structure например, как в функциях traverseS и sequenceS, которые принимают на вход объект вида ключ функция преобразования.
  • Суффикс L раньше означал lazy, но в последних релизах от него отказались в пользу ленивости по умолчанию.

Эти суффиксы могут объединяться например, как в функции apSW: это функция ap из класса типов Apply, которая умеет делать type widening и принимает на вход структуру, по ключам которой итерирует.


Возвращаемся к написанию handler. Я использую chainW, чтобы собрать тип возможных ошибок как тип-сумму AppError:


const handler = (email: string, pwd: string): Either<AppError, Account> => pipe(  validateAtSign(email),  E.chainW(validateAddress),  E.chainW(validateDomain),  E.chainW(validEmail => pipe(    validatePassword(pwd),    E.map(validPwd => ({ email: validEmail, password: validPwd })),  )),);

Что же мы получили в результате такого переписывания? Во-первых, функция handler явно сообщает о своих побочных эффектах она может не только вернуть объект типа Account, но и вернуть ошибки типов AtSignMissingError, LocalPartMissingError, ImproperDomainError, EmptyPasswordError. Во-вторых, функция handler стала чистой контейнер Either это просто значение, не содержащее дополнительной логики, поэтому с ним можно работать без боязни, что произойдет что-то нехорошее в месте вызова.


NB: Разумеется, эта оговорка просто соглашение. TypeScript как язык и JavaScript как рантайм никак нас не ограничивают от того, чтобы написать код в духе:
const bad = (cond: boolean): Either<never, string> => {  if (!cond) {    throw new Error('COND MUST BE TRUE!!!');  }  return E.right('Yay, it is true!');};


Понятное дело, что в приличном обществе за такой код бьют канделябром по лицу на код ревью, а после просят переписать с использованием безопасных методов и комбинаторов. Скажем, если вы работаете со сторонними синхронными функциями, их стоит оборачивать в Either/IOEither с помощью комбинатора tryCatch, если с промисами через TaskEither.tryCatch и так далее.

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


Есть у Either брат-близнец тип Validation. Это точно такой же тип-сумма, у которого правая часть означает успех, а левая ошибку валидации. Нюанс заключается в том, что Validation требует, чтобы для левой части типа E была определена операция contact :: (a: E, b: E) => E из класса типов Semigroup. Это позволяет использовать Validation вместо Either в задачах, где необходимо собирать все возможные ошибки. Например, мы можем переписать предыдущий пример (функцию handler) так, чтобы собрать все возможные ошибки валидации входных данных, не переписывая при этом остальные функции валидации (validateAtSign, validateAddress, validateDomain, validatePassword).


Расскажу пару слов об алгебраических структурах, умеющих объединять два элемента

Они выстраиваюся в следующую иерархию:


  • Magma (Магма), или группоид базовый класс типов, определяющий операцию contact :: (a: A, b: A) => A. На эту операцию не налагается никаких других ограничений.
  • Если к магме добавить ограничение ассоциативности для операции concat, получим полугруппу (Semigroup). На практике оказывается, что полугруппы более полезны, так как чаще всего работа ведется со структурами, в которых порядок элементов имеет значимость вроде массивов или деревьев.
  • Если к полугруппе добавить единицу (unit) значение, которое можно сконструировать в любой момент просто так, получим моноид (Monoid).
  • Наконец, если к моноиду добавим операцию inverse :: (a: A) => A, которая позволяет получить для произвольного значения его инверсию, получим группу (Group).

Groupoid hierarchy
Детальнее об иерархии алгебраических структур можно почитать в вики.


Иерархию классов типов, соответствующих таким алгебраическим структурам, можно продолжать и дальше: в библиотеке fp-ts определены классы типов Semiring, Ring, HeytingAlgebra, BooleanAlgebra, разного рода решётки (lattices) и т.п.


Нам для решения задачи получения списка всех ошибок валидации понадобится две вещи: тип NonEmptyArray (непустой массив) и полугруппа, которую можно определить для этого типа. Вначале напишем вспомогательную функцию lift, которая будет переводить функцию вида A => Either<E, B> в функцию A => Either<NonEmptyArray<E>, B>:


const lift = <Err, Res>(check: (a: Res) => Either<Err, Res>) => (a: Res): Either<NonEmptyArray<Err>, Res> => pipe(  check(a),  E.mapLeft(e => [e]),);

Для того, чтобы собрать все ошибки в большой кортеж, я возпользуюсь функцией sequenceT из модуля fp-ts/Apply:


import { sequenceT } from 'fp-ts/Apply';import NonEmptyArray = A.NonEmptyArray;const NonEmptyArraySemigroup = A.getSemigroup<AppError>();const ValidationApplicative = E.getApplicativeValidation(NonEmptyArraySemigroup);const collectAllErrors = sequenceT(ValidationApplicative);const handlerAllErrors = (email: string, password: string): Either<NonEmptyArray<AppError>, Account> => pipe(  collectAllErrors(    lift(validateAtSign)(email),    lift(validateAddress)(email),    lift(validateDomain)(email),    lift(validatePassword)(password),  ),  E.map(() => ({ email, password })),);

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


> handler('user@host.tld', '123'){ _tag: 'Right', right: { email: 'user@host.tld', password: '123' } }> handler('user_host', ''){ _tag: 'Left', left: AtSignMissingError: Email must contain "@" sign }> handlerAllErrors('user_host', ''){  _tag: 'Left',  left: [    AtSignMissingError: Email must contain "@" sign,    ImproperDomainError: Email domain must be in form "example.tld",    EmptyPasswordError: Password must not be empty  ]}

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



На этом текущую статью я заканчиваю, а в следующей будем говорить уже про Task, TaskEither и ReaderTaskEither. Они позволят нам подойти к идее алгебраических эффектов и понять, что это даёт в плане удобства разработки.

Подробнее..

Функциональное программирование на TypeScript задачи (tasks) как альтернатива промисам

24.03.2021 20:12:56 | Автор: admin

Предыдущие статьи цикла:


  1. Полиморфизм родов высших порядков
  2. Паттерн класс типов
  3. Option и Either как замены nullable-типам и исключениям



В предыдущей статье мы рассмотрели типы Option и Either, которые предоставляют функциональную замену nullable-типам и выбрасыванию исключений. В этой статье я хочу поговорить о ленивой функциональной замене промисам задачам (tasks). Они позволят нам подойти к понятию алгебраических эффектов, которые я подробно рассмотрю в следующих статьях.


Как всегда, я буду иллюстрировать примеры с помощью структур данных из библиотеки fp-ts.


Promise/A+, который мы потеряли заслужили


В далеком 2013 году Брайан МакКенна написал пост о том, что следовало бы изменить в спецификации Promise/A+ для того, чтобы промисы соответствовали монадическому интерфейсу. Эти изменения были незначительные, но очень важные с точки зрения соблюдения теоретико-категорных законов для монады и функтора. Итак, Брайан МакКенна предлагал:


  1. Добавить статический метод конструирования промиса Promise.point:
    Promise.point = function(a) {  // ...};
    
  2. Добавить метод onRejected для обработки состояния неудачи:
    Promise.prototype.onRejected = function(callback) {  // ...};
    
  3. Сделать так, чтобы Promise.prototype.then принимал только один коллбэк, и этот коллбэк обязательно должен возвращать промис:
    Promise.prototype.then = function(onFulfilled) {  // ...};
    
  4. Наконец, сделать промис ленивым, добавив метод done:
    Promise.prototype.done = function() {  // ...};
    

Эти изменения позволили бы получить простое расширяемое API, которое в дальнейшем позволило бы элегантно отделять поведение контекста вычислений от непосредственной бизнес-логики скажем, так, как это сделано в Haskell с его do-нотацией, или в Scala с for comprehension. К сожалению, так называемые прагматики в лице Доменика Дениколы и нескольких других контрибьюторов отвергли эти предложения, поэтому промисы в JS так и остались невнятным энергичным бастардом, которого достаточно проблематично использовать в идиоматичном ФП-коде, предполагающим equational reasoning и соблюдение принципа ссылочной прозрачности. Тем не менее, благодаря достаточно простому трюку можно сделать из промисов законопослушную абстракцию, для которой можно реализовать экземпляры функтора, аппликатива, монады и много чего еще.


Task<A> ленивый промис


Первой абстракций, которая позволит сделать промис законопослушным, является Task. Task<A> это примитив асинхронных вычислений, который олицетворяет задачу, которая всегда завершается успешно со значением типа A (то есть не содержит выразительных средств для представления ошибочного состояния):


// Task  ленивый примитив асинхронных вычисленийtype Task<A> = () => Promise<A>;// Уникальный идентификатор ресурса  тэг типа (type tag)const URI = 'Task';type URI = typeof URI;// Определение Task как типа высшего порядка (higher-kinded type)declare module 'fp-ts/HKT' {  interface URItoKind<A> {    [URI]: Task<A>;  }}

Для Task можно определить экземпляры классов типов Functor, Apply, Applicative, Monad. Обратите внимание, как один из самых простых классов типов функтор порождает структуры, обладающие всё более и более сложным поведением.


N.B.: Также оговорюсь, что для простоты реализации код по обработке состояния rejected в промисах, использующихся внутри Task, не пишется подразумевается, что конструирование экземпляров Task происходит при помощи функций-конструкторов, а не ad hoc.

Функтор позволяет преобразовывать значение, которое будет возвращено задачей, из типа A в тип B при помощи чистой функции:


const Functor: Functor1<URI> = {  URI,  map: <A, B>(    taskA: Task<A>,     transform: (a: A) => B  ): Task<B> => async () => {    const prevResult = await taskA();    return transform(prevResult);  },};

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


const Apply: Apply1<URI> = {  ...Functor,  ap: <A, B>(    taskA2B: Task<(a: A) => B>,     taskA: Task<A>  ): Task<B> => async () => {    const transformer = await taskA2B();    const prevResult = await taskA();    return transformer(prevResult);  },};const ApplyPar: Apply1<URI> = {  ...Functor,  ap: <A, B>(    taskA2B: Task<(a: A) => B>,     taskA: Task<A>  ): Task<B> => async () => {    const [transformer, prevResult] = await Promise.all([taskA2B(), taskA()]);    return transformer(prevResult);  },};

Аппликативный функтор (аппликатив) позволяет конструировать новые значения некоего типа F, поднимая (lift) их в вычислительный контекст F. В нашем случае аппликатив оборачивает чистое значение в задачу. Для простоты я буду использовать последовательный экземпляр Apply для наследования:


const Applicative: Applicative1<URI> = {  ...Apply,  of: <A>(a: A): Task<A> => async () => a,};

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


const Monad: Monad1<URI> = {  ...Applicative,  chain: <A, B>(    taskA: Task<A>,     next: (a: A) => Task<B>  ): Task<B> => async () => {    const prevResult = await taskA();    const nextTask = next(prevResult);    return nextTask();  },};

N.B.: так как экземпляр монады для Task может наследоваться от одного из двух экземпляров аппликатива параллельного или последовательного, то подставляя нужный экземпляр монады в программы, написанные в стиле Tagless Final, можно получить разное поведение аппликативных операций. Про реализацию стиля Tagless Final на тайпскрипте можно почитать в этом треде #MonadicMondays.

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


TaskEither<E, A> задача, которая может вернуть ошибку


В предыдущей статье мы рассмотрели тип данных Either, который представляет вычисления, которые могут идти по одному из двух путей. Для типа Either можно реализовать экземпляры функтора, монады, альтернативы (Alt + Alternative, позволяет выражать fallback-значения), бифунктора (позволяет модифицировать одновременно как левую, так и правую часть Either) и много чего еще.


Комбинируя Task и Either, мы получаем абстракцию, которая обладает новой семантикой TaskEither<E, A> это асинхронные вычисления, которые могут завершиться успешно со значением типа A или завершиться неудачей с ошибкой типа E. В fp-ts для TaskEither реализован ряд комбинаторов, как то:


  • bracket позволяет безопасно получить (acquire), использовать (use) и утилизировать (release) какой-либо ресурс например, соединение с базой данных или файловый дескриптор. При этом функция release вызовется вне зависмости от того, завершилась ли функция use успехом или неудачей:


    bracket: <E, A, B>(  acquire: TaskEither<E, A>,  use: (a: A) => TaskEither<E, B>,  release: (a: A, e: E.Either<E, B>) => TaskEither<E, void>) => TaskEither<E, B>
    

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


    tryCatch: <E, A>(  f: Lazy<Promise<A>>,   onRejected: (reason: unknown) => E) => TaskEither<E, A>tryCatchK: <E, A extends readonly unknown[], B>(  f: (...a: A) => Promise<B>,   onRejected: (reason: unknown) => E) => (...a: A) => TaskEither<E, B>
    

  • taskify функция, которая позволяет превратить коллбэк в стиле Node.js в функцию, возвращающую TaskEither. taskify перегружена для оборачивания функций от 0 до 6 аргументов + коллбэк:


    taskify<A, L, R>(  f: (a: A, cb: (e: L | null | undefined, r?: R) => void) => void): (a: A) => TaskEither<L, R>
    


Благодаря тому, что для TaskEither реализованы экземпляры Traversable и Foldable, возможна простая работа по обходу массива задач. Функции traverseArray, traverseArrayWithIndex, sequenceArray и их последовательные вариации traverseSeqArray, traverseSeqArrayWithIndex, sequenceSeqArray позволяют обойти массив задач и получить как результат задачу, чьим результатом является массив результатов. Например, вот как можно написать программу, которая должна прочитать три файла с диска и записать их содержимое в единый новый файл:


import * as fs from 'fs';import { pipe } from 'fp-ts/function';import * as Console from 'fp-ts/Console';import * as TE from 'fp-ts/TaskEither';// Сначала я оберну функции из системного модуля `fs` при помощи `taskify`, сделав их чистыми:const readFile = TE.taskify(fs.readFile);const writeFile = TE.taskify(fs.writeFile);const program = pipe(  // Входная точка  массив задач по чтению трёх файлов с диска:  [readFile('/tmp/file1'), readFile('/tmp/file2'), readFile('/tmp/file3')],  // Для текущей задачи важен порядок обхода массива, поэтому я использую  // последовательную, а не параллельную версию traverseArray:  TE.traverseSeqArray(TE.map(buffer => buffer.toString('utf8'))),  // При помощи функции `chain` из интерфейса монады я организую  // последовательность вычислений:  TE.chain(fileContents =>     writeFile('/tmp/combined-file', fileContents.join('\n\n'))),  // Наконец, в финале я хочу узнать, завершилась ли программа успешно или   // ошибочно, и залогировать это. Тут мне поможет модуль `fp-ts/Console`,  // содержащий чистые функции по работе с консолью:  TE.match(    err => TE.fromIO(Console.error(`An error happened: ${err.message}`)),    () => TE.fromIO(Console.log('Successfully written to combined file')),  ));// Наконец, запускаем нашу чистую программу на выполнение, // выполняя все побочные эффекты:await program();

N.B.: Если обратите внимание, то я пишу про функции, возвращающие TaskEither, как про чистые. В прошлых статьях я вскользь затрагивал эту тему: в функциональном подходе многое строится на создании описания вычислений с последующей интерпретацией их по необходимости. Когда я буду рассказывать про алгебраические эффекты и свободные монады, эта тема будет раскрыта более полно; сейчас же я просто скажу, что Task/TaskEither/ReaderTaskEither/etc. это просто значения, а не запущенные вычисления, поэтому с ними можно обращаться более вольготно, чем с промисами. Именно ленивость Task'ов позволяет им быть настолько удобной и мощной абстракцией. Код, написанный с применением TaskEither, проще рефакторить с помощью принципа ссылочной прозрачности: задачи можно спокойно создавать, отменять и передавать в другие функции.

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


Reader доступ к неизменному вычислительному контексту


Если мы возьмем тип функции A -> B, и зафиксируем тип аргумента A как неизменный, мы получим структуру, для которой можно определить экземпляры функтора, аппликатива, монады, профунктора, категории и т.п., которую назвали Reader:


// Reader это функция из некоторого окружения типа `E` в значение типа `A`:type Reader<E, A> = (env: E) => A;// Reader является типом высшего порядка, поэтому определим всё необходимое:const URI = 'Reader';type URI = typeof URI;declare module 'fp-ts/HKT' {  interface URItoKind2<E, A> {    readonly [URI]: Reader<E, A>;  }}

Для Reader можно определить экземпляры следующих классов типов:


// Функтор:const Functor: Functor2<URI> = {  URI,  map: <R, A, B>(    fa: Reader<R, A>,     f: (a: A) => B  ): Reader<R, B> => (env) => f(fa(env))};// Apply:const Apply: Apply2<URI> = {  ...Functor,  ap: <R, A, B>(    fab: Reader<R, (a: A) => B>,     fa: Reader<R, A>  ): Reader<R, B> => (env) => {    const fn = fab(env);    const a = fa(env);    return fn(a);  }};// Аппликативный функтор:const Applicative: Applicative2<URI> = {  ...Apply,  of: <R, A>(a: A): Reader<R, A> => (_) => a};// Монада:const Monad: Monad2<URI> = {  ...Applicative,  chain: <R, A, B>(    fa: Reader<R, A>,     afb: (a: A) => Reader<R, B>  ): Reader<R, B> => (env) => {    const a = fa(env);    const fb = afb(a);    return fb(env);  },};

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


interface AppConfig {  readonly host: string; // имя хоста веб-сервера  readonly port: number; // порт, который будет слушать веб-сервер  readonly connectionString: string; // параметры соединения с некоторой БД}

Для упрощения я сделаю типы БД и express алиасами для строковых литералов сейчас мне не так важно, какой бизнес-тип будут возвращать функции; важнее продемонстрировать принципы работы с Reader:


type Database = 'connected to the db';type Express = 'express is listening';// Наше приложение  это *значение типа A*, вычисляемое *в контексте доступа // к конфигурации типа AppConfig*:type App<A> = Reader<AppConfig, A>;

Для начала напишем функцию, которая соединяется с нашим фейковым экспрессом:


const expressServer: App<Express> = pipe(  // `ask` позволяет запросить от окружения значение типа AppConfig.   // Ее реализация тривиальна:  // const ask = <R>(): Reader<R, R> => r => r;  R.ask<AppConfig>(),  // Я использую функтор, чтобы получить доступ к конфигу и что-то сделать   // на его основе  например, залогировать параметры и вернуть значение   // типа `Express`:  R.map(    config => {      console.log(`${config.host}:${config.port}`);      // В реальном приложении здесь нужно выполнять асинхронные операции       // по запуску сервера.      // Мы поговорим о работе с асинхронностью в следующей секции:      return 'express is listening';    },  ),);

Функция databaseConnection работает в контексте конфига и возвращает соединение с фейковой БД:


const databaseConnection: App<Database> = pipe(  // `asks` позволяет запросить значение определенного типа и сразу же   // преобразовать его в какое-то другое  например, здесь я просто достаю   // из конфига строку с параметрами соединения:  R.asks<AppConfig, string>(cfg => cfg.connectionString),  R.map(    connectionString => {      console.log(connectionString);      return 'connected to the db';    },  ),);

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


interface AdHocStruct {  readonly db: App<Database>;  readonly express: App<Express>;}

к типу App<{ readonly db: Database; readonly express: Express }>. Мы якобы достаём из структуры данные, обёрнутые в контекст App, и собираем новый контекст App с похожей структурой, только содержащей уже чистые данные:


import { sequenceS } from 'fp-ts/Apply';const seq = sequenceS(R.Apply);const application: App<void> = pipe(  seq({    db: databaseConnection,    express: expressServer  }),  R.map(    ({ db, express }) => {      console.log([db, express].join('; '));      console.log('app was initialized');      return;    },  ),);

Чтобы запустить Reader<E, A> на выполнение, ему необходимо передать аргумент того типа, который зафиксирован в типопеременной E, и результатом будет значение типа A:


application({  host: 'localhost',  port: 8080,  connectionString: 'mongo://localhost:271017',});

Наконец, объединяя две вышеописанные концепции, мы приходим к последней для данной статьи абстракции ReaderTaskEither.


ReaderTaskEither<R, E, A> задача, выполняющаяся в контексте окружения


Комбинируя Reader и TaskEither, мы получаем следующую абстракцию: ReaderTaskEither<R, E, A> это асинхронные вычисления, которые имеют доступ к некоему неизменному окружению типа R, могут вернуть результат типа A или ошибку типа E. Оказалось, что такая конструкция позволяет описывать подавляющее большинство задач, с которыми в принципе приходится сталкиваться программисту при написании функций. Более того, заполняя типопараметры ReaderTaskEither значениями any и never, можно получить такие абстракции:


// Task никогда не может упасть и может быть запущен в любом окружении:type Task<A> = ReaderTaskEither<any, never, A>;// ReaderTask никогда не падает, но требует для работы окружения типа `R`:type ReaderTask<R, A> = ReaderTaskEither<R, never, A>;// TaskError может упасть с обобщенной ошибкой типа Error:type TaskError<A> = ReaderTaskEither<any, Error, A>;// ReaderTaskError может упасть с ошибкой типа Error и требует для работы // окружение типа `R`:type ReaderTaskError<R, A> = ReaderTaskEither<R, Error, A>;// TaskEither, с которым мы познакомились ранее, может быть представлен как // алиас для ReaderTaskEither, который может быть запущен в любом окружении:type TaskEither<E, A> = ReaderTaskEither<any, E, A>;

Для ReaderTaskEither в соответствующем модуле fp-ts реализовано большое количество конструкторов, деструкторов и комбинаторов. Однако сам по себе ReaderTaskEither не так интересен, как схожая по семантике с ним ZIO-подобная конструкция, которая несёт дополнительный интересный механизм под капотом, называемый свободными монадами.


N.B. Про ReaderTaskEither я достаточно много говорил на камеру в пятом эпизоде видеоподкаста ФП для чайника. Пример, который я там рассматриваю, можно найти здесь.



На этом данную статью я заканчиваю. Абстракция ReaderTaskEither плавно подвела нас к концепции алгебраических эффектов. Но перед тем, как рассмотреть их на примере ZIO-подобной библиотеки Effect-TS, в следующей статье я хочу поговорить о свободных конструкциях на примере свободных и более свободных монад (Free & Freer monads).


Вы можете найти примеры кода из этой статье у меня в Gist на гитхабе.

Подробнее..

Kotlin FP моноиды и сортировки

26.11.2020 08:23:40 | Автор: admin
В данной статье мы рассмотрим понятие моноид и узнаем, как он может помочь нам при сортировке данных.

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




Теория


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

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

Ассоциативность это свойство, при котором результат последовательного применения операций не зависит от расстановки скобок:

$a + b + c == a + (b + c) == (a + b) + c$


Другими словами, при сложении чисел мы можем опустить скобки.

Нейтральный элемент ноль, поскольку:

$0 + a == a == a + 0$


Многие примитивные типы являются моноидом.

Например:

Натуральные числа с операцией умножения и нейтральным элементом 1

$a * b * c == a * (b * c) == (a * b) * c$


$1 * a == a == a * 1 $


Строки с операцией конкатенации (склеивания) и нейтральным элементом (пустой строкой)

$a + b + c == a + (b + c) == (a + b) + c$


$$display$$ "" + a == a == a + ""$$display$$


Теперь перейдем к функциям.


Возьмем функцию, у которой принимаемый тип равен возвращаемому:

$(A) -> A$


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

Прежде чем рассматривать моноид, предлагаю вспомнить, что такое композиция функций.
Допустим, у нас есть две функции.
Первая принимает аргумент A и возвращает значения типа B:

$(A) -> B$


Вторая принимает аргумент B и возвращает значения типа С:

$(B) -> C$


Из этих двух функций мы можем создать третью, которая принимает аргумент A
и возвращает значения типа С:

$(A) -> C$


Это преобразование и есть композиция функций.

Реализация композиции функций:

infix fun <A, B, C> ((A) -> B).andThen(g: (B) -> C): (A) -> C = { a: A ->    g(this(a))}

Ассоциативность композиции довольно очевидна:

val f: (A) -> Bval g: (B) -> Cval h: (C) -> Df andThen g andThen h == f andThen (g andThen h) == (f andThen g) andThen h

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

fun <T> identity(x: T): T = x

То есть такая функция, которая возвращает значение, принятое на входе.

Доказательство того, что identity является нейтральным элементом так же очевидно:

f andThen ::identity = f = ::identity andThen f

Теперь вновь вернемся к функции

$(A) -> A$


Так как композиция подобных функций является бинарной операцией,
а композиция ассоциативна и имеет нейтральный элемент, то функция
(A) -> A является моноидом.

Далее рассмотрим функцию:

$(A) -> B$


где B это моноид (т.е. область значения функции является моноидом).

Такая функция с бинарной операцией

$((A) -> B), (A) -> B)) -> (A) -> B$


будет также являться моноидом.

Для примера рассмотрим функцию:

$(A) -> Int$


Ранее мы рассмотрели, что множество значений типа Int с бинарной операцией сложения это моноид.

А для функции (A) -> Int мы можем написать бинарную операцию:

operator fun <A> ((A) -> Int).plus(g: (A) -> Int) = { a: A -> this(a) + g(a) }

Ассоциативность которой прослеживается из Int и нейтральным элементом является функция, возвращающая нейтральный элемент для Int, то есть:

val f: (A) -> Int = { 0 }

Функции моноиды нам пригодятся на практике, к которой мы сейчас перейдем.

Практика


Создадим интерфейс моноида:

interface Monoid<A> {   operator fun plus(rh: A): A   fun empty(): A}

Он содержит две функции:
  • plus ассоциативная бинарная операция
  • empty нейтральный элемент для функции plus


Создадим enum-класс, который содержит в себе значения, соответствующие типу отношений между объектами (больше, меньше, либо равно):

enum class Order(val compareValue: Int) {   LT(-1),   EQ(0),   GT(1)}

Допустим, у нас имеется класс пользователя User с полями name и age.
Перед нами стоит задача сравнить двух пользователей user1 и user2.
Мы можем сравнить имена пользователей и получить значение Order, показывающее тип отношений между ними.
Также мы можем сравнить возраст пользователей и получить соответствующее значение Order.

В обычной ситуации сначала мы сравниваем имена пользователей.
Если они не равны, возвращаем результат сравнения имен.
Если же имена равны, то возвращаем результат сравнения возрастов:

fun plus(nameOrder: Order, ageOrder: Order) = when(nameOrder) {       LT -> LT       EQ -> ageOrder       GT -> GT   }

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

enum class Order(val compareValue: Int) : Monoid<Order> {  LT(-1),  EQ(0),  GT(1);   override fun plus(rh: Order) = when (this) {       LT -> LT       EQ -> rh       GT -> GT   }   override fun empty() = EQ}

Ассоциативность в данном случае хорошо прослеживается:

$order1 + order2 + order3 == order1 + (order2 + order3) == (order1 + order2) + order3$


Так как не важен порядок скобок, наибольший приоритет будет иметь order, стоящий в операции левее остальных.
И нейтральный элемент EQ, который соответствует правилу:

$EQ + order == order + EQ == order$


Следующим шагом рассмотрим функцию:

$(A, A) -> Order$


Она также является моноидом, так как область значения этой функции моноид.

Реализация:

fun interface ComparatorMonoid<A> : Monoid<ComparatorMonoid<A>> {   fun compare(a: A, other: A): Order   override fun plus(rh: ComparatorMonoid<A>) =       ComparatorMonoid<A> { a, other -> compare(a, other) + rh.compare(a, other) }   override fun empty() = ComparatorMonoid<A> { _, _ -> Order.EQ }}

В Kotlin мы можем сравнивать основные типы (String, Int, Float, ...), потому что они наследуются от интерфейса Comparable. Из этого следует, что мы можем написать функцию получения Order для основных типов Kotlin:

fun <A : Comparable<A>> A.compare(other: A) = when {   this > other -> Order.GT   this == other -> Order.EQ   else -> Order.LT}

Также мы сможем получать наш ComparatorMonoid из функции, возвращающей значение типа Comparable:

val <A, B : Comparable<B>> ((A) -> B).comparator    get():ComparatorMonoid<A> = ComparatorMonoid<A> { a, b ->        invoke(a).compare(invoke(b))    }

Теперь научим списки работать с ComparatorMonoid:

fun <A> Iterable<A>.sort(comparatorMonoid: ComparatorMonoid<A>) =   sortedWith { a, b -> comparatorMonoid.compare(a, b).compareValue }

В итоге мы получили следующее:
  • Списки, которые умеют сортироваться на основе ComparatorMonoid;
  • ComparatorMonoid, который умеет комбинироваться на основании того, что он является моноидом;
  • ComparatorMonoid можно получить из Kotlin Comparable.


Теперь давайте воспользуемся этим.

Создадим класс User и необходимый для него класс Address:

data class User(val name: String, val age: Int, val gender: String, val address: Address)data class Address(val city: String, val number: Int)

Создадим тестовые данные:

val users: List<User>

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

users.sort(User::age.comparator + User::name.comparator)

Если нам необходима сортировка пользователей по названию города, которое содержится в поле city класса Address, а затем по полу (gender), а после него ещё и по имени (name), то функция будет выглядеть следующим образом:

users.sort(   User::address.andThen(Address::city).comparator +   User::gender.comparator +   User::name.comparator)

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

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

users.sort(   User.address.city.comparator +   User.gender.comparator +   User.name.comparator)




Lens и многие другие интересные, на мой взгляд, моменты из функционального программирования уже рассмотрены и будут рассматриваться дальше на моём youtube-канале
Подробнее..

Сильные стороны функционального программирования

15.02.2021 16:13:26 | Автор: admin


Привет! Меня зовут Катерина, и я испытываю самые тёплые чувства к функциональному программированию, использую функциональный язык на постоянной основе и даже немного преподаю.

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

Как ФП улучшает программирование


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

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

  1. Код станет более лаконичным и выразительным. Выразительность можно определить как количество идей на единицу кода, и в целом функциональные языки, будучи более высокоуровневыми, оказываются и более выразительными. Например, преобразование каждого элемента в массиве или списке реализуется функциональным однострочником (используя map/foreach/whatever и анонимную функцию), в то время как в императивном стиле пришлось бы организовывать цикл, объявлять переменную для счётчика или итератора и использовать явное присваивание. Для более сложных примеров различие в выразительности только усиливается.
  2. Декомпозиция кода будет происходить более естественно. Принцип Разделяй и властвуй уже прочно закрепился в разработке и является базовым принципом борьбы со сложностью ПО. Речь идёт не о способе построения алгоритмов, а о более общем понятии. Например, даже простую программу, которая сначала читает целиком текст, разбивает его на слова и что-то делает с каждым словом, можно разбить на логические части: само чтение, разбиение прочитанного текста на слова (например, по пробелам) и создание структуры для хранения слов, обход этой структуры с преобразованием слов и печать результата. Каждое из этих действий можно реализовать отдельно и достаточно абстрактно, чтобы затем переиспользовать для решения других подобных задач. Декомпозиция кода на более мелкие и более общие части делает его гораздо более понятным (в том числе и для самого автора кода в будущем), позволяет избежать ошибок копипаста и упрощает дальнейший рефакторинг. Думаю, многим разработчикам приходилось копаться в своей или чужой простыне неструктурированного кода, написанного наспех, чтобы скорее заработало. Человеческому мозгу тяжело удерживать внимание на большом количестве сущностей одновременно и решать одну глобальную задачу сразу (working memory), поэтому для нас вполне естественно разбивать задачи на более мелкие, решать их по отдельности и комбинировать результат. В функциональном программировании эти мелкие задачи выражаются как небольшие вспомогательные функции, каждая из которых делает своё дело и её работу можно описать одним коротким предложением. А построение итогового результата это композиция таких функций. Конечно, разбить код на отдельные переиспользуемые части можно и в ООП, и в чисто императивном низкоуровневом языке типа C, и для этого уже есть известные принципы типа SOLID и GoF-паттерны, но, когда сам язык заставляет программиста думать в терминах функций, декомпозиция кода происходит гораздо более естественно.

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

Как ФП улучшает программиста





Далее мне хотелось бы поделиться своим опытом, не относящимся непосредственно к написанию программ на функциональных языках, и рассказать про то, чем ещё знание и использование ФП было полезно для меня и может оказаться полезным для вас:

  1. Изучение альтернативной парадигмы само по себе полезно для мозга, поскольку в процессе освоения программирования в функциональном стиле вы научитесь смотреть на привычные вещи по-другому. Кому-то такой способ мышления покажется гораздо более естественным, чем императивный. Можно долго спорить о том, что нужно и не нужно в индустриальном программировании, но там в любом случае нужны хорошие мозги, а их нужно тренировать. Осваивайте то, что не используете в работе: Лиспы, Пролог, Haskell, Brainfuck, Piet. Это поможет расширить кругозор и может стать для вас увлекательной головоломкой. Со временем вы сможете начать применять более элегантные решения в функциональном стиле, даже если пишете на императивном языке.
  2. За функциональными языками стоит серьёзная теория, которая тоже может стать частью ваших увлечений или даже исследований, если вы в той или иной степени хотите связать свою жизнь с computer science. Учиться никогда не поздно, особенно когда перед вами будут наглядные примеры использования довольно занятной теории для решения повседневных задач. Я бы никогда не подумала, что уже после окончания университета буду смотреть лекции по теории категорий, которую мой мозг когда-то отказывался воспринимать, и решать задачки из курса ручкой на бумаге, просто потому что мне это интересно.
  3. Помимо расширения кругозора увлечение ФП поможет вам расширить и круг общения. Возможно, за тусовкой функциональщиков закрепилась репутация академических снобов, которые спрашивают у вас определение монады перед тем, как продолжить общение. Я тоже так раньше думала, пока меня не вытащили на первые функциональные митапы и конференции. Я была совсем неопытным джуном и не знала определение монады, но не встретила по отношению к себе никакого негатива. Напротив, я познакомилась с интересными людьми, увлечёнными своим делом и готовыми делиться опытом, рассказывать и объяснять. Разумеется, в любом комьюнити есть совершенно разные люди, кто-то вам понравится больше, кто-то покажется токсичным и отталкивающим, и это совершенно нормально. Гораздо важнее то, что у вас появится возможность обмениваться идеями с теми, кто смотрит на мир разработки немного иначе и обладает другим опытом.
  4. Самый неожиданный для меня пункт: мне было легче всего найти работу именно на Haskell! На данный момент мой опыт работы чуть больше пяти лет, за это время на двух из трёх местах работы я писала на Haskell, и это был наиболее комфортный и безболезненный опыт трудоустройства. Более того, начинала я тоже с позиции Haskell-разработчика, о чём ни разу не пожалела. На первой работе я получила базовые навыки клиент-серверной разработки и работы с БД. Мы занимались такими же приземлёнными и ненаучными задачами, как и компании, использующие более распространённые языки. На популярных сайтах с вакансиями вы, скорее всего, почти не найдёте ничего по запросу Haskell-разработчик. В лучшем случае найдутся вакансии, где указано, что знание альтернативных парадигм будет преимуществом. Однако, это не значит, что таких вакансий нет. В Твиттере и тематических каналах в Телеграме вакансии появляются регулярно. Да, их мало, нужно знать, где искать, но и хороших специалистов такого узкого профиля тоже немного. Разумеется, вас не возьмут сразу и везде, но свою востребованность вы почувствуете значительно сильнее, чем при поиске работы на более распространённых языках. Возможно, компании могут быть готовы вкладываться в развитие программистов в нужном направлении: не можешь найти хаскелиста вырасти его сам!

Заключение


Появление элементов ФП в популярных языках индустриальной разработки, таких как Python, C++, Kotlin, Swift и т.д., подтверждает, что этот подход действительно полезен и обладает сильными сторонами. Применение функционального стиля позволяет получить более надёжный код, который проще разбивать на части, обобщать и тестировать, независимо от языка программирования. Разумеется, функциональный язык позволяет использовать все перечисленные преимущества по максимуму, предоставляя естественные конструкции с высокой степенью выразительности.

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

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

Создаем веб-приложение на Haskell с использованием Reflex. Часть 1

24.02.2021 20:16:16 | Автор: admin

Введение


Всем привет! Меня зовут Никита, и мы в Typeable для разработки фронтенда для части проектов используем FRP-подход, а конкретно его реализацию на Haskell веб-фреймоворк reflex. На русскоязычных ресурсах отсутствуют какие-либо руководства по данному фреймворку (да и в англоязычном интернете их не так много), и мы решили это немного исправить.


В этой серии статей будет рассмотрено создание веб-приложения на Haskell с использованием платформы reflex-platform. reflex-platform предоставляет пакеты reflex и reflex-dom. Пакет reflex является реализацией Functional reactive programming (FRP) на языке Haskell. В библиотеке reflex-dom содержится большое число функций, классов и типов для работы с DOM. Эти пакеты разделены, т.к. FRP-подход можно использовать не только в веб-разработке. Разрабатывать мы будем приложение Todo List, которое позволяет выполнять различные манипуляции со списком задач.



Для понимания данной серии статей требуется ненулевой уровень знания языка программирования Haskell и будет полезно предварительное ознакомление с функциональным реактивным программированием.

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


  • Behavior a реактивная переменная, изменяющаяся во времени. Представляет собой некоторый контейнер, который на протяжении всего своего жизненного цикла содержит значение.
  • Event a событие в системе. Событие несет в себе информацию, которую можно получить только во время срабатывания события.

Пакет reflex предоставляет еще один новый тип:


  • Dynamic a является объединением Behavior a и Event a, т.е. это контейнер, который всегда содержит в себе некоторое значение, и, подобно событию, он умеет уведомлять о своем изменении, в отличие от Behavior a.

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


Подготовка


В первую очередь потребуется установленный пакетный менеджер nix. Как это сделать описано здесь.


Чтобы ускорить процесс сборки, имеет смысл настроить кэш nix. В случае, если вы не используете NixOS, то вам нужно добавить следующие строки в файл /etc/nix/nix.conf:


binary-caches = https://cache.nixos.org https://nixcache.reflex-frp.orgbinary-cache-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= ryantrinkle.com-1:JJiAKaRv9mWgpVAz8dwewnZe0AzzEAzPkagE9SP5NWI=binary-caches-parallel-connections = 40

Если используете NixOS, то в файл /etc/nixos/configuration.nix:


nix.binaryCaches = [ "https://nixcache.reflex-frp.org" ];nix.binaryCachePublicKeys = [ "ryantrinkle.com-1:JJiAKaRv9mWgpVAz8dwewnZe0AzzEAzPkagE9SP5NWI=" ];

В этом туториале мы будем придерживаться стандартной структуры с тремя пакетами:


  • todo-client клиентская часть;
  • todo-server серверная часть;
  • todo-common содержит общие модули, которые используются сервером и клиентом (например типы API).

Далее необходимо подготовить окружение для разработки. Для этого надо повторить действия из документации:


  • Создать директорию приложения: todo-app;
  • Создать проекты todo-common (library), todo-server (executable), todo-client (executable) в todo-app;
  • Настроить сборку через nix (файл default.nix в директории todo-app);
    • Также надо не забыть включить опцию useWarp = true;;
  • Настроить сборку через cabal (файлы cabal.project и cabal-ghcjs.project).

На момент публикации статьи default.nix будет выглядеть примерно следующим образом:


{ reflex-platform ? ((import <nixpkgs> {}).fetchFromGitHub {    owner = "reflex-frp";    repo = "reflex-platform";    rev = "efc6d923c633207d18bd4d8cae3e20110a377864";    sha256 = "121rmnkx8nwiy96ipfyyv6vrgysv0zpr2br46y70zf4d0y1h1lz5";    })}:(import reflex-platform {}).project ({ pkgs, ... }:{  useWarp = true;  packages = {    todo-common = ./todo-common;    todo-server = ./todo-server;    todo-client = ./todo-client;  };  shells = {    ghc = ["todo-common" "todo-server" "todo-client"];    ghcjs = ["todo-common" "todo-client"];  };})

Примечание: в документации предлагается вручную склонировать репозиторий reflex-platform. В данном примере мы воспользовались средствами nix для получения платформы из репозитория.

Во время разработки клиента удобно пользоваться инструментом ghcid. Он автоматически обновляет и перезапускает приложение при изменении исходников.


Чтобы убедиться, что все работает, добавим в todo-client/src/Main.hs следующий код:


{-# LANGUAGE OverloadedStrings #-}module Main whereimport Reflex.Dommain :: IO ()main = mainWidget $ el "h1" $ text "Hello, reflex!"

Вся разработка ведется из nix-shell, поэтому в самом начале необходимо войти в этот shell:


$ nix-shell . -A shells.ghc

Для запуска через ghcid требуется ввести следующую команду:


$ ghcid --command 'cabal new-repl todo-client' --test 'Main.main'

Если все работает, то по адресу localhost:3003 вы увидите приветствие Hello, reflex!



Почему 3003?


Номер порта ищется в переменной окружения JSADDLE_WARP_PORT. Если эта переменная не установлена, то по умолчанию берется значение 3003.


Как это работает


Вы можете заметить, мы использовали при сборке не GHCJS, а обычный GHC. Это возможно благодаря пакетам jsaddle и jsaddle-warp. Пакет jsaddle предоставляет интерфейс для JS для работы из-под GHC и GHCJS. С помощью пакета jsaddle-warp мы можем запустить сервер, который посредством веб-сокетов будет обновлять DOM и играть роль JS-движка. Как раз для этого и был установлен флаг useWarp = true;, иначе по умолчанию использовался бы пакет jsaddle-webkit2gtk, и при запуске мы бы увидели десктопное приложение. Стоит отметить, что еще существуют прослойки jsaddle-wkwebview (для iOS приложений) и jsaddle-clib (для Android приложений).


Простейшее приложение TODO


Приступим к разработке!


Добавим следующий код в todo-client/src/Main.hs.


{-# LANGUAGE MonoLocalBinds #-}{-# LANGUAGE OverloadedStrings #-}module Main whereimport Reflex.Dommain :: IO ()main = mainWidgetWithHead headWidget rootWidgetheadWidget :: MonadWidget t m => m ()headWidget = blankrootWidget :: MonadWidget t m => m ()rootWidget = blank

Можно сказать, что функция mainWidgetWithHead представляет собой элемент <html> страницы. Она принимает два параметра head и body. Существуют еще функции mainWidget и mainWidgetWithCss. Первая функция принимает только виджет с элементом body. Вторая первым аргументом принимает стили, добавляемые в элемент style, и вторым аргументом элемент body.


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

Функция blank равносильна pure () и она ничего не делает, никак не изменяет DOM и никак не влияет на сеть событий.


Теперь опишем элемент <head> нашей страницы.


headWidget :: MonadWidget t m => m ()headWidget = do  elAttr "meta" ("charset" =: "utf-8") blank  elAttr "meta"    (  "name" =: "viewport"    <> "content" =: "width=device-width, initial-scale=1, shrink-to-fit=no" )    blank  elAttr "link"    (  "rel" =: "stylesheet"    <> "href" =: "https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css"    <> "integrity" =: "sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh"    <> "crossorigin" =: "anonymous")    blank  el "title" $ text "TODO App"

Данная функция сгенерирует следующее содержимое элемента head:


<meta charset="utf-8"><meta content="width=device-width, initial-scale=1, shrink-to-fit=no" name="viewport"><link crossorigin="anonymous" href="http://personeltest.ru/aways/stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css"  integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" rel="stylesheet"><title>TODO App</title>

Класс MonadWidget позволяет строить или перестраивать DOM, а также определять сеть событий, которые происходят на странице.


Функция elAttr имеет следующий тип:


elAttr :: forall t m a. DomBuilder t m => Text -> Map Text Text -> m a -> m a

Она принимает название тэга, атрибуты и содержимое элемента. Возвращает эта функция, и вообще весь набор функций, строящих DOM, то, что возвращает их внутренний виджет. В данном случае наши элементы пустые, поэтому используется blank. Это одно из наиболее частых применений этой функции когда требуется сделать тело элемента пустым. Так же используется функция el. Ее входными параметрами являются только название тэга и содержимое, другими словами это упрощенная версия функции elAttr без атрибутов. Другая функция, используемая здесь text. Ее задача вывод текста на странице. Эта функция экранирует все возможные служебные символы, слова и тэги, и поэтому именно тот текст, который передан в нее, будет выведен. Для того чтобы встроить кусок html, существует функция elDynHtml.


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


type MonadWidgetConstraints t m =  ( DomBuilder t m  , DomBuilderSpace m ~ GhcjsDomSpace  , MonadFix m  , MonadHold t m  , MonadSample t (Performable m)  , MonadReflexCreateTrigger t m  , PostBuild t m  , PerformEvent t m  , MonadIO m  , MonadIO (Performable m)#ifndef ghcjs_HOST_OS  , DOM.MonadJSM m  , DOM.MonadJSM (Performable m)#endif  , TriggerEvent t m  , HasJSContext m  , HasJSContext (Performable m)  , HasDocument m  , MonadRef m  , Ref m ~ Ref IO  , MonadRef (Performable m)  , Ref (Performable m) ~ Ref IO  )class MonadWidgetConstraints t m => MonadWidget t m

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


newtype Todo = Todo  { todoText :: Text }newTodo :: Text -> TodonewTodo todoText = Todo {..}

Тело будет иметь следующую структуру:


rootWidget :: MonadWidget t m => m ()rootWidget =  divClass "container" $ do    elClass "h2" "text-center mt-3" $ text "Todos"    newTodoEv <- newTodoForm    todosDyn <- foldDyn (:) [] newTodoEv    delimiter    todoListWidget todosDyn

Функция elClass на вход принимает название тэга, класс (классы) и содержимое. divClass это сокращенная версия elClass "div".


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


foldDyn :: (Reflex t, MonadHold t m, MonadFix m) => (a -> b -> b) -> b -> Event t a -> m (Dynamic t b)

Она похожа на foldr :: (a -> b -> b) -> b -> [a] -> b и, по сути, выполняет такую же роль, только в роли списка здесь событие. Результирующее значение обернуто в контейнер Dynamic, т.к. оно будет обновляться после каждого события. Процесс обновления задаётся функцией-параметром, которая принимает на вход значение из возникшего события и текущее значение из Dynamic. На их основе формируется новое значение, которое будет находиться в Dynamic. Это обновление будет происходить каждый раз при возникновении события.


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


Функция newTodoForm строит ту часть DOM, в которой будет форма ввода описания задания, и возвращает событие, которое несет в себе новое Todo. Именно при возникновении этого события будет обновляться список заданий.


newTodoForm :: MonadWidget t m => m (Event t Todo)newTodoForm = rowWrapper $  el "form" $    divClass "input-group" $ do      iEl <- inputElement $ def        & initialAttributes .~          (  "type" =: "text"          <> "class" =: "form-control"          <> "placeholder" =: "Todo" )      let        newTodoDyn = newTodo <$> value iEl        btnAttr = "class" =: "btn btn-outline-secondary"          <> "type" =: "button"      (btnEl, _) <- divClass "input-group-append" $        elAttr' "button" btnAttr $ text "Add new entry"      pure $ tagPromptlyDyn newTodoDyn $ domEvent Click btnEl

Первое нововведение, которое мы встречаем тут, это функция inputElement. Ее название говорит само за себя, она добавляет элемент input. В качестве параметра она принимает тип InputElementConfig. Он имеет много полей, наследует несколько различный классов, но в данном примере нам наиболее интересно добавить нужные атрибуты этому тегу, и это можно сделать при помощи линзы initialAttributes. Функция value является методом класса HasValue и возвращает значение, которое находится в данном input. В случае типа InputElement оно имеет тип Dynamic t Text. Это значение будет обновляться при каждом изменении, происходящем в поле input.


Следующее изменение, которое тут можно заметить, это использование функции elAttr'. Отличие функций со штрихом от функций без штриха для построения DOM заключается в том, что эти функции вдобавок возвращают сам элемент страницы, с которым мы можем производить различные манипуляции. В нашем случае он необходим, чтобы мы могли получить событие нажатия на этот элемент. Для этого служит функция domEvent. Эта функция принимает название события, в нашем случае Click и сам элемент, с которым связано это событие. Функция имеет следующую сигнатуру:


domEvent :: EventName eventName -> target -> Event t (DomEventType target eventName)

Ее возвращаемый тип зависит от типа события и типа элемента. В нашем случае это ().


Следующая функция, которую мы встречаем tagPromptlyDyn. Она имеет следующий тип:


tagPromptlyDyn :: Reflex t => Dynamic t a -> Event t b -> Event t a

Ее задача заключается в том, чтобы при срабатывании события, поместить в него значение, которое находится в данный момент внутри Dynamic. Т.е. событие, являющееся результатом функции tagPromptlyDyn valDyn btnEv возникает одновременно с btnEv, но несёт в себе значение, которое было в valDyn. Для нашего примера это событие будет происходить при нажатии кнопки и нести в себе значение из текстового поля ввода.


Тут следует сказать про то, что функции, которые содержат в своём названии слово promptly, потенциально опасные они могут вызывать циклы в сети событий. Внешне это будет выглядеть так, как будто приложение зависло. Вызов tagPromplyDyn valDyn btnEv, по возможности, надо заменять на tag (current valDyn) btnEv. Функция current получает Behavior из Dynamic. Эти вызовы не всегда взаимозаменяемые. Если обновление Dynamic и событие Event в tagPromplyDyn возникают в один момент, т.е. в одном фрейме, то выходное событие будет содержать те данные, которые получил Dynamic в этом фрейме. В случае, если мы будем использовать tag (current valDyn) btnEv, то выходное событие будет содержать те данные, которыми исходный current valDyn, т.е. Behavior, обладал в прошлом фрейме.


Здесь мы подошли к еще одному различию между Behavior и Dynamic: если Behavior и Dynamic получают обновление в одном фрейме, то Dynamic будет обновлен уже в этом фрейме, а Behavior приобретет новое значение в следующем. Другими словами, если событие произошло в момент времени t1 и в момент времени t2, то Dynamic будет обладать значением, которое принесло событие t1 в промежутке времени [t1, t2), а Behavior (t1, t2].


Задача функции todoListWidget заключается в выводе всего списка Todo.


todoListWidget :: MonadWidget t m => Dynamic t [Todo] -> m ()todoListWidget todosDyn = rowWrapper $  void $ simpleList todosDyn todoWidget

Здесь встречается функция simpleList. Она имеет следующую сигнатуру:


simpleList  :: (Adjustable t m, MonadHold t m, PostBuild t m, MonadFix m)  => Dynamic t [v]  -> (Dynamic t v -> m a)  -> m (Dynamic t [a])

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


todoWidget :: MonadWidget t m => Dynamic t Todo -> m ()todoWidget todoDyn =  divClass "d-flex border-bottom" $    divClass "p-2 flex-grow-1 my-auto" $      dynText $ todoText <$> todoDyn

Функция dynText отличается от функции text тем, что на вход принимает текст, обернутый в Dynamic. В случае, если элемент списка будет изменен, то это значение также обновится в DOM.


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


rowWrapper :: MonadWidget t m => m a -> m arowWrapper ma =  divClass "row justify-content-md-center" $    divClass "col-6" ma

Функция delimiter просто добавляет элемент-разделитель.


delimiter :: MonadWidget t m => m ()delimiter = rowWrapper $  divClass "border-top mt-3" blank


Полученный результат можно посмотреть в нашем репозитории.


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

Подробнее..

Создаем веб-приложение на Haskell с использованием Reflex. Часть 3

17.05.2021 18:06:47 | Автор: admin

Часть 1.


Часть 2.


Всем привет! В этой части мы рассмотрим использование класса EventWriter и библиотеки ghcjs-dom.



Использование EventWriter


Сейчас, для того, чтобы прокинуть события с более глубоких уровней, мы передаем их в качестве возвращемых значений. Это не всегда удобно, особенно, когда надо возвращать что-то, помимо события (например, форма ввода может возвращать одновременно и событие нажатия кнопки, и данные из формы). Гораздо удобнее было бы использовать механизм, который может "прокинуть" события наверх автоматически, не задумываясь о том, что надо их постоянно возвращать. И такой механизм есть EventWriter. Этот класс позволяет записывать события, наподобие стандартной монады Writer. Перепишем наше приложение с использованием EventWriter.


Для начала рассмотрим сам класс EventWriter:


class (Monad m, Semigroup w) => EventWriter t w m | m -> t w where  tellEvent :: Event t w -> m ()

Тип w как раз и есть тип нашего события, и этот тип является экземпляром класса Semigroup, т.е. значения этого типа можно комбинировать друг с другом. В том случае, если два разных события записываются с помощью tellEvent, и они в какой-то один и тот же момент срабатывают, то они должны быть как-то объединены в одно событие того же типа, чтобы результатом выполнения монады было одно событие.


Существует трансформер, являющийся экземпляром этого класса EventWriterT, для его запуска используется функция runEventWriterT.


Далее переходим к изменению функций. Наибольшие изменения ожидают функцию rootWidget.


rootWidget :: MonadWidget t m => m ()rootWidget =  divClass "container" $ mdo    elClass "h2" "text-center mt-3" $ text "Todos"    (_, ev) <- runEventWriterT $ do      todosDyn <- foldDyn appEndo mempty ev      newTodoForm      delimiter      todoListWidget todosDyn    blank

Мы добавили запуск трансформера и избавились от всех возвращаемых событий.


Изменения в newTodoForm не такие большие, но все же, стоит их отметить:


newTodoForm :: (EventWriter t (Endo Todos) m, MonadWidget t m) => m ()newTodoForm = rowWrapper $ el "form" $ divClass "input-group" $ mdo  iEl <- inputElement $ def    & initialAttributes .~      (  "type" =: "text"      <> "class" =: "form-control"      <> "placeholder" =: "Todo" )    & inputElementConfig_setValue .~ ("" <$ btnEv)  let    addNewTodo = \todo -> Endo $ \todos ->      insert (nextKey todos) (newTodo todo) todos    newTodoDyn = addNewTodo <$> value iEl    btnAttr = "class" =: "btn btn-outline-secondary"      <> "type" =: "button"  (btnEl, _) <- divClass "input-group-append" $    elAttr' "button" btnAttr $ text "Add new entry"  let btnEv = domEvent Click btnEl  tellEvent $ tagPromptlyDyn newTodoDyn $ domEvent Click btnEl

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


Функция todoListWidget сильно упростилась.


todoListWidget  :: (EventWriter t (Endo Todos) m, MonadWidget t m)  => Dynamic t Todos -> m ()todoListWidget todosDyn = rowWrapper $  void $ listWithKey (M.fromAscList . IM.toAscList <$> todosDyn) todoWidget

Нас теперь вообще не интересует возвращаемое событие, и, соответственно, отпала необходимость в извлечении Event из Dynamic.


В функции todoWidget также произошли заметные изменения. Больше нет необходимости работать с возвращаемым типом преобразовывать Event t (Event t TodoEvent). Отличие функции dyn_ от функции dyn, в том, что она игнорирует возвращаемое значение.


todoWidget  :: (EventWriter t (Endo Todos) m, MonadWidget t m)  => Int -> Dynamic t Todo -> m ()todoWidget ix todoDyn' = do  todoDyn <- holdUniqDyn todoDyn'  dyn_ $ ffor todoDyn $ \td@Todo{..} -> case todoState of    TodoDone         -> todoDone ix todoText    TodoActive False -> todoActive ix todoText    TodoActive True  -> todoEditable ix todoText

Единственное изменение в функциях todoDone, todoActive и todoEditable это новый тип и запись события вместо его возврата.


todoActive  :: (EventWriter t (Endo Todos) m, MonadWidget t m)  => Int -> Text -> m ()todoActive ix todoText = divClass "d-flex border-bottom" $ do  divClass "p-2 flex-grow-1 my-auto" $    text todoText  divClass "p-2 btn-group" $ do    (doneEl, _) <- elAttr' "button"      (  "class" =: "btn btn-outline-secondary"      <> "type" =: "button" ) $ text "Done"    (editEl, _) <- elAttr' "button"      (  "class" =: "btn btn-outline-secondary"      <> "type" =: "button" ) $ text "Edit"    (delEl, _) <- elAttr' "button"      (  "class" =: "btn btn-outline-secondary"      <> "type" =: "button" ) $ text "Drop"    tellEvent $ Endo <$> leftmost      [ update (Just . toggleTodo) ix <$ domEvent Click doneEl      , update (Just . startEdit) ix  <$ domEvent Click editEl      , delete ix <$ domEvent Click delEl      ]todoDone  :: (EventWriter t (Endo Todos) m, MonadWidget t m)  => Int -> Text -> m ()todoDone ix todoText = divClass "d-flex border-bottom" $ do  divClass "p-2 flex-grow-1 my-auto" $    el "del" $ text todoText  divClass "p-2 btn-group" $ do    (doneEl, _) <- elAttr' "button"      (  "class" =: "btn btn-outline-secondary"      <> "type" =: "button" ) $ text "Undo"    (delEl, _) <- elAttr' "button"      (  "class" =: "btn btn-outline-secondary"      <> "type" =: "button" ) $ text "Drop"    tellEvent $ Endo <$> leftmost      [ update (Just . toggleTodo) ix <$ domEvent Click doneEl      , delete ix <$ domEvent Click delEl      ]todoEditable  :: (EventWriter t (Endo Todos) m, MonadWidget t m)  => Int -> Text -> m ()todoEditable ix todoText = divClass "d-flex border-bottom" $ do  updTodoDyn <- divClass "p-2 flex-grow-1 my-auto" $    editTodoForm todoText  divClass "p-2 btn-group" $ do    (doneEl, _) <- elAttr' "button"      (  "class" =: "btn btn-outline-secondary"      <> "type" =: "button" ) $ text "Finish edit"    let updTodos = \todo -> Endo $ update (Just . finishEdit todo) ix    tellEvent $      tagPromptlyDyn (updTodos <$> updTodoDyn) (domEvent Click doneEl)

Применение класса EventWriter упростило код и сделало его более читаемым.


ghcjs-dom


reflex позволяет нам только модифицировать DOM, но зачастую от JS-приложений требуется больше. Например, если требуется копировать текст по нажатию на кнопку, то reflex не предоставляет нужных нам для этого средств. На помощь приходит библиотека ghcjs-dom. По сути, это реализация JS API на Haskell. В ней можно найти все те же самые типы и функции, которые есть в JS.


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


function toClipboard(txt){  var inpEl = document.createElement("textarea");  document.body.appendChild(inpEl);  inpEl.value = txt  inpEl.focus();  inpEl.select();  document.execCommand('copy');  document.body.removeChild(inpEl);}

В привычном использовании мы вешаем этот обработчик, например, на кнопку.
Как это будет выглядеть на Haskell? В первую очередь, создадим новый модуль GHCJS для работы с ghcjs и определим соответствующую функцию.


{-# LANGUAGE OverloadedStrings #-}{-# LANGUAGE MonoLocalBinds #-}module GHCJS whereimport Control.Monadimport Data.Functor (($>))import Data.Text (Text)import GHCJS.DOMimport GHCJS.DOM.Document  (createElement, execCommand, getBodyUnchecked)import GHCJS.DOM.Element as Element hiding (scroll)import GHCJS.DOM.HTMLElement as HE (focus)import GHCJS.DOM.HTMLInputElement as HIE (select, setValue)import GHCJS.DOM.Node (appendChild, removeChild)import GHCJS.DOM.Types hiding (Event, Text)import Reflex.Dom as RtoClipboard :: MonadJSM m => Text -> m ()toClipboard txt = do  doc <- currentDocumentUnchecked  body <- getBodyUnchecked doc  inpEl <- uncheckedCastTo HTMLInputElement <$> createElement doc    ("textarea" :: Text)  void $ appendChild body inpEl  HE.focus inpEl  HIE.setValue inpEl txt  HIE.select inpEl  void $ execCommand doc ("copy" :: Text) False (Nothing :: Maybe Text)  void $ removeChild body inpEl

Почти каждой строке из haskell функции toClipboard есть соответствие из JS функции. Стоит отметить, что здесь нет привычного класса MonadWidget, а используется MonadJSM это та монада, в которой производятся вся работы с помощью ghcjs-dom. Класс MonadWidget наследует класс MonadJSM. Рассмотрим, как осуществляется привязка обработчика к событию:


copyByEvent :: MonadWidget t m => Text -> Event t () -> m ()copyByEvent txt ev =  void $ performEvent $ ev $> toClipboard txt

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


class (Reflex t, Monad (Performable m), Monad m) => PerformEvent t m | m -> t where  type Performable m :: * -> *  performEvent :: Event t (Performable m a) -> m (Event t a)  performEvent_ :: Event t (Performable m ()) -> m ()

Теперь изменим виджет невыполненного задания, предварительно не забыв добавить импорт import GHCJS:


todoActive  :: (EventWriter t TodoEvent m, MonadWidget t m) => Int -> Todo -> m ()todoActive ix Todo{..} =  divClass "d-flex border-bottom" $ do    divClass "p-2 flex-grow-1 my-auto" $      text todoText    divClass "p-2 btn-group" $ do      (copyEl, _) <- elAttr' "button"        (  "class" =: "btn btn-outline-secondary"        <> "type" =: "button" ) $ text "Copy"      (doneEl, _) <- elAttr' "button"        (  "class" =: "btn btn-outline-secondary"        <> "type" =: "button" ) $ text "Done"      (editEl, _) <- elAttr' "button"        (  "class" =: "btn btn-outline-secondary"        <> "type" =: "button" ) $ text "Edit"      (delEl, _) <- elAttr' "button"        (  "class" =: "btn btn-outline-secondary"        <> "type" =: "button" ) $ text "Drop"      copyByEvent todoText $ domEvent Click copyEl      tellEvent $ leftmost        [ ToggleTodo ix <$ domEvent Click doneEl        , StartEditTodo ix <$ domEvent Click editEl        , DeleteTodo ix <$ domEvent Click delEl        ]

Была добавлена новая кнопка Copy и вызов определенной функции copyByEvent. Эти же самые действия можно проделать с виджетами для других состояний задания.


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


В следующей части рассмотрим использование JSFFI (JS Foreign Function Interface).

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru