Передача указателя на набор полей примитивных типов, расположенных в определённом порядке, - широко используемый паттерн. Так передаются указатели на структуры и объекты, массивы, файловые и сетевые буферы, данные в общей памяти и специальные типы (к примеру, массивы виртуальных функций), а отладчик, получив указатель на стек, может просматривать значения содержащихся в нём переменных.

Если набор полей, доступный по указателю, содержит данные различных типов, обычной практикой является определение типов структур, описывающих расположение полей в наборе. Так, заголовочные файлы Windows содержат сотни подобных типов. Для C/C++, допускающих прямые арифметические операции над указателями, данная возможность является, скорее, синтаксическим сахаром, существенно упрощающим доступ к таким полям, тогда как для языков, строже следящих за типобезопасностью, она может быть единственной.

Тем не менее, подход с определением структур имеет свои недостатки. Во-первых, количество типов имеет тенденцию быстро расти с усложнением API. Во-вторых, код работы с каждым типом структуры нужно писать (или генерировать) отдельно - или же делать типы шаблонными, теряя возможность дать каждому полю осмысленное имя. В-третьих, в ситуациях, когда порядок и типы полей не известны во время компиляции, написать соответствующий тип структуры попросту невозможно.

В данной статье я хочу показать принятую в нашем проекте организацию работы с подобными наборами полей в стиле C++ - через соответствующие типы итераторов.

Постановка задачи

Имеется указатель на последовательность двоичных полей примитивных типов, организованных в записи. Требуется определить итераторы, которые могут использоваться для перемещения по данной последовательности, чтения и записи отдельных полей. При этом:

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

  2. Размер, по которому выравниваются поля, в одних случаях известен во время компиляции, в других (например, при возврате итератора из виртуальной функции, принимающей размер поля как аргумент) - во время выполнения.

  3. Поля последовательности могут быть одного типа или разных типов. В первом случае итератор работает как обычный итератор C++, во втором - допускает чтение и запись любых примитивных типов, размер которых не превышает текущий размер выравнивания полей.

  4. Порядок байтов в полях может совпадать с естественным порядком байтов данной платформы (big или little endian) или с сетевым порядком байтов (всегда big endian).

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

  6. Поля, к которым осуществляется доступ, могут иметь гарантию правильного выравнивания или не иметь такой гарантии. В первом случае доступ к ним нужно проводить в конечных типах (соответствующие команды копирования многобайтных слов будут корректно работать на всех платформах), тогда как во втором случае требуется побайтовое копирование.

  7. Операции чтения и записи, перемещения по последовательности и произвольного доступа к её элементам должны осуществляться в обычной для итераторов C++ форме.

  8. Итератор может допускать доступ для чтения и записи или только для чтения (const-итератор).

В качестве стандарта реализации был выбран C++20.

Архитектура

В первой версии описываемого кода типов итератора было 4: binary_record_iterator, binary_record_const_iterator, specialized_binary_record_iterator и specialized_binary_record_const_iterator. Типы без суффикса const позволяли модифицировать последовательность, типы с таким суффиксом - не позволяли. Типы с префиксом specialized работали с последовательностями полей одного и того же типа, а типы без этого префикса - с последовательностями полей разных типов.

Практика показала, что 90% кода этих типов совпадает или отличается незначительно, посему в конечном счёте четыре класса слились в один. Сам класс получился шаблонным, с четырьмя аргументами:

  1. Тип поля последовательности: void позволяет работать с любым типом, любой другой тип - только со значениями данного типа.

  2. Стратегия копирования содержимого полей.

  3. Стратегия определения размера поля.

  4. Булев флаг - признак константности последовательности, по которой осуществляется итерация.

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

Стратегия

Описание

endianness_converter

Копирует поля по одному байту. Хранит флаг состояния, указывающий, сохраняется ли при доступе к полям порядок байтов или меняется на обратный.

endianness_keeper

Копирует поля по одному байту. Всегда сохраняет порядок байтов. Не имеет состояния.

endianness_reverser

Копирует поля по одному байту. Всегда меняет порядок байтов на обратный. Не имеет состояния.

native_field_copier

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

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

В своё время ваш покорный слуга наступил на эти грабли при разработке SCADA-системы на базе ARM-компьютера Moxa UC-7110. При чтении значения с плавающей точкой, доступного по указателю, оно отличалось от ожидаемого, хотя побайтовое сравнение показывало идентичность эталону. Использование функции memcpy() вместо простого разыменования с присваиванием позволило решить проблему.

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

Таким образом, при чтении значений, гарантированно имеющих естественный порядок байтов, используются native_field_copier или endianness_keeper, в зависимости от наличия гарантий на выравнивание полей. В случаях, когда порядок полей нужно всегда менять на противоположный, используется endianness_reverser. Наконец, если алгоритм должен работать как с прямым, так и с обратным порядком байтов, используется стратегия endianness_converter - самая тяжёлая за счёт хранения и проверки флага состояния.

Для проверки того, какое выравнивание является естественным для данной платформы, используется заголовочник <bit>, который как раз подвезли в C++20. Фактически используемые типы стратегий копирования значений определены следующим образом:

static constexpr std::endian network_byte_order = std::endian::big;
using local_or_network_field_copier =
    std::conditional_t<std::endian::native == network_byte_order,
                       endianness_keeper, endianness_converter>;
using network_field_copier =
    std::conditional_t<std::endian::native == network_byte_order,
                       endianness_keeper, endianness_reverser>;

local_or_network_field_copier используется там, где требуется доступ к данным, полученным локально или по сети, а network_field_copier - там, где ведётся работа исключительно с сетевыми данными. Такое определение переводит максимальное количество проверок на этап компиляции. Там, где данные заведомо имеют корректное выравнивание, используется native_field_copier.

Стратегий определения размера поля всего две, и они гораздо проще:

runtime_record_size

Позволяет менять размер поля во время выполнения. Содержит состояние - размер поля.

fixed_record_size

Размер поля определяется только во время компиляции (аргументом шаблона). Не имеет состояния.

Итераторы предоставляют стандартные операции: пре- и пост-инкремент и декремент, а также операторы +, -, += и -= с аргументами типа size_t и ptrdiff_t. Единицей перемещения является поле, размер которого определяется текущей стратегией ширины поля. Для определения взаимного расположения двух итераторов доступен полный комплект операторов сравнения, а также оператор -. Присутствует также синтаксический сахар: операторы проверки на (не)равенство nullptr и дублирующие их операторы ! и приведения к bool.

Для доступа к данным используются операторы разыменования и доступа по индексу. Кроме того, доступны методы для чтения (у любых итераторов) и записи (у неконстантных) полей с автоматическим перемещением к следующему полю, а также для получения текущих размера поля и указателя на данные. Также есть специальный метод для чтения 64-битной записи в тип size_t с контролем переполнения для 32- и менее битных платформ.

Метод specialize() используется для перехода от нетипизированных итераторов к типизированным. Метод with_record_size фиксирует размер поля, заменяя текущую стратегию стратегией fixed_record_size с переданным аргументом шаблона.

Реализация

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

Итератор, сконструированный конструктором по умолчанию или с единственным nullptr-аргументом, не указывает на какие-либо данные. Также доступны конструкторы копирования и перемещения и соответствующие операторы присваивания. Основные же конструкторы принимают обязательный указатель на данные (const void* для константного итератора, void* для обычного) и аргументы стратегий.

В качестве аргументов стратегий могут быть переданы экземпляры самих стратегий. Альтернативно, для стратегии копирования данных можно передать порядок байт (std::endian) или признак его инверсии, а для стратегии определения размера поля - значение этого самого размера в байтах. Стратегии, допускающие изменение параметра в рантайме, сохраняют переданное значение, а не имеющие состояния - проверяют (через assert), что переданное значение соответствует их поведению. Аргументы для стратегий, имеющих состояние, обязательны, для не имеющих состояния - опциональны.

Указатель на данные всегда хранится как char* или const char* для удобства навигации по полям. Операции перемещения по данным выглядят тривиально:

template <typename element_type, typename field_copier, typename record_size_policy,
          bool is_const>
class binary_record_iterator : private field_copier, private record_size_policy
{
public:
    binary_record_iterator& operator+=(std::ptrdiff_t offset)
    {
        assert(is_forward_offset_valid(offset));
        m_data += offset *
                  static_cast<ptrdiff_t>(record_size_policy::get_record_size());
        return *this;
    }
};

Здесь is_forward_offset_valid() - простейшая проверка переполнения при выполнении арифметических операций с указателями. Поскольку данных о доступном диапазоне данных у итератора нет, выход за его границы не контролируется.

template <typename element_type, typename field_copier, typename record_size_policy,
          bool is_const>
class binary_record_iterator : private field_copier, private record_size_policy
{
private:
    bool is_forward_offset_valid(std::ptrdiff_t offset) const
    {
        if (offset >= 0)
            return std::numeric_limits<std::ptrdiff_t>::max() /
                   static_cast<std::ptrdiff_t>(record_size_policy::get_record_size())
                   >= offset
                &&
                   m_data + offset *
                   static_cast<ptrdiff_t>(record_size_policy::get_record_size())
                   >= m_data;
        else
            return std::numeric_limits<std::ptrdiff_t>::min() /
                   static_cast<std::ptrdiff_t>(record_size_policy::get_record_size())
                   <= offset
                &&
                   m_data + offset *
                   static_cast<ptrdiff_t>(record_size_policy::get_record_size())
                   < m_data;
    }
};

Для совместимости со стандартными алгоритмами определены типы-члены iterator_category, value_type, difference_type, reference и pointer.

Доступ к значению при разыменовании итератора осуществляется при помощи вспомогательного класса binary_record_reference, имеющего те же аргументы шаблона, что и класс итератора. Как и итератор, binary_record_reference содержит указатель на данные типа char* или const char*, а также наследует стратегии копирования данных и определения размера поля в закрытом режиме. Первая стратегия используется по прямому назначению, вторая - для проверки того, что размер копируемого значения не превышает размера поля.

Существует две специализации binary_record_reference. Первая работает с итераторами полей фиксированного типа и допускает чтение и запись только значений данного типа. Вторая работает с последовательностями полей разных типов и позволяет читать и писать значения любых типов, размер которых не превышает размера поля, заданного стратегией.

Чтение и запись поля фиксированного типа выглядят тривиально:

template <typename element_type, typename field_copier, typename record_size_policy,
          bool is_const>
class binary_record_reference : private field_copier, private record_size_policy
{
public:
    operator element_type() const
    {
        assert(sizeof(element_type) <= record_size_policy::get_record_size());
    
        element_type value;
        field_copier::read(m_data, &value);
        return value;
    }
    template <bool is_const2 = is_const, typename = std::enable_if_t<!is_const2>>
    binary_record_reference& operator=(element_type value)
    {
        assert(sizeof(element_type) <= record_size_policy::get_record_size());

        // Если обмануть компилятор, передав явный аргумент is_const2 = false при
        // is_const = true, следующая строка всё равно не скомпилируется
        field_copier::write(value, m_data);
        return *this;
    }
};

Чтение и запись полей произвольных типов реализованы аналогично:

template <typename field_copier, typename record_size_policy, bool is_const>
class binary_record_reference<void, field_copier, record_size_policy, is_const> :
      private field_copier, private record_size_policy
{
public:
    template <typename type>
    operator type() const
    {
        assert(sizeof(type) <= record_size_policy::get_record_size());
        static_assert(std::is_arithmetic<type>::value || std::is_enum<type>::value ||
            std::is_pointer<type>::value || std::is_same<type, std::nullptr_t>::value,
            "Unsupported value type");
    
        type value;
        field_copier::read(m_data, &value);
        return value;
    }
    template <typename type, bool is_const2 = is_const,
              typename = std::enable_if_t<!is_const2>>
    binary_record_reference& operator=(type value)
    {
        assert(sizeof(type) <= record_size_policy::get_record_size());
        static_assert(std::is_arithmetic<type>::value || std::is_enum<type>::value
                      || std::is_pointer<type>::value
                      || std::is_same<type, std::nullptr_t>::value,
                      "Can only access arithmetic and enum values");
    
        field_copier::write(value, m_data);
        return *this;
    }
};

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

Применение

Система, ради которой писалась данная обёртка, оперирует пакетами двоичных данных, состоящими из двух частей: данных фиксированного размера и данных переменного размера.

Часть фиксированного размера организована как последовательность 64-битных полей. Как правило, одно поле содержит одно примитивное значение размером до 64 бит (незанятые байты игнорируются). Единственное исключение - массивы фиксированного размера, элементы которых располагаются в стык и могут занимать более одного поля: так, массив из пяти 16-битных значений займёт два 64-битных поля, внутри которых первые 80 бит содержат значения, а оставшиеся 48 бит не используются.

Данные динамического размера используются для передачи контейнеров произвольной длины и содержат элементы этих самых контейнеров, упакованные без дырок. Ссылки на такие контейнеры размещаются в части фиксированного размера в виде двух 64-битных полей: количества элементов и адреса.

Итераторы, служащие для чтения данных, определены так:

using handle_type = std::uint64_t;
using pack_field_const_iterator = binary_record_iterator<void,
        local_or_network_field_copier, fixed_record_size<sizeof(handle_type)>, true>;
using dynamic_data_const_iterator = binary_record_iterator<void,
        local_or_network_field_copier, runtime_record_size, true>;
template <typename element_type>
using specialized_dynamic_data_const_iterator = binary_record_iterator<element_type,
        local_or_network_field_copier, fixed_record_size<sizeof(element_type)>, true>;

Алгоритмы чтения данных работают с базовым абстрактным типом пакета:

enum class dynamic_data_pointer : uint64_t;
class data_pack
{
public:
    virtual ~data_pack() = default;

    // Метод доступа к данным фиксированного размера
    virtual pack_field_const_iterator read_static_data(
        size_type field_count) const = 0;

    // Методы доступа к данным динамического размера
    virtual dynamic_data_const_iterator read_dynamic_data(
        dynamic_data_pointer pointer, size_t element_size,
        size_type element_count) const = 0;
    template <typename element_type>
    specialized_dynamic_data_const_iterator<element_type> read_dynamic_data(
        dynamic_data_pointer pointer, size_t element_count) const
    {
        return read_module_side_data(pointer, sizeof(element_type), element_count)
                   .specialize<element_type>();
    }
};

Аргументы field_count и element_count служат для проверки выхода за границы доступных данных.

Существует два типа пакетов. Первый описывает данные, размещённые в оперативной памяти: имеющие естественный порядок байтов и использующие значение указателя в качестве адреса динамических данных (dynamic_data_pointer). Второй описывает пакет, полученный по сети: данные имеют сетевой порядок байтов, а в качестве адреса динамических данных используется смещение от начала пакета.

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

struct data
{
    std::int32_t a;
    double b;
    std::string c;
};
data read_data_from_pack(const data_pack* pack)
{
    data result;
    size_t string_size;
    dynamic_data_pointer string_data_pointer;
    auto static_data = pack->read_static_data(4);
    static_data.read(&result.a).read(&result.b); // Чтение цепочкой вызовов
    if (!static_data.read_length(&string_size))
        throw std::bad_alloc(); // Длина строки не помещается в наш size_t
    string_data_pointer = *static_data++; // Чтение в стиле итератора
    auto string_data_begin = pack->read_dynamic_data<char>(string_data_pointer,
        string_size);
    result.c = std::string(string_data_begin, string_data_begin + string_size);
    return result;
}

Пример выше показывает работу с итераторами, полученными от пакета, однако не раскрывает весь потенциал данного решения. Типы всех читаемых из пакета полей известны заранее, и того же результата можно было бы добиться приведением указателя на данные пакеты к типу структуры из четырёх полей (с последующей инверсией порядка байтов при необходимости).

Удобство от работы с такими "итераторами по void*" становится более очевидным при написании обобщённого кода, не привязанного к конкретным типам. Например, следующий пример извлекает из пакета последовательность полей произвольных типов и передаёт их некому функтору:

template <typename cpp_type, typename = void>
struct type_traits;
template <typename cpp_type>
struct type_traits<cpp_type, std::enable_if_t<std::is_arithmetic<cpp_type>::value ||
    std::is_enum<cpp_type>::value>>
{
    static constexpr size_type field_count = 1;
    static cpp_type extract_value(data_pack*, pack_field_const_iterator position)
    {
        return static_cast<cpp_type>(*position);
    }
};
template <>
struct type_traits<std::u16string, void>
{
    static constexpr size_type field_count = 2;
    static std::u16string extract_value(const data_pack* pack,
        pack_field_const_iterator position)
    {
        size_t size;
        if (!position.read_length(&size))
            throw std::bad_alloc();
        if (size == 0)
            return std::u16string();
        module_pointer pointer;
        position.read(&pointer);
        auto data = pack->template read_dynamic_data<char16_t>(pointer, size);
        if (!data)
            throw invalid_data_pack();
        return std::u16string(data, data+size);
    }
};
template <typename... cpp_types>
struct argument_sequence_handler;
template <typename first_type, typename... next_types>
struct argument_sequence_handler<first_type, next_types...>
{
    using next_handler = argument_sequence_handler<next_types...>;
    using current_traits = type_traits<first_type>;
    template <typename callback_type, typename... extracted_arguments>
    static auto extract_and_invoke(data_pack *pack, pack_field_const_iterator input,
        callback_type callback, extracted_arguments&&... arguments)
    {
        first_type current_argument = current_traits::extract_value(pack, input);
        return next_handler::extract_and_invoke(pack, input +
              current_traits::field_count, callback, arguments..., current_argument);
    }
};
template <>
struct argument_sequence_handler<>
{
    template <typename callback_type, typename... extracted_arguments>
    static auto extract_and_invoke(data_pack*, pack_field_const_iterator,
        callback_type callback, extracted_arguments&&... arguments)
    {
        return callback(argunents...);
    }
};

Теперь шаблон argument_sequence_handler можно использовать для передачи в функтор произвольного числа аргументов произвольных типов, извлечённых из пакета:

auto static_data = pack->read_static_data(4);
auto const concatenated = argument_sequence_handler<std::u16string, std::u16string>::
    extract_and_invoke(pack, static_data, std::plus<std::u16string>());

Поддержка новых типов добавляется элементарно - через специализацию type_traits. Аналогичным образом можно добавить алгоритмы для сохранения данных в пакет.

Таким образом, удалось добиться разделения обязанностей между несколькими классами:

  1. Класс binary_record_iterator отвечает за перемещение по байтовым последовательностям и за предоставление высокоуровневого интерфейса доступа к ним.

  2. Стратегии копирования значений отвечают за доступ к данным и конверсию порядка байтов при необходимости.

  3. Стратегии размера поля следят за упаковкой данных с дырками или без дырок.

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

  5. Класс type_traits отвечает за чтение и запись конкретных типов, получая всю необходимую информацию о расположении данных от пакета и argument_sequence_handler.

  6. Наконец, argument_sequence_handler берёт на себя работу по упорядочению работы с отдельными значениями, закодированными в последовательности.

В нашем проекте данный подход используется при взаимодействии модулей с ядром, а также при передаче пакетов данных по сети. Это позволяет расширять API взаимодействия (добавлять новые последовательности полей) без раздувания кода - простой специализацией соответствующих шаблонов. Помимо примитивных типов и строк, система позволяет обмениваться массивами фиксированного и динамического размера, а также дескрипторами объектов. Также планируется добавить поддержку структур.

Ссылки

Комментарии (6)


  1. Okker
    10.06.2023 19:40

    Вызвана ли необходимость написания руками данного довольно сложного кода отсутствием рефлексии в плюсах?

    Вы писали, что при разыменовании получаем char* либо const char*. В рамках рассматриваемой ситуации в полях хранятся только примитивы всегда?


    1. Furax Автор
      10.06.2023 19:40
      +1

      Добрый день!
      Да, если бы была рефлексия, можно было бы перевести код на неё. Не думаю, что он бы стал сильно проще в итоге: поскольку речь о передаче данных в т. ч. по сети и в модули на других языках, к собственно плюсовому представлению данных привязываться нельзя.
      Да, только примитивы - возможна передача по сети. Представление строк показано, представление массивов аналогично, вместо объектов передаются хэндлы, которые можно затем использовать для вызова.


    1. buldo
      10.06.2023 19:40

      Решал подобную задачу на языке с рефлексией. Если типов пакетов не много, то проще фигачить итераторы по входящему потоку байт.

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


    1. cdriper
      10.06.2023 19:40

      рефлексия, когда имена полей можно игнорировать, в современных плюсах уже есть

      https://github.com/boostorg/pfr


  1. aamonster
    10.06.2023 19:40

    "Но зачем?" (c)

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


    1. Furax Автор
      10.06.2023 19:40

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

      Если вкратце - на основе API метода (оторванного от языка) формируется соглашение о раскладке полей аргументов и возвращаемых значений. Затем ядро пакует данные с использованием описанного итератора и передаёт модулю, а тот распаковывает - либо с использованием тех же итераторов (если используется C++ SDK), либо самостоятельно (есть пример модуля на чистом C, который просто приводит полученный void* к указателю на соответствующую структуру). И наоборот: модули пакуют данные, ядро - распаковывает. Получилось лаконичнее, чем если бы на каждый метод была своя структура данных (как у GRPC, к примеру).