Монады и do-нотация в C++

от автора

В Хаскеле как известно есть монады, а в C++ их нет. Но ничто не мешает реализовать монады в С++. Посмотрим есть ли у нас всё необходимое для их реализации:

  • Передача функции как аргумент в другую функцию – есть.
  • Лямбда-функции – есть, добавлены в C++11. Я не уверен, что они необходимы для реализации монад, но с ними, несомненно, проще.
  • Type classes – нет. В C++ хотели добавить их аналог – концепты, но пока не добавили. Но для реализации монад они не нужны, можно просто перегружать функции или операторы под конкретные монады.
  • do-нотация. В C++ её нет, но для реализации монад она не нужна, хотя и делает их использование гораздо более удобным. Уже есть предложение добавить её аналог в стандарт, но об этом ниже.


Монада в Haskell определяется следующим образом:

class Monad m where 	(>>= ) ::m a -> (a->m b)->m b 	(>> ) ::m a->m b->m b 	return ::a->m a 	fail::String->m a 

Для начала возьмём некую абстрактную монаду, пусть в C++ она имеет тип Monad<T>. Посмотрим, как будут выглядеть соответствующие функции.

template<typename A, typename B> Monad<B> operator>>=(Monad<A> ma, function<Monad<B> (A)> f) { 	... // реализация зависит от монады } 

Эта функция извлекает значение из объекта ma, подставляет в функцию f и возвращает результат f.

template<typename A, typename B> Monad<B> operator>>(Monad<A> ma, Monad<B> mb) {     return Ma >>= [=](A){ return mb; }; } 

Эта функция аналогична функции >>=, но она игнорирует значение извлечённое из первой монады. У этой функции есть стандартная реализация (m >> k = m >>= \_ -> k), которую я перевёл с Haskell на C++.

template<typename A> Monad <A> mreturn(A a) {     ... // реализация зависит от монады } 

Слово return является зарезервированным в C++, поэтому я назвал эту функцию mreturn. Смысл этой функции в том, что она помещает объект a в некий минимальный контекст для данной монады.
Функция fail нужна для обработки ошибок, чтобы не загромождать эту статью я её опущу.

Итак, теперь понятно как будут выглядеть монады на C++, настало время взять какую-нибудь конкретную монаду и реализовать для неё эти функции. Можно конечно начать с монад типичных для Хаскеля — Maybe, список и др., но меня больше интересует std::future. Этот класс был добавлен в C++11. Объект типа future<T> позволяет получить доступ к объекту типа T, который будет доступен в будущем. Это может быть, например, результат вычисления какой-то очень сложной функции, которая долго вычисляется в другом потоке, или результат ввода, например информация, которая должна будет поступить по сети. Из всего класса future нас будет интересовать только функция get.

template<class T> class future { public:     ...     T get(); // функция get дожидается пока будет доступен объект и возвращает его     ... }; 

Также в C++11 появилась новая функция – std::async. В качестве аргумента она принимает функцию, запускает её в другом потоке и возвращает её результат в std::future. То есть можно писать код навроде этого:

future<int> result = async(f); // делаем какие-то операции одновременно с вычислением функции f int a = result.get(); // дожидаемся выполнения функции f и получаем её рузультат. 

Теперь нам не составит труда реализовать функции mreturn и >>= для монады std::future

#include <future> using namespace std;  template<typename A> future<A> mreturn(const A& a) { 	return async([=]{ return a; }); } 

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

template<typename A, typename B> future<B> operator>>=(future<A>& ma, const function<future<B> (A)>& f) { 	return async([&] () -> B { return f(ma.get()).get(); }); } 

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

Реализация готова, осталось проверить выполняются ли аксиомы монад. Всего у нас 3 аксиомы, которые на Хаскеле выглядят так:

Left identity : return a >>= f == f a Right identity: m >>= return == m Associativity: (m >>= f) >>= g == m >>= (\x -> f x >>= g) 

Переводим первую аксиому на C++, получается:

(mreturn(a) >>= f) == f(a) 

Подставим наши функции, получим:

async([&] () -> B { return f(mreturn(a).get()).get(); }) == f(a) 

Ясно, что mreturn(a).get() это тоже самое, что а. Эта конструкция просто помещает a в std::future, а затем извлекает его оттуда. Упрощаем, получаем:

async([&] () -> B { return f(a).get(); }) == f(a) 

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

Вторая аксиома:

m >>= mreturn == m 

Подставим наши функции, получим:

async([&] () -> B { return mreturn(m.get()).get(); }) == m 

Мы опять можем избавиться от mreturn(x).get(), получим:

async([&] () -> B { return m.get(); }) == m 

Теперь ясно, что выражение слева эквивалентно m. Оно просто создаёт поток, который дожидается пока будет доступно значение из m.

Третья аксиома:

(m >>= f) >>= g == m >>= (\x -> f x >>= g) 

Рассматривать подробно третью аксиому мне уже лень, но грубо говоря, она означает следующее. В выражении слева к фьючеру m навешивается функция f, которая будет выполнена, когда будет доступно значение из m. Затем на полученную конструкцию вешается функция g, которая будет выполнена после f. В выражении справа функции f и g сперва объединяются в одну функцию и затем уже она вешается на фьючер m.

С аксиомами мы разобрались. Теперь можно написать какой-нибудь бессмысленный код с нашей монадой:

function<future<int> (int)> f = [](int a){ cout << a << '\n'; return mreturn(a + 6); }; int a = (mreturn(5) >>= f).get(); cout << a; 

Как и ожидалось, этот код выводит 5 и 11.

В C++11 нет функций аналогичных написанным мной mreturn и >>=. Но есть предложение добавить подобные функции: n3721
Аналогом mreturn будет make_ready_future.
Аналогом >>=, будет функция класса std::future под названием then. Основное её отличие в том, что она принимает аргумент не типа A, а типа future<A>. Это сделано для обработки ошибок, если нужное значение так и не будет сгенерировано, то получится future<A> содержащий ошибку и функция, переданная в then, сможет его обработать.
Также планируется добавить функцию unwrap которая сможет преобразовывать значения типа future<future<A>> в future<A>. Это аналог join из Control.Monad в Хаскеле.

Использование then может стать весьма нетривиальной вещью:

future<int> f(shared_ptr<stream> str)  {      shared_ptr<vector<char>> buf = ...;      return str->read(512, buf).then([](future<int> op)      {         return op.get() + 11;      });  }   future<void> g()  {      shared_ptr<stream> s = ...;      return f(s).then([s](future<int> op)      {          s->close();     });  }  

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

future<int> f(stream str) resumable {      shared_ptr<vector<char>> buf = ...;      int count = await str.read(512, buf);      return count + 11;  }  future<void> g() resumable {      stream s = ...;      int pls11 = await f(s);      s.close();  }  

Ключевое слово resumable означает, что это определение функции, которая может быть прервана, а затем её вычисление может быть продолжено. Самое интересное здесь это await – это аналог стрелки влево (<-) в do нотации в Хаскеле. Заметьте, что и str.read и f возвращают значения типа future<int>, но после применения к ним await получается int, аналогично работает и стрелка влево в Хаскеле.

Как же всё это должно работать? Та статья, в которой это предлагается, даёт два возможных пути для реализации: первый из них основан на создании дополнительного стека под каждую resumable функцию, аналогично тому, как работают Fibers в Windows или boost::coroutine. Второй способ более эффективный, интересный и сложный для реализации. В этом случае каждая resumable функция будет как-бы разбиваться на составляющие в местах, в которых находится await. Так, что каждый await будет вызывать соответствующий then и передавать ему в качестве аргумента функцию, которая начинается после await. Это позволит использовать resumable функции не только с std::future, но и с любыми типами у которых есть методы then и get. Тогда можно будет использовать и другие монады на C++, хотя возможно проблемы могут возникнуть с теми монадами, где требуется копирование функции, как например, в монаде список. Сложно сказать будет ли вообще смысл в использовании других монад помимо std::future.
Что интересно, в статье предлагающей resumable функции ни разу не упоминается слово монада. Видимо её авторы не знают что это такое, так как связь между resumable функциями и монадами довольно очевидна.

ссылка на оригинал статьи http://habrahabr.ru/post/205026/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *