Работа с void* в стиле C++
Передача указателя на набор полей примитивных типов, расположенных в определённом порядке, — широко используемый паттерн. Так передаются указатели на структуры и объекты, массивы, файловые и сетевые буферы, данные в общей памяти и специальные типы (к примеру, массивы виртуальных функций), а отладчик, получив указатель на стек, может просматривать значения содержащихся в нём переменных.
Если набор полей, доступный по указателю, содержит данные различных типов, обычной практикой является определение типов структур, описывающих расположение полей в наборе. Так, заголовочные файлы Windows содержат сотни подобных типов. Для C/C++, допускающих прямые арифметические операции над указателями, данная возможность является, скорее, синтаксическим сахаром, существенно упрощающим доступ к таким полям, тогда как для языков, строже следящих за типобезопасностью, она может быть единственной.
Тем не менее, подход с определением структур имеет свои недостатки. Во-первых, количество типов имеет тенденцию быстро расти с усложнением API. Во-вторых, код работы с каждым типом структуры нужно писать (или генерировать) отдельно — или же делать типы шаблонными, теряя возможность дать каждому полю осмысленное имя. В-третьих, в ситуациях, когда порядок и типы полей не известны во время компиляции, написать соответствующий тип структуры попросту невозможно.
В данной статье я хочу показать принятую в нашем проекте организацию работы с подобными наборами полей в стиле C++ — через соответствующие типы итераторов.
Постановка задачи
Имеется указатель на последовательность двоичных полей примитивных типов, организованных в записи. Требуется определить итераторы, которые могут использоваться для перемещения по данной последовательности, чтения и записи отдельных полей. При этом:
Внутри записи все поля выровнены по фиксированному размеру. Поля меньшего размера дополняются незначащими байтами, поля большего размера отсутствуют.
Размер, по которому выравниваются поля, в одних случаях известен во время компиляции, в других (например, при возврате итератора из виртуальной функции, принимающей размер поля как аргумент) — во время выполнения.
Поля последовательности могут быть одного типа или разных типов. В первом случае итератор работает как обычный итератор C++, во втором — допускает чтение и запись любых примитивных типов, размер которых не превышает текущий размер выравнивания полей.
Порядок байтов в полях может совпадать с естественным порядком байтов данной платформы (big или little endian) или с сетевым порядком байтов (всегда big endian).
В одних случаях порядок байтов известен во время компиляции, в других (например, при возврате итератора виртуальной функцией, осуществляющей доступ к локальным или доступным по сети данным) — только во время выполнения. Хранить и проверять состояние нужно во втором случае, но не в первом (оптимизация). На платформе, где сетевой порядок байтов совпадает с естественным, хранить и проверять состояние не нужно никогда.
Поля, к которым осуществляется доступ, могут иметь гарантию правильного выравнивания или не иметь такой гарантии. В первом случае доступ к ним нужно проводить в конечных типах (соответствующие команды копирования многобайтных слов будут корректно работать на всех платформах), тогда как во втором случае требуется побайтовое копирование.
Операции чтения и записи, перемещения по последовательности и произвольного доступа к её элементам должны осуществляться в обычной для итераторов C++ форме.
Итератор может допускать доступ для чтения и записи или только для чтения (const-итератор).
В качестве стандарта реализации был выбран C++20.
Архитектура
В первой версии описываемого кода типов итератора было 4: binary_record_iterator, binary_record_const_iterator, specialized_binary_record_iterator и specialized_binary_record_const_iterator. Типы без суффикса const позволяли модифицировать последовательность, типы с таким суффиксом — не позволяли. Типы с префиксом specialized работали с последовательностями полей одного и того же типа, а типы без этого префикса — с последовательностями полей разных типов.
Практика показала, что 90% кода этих типов совпадает или отличается незначительно, посему в конечном счёте четыре класса слились в один. Сам класс получился шаблонным, с четырьмя аргументами:
Тип поля последовательности: void позволяет работать с любым типом, любой другой тип — только со значениями данного типа.
Стратегия копирования содержимого полей.
Стратегия определения размера поля.
Булев флаг — признак константности последовательности, по которой осуществляется итерация.
Стратегия копирования содержимого полей осуществляет непосредственно чтение и запись полей, доступных по указателю. Доступны несколько типов стратегий:
Стратегия | Описание |
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 — самая тяжёлая за счёт хранения и проверки флага состояния.
Для проверки того, какое выравнивание является естественным для данной платформы, используется заголовочник
static constexpr std::endian network_byte_order = std::endian::big;
using local_or_network_field_copier =
std::conditional_t;
using network_field_copier =
std::conditional_t;
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
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(record_size_policy::get_record_size());
return *this;
}
};
Здесь is_forward_offset_valid () — простейшая проверка переполнения при выполнении арифметических операций с указателями. Поскольку данных о доступном диапазоне данных у итератора нет, выход за его границы не контролируется.
template
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::max() /
static_cast(record_size_policy::get_record_size())
>= offset
&&
m_data + offset *
static_cast(record_size_policy::get_record_size())
>= m_data;
else
return std::numeric_limits::min() /
static_cast(record_size_policy::get_record_size())
<= offset
&&
m_data + offset *
static_cast(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
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 >
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
class binary_record_reference :
private field_copier, private record_size_policy
{
public:
template
operator type() const
{
assert(sizeof(type) <= record_size_policy::get_record_size());
static_assert(std::is_arithmetic::value || std::is_enum::value ||
std::is_pointer::value || std::is_same::value,
"Unsupported value type");
type value;
field_copier::read(m_data, &value);
return value;
}
template >
binary_record_reference& operator=(type value)
{
assert(sizeof(type) <= record_size_policy::get_record_size());
static_assert(std::is_arithmetic::value || std::is_enum::value
|| std::is_pointer::value
|| std::is_same::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, true>;
using dynamic_data_const_iterator = binary_record_iterator;
template
using specialized_dynamic_data_const_iterator = binary_record_iterator, 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
specialized_dynamic_data_const_iterator read_dynamic_data(
dynamic_data_pointer pointer, size_t element_count) const
{
return read_module_side_data(pointer, sizeof(element_type), element_count)
.specialize();
}
};
Аргументы 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(string_data_pointer,
string_size);
result.c = std::string(string_data_begin, string_data_begin + string_size);
return result;
}
Пример выше показывает работу с итераторами, полученными от пакета, однако не раскрывает весь потенциал данного решения. Типы всех читаемых из пакета полей известны заранее, и того же результата можно было бы добиться приведением указателя на данные пакеты к типу структуры из четырёх полей (с последующей инверсией порядка байтов при необходимости).
Удобство от работы с такими «итераторами по void*» становится более очевидным при написании обобщённого кода, не привязанного к конкретным типам. Например, следующий пример извлекает из пакета последовательность полей произвольных типов и передаёт их некому функтору:
template
struct type_traits;
template
struct type_traits::value ||
std::is_enum::value>>
{
static constexpr size_type field_count = 1;
static cpp_type extract_value(data_pack*, pack_field_const_iterator position)
{
return static_cast(*position);
}
};
template <>
struct type_traits
{
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(pointer, size);
if (!data)
throw invalid_data_pack();
return std::u16string(data, data+size);
}
};
template
struct argument_sequence_handler;
template
struct argument_sequence_handler
{
using next_handler = argument_sequence_handler;
using current_traits = type_traits;
template
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
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::
extract_and_invoke(pack, static_data, std::plus());
Поддержка новых типов добавляется элементарно — через специализацию type_traits. Аналогичным образом можно добавить алгоритмы для сохранения данных в пакет.
Таким образом, удалось добиться разделения обязанностей между несколькими классами:
Класс binary_record_iterator отвечает за перемещение по байтовым последовательностям и за предоставление высокоуровневого интерфейса доступа к ним.
Стратегии копирования значений отвечают за доступ к данным и конверсию порядка байтов при необходимости.
Стратегии размера поля следят за упаковкой данных с дырками или без дырок.
Классы, производные от data_pack, отвечают за хранение данных и получение итераторов на них, а также сообщают итератору, какой порядок байтов используется в конкретном пакете.
Класс type_traits отвечает за чтение и запись конкретных типов, получая всю необходимую информацию о расположении данных от пакета и argument_sequence_handler.
Наконец, argument_sequence_handler берёт на себя работу по упорядочению работы с отдельными значениями, закодированными в последовательности.
В нашем проекте данный подход используется при взаимодействии модулей с ядром, а также при передаче пакетов данных по сети. Это позволяет расширять API взаимодействия (добавлять новые последовательности полей) без раздувания кода — простой специализацией соответствующих шаблонов. Помимо примитивных типов и строк, система позволяет обмениваться массивами фиксированного и динамического размера, а также дескрипторами объектов. Также планируется добавить поддержку структур.