Как стать автором
Обновить

Декларативное программирование на C++

Время на прочтение7 мин
Количество просмотров16K
В пятницу выдался свободный вечер, такой когда срочных дел нет, а несрочные делать лень и хочется чего-то для души. Для души я решил посмотреть какой-нибудь доклад CppCon 2015 которая прошла чуть больше месяца назад. Как правило на видео доклады вживую у меня никогда времени не хватает, но тут все так уж сложилось — прошел месяц, C++-17 уже на носу и конференция должна была быть интересной, но никто еще ничего о ней не писал, а тут еще и вечер свободный.В общем я быстренько ткнул мышкой в первый привлекший внимание заголовок: Andrei Alexandrescu “Declarative Control Flow" и приятно провел вечер. А потом решил поделиться с хабрасообществом вольным пересказом.
Давайте вспомним что такое обычный для C++ Explicit Flow Control, напишем транзакционнo стабильную функцию для копирования файла, стабильную в том смысле что она имеет только два исхода: либо завершается успешно, либо по каким-либо причинам неуспешно, но при этом не имеет побочных эффектов, (красивое выражение — successful failure). Задача выглядит тривиальной, тем более если использовать boost::filesystem:
void copy_file_tr(const path& from, const path& to) {
    path tmp=to+".deleteme";
    try {
        copy_file(from, tmp);
        rename(tmp, to);
    } catch(...) {
        ::remove(tmp.c_str());
        throw;
    }
}
Что бы ни случилось во время копирования, временный файл будет удален, что нам и требовалось. Однако, если присмотреться здесь всего три строчки значимого кода, все остальное — проверка успешности вызова функций через try/catch, то есть ручное управление исполнением. Структура программы здесь не отражает реальную логику задачи. Еще один неприятный момент — этот код сильно зависит от явно неописанных здесь свойств вызываемых функций, так функция rename() предполагается атомарной (транзакционно стабильной), а remove() не должна выбрасывать исключений ( почему здесь и используется ::remove() вместо boost::filesystem::remove() ).Давайте еще усугубим и напишем парную функцию move_file_tr:
void move_file_tr(const path& from, const path& to) {
    copy_file_tr(from, to);
    try {
        remove(from);
    } catch(...) {
        ::remove(to.c_str());
        throw;
    }
}
Мы видим здесь все те же проблемы, в таком крохотном кусочке кода нам пришлось добавить еще один try/catch блок. Более того, даже здесь уже можно заметить насколько плохо такой код масштабируется, каждый блок вводит свою область видимости, пересечение блоков невозможно и т.д. Если вас все это еще не убедило, стандарт рекомендует свести к минимуму ручное использование try/catch, ибо «verbose and non-trivial uses error-prone».Давайте заявим прямо и честно что непосредственное управление деталями исполнения нас больше не устраивает, мы хотим большего.
Декларативный стиль вместо этого обращает основное внимание на описании целей, при этом детальные инструкции по достижению сведены к необходимому минимуму, исполнение кода правильным образом происходит без непосредственного контроля за исполнением каждого шага. Это могло бы звучать как фантастика, однако такие языки — вокруг нас и мы их используем каждый день не задумываясь. Посмотрите — SQL, make, regex, все они декларативны по своей природе. Что мы можем использовать в C++ чтобы достичь такого эффекта?
RAII и деструкторы имеют декларативную природу поскольку вызываются неявно, а также близкая идиома ScopeGuard. Давайте посмотрим как устроен макрос SCOPE_EXIT с использованием ScopeGuard, это на самом деле довольно старый трюк, достаточно сказать что одноименный макрос присутствует в boost начиная с версии 1.38. И тем не менее, повторение мать учения:
namespace detail {
    enum class ScopeGuardOnExit {};

    template<typename<Fun> ScopeGuard<Fun> operator+
    (ScopeGuardOnExit, Fun&& fn) {
        return ScopeGuard<Fun>(std::forward<Fun>(fn));
    }
}

#define SCOPE_EXIT \
    auto ANONIMOUS_VARIABLE(SCOPE_EXIT_STATE) \
    = ::detail::ScopeGuardOnExit + (&)[] 
}
Фактически, это половинка определения лямбда-функции, тело надо добавить при вызове.
Тут все достаточно прямолинейно, создается анонимная переменная содержащая ScopeGuard, который содержит лямбда-функцию, определенную непосредственно за вызовом макроса и которая функция будет вызвана в деструкторе этой переменной, который рано или поздно но при выходе из области видимости будет вызван. (В легких кончился воздух, а то бы я еще пару придаточных добавил)
Для полноты картины, вот так выглядят вспомогательные макросы:
#define CONACTENATE_IMPL(s1,s2) s1##s2
#define CONCATENATE(s1,s2) CONCATENATE_IMPL(s1,s2)
#define ANONYMOUS_VARIABLE(str) CONCATENATE(str,__COUNTER__)
С использованием такой конструкции привычный C++ код разом приобретает невиданные разом черты:
void fun() {
    char name[] = "/tmp/deleteme.XXXXXX";
    auto fd = mkstemp(name);
    SCOPE_EXIT { fclose(fd); unlink(name); };
    auto buf = malloc(1024*1024);
    SCOPE_EXIT { free(buf); };
    ...
}
Так вот, утверждается что для полноценного перехода к декларативному стилю нам достаточно определить еще два подобных макроса — SCOPE_FAIL и SCOPE_SUCCESS, с использованием этой тройки можно разделить логически значимый код и детальные управляющие инструкции. Для этого нам необходимо и достаточно знать, вызывается деструктор, нормально или в результате отмотки стека. И такая функция есть в C++ — bool uncaught_exception(), она возвращает true если была вызвана изнутри catch блока. Однако тут есть один неприятный нюанс — эта функция в текущей версии C++ поломана и не всегда возвращает правильное значение. Дело в том что она не различает, является ли вызов деструктора частью размотки стека или это обычный обьект на стеке созданный внутри catch блока, подробнее почитать об этом можно из первоисточника. Как бы то ни было, В C++-17 эта функция будет официально обьявлена deprecated и вместо нее введена другая — int uncaught_exceptions() (найдите сами два отличия), которая возвращает число вложенных обработчиков из которых была вызвана. Мы можем теперь создать вспомогательный класс, который точно покажет, вызывать SCOPE_SUCCESS или SCOPE_FAIL:
class UncaughtExceptionCounter {
    int getUncaughtExceptionCount() noexcept;
    int exceptionCount_;
public:
    UncaughtExceptionCounter()
    : exceptionCount_(std::uncaught_exceptions()) {}
    
    bool newUncaughtException() noexcept {
        return std::uncaught_exceptions() > exceptionCount_;
    }
};
Забавно что этот класс сам тоже использует RAII чтобы захватить состояние в конструкторе.
Вот теперь можно нарисовать полноценный шаблон который будет вызываться в случае успеха или неуспеха:
template <typename FunctionType, bool executeOnException>
class ScopeGuardForNewException {
    FunctionType function_;
    UncaughtExceptionCounter ec_;

public:
    explicit ScopeGuardForNewException(const FunctionType& fn)
    : function_(fn) {}

    explicit ScopeGuardForNewException(FunctionType&& fn)
    : function_(std::move(fn)) {}

    ~ScopeGuardForNewException() noexcept(executeOnException) {
        if (executeOnException == ec_.isNewUncaughtException()) {
            function_();
        }
    }
};
Собственно, все интересное сосредоточено в деструкторе, именно там сравнивается состояние счетчика исключений с шаблонным параметром и принимается решение вызывать или нет внутренний функтор. Обратите еще внимание как тот же шаблонный параметр изящно определяет сигнатуру деструктора: noexcept(executeOnException), поскольку SCOPE_FAIL должен быть exception safe, а SCOPE_SUCCESS вполне себе может выбросить исключение напоследок, чисто из вредности. По моему мнению, именно такие мелкие архитектурные детали делают C++ именно тем языком который я люблю.
Дальше все становится тривиальным, подобно SCOPE_EXIT мы определяем новый макрос:
enum class ScopeGuardOnFail {};
template <typename FunctionType>
ScopeGuardForNewException<
    typename std::decay<FunctionType>::type, true>
    operator+(detail::ScopeGuardOnFail, FunctionType&& fn) {
        return ScopeGuardForNewException<
            typename std::decay<FunctionType>::type, true
        >(std::forward<FunctionType>(fn));
    }

#define SCOPE_FAIL \
    auto ANONYMOUS_VARIABLE(SCOPE_FAIL_STATE) \
    = ::detail::ScopeGuardOnFail() + [&]() noexcept
И аналогично для SCOPE_EXIT
Посмотрим как теперь будут выглядеть исходные примеры:
void copy_file_tr(const path& from, const path& to) {
    bf::path t = to.native() + ".deleteme";
    SCOPE_FAIL { ::remove(t.c_str()); };
    bf::copy_file(from, t);
    bf::rename(t, to);
}

void move_file_tr(const path& from, const path& to) {
    bf::copy_file_transact(from, to);
    SCOPE_FAIL { ::remove(to.c_str()); };
    bf::remove(from);
}
Код выглядит прозрачнее, более того, каждая строчка что-то значит. А вот пример использования SCOPE_SUCCESS, заодно и демонстрация почему этот макрос может бросать исключения:
int string2int(const string& s) {
    int r;
    SCOPE_SUCCESS { assert(int2string(r) == s); };
    ...
    return r;
}
Таким образом, совсем небольшой синтаксический барьерчик отделяет нас от того чтобы добавить к идиомам C++ еще одну — декларативный стиль.

Заключение от первого лица

Все это наводит на определенные мысли о том что нас может ждать в недалеком будущем. Мне прежде всего бросилось в глаза то что все ссылки в докладе далеко не новы. Например, SCOPE_EXIT присутствует в boost.1.38, то есть уже почти десять лет, а статья самого Александреску о ScopeGuard вышла в Dr.Dobbs аж в 2000м году. Хочу напомнить что Александреску имеет репутацию провидца и пророка, так созданная им как демонстрация концепции библиотека Loki легла в основу boost::mpl, а потом почти полностью вошла в новый стандарт и еще задолго до того фактически задала идиомы метапрограммирования. С другой стороны, сам Александреску последнее время в основном занимается развитием языка D где все три упомянутые конструкции — scope exit, scope success and scope failure являются частью синтаксиса языка и давно заняли в нем прочное место.
Еще один любопытный момент — доклад Эрика Ниблера на той же самой конференции называется Ranges for the Standard Library. Хочу напомнить что ranges — еще одна стандартная концепция языка D, дальнейшее развитие концепции итераторов. Более того, сам доклад — фактически перевод (с D на C++) замечательной статьи H.S.Teoh Component programming with ranges.
Таким образом, похоже что C++ начал активно включать в себя концепции других языков, которые впрочем он сам же и инициировал. В любом случае, грядущий C++-17 похоже не будет рутинным обновлением. Учитывая уроки истории, семнадцатый год скучным не бывает, запасаемся попкорном, ананасами и рябчиками.

Литература

Здесь просто собраны в одном месте ссылки уже включенные в пост.
  1. Оригинальный аудио доклад
  2. Ссылка на материалы CppCon 2015
  3. Слайды к докладу Александреску
  4. Ссылка на оригинальную статью о ScopeGuard 2000г
  5. Документация по boost::ScopeExit
  6. Предложение Herb Sutter по изменению uncaught_exception()
  7. Оригинальная статья по ranges в D, кому интересно, хорошее неформальное введение в один из аспектов этого языка
Теги:
Хабы:
+15
Комментарии19

Публикации

Истории

Работа

Программист C++
132 вакансии
QT разработчик
7 вакансий

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн