Reinterpret_cast между char * и std:: uint8_t * - безопасно?

Теперь нам всем приходится работать с двоичными данными. В С++ мы работаем с последовательностями байтов, а с начала char был наш строительный блок. Определено, что sizeof из 1, это байт. И все функции ввода-вывода библиотеки используют char по умолчанию. Все хорошо, но всегда была небольшая проблема, небольшая странность, которая прослушивала некоторых людей - количество бит в байте определяется реализацией.

Итак, на C99 было решено ввести несколько typedef, чтобы разработчики могли легко выразить себя, целые типы фиксированной ширины. Необязательно, конечно, поскольку мы никогда не хотим вредить переносимости. Среди них uint8_t, перенесенный в С++ 11 как std::uint8_t, 8-разрядный целочисленный целочисленный тип с фиксированной шириной, был идеальным выбором для людей, которые действительно хотели работать с 8-битными байтами.

Итак, разработчики приняли новые инструменты и начали создавать библиотеки, которые выразительно заявляют, что они принимают 8-битные последовательности байтов, как std::uint8_t*, std::vector<std::uint8_t> или иначе.

Но, возможно, с очень глубокой мыслью, комитет по стандартизации решил не требовать реализации std::char_traits<std::uint8_t>, поэтому запрещал разработчикам легко и портативно создавать экземпляры, скажем, std::basic_fstream<std::uint8_t>, и легко читать std::uint8_t как двоичные данные. Или, может быть, некоторые из нас не заботятся о количестве бит в байте и довольны им.

Но, к сожалению, два мира сталкиваются, и иногда вам нужно взять данные как char* и передать их в библиотеку, ожидающую std::uint8_t*. Но подождите, говорят, не char переменный бит, а std::uint8_t зафиксирован на 8? Это приведет к потере данных?

Ну, на этом есть интересный Стандард. char, определяемый для хранения только одного байта и байта, является самым младшим адресуемым блоком памяти, поэтому не может быть типа с шириной бита, меньшей, чем ширина char. Затем определено, что он может удерживать UTF-8. Это дает нам минимум - 8 бит. Итак, теперь у нас есть typedef, который должен иметь ширину 8 бит и тип шириной не менее 8 бит. Но есть ли альтернативы? Да, unsigned char. Помните, что подпись char определяется реализацией. Любой другой тип? К счастью, нет. Все другие интегральные типы требуют диапазонов, которые выходят за пределы 8 бит.

Наконец, std::uint8_t является необязательным, это означает, что библиотека, которая использует этот тип, не будет компилироваться, если она не определена. Но что, если он скомпилируется? Я могу с большой долей уверенности сказать, что это означает, что мы находимся на платформе с 8-битными байтами и CHAR_BIT == 8.

Как только у нас есть это знание, у нас есть 8-битные байты, что std::uint8_t реализуется как char или unsigned char, можно предположить, что мы можем сделать reinterpret_cast от char* до std::uint8_t* и наоборот? Он переносится?

Это то, где мои навыки чтения в Стандарте не позволяют мне. Я читал о безопасно полученных указателях ([basic.stc.dynamic.safety]) и, насколько я понимаю, следующее:

std::uint8_t* buffer = /* ... */ ;
char* buffer2 = reinterpret_cast<char*>(buffer);
std::uint8_t buffer3 = reinterpret_cast<std::uint8_t*>(buffer2);

является безопасным, если мы не касаемся buffer2. Исправьте меня, если я ошибаюсь.

Итак, учитывая следующие предпосылки:

  • CHAR_BIT == 8
  • std::uint8_t.

Является ли переносной и безопасный листинг char* и std::uint8_t* назад и вперед, если предположить, что мы работаем с двоичными данными, а потенциальный недостаток знака char не имеет значения?

Я был бы признателен за ссылки на Стандарт с пояснениями.

EDIT: Спасибо, Джерри Коффин. Я собираюсь добавить цитату из стандарта ([basic.lval], §3.10/10):

Если программа пытается получить доступ к сохраненному значению объекта через значение gl, отличное от одного из следующие типы: undefined:

...

- a char или неподписанный char тип.

EDIT2: Хорошо, идем глубже. std::uint8_t не гарантируется typedef unsigned char. Он может быть реализован как расширенный беззнаковый целочисленный тип, а расширенные беззнаковые целочисленные типы не включены в §3.10/10. Что теперь?

Ответ 1

Хорошо, пусть станет по-настоящему педантичным. После прочтения этого this и this, я уверен, что понимаю смысл обоих стандартов.

Итак, сделав reinterpret_cast от std::uint8_t* до char*, а затем разыменование результирующего указателя безопасно и переносится и явно разрешено [basic.lval].

Однако выполнение reinterpret_cast от char* до std::uint8_t*, а затем разыменование результирующего указателя является нарушением правила строгого сглаживания и является undefined поведение если std::uint8_t реализуется как расширенный целочисленный тип без знака.

Однако существует два возможных способа обхода, сначала:

static_assert(std::is_same_v<std::uint8_t, unsigned char> || \
"This library requires std::uint8_t to be implemented as unsigned char.");

При этом утверждается, что ваш код не будет компилироваться на платформах, на которых это приведет к поведению undefined в противном случае.

Во-вторых:

std::memcpy(uint8buffer, charbuffer, size);

Cppreference говорит, что std::memcpy обращается к объектам как к массивам unsigned char, поэтому он безопасен и переносной.

Повторить, чтобы иметь возможность reinterpret_cast между char* и std::uint8_t* и работать с результирующими указателями портативно и безопасно в 100% стандарте -conforming, должны выполняться следующие условия:

  • CHAR_BIT == 8.
  • std::uint8_t.
  • std::uint8_t реализуется как unsigned char.

С практической точки зрения, вышеуказанные условия верны на 99% платформ, и, вероятно, нет платформы, на которой первые 2 условия являются истинными, а третий - ложными.

Ответ 2

Если uint8_t существует вообще, по существу, единственным выбором является то, что он typedef для unsigned char (или char, если это происходит без знака). Ничто (но битовое поле) не может представлять меньше памяти, чем char, а единственным типом, который может быть как 8 бит, является bool. Следующий наименьший нормальный целочисленный тип - это short, который должен быть не менее 16 бит.

Таким образом, если uint8_t существует вообще, у вас действительно есть только две возможности: вы либо отбрасываете unsigned char до unsigned char, либо отбрасываете signed char в unsigned char.

Первый - это преобразование идентичности, что очевидно безопасно. Последнее относится к "специальному распределению", которое предоставляется для доступа к любому другому типу в виде последовательности char или unsigned char в §3.10/10, поэтому оно также дает определенное поведение.

Так как это включает как char, так и unsigned char, приведение к нему в качестве последовательности char также дает определенное поведение.

Изменить: насколько Luc упоминает расширенные целые типы, я не уверен, как вам удастся применить его, чтобы получить разницу в этом случае. С++ относится к стандарту C99 для определений uint8_t и тому подобное, поэтому кавычки в остальном из этого исходят из C99.

В §6.2.6.1/3 указано, что unsigned char должно использовать чисто двоичное представление без битов заполнения. Биты заполнения допускаются только в 6.2.6.2/1, что исключает unsigned char. Однако этот раздел описывает чисто двоичное представление в деталях - буквально до бит. Следовательно, unsigned char и uint8_t (если он существует) должны быть представлены одинаково на уровне бит.

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

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

Даже на чисто теоретическом уровне этого трудно достичь. Что-то приближается к практическому уровню, это явно смешно.