четверг, 14 марта 2024 г.

[prog.flame] Самодокументирующися код против документированного, наглядно

Намедни в LinkedIn поиронизировал на счет "самодокументирующегося кода". В очередной раз 😎

В очередной раз с удивлением для себя обнаружил людей, скептически относящихся к необходимости комментировать код.

Подумал, что лучше одна иллюстрация лучше тысячи слов. Поэтому вот пример кода из реального проекта. Изменены только названия сущностей, все комментарии оставлены как есть. Плюс изъяты фрагменты, не относящиеся к самой иллюстрации.

Каждый сам может для себя решить, с каким кодом ему проще было бы разбираться. Сам-то я для себя уже давно выводы сделал. Поэтому и пишу комментарии, хотя тут мне еще есть куда расти, к сожалению. Не всегда получается сделать хорошо с первого раза 🙁

Под катом оригинальный фрагмент с комментариями. Такими, какими они и были написаны при разработке. Но сперва этот же фрагмент, но вообще без комментариев. Именно так "самодокументирующися" код и выглядит, по моему (не)скромному опыту.

class DefaultThreadPoolScheduler final : public Scheduler
{
   ...

private:
   struct WorkerData
   {
      std::mutex _lock;
      std::condition_variable _wakeupCondition;

      Scheduler::TaskUniquePtr _taskToRun;

      bool _shutdownInitiated{ false };
   };

   using WorkerDataContainer =
      std::vector<std::reference_wrapper<WorkerData>>;

   std::latch _allWorkersStartedLatch;

   std::mutex _lock;

   TasksContainer _tasksQueue;

   ThreadPool _threadPool;

   WorkerDataContainer _availableWorkers;

   bool _shutdown{ false };
};

void DefaultThreadPoolScheduler::doWork() noexcept
{
   WorkerData thisWorkerData;

   {
      std::lock_guard schedulerLock{ _lock };
      _availableWorkers.push_back(std::ref(thisWorkerData));

      _allWorkersStartedLatch.count_down();
   }

   bool shutdownIntitiated{ false };
   while( !shutdownIntitiated )
   {
      std::unique_lock workerLock{ thisWorkerData._lock };

      if( TaskUniquePtr taskToRun = std::move(thisWorkerData._taskToRun); !taskToRun )
      {
         shutdownIntitiated = thisWorkerData._shutdownInitiated;
         if( !shutdownIntitiated )
         {
            thisWorkerData._wakeupCondition.wait(workerLock);

            shutdownIntitiated = thisWorkerData._shutdownInitiated;
         }
      }
      else
      {
         workerLock.unlock();
         taskToRun->run(Scheduler::RunCondition::Normal);
         completeTaskThenTryGetNext(std::move(taskToRun), thisWorkerData);
      }
   }
}

понедельник, 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.