Монада Maybe на языке C++

Монада – это вообщем-то просто паттерн, но из функционального программирования. А поскольку в 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++”

  1. Evgeny Khlynov Avatar
    Evgeny Khlynov

    В вашей лекции с 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?

    1. Да, собственно по мере развития компилятора он научился таки выводить типы, и весь тот огород что я там нагородил уже не нужен. Кстати constructor type deduction тоже вроде появилось в С++14.

Оставить комментарий