среда, 7 мая 2025 г.

[prog.c++] Настройка библиотеки под нужды пользователя чисто C++ными средствами

Подсмотрел вот в этом блог-посте - A New Approach to Build-Time Library Configuration - интересный трюк, который захотелось утащить к себе в склерозник на всякий случай.

Допустим, что мы сделали библиотеку. И допустим, что мы хотим дать пользователю как-то кастомизировать поведение библиотеки под себя во время компиляции.

Например, допустим, что мы используем в библиотеке какие-то временные вектора на стеке (скажем std::array<unsigned char, N>) и пользователь должен уметь управлять значением N. Чтобы он мог увеличить N когда это нужно или уменьшить N когда не нужно.

Традиционно в C++ для этих целей используют унаследованный из чистого Си подход с определением символов (они же define-ы). Например, определяют MY_LIB_DEFAULT_N через ключики компиляции. Что-то вроде:

g++ -D MY_LIB_DEFAULT_N=10240 ...

А в коде библиотеки мы делаем что-то вроде:

#if !defined(MY_LIB_DEFAULT_N)
  #define MY_LIB_DEFAULT_N 4096
#endif

namespace my_lib {

constexpr tmp_array_size = MY_LIB_DEFAULT_N;

...
void some_func() {
  std::array<unsigned char, tmp_array_size> tmp_array;
  ...
}

/* namespace my_lib */

Когда таких ручек для тонкой настройки много, все они собираются в некий user-config.h, который должен быть предоставлен пользователем библиотеки (а дефолтная версия user-config.h может генерироваться при установке библиотеки). Получается что-то вроде:

#define MY_LIB_DEFAULT_N 10240
#define MY_LIB_LOGGING_POLICY 0
#define MY_LIB_TRACING_MODE 3
...

В самой же библиотеке мы имеем специальный заголовочный файл impl/config.h, который будет иметь вид:

// Подключаем то, что выставил пользователь.
#include "user-config.h"

// А потом разбираемся с тем, что пользователь выставил или не выставил.
#if !defined(MY_LIB_DEFAULT_N)
  #define MY_LIB_DEFAULT_N 4096
#endif
#if !defined(MY_LIB_LOGGING_POLICY)
  #define MY_LIB_LOGGING_POLICY 2
#endif
#if !defined(MY_LIB_TRACING_MODE)
  #define MY_LIB_TRACING_MODE 10
#endif
... // Далее преобразуем define-ы в типизированные константы.

Для проверки наличия файла user-config.h, как показано в упомянутом выше блог-посте, можно использовать __has_include:

// Подключаем то, что выставил пользователь.
#if __has_include("user-config.h"
  #include "user-config.h"
#endif

// А потом разбираемся с тем, что пользователь выставил или не выставил.
#if !defined(MY_LIB_DEFAULT_N)
  #define MY_LIB_DEFAULT_N 4096
#endif
#if !defined(MY_LIB_LOGGING_POLICY)
  #define MY_LIB_LOGGING_POLICY 2
#endif
#if !defined(MY_LIB_TRACING_MODE)
  #define MY_LIB_TRACING_MODE 10
#endif
... // Далее преобразуем define-ы в типизированные константы.

Но это Си-ное наследие. А ведь можно использовать и чисто C++ные механизмы.

Так, в нашем impl/config.h может быть:

namespace my_lib {

...

namespace config_defaults {
  constexpr std::size_t tmp_array_size = 4096;
  constexpr logging_policy_t logging_policy = logging_policy_t::minimal;
  constexpr tracing_mode_t tracing_mode = tracing_mode_t::runtime_control;
  ...
/* namespace config_defaults */

using namespace config_defaults; // А вот и трюк.

/* namespace my_lib */

// Подключаем то, что выставил пользователь.
#if __has_include("user-config.h"
  #include "user-config.h"
#endif

Теперь пользователь в своем user-config.h может написать, например, так:

namespace my_lib {

constexpr std::size_t tmp_array_size = 10240;
constexpr logging_policy_t logging_policy = logging_policy_t::detailed;
constexpr tracing_mode_t tracing_mode = tracing_mode_t::off;
  ...

/* namespace my_lib */

И эти значения будут иметь больший приоритет, чем те, которые были определены в нашем my_lib::config_defaults, а затем введены в область видимости my_lib через using namespace.


Любопытно, что что-то похожее мы использовали в RESTinio для упрощения настройки свойств сервера (пример). Но у нас нужно было наследоваться от trait-класса. А тут без наследования, что для каких-то случаев гораздо удобнее.

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