Share This
Связаться со мной
Крути в низ
Categories
//Что такое Strict Aliasing и почему нас должно это волновать?

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

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

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

chto takoe strict aliasing i pochemu nas dolzhno eto volnovat ece3294 - Что такое 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("%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, использующий любой из этих типов в их интерфейсе.

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

Библиотека рекомендует
Профессиональный 5 мес. онлайн-курс «Разработчик С++», который посвящен изучению современных особенностей языка, паттернов, многопоточности, работе с сетью и большими данными. Для зачисления в группу пройдите вступительный тест.

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

  • Основы C++ для начинающих программистов: вводный видеокурс
  • Составляем план обучения и выбираем книги C++ для чайников
  • Обновления C++: подборка изменений из трех стандартов языка
  • 5 views
  • 0 Comment

Leave a Reply

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Этот сайт использует Akismet для борьбы со спамом. Узнайте, как обрабатываются ваши данные комментариев.

Связаться со мной
Close