понедельник, 11 марта 2024 г.

[prog.c++] Первая попытка написать собственный концепт

В проекте для одного из клиентов уже используется 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 комментирует...

Как я понимаю, концепты нужны для облегчения чтения ошибок при компиляции шаблонного кода. Помогли ли они в данном случае? Попробовал покомпилировать код с концептами и без, вроде разница не особо большая. И в одном, и в другом случае пару строк с ошибкой компиляции, в которой вроде все боле-менее понятно

eao197 комментирует...

@sv

Я бы не сказал, что концепты в этом плане как-то кардинально лучше. ИМХО, самые толковые сообщения об ошибках дают static_assert-ы внутри реализации шаблонов (особенно когда разрешат там использовать std::format для формирования внятного описания ошибки в compile-time).

Так что, с моей колокольни, концепты нужно не для облегчения разбирательства с ошибками, а для:

- выполнения перегрузки по шаблонам (уход от SFINAE в пользу нормального механизма перегрузки);
- документирования через код. Все-таки, когда у нас в параметрах шаблона не typename T, а какой-то MyConcept T, то это дает больше информации при погружении в код;
- упрощения при использовании auto. Почти как предыдущий пункт, но применительно к объявлениям пременных. Т.е., вот так:

auto i = get_something();

менее понятно, чем вот так:

MyConcept auto i = get_something();

sv комментирует...

Ну, auto я както не долюбливаю. Лучше уж прямо написать, что будет на выходе. В языках, где auto по умолчанию везде (например, rust) спасает то, что среда разработки сама выводит тип и показывает его. Но стоит выйти за среду разработки, и начать смотреть например код на гитхаб, то начинается попаболь.
Почему вместо typename T и MyConcept T не писать typename MyConcept?
Перегрузка по шаблонам - может быть. Уже сильно подзабыл шаблоны (да особо никогда и не помнил), так что не могу комментировать.

Я бы для интереса взял бы какую-нибудь совсем бесячую ошибку компиляции (если есть) и попробовал бы покрыть ее концептом. Посмотреть, будет ли разница. Потому что на маленьком примере, как видно, разницы нет

eao197 комментирует...

@sv

Я в vim-е работаю, без каких-либо подсказок. Если не злоупотреблять auto, то нормально. Кроме того, есть ситуации, когда без auto ну вот вообще никак (у нас в RESTinio есть самодельный генератор парсеров на базе PEG, там auto на auto и auto погоняет).

> Почему вместо typename T и MyConcept T не писать typename MyConcept?

Потому что со временем разницы между T и MyConcept не будет. Особенно в чужом коде. Напишешь вместо T вменяемое имя, вроде EventListener, но ведь все равно непонятно какие требования к этому самому EventListener. А так можно будет в соответствующий концепт заглянуть.