Привет, Хабр! Сегодня поговорим про один из самых незаметных, но любопытных моментов языка C. Если вы пишете на C, скорее всего, вы никогда не использовали ключевое слово restrict. А зря — этот квалификатор указателя может дать вашему коду неплохой прирост производительности. Правда, для этого придётся дать компилятору честное слово насчёт своих указателей.

Зачем нужен restrict и что такое aliasing

Указатели — штука удобная. Но есть проблема, компилятор живёт в постоянной паранойе. А вдруг два разных указателя ссылаются на одну и ту же область памяти?

Это явление называется aliasing. И именно из‑за него компилятор вынужден перестраховываться, генерируя менее эффективный код. Он просто не уверен, не повлияет ли изменение через один указатель на данные, которые читаются через другой.

restrict, появившийся в стандарте C99, помечает указатель как restrict, мы обещаем компилятору, что никакой другой указатель не будет обращаться к этой области памяти на протяжении всего времени жизни нашего restrict‑указателя. Компилятор верит и начинает оптимизировать агрессивнее.

Но если вы солгали...если два restrict‑указателя всё‑таки указывают на одну память, ждите неопределённого поведения. Компилятор вам доверял, а вы его подставили!

Как aliasing тормозит код

Посмотрим на простой пример. Вот функция, которая увеличивает два значения на одно и то же число:

void updatePtrs(size_t *ptrA, size_t *ptrB, size_t *val) {
    *ptrA += *val;
    *ptrB += *val;
}

На первый взгляд всё элементарно, но компилятор вам не доверяет. Теоретически ptrA, ptrB и val могут указывать на одно место в памяти. Представьте, что кто‑то вызвал функцию так: updatePtrs(&x, &y, &x). В этом случае ptrA и val указывают на один адрес. Когда мы выполняем ptrA += val, мы одновременно модифицируем и читаем одну переменную двумя разными указателями.

Компилятор перестраховывается. Он генерирует код, который каждый раз перечитывает значение по указателю val перед использованием. Не может же он сохранить *val в регистре на всю функцию, вдруг между операциями значение изменилось через ptrA или ptrB? Получается примерно такой код:

// Загружаем значение *val в регистр R1
R1 = *val

// Обновляем *ptrA
R2 = *ptrA
R2 = R2 + R1
*ptrA = R2

// Компилятор снова загружает *val — вдруг оно изменилось через ptrA?
R1 = *val  

// Обновляем *ptrB
R2 = *ptrB
R2 = R2 + R1
*ptrB = R2

Значение по адресу val прочитано дважды, перед обновлением ptrA и перед обновлением ptrB. А повторное чтение из памяти дорого. Компилятор просто не может рисковать, без подсказок он должен предполагать, что ptrA и val могут указывать на один блок памяти.

Используем restrict

Теперь перепишем функцию с restrict:

void updatePtrs(size_t *restrict ptrA, 
               size_t *restrict ptrB, 
               size_t *restrict val) {
    *ptrA += *val;
    *ptrB += *val;
}

Видите слово restrict между звёздочкой и именем каждого указателя? Это наш контракт с компилятором. Мы клянёмся, что ptrA, ptrB и val указывают на разные области памяти. И компилятор верит.

Теперь он понимает,значение *val можно спокойно загрузить один раз и переиспользовать для обеих операций. Никаких лишних чтений из памяти.

Классический пример использования restrict — стандартная функция memcpy. Её прототип в C99 выглядит так:

void *memcpy(void *restrict dest, const void *restrict src, size_t n);

И источник, и назначение помечены как restrict. Это неспроста, самый быстрый копирующий код возможен только если области памяти не перекрываются. Нарушите это правило и получите неопределённое поведение. Компилятор может копировать данные крупными блоками или в необычном порядке, и при перекрытии областей случится что‑нибудь неприятное.

Нюансы restrict

Звучит здорово. Но прежде чем кидаться расставлять restrict повсюду, давайте разберёмся с правилами игры.

Только указатели. Квалификатор restrict применим только к указателям. Целочисленную переменную или ссылку в C++ им не пометишь.

Один участок памяти — один restrict‑указатель. Объявили два restrict‑указателя в одном блоке и направили их на одну память? Вы нарушили обещание.

Не присваивайте restrict‑указатель другому restrict‑указателю. По крайней мере в пределах одного блока — внутренности компилятора могут запутаться. Зато присвоить restrict‑указатель обычному указателю можно. Такой обычный указатель считается порождённым от restrict, и компилятор понимает, что это не независимый конкурент.

Разные области видимости — разные правила. Если очень нужно, можно во вложенном блоке объявить второй restrict‑указатель на ту же память — это допустимо. Внутри внутреннего блока компилятор будет считать, что на этот участок памяти есть единственный указатель.

Действует только внутри области видимости. Обычно это тело функции, если restrict применён к параметрам. Гарантия неaliasing действует только внутри функции и никак не влияет на внешний код. Сохранили где‑то снаружи копию адреса и потом внезапно через неё поменяли данные? Для компилятора внутри функции это неожиданный подвох. Впрочем, это общая проблема оптимизаций в C, нарушать контракты опасно.

Если вы пишете обычные программы, то специально оптимизировать aliasing через restrict почти никогда не потребуется. Компиляторы за последние годы поумнели и сами многое оптимизируют.

В общем, используйте restrict разумно, помечайте параметры функций, когда уверены, что указатели не пересекаются. И не забывайте, что это обещание, которое нужно держать.

Если хочется глубже в ту же сторону — курс «Программист C» разбирает C как инструмент контроля памяти и взаимодействия с ОС: архитектура процессора, указатели, системные вызовы, отладка и практические задачи уровня middle. Отдельный блок — встраивание C в проекты и интеграции с PostgreSQL/MySQL/SQLite. Пройдите входной тест.

Для знакомства с форматом обучения и экспертами приходите на бесплатные демо-уроки:

  • 15 января, 20:00. «Работа с памятью на языке C». Записаться

  • 22 января, 20:00. «Сетевые приложения: Путешествие в мир сокетов». Записаться