В пятницу выдался свободный вечер, такой когда срочных дел нет, а несрочные делать лень и хочется чего-то для души. Для души я решил посмотреть какой-нибудь доклад 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, кому интересно, хорошее неформальное введение в один из аспектов этого языка

Комментарии (19)


  1. pavelsh
    24.11.2015 08:22
    -9

    У меня ощущение, что go произвел на Александреску впечатление.
    Тут и defer-ы появляются и try-catch критикуются.


    1. aspcartman
      24.11.2015 09:42
      +6

      Может, наоборот, учитывая время выхода статей? :)


    1. NeoCode
      24.11.2015 12:44
      +1

      Эти defer'ы реализованы в языке D вместе с исключениями (поэтому в отличие от одного defer в Go, в D целых три ключевых слова — scope(exit), scope(success) и scope(failure) — для общего случая, выхода без исключения и выхода по исключению, что видимо и позаимствовано в Boost.ScopeExit и здесь). А сама концепция подозреваю что более древняя чем реализации Go, D и Boost, наверняка в каких-нибудь теоретических трудах есть.


  1. beeruser
    24.11.2015 11:35
    +5

    К слову говоря, файловые операции весьма условно могут быть «транзакционно стабильными».
    То что файл закрылся без ошибок, не означает что он вообще будет записан.
    https://fgiesen.wordpress.com/2015/10/25/reading-and-writing-are-less-symmetric-than-you-probably-think/


  1. tangro
    24.11.2015 11:46

    Я, может быть, торможу, но вся статья ведь построена на предположении, что мы ловим «catch(...)» и вообще никак не обрабатываем определённые типы исключений — а где это вы такое в нормальном продакшн-коде видели?


    1. AMDmi3
      24.11.2015 14:01

      Тормозите, исключения прокидываются на уровень выше. Плохим стилем же обычно считается безусловное игнорирование исключений (catch(...){})


      1. tangro
        24.11.2015 16:48

        Ну, если они прокидываются, то мы всё-равно приходим к необходимости написания ифа или свича в зависимости от их типа, плюс обработки того самого catch(...). В итоге у нас будет весь тот же самый код, только расположенный чуть выше и со странным никому не известным макросом вместо всем известного и входящего в стандарт try\catch. И в чём выигрыш?


        1. degs
          24.11.2015 18:45

          Разница в акцентах использования. Если try/catch в каком-то смысле эквивалентно проверке if(result != 0)/else, то scope задает в начале блока действия которые надо выполнить при любом выходе, успешном выходе и/или неуспешном выходе из области видимости. Просто еще одна абстракция на ступеньку выше. Однако я поглядел быстренько на D код, да, там этот стиль используется очень широко и действительно сильно меняет вид кода.
          Макросы конечно неуклюжи и вряд ли будут использоваться в таком виде. Как я понимаю идея доклада — то что в C++ уже сейчас тоже возможен такой стиль написания и при внесении небольших изменений в синтаксис языка это станет нативной фичей.
          А то что стандарт теперь прямо рекомендует избегать чрезмерного использования try/catch вас не удивляет? Еще совсем ведь недавно даже явная спецификация типов исключений была рекомендованным стандартом и тоже ее отмену многие в штыки встречали.


      1. BalinTomsk
        24.11.2015 18:48
        -2

        В нормальном продакшине и throw под запретом. Мы же не на каком-то богохульном java пишем. throw только для использования внутри драйверов при обработке аппаратных исключений.


        1. degs
          24.11.2015 19:09

          throw — совершенно нормальный и очень удобный механизм перехвата ошибок из глубины вложенных функций, альтернатива ему — бесконечная лента проверок возвращаемого значения которая масштабируется гораздо хуже, от слова совсем. В продакшне бывает под запретом что угодно, но как правило это волевое решение менеджера, который либо вообще далек от написания кода, либо прочитал лет… дцать назад книжку где ему так посоветовали. Я бы не стал называть «нормальной» команду где исключения под запретом, хотя легко могу себе такое представить.


          1. BalinTomsk
            24.11.2015 23:55

            Я работал в нескольких компаниях из топ 100 — ни у кого там даже в мыслях нету писать в предложенном вами стиле.
            И это не капризы менеджеров.

            Да, Гугль тоже можно причислить к ненормальным.


            1. Lol4t0
              25.11.2015 00:36

              Гугль — это

              1. легаси
              2. максимальное упрощение, идущее на пользу в масштабах корпорации

              Для того, чтобы равняться на Гугль, надо быть гуглем


            1. degs
              25.11.2015 05:03

              Естественно, это не библиотека и к использованию в продакшне не предназначено. Это — концепция, которая как я предполагаю будет где-то обкатана, в том же бусте например и потом предложена к включению в стандарт.

              Да, Гугль тоже можно причислить к ненормальным
              я наверное что-то не понял, это здесь вообще к чему?


              1. BalinTomsk
                25.11.2015 05:28

                Потому что это нерабочая концепция. Может в виде учебных примеров и красиво а в жизни малоприменимо.
                Microsoft отказался примерно к 2005 году от такого стиля — и вот почему.

                Я когда extended stored procedures отлаживал под MSSQL2000 — у меня шторма исключений в дебагере пролетали, к счастью в MSSQL2005 от этого отказались и на курсах Microsoft настоятельно таким стилем не рекомендовали пользоватся.

                Забуду я где оттрайкетчить вызов из такой библиотеки и упадет мой сервер в самом неожиданном месте.


                1. degs
                  25.11.2015 06:26

                  Не обижайтесь только, вы по-моему не поняли концепции. Забудьте про макросы, они используются только потому что в текущей версии C++ без них не обойтись. Пусть у нас будут, как в D, языковые конструкции scope exit, skope success и scope fail. С try/catch они не связаны напрямую вообще никак, но пусть у вас в теле некоторой функции определены три оператора:
                  scope exit {… }
                  scope success {… }
                  scope fail {… }
                  Эта функция так или иначе когда-нибудь завершится, если это будет нормальный выход, то будут вызваны первые два функтора; если будет выброшено исключение, то оно будет где-нибудь поймано и тогда в нем будут вызваны первый и третий функтор. Александреску утверждает что это мощная концепция которая будет очень к месту в C++ и я с ним полностью согласен, не в том смысле что она заменит собой все остальное, а в том что будет еще один полезный архитектурный элемент.
                  А насчет того что посоветовала Microsoft в 2005 году… Этого стиля тогда просто не было и быть не могло.
                  Во-первых C++ в 2005-м был совсем другим и я сомневаюсь что кто-то мог тогда дать осмысленный совет для 2015. А во-вторых, мы с Вами примерно ровесники (и похоже даже дважды земляки), я например помню как они яростно боролись с TCP/IP, потом с std::string и еще чем-то там из std::, потом еще с чем-то..., короче они не пророки, в этой области по крайней мере, как впрочем и ни одна из крупных современных компаний. В общем совет от Microsoft 2005 года сегодня на путеводную звезду никак не тянет.


  1. Gorthauer87
    24.11.2015 12:09

    А где здесь декларативное программирование? Слова scope, control flow как-то не вяжутся с ним. Да и потом, опять кучи макросов, которые выглядят громоздко.


  1. Lol4t0
    25.11.2015 00:12

    Я может чего-то не понимаю, и вообще статью не читал, но SOLID-ный дизайн с использовнием RAII выглядит вот так

    struct Temporary
    {
        ~Temporary() {
            remove(path);
        }
        const ::path path;
    };
    
    void copy_file_tr(const path& from, const path& to) {
        Temporary tmp {to+".deleteme"};
        copy_file(from, tmp.path);
        rename(tmp.path, to);
    }
    


    При этом, естественно, и с move_file все сразу становится проще


    1. degs
      25.11.2015 05:20

      Ну так это оно и есть, если вы еще не заметили все метапрограммирование сводится к тому чтобы вместо таких вот классов, создаваемых специально для каждого случая предоставить готовый универсальный шаблон.
      Раз уж вы не читали, я специально для вас скажу суть в двух предложениях:
      — В узкотехническом смысле, вдобавок к макросу SCOPE_EXIT, который известен уже 15 лет минимум, скоро можно будет добавить еще два — SCOPE_SUCCESS и SCOPE_FAIL используя новую функцию int uncaught_exceptions(), которой еще правда нет, но появится в C++17.
      — В широком архитектурном смысле, использование этих трех конструкций позволяет писать на C++ в декларативном стиле, и приведены иллюстрации как это может выглядеть.
      На самом деле это все.


      1. Lol4t0
        25.11.2015 10:29
        +1

        Универсальный шаблон и так уже есть — unique_ptr со своим Deleter. Ну то есть технически вы можете сделать

        std::unique_ptr<const char, decltype(remove1)*> autodelete(tmp.c_str(), remove);
        
        Но даже это не нужно, потому чтоTemporary — вполне самостоятельный класс, и совершенно непонятно, почему это не нужно его выделить?

        А уж использовать ужасные С-ные SCOPE* вместо RAII просто дикость какая-то на мой взгляд