Монада – это вообщем-то просто паттерн, но из функционального программирования. А поскольку в 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++ за помощь с конструированием этой монстрятины. Да, с «методами расширения» да более краткими лямбдами было бы еще круче, я знаю! ■
Оставить комментарий