Что такое Strict Aliasing и почему нас должно это волновать?

Хочешь уверенно проходить IT-интервью?

Готовься к IT-собеседованиям уверенно с AI-тренажёром T1!

Мы понимаем, как сложно подготовиться: стресс, алгоритмы, вопросы, от которых голова идёт кругом. Но с AI тренажёром всё гораздо проще.

💡 Почему Т1 тренажёр — это мастхэв?

  • Получишь настоящую обратную связь: где затык, что подтянуть и как стать лучше
  • Научишься не только решать задачи, но и объяснять своё решение так, чтобы интервьюер сказал: "Вау!".
  • Освоишь все этапы собеседования, от вопросов по алгоритмам до диалога о твоих целях.

Зачем листать миллион туториалов? Просто зайди в Т1 тренажёр, потренируйся и уверенно удиви интервьюеров. Мы не обещаем лёгкой прогулки, но обещаем, что будешь готов!

Реклама. ООО «Смарт Гико», ИНН 7743264341. Erid 2VtzqwP8vqy


Что такое strict aliasing? Сначала мы опишем, что такое алиасинг (aliasing), а затем мы узнаем, к чему тут строгость (strict).

Новая полезная статья, которую мы подготовили для вас рамках курса «Разработчик C++». Надеемся, что она будет полезна и интересна вам, как и нашим слушателям.

Что такое Strict Aliasing и почему нас должно это волновать?

В 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, использующий любой из этих типов в их интерфейсе.

А о каламбуре типизации и выравнивании в следующей части.

Еще больше полезных материалов:

Комментарии

ВАКАНСИИ

Добавить вакансию
Golang-разработчик
Пермь, по итогам собеседования
Hotel Search Team Lead (Golang)
по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ