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

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

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

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

matyushkin
29 марта 2020

ТОП-10 книг по C++: от новичка до профессионала

Книги по C++ на русском языке с лучшими оценками. Расставлены в порядке воз...
Библиотека программиста
31 января 2019

Лучшие инструменты и советы начинающему C++ программисту

Хотите изучать C++? Делимся важными навыками, фреймворками и советами, кото...