(ИЛИ каламбур типизации, неопределенное поведение и выравнивание, о мой Бог!)
Всем привет, уже через несколько недель мы запускаем новый поток по курсу «Разработчик С++». Этому событию и будет посвящен наш сегодняшний материал
Что такое strict aliasing? Сначала мы опишем, что такое алиасинг (aliasing), а затем мы узнаем, к чему тут строгость (strict).
В C и C ++ алиасинг связан с тем, через какие типы выражений нам разрешен доступ к хранимым значениям. Как в C, так и в C ++ стандарт определяет, какие выражения для именования каких типов допустимы. Компилятору и оптимизатору разрешается предполагать, что мы строго следуем правилам алиасинга, отсюда и термин — правило строгого алиасинга (strict aliasing rule). Если мы пытаемся получить доступ к значению, используя недопустимый тип, оно классифицируется как неопределенное поведение (undefined behavior — UB). Когда у нас неопределенное поведение, все ставки сделаны, результаты нашей программы перестают быть достоверными.
К сожалению, с нарушениями строго алиасинга, мы часто получаем ожидаемые результаты, оставляя возможность того, что будущая версия компилятора с новой оптимизацией нарушит код, который мы считали допустимым. Это нежелательно, стоит понять строгие правила алиасинга и избежать их нарушения.
Чтобы лучше понять, почему нас должно это волновать, мы обсудим проблемы, возникающие при нарушении правил строго алиасинга, каламбур типизаций (type punning), так как он часто используется в правилах строгого алиасинга, а также о том, как правильно создавать каламбур, наряду с некоторой возможной помощью C++20, чтобы упростить каламбур и уменьшить вероятность ошибок. Мы подведем итоги обсуждения, рассмотрев некоторые методы выявления нарушений правил строго алиасинга.
Предварительные примеры
Давайте взглянем на некоторые примеры, а затем мы сможем обсудить то, что конкретно говорится в стандарте(-ах), рассмотрим некоторые дополнительные примеры, а затем посмотрим, как избежать строгого алиасинга и выявить нарушения, которые мы пропустили. Вот пример, который не должен вас удивить:
int x = 10;
int *ip = &x;
std::cout << *ip << "n";
*ip = 12;
std::cout << x << "n";
У нас есть int*, указывающий на память, занятую int, и это допустимый алиасинг. Оптимизатор должен предполагать, что присваивания через ip могут обновить значение, занятое x.
В следующем примере показан алиасинг, который приводит к неопределенному поведению:
int foo( float *f, int *i ) {
*i = 1;
*f = 0.f;
return *i;
}
int main() {
int x = 0;
std::cout << x << "n"; // Expect 0
x = foo(reinterpret_cast<float*>(&x), &x);
std::cout << x << "n"; // Expect 0?
}
В функции foo мы берем int* и float*. В этом примере мы вызываем foo и устанавливаем оба параметра, чтобы они указывали на одну и ту же ячейку памяти, которая в этом примере содержит int. Обратите внимание, что reinterpret_cast говорит компилятору обрабатывать выражение так, как если бы оно имело тип, заданный параметром шаблона. В этом случае мы говорим ему обрабатывать выражение & x, как если бы оно имело тип float*. Мы можем наивно ожидать, что результат второй cout будет равен 0, но при включенной оптимизации с использованием -O2 и gcc, и clang получат следующий результат:
0
1
Что может быть и неожиданно, но совершенно правильно, так как мы вызвали неопределенное поведение. Float не может быть валидным псевдонимом int-объекта. Следовательно, оптимизатор может предположить, что константа 1, сохраненная при разыменовании i, будет возвращаемым значением, поскольку сохранение через f не может корректно влиять на объект int. Подсоединение кода в Compiler Explorer показывает, что это именно то, что происходит (пример):
foo(float*, int*): # @foo(float*, int*)
mov dword ptr [rsi], 1
mov dword ptr [rdi], 0
mov eax, 1
ret
Оптимизатор, использующий анализ псевдонимов на основе типов (TBAA — Type-Based Alias Analysis), предполагает, что будет возвращен 1, и непосредственно перемещает постоянное значение в регистр eax, который хранит возвращаемое значение. TBAA использует правила языков о том, какие типы разрешены для алиасинга для оптимизации загрузки и хранения. В этом случае TBAA знает, что float не может быть псевдонимом int, и оптимизирует насмерть загрузку i.
Теперь к справочнику
Что именно стандарт говорит о том, что нам разрешено и не разрешено делать? Стандартный язык не является прямолинейным, поэтому для каждого элемента я постараюсь предоставить примеры кода, которые демонстрируют смысл.
Что говорит стандарт C11?
Стандарт C11 говорит следующее в разделе “6.5 Выражения” параграфа 7:
Объект должен иметь свое сохраненное значение, доступ к которому осуществляется только с помощью выражения lvalue, имеющего один из следующих типов: 88) — тип, совместимый с эффективным типом объекта,
int x = 1;
int *p = &x;
printf("%dn", *p); //* p дает нам lvalue-выражение типа int, которое совместимо с int
— квалифицированная версия типа, совместимого с действующим типом объекта,
int x = 1;
const int *p = &x;
printf("%dn", *p); // * p дает нам lvalue-выражение типа const int, которое совместимо с int
— тип, который является типом со знаком или без знака, соответствующим квалифицированному типу объекта,
int x = 1;
unsigned int *p = (unsigned int*)&x;
printf("%un", *p ); // *p дает нам lvalue-выражение типа unsigned int, которое соответствует квалифицированному типу объекта
См. Сноску 12 для расширения gcc/clang, которое позволяет назначать unsigned int* int*, даже если они не являются совместимыми типами.
— тип, который является типом со знаком или без знака, соответствующим квалифицированной версии действующего типа объекта,
int x = 1;
const unsigned int *p = (const unsigned int*)&x;
printf("%un", *p ); // *p дает нам lvalue-выражение типа const unsigned int, которое является типом без знака, который соответствует квалифицированной варианту действующего типа объекта
— агрегатный или объединенный тип, который включает один из вышеупомянутых типов среди своих членов (включая, рекурсивно, член субагрегированного или содержащегося объединения), или
struct foo {
int x;
};
void foobar( struct foo *fp, int *ip );// struct foo - это агрегат, который включает int среди своих членов, поэтому он может иметь псевдоним с *ip
//
foo f;
foobar( &f, &f.x );
— символьный тип.
int x = 65;
char *p = (char *)&x;
printf("%cn", *p ); // * p дает нам lvalue-выражение типа char, которое является символьным типом.
// Результаты не портативны из-за проблем с порядком байтов.
Что говорит C ++ 17 Draft Standard
Стандарт проекта C ++ 17 в разделе 11 [basic.lval] гласит: если программа пытается получить доступ к сохраненному значению объекта через glvalue, отличный от одного из следующих типов, поведение не определено: 63 (11.1) — динамический тип объекта,
void *p = malloc( sizeof(int) ); // Мы выделили хранилище, но не начали время жизни объекта
int *ip = new (p) int{0}; // Размещение нового меняет динамический тип объекта на int
std::cout << *ip << "n"; // * ip дает нам glvalue-выражение типа int, которое соответствует динамическому типу выделенного объекта
(11.2) — cv-квалифицированная (cv — const and volatile) версия динамического типа объекта,
int x = 1;
const int *cip = &x;
std::cout << *cip << "n"; // * cip дает нам выражение glvalue типа const int, которое является cv-квалифицированной версией динамического типа x
(11.3) — тип, подобный (как определено в 7.5) динамическому типу объекта,
// Нуждаюсь в примере для этого
(11.4) — тип, который является типом со знаком или без знака, соответствующим динамическому типу объекта,
// si и ui являются знаковыми или беззнаковыми типами, соответствующими динамическим типам друг друга
// Из этого godbolt (https://godbolt.org/g/KowGXB) видно, что оптимизатор предполагает алиасинг.
signed int foo( signed int &si, unsigned int &ui ) {
si = 1;
ui = 2;
return si;
}
(11.5) — тип, который является типом со знаком или без знака, соответствующий cv-квалифицированной версии динамического типа объекта,
signed int foo( const signed int &si1, int &si2); // Трудно показать, но это предполагает алиасинг
(11.6) — агрегатный или объединенный тип, который включает один из вышеупомянутых типов среди своих элементов или нестатических элементов данных (включая, рекурсивно, элемент или нестатический элемент данных субагрегата или содержащего объединения),
struct foo {
int x;
};
// Пример Compiler Explorer (https://godbolt.org/g/z2wJTC) показывает предположение о алиасинге
int foobar( foo &fp, int &ip ) {
fp.x = 1;
ip = 2;
return fp.x;
}
foo f;
foobar( f, f.x );
(11.7) — тип, который является (возможно, cv-квалифицированным) типом базового класса динамического типа объекта,
struct foo { int x ; };
struct bar : public foo {};
int foobar( foo &f, bar &b ) {
f.x = 1;
b.x = 2;
return f.x;
}
(11.8) — тип char, unsigned char или std :: byte.
int foo( std::byte &b, uint32_t &ui ) {
b = static_cast<std::byte>('a');
ui = 0xFFFFFFFF;
return std::to_integer<int>( b ); // b дает нам glvalue-выражение типа std::byte, которое может псевдонимом объекта типа uint32_t
}
Стоит отметить, что signed char
не включен в приведенный выше список, это заметное отличие от C, который говорит о типе символа.
Тонкие различия
Таким образом, хотя мы можем видеть, что C и C ++ говорят схожие вещи о алиасинге, есть некоторые различия, о которых мы должны знать. C ++ не имеет концепции C действующего или совместимого типа, а C не имеет концепции C ++ динамического или подобного типа. Хотя оба имеют выражения lvalue и rvalue, C ++ также имеет выражения glvalue, prvalue и xvalue. Эти различия в основном выходят за рамки данной статьи, но один интересный пример — как создать объект из памяти задействованной malloc. В C мы можем установить действующий тип, например, записав в память через lvalue или memcpy.
// Следующее является допустимым в C, но не допустимым C ++
void *p = malloc(sizeof(float));
float f = 1.0f;
memcpy( p, &f, sizeof(float)); // Действующий тип *p - float в C
// Или
float *fp = p;
*fp = 1.0f; // Действующий тип *p - float в C
Ни один из этих методов не является достаточным в C ++, который требует размещения new:
float *fp = new (p) float{1.0f} ; // Динамический тип *p теперь float
Являются ли int8_t и uint8_t char-типами?
Теоретически, ни int8_t, ни uint8_t не должны быть типами char, но практически они реализованы именно таким образом. Это важно, потому что если они действительно являются символьными типами, то они также псевдонимы, подобные char-типам. Если вы не знаете об этом, это может привести к неожиданному снижению производительности. Мы видим, что glibc typedef
-ит int8_t
и uint8_t
для signed char
и unsigned char
соответственно.
Это было бы трудно изменить, так как для C ++ это был бы разрыв ABI. Это изменило бы искажение имени и сломало бы любой API, использующий любой из этих типов в их интерфейсе.
Конец первой части. А о каламбуре типизации и выравнивании расскажем уже через несколько дней.
Пишите ваши комментарии и не пропустите открытый вебинар, который уже 6 марта проведет руководитель отдела разработки технологий в Rambler&Co — Дмитрий Шебордаев.
Автор: MaxRokatansky