В проекте для одного из клиентов уже используется C++20, так что есть возможность на практике прикоснуться к некоторым фичам двадцатого стандарта. Вроде ranges и concepts.
Добавил в проект кусок кода на обычных C++ные шаблонах. А потом подумал, что в паре мест там можно было бы попробовать задействовать концепты. Т.к. опыта с концептами не было, то попытался в очередной раз разобраться с этой темой и сделать что-то работающее.
Нужно сказать, что к теме концептов подступался уже несколько раз. В смысле читал разные статьи, рассказывающие о том, что такое концепты. Но, т.к. это все была только теория без практики, то "в одно ухо влетало, в другое вылетало". Тут же при попытке написать свой первый концепт пришлось все перечитывать заново 🙂 При этом оказалось, что моя задачка не похожая на те, что разбираются в обзорных статьях про концепты. Так что потребовалось не только перечитать, но еще и покурить бамбук.
В общем, смысл в том, что есть интерфейс диспетчера задач Scheduler и есть интерфейс отдельной задачи Task:
class Scheduler { public: class Task { public: enum class Status { Normal, Deleted }; virtual ~Task() = default; virtual void run(Status) = 0; }; using TaskUniquePtr = std::unique_ptr<Task>; virtual void push(TaskUniquePtr task) = 0; }; |
Существует несколько реализаций Scheduler-ов. В качестве же Task-ов планировалось использовать лямбда-функции, которые должны оборачиваться в реализацию интерфейса Scheduler::Task. Т.е. вместо того, чтобы делать как-то так:
class FirstTask final : public Scheduler::Task { ... // Какие-то внутренности. public: ... // Какой-то конструктор. void run(Status status) { ... /* Какие-то действия */ } }; ... scheduler.push(std::make_unique<FirstTask>(...)); |
предполагалось делать как-то так:
make_task(scheduler, [...](Scheduler::Task::Status status) {... /* Какие-то действия */ }); |
И вспомогательный шаблон функции make_task должен был обернуть лямбду в некий класс-наследник Task. Что-то вроде:
template<typename H> class LamdaAsTask final : public Scheduler::Task { H _handler; public: explicit LamdaAsTask(H handler) : _handler{std::move(handler)} {} void run(Status condition) override { _handler(condition); }; }; template<typename H> void make_task( Scheduler & scheduler, H && handler) { scheduler.push(std::make_unique<LamdaAsTask<H>>(std::forward<H>(handler))); } |
Но ситуация была чуть сложнее. Нужно было использовать разные фабрики для Task. Т.е. в make_task передавались не только лямбда и ссылка на Scheduler, но еще и некая фабрика. Именно фабрика брала лямбду и возвращала указатель на новый Task. Т.е. шаблон make_task выглядел так:
template<typename Factory, typename H> void make_task( Scheduler & scheduler, Factory && factory, H && handler) { scheduler.push(factory(std::forward<H>(handler))); } |
И место для улучшения кода я видел в том, чтобы ограничить параметры шаблона make_task концептами.
Вырисовывалось два концепта.
Первый концепт описывает лямбду-обработчик. Ну типа ее можно вызвать с нужным параметром и она возвращает void.
template<typename H> concept Handler_C = requires(H h) { { h(Scheduler::Task::Status::Normal) } -> std::same_as<void>; }; |
Второй концепт описывает фабрику Task-ов:
template<typename F, typename H> concept TaskFactory_C = requires(F f) { requires Handler_C<H>; { f(H{}) } -> std::convertible_to<Scheduler::TaskUniquePtr>; }; |
Т.е. этот концепт зависит от параметра H, который описывает лямбду. Эта лямбда должна удовлетворять требованиям концепта Handler_C. А фабрика сама по себе должна поддерживать вызов operator() с передачей туда лямбды H и возвратом чего-то приводимого к TaskUniquePtr.
Благодаря таким концептам make_task приняла вид:
template<Handler_C H, TaskFactory_C<H> Factory> void make_task( Scheduler & scheduler, Factory && factory, H && handler) { scheduler.push(factory(std::forward<H>(handler))); } |
В общем, я получил то, что хотел.
Под катом полный код автономного примера, который всю эту кухню демонстрирует.
У меня же пока впечатления от концептов смешанные. Вероятно, вещь хорошая и, местами, полезная. Но вот ВАУ эффекта пока не случилось.
Вообще с C++ есть давняя история: сперва в нем появляется какая-то фича (в смысле описывается в стандарте языка), потом она становится доступна в каком-то из компиляторов, потом у тебя появляется возможность применять ее на практике, потом ты отрефлексируешь свой опыт ее использования и только тогда тебя догоняет понимание того, как же ее нужно использовать, чтобы это было и полезно, и не сложно, и не приводило к проблемам, о которых ты раньше и не знал. С некоторыми фичами эта история растягивается не на один год. Вот как у меня с концептами, например. А до этого похожая история была с noexcept.
А вот и исходный текст, который можно взять и поэкспериментировать (например, на wandbox-е).
#include <iostream> #include <memory> #include <utility> #include <concepts> class Scheduler { public: class Task { public: enum class Status { Normal, Deleted }; virtual ~Task() = default; virtual void run(Status) = 0; }; using TaskUniquePtr = std::unique_ptr<Task>; virtual void push(TaskUniquePtr task) = 0; }; template<typename H> concept Handler_C = requires(H h) { { h(Scheduler::Task::Status::Normal) } -> std::same_as<void>; }; template<typename F, typename H> concept TaskFactory_C = requires(F f) { requires Handler_C<H>; { f(H{}) } -> std::convertible_to<Scheduler::TaskUniquePtr>; }; template<Handler_C H> class DummyTask final : public Scheduler::Task { H _handler; public: explicit DummyTask(H handler) : _handler{std::move(handler)} {} void run(Status condition) override { _handler(condition); }; }; class DummyScheduler final : public Scheduler { public: void push(TaskUniquePtr task) override { task->run(Task::Status::Normal); } }; template<Handler_C H, TaskFactory_C<H> Factory> void make_task( Scheduler & scheduler, Factory && factory, H && handler) { scheduler.push(factory(std::forward<H>(handler))); } struct SimpleFactory { template<Handler_C H> [[nodiscard]] Scheduler::TaskUniquePtr operator()(H && handler) { using TaskType = DummyTask< std::decay_t<H> >; return std::make_unique<TaskType>(std::forward<H>(handler)); } }; struct NotAFactory { template<typename H> [[nodiscard]] int operator()(H && /*handler*/) { return 0; } }; int main() { DummyScheduler scheduler; make_task( scheduler, SimpleFactory{}, [](Scheduler::Task::Status /*condition*/) { std::cout << "Hello!" << std::endl; }); #if 0 make_task( scheduler, NotAFactory{}, [](Scheduler::Task::Status /*condition*/) { std::cout << "Hello!" << std::endl; }); #endif #if 0 make_task( scheduler, SimpleFactory{}, [](int /*i*/) { std::cout << "Hello(int)!" << std::endl; }); #endif } |
4 комментария:
Как я понимаю, концепты нужны для облегчения чтения ошибок при компиляции шаблонного кода. Помогли ли они в данном случае? Попробовал покомпилировать код с концептами и без, вроде разница не особо большая. И в одном, и в другом случае пару строк с ошибкой компиляции, в которой вроде все боле-менее понятно
@sv
Я бы не сказал, что концепты в этом плане как-то кардинально лучше. ИМХО, самые толковые сообщения об ошибках дают static_assert-ы внутри реализации шаблонов (особенно когда разрешат там использовать std::format для формирования внятного описания ошибки в compile-time).
Так что, с моей колокольни, концепты нужно не для облегчения разбирательства с ошибками, а для:
- выполнения перегрузки по шаблонам (уход от SFINAE в пользу нормального механизма перегрузки);
- документирования через код. Все-таки, когда у нас в параметрах шаблона не typename T, а какой-то MyConcept T, то это дает больше информации при погружении в код;
- упрощения при использовании auto. Почти как предыдущий пункт, но применительно к объявлениям пременных. Т.е., вот так:
auto i = get_something();
менее понятно, чем вот так:
MyConcept auto i = get_something();
Ну, auto я както не долюбливаю. Лучше уж прямо написать, что будет на выходе. В языках, где auto по умолчанию везде (например, rust) спасает то, что среда разработки сама выводит тип и показывает его. Но стоит выйти за среду разработки, и начать смотреть например код на гитхаб, то начинается попаболь.
Почему вместо typename T и MyConcept T не писать typename MyConcept?
Перегрузка по шаблонам - может быть. Уже сильно подзабыл шаблоны (да особо никогда и не помнил), так что не могу комментировать.
Я бы для интереса взял бы какую-нибудь совсем бесячую ошибку компиляции (если есть) и попробовал бы покрыть ее концептом. Посмотреть, будет ли разница. Потому что на маленьком примере, как видно, разницы нет
@sv
Я в vim-е работаю, без каких-либо подсказок. Если не злоупотреблять auto, то нормально. Кроме того, есть ситуации, когда без auto ну вот вообще никак (у нас в RESTinio есть самодельный генератор парсеров на базе PEG, там auto на auto и auto погоняет).
> Почему вместо typename T и MyConcept T не писать typename MyConcept?
Потому что со временем разницы между T и MyConcept не будет. Особенно в чужом коде. Напишешь вместо T вменяемое имя, вроде EventListener, но ведь все равно непонятно какие требования к этому самому EventListener. А так можно будет в соответствующий концепт заглянуть.
Отправить комментарий