Функциональное программирование непопулярно, потому что оно странное

в 22:32, , рубрики: c++, Программирование, разработка, функциональное программирование, шаблоны c++

Я знаю людей, которые искренне недоумевают по поводу того, что функциональное программирование не очень популярно. К примеру, сейчас я читаю книжку «Из смоляной ямы» (Out of the Tar Pit), в которой авторы после аргументов в пользу функционального программирования говорят:

Тем не менее, факт остается фактом: этих аргументов не хватило для того, чтоб функциональное программирование получило широкое распространение. Следовательно, мы должны заключить, что главный недостаток функционального программирования—это обратная сторона его главного достоинства, а именно, что проблемы возникают, когда (как это часто бывает) проектируемая система должна поддерживать какого-то рода состояние.

А я думаю, что причина недостаточной популярности намного проще: программирование в функциональном стиле часто происходит «задом наперед» и выглядит больше как решение головоломок, чем объяснение задачи компьютеру. Часто, когда я пишу на функциональном языке, я знаю, что хочу сказать, но в итоге я решаю головоломки, чтоб выразить это средствами языка. Короче, функциональное программирование просто слишком неестественное.

Чтоб дальше обсуждать функциональное программирование, давайте попробуем испечь пирог. Возьмем рецепт отсюда. Примерно так мы будем печь императивный пирог:

  1. Разогрейте духовку до 175°C. Смажьте маслом и посыпьте мукой противень. В маленькой миске смешайте муку, пищевую соду и соль.
  2. В большой миске взбивайте масло, сахар-песок и коричневый сахар до тех пор, пока масса не станет легкой и воздушной. Вбейте яйца, одно за раз. Добавьте бананы и разотрите до однородной консистенции. Поочередно добавляйте в получившуюся кремовую массу основу для теста из п. 1 и кефир. Добавьте измельченные грецкие орехи. Выложите тесто в подготовленный противень.
  3. Запекайте в разогретой духовке 30 минут. Выньте противень из духовки, поставьте на полотенце, чтоб пирог остыл.

Я позволил себе несколько вольностей с нумерацией (очевидно, каждый шаг—это на самом деле несколько шагов), но давайте лучше посмотрим, как мы будем печь функциональный пирог:

  1. Пирог—это горячий пирог, остывший на полотенце, где горячий пирог—это подготовленный пирог, выпекавшийся в разогретой духовке 30 минут.
  2. Разогретая духовка—это духовка, разогретая до 175°C.
  3. Подготовленный пирог—это тесто, выложенное в подготовленный противень, где тесто—это кремовая масса, в которую добавили измельченные грецкие орехи. Где кремовая масса—это масло, сахар-песок и коричневый сахар, взбитые в большой миске до тех пор, пока они не стали легкими и воздушными, где…

А, ну его к черту—я не могу это закончить! (прим. перев. на самом деле, если следовать логике, даже приведенные пункты должен быть еще сложнее). Я не знаю, как перенести эти шаги в функциональный стиль без использования изменяемого состояния. Либо теряется последовательность шагов, либо надо писать «добавьте бананы», но тогда изменяется текущее состояние. Может, кто-нибудь в комментариях закончит? Хотелось бы посмотреть на версии с использованием монад и без использования монад.

В комментариях к оригинальноей статье предложили несколько вариантов

С монадами не было, но было с и без pipe forward operator.

Без использования pipe forward operator:

cake = cooled(removed_from_oven(added_to(30min, poured(greased(floured(pan)), stirred(chopped(walnuts),
alternating_mixed(buttermilk, whisked(flour, baking soda, salt), 
mixed(bananas, beat_mixed(eggs, creamed_until(fluffy, butter, white sugar, brown sugar)))), 
preheated(175C, oven))))))

C использованием pipe forward operator:

cake = bake(cake_mixture, 30min, prepare(pan, (grease, flour)), preheated(175C, oven))
where cake_mixture =
creamed :until_fluffy ‘butter’ ‘white’ ‘sugar’ ‘brown sugar’
|> beat_mixed_with ‘eggs’
|> mixed_with ‘bananas’
|> mixed_with :alternating ‘buttermilk’ ‘dry_goods’
|> mixed_with chopped ‘walnuts’
where dry_goods = whisked ‘flour’ ‘baking soda’ ‘salt’

У императивных языков-таки есть огромное преимущество в том, что у них есть неявное состояние. И люди, и машины очень хорошо работают с неявным состоянием, привязанным ко времени. Когда вы читаете рецепт пирога, вы знаете, что после выполнения первой инструкции духовка разогрета, противень смазан и мы замесили основу для теста. Это не нужно явно описывать. У нас есть инструкции и мы знаем, что конечное состояние получится путем выполнения этих инструкций. Императивный рецепт никого не поставит в тупик. А если бы мне удалось закончить функциональный рецепт и если бы я показал его моей маме, она была бы им, наверное, очень сильно озадачена. (Ну как минимум версией без монад. Может быть, версия с монадами не так сильно сбивала бы с толку.)

Я пишу этот пост, потому что столкнулся недавно с похожей проблемой. Так уж оказалось, что шаблоны С++ — это функциональный язык. И когда разработчики С++ это поняли, то вместо того, чтоб решить проблему, стали всячески лелеять шаблоны в функциональном стиле, что иногда делает переписывание обычного кода через шаблоны очень утомительным. Например, вот что я недавно написал для парсера. (Я знаю, глупо писать свой собственный парсер, но старые тулзы типа yacc и bison плохие, а когда я попробовал пользоваться boost spirit, то столкнулся с проблемами, решение которых занимало слишком много времени, и в конце концов я просто решил написать свой парсер.)

ParseResult<V> VParser::parse_impl(ParseState state)
{
    ParseResult<A> a = a_parser.parse(state);
    if (ParseSuccess<A> * success = a.get_success())
        return ParseSuccess<V>{{std::move(success->value)}, success->new_state};
    ParseResult<B> b = b_parser.parse(state);
    if (ParseSuccess<B> * success = b.get_success())
        return ParseSuccess<V>{{std::move(success->value)}, success->new_state};
    ParseResult<C> c = c_parser.parse(state);
    if (ParseSuccess<C> * success = c.get_success())
        return ParseSuccess<V>{{std::move(success->value)}, success->new_state};
    ParseResult<D> d = d_parser.parse(state);
    if (ParseSuccess<D> * success = d.get_success())
        return ParseSuccess<V>{{std::move(success->value)}, success->new_state};
    return select_parse_error(*a.get_error(), *b.get_error(), *c.get_error(), *d.get_error());
}

Эта функция парсит входной параметр в variant type типа V, пытаясь запарсить входной параметр как тип A, B, C или D.

прим. перев.

Если я правильно понимаю, в этом контексте термин variant type значит примерно «размеченное объединение/алгебраический тип данных», и действительно: V может быть либо A, либо B, либо C, либо D. Такое значение есть и в википедии (последний параграф перед оглавлением).

В реальном коде имена у этих типов получше, но они для нас не важны. В этом примере есть очевидная дупликация: мы четыре раза выполняем совершенно одинаковый фрагмент кода с четырьмя разными парсерами. C++, в общем-то, по-настоящему не поддерживает монады, но можно было бы сделать этот фрагмент кода переиспользуемым, написав цикл, который бы перебирал все четыре парсера по порядку:

template<typename Variant, typename... Types>
ParseResult<Variant> parse_variant(ParseState state, Parser<Types> &... parsers)
{
    boost::optional<ParseError> error;
    template<typename T>
    for (Parser<T> & parser : parsers)
    {
        ParseResult<T> result = parser.parse(state);
        if (ParseSuccess<T> * success = result.get_success())
            return ParseSuccess<Variant>{{std::move(success->value)}, success->new_state};
        else
            error = select_parse_error(error, *result.get_error());
    }
    return *error;
}
ParseResult<V> VParser::parse_impl(ParseState state)
{
    return parse_variant<V>(state, a_parser, b_parser, c_parser, d_parser);
}

Этот код чуть-чуть неоптимален, потому что надо выбирать нужное сообщение об ошибке, но в целом это довольно тривиальная трансформация исходного примера. За исключением того, что вы не можете написать так на С++. Как только в игру вступают шаблоны, вам нужно думать более функционально. Вот мой вариант:

template<typename Variant, typename First>
ParseResult<Variant> parse_variant(ParseState state, Parser<First> & first_parser)
{
    ParseResult<First> result = first_parser.parse(state);
    if (ParseSuccess<First> * success = result.get_success())
        return ParseSuccess<Variant>{{std::move(success->value)}, success->new_state};
    else
        return *result.get_error();
}
template<typename Variant, typename First, typename... More>
ParseResult<Variant> parse_variant(ParseState state, Parser<First> & first_parser, 
    Parser<More> &... more_parsers)
{
    ParseResult<First> result = first_parser.parse(state);
    if (ParseSuccess<First> * success = result.get_success())
        return ParseSuccess<Variant>{{std::move(success->value)}, success->new_state};
    else
    {
        ParseResult<Variant> more_result = parse_variant<Variant>(state, more_parsers...);
        if (ParseSuccess<Variant> * more_success = more_result.get_success())
            return std::move(*more_success);
        else
            return select_parse_error(*result.get_error(), *more_result.get_error());
    }
}
ParseResult<V> VParser::parse_impl(ParseState state)
{
    return parse_variant<V>(state, a_parser, b_parser, c_parser, d_parser);
}

И я, честно говоря, очень доволен этим вариантом. Конечно, это тяжелее читать, потому что итерирование скрыто теперь в рекурсии, но если б вы только видели мой код до того, как я придумал это решение… У меня была структура с полем std::tuple<std::reference_wrapper<Parser>…>. Если вы когда-нибудь работали с кортежем переменной длины из стандартной библиотеки (то бишь variadic sized std::tuple), вы должны знать, что одно это превращает любой код в ребус.

Так или иначе, мой посыл таков: у меня был простой императивный код, который делал одно и то же несколько раз. Чтоб переписать его через шаблоны, нельзя просто так взять и обернуть повторяющийся фрагмент в цикл. Вместо этого надо полностью поменять логику программы. И для этого нужно решить слишком много головоломок. Более того, я даже и не решил их с первого раза. В первой попытке я остановился на чем-то слишком сложном и так и оставил императивный вариант. И только после возвращения к проблеме через несколько дней я придумал более простое решение выше. Переписывание кода через шаблоны не должно быть таким сложным. Когда основная проблема не в том, чтоб понять, что должна делать программа, а в том, чтоб понять, как заставить ее это делать.

И у меня такое ощущение в функциональных языках слишком часто. Я знаю, шаблоны С++ — плохой функциональный язык, но даже в хороших функциональных языках я трачу слишком много времени на попытки понять, как именно мне выразить вещи в языке, вместо размышлений о том, что же я хочу выразить.

С учетом всего вышесказанного, считаю ли я, что функциональные языки плохие? Вовсе нет. Польза от функциональных языков совершенно осязаемая. Каждый должен выучить хотя бы один функциональный язык и пробовать применять полученные знания в других языках. Но если функциональные языки хотят стать популярными, они должны быть меньше связаны с разгадыванием ребусов.

Автор: Bas1l

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js