Работа с void* в стиле C++

от автора

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

Если набор полей, доступный по указателю, содержит данные различных типов, обычной практикой является определение типов структур, описывающих расположение полей в наборе. Так, заголовочные файлы 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 взаимодействия (добавлять новые последовательности полей) без раздувания кода — простой специализацией соответствующих шаблонов. Помимо примитивных типов и строк, система позволяет обмениваться массивами фиксированного и динамического размера, а также дескрипторами объектов. Также планируется добавить поддержку структур.

Ссылки


ссылка на оригинал статьи https://habr.com/ru/articles/736860/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *