У нас завершается очередная итерация работ над демо-проектом 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 ) { if( auto * 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 { if( 0u == 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-а усложнило и раздуло. Но очень уж лень удалять то, что сделано и что работает. Тем более то, что повышает надежность кода. Так что решение оказалось сильно неоднозначным. Поэтому, если вам в небольшом проекте захочется сделать что-то подобное, то имеет смысл хорошенько подумать -- а оно вам надо? В большой же проект можно затянуть какие-то готовые библиотеки, с помощью которых все это же самое будет достигаться проще и дешевле. Ну и в большом проекте сложность решения будет компенсироваться высокой ценой ошибки, которую можно допустить по недосмотру. Так что в большом проекте подобные навороты будут более оправданы.
Комментариев нет:
Отправить комментарий