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

У п'ятницю видався вільний вечір, коли такий термінових справ немає, а нетермінові робити лінь і хочеться чогось для душі. Для душі я вирішив подивитися який-небудь доповідь CppCon 2015 яка пройшла трохи більше місяця тому. Як правило на відео доповіді наживо у мене ніколи часу не вистачає, але тут все так вже склалося, що пройшов місяць, C++-17 вже на носі і конференція повинна була бути цікавою, але ніхто ще нічого про неї не писав, а тут ще й вечір вільний.Загалом я швиденько тицьнув мишкою в перший привернув увагу заголовок: Andrei Alexandrescu “Declarative Control Flow" і приємно провів вечір. А потім вирішив поділитися з хабрасообществом вільним переказом.
Давайте згадаємо що таке звичайний для C++ Explicit Flow Control, напишемо транзакционно стабільну функцію для копіювання файлу, стабільну в тому розумінні, що вона має тільки два результати: або завершується успішно, або з якихось причин невдало, але при цьому не має побічних ефектів, (гарне вираз — 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® == 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, кому цікаво, гарне неформальне введення в один з аспектів цієї мови


Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.