Недавняя тема "Не хватает мне в C++ noexcept-блоков с compile-time проверками" показала, что идея может оказаться востребованной, но пока она является еще очень сырой. Данный пост ставит целью сделать более понятное описание того, что же хочется получить. А так же показать, как эта идея может быть расширена для поддержки не только информации о выбрасывании исключений, но и произвольных атрибутов, которые нужны конкретным разработчикам.
Итак, сперва поговорим о том, почему мне хочется иметь что-то в дополнение к noexcept.
На мой взгляд, noexcept отличная штука и помогает разработчику писать устойчивый к исключениям код используя уже написанные кем-то функции/методы (то, что дает noexcept компилятору для оптимизации кода сейчас рассматривать вообще не будем). Допустим, мне нужно написать оператор копирования, который обеспечивает сильную гарантию безопасности исключений. Я делаю что-то вроде:
class my_type { third_party_lib::some_type a_; another_lib::another_type b_; ... public : friend void swap(my_type & a, my_type & b) noexcept { // (3) using namespace std; swap(a.a_, b.a_); swap(a.b_, b.b_); ... } my_type & operator=(const my_type & o) { my_type tmp(o); // (1) swap(*this, tmp); // (2) return *this; } ... }; |
Здесь у меня в точке (1) могут вылетать исключения, но это не страшно, т.к. ничего нигде не портится и не меняет своего состояния. А в точке (2) я могу быть уверен, что исключений не будет, т.к. это гарантируется в точке (3). Причем в точке (3) я рассчитываю на то, что нормальные реализации swap для типов из библиотек third_party_lib и another_lib не бросают исключений. Но даже если они исключения бросают, то поделать в этой ситуации я все равно уже ничего не могу и поэтому блее правильно позвать std::terminate, чем пытаться как-то выкрутиться из этой ситуации.
Именно такое поведение noexcept для функций/методов является одновременно и сильной, и слабой сторонами noexcept-а. И если про сильную сторону уже было сказано, то теперь попробую проиллюстрировать слабую сторону.
Допустим, я пишу нетривиальный деструктор класса, в котором мне нужно очистить ряд ресурсов. Какая-то часть из этих очисток может порождать исключения, я об этом знаю и меня устраивает их перехват и "проглатывание". Часть очисток для меня выглядит вполне безопасно и исключений оттуда я не жду. Ну, что-то вроде (прошу не обращать внимания на откровенный говнокод, который явно нуждается в рефакторинге):
class my_complex_actor { ... public : ~my_complex_actor() { // Закрываем внешние ресурсы. Тут можно поймать system_error, // но поделать уже ничего нельзя, поэтому просто проглатываем его. try { close_external_resource_1(); } catch(const system_error &) { ... // Возможно мы здесь попробуем еще что-то сделать, вроде // логирования, но суть в том, что пойманное исключение // дальше не пойдет. } // Ну и дальше еще несколько раз по образу и подобию... try { close_external_resource_2(); } catch(const system_error &) { ... } ... // А теперь мы выполняем действия, которые в принципе не должны // бы приводить к возникновению исключений. Например, уничтожаем // свои подписки и информируем тех, кто подписан на нас о том, что // мы исчезаем. Тут должна быть только модификация каких-то списков // в памяти поэтому исключений не предвидится. for( auto & s : subscriptions_ ) s.remove_subscriber( *this ); for( auto & l : listeners_ ) l.stop_listen( *this ); } ... }; |
Итак, в чем здесь я вижу проблему?
Дело в том, что код со временем эволюционирует и, даже если я ничего не меняю в своем коде, что-то может поменяться в сторонних библиотеках. Так, первоначально метод remove_subscriber не бросал исключений, а затем начал это делать. Причем начал это делать достаточно активно, скажем, в 50% случаев. И проблема в том, что при перекомпиляции своей программы об изменении поведения remove_subscriber я ничего не узнаю. При этом деструктор my_complex_actor продолжит оставаться noexcept методом. Но в run-time его работа с вероятностью 50% будет приводить к std::terminate, что лично мне не понравится. Я бы хотел получить какое-то предупреждение в compile-time чтобы иметь возможность переписать фрагмент своего деструктора, например, вот так (опять же говнокод, но для демонстрации сойдет):
for( auto & s : subscriptions_ ) { try { s.remove_subscriber( *this ); } catch(...) {} } |
Собственно, идея в том, чтобы добавить в C++ какой-то механизм для того, чтобы:
- функция/метод могли дать какие-то обещания своим пользователям. Например, обещание не бросать исключения;
- потребовать проверки того, что в некотором фрагменте кода вызываются только те функции/методы, которые обещают то, что требуется этому фрагменту. Например, проверка того, что все вызываемые в блоке кода функции обещают не бросать исключений.
Например, выглядеть это может так (синтаксис приблизительный). Обещание не бросать исключения
class subscription_manager { ... public : // Метод обещает не бросать исключений. void remove_subscriber(subscriber & s) [[implies(std::nothrow)]] { ... } ... }; |
Ожидание того, что вызываемый код не бросает исключений:
[[expects(std::nothrow)]] { for( auto & s : subscriptions_ ) { s.remove_subscriber( *this ); } } |
Компилятор встречая перед блоком кода атрибут [[expects]] начинает проверять все вызовы внутри блока на предмет того, чтобы они обещали именно то, что записано в expect. Если обещают, то компиляция проходит успешно. Если не обещают, то либо возникает ошибка компиляции, либо предупреждение (пока мне кажется, что предупреждение предпочтительнее, т.к. всегда есть возможность приравнять предупреждения к ошибкам).
Компилятор так же проверяет атрибут [[implies]] для функции/метода. И выдает диагностику, если в коде функции/метода есть явные нарушения выданных ранее обещаний. По сути, запись void f() [[implies(Something)]] раскрывается во что-то вроде:
void __compiler_generated_f() [[impies(Something)]] { [[expects(Something)]] { f(); } } |
Поэтому, если разработчик subscription_manager напишет что-то вроде:
class subscription_manager { ... public : // Метод обещает не бросать исключений. void remove_subscriber(subscriber & s) [[implies(std::nothrow)]] { if(invalid_state) throw std::runtime_error("can't work in invalid state"); ... } ... }; |
То компилятор сразу даст такому разработчику по рукам за нарушение собственных же обещаний.
Мне кажется, что обещания нужно разделить на два типа: стандартные, которые может проверить сам компилятор, и пользовательские, проверку которых компилятор сделать не может в принципе.
Например, к стандартным обещаниям можно отнести такие вещи, как:
- std::nothrow -- нет вызова функции/методов/операторов, которые бросают исключения. Например, функция выполняет только (x=x+1), где x -- это переменная типа int;
- std::pure -- нет модифицирующих обращений к чему-либо;
- std::norecursion -- нет рекурсивного вызова внутри функции.
Стандартные обещания нужны потому, что в каких-то случаях только компилятор может проверить, выполняется ли обещание или нет. Как в случаях с теми же std::nothrow и std::pure.
А вот пользовательские обещания компилятор проверять не в состоянии. Поэтому он может только удостовериться, что список обещаний в [[implies]] для какой-то функции совпадает с тем, что перечислено в [[expects]] для блока кода. Ну, например, кто-то может написать:
std::chrono::milliseconds optimal_timeout() [[impiles(std::nothrow, std::pure, mylib::thread_safe)]] { ... } |
Компилятор разве что сможет проверить выполнение std::nothrow и std::pure. А вот выполнение mylib::thread_safe он проверить не сможет. Поэтому когда кто-то пишет:
[[expects(mylib::thread_safe)]] { const auto timeout = optimal_timeout(); ... } |
То можно только надеяться на то, что optimal_timeout действительно обеспечивает thread safety.
Однако, имхо, это все равно лучше, чем текущая ситуация. Поскольку, если у меня есть что-то вроде:
std::chrono::milliseconds optimal_timeout() [[impiles(std::nothrow, std::pure, mylib::thread_safe)]] { ... } void put_actor_to_sleep(actor a, std::chrono::milliseconds timeout) { // (1) ... } ... [[expects(std::nothrow, mylib::thread_safe)]] { // (2) put_actor_to_sleep(my_actor, optimal_timeout()); // (3) ... } |
То я бы предпочел во время компиляции получить предупреждение о том, что в точке (3) у меня происходит нарушение моих ожиданий, задекларированных в точке (2), поскольку функция put_actor_to_sleep не дает нужных мне обещаний (нет [[implies(std::nothrow, mylib::thread_safe)]] в точке (1)). Возможно, я бы тогда переписал функцию put_actor_to_sleep. Возможно, сделал бы вокруг ее вызова какую-то обертку, которая нужные мне обещания предоставит.
Пожалуй на этом на данный момент все. Пока описано все на текущей степени понимания собственных хотелок.
Немного соображений по околотехническим деталям.
Вероятно, обещания можно представлять в виде пустых структур-тегов. Что-то вроде:
namespace mylib { struct thread_safe {}; struct lock_free {}; struct cpu_bound {}; ... }; |
Может быть (может быть), когда в C++ добавят концепты, то и концепты можно будет рассматривать в качестве подобных обещаний. Хотя я не настолько разбирался с концептами, дабы понимать, есть ли от этого какой-то профит.
Вероятно, список обещаний должен быть частью типа функции/метода, по аналогии с тем, как noexcept стала частью типа функции/метода в C++17. Тогда получится, что мы сможем объявить указатель на функцию, которая обещает std::pure, но не сможем присвоить этому указателю значение функции, которая std::pure не обещает.
Вероятно, нужно будет уметь как-то обозначать тождественность обещаний, определенных в разных библиотеках. Например, если thread_safe определен в моей библиотеке mylib и в чужой библиотеке yourlib, но при этом mylib::thread_safe и yourlib::thread_safe обозначают одно и то же, то нужно дать об этом знать компилятору. Чтобы можно было писать так:
[[expects(mylib::thread_safe)]] { yourlib::some_thread_safe_func(...); } |
Так же нужно будет иметь возможность описывать "поглощение" обещаний. Например, std::pure может автоматически означать mylib::thread_safe.
Вероятно, в stdlib нужно будет включить как обещания, которые компилятор гарантированно может проверить сам (как, например, nothrow и pure), так и обещания, которые компилятор не может проверить, но которые являются общеупотребительными (например, thread_safe). И тогда можно использовать имена вроде std::strict::nothrow и std::relaxed::thread_safe.
Пока для меня самого непонятно, нужно ли в [[expects]] позволять делать какую-то группировку обещаний. Что-то вроде:
[[expects(std::nothrow && (std::pure || std::thread_safe))]] { do_some_call(); } |
Вероятно, это нужно. Но в эту сторону я пока не копал.
Ну вот как-то так. Что было в голове, то и записал.
Возможно, где-то сумбур, где-то что-то не очень понятно. Оставляйте комментарии/соображения/замечания/предложения. Попробую их осмысливать и вносить правки в текст (ну или появится еще один текст с учетом всего высказанного).
Так же, судя по всему, для меня лично это будет просто неподъемная задача по созданию предложения для комитета по стандартизации C++. В том числе и потому, что у меня сейчас есть более насущные (во всех смыслах) задачи, от которых зависит и мое будущее, и будущее всех людей, за которых я несу ответственность. Так что я лично себя в качестве основного движителя не рассматриваю в ближайшие 6-9 месяцев. Но, возможно, кого-то весь этот поток сознания натолкнет на что-то большее, с лучшими перспективами успешного завершения.
Комментариев нет:
Отправить комментарий