Монада – это вообщем-то просто паттерн, но из функционального программирования. А поскольку в C++ есть и лямбды и всякие функциональные объекты, почему бы не построить монаду? Не обещаю что будет красиво, но все же.
Сценарий
Монада Maybe – это такой подход при обработке присутствия или отсутствия того или иного поля у класса. Вот например допустим мы моделируем человека и у него есть адрес:
struct Person
{
Address* address = nullptr;
};
Сам по себе адрес – это там всякие номер дома, улица и так далее, но если вы мажор (или Enya) и вы купили замок, то у него вместо улицы и номера доме есть просто имя. Соответственно имеем:
struct Address
{
string* house_name = nullptr;
};
Тут и выше, использование raw pointer’ов – оно намеренное. Можно было бы вместо них использовать shared_ptr или boost::optional, об этом поговорим позже.
Итак, допустим мы хотим написать функцию которая, если ей скормить указатель на Person, печатает имя дома если таковое имеется. Простая реализация будет выглядеть как-то так:
void print_house_name(Person* p)
{
if (p!= nullptr && p->address != nullptr && p->address->house_name != nullptr)
cout << *p->address->house_name << endl;
}
Думаю проблема ясна – все эти проверки на nullptr утомительны и хотелось бы че-то попроще. Ну как сказать попроще… поумнее что ли.
Реализуем Maybe
Итак, мы будем строить экскаватор, т.е. класс который умеет копать вглубь объектов и при этом проверки на nullptr в него встроены, т.е. их не нужно делать самому. Начнем с того, что сделаем просто класс с указателем:
template <typename T> struct Maybe
{
T* context;
explicit Maybe(T* const context)
: context{context}
{
}
};
Вот, просто класс который держит указатель на какой-то контекст, т.е. на то место, где мы сейчас копаем. Все бы хорошо, но у C++ нет вывода типов для конструкторов, т.к. вот так нельзя:
Person p;
Maybe m{p}; // не сработает
// придется писать Maybe<Person> m{p};
Это как-то уныло, в связи с чем мы реализуем helper function который займется собственно выводом типов:
template <typename T> Maybe<T> maybe(T* context)
{
return Maybe<T>(context);
}
Немного грустно писать подобные вещи, но что поделать. Теперь мы можем написать Person p; maybe(p) а дальше вызывать функции на свежеиспеченном объекте. Что за функции? В вот тут все самое интересное.
Давайте представим, что мы у человека хотим получить адрес и записать его в контекст. Это можно сделать с помощью лямбды, т.е. как-то вот так:
maybe(p)
.With([](auto x) { return x->address; })
// а что тут - потом узнаете
Тут правила такие:
- Если текущий контекст не
nullptr, то можно исполнять лямбду, и возвращатьMaybe<Address>с установленным контекстом - Если текущий контекст уже
nullptr, то вызывать лямбду нельзя, но возвращать контекст все еще нужно
Печаль состоит в том, что нельзя просто вернуть текущий контекст, т.к. Maybe<Person> и Maybe<Address> – это разные типы. В связи с этим мы получим что-то вроде
template <typename Func>
auto With(Func evaluator)
{
if (context == nullptr)
{
// а вот тут будет интересно
}
else
{
return maybe(evaluator(context));
}
}
Итак, вроде как возврат значения в случае если «все ОК» идет через функцию, которая тут шаблонным параметром представлена (были попытки использовать std::function, но оно в контесте шаблонов с лямбдами не дружит). Так вот, тут все как бы ОК, но что насчет возврата контекста если у нас уже nullptr? По идее нужно вернуть всего лишь Maybe<T>{nullptr} где T – это тип контеста, который использует скормленная нам лямбда. Проблема в том, что
- У нас нет
Tв явном виде - Зато мы знаем что функция, которую нам скормили, возвращает
T* - Следовательно, удаляем указатель, и вперед:
template <typename Func>
auto With(Func evaluator)
{
if (context == nullptr)
{
return Maybe<typename remove_pointer<decltype(evaluator(context))>::type>(nullptr);
}
else
{
return maybe(evaluator(context));
}
}
Как видите, получилось немного адово, но оно работает. Теперь можно писать
maybe(p)
.With([](auto x) { return x->address; })
.With([](auto x) { return x->house_name; })
и углубление в структуру произойдет только если текущий контекст не nullptr.
Icing on the Cake
Обычно в монаду Maybe добавляют еще всякого мусора для просто обработки, if-ов и всякого такого. Например, добавим в нашу монаду функцию Do(), которая будет просто выполнять некую лямбду:
template <typename Func>
auto Do(Func action)
{
if (context != nullptr) action(context);
return *this;
}
Выше, как видите, мы как раз можем возвращать *this потому что контекст мы в этой функции не меняем. Теперь мы можем дописать нашу оригинальную функцию:
void print_house_name(Person* p)
{
maybe(p)
.With([](auto x) { return x->address; })
.With([](auto x) { return x->house_name; })
.Do([](auto x){ cout << *x << endl; });
}
Вот как-то так: теперь эту функцию можно саму вызывать с nullptr, или вызвать с не до конца инициализированным адресом (или отсутствующим вообще), и все будет работать спокойно и безопасно, просто как только цепочка вызовов наткнется на nullptr, все что будет происходить дальще — один большой NOOP.
Дискуссия
Я тут очень синтетично использовал nullable конструкты, что в реальной жизни используют разве что в языке С, в котором все равно подобную штуку не построить. В реальной жизни можно строки очень часто просто держатся by value, то есть ну будет пустая строка, но никак не null, и соответственно придется уже разбирать тот факт что она пустая.
То же самое насчет просто объектов, которые часто не T* а shared_ptr<T>/unique_ptr<T> или даже boost::optional<T> и соответственно проверять их уже нужно по-другому.
Вообщем вот как-то так. Спасибо коллегам из R++ за помощь с конструированием этой монстрятины. Да, с «методами расширения» да более краткими лямбдами было бы еще круче, я знаю! ■
2 responses to “Монада Maybe на языке C++”
В вашей лекции с youtube функция With выглядит проще
template
auto With(TFunc evaluator)
{
return context != nullptr ? maybe(evaluator(context)) : nullptr;
};
Вместо
return Maybe<typename remove_pointer::type>(nullptr);
возвращается просто nullptr.
Получается что компилятор (зная что функция должна возвращать Maybe) сам подбирает тип аргумента и конструирует временное Maybe инициализированное nullptr?
Да, собственно по мере развития компилятора он научился таки выводить типы, и весь тот огород что я там нагородил уже не нужен. Кстати constructor type deduction тоже вроде появилось в С++14.