среда, 15 августа 2018 г.

[prog.c++] Про попытку изобразить strong typedef из подручных средств

У нас завершается очередная итерация работ над демо-проектом Shrimp. На днях выйдет статья на Хабре с некоторыми подробностями (если кто-то пропустил, то вот первая статья про Shrimp, а вот вторая). Сегодня же я попробую рассказать об одной штуке в коде Shrimp-а, которая у меня самого вызывает неоднозначные впечатления. Если кому-то интересны потуги натянуть сову на глобус извращения старого сиплюсплюсника, то милости прошу под кат.

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

Указать количество рабочих нитей пользователь может двумя способами: либо через командную строку, либо через переменную окружения. Соответственно, если, скажем, аргумент командной строки --io-threads не задан, то нужно проверить значение переменной окружения SHRIMP_IO_THREADS. Если не задано ни то, ни другое, то количество рабочих нитей Shrimp определит самостоятельно.

Итак, в Shrimp-е появляется необходимость работать с парой опциональных значений типа unsigned int. Что, собственно, прямо так и записывается:

struct app_args_t
{
   ...

   std::optional<unsigned int> m_io_threads;
   std::optional<unsigned int> m_worker_threads;

   // Вспомогательный метод для парсинга переменных окружения
   // SHRIMP_IO_THREADS и SHRIMP_WORKER_THREADS.
   [[nodiscard]]
   static std::optional<unsigned int>
   uint_from_env_var( const char * env_var_name ) {...}
   ...
};

Эти два значения нужно получить во время разбора аргументов командной строки. Для парсинга этих самых аргументов используется библиотека Clara от автора известного C++ного фреймворка для unit-тестирования Catch2. Т.к. из коробки Clara значения optional<T> не поддерживает, то мы используем трюк с лямбда-функциями:

[[nodiscard]]
static app_args_t
parse( int argc, const char * argv[] )
{
   using namespace clara;

   ...

   std::optional<unsigned int> io_threads;
   std::optional<unsigned int> worker_threads;

   ...

   auto cli = make_opt(
            result.m_app_params.m_http_server.m_address, "address",
            "-a""--address",
            "address to listen (default: {})" )
      ...
      | Opt( [&io_threads](unsigned int const v) {
               io_threads = v;
            }, "non-zero number" )
            [ "--io-threads" ]
            ( "Count of threads for IO operations" )
      | Opt( [&worker_threads](unsigned int const v) {
               worker_threads = v;
            }, "non-zero number" )
            [ "--worker-threads" ]
            ( "Count of threads for resize operations" )
      ...
      | Help(result.m_help);

Т.е. если Clara обнаруживает аргумент --io-threads и успешно разбирает его значение, то вызывается заданная нами лямбда-функция, в которой мы обновляем значение переменной типа optional.

Далее в функции parse нам нужно проверить, были ли получены значения типа optional. Если не были, то нужно попробовать получить значение из переменной окружения. Если же в итоге значение было получено, то нужно проверить его валидность (значение 0 недопустимо). Все это записывается следующим образом:

if( !io_threads )
   io_threads = uint_from_env_var( "SHRIMP_IO_THREADS" );
if( io_threads && 0u == *io_threads )
   throw shrimp::exception_t{
         "Count of IO-threads must be greater than 0" };
result.m_io_threads = io_threads;

if( !worker_threads )
   worker_threads = uint_from_env_var( "SHRIMP_WORKER_THREADS" );
if( worker_threads && 0u == *worker_threads )
   throw shrimp::exception_t{
         "Count of worker threads must be greater than 0" };
result.m_worker_threads = worker_threads;

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

[[nodiscard]]
auto
calculate_thread_count(
   const std::optional<unsigned int> default_io_threads,
   const std::optional<unsigned int> default_worker_threads )
{
   struct result_t {
      unsigned int m_io_threads;
      unsigned int m_worker_threads;
   };

   const auto actual_io_threads_calculator = []() -> unsigned int {
      constexpr unsigned int max_io_threads = 2u;
      const unsigned int cores = std::thread::hardware_concurrency();
      return cores < max_io_threads * 3u ? 1u : max_io_threads;
   };

   const auto actual_worker_threads_calculator =
         [](unsigned int io_threads) -> unsigned int {
            const unsigned int cores = std::thread::hardware_concurrency();
            return cores <= io_threads ? 2u : cores - io_threads;
         };

   const unsigned int io_threads = default_io_threads ?
         *default_io_threads : actual_io_threads_calculator();
   const unsigned int worker_threads = default_worker_threads ?
         *default_worker_threads : actual_worker_threads_calculator(io_threads);

   return result_t{ io_threads, worker_threads };
}

Все бы было бы хорошо, но меня воспитали дятлы я учился программировать на Pascal-е и приобрел вредную привычку давать имена типам. В особенности если эти типы имеют какие-то ограничения. Так, в данном случае напрашивается некий тип thread_count, у которого есть важное ограничение -- значение 0 для thread_count недопустимо.

В Pascal (Modula-2, Ada и еще нескольких языках) хорошо -- там есть strong typedef. В C++ этого понятия "искаропки" нет. Но зато его можно попробовать изобразить. Что я и попытался сделать. Пользуясь только стандартной библиотекой языка C++, без привлечения каких-то сторонних инструментов.

Самой простой была бы версия типа thread_count_t, которая бы в конструкторе проверяла бы переданное значение и бросала бы исключение при неправильном значении. Но это затруднило бы конструирование объектов такого типа, скажем, в лямбда-функциях, которые передаются в Clara. Поэтому показанная ниже реализация содержит и бросающий исключения конструктор и небросающий метод-фабрику try_construct. Из-за чего реализация такого простого типа оказалась, на мой взгляд, слишком монструозной:

class thread_count_t final
{
public:
   using underlying_type_t = unsigned int;

private:
   static constexpr const char * const invalid_value = "Thread count can't be zero";

   struct trusty_t { underlying_type_t m_value; };

   underlying_type_t m_value;

   thread_count_t(trusty_t trusty) noexcept
      :  m_value{ trusty.m_value }
   {}

   [[nodiscard]]
   static underlying_type_t
   valid_value_or_throw( const std::variant<thread_count_t, const char *> v )
   {
      ifauto * msg = std::get_if<const char *>(&v); msg )
         throw std::runtime_error( *msg );

      return std::get<thread_count_t>(v).value();
   }

public:
   // Returns a normal object or pointer to error message in the case
   // of invalid value.
   [[nodiscard]]
   static std::variant<thread_count_t, const char *>
   try_construct( underlying_type_t value ) noexcept
   {
      if0u == value )
         return { invalid_value };
      else
         return { thread_count_t{ trusty_t{ value } } };
   }

   // Construct an object with checking for errors.
   // Throws an exception in case of invalid value.
   thread_count_t(underlying_type_t value)
      : m_value{ valid_value_or_throw( try_construct(value) ) }
   {}

   [[nodiscard]]
   underlying_type_t
   value() const noexcept { return m_value; }
};

Тут, наверное, имеет смысл дать пояснение по поводу приватного конструктора. Метод try_construct должен иметь возможность сконструировать объект thread_count_t, но при этом нет смысла вызвать бросающий конструктор -- там же опять вызывается try_construct. Поэтому нужен еще один конструктор без проверок. Отсюда и второй, приватный конструктор. Но передать туда просто unsigned int нельзя, тогда его формат не будет отличаться от единственного публичного конструктора. Поэтому приватный конструктор получает не unsigned int на вход, а экземпляр вспомогательной структуры trusty_t. Мол, тебе дают проверенное значение, которому можно доверять.

Соответственно, у нас появляется возможность использовать optional<thread_count_t>:

struct app_args_t
{
   ...

   std::optional<thread_count_t> m_io_threads;
   std::optional<thread_count_t> m_worker_threads;

   [[nodiscard]]
   static std::optional<thread_count_t>
   thread_count_from_env_var( const char * env_var_name ) {...}

Ну и при парсинге аргументов командной строки мы так же работаем с thread_count_t.

[[nodiscard]]
static auto
make_thread_count_handler( std::optional<thread_count_t> & receiver )
{
   return [&receiver]( thread_count_t::underlying_type_t const v ) {
      using namespace clara;
      return std::visit( shrimp::variant_visitor{
            [&receiver]( const thread_count_t & count ) {
               receiver = count;
               return ParserResult::ok( ParseResultType::Matched );
            },
            []( const char * error_msg ) {
               return ParserResult::runtimeError( error_msg );
            } },
            thread_count_t::try_construct(v) );
   };
}

[[nodiscard]]
static app_args_t
parse( int argc, const char * argv[] )
{
   ...

   std::optional<thread_count_t> io_threads;
   std::optional<thread_count_t> worker_threads;

   ...

   auto cli = make_opt(
            result.m_app_params.m_http_server.m_address, "address",
            "-a""--address",
            "address to listen (default: {})" )
      ...
      | Opt( make_thread_count_handler(io_threads), "non-zero number" )
            [ "--io-threads" ]
            ( "Count of threads for IO operations" )
      | Opt( make_thread_count_handler(worker_threads), "non-zero number" )
            [ "--worker-threads" ]
            ( "Count of threads for resize operations" )
      ...
      | Help(result.m_help);

Причем тут можно обратить внимание на страшного вида вспомогательный метод make_thread_count_handler. Этот метод нужен для конструирования лямбды, которая будет передана библиотеке Clara. А в самой этой лямбда-функции активно используется C++ный вариант patter-matching-а для бедных на основе std::visit и трюка с overloadded, описанного в cppreference.

Выглядит все это весьма страшно. Но сходу более компактного и читаемого варианта я не нашел.

Зато потом работа с опциональными значениями типа thread_count_t выглядит более лаконичной и адекватной. Вот фрагмент метода parse, который проверяет, было ли задано значение в командной строке и, если не было, пытается взять значение из переменной окружения:

result.m_io_threads = io_threads ? io_threads :
      thread_count_from_env_var( "SHRIMP_IO_THREADS" );

result.m_worker_threads = worker_threads ? worker_threads :
      thread_count_from_env_var( "SHRIMP_WORKER_THREADS" );

Можно сравнить с тем, что было показано выше.

Ну и вот как опциональные значения типа thread_count_t используются для вычисления количества рабочих нитей:

[[nodiscard]]
auto
calculate_thread_count(
   const std::optional<thread_count_t> default_io_threads,
   const std::optional<thread_count_t> default_worker_threads )
{
   struct result_t {
      thread_count_t m_io_threads;
      thread_count_t m_worker_threads;
   };

   const auto actual_io_threads_calculator = []() -> thread_count_t {
      constexpr unsigned int max_io_threads = 2u;
      const unsigned int cores = std::thread::hardware_concurrency();
      return { cores < max_io_threads * 3u ? 1u : max_io_threads };
   };

   const auto actual_worker_threads_calculator =
         [](thread_count_t io_threads) -> thread_count_t {
            const unsigned int cores = std::thread::hardware_concurrency();
            return { cores <= io_threads.value() ?
                  2u : cores - io_threads.value() };
         };

   const auto io_threads = default_io_threads ?
         *default_io_threads : actual_io_threads_calculator();
   const auto worker_threads = default_worker_threads ?
         *default_worker_threads : actual_worker_threads_calculator(io_threads);

   return result_t{ io_threads, worker_threads };
}

Тут особо ничего не поменялось.


Что можно сказать по итогу такой переделки?

С одной стороны, strong typedefs сильно повышают коэффициент спокойного сна. Понятно, что по ошибке произвольный unsigned int в качестве thread_count уже не засунешь. Да и прикладной код, который оперирует понятиями thread_count, становится более читабельным и, местами, даже более лаконичным.

Но сильно расстраивает объем вспомогательного кода, который приходится написать для достижения всего этого благоденствия. Имхо, если бы в C++ была лучшая поддержка алгебраических типов данных и паттерн-матчинга, то жизнь была бы веселее. Так же в стандартной библиотеке не помешало бы что-то из обсуждающихся в C++-сообществе заимствований из функциональных языков, вроде expected<T, E>. Конечно, уже сейчас можно взять Expected из folly или result из Boost.Outcome, но это нужно сторонние библиотеки к себе в проект затягивать. Что печально, т.к. это функциональность, которую хотелось бы видеть в stdlib.

В общем, реализацию со strong typedef в виде класса thread_count_t в коде Shrimp-а я пока оставил. Хотя это явно реализацию Shrimp-а усложнило и раздуло. Но очень уж лень удалять то, что сделано и что работает. Тем более то, что повышает надежность кода. Так что решение оказалось сильно неоднозначным. Поэтому, если вам в небольшом проекте захочется сделать что-то подобное, то имеет смысл хорошенько подумать -- а оно вам надо? В большой же проект можно затянуть какие-то готовые библиотеки, с помощью которых все это же самое будет достигаться проще и дешевле. Ну и в большом проекте сложность решения будет компенсироваться высокой ценой ошибки, которую можно допустить по недосмотру. Так что в большом проекте подобные навороты будут более оправданы.

Комментариев нет: