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

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

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

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

В C и C ++ алиасинг связан с тем, через какие типы выражений нам разрешен доступ к хранимым значениям. Как в C, так и в C ++, стандарт определяет, какие выражения для именования каких типов допустимы. Компилятору и оптимизатору разрешается предполагать, что мы строго следуем правилам алиасинга, отсюда и термин – правило строгого алиасинга (strict aliasing rule). Если мы пытаемся получить доступ к значению, используя недопустимый тип, оно классифицируется как неопределенное поведение (undefined behavior – UB). Когда у нас неопределенное поведение, все ставки сделаны, результаты нашей программы перестают быть достоверными.

К сожалению, с нарушениями строго алиасинга, мы часто получаем ожидаемые результаты, оставляя возможность того, что будущая версия компилятора с новой оптимизацией нарушит код, который мы считали допустимым. Это нежелательно, стоит понять строгие правила алиасинга и избежать их нарушения.

Чтобы лучше понять, почему нас должно это волновать, мы обсудим проблемы, возникающие при нарушении правил строго алиасинга, каламбур типизаций (type punning), так как он часто используется в правилах строгого алиасинга, а также о том, как правильно создавать каламбур, наряду с некоторой возможной помощью C++20, чтобы упростить каламбур и уменьшить вероятность ошибок. Мы подведем итоги обсуждения, рассмотрев некоторые методы выявления нарушений правил строго алиасинга.

Предварительные примеры

Давайте взглянем на некоторые примеры, а затем мы сможем обсудить то, что конкретно говорится в стандарте(-ах), рассмотрим некоторые дополнительные примеры, а затем посмотрим, как избежать строгого алиасинга и выявить нарушения, которые мы пропустили. Вот пример, который не должен вас удивить:

У нас есть int*, указывающий на память, занятую int, и это допустимый алиасинг. Оптимизатор должен предполагать, что присваивания через ip могут обновить значение, занятое x.

В следующем примере показан алиасинг, который приводит к неопределенному поведению:

В функции foo мы берем int* и float*. В этом примере мы вызываем foo и устанавливаем оба параметра, чтобы они указывали на одну и ту же ячейку памяти, которая в этом примере содержит int. Обратите внимание, что reinterpret_cast говорит компилятору обрабатывать выражение так, как если бы оно имело тип, заданный параметром шаблона. В этом случае мы говорим ему обрабатывать выражение & x, как если бы оно имело тип float*. Мы можем наивно ожидать, что результат второй cout будет равен 0, но при включенной оптимизации с использованием -O2 и gcc, и clang получат следующий результат:

Что может быть и неожиданно, но совершенно правильно, так как мы вызвали неопределенное поведение. Float не может быть валидным псевдонимом int-объекта. Следовательно, оптимизатор может предположить, что константа 1, сохраненная при разыменовании i, будет возвращаемым значением, поскольку сохранение через f не может корректно влиять на объект int. Подсоединение кода в Compiler Explorer показывает, что это именно то, что происходит (пример):

Оптимизатор, использующий анализ псевдонимов на основе типов (TBAA – Type-Based Alias Analysis), предполагает, что будет возвращен 1, и непосредственно перемещает постоянное значение в регистр eax, который хранит возвращаемое значение. TBAA использует правила языков о том, какие типы разрешены для алиасинга для оптимизации загрузки и хранения. В этом случае TBAA знает, что float не может быть псевдонимом int, и оптимизирует насмерть загрузку i.

Теперь к справочнику

Что именно стандарт говорит о том, что нам разрешено и не разрешено делать? Стандартный язык не является прямолинейным, поэтому для каждого элемента я постараюсь предоставить примеры кода, которые демонстрируют смысл.

Что говорит стандарт C11?

Стандарт C11 говорит следующее в разделе “6.5 Выражения” параграфа 7:

Объект должен иметь свое сохраненное значение, доступ к которому осуществляется только с помощью выражения lvalue, имеющего один из следующих типов: 88) – тип, совместимый с эффективным типом объекта,

— квалифицированная версия типа, совместимого с действующим типом объекта,

— тип, который является типом со знаком или без знака, соответствующим квалифицированному типу объекта,

См. Сноску 12 для расширения gcc/clang, которое позволяет назначать unsigned int* int*, даже если они не являются совместимыми типами.

— тип, который является типом со знаком или без знака, соответствующим квалифицированной версии действующего типа объекта,

— агрегатный или объединенный тип, который включает один из вышеупомянутых типов среди своих членов (включая, рекурсивно, член субагрегированного или содержащегося объединения), или

— символьный тип.

Что говорит C ++ 17 Draft Standard

Стандарт проекта C ++ 17 в разделе 11 [basic.lval] гласит:

Если программа пытается получить доступ к сохраненному значению объекта через glvalue, отличный от одного из следующих типов, поведение не определено: 63 (11.1) – динамический тип объекта,

(11.2) – cv-квалифицированная (cv – const and volatile) версия динамического типа объекта,

(11.3) – тип, подобный (как определено в 7.5) динамическому типу объекта,

(11.4) – тип, который является типом со знаком или без знака, соответствующим динамическому типу объекта,

(11.5) – тип, который является типом со знаком или без знака, соответствующий cv-квалифицированной версии динамического типа объекта,

(11.6) – агрегатный или объединенный тип, который включает один из вышеупомянутых типов среди своих элементов или нестатических элементов данных (включая, рекурсивно, элемент или нестатический элемент данных субагрегата или содержащего объединения),

(11.7) – тип, который является (возможно, cv-квалифицированным) типом базового класса динамического типа объекта,

(11.8) – тип char, unsigned char или std :: byte.

Стоит отметить, что signed char не включен в приведенный выше список, это заметное отличие от C, который говорит о типе символа.

Тонкие различия

Таким образом, хотя мы можем видеть, что C и C ++ говорят схожие вещи о алиасинге, есть некоторые различия, о которых мы должны знать. C ++ не имеет концепции C действующего или совместимого типа, а C не имеет концепции C ++ динамического или подобного типа. Хотя оба имеют выражения lvalue и rvalue, C ++ также имеет выражения glvalue, prvalue и xvalue. Эти различия в основном выходят за рамки данной статьи, но один интересный пример – как создать объект из памяти задействованной malloc. В C мы можем установить действующий тип, например, записав в память через lvalue или memcpy.

Ни один из этих методов не является достаточным в C ++, который требует размещения new:

Являются ли int8_t и uint8_t char-типами?

Теоретически, ни int8_t, ни uint8_t не должны быть типами char, но практически они реализованы именно таким образом. Это важно, потому что если они действительно являются символьными типами, то они также псевдонимы, подобные char-типам. Если вы не знаете об этом, это может привести к неожиданному снижению производительности. Мы видим, что glibc typedef-ит int8_t и uint8_t для signed char и unsigned char соответственно.

Это было бы трудно изменить, так как для C ++ это был бы разрыв ABI. Это изменило бы искажение имени и сломало бы любой API, использующий любой из этих типов в их интерфейсе.

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

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

Блог компании OTUS онлайн-образование


proglib.io

Добавить комментарий

Ваш e-mail не будет опубликован.

четыре + один =