Привет, Хабр! Меня зовут Владислав, я разрабатываю компиляторы в YADRO. В этой статье я расскажу вам про алиасинг памяти в C++: как он развивался, к чему пришел сейчас и что комитет по стандартизации языка думает делать с алиасингом в будущем. По пути я немного затрону алиасинг в других языках, рассмотрю связанные случаи undefined behavior, а также пропозалы C++, которые, как ожидалось, проблемы с алиасингом решат.

Понятие алиасинга
Даже в простых языках программирования с относительно легкой семантикой нормально, когда два или более имени указывают на одну и ту же сущность. Это мы и называем алиасингом. В C или C++ алиасинг чаще всего вызывает объекты, указатели и ссылки.

Предположим, у нас есть объект x. Мы можем сделать на него указатель и ссылку, в итоге получится три имени, указывающих на одно и то же lvalue:
int x = 42;int *px = &x;int &xref = x;
Алиасинг присутствует во всех языках программирования и всегда вызывает сложности у компилятора.
int f(int *x, int *y) { *x = 42; *y = 14; return *x; // lw a0,0(a0)}
Вот пример: функция f принимает два указателя, x и y. Мы записываем в x 42, в y 14 и возвращаем значение, которое только что записали, в x. Кажется, что можно не делать лишний load из x, а сразу вернуть 42. Однако это не так: ничто не гарантирует, что x и y в функции f не указывают на одну и ту же область памяти. Компилятор консервативно вставит здесь лишнюю выгрузку, и возможный алиасинг заблокирует оптимизацию. Но лишний load, сгенерированный компилятором, может показаться незначительным по влиянию на итоговую производительность программы.
Рассмотрю тогда более сложный пример: распространенную функцию, которая принимает буфер, его размер, затем инкрементирует счетчик столько раз, сколько в буфере встречается нечто удовлетворяющее предикату — в данном случае положительных чисел.
int f(int *buf, int n) { int cnt = 0; for (int i = 0; i < n; ++i) if (buf[i] > 0) cnt++; return cnt;}
В этом случае мы заводим локальную переменную и потом ее возвращаем.
void f(int *buf, int n, int *cnt) { *cnt = 0; for (int i = 0; i < n; ++i) if (buf[i] > 0) (*cnt)++;}
А в этом — пользуемся output-параметром: инициализируем его в ноль, далее инкрементируем и получаем итоговое значение.
Сравним производительность первого и второго участка кода. Разница может быть аж пятидесятикратной!

Всему виной алиасинг. Во втором примере, где мы передаем output-параметр, компилятор не может консервативно сделать вывод, что участки памяти не будут пересекаться, и блокирует для себя какие-то дальнейшие оптимизации (спойлер: векторизацию :)).
Алиасинг в Fortran
Люди давно поняли, что случайный или намеренный алиасинг в программе портит нам жизнь. Еще во времена Fortran, который был спроектирован именно для высокопроизводительных вычислений, разработчики компиляторов осознали, что алиасинг блокирует оптимизации. Поэтому в Fortran приняли весьма смелое решение — сделать алиасинг ошибкой. Если вы передаете параметры в функцию как dummy-аргументы и они указывают на одно и то же место памяти, то неминуем ответ: «Вы сами виноваты — не делайте так».
subroutine bad(scale, y) implicit none real :: scale real :: y(3) y(1) = y(1) + scale y(2) = y(2) + scale y(3) = y(3) + scaleend subroutine
Вот пример кода на Fortran. Здесь у нас предполагается какая-то работа с двумя указателями.
program demo implicit none real :: a(3) = [3.0, 2.0, 1.0] call bad(a(1), a) print *, aend program
А здесь один массив, который мы дважды передаем в функцию — то есть, очевидно, передаем участки с алиасингом. Скомпилируем эту программу с оптимизациями и без оптимизации: наблюдаемое поведение начинает различаться.

В чем причина различия? В том, что алиасинг в таком контексте в Fortran — это ошибка. Можно возразить: давайте писать программы аккуратно, чтобы нигде никакие участки памяти не пересекались.
arr x[123] = {1, 2, 3, 4, 5, ...};ptr p1 = x + input_random_stuff();ptr p2 = x + input_random_stuff();some_func(p1, p2);// (pointers alias) -> destroy the World!
Представим массив из 123 элементов на некоем языке программирования. Мы берем два указателя в некие участки этого массива и передаем их в некоторую функцию some_func. Представим, что если эти два указателя пересекутся, то есть укажут в одно и то же место, произойдет нечто очень плохое.
На этапе компиляции значение, возвращаемое функцией input random stuff, просто-напросто неизвестно. Если пользователь вводит значения с клавиатуры, то никакой гарантии мы дать не можем. В общем случае мы не всегда знаем, будут пересекаться участки памяти или нет.
Алиасинг в языке C
Люди жили с этой проблемой с 50-х годов до 1989-го. Тогда был стандартизирован язык C, в котором нашли очень красивое и удобное решение. Оно сформулировано через правила, которые в несколько упрощенном виде выглядят так:
-
Обращения к lvalue допустимы только через «свой» тип (с точностью до знаковости).
-
Возможен алиасинг между произвольным указателем и указателем типа char*.
-
Важно не терять квалификаторы.
Эти правила стали впоследствии основой для знаменитых strict aliasing rules. Они оказались очень удобны для программистов: нужно всего лишь следить, что мы не передаем один и тот же участок памяти как разные типы.
Это очень удобно и для компиляторов. Структура памяти в языке C начинает отображаться как дерево. Существуют типы, связанные по веткам, и если есть ветка, по которой можно дойти от одного типа до другого, то они потенциально могут алиаситься. Если такой ветки нет — то не могут.
Компилятор теперь вправе применять так называемый type-based alias анализ: «вложенные» типы — may alias, в противном случае — no alias. Программисты пишут код, передают указатели разных типов, компилятор оптимизирует, все довольны.

Type-based alias анализ
Как компилятор реализует оптимизации, завязанные на типах? LLVM с помощью type-based alias анализа строит иерархию типов. Представим структуру с двумя полями — int и double. Мы передаем в функцию f эту структуру как int-указатель и float-указатель.
!1 = !{!"char", !0, i64 0}!2 = !{!"int", !1, i64 0}!3 = !{!“float", !1, i64 0}!4 = !{!"double", !1, i64 0}!5 = !{!"S", !2, i64 0, !4, i64 8}!6 = !{!5, !2, i64 0}!7 = !{!5, !4, i64 8}
Далее компилятор создает промежуточное представление AST, удобное ему для анализа языковых свойств и дальнейшего построения высокоуровневого промежуточного представления. Затем строит граф, по которому очень удобно определять отношения между типами, пересекаются они или нет.
Такие алгоритмы в компиляторе работают быстро и очень эффективно. Type-based alias — это очень важный анализ в компиляторах, разблокирующий гигантское количество оптимизаций. Заново посмотрим на пример, похожий на тот, что был в начале статьи:
int f(int *x, short *y) { *x = 42; *y = 14; return *x; // return 42}
Здесь мы передаем указатели int и short. Эти типы несовместимы, а значит, компилятор может спропагировать возвращаемое значение 42.
Когда типы обманывают компилятор
Что будет, если мы ошиблись и случайно передали один и тот же участок памяти под видом разных?
int x;short *y = (short *)(&x);f(&x, y); // Oops?
Поздравляю, это undefined behaviour, и с этого момента программа может вести себя как угодно. Можно подумать, что мои примеры имеют мало общего с реальностью, но по факту в любой кодовой базе на C++ очень много type aliasing violation — особенно в embedded-разработке.
Как такие ошибки отлавливать? Они очень неприятны. Динамический анализатор справляется с ними плоховато, потому что теряется информация о семантике типов языка, а доказать что-то статическим анализом трудно. Проблема лежит где-то между ними. Здесь могут помочь анализаторы, но порой они справляются плоховато — в своей статье об этом рассказала Анастасия Черникова.
Если же у вас большая кодовая база с гигантским количеством неприятных багов и вы подозреваете, что это может быть связано с type aliasing violation, то можете прибегнуть, я бы сказал, к неспортивному трюку. Если вы пользуетесь GCC или Clang, подайте в компилятор опцию fno-strict-aliasing, которая отключит эти оптимизации в компиляторе. Но это не бесплатно, и вы очень сильно потеряете в производительности — об этом на С++ Zero Сost Conf 2023 рассказывал Роман Русяев.
Алиасинг в C++ сегодня
Как алиасинг выглядит в языке C++ сейчас, в 2026 году? Правила очень похожи. Допустим алиасинг:
-
через совместимые типы — int, unsigned int;
-
квалифицированная версия — через const int и volatile int;
-
через базовый производный класс (базовый тип может указывать на тот же объект/подобъект наследника);
-
через исключения для байтовых типов: char, unsigned char и std::byte.
И все было бы просто, но есть очень важная оговорка. Хотя байтовые типы позволяют смотреть и копировать байты любого объекта, важно следить за lifetime.
alignas(float) char storage[sizeof(float)] = { /* */ };float* pf = reinterpret_cast<float*>(storage);float f = *pf; // UB: lifetime объекта float не начат [basic.life]
Представим, что мы завели буфер, обычный массив char, и инициализировали его байтами, которые, предположительно, очень похожи на float. Далее нужно просто интерпретировать этот буфер как float-буфер. С точки зрения алиасинга проблем быть не должно, char может алиаситься с кем угодно. Но в результате мы получим undefined behaviour просто потому, что не заводили объект типа float. Он не начинал свой lifetime.
За такими случаями важно следить, они могут запутать любого. Чтобы решить проблему, с C++ 23 можно использовать std::start_lifetime_as. Тогда компилятор сам позаботится завести нужный объект:
#include <memory>alignas(float) char storage[sizeof(float)] = { /* */ };float* pf = std::start_lifetime_as<float>(storage);float f = *pf; // OK
Type punning
Перейдем к другой интересной теме, связанной с алиасингом в C++, — type punning. Это касается правил доступа к одним и тем же участкам памяти через union и через структуру.
union U { int i, float f; };U u;u.i = 42;int *p;p = reinterpret_cast<int *>(&u);*p = 14; // ok
Интуитивно все выглядит очень просто: есть union, в нем есть возможные типы int и float — можем активировать с помощью любого. Далее мы активируем поле i и можем совершать доступ к lvalue как через сам union, так и через какой-нибудь int-указатель, которому мы его скастуем.
struct S { int i; };S s;s.i = 42;int *p;p = reinterpret_cast<int *>(&s);*p = 14; // ok
То же самое касается структур: можно кастовать к первому полю либо любому полю с offset — все пройдет хорошо.
Однако в мире type punning есть куча интересных тонкостей. К union-полям нельзя совершать доступ, если эти поля не были активированы.
union U { int i; float f; };U u;u.i = 0x3f800000;float x = u.f; // "ну union же"
В примере мы заводим union, затем активируем поле i типа int во float-значение, а дальше хотим интерпретировать его как float. Внезапно — undefined behavior с точки зрения стандартов C++.
Что здесь удивительного? С точки зрения языка C этот код валиден. Это одно из тонких мест, где C и C++ расходятся с точки зрения алиасинга. Если вы захотите скомпилировать большую кодовую базу языка C «плюсовым» компилятором, это одна из проблем, с которой вы можете столкнуться. Если нам нужен доступ к каким-то полям union, мы должны их предварительно активировать.
struct Vec2 { float x; float y; };struct Complex { float re; float im; };Vec2 v{1.0f, 2.0f};Complex* c = reinterpret_cast<Complex*>(&v);float r = c->re; // UB
Рассмотрим теперь структуры. Интуитивно кажется, что правила здесь очень просты. В примере выше есть структуры Vec2 и Complex, идентичные с точки зрения типов: везде по два float, выравнивание одинаково.
Далее пробуем небольшую шалость: интерпретируем объект Vec2 как Complex указателей. Даже интуитивно понятно, что здесь что-то неладно. Абсолютно одинаковый пэддинг, расположение в памяти, layout и так далее, но что-то не так. Действительно, проблема здесь такая же, как буфером char и float: мы не начинали lifetime объекта типа Complex.
Мы закрепили два правила — для union с активными типами и для структур с layout. Но есть интересное исключение: когда мы комбинируем union и структуры.
struct A { int tag; int x; };struct B { int tag; float y; };union U { A a; B b; };U u;u.a = A{1, 42};int tag = u.b.tag; // OK, read
Вот структуры А и B, первое поле в каждой — это тип int tag. Далее мы берем union двух структур. Если мы заводим объект типа U, активируем его поле A, а дальше пытаемся прочитать первое поле внутри структуры A через поле B, это будет валидно.
Чтобы пример стал невалидным, нужно слегка подправить исходные:
struct A {int tag;int x;};struct B {int tag;const int y; // из-за const-поля default constructor B будет deleted};union U {A a;B b;};int main() {U u{.a = A{1, 42}};int tag = u.b.tag; // OK: common initial sequenceu.b.tag = 14; // UB: lifetime B не начинается неявно,// потому что default constructor B deleted}
С type punning тоже сложно запомнить все правила: логика здесь — на уровне «хорошо делайте, плохо не делайте». Если вкратце, то можно выделить три важных пункта:
-
Для punning через заранее выделенную память нужен корректный старт lifetime объекта.
-
Одинаковый layout структур не дает права читать одну структуру как другую. Исключение для union: у standard-layout struct alternatives можно читать common initial sequence.
-
Union в C++ хранит один активный member; чтение неактивного member– это undefined behavior.
Кстати, когда я изучал, как работает type punning в стандарте, стало интересно, что вообще такое punning.

У переводчика Google это значит просто «каламбур». Переводчик Яндекса (справа) идет дальше: предлагает контексты, в которых type punning употребляется в мировой литературе. Очевидно, что фразы в примере взяты либо из стандарта, либо из обсуждения стандарта. Так что при сохранении перевода «каламбур» итог звучит это очень забавно — type punning с Aliexpress какой-то 🙂
Как правильно кастовать объекты
Вернемся к основной теме и рассмотрим, как правильно кастовать объекты:
float f = 1.0; int x = *reinterpret_cast<int *>(&f); // Oops…
Берем единицу во float, интерпретируем её как указатель int. Мы знаем, что float и int у нас одного размера — допустим, по четыре байта. Далее просто разыменовываем и читаем… но нет, это undefined behaviour по всем правилам, что я описал выше.
До C++ версии 20 у нас был единственный способ это сделать легально — воспользоваться старым добрым std::memcpy: побайтово скопировать данные из этого float в int:
std::memcpy(&x, &f, sizeof(int));
С C++ версии 20 у нас есть std::bit_cast, что изолирует нас от всей неприятной возни с памятью. Разумно пользоваться именно им:
x = std::bit_cast<int>(f); // No explicit memory stuff at all
Вернусь к std::memcpy — точнее, к его «злобному двойнику» std::memmove. С виду они абсолютно одинаковы: destination, search-буфер, count. И по документации они делают одно и то же: переносят данные из одного буфера в другой.
В чем их различие? Фукнция std::memcpy запрещает указателям dest и src пересекаться в диапазоне [0, count]. Жаль, у нас нет языковых средств отличить одну функцию от другой. Или есть?
Привет из прошлого: restrict
Вернемся к языку C и вспомним ключевое слово restrict. Оно вводилось как раз для таких случаев.
{ // some code void * restrict ptr1; void * restrict ptr2; // some code with ptr1 and ptr2}void f(void * restrict p1, void * restrict p2);
Здесь нам необходимо пометить два указателя в какой-то зоне нашего кода как непересекающиеся. Мы знаем, что они одного типа, поэтому type-based alias анализ здесь работать не будет. Мы просто хотим сказать, что они не пересекаются — например, в сигнатуре функции.
void* memcpy(void* restrict dest, const void* restrict src, size_t count);void* memmove(void* dest, const void* src, size_t count);
Теперь, действительно, если мы таким образом пометим memcpy, все встает на свои места: restrict как бы дает некоторый контракт на непересекаемость для передаваемых параметров. Но что это за контракт? Надо разобраться, и если это так удобно, попробовать его в C++.
Оказывается, что restrict не так просто, как кажется на первый взгляд. Подробно останавливаться на нем не буду: в совместном докладе с Константином Владимировым на C++ Zero Cost Conf в 2025 году я подробно рассказывал, как работает restrict и почему ни один из промышленных компиляторов его до сих пор полноценно не поддерживает.
Недавно завирусилась новость о том, что 16 агентов Claude Code написали компилятор C за две недели. Как он обрабатывает ключевое слово restrict? Правильно, никак.
В чем сложности ключевого слова restrict:
-
restrict может квалифицировать любой указатель на объект, в том числе на поля структур, локальную и глобальную переменную (всюду есть свои неприятные особенности);
-
restrict действует в определенных областях видимости и тоже по странным правилам;
-
restrict можно «перезахватывать» и распространять по потоку данных на не-restrict-указатели. И это самое страшное.
Последний пункт я называю «эффект наблюдателя». Это самый показательный пример вреда от restrict.
{ int x; x = 42; int y; { // new block int * restrict px = &x; y = *px + x; // valid ! }}
Есть переменная x, мы говорим, что x = 42. Затем заводим переменную y, открываем новый скоуп, который вроде как ни на что не влияет — это просто для удобства. Заводим restrict-указатель px, который берет адрес x. А далее просто записываем в y разыменованные px + x. Px указывает туда же, куда x, поэтому, по сути, мы имеем x, умноженный на 2. Код абсолютно валиден.
Хотя мы совершили доступ к одному и тому же участку памяти через restrict-переменную и не через restrict-переменную (сам объект), это валидно, поскольку мы здесь просто читаем данные. При чтении необязательно соблюдать отсутствие пересечений.
{ int x; x = 42; int y; int * restrict px = &x; y = *px + x; // invalid !}
Вот другой код, который, по сути, ничем не отличается. Берем указатель px, адрес x, а дальше просто читаем данные. Но есть нюанс: хоть restrict-указатель и введен достаточно низко, но все-таки введен в том же блоке, что и int x, а модификация здесь уже была.
Стандарт языка C для restrict обязывает модифицировать объекты, на которые указывает restrict-указатель, исключительно через restrict-указатели и выражения, основанные на нем. Здесь это правило нарушено. Мы модифицируем указатель во второй строке, а затем совершаем доступ через restrict-указатель. Этот код невалиден.
Выстрелить себе в ногу в такой ситуации очень просто, а следить за этим программисту невозможно. Компиляторы здесь тоже страдают. И поэтому restrict — это неудачный эксперимент. Деннис Ритчи ещё до стандартизации языка в 1989 году был против включения в стандарт квалификатора noalias — праотца restrict. Тогда noalias забраковали, но в стандарте C99 ввели слово restrict, которое на следующие 30 лет вскружило головы разработчикам компиляторов и породило множество легаси.. Сегодня разработчики компиляторов не знают, что делать с restrict. Если вы попытаетесь поддержать его, то точно сломаете чью-нибудь кодовую базу.
Давайте тогда просто удалим restrict — как когда-то ключевое слово register, которое нам ничего не давало. Вот только проблема: слово restrict безальтернативно. Если мы выбросим restrict из всей кодовой базы, то сразу сильно потеряем в производительности. Вот несколько бенчмарков для примера:

Ситуация непростая. Мы не можем выбросить restrict, поскольку сейчас очень много кода закладывается на restrict-семантику, и не можем полноценно его поддержать.
Стоит ли перенести restrict в C++? Попробуем. Вспомним, что, в отличие от C, в C++ есть перегрузки функций.
int foo(int ** a, int ** d); // 1int foo(int * __restrict * a, int * __restrict * d); // 2
Первые, кто заметил эту проблему в пропозале n3635, отметили: если у вас есть две функции — c int ** и restrict-квалифицированным указателем — то непонятно, одинаковые или разные эти overload-кандидаты. Если вводить restrict на правах const или volatile, то да — и тогда оно участвует в перегрузке и манглировании. Но restrict в языке C не является частью типа. Тогда зачем нам переносить его в языке C++ как часть типа?
struct S { int foo(int a = 42) __restrict; // 1, S * restrict this int foo() const; // 2, const S * this};S s(&a);S * __restrict ps = &s;ps->foo(); // ambiguous
Следующая проблема. В языке C++ есть методы и члены классов. Мы restrict-анонсируем метод. Мы можем это сделать, поскольку this — это просто указатель, а restrict относится к указателям. Имеем две функции foo — одна restrict-анонсирована, другая const-анонсирована. Дальше заводим объект S, restrict-указатель объекта S и пробуем вызвать foo.
Хоть restrict и не вошло в стандарт, компиляторы поддерживают его уже достаточно давно. Это ключевое слово механически перенесли с фронтенда C на фронтенд C++. Без стандарта компиляторы, очевидно, расходятся во мнениях, как restrict поддерживать.
В комитете стандартизации договариваться не стали: ключевое слово restrict в C++ признано сломанным. Нам не стоит ожидать его стандартизации. Да, оно поддерживается как компиляторное расширение в GCC и Clang, работает и прибавляет производительности в очень ограниченных случаях. Но учитывая все сказанное, трогать его лишний раз не стоит.
Но все же нам пригодятся какие-нибудь гибкие механизмы языка, которые бы помогали контролировать алиасинг. Признаем, что слово restrict было не очень удачным экспериментом. Но прошла уже куча времени, и мы, наверное, можем придумать что-то лучше в более развитом C++.
Newtype: иллюзия безопасности
В том же самом пропозале, который раскритиковал restrict для C++, предложили новую идею — newtype:
newtype D1 : double;newtype D2 : double;void foo(D1* x, D2 *y) { // no alias here }
Что такое newtype? Мы заводим новые типы, так называемые strong typedef, которые цепляются за некий базовый тип. В примере D1 цепляется за double, D2 цепляется за double. И теперь D1* и D2* могут алиаситься с double — с типом, от которого они, так сказать, наследуются. Но между собой они алиаситься не могут. Чтобы ходить туда-обратно, добавим также cast:
// p and q are double-type pointersauto *p1 = alias_cast<D1 *>(p);auto *q1 = alias_cast<D2 *>(q);
Выглядит красиво. Похоже, мы на уровне семантики языка начинаем попытки вручную контролировать тот самый type-based alias анализ. Однако все не так однозначно.
newtype Position : double;newtype Velocity : double;double *raw = get_buffer();auto *p = alias_cast<Position*>(raw);auto *v = alias_cast<Velocity*>(raw + 1);
Newtype — это лишь иллюзия безопасности относительно алиасинга. Представьте обычную ситуацию, как в примере, когда нужно завести два новых типа, Position и Velocity, и их данные приходят из одного буфера, double. Получается, программист должен сам контролировать, что данные, о которых он заявляет, не пересекаются. А в коде действительно заявлено, что position и velocity пересекаться не могут, что алиасинг нигде не нарушается. Вся ответственность за алиасинг перекладывается на программиста.
В C++ эта проблема не выглядит значительной: нарушил стандарт, получил undefined behavior, все понятно. Но есть более серьезные замечания в сторону newtype. Внезапно он оказывается очень полезен для алиасинга, но при этом не специфичен для алиасинга. Это просто какой-то новый тип, и мы можем использовать его и не в контекстах указателей.
newtype NotInt : int;void g(long);void g(double);NotInt x(42);g(x); // NotInt -> int -> long // NotInt -> int -> double
Мы заводим newtype Notint, который наследуется от int. Далее берем две функции — g(long), g(double), заводим NotInt x(42). Ожидаем, что такое будет спокойно конструироваться, и пытаемся вызвать g(x).
Было бы очень удобно, если бы в контекстах, отвязанных от указателей, NotInt спокойно делегировал к int по базовым операциям, по cast. Но тогда нам необходимо разрешить перегрузку. В этом случае получается две одинаковых цепочки, ambiguity — то есть такую перегрузку мы разрешить не можем.
newtype NotInt : int;void h(int);void h(int &);NotInt x(42);h(x); // h(int);
А что, если у нас int и int &? Появляются ссылки, которые тоже несут под собой алиасинг. Там, где ссылки, есть указания на один и тот же объект с двух разных имен. В примере мы пробуем передать в этот объект x от NotInt-объекта. Куда он должен идти: копироваться или кастоваться к int &? Такое в пропозале NewType просто запретили.
void h(const int &);
Что будет, если мы захотим передать const int &? Это это семантически гораздо ближе к int, чем к int &. Такое в пропозале просто-напросто не рассмотрели.
К сожалению, на этом newtype почему-то заглох. Единственная официально сформулированная причина: это слишком тяжеловесное решение: «The alternative of newtype was rejected as too heavy weight of a solution». Модули и ranges тяжеловесными не были, а NewType таковым посчитали 🙂
Резюмируем преимущества и недостатки newtype. Мы вручную контролируем распространение алиасинга, и это, естественно, ложится на TBAA. Однако, конечно же, здесь у нас будут распространяться очень назойливые cast: постоянно придется кастовать то к одному типу, то к другому. Если строить очень сложные древовидные иерархии, это будет не очень удобно. И конечно, newtype потребовал бы пересмотра значительной части семантических процессов. Нам необходимо было бы описывать новые правила для разрешения перегрузки, для выводов типов шаблонов и т. д.
Другой подход: aliasGroup и alias_set
В этом же пропозале предложили альтернативное решение, которое считалось почему-то гораздо более приятным, чем newtype, — alias_set.
struct tag1; struct tag2;void foo([[ alias_set(tag1) ]] double *a, [[ alias_set(tag1) ]] double *b, [[ alias_set(tag2) ]] double *c);
По сути, это тегирование типов неким значением, что как раз указывает, могут ли данные пересекаться. Если тег одинаковый — могут, если разные — то нет. Честно говоря, я не совсем понимаю, чем такое решение отличается от restrict.
int x, y;[[ alias_set(tag1) ]] int *p = &x;p = &y; // Распространяется ли тег?
Допустим, у нас есть две переменные x и y. Мы берем тегированный указатель int * и &x. Говорим, что p = &y. У &y и &x никакого тега не было, но, допустим, мы ввели p с каким-то тегом. Должен ли в третьей строке тег у p сохраниться на весь lifetime или стереться, поскольку y не имел тега?
Если тег сохраняется на весь lifetime p, то, кажется, тег надо делать частью типа p. Однако частью типа это не сделали, потому что у нас начнется вывод тегов.
struct tag;auto *f([[alias_set(tag)]] double *ptr) { return ptr;}double *ptr;auto *x = f(ptr); // Выводится ли тег? А как выводится?
Представим ситуацию, где мы пытаемся вывести типы. Есть функция, она берет тегированный указатель и возвращает его. Далее auto *x = f(ptr), и мы на распутье: выводить этот тег или нет? А если у нас conditionally или по разным ветвям control flow, неизвестным на этапе компиляции, приходят разные теги?
Кажется, мы приходим к той же проблеме, что когда-то заблокировала стандартизацию restrict. Да, предложение alias_set здесь тоже заглохло.
Контракты: std::disjoint
Перейдем к следующему предложению. Давайте вообще не будем вводить новые ключевые слова, типы, теги и чего бы то ни было, а посмотрим в сторону контрактного программирования.
template <typename T, typename U>bool disjoint(const T* pt, size_t nt, const U* pu, size_t nu){ intptr_t bt = pt, et = pt+nt; intptr_t bu = pu, eu = pu+nu; return (et <= bu) || (eu <= bt);}char *strcpy(char *dest, const char *src)[[expects: disjoint(src, strlen(src), dest, strlen(src))]];
В 2018 году, когда все верили, что контракты войдут в C++ 20, в пропозале p1296r0 предложили: давайте воспользуемся контрактами, чтобы утверждать, что какие-то указатели не пересекаются.
template <typename T, typename U>bool disjoint(const T* pt, size_t nt, const U* pu, size_t nu){ intptr_t bt = pt, et = pt+nt; intptr_t bu = pu, eu = pu+nu; return (et <= bu) || (eu <= bt);}char *strcpy(char *dest, const char *src)[[expects: disjoint(src, strlen(src), dest, strlen(src))]];
Мы сами вводим соответствующую функцию — она может быть как constexpr, так и не constexpr. В функции мы будем считать, что участки памяти либо пересекаются, либо нет.
Такое решение очень сложно критиковать. Если компилятор на этапе компиляции может доказать эти свойства, то он применит все виды оптимизации. Он может утверждать, что disjoint верен, и проводить оптимизацию так же, как с restrict. Если мы хотим включить эти проверки в runtime, мы их включаем и получаем осмысленные ошибки. Очень удобно.
А главное, что эта идея с контрактами масштабируется. Всю статью я рассуждал о голых указателях в C++. Но, с другой стороны, у нас есть есть std::span, range, итераторы, стандартные контейнеры — всё, чтобы голыми указателями не пользоваться. Так что мы пытаемся изобретать кучу велосипедов, чтобы разграничивать голые указатели.
Контракты же достаточно просто обобщаются на итераторы, диапазоны, контейнеры стандартной библиотеки, потому что disjoint можно определять по-разному. Замечательно!
Этот пропозал был опубликован в 2018 году, но не был принят, поскольку тогда контракты не вошли в язык и несколько видоизменились. А в последние пять лет алиасинг вышел из поля зрения комитета стандартизации. А я считаю, что пропозал p1296r0 можно все-таки взять и попробовать доработать.
Подведем итоги
Алиасинг памяти — тривиальнейшее явление, характерное чуть ли не для всех языков программирования. Но внезапно он становится головной болью для программистов, а для разработчиков компиляторов и вовсе превращается в полноценную мигрень..
Языкам программирования с развитой семантикой очень сложно придумать простой и эффективный механизм ручного контроля алиасинга. Это пробовали делать в Fortran — консервативно, но пробовали. Это пробовали в языке C, это пробуют сейчас в C++ и в Rust.
В комитете стандартизации C++ работают над внедрением алиасинга, но по неизвестным мне причинам это направление работы в последние годы затихло. Newtype — это хороший пропозал: его сложно критиковать так, будто он совсем не рабочий. Контракты — это вообще замечательный пропозал, но их будто проигнорировали.
Что ж, тащить пропозал в комитете стандартизации — дело непростое. Будем верить, что алиасинг все-таки получит надежную реализацию в C++.
Если вам нравится работа на C++, возможно, вас заинтересуют эти вакансии YADRO, включая:
ссылка на оригинал статьи https://habr.com/ru/articles/1045947/