Прочитав следующее 1 а также 2 Q / Поскольку я использовал методику, обсуждаемую ниже, в течение многих лет на архитектурах x86 с GCC и MSVC, и не вижу проблем, я теперь очень озадачен тем, что должно быть правильным, но также и важным «наиболее эффективным» способом. чтобы сериализовать, а затем десериализовать двоичные данные, используя C ++.
Учитывая следующий «неправильный» код:
int main()
{
std::ifstream strm("file.bin");
char buffer[sizeof(int)] = {0};
strm.read(buffer,sizeof(int));
int i = 0;
// Experts seem to think doing the following is bad and
// could crash entirely when run on ARM processors:
i = reinterpret_cast<int*>(buffer);
return 0;
}
Теперь, когда я понимаю вещи, приведение к переинтерпретации указывает компилятору, что он может обрабатывать память в буфере как целое число и впоследствии может свободно выдавать целочисленные совместимые инструкции, которые требуют / предполагают определенные выравнивания для рассматриваемых данных — с единственными издержками дополнительные операции чтения и сдвига, когда ЦП обнаруживает адрес, который он пытается выполнить, ориентированные на выравнивание инструкции, фактически не выровнены.
При этом приведенные выше ответы, по-видимому, указывают на то, что C ++ обеспокоен тем, что это все неопределенное поведение.
Если предположить, что выравнивание местоположения в буфере, из которого произойдет приведение, не соответствует, то верно ли, что единственным решением этой проблемы является копирование байтов 1 на 1? Возможно, есть более эффективная техника?
Кроме того, за многие годы я видел много ситуаций, когда структура, состоящая полностью из pod-ов (с использованием специальных прагм компилятора для удаления отступов), преобразуется в char * и затем записывается в файл или сокет, а затем читается обратно в буфер. и буфер, приведенный обратно к указателю исходной структуры (игнорируя потенциальные проблемы с порядком байтов и форматов с плавающей запятой / двойного формата между машинами), этот вид кода также считается неопределенным поведением?
Ниже приведен более сложный пример:
int main()
{
std::ifstream strm("file.bin");
char buffer[1000] = {0};
const std::size_t size = sizeof(int) + sizeof(short) + sizeof(float) + sizeof(double);
const std::size_t weird_offset = 3;
buffer += weird_offset;
strm.read(buffer,size);
int i = 0;
short s = 0;
float f = 0.0f;
double d = 0.0;
// Experts seem to think doing the following is bad and
// could crash entirely when run on ARM processors:
i = reinterpret_cast<int*>(buffer);
buffer += sizeof(int);
s = reinterpret_cast<short*>(buffer);
buffer += sizeof(short);
f = reinterpret_cast<float*>(buffer);
buffer += sizeof(float);
d = reinterpret_cast<double*>(buffer);
buffer += sizeof(double);
return 0;
}
Во-первых, вы можете правильно, переносимо и эффективно решить проблему с выравниванием, используя, например, std :: align_storage :: value> :: type вместо char [sizeof (int)] (или, если у вас нет C ++ 11, могут быть аналогичные функциональные возможности компилятора).
Даже если вы имеете дело со сложным POD, aligned_stored
а также alignment_of
даст вам буфер, который вы можете memcpy
POD в и из, построить его и т. д.
В некоторых более сложных случаях вам нужно написать более сложный код, возможно, с использованием арифметики во время компиляции и статических переключателей на основе шаблонов и т. Д., Но, насколько мне известно, никто не придумал такой случай во время обсуждений C ++ 11 это было невозможно справиться с новыми функциями.
Тем не менее, просто используя reinterpret_cast
на случайном буфере выравнивания символов недостаточно. Давайте посмотрим, почему:
приведение переинтерпретации указывает компилятору, что он может обрабатывать память в буфере как целое число
Да, но вы также указываете, что он может предполагать, что буфер выровнен правильно для целого числа. Если вы врете об этом, вы можете генерировать неработающий код.
и впоследствии свободен выдавать целочисленные совместимые инструкции, которые требуют / предполагают определенные выравнивания для данных данных
Да, вы можете издавать инструкции, которые либо требуют этих выравниваний, либо предполагают, что о них уже позаботились.
единственными дополнительными затратами являются дополнительные операции чтения и сдвига, когда процессор обнаруживает адрес, который он пытается выполнить, ориентированные на выравнивание инструкции фактически не выровнены.
Да, он может выдавать инструкции с дополнительными чтениями и сменами. Но он также может выдавать инструкции, которые их не выполняют, потому что вы сказали, что это не обязательно. Таким образом, он может выдавать инструкцию «чтение выровненного слова», которая вызывает прерывание при использовании на невыровненных адресах.
Некоторые процессоры не имеют инструкции «чтение выровненного слова» и просто «читают слово» быстрее с выравниванием, чем без него. Другие могут быть настроены так, чтобы подавлять ловушку и вместо этого возвращаться к более медленному «чтению слова». Но другие — как ARM — просто потерпят неудачу.
Если предположить, что выравнивание местоположения в буфере, из которого произойдет приведение, не соответствует, то верно ли, что единственным решением этой проблемы является копирование байтов 1 на 1? Возможно, есть более эффективная техника?
Вам не нужно копировать байты 1 на 1. Вы можете, например, memcpy
каждая переменная одна за другой в правильно выровненное хранилище. (Это было бы только копированием байтов 1 на 1, если бы все ваши переменные были длиной в 1 байт, и в этом случае вы не будете беспокоиться о выравнивании с самого начала…)
Что касается приведения POD к char * и обратно с использованием прагм, специфичных для компилятора… ну, любой код, который полагается на прагмы, специфичные для компилятора, для корректности (а не, скажем, для эффективности), очевидно, не является правильным, переносимым C ++. Иногда «исправить с помощью g ++ 3.4 или более поздней версии на любой 64-разрядной платформе с прямым порядком байтов с 64-разрядными двойными битами IEEE» достаточно для ваших случаев использования, но это не то же самое, что на самом деле быть действительным C ++. И вы, конечно, не можете ожидать, что он будет работать, скажем, с Sun cc на 32-битной платформе с прямым порядком байтов с 80-битными двойными числами, а затем жаловаться, что это не так.
Для примера, который вы добавили позже:
// Experts seem to think doing the following is bad and
// could crash entirely when run on ARM processors:
buffer += weird_offset;
i = reinterpret_cast<int*>(buffer);
buffer += sizeof(int);
Эксперты правы. Вот простой пример того же:
int i[2];
char *c = reinterpret_cast<char *>(i) + 1;
int *j = reinterpret_cast<int *>(c);
int k = *j;
Переменная i
будет выровнен по некоторому адресу, кратному 4, скажем, 0x01000000. Так, j
будет в 0x01000001. Итак, линия int k = *j
выдаст инструкцию для чтения 4-байтового выровненного 4-байтового значения из 0x01000001. На PPC64, скажем, это займет примерно 8 раз int k = *i
Но, скажем, на ARM, он потерпит крах.
Итак, если у вас есть это:
int i = 0;
short s = 0;
float f = 0.0f;
double d = 0.0;
И ты хочешь записать это в поток, как ты это делаешь?
writeToStream(&i);
writeToStream(&s);
writeToStream(&f);
writeToStream(&d);
Как вы читаете обратно из потока?
readFromStream(&i);
readFromStream(&s);
readFromStream(&f);
readFromStream(&d);
Предположительно, какой бы вид потока вы не использовали (будь то ifstream
, FILE*
что угодно) имеет буфер в нем, так readFromStream(&f)
собирается проверить, есть ли sizeof(float)
байты доступны, если нет, прочитайте следующий буфер, затем скопируйте первый sizeof(float)
байтов из буфера по адресу f
, (На самом деле, это может быть даже умнее — разрешено, например, проверять, возле конец буфера и, если это так, выдает асинхронное упреждающее чтение, если разработчик библиотеки посчитал это хорошей идеей.) В стандарте не указано, как он должен делать копию. Стандартные библиотеки не должны запускаться нигде, кроме той реализации, частью которой они являются, поэтому ваша платформа ifstream
мог бы использовать memcpy
, или же *(float*)
или встроенная компилятор, или встроенная сборка — и она, вероятно, будет использовать все, что быстрее всего на вашей платформе.
Итак, как именно выровненный доступ поможет вам оптимизировать или упростить это?
Почти во всех случаях выбор правильного вида потока и использование его методов чтения и записи является наиболее эффективным способом чтения и записи. И, если вы выбрали поток из стандартной библиотеки, он тоже будет правильным. Итак, вы получили лучшее из обоих миров.
Если в вашем приложении есть что-то особенное, что делает что-то другое более эффективным, или если вы парень, пишущий стандартную библиотеку, тогда, конечно, вы должны пойти дальше и сделать это. Пока вы (и любые потенциальные пользователи вашего кода) осведомлены о том, где вы нарушаете стандарт и почему (и вы на самом деле оптимизируете вещи, а не просто делаете что-то, потому что «кажется, что это должно быть быстрее»), это совершенно разумно.
Кажется, вы думаете, что это помогло бы поместить их в какую-то «упакованную структуру» и просто написать это, но стандарт C ++ не имеет такой вещи, как «упакованная структура». Некоторые реализации имеют нестандартные функции, которые вы можете использовать для этого. Например, и MSVC, и gcc позволят вам упаковать вышеупомянутое в 18 байтов на i386, и вы можете взять эту упакованную структуру и memcpy
Это, reinterpret_cast
это к char *
отправить по сети, что угодно. Но он не будет совместим с точно таким же кодом, скомпилированным другим компилятором, который не понимает специальных прагм вашего компилятора. Он даже не будет совместим со связанным компилятором, таким как gcc для ARM, который упакует ту же самую вещь в 20 байтов. Когда вы используете непереносимые расширения стандарта, результат не является переносимым.
Сериализация — это, по сути, преобразование класса в двоичную форму, чтобы его можно было прочитать позже или отправить по сети, а затем — из файла или по сети как объект. Это действительно простая, но очень мощная концепция, которая позволяет объекту сохранять свою форму даже в сети. Вот Это пример того же, который даст вам правильное представление о том, как сделать сериализацию в C ++.