среда, 19 мая 2021 г.

[prog.c++] Больше шаблонов богу шаблонов: детали реализации неоднозначной фичи, которая может стать частью json-dto

Где-то пять лет назад мы сделали небольшую библиотеку-обертку над RapidJSON: json-dto. Целью было снизить количество писанины при работе RapidJSON. Вроде бы у нас получилось:

#include <json_dto/pub.hpp>

#include <deque>
#include <set>
#include <map>

struct my_message {
   std::deque<int> ids_;
   std::set<std::string> tags_;
   std::map<std::string, some_another_type> props_;
   ...
   template<typename Json_Io>
   void json_io(Json_Io & io) {
      io & json_dto::mandatory("ids", ids_)
         & json_dto::mandatory("tags", tags_)
         & json_dto::mandatory("properties", props_)
         ...
         ;
   }
};

Библиотека открытая, но про нее мало кто знает. Тем удивительнее, ей пользуется еще кто-то кроме нас. По крайней до сих пор приходят запросы на добавление той или иной фичи в json-dto.

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

Человек захотел, чтобы можно было описывать поля объекта, которые подлежат только сериализации. Т.е. они участвуют при записи в JSON, а вот при десериализации из JSON их значения должны игнорироваться. Автор этого запроса даже сделал PR с реализацией фичи, которую он хотел получить в json-dto. Однако, предложенный вариант мне не понравился.

Данный пост посвящен рассказу о том, почему же PR не был принят в json-dto, что захотелось получить мне и что в итоге удалось придумать.

Что хотелось иметь пользователю?

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

struct demo {
  int version_;
  int payload_;
  ...
  template<typename Json_Io>
  void json_io(Json_Io & io) {
    io & json_dto::serialize_only("version", version_)
      & json_dto::mandatory("payload", payload_)
      ;
  }
};

При сериализации значения объекта типа demo в результирующий JSON должны были попасть и поле demo::version_, и поле demo::payload_. А вот при десериализации из JSON должно было быть прочитано только значение поля demo::payload_, а значение "version" (если таковое присутствует в JSON) должно было быть проигнорировано.

Более того, в процессе общения выяснилось, что эти самые serialize-only-атрибуты представлены не обычными полями структуры/класса, а возвращаемыми значениями методов класса. Что-то вроде:

struct demo {
  int version() const { return 1; }
  int payload_;
  ...
  template<typename Json_Io>
  void json_io(Json_Io & io) {
    io & json_dto::serialize_only("version", version())
      & json_dto::mandatory("payload", payload_)
      ;
  }
};

Что пользователь предлагал сделать и почему мне это не понравилось?

Решение, которое в виде PR предложил сам пользователь, состояло в том, чтобы расширить json-dto еще парой функций для создания binder-ов для полей объекта (о binder-ах будет сказано ниже). Т.е., к уже имеющимся mandatory, optional, optional_null и optional_no_default добавился бы еще serialize_only.

Этот новый serialize_only работал бы по простой схеме: при сериализации преобразовывал значение поля в JSON-формат. При десериализации бы вообще ничего не делал. Т.е. даже не проверял бы наличие и формат поля в JSON-е.

Почему PR не прошел?

В предложенном варианте мне не понравилось, как минимум, две вещи.

Первое, и самое простое, -- это отсутствие симметрии. Т.е., если есть serialize_only, то логично было бы добавить еще и deserialize_only. Ведь вполне могут быть случаи, когда мы хотим прочитать значения из JSON-а, но не хотим их затем записывать. Например, это может потребоваться когда мы конвертируем какие-то данные из старого представления в новое. Скажем, был у нас JSON, в котором было поле version. А нужно сделать другой JSON, в котором поля version уже нет.

Следовательно, кроме новой функции serialize_only нужно ввести еще и deserialize_only. Что еще больше увеличивает набор функций для создания binder-ов: mandatory, optional, optional_null, optional_no_default, serialize_only и deserialize_only. Что не есть хорошо с учетом второй не понравившейся мне вещи.

Второй момент, который смущал гораздо сильнее, -- это разрушение привычной и понятной логики, которая ранее обеспечивалась функциями mandatory, optional и optional_no_default. А именно:

  • mandatory означает, что атрибут всегда сериализуется, а при десериализации всегда требуется его присутствие;
  • optional означает, что атрибут сериализуется только если его значение отличается от дефолтного, а при десериализации атрибут может и отсутствовать в JSON-е (в этом случае после десериализации он получает дефолтное значение). Если же атрибут при десериализации в JSON-е найден, то полностью проверяется его формат и, если нужно, его значение;
  • optional_no_default означает, что атрибут сериализуется всегда. А при десериализации, если атрибут в JSON-е найден, то его формат и, если нужно, значение проверяются. Если при десериализации атрибута в JSON-е не найдено, то ничего не делается.

Теперь мы добавляем serialize_only, который должен вести себя как?

Должен ли он сериализоваться всегда или же только когда его значение отличается от какого-то дефолтного?

Должны ли мы при десериализации проверять наличие такого атрибута и его формат? Ведь одно дело, когда в JSON атрибута нет вообще. Ну нет и нет, что уж поделать. А вот если есть, но содержит какой-то невалидный мусор? Устраивает ли нас тот факт, что нам могут подсунуть какую-то откровенную ерунду, а мы оттуда возьмем лишь то, что нам интересно? А если не устраивает?

Т.е. если мы решаем какую-то конкретную задачу, типа взять отсюда вот такой JSON (который может быть только вот таким), прочитать из него лишь то-то и то-то, а затем создать новый JSON (который может быть только вот таким) и положить его вот сюда, то набор частных функций serialize_only/deserialize_only с жестко зашитой логикой поведения -- это нормальный и, возможно, единственно верный подход.

Но для универсальной библиотеки, которую разные люди используют в разных условиях и для разных задач... Мне показалось, что не следует двигаться по этому пути.

Что хотелось бы добавить в json-dto?

Во-первых, не хотелось добавлять в сам json-dto функциональность serialize_only и deserialize_only. Потому, что вряд ли это будет нужно большому количеству пользователей. А добавлять маргинальную фичу, на которую затем постоянно придется оглядываться, ну так себе идея.

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

Так что вместо включения в состав json-dto новых дополнительных функций хотелось добавить какие-то средства, которые бы позволили пользователям, в случае острой необходимости, самостоятельно добавить и serialize_only, и deserialize_only, и еще что-нибудь эдакое.

Во-вторых, очень хотелось оставить набор штатных функций mandatory, optional и optional_no_default с их привычной логикой. И чтобы новая функциональность, вроде serialize_only/deserialize_only, могла быть вписана в уже имеющиеся mandatory/optional/optional_no_default.

Найденый способ

Пару слов про binder-ы для полей

Прежде чем рассказывать о том, какой способ был найден и реализован, нужно кратко пройтись по binder-ам для (де)сериализуемых полей объектов.

Когда пользователь вызывает функцию mandatory (или optional, или optional_no_default), то эта функция возвращает объект binder. Данный объект содержит имя (де)сериализуемого поля и ссылку на это самое поле. Плюс к тому, в объекте binder хранятся reader_writer (отвечает за форматирование значения) и validator (отвечает за проверку значения).

Так, когда пользователь записывает у себя:

template<typename Json_Io>
void demo::json_io(Json_Io & io) {
  io & json_dto::mandatory("version", version_);
}

то за этой записью скрывается создание объекта binder-а и применение к этому объекту `operator&`.

С `operator&` связана совсем тривиальная логика.

Когда нужно сериализовать содержимое объекта посредством json_dto::to_json, то внутри to_json создается объект типа json_output_t, ссылка на который и передается в json_io. В результате чего компилятор находит определение `operator&` для json_output_t и binder-а. Этот оператор просто вызывает у binder-а метод write_to:

class json_output_t
{
   public:
...
      templatetypename Binder >
      json_output_t &
      operator & ( const Binder & b )
      {
         b.write_to( m_object, m_allocator );
         return *this;
      }

   private:
      rapidjson::Value & m_object;
      rapidjson::MemoryPoolAllocator<> & m_allocator;
};

При десериализации посредством json_dto::from_json происходит практически тоже самое, только создается объект типа json_input_t, а `operator&` для него вызывает у binder-а метод read_from:

class json_input_t
{
   public:
...
      templatetypename Binder >
      json_input_t &
      operator & ( const Binder & b )
      {
         b.read_from( m_object );
         return *this;
      }

   private:
      const rapidjson::Value & m_object;
};

Т.е. вся основная работа по (де)сериализации полей объекта выполняется объектами binder-ами. Объекты binder-ы создаются вспомогательными функциями mandatory, optional, optional_null, optional_no_default. При этом наиболее важными являются следующие вещи:

  • объект binder хранит ссылку на поле объекта;
  • (де)сериализацию выполняют нестатические методы read_from/write_to объекта binder-а;
  • объект binder также хранит reader_writer и validator для работы со значением поля. Во многих случаях это будет дефолтные reader_writer/validator, но где-то это будут заданные пользователем экземпляры.

Основная идея

Идея состоит в том, чтобы:

  • позволить передавать в binder не только ссылку на поле объекта. А какой-то прокси, внутри которого уже может быть что угодно. Хоть ссылка на поле объекта, хоть какой-то промежуточный объект...
  • позволить пользователю делать специализацию для методов binder_t::write_to и binder_t::read_from.

Что это дает?

Например, пользователь может сделать свой прокси-тип serialize_only_proxy, который будет хранить ссылку на сериализуемое поле. И при вызове json_dto::mandatory пользователь будет отдавать в mandatory уже не ссылку на сериализуемое поле, а прокси-объект. Скажем, вот так:

template<typename F>
struct serialize_only_proxy {
  const F & field_;
};

template<typename F>
auto serialize_only(const F & field) noexcept { return serialize_only_proxy{field}; }

struct demo {
  int version() const { return 1; }
  ...
  template<typename Json_Io>
  void json_io(Json_Io & io) {
    io & json_dto::mandatory("version", serialize_only(version()))
      ...;
  }
};

Далее пользователь делает специализации методов binder_t::read_from/write_to для своего serialize_only_proxy. Специализация read_from может вообще ничего не делать (т.е. вообще игнорировать наличие атрибута в JSON). А специализация write_to будет выполнять обычную сериализацию.

При этом очень хотелось бы, чтобы пользователь мог переиспользовать штатные реализации read_from/write_to. Поскольку там уже задействуются должным образом reader_writer и validator. И, если пользователю не нужна какая-то хитрая процедура (де)сериализации, то чтобы ему не нужно было самому вручную работать с reader_writer и validator.

Точки кастомизации

Тип binder_t в json-dto -- это шаблон класса:

template<
   typename Reader_Writer,
   typename Field_Type,
   typename Manopt_Policy,
   typename Validator >
class binder_t
{
...
};

Параметр Field_Type в этом шаблоне означает тип (де)сериализуемого поля. Т.е., это будет, например, int или std::string, или std::vector<std::uint32_t>, или что-то еще.

Если разрешить передавать в mandatory/optional/прочие-функции не только ссылки на поля, а какие-то прокси объекты (вроде показанного выше serialize_only_proxy), то Field_Type будет обозначать тип прокси объекта. Т.е., если написать:

io & json_dto::mandatory("version", serialize_only(version()))

То в этом месте будет создан объект binder_t<default_reader_writer_t, serialize_only_proxy<int>, mandatory_attr_t, empty_validator_t>.

В принципе, этого уже было бы достаточно, чтобы поддержать serialize_only/deserialize_only и пр. подобные штуки. Т.к. пользователь мог бы сделать частичную специализацию шаблона binder_t для своего serialize_only_proxy<F>:

template<
   typename Reader_Writer,
   typename Actual_Field_Type,
   typename Manopt_Policy,
   typename Validator >
class binder_t<
   Reader_Writer,
   const serialize_only_proxy<Actual_Field_Type>,
   Manopt_Policy,
   Validator >
{
...
};

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

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

template<
   typename Reader_Writer,
   typename Actual_Field_Type,
   typename Manopt_Policy,
   typename Validator >
class binder_t<
      Reader_Writer,
      const serialize_only_proxy<Actual_Field_Type>,
      Manopt_Policy,
      Validator >
   : public binder_t< // Наследуем дефолтную реализацию.
      Reader_Writer,
      const Actual_Field_Type,
      Manopt_Policy,
      Validator >
{
   using base_t = binder_t<Reader_Writer, const Actual_Field_Type,   Manopt_Policy, Validator>;
public:
   binder_t(
      Reader_Writer && reader_writer,
      serialize_only_proxy<Actual_Field_Type> proxy,
      Manopt_Policy && manopt_policy,
      Validator && validator)
      : base_t{
         std::move(reader_writer),
         proxy.field_, // ВАЖНО: в базовый тип идет ссылка на исходное поле.
         std::move(manopt_policy),
         std::move(validator)
      }
   {}

   void read_from( const rapidjson::Value & object ) const {
      // Ничего не делаем.
   }
};

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

Но, допустим, нам нужно хранить именно прокси-объект. И из этого прокси-объекта мы ничего не можем отдать в дефолтную реализацию шаблона binder_t...

В этом случае пользователю ничего не останется, как полностью повторить у себя реализацию binder_t. Что не есть хорошо.

Поэтому я пошел несколько другим путем и добавил несколько точек кастомизации в штатную реализацию binder_t.

binder_data_holder_t

Первая точка кастомизации -- это шаблон класса binder_data_holder_t, который хранит все данные, необходимые для binder_t. Т.е. все то, что раньше хранилось непосредственно внутри binder_t -- reader_writer, field_name, field, validator -- все это теперь переехало в binder_data_holder_t. А binder_t теперь просто хранит у себя экземпляр binder_data_holder_t.

У шаблона binder_data_holder_t есть штатная реализация, которую можно переиспользовать для собственных целей.

binder_read_from_implementation_t и binder_write_to_implementation_t

Две другие добавленные точки кастомизации -- это шаблоны классов binder_read_from_implementation_t и binder_write_to_implementation_t.

Класс binder_read_from_implementation_t должен предоставить статический публичный метод read_from. А класс binder_write_to_implementation_t -- статический публичный метод write_to.

Класс binder_t теперь просто дергает эти статические методы при выполнении соответствующих операций.

У шаблонов binder_read_from_implementation_t и binder_write_to_implementation_t есть штатные реализации, которые можно переиспользовать для собственных целей.

Во что превратился binder_t?

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

template<
      typename Reader_Writer,
      typename Field_Type,
      typename Manopt_Policy,
      typename Validator >
class binder_t
{
      using data_holder_t = binder_data_holder_t<
            Reader_Writer,
            Field_Type,
            Manopt_Policy,
            Validator >;

   public:
      binder_t(
         Reader_Writer && reader_writer,
         string_ref_t field_name,
         Field_Type & field,
         Manopt_Policy && manopt_policy,
         Validator && validator )
         :  m_data_holder{
               std::move(reader_writer),
               field_name,
               field,
               std::move( manopt_policy ),
               std::move( validator )
            }
      {}

      void read_from( const rapidjson::Value & object ) const
      {
         binder_read_from_implementation_t<data_holder_t>::read_from( m_data_holder, object );
      }

      void write_to(
         rapidjson::Value & object,
         rapidjson::MemoryPoolAllocator<> & allocator ) const
      {
         binder_write_to_implementation_t<data_holder_t>::write_to(
               m_data_holder, object, allocator );
      }

   private:
      data_holder_t m_data_holder;
};

 

Как эти точки кастомизации использовать для своих нужд?

Итак, пользователь захотел сделать свой serialize_only. Что ему нужно сделать?

Во-первых, ему нужно описать свой тип прокси-объекта, который будет отдаваться в функции mandatory/optional/прочие-функции вместо ссылки на поле.

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

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

Вот, собственно, и все. Хотя, как можно увидеть ниже, выглядит это достаточно многословно. И многоэтажно... :)

Как это все выглядит в коде

 

Простой serialize_only

Вот пример простейшей реализации serialize_only для C++14. При десериализации наличие атрибута с таким именем и его формата вообще никак не проверяется.

namespace tutorial_20_1
{

templatetypename F >
struct serialize_only_proxy_t
{
   using field_type = const F;

   const F * m_field;
};

templatetypename F >
serialize_only_proxy_t<F> serialize_only( const F & field ) noexcept
{
   return { &field };
}

/* namespace tutorial_20_1 */

namespace json_dto
{

template<
   typename Reader_Writer,
   typename Field_Type,
   typename Manopt_Policy,
   typename Validator >
class binder_data_holder_t<
      Reader_Writer,
      const tutorial_20_1::serialize_only_proxy_t<Field_Type>,
      Manopt_Policy,
      Validator >
   :  public binder_data_holder_t<
         Reader_Writer,
         typename tutorial_20_1::serialize_only_proxy_t<Field_Type>::field_type,
         Manopt_Policy,
         Validator >
{
   using serialize_only_proxy =
         tutorial_20_1::serialize_only_proxy_t<Field_Type>;

   using actual_field_type = typename serialize_only_proxy::field_type;

   using base_type = binder_data_holder_t<
         Reader_Writer,
         actual_field_type,
         Manopt_Policy,
         Validator >;

public:
   binder_data_holder_t(
      Reader_Writer && reader_writer,
      string_ref_t field_name,
      const serialize_only_proxy & proxy,
      Manopt_Policy && manopt_policy,
      Validator && validator )
      :  base_type{
            std::move(reader_writer),
            field_name,
            *(proxy.m_field),
            std::move(manopt_policy),
            std::move(validator)
         }
   {}
};

template<
   typename Reader_Writer,
   typename Field_Type,
   typename Manopt_Policy,
   typename Validator >
struct binder_read_from_implementation_t<
      binder_data_holder_t<
         Reader_Writer,
         const tutorial_20_1::serialize_only_proxy_t<Field_Type>,
         Manopt_Policy,
         Validator
      >
   >
{
   using data_holder_t = binder_data_holder_t<
         Reader_Writer,
         const tutorial_20_1::serialize_only_proxy_t<Field_Type>,
         Manopt_Policy,
         Validator >;

   static void
   read_from(
      const data_holder_t & /*binder_data*/,
      const rapidjson::Value & /*object*/ )
   {
      // Nothing to do.
   }

};

/* namespace json_dto */

Можно обратить внимание, что не нужно делать специализацию для binder_write_to_implementation_t. За счет того, что была сделана специализация для binder_data_holder_t за сериализацию отвечает штатная реализация binder_write_to_implementation_t.

Используется это вот так (можно обратить внимание на применение не только mandatory, но и optional):

struct example_data
{
   std::vector< std::uint32_t > ids() const { return { 1u2u3u4u }; }
   std::uint32_t m_payload{};
   std::uint32_t m_priority{};

   int m_version_base{ 18 };
   int version() const noexcept { return m_version_base + 2; }

   example_data() = default;
   example_data( std::uint32_t payload ) : m_payload{ payload } {}

   template < typename Json_Io >
   void
   json_io( Json_Io & io )
   {
      io
         & json_dto::mandatory( "ids",
               tutorial_20_1::serialize_only( ids() ) )
         & json_dto::mandatory( "payload", m_payload )
         & json_dto::optional( "priority",
               tutorial_20_1::serialize_only( m_priority ),
               0u )
         & json_dto::optional( "version",
               tutorial_20_1::serialize_only( version() ),
               18 );
   }
};

 

Простой deserialize_only

Простейшая реализация deserialize_only для C++14 выглядит вот так:

namespace tutorial_20_2
{

templatetypename F >
struct deserialize_only_proxy_t
{
   using field_type = F;

   F * m_field;
};

templatetypename F >
deserialize_only_proxy_t<F> deserialize_only( F & field ) noexcept
{
   static_assert( !std::is_const<F>::value,
         "deserialize_only can't be used with const objects" );

   return { &field };
}

/* namespace tutorial_20_2 */

namespace json_dto
{

template<
   typename Reader_Writer,
   typename Field_Type,
   typename Manopt_Policy,
   typename Validator >
class binder_data_holder_t<
      Reader_Writer,
      const tutorial_20_2::deserialize_only_proxy_t<Field_Type>,
      Manopt_Policy,
      Validator >
   :  public binder_data_holder_t<
         Reader_Writer,
         typename tutorial_20_2::deserialize_only_proxy_t<Field_Type>::field_type,
         Manopt_Policy,
         Validator >
{
   using deserialize_only_proxy =
         tutorial_20_2::deserialize_only_proxy_t<Field_Type>;

   using actual_field_type = typename deserialize_only_proxy::field_type;

   using base_type = binder_data_holder_t<
         Reader_Writer,
         actual_field_type,
         Manopt_Policy,
         Validator >;

public:
   binder_data_holder_t(
      Reader_Writer && reader_writer,
      string_ref_t field_name,
      const deserialize_only_proxy & proxy,
      Manopt_Policy && manopt_policy,
      Validator && validator )
      :  base_type{
            std::move(reader_writer),
            field_name,
            *(proxy.m_field),
            std::move(manopt_policy),
            std::move(validator)
         }
   {}
};

template<
   typename Reader_Writer,
   typename Field_Type,
   typename Manopt_Policy,
   typename Validator >
struct binder_write_to_implementation_t<
      binder_data_holder_t<
         Reader_Writer,
         const tutorial_20_2::deserialize_only_proxy_t<Field_Type>,
         Manopt_Policy,
         Validator
      >
   >
{
   using data_holder_t = binder_data_holder_t<
         Reader_Writer,
         const tutorial_20_2::deserialize_only_proxy_t<Field_Type>,
         Manopt_Policy,
         Validator >;

   static void
   write_to(
      const data_holder_t & /*binder_data*/,
      rapidjson::Value & /*object*/,
      rapidjson::MemoryPoolAllocator<> & /*allocator*/ )
   {
      // Nothing to do.
   }

};

/* namespace json_dto */

Здесь так же обращу внимание на то, что не пришлось делать специализацию для binder_read_from_implementation_t -- отлично подходит штатная версия.

Используется это вот так:

struct example_data
{
   std::uint32_t m_a{};
   std::uint32_t m_b{};
   std::uint32_t m_c{};

   template < typename Json_Io >
   void
   json_io( Json_Io & io )
   {
      io
         & json_dto::mandatory( "a",
               tutorial_20_2::deserialize_only( m_a ) )
         & json_dto::optional( "b",
               tutorial_20_2::deserialize_only( m_b ), 42u )
         & json_dto::optional_no_default( "c", m_c );
   }
};

 

Более сложный ignore_after_deserialization

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

namespace tutorial_20_3
{

templatetypename F >
struct ignore_after_deserialization_proxy_t
{
   using field_type = const F;

   const F * m_field;
};

templatetypename F >
ignore_after_deserialization_proxy_t<F>
ignore_after_deserialization( const F & field ) noexcept
{
   return { &field };
}

/* namespace tutorial_20_3 */

namespace json_dto
{

template<
   typename Reader_Writer,
   typename Field_Type,
   typename Manopt_Policy,
   typename Validator >
class binder_data_holder_t<
      Reader_Writer,
      const tutorial_20_3::ignore_after_deserialization_proxy_t<Field_Type>,
      Manopt_Policy,
      Validator >
   :  public binder_data_holder_t<
         Reader_Writer,
         typename tutorial_20_3::ignore_after_deserialization_proxy_t<Field_Type>::field_type,
         Manopt_Policy,
         Validator >
{
   using proxy_type =
         tutorial_20_3::ignore_after_deserialization_proxy_t<Field_Type>;

   using actual_field_type = typename proxy_type::field_type;

   using base_type = binder_data_holder_t<
         Reader_Writer,
         actual_field_type,
         Manopt_Policy,
         Validator >;

public:
   binder_data_holder_t(
      Reader_Writer && reader_writer,
      string_ref_t field_name,
      const proxy_type & proxy,
      Manopt_Policy && manopt_policy,
      Validator && validator )
      :  base_type{
            std::move(reader_writer),
            field_name,
            *(proxy.m_field),
            std::move(manopt_policy),
            std::move(validator)
         }
   {}
};

template<
   typename Reader_Writer,
   typename Field_Type,
   typename Manopt_Policy,
   typename Validator >
struct binder_read_from_implementation_t<
      binder_data_holder_t<
         Reader_Writer,
         const tutorial_20_3::ignore_after_deserialization_proxy_t<Field_Type>,
         Manopt_Policy,
         Validator
      >
   >
{
   using proxy_type = tutorial_20_3::ignore_after_deserialization_proxy_t<Field_Type>;

   using data_holder_t = binder_data_holder_t<
         Reader_Writer,
         const proxy_type,
         Manopt_Policy,
         Validator >;

   static void
   read_from(
      const data_holder_t & binder_data,
      const rapidjson::Value & object )
   {
      if( !object.IsObject() )
      {
         throw ex_t{
            "unable to extract field \"" +
            std::string{ binder_data.field_name().s } + "\": "
            "parent json type must be object" };
      }

      // Temporary object for holding deserialized value.
      Field_Type tmp_object{};

      const auto it = object.FindMember( binder_data.field_name() );

      if( object.MemberEnd() != it )
      {
         const auto & value = it->value;

         if( !value.IsNull() )
         {
            binder_data.reader_writer().read( tmp_object, value );
         }
         else
         {
            set_value_null_attr( tmp_object );
         }
      }
      else
      {
         binder_data.manopt_policy().on_field_not_defined( tmp_object );
      }

      binder_data.validator()( tmp_object ); // validate value.

      // NOTE: the value from tmp_object will be lost.
   }
};

/* namespace json_dto */

Применяться это может вот так (обращу внимание на использование ignore_after_deserialization совместно с optional):

struct example_data
{
   std::vector< std::uint32_t > ids() const { return { 1u2u3u4u }; }
   std::uint32_t m_payload{};
   std::uint32_t m_priority{};

   int m_version_base{ 18 };
   int version() const noexcept { return m_version_base + 2; }

   example_data() = default;
   example_data( std::uint32_t payload ) : m_payload{ payload } {}

   template < typename Json_Io >
   void
   json_io( Json_Io & io )
   {
      io
         & json_dto::mandatory( "ids",
               tutorial_20_3::ignore_after_deserialization( ids() ) )
         & json_dto::mandatory( "payload", m_payload )
         & json_dto::mandatory( "priority",
               tutorial_20_3::ignore_after_deserialization( m_priority ),
               json_dto::min_max_constraint(
                     std::uint32_t{0u},
                     std::uint32_t{9u} ) )
         & json_dto::optional( "version",
               tutorial_20_3::ignore_after_deserialization( version() ),
               18 );
   }
};

Вместо заключения

Спасибо всем, кто нашел в себе силы дочитать до этого места. Данный пост я писал в большей степени для себя самого. Чтобы в процессе формулирования текста еще раз понять, действительно ли найденное решение стоило того.

Пока что думается, что оно того стоило. Хотя я не могу сказать, что оно мне вот прям нравится. Просто это лучшее из придуманного. Оно решает поставленные задачи. И, не смотря на свою сложность и многословность, его смысл и принцип использования можно донести до пользователей (я на это надеюсь).

Так что все идет к тому, что данное решение завтра-послезавтра станет официальной частью json-dto. Если только кто-нибудь из читателей не укажет на какой-то из незамеченных мной фатальных недостатков.

В принципе, данный рассказ можно рассматривать и как заметку "из будней библиотекописателей".

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

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