Монады и монад трансформеры для разработчиков на С#. ENG
25 Июня 2014 – Карелия
Русский вариант поста не исправляется, и немного устарел, поэтому если вы видите явную ошибку, то гляньте английскую версию вернее всего там она исправлена. У меня просто нет времени на синхронизацию исправлений между двумя версиями.
После публикации этого поста некоторые читатели выразили мнение, что пример с монадой немного надуман и не показывают какого-либо преимущества перед простым кодом. Чтож, если вам необходим более интересный, но в тоже время более сложный пример, тогда вам стоит прочесть этот пост и после этого вернуться сюда
В процессе переноса небольшого проекта на fsharp, я задумался на тему использования монад на практике. Тема меня достаточно увлекла и я решил описать свой опыт в паре другой постов. К сожалению весь материал требовал неплохих знаний о монадах и монад трансформерах, в тоже время материал хотелось сделать цельным, самодостаточным, без кучи слов и простым для обычных программистов не обремененных знанием математики и синтаксиса функциональных языков программирования. Так родилась идея написать еще одно объяснение что же такое монады. Так как пост ориентирован на программистов, то и будем подходить не со стороны объяснений или математики, а со стороны кода. Готовы? Поехали.
Давайте рассмотрим простой код:
Функция GetData имитирует запрос к источнику данных и случайным образом возвращает null или значение. Функция Compare использует функцию GetData для получения двух значений, сравнивает их между собой и пишет ответ на консоль.
Что мы как ответственные программисты хотим добавить в этот код первым делом? Правильно, проверку на null в стиле defensive programming.
Да программа стала безопасней но видно повторяющийся код. Берем на вооружение DRY принцип и убираем повторы.
Стало немного лучше, но все равно после каждого вызова надо добавлять проверку if. Давайте попробуем убрать управление потоком исполнения в функцию Defend.
Выглядит замечательно, но есть проблема в том, что если мы будем использовать как значение например тип Test, то информация о нем пропадет и мы не сможем использовать его свойства и методы.
Попробуем обойти эту проблемы с помощью переменных типа.
Хм новая ошибка, дело в том, что мы пытаемся вернуть строку вместо типа Test. Что делать? А давайте создадим тип Check который будет содержать или значение или строку с ошибкой.
Вроде выглядит красиво. Попробуем в деле.
Опять проблема, вместо того чтобы вернуть в res тип Check туда приходит тип Check<Check>. Попробуем изменить немного логику работы чтобы предотвратить эту ошибку.
Теперь проверка на нулл происходит в функции лифт, задача которой преобразовать функцию возвращающую T в функцию возвращающую Check. Проблема решена. Можно думать об этом так: есть обычные функции и есть наша функция дефенд. Но для того чтобы функция дефенд могла работаь с нашими функциями их надо адаптировать. Вот как раз адаптацией функция lift и занимается. Проверяем.
Опять проблема, концовка вида b => a == b ? a : b возвращает тип Test который не адаптирован к функции Defend. Напишем простую вспомогательную функцию Return, которую будем дергать в самом конце и немного отрефакторим код.
Ура все заработало! Итак что мы имеем: тип обертку Check над T, две функции Defend, Return и все это вместе позволяет нам писать защищенный код без кучи if проверок. На самом деле мы можем определить другой враппер класс и описать функцию return и defend над ним и получить довольно интересные возможности по исполнению dry кода в цепочке функций. На самом деле возможностей у этого паттерна намного больше и он давно известен под названием монада, вот только функция defend там называется bind.
Вот только есть один недостаток: очень уж неудобно записывать подобным образом код в виде вложенных функций. К счастью решение есть. В некоторых языках есть поддержка монадического синтаксиса, например в csharp это linq queries, в Haskell Do нотация, в fsharp computation expressions. К слову сказать возможности computation expressions выходят далеко за пределы монад, но об этом в другой раз. Давайте попробуем адаптировать наш код под linq expressions для этого нам надо над нашим враппер типом описать статическую экстеншн функцию SelectMany. Экстеншн функция это статическая функция которую можно подцепить к существующему классу. Давайте ее определим и заодно отрефакторим код:
Вот теперь все в порядке. И подобным образом мы можем определить другие монады например async которая позволит нам выполнять наш код асинхронно(мы ее напишем чуть позже). Мир чудесен. Но не в нашем туториале. Одно из достоинств монад в том что описав код над монадой, мы можем его запускать поверх других монад до тех пор пока монада внутри себя проносит тот же тип. Например сравните код для монады Async
и монады Check
Очень круто но есть проблема с композицией монад. Допустим хотелось бы нам заставить работать функции которые возвращают Async<Check> с нашим красивым синтаксисом. Однако наша функция Bind в типе Async ничего не знают о вложенном типе Check, поэтому наш красивый код работать не будет, наша функция bind развернет только Async и вернет Сheck вместо T. Вот тут в дело вступают монад трансформеры. Что такое монад трансформер? Это такая штука которая беря на вход неизвестную монаду добавляет к ней функциональность известной трансформеру монады и в результате получаем комбинированную монаду. Допустим в нашем случае к монадам Async и Check которые мы не можем использовать вместе, мы можем написать монад трансформеры AsyncT<T,ParеntMonad> и CheckT<T, ParentMonad> и для нашего случая Async<Check> мы спокойно можем сделать что то типа такого:
Обычно все туториалы в этом месте оканчиваются словами о том что подбные штуки есть в Хаскелл и Скала, но в C# нету higher kinded types и тут их реализовать невозможно. Занавес. Но мы то прожженные SharePoint энтерпрайз разработчики, мы не знаем слов любви и жалости. Заказчику НАДО значит надо. Ок давайте посмотрим на эту проблему поближе. Мы разберем типичный пример. На самом деле он не показателен, так как конкретно эту проблему можно обойти несколькими способами, но мы решим ее механизмом который позволит в дальнейшем решить проблему трансформеров. Итак есть интерфейс:
На первый взгляд ничего подозрительного, у нас есть контейнерный тип и далее мы определяем сигнатуру требуемого метода который применяет функцию над типом A к завернутому в T типу A и оборачивает результат функции типа B в контейнерный тип T. Все хорошо за искоючением того что в c# нельзя задать подобный тип T. Как видно из кода тип T является генериком T<_> и тут то собака и порылась. Я не буду объяснять суть проблемы, тут проще взять и скопировать определение функтора данное выше и попробовать решить проблему самим в ide. Это отличная головоломка. И сразу становиться все ясно.
Попробовали? Решили? Отлично. Можете дальше не читать. Теперь решим вместе.
В чем суть типа T в нашем интерфейсе? Зачем он нужен, а нужен он для того чтобы пометить входящий и выходящий контейнеры одним маркером и наложить ограничение на то, что они должны быть одним и темже генерик типом. Чтобы не получилось что на входе List, а на выходе Check. Ок задача ясна пометить генерик тип негенерик типом. Как сделать? Да просто:
Хм интересно. Давайте подумаем какую гарантию нам это дает. А дает оно нам гарантию того что инстанс типа Wrapper всегда будет инстансом типа WrapperImpl, единственное в чем надо быть уверенными так это в том что при апкасте мы не промажем с типом содержащимся в WrapperImpl. Давайте введем специальный тип контейнер для информации о генерик типе и немного перепишем определение враппер типа.
Ну и теперь добавить безопасный хелпер метод для кастов.
Ну и с учетом выше написанного перепишем определение интерфейса для функтора
Вуаля, ловкость пальцев и никакого обмана. Единственное условие, это следовать паттерну одиночного наследника, чтобы одним маркером нельзя было маркировать несколько классов. Давайте соберем все вместе и посмотрим работает ли.
Ура. Мы сорвали джек пот, пора просить добавку к зарплате у начальства.
Теперь у нас есть все, чтобы для начала определить интерфейс для монад и наслаждаться полиморфным кодом над ними, а также получить возможность их композиции.
На данный момент в коде должно быть все понятно, мы взяли идею из функтора применили к интерфейсу для монад, который содержит определение методов bind и return. Теперь мы можем переписать Check монаду на основе этого интерфейса и реализовать Async монаду. Async монада может послужить примером того как можно сделать монадическую обертку над существующим генерик типом, который не имплементирует интерфейс монады. В нашем случае Async это просто обертка над типом Task и можно думать, что это адаптер типа Task к монадическому интерфейсу.
Ну Check тип мы уже видели что работает, а как там насчет Async?
Удивительно я бы сказал, но оно работает. Итак полиморфные монады у нас в кармане, теперь приступим к трансформерам. Итак есть тип Async<Check> который является монадой Async над типом Check, но нам бы хотелось его превратить в монаду Async<Check<_>> над типом T. Как это сделать? Все просто надо завернуть Async<Check> в монаду над типом T. Допустим назовем этот тип трансформером и для Check монады назовем его CheckT. В конце концов мы получим конструкцию вида CheckT<Async<Check>> этакий трехслойный бутерброд. Главная фишка что этот CheckT реализует интерфейс не IMonad над Async<Check>>, а Imonad над T. Еще раз повторим: CheckT это враппер для типов вида SomeOtherMonad<CheckMonad> и функция Lift для CheckT будет преобразовывать функции возвращающие SomeMonad в функции возвращающие CheckT<SomeOtherMonad<CheckMonad>>. В функции return он будет завертывать значение в тип Check, а потом его передавать методу return класа SomeOtherMonad и на выходе кастить в себя. В Bind поведение чуть сложнее и вся магия происходит там, но суть таже. Наверное это сложнее описать словами чем кодом. Чтож давайте реализуем CheckT. Я для облегчения понимания решил разделить определение типов CheckedVal это просто тип контейнер. Check.CheckM это монада над CheckedVal и
CheckForT.CheckT это трансформер над CheckedVal. Хотя такое разделение излишне и тип CheckedVal должен быть включен в тип Check.CheckM.
Особое внимание в коде надо обратить на функцию bind и место где описан маркер обертываемой монады TMI. Он определен в типе маркере трансформера CheckForT.CheckT, а не в типе трансформера CheckForT.CheckT<T,TMI>. Это сделано для того чтобы при реалзации методов интерфейса монады, у нас не терялся тип обернутой монады. Так мы застраховались от вызова bind на функциях которые возвращают разные обернутые и трансформированные в CheckT монады. Это вызвало бы ошибку в рантайм. Итак вся магия здесь.
Ну что пора показать заказчику рабочий код для Async монады.
Полный код здесь. Опять работает. Ну мы добились того о чем грезили все генетики последние годы, мы скрестили ужа с ежом. Причем одна из монад была просто оболчкой над существующим классом Task. В коде видно как неудобно разворачивать завернутые в кучу оберток типы, но это можно исправить перенеся финальный код в синтаксис монады или создав хелпер методы. Я надеюсь материал был не шибко запутан и мой стиль подачи вас не напугал. В следующей часте будет интересней: посмотрим на отличия computation expressions от монад, обнаружим что монады являются тюринг полными и обсудим возможности их применения в области определения семантики для монадического синтаксиса.