Что такое Strict Aliasing и почему нас должно это волновать?
Что такое strict aliasing? Сначала мы опишем, что такое алиасинг (aliasing), а затем мы узнаем, к чему тут строгость (strict).
Новая полезная статья, которую мы подготовили для вас рамках курса «Разработчик C++». Надеемся, что она будет полезна и интересна вам, как и нашим слушателям.
В 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("%d\n", *p); //* p дает нам lvalue-выражение типа int, которое совместимо с int
- квалифицированная версия типа, совместимого с действующим типом объекта,
int x = 1; const int *p = &x; printf("%d\n", *p); // * p дает нам lvalue-выражение типа const int, которое совместимо с int
- тип, который является типом со знаком или без знака, соответствующим квалифицированному типу объекта,
int x = 1; unsigned int *p = (unsigned int*)&x; printf("%u\n", *p ); // *p дает нам lvalue-выражение типа unsigned int, которое соответствует квалифицированному типу объекта
См. Сноску 12 для расширения gcc/clang, которое позволяет назначать unsigned int* int*, даже если они не являются совместимыми типами.
- тип, который является типом со знаком или без знака, соответствующим квалифицированной версии действующего типа объекта,
int x = 1; const unsigned int *p = (const unsigned int*)&x; printf("%u\n", *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("%c\n", *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, использующий любой из этих типов в их интерфейсе.
А о каламбуре типизации и выравнивании в следующей части.