Вероломные указатели

Настоящая статья частично навеяна различными вопросами на StackOverflow, которые я периодически встречаю, частично другими статьями и видео с конференций. В результате появилась идея собрать основные проблемные и интересные места в стандартном C++, которые связаны с указателями и являются совершенно неочевидными для простого C++-программиста. При этом я не собираюсь впадать в какую-то «эзотерику» и приводить код, который никто не пишет.

Отнюдь, в этой статье мы посмотрим такие важные вещи как strict aliasing, сравнение указателей, а также то, что делать с ними можно, а что не очень можно. Естественно, что я не собираюсь повторять себя и переписывать ту информацию, что я представил в цикле по указателям. Текст данной статьи предназначен скорее «продвинутым» пользователям языка C++.

Если в статье не указано иного, то используются следующие компиляторы для тестов: GCC 9.1, clang 8.0 и MSVC 16.3.10 (MSVS 2019).

Сравнение указателей

Давайте рассмотрим такой, казалось бы, простой вопрос как сравнение указателей. Говорить в этом разделе мы будем как о языке C++, так и о языке C. Первый мы будем рассматривать в контексте стандарта 2017 года, а второй — 2018.

Для начала приведём вот такой простой пример:

#include <stdio.h>

int main()
{
    int first = 5;
    int second = 15;
    int* firstPtr = &first;
    int* secondPtr = &first;
    const int isEqual = firstPtr == secondPtr;
    printf("Addresses are: %p and %p\n", firstPtr, secondPtr);
    printf("Are pointers equal? %d\n", isEqual);
    return 0;
}

Является ли этот код корректным C/C++? Что он выведет? Тут нет никакого подвоха, конечно это нормальный C++ код (я буду опускать C/C++ и писать просто C++, пока не будет надобности в упоминании C отдельно). А выведет он одинаковые адреса и единицу. Теперь немного изменим код инициализации указателей, на такой:

int* firstPtr = &second;
int* secondPtr = &first;

Что-то изменилось относительно корректности кода и вывода? Да, кое-что изменилось. Теперь у нас указатели на 2 разных объекта, которые к тому же никак не связаны. Что нам на это говорит интуиция? Согласно моей интуиции этот код должен вывести 2 разных адреса и 0. Это вполне логично. Но что думает по этому поводу стандарт? Оба стандарта поддерживают нашу интуицию, и говорят, что сравнение указателей на разные объекты однозначно даёт отрицательный результат. Отсюда важно вынести то, что указатели на разные несвязанные объекты сравнивать на равенство можно и результат такого сравнения определён. Казалось бы, зачем я вообще эту тему поднимаю, это ведь самоочевидно! Но не торопитесь с выводами.

Прежде чем мы продолжим наше общение с указателями, давайте вспомним некоторые основы стека в архитектуре x86. Стек это секция данных, которая расширяется вниз, т.е. адреса помещаемых туда объектов уменьшаются: если мы помещаем 2 объекта в стек, то второй объект будет размещаться по меньшему адресу относительно первого. Разумеется, всё это не имеет отношения к стандарту C++, который ничего подобного не говорит, но это знание будет полезно при анализе следующего примера. Давайте немного исправим наш код:

int* firstPtr = &second + 1;
int* secondPtr = &first;

Вооружившись знаниями о стеке, мы добавили к указателю на второй объект единицу. Это должно позволить нам получить указатель на первый объект, при условии, что компилятор разместил их друг за другом в стековом порядке. В результате логично предположить, что наш код должен вывести одинаковые адреса и 1. Но так ли это? Здесь мы впервые в этой статье вступаем на зыбкую почву. Для начала давайте просто проверим, что нам покажут разные компиляторы. А показать они нам могут всё, что угодно — зависит от настроек компиляции и версии компилятора, поэтому я даже не стану приводить примеры вывода. А раз в компиляторах согласия нет, то нужно обращаться к стандарту.

Хотя стандарт C++ (expr.eq/p2.1) говорит, что результат такого сравнения неспецифицирован, описанное в нём поведение по сути совпадает с описанием в стандарте C (6.5.9/p6): если так вышло, что объекты расположены друг за другом, то сравнение указателя, который указывает сразу за первый объект, с указателем на второй объект, даст положительный результат (в нашем случае всё наоборот, указатель за второй объект сравнивается с первым из-за особенности роста адресов стека). Таким образом, поведение компиляторов вполне объяснимо: если они расположили объекты друг за другом в памяти, то результат будет положительным, в противном случае — отрицательным. Всё логично и совпадает с нашей интуицией. Но всё же есть одно «но»: ни C, ни C++ не говорят, что при подобном размещении результат сравнения должен быть положительным, только о том, что может быть.

Но почему вообще такое сравнение разрешено и имеет отдельное упоминание в стандарте? Ведь прибавь мы, скажем, 2, и мы бы оказались уже в совершенно другой ситуации. Вытекает подобное поведение из того, что в C++ разрешено сравнение указателя внутри массива с указателем, который указывает сразу за массивом (т.е. указатель на последний элемент + 1), а каждый объект является массивом состоящим из одного элемента. Т.е. такое сравнение абсолютно легально и, как мы уже сказали, иногда даже даёт положительный результат. Но ведь я там что-то писал про «зыбкую почву», где она? Давайте немного дополним наш пример, добавив в него сравнение значений, которые хранят наши указатели (сравнение адресов):

#include <stdio.h>

int main()
{
    int first = 5;
    int second = 15;
    int* firstPtr = &second + 1;
    int* secondPtr = &first;
    const int ptrEqual = firstPtr == secondPtr;
    const int addrEqual = (size_t)firstPtr == (size_t)secondPtr;
    printf("Are pointers equal? %d\n", ptrEqual);
    printf("Are addresses equal? %d\n", addrEqual);
    return 0;
}

Исходя из того, что мы выяснили ранее, может ли так случиться, что вывод будет отличаться в цифрах? Т.е. не 0 0 или 1 1, а 0 1 или 1 0? Может, потому что ничего в стандарте не гарантирует, что при совпадении адресов, указатели должны быть равны!

Адрес, точнее интегральное значение указателя, это вообще интересная штука. Мы привыкли думать об указателях и адресах, как о чём-то практически идентичном, поэтому для нас логично, что сравнение адресов эквивалентно сравнению указателей, но с точки зрения языка это не так. C++ является достаточно низкоуровневым, но это всё-таки язык высокого уровня. И в правилах этого языка есть чёткие указания на то, что можно делать с указателями, а что нет. Так вот, нигде в языке нет правил, которые бы гарантировали то, что из равенства адресов, должно следовать равенство указателей.

Но когда такое различие может быть? Я вижу здесь два варианта: экзотический и мирской. В экзотическом мы имеем некую архитектуру, которая выдаёт разные адреса, каждый раз, когда указатель приводится к интегральному типу. Но при обратном преобразовании мы всегда получаем один и тот же указатель из этих разных адресов. А вот мирской пример куда банальнее и проще: компилятор выполняет оптимизацию, считая, что указатели не могут указывать на одно и то же место, поэтому он проверку просто убирает, заменяя её на true/false, ведь он имеет на это полное право.

Автор, но это же нонсенс, ни один вменяемый компилятор не выдаст подобного результата — может возразить читатель. Но не торопитесь писать гневный комментарий, давайте возьмем последний (на момент написания статьи) GCC, версии 9.1.0, и соберём c его помощью наш пример, включив оптимизацию -O1. Мы получим следующим вывод:

Are pointers equal? 0
Are addresses equal? 1

Да, есть те, кто считают, что это баг в GCC, но я не вижу причин к таким рассуждениям; это поведение не противоречит стандарту, поэтому и багом это считать не следует. Можно посчитать, что все эти рассуждения не имеют практического смысла, но ведь мы показали на простейшем примере, как один из «большой тройки» компиляторов даёт результат, который мало кто ожидает.

А что если наши примеры будут сложнее, будут использоваться указатели из разных единиц трансляции и прочее? При этом код, который оперирует именно адресами, это не такая уж редкость в мире C/C++, особенно C. Поэтому хочется предостеречь читателей — не стоит оперировать сущностями более низкими, чем привычные для языка. Иначе можно заложить в код бомбу, отыскать которую впоследствии будет очень сложно.

Неравенство указателей

В прошлом разделе мы рассмотрели лишь один способ сравнения указателей — сравнение на равенство. Теперь же мы рассмотрим неравенство указателей, а именно операторы отношения: <,>, <= и >=. Я выделил неравенство в свой собственный раздел, потому что ситуация с ним в C++ гораздо сложнее, чем с проверкой на равенство. Если с равенством всё довольно просто и, в целом, соответствует нашей интуиции, то с неравенством это не так. Немного переделаем код из прошлого раздела, чтобы получить вот такой пример:

#include <stdio.h>

int main()
{
    int first = 5;
    int second = 15;
    int* firstPtr = &first;
    int* secondPtr = &first + 1;
    printf("First < second? %d\n", firstPtr < secondPtr);
    return 0;
}

Какой будет вывод? 1. Тут всё понятно и соответствует нашей интуиции. Но давайте совсем чуть-чуть изменим код:

int* firstPtr = &first;
int* secondPtr = &first + 2;

Что будет выведено после этого изменения? Неизвестно, т.к. этот код содержит неопределённое поведение (НП). Правда, тут НП даёт сам факт прибавления двойки к указателю, поэтому данный пример не очень показателен. Давайте рассмотрим другой:

int* firstPtr = &first;
int* secondPtr = &second;

А теперь какой будет вывод? Правильно, снова неизвестно. Мы уже говорили раньше, что порядок размещения объектов не специфицирован и не известно, что будет размещено раньше first или second. Т.е. логично предположить, что результат неспецифицирован, но предсказуем на конкретном компиляторе. Но на самом деле ситуация несколько сложнее. В примере выше мы имеем сравнение указателей на 2 несвязанных объекта с помощью операторов отношения, а их правила отличаются от тех, что мы рассмотрели для оператора равенства.

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

Разобравшись с этим, давайте ещё раз перепишем наш пример (мы ещё с прошлым не закончили, но этот будет более наглядным):

#include <stdio.h>

int main()
{
    int first = 5;
    int second = 15;
    int* firstPtr = &first;
    int* secondPtr = &second;
    printf("First < second? %d\n", firstPtr < secondPtr);
    printf("Second < first? %d\n", firstPtr > secondPtr);
    return 0;
}

Как думаете, что должен вывести этот код? Полагаю, что правильный ответ вас удивит. Более того, правильных ответа тут 2: один для C, а другой для C++. Итак, с точки зрения C мы имеем НП, потому что сравнивать указатели на несвязанные объекты нельзя (6.5.8/p5). С другой стороны, в C++ подобное сравнение неспецифицированно: при сравнении указателей на 2 несвязанных объекта ни один из указателей не должен быть больше другого (expr.rel/p3.3), а когда ни один не больше другого, стандарт не специфицирует результат операторов отношения (expr.rel/p4). Хотя это и является разночтением, суть в обоих языках одинакова: для указателей определён лишь частичный порядок. Да, можно рассматривать под микроскопом эту разницу, а можно просто принять очевидный факт: осмысленное сравнение двух несвязанных указателей невозм��жно ни в C, ни в C++.

Но какова причина подобных ограничений? Почему нельзя было в языке сделать нормальное сравнение указателей? Полагаю, причина довольно проста: не во всех архитектурах можно провести осмысленное сравнение двух адресов в памяти. Сейчас многие привыкли к плоской модели памяти, когда адреса линейно увеличиваются от нуля до какого-нибудь числа, но так было не всегда, и неизвестно, что будет дальше. В плоской модели сравнение на больше/меньше не является проблемой, но что делать, к примеру, в сегментированной модели памяти, когда любой адрес это комбинация сегмента и адреса внутри сегмента? Как можно определить меньше или больше указатель на сегмент А со смещением М, указателя на сегмент Б со смещением К? Да, можно принять какие-то условности и произвести сравнение, но от этого подобное сравнение осмысленным не станет. Поэтому, вероятно, в таком низкоуровневом языке как C++ стандарт и не пытается навязывать спорное поведение. Тем более, что сравнение несвязанных между собой указателей для получение некого содержательного результата это, в принципе, сомнительная затея.

Вот пример, когда программист попытался использовать сравнение указателей на несвязанные объекты для сомнительной пользы: stack growth direction. Человек решил определять направление роста стека, сравнивая указатель на параметр функции с указателем на объект внутри функции. Очевидно, что эта проверка не работает по описанным нами ранее причинам. Но даже если бы ограничения на сравнение не было, подобный алгоритм изначально предполагает слишком многое: стек растёт линейно в одном направлении, параметры передаются через стек, локальные объекты выделяются на стеке и, в конце концов, стек вообще есть. Но если мы делаем столько допущений, то мы скорее всего и так знаем на какой архитектуре у нас запускается приложение, а следовательно проще посмотреть спецификацию и «зашить» это в коде.

Тем не менее на этом наше приключение с операторами отношения не заканчивается, потому что C++ имеет в стандартной библиотеке следующие функторы: std::less и std::greater (а также их X_equal собратьев). Логично предположить, что это просто обёртки над операторами отношения, которые созданы для удобства использования в стандартной библиотеке. Это так и есть, но для этих 4-х функторов есть интересная приписка (описанная в comparisons/p2). Согласно этому пункту, для всех этих функторов должна существовать специализация под указатели, и она обязана выдавать линейный порядок! Другими словами, каждый из этих функторов можно применять для сравнения любых указателей, даже несвязанных. Интересный поворот, не правда ли?

Одной рукой комитет пишет, что подобное сравнение нерегламентировано, другой — заставляет авторов стандартной библиотеки придумывать способ сравнения. Это просто блеск. Мы говорили, что осмысленное сравнение указателей получить на всех архитектурах невозможно, тогда зачем вообще подобные требования? Полагаю, что причина в контейнерах. Представьте, что у вас есть объект следующего типа: std::map<char*, SomeObject>. Т.е. ключом в нашем словаре выступает указатель. Очевидно, что часто эти указатели будут на области никак не связанные, а мы всё же хотим, чтобы код просто работал. Не могу сказать, что я постоянно встречаю подобные типы, но и редкими я их назвать не могу — это вполне обычное желание: иметь указатель ключом. Но если не иметь специального правила, которое бы позволило сравнивать несвязанные указатели, тогда реализация словаря была бы невозможной. Поэтому появление такого требования к std::less и компании вполне понятно.

Другое дело, что подобное решение является очень скользким. Предположим, что мы живём в другом мире, где много конкурирующих реализаций C++, и мы имеем целый ворох C++ компиляторов и реализаций стандартных библиотек. Одни хорошо делают компиляторы, другие делают библиотеки. И вот получается интересная история: каким образом автор библиотеки может надёжно реализовать std::less без помощи компилятора? Я, честно говоря, не представляю как это можно сделать, но можно предположить, что каким-то сложным путём это было бы достижимо. С другой стороны, разработчики компиляторов тоже не могут не понимать, что разработчики библиотек как-то должны решать эту проблему, поэтому, вероятно, они бы предоставляли средства для реализации подобного.

Но если мы имеем очевидную проблему, почему бы не решить её сразу на уровне языка? Понятно, что осмысленного сравнения случайных указателей добиться невозможно, но если мы требуем от библиотеки иметь хоть какое-то сравнение, то с таким же успехом мы можем требовать этого от языка. Т.е. лично мне не понятно, что удерживает комитет от принятия подобного решения. Какие такие выгоды получает код от того, что мы имеем такую подвешенную ситуацию? Ведь в мире миллионы строк шаблонного кода, в котором используется сравнение. Но сколько подобного кода содержит прямой вызов операторов отношения, вместо функторов? А в C получается вообще невозможно реализовать словарь, который будет хранить указатели. В общем, ситуация действительно не очень приятная.

Строгое соответствие

Разобравшись со сравнением, предлагаю перейти к ещё одной теме связанной с указателями. Предположим, что у нас есть некоторая функция, предназначение которой принимать 32-х битное число и преобразовывать его в строку. Это число представляет собой версию приложения, где старшие 16 бит отведены под мажорную версию, а младшие — под минорную. Мы могли бы написать эту функцию (с примером использование) так:

#include <string>
#include <iostream>
using namespace std;

string versionToStr(uint32_t version)
{
    uint16_t* values = (uint16_t*)&version;
    return to_string(values[0]) + "." + to_string(values[1]);
}

int main()
{
    cout << "App version: " << versionToStr(0x05000Fu);
    return 0;
}

Данный код выглядит вполне невинно и скорее всего он даже выведет то, что мы от него ожидаем. Но с этим кодом есть одна небольшая проблема — он содержит НП! Дело в том, что C++ (и C) очень ревностно относится к объектам и тому, как к этим объектам обращаются. В коде нашей функции есть параметр типа uint32_t, к которому мы обращаемся как к массиву объектов uint16_t. Но C++ прямо запрещает (для C++ это basic.lval/p8 и 6.5/p7 для C) обращаться к объектам одного типа, через указатель на другой тип. Но есть у этого правила и исключения, которые прописаны там же. Предлагаю по очереди разобраться с каждым случаем, когда использование (разыменование) указателя считается корректным в C++ (в C список тот же, за исключением случаев, отсутствующих в языке C).

Хотя все эти пункты применимы к lvalue-выражениям в целом, а не только к разыменованию указателя, нас интересует исключительно разыменование. Потому что большая часть реальных проблем вызвана именно работой с указателями.

Итак, пункт первый, банальный. Мы можем использовать указатель на объект, когда динамический тип объекта совпадает с типом указателя. Динамический тип объекта этот тот тип, под который была выделена память (что было сконструировано в этой памяти). Т.е. если мы имеем такой код:

float value{0.1f};
float* ptr = &value;
*ptr = 0.2f;

То у нас получается, что ptr указывает на объект, чей динамический тип является float. Или чуть более интересный пример:

struct Parent
{
};

struct Child: Parent
{   
};

Child child;
Parent* parentPtr = &child;
Child* childPtr = &child;

Здесь у нас тип childPtr совпадает с динамическим типом объекта, на который он указывает, а тип parentPtr — нет. Поэтому parentPtr под это правило не подходит.

Но мы-то знаем, что весь этот код корректен, поэтому мы несомненно найдём подходящее правило далее.

Пункт второй, неудивительный. Если у нас есть указатель на более квалифицированный тип (в терминах const и volatile), то по этому указателю мы можем обращаться к объекту, чей динамический тип менее квалифицирован. Например:

int object{45};
const volatile int* ptrObj = &object;
*ptrObj;

Пункт третий, похожий. Имея указатель на похожий тип, мы можем обращаться по нему к объекту другого, но похожего типа. Похожий тип это продолжение предыдущего пункта (очень упрощённо): если взять мешанину из cv-квалификторов и указателей, то эта мешанина схожа с другой мешаниной, если количество звёздочек одинаково и всё это указывает на один и тот же тип, безотносительно cv-квалификаторов. Это, наверное, самое грубое определение, что я вообще писал когда-либо в этом блоге, но если хочется строго математического определения, то добро пожаловать в стандарт — там всё есть. Пример:

char* ptrChar;
char* const* doublePtrChar = &ptrChar;
*doublePtrChar;

Тип &ptrCharchar**, а тип нашего указателя — char* const*, т.е. эти типы похожи.

Пункт четвёртый, знаковый. Имея в своё распоряжении объект знакового или беззнакового типа, мы можем обращаться к нему через указатель на беззнаковый или знаковый тип соответственно; т.е. главное, чтобы тип совпадал, а знаковый он или нет — не важно. Пример:

int object = 43;
auto pUObject = reinterpret_cast<unsigned int*>(&object);
*pUObject = 143;
auto pObject = reinterpret_cast<int*>(pUObject);
*pObject = 243;

Оба этих указателя можно использовать для разыменования и это не приведёт к НП.

Пункт пятый, очевидный. Этот пункт совмещает в себе пункты два и четыре: т.е. если мы добавим cv-квалификаторы к signed/unsigned, то всё по-прежнему останется определённым.

Пункт шестой, сложный. Имея указатель на некоторый сложный тип (объединение, массив, класс), тип одного из членов которого является одним из типов описанных в пунктах выше, мы можем использовать этот указатель для доступа к объекту подходящего типа, находящегося вне объекта нашего сложного типа. Сложное предложение, но смысл простой. Лучше всего показать на примере:

struct Weird
{
    int mem;
};
int object = 43;
auto pWeird = reinterpret_cast<Weird*>(&object);
pWeird->mem = 46;

Пока что это самый интересный и спорный пункт из всех, что мы рассмотрели. Во-первых, он выглядит подозрительно. Во-вторых, он выглядит избыточно: зачем вообще нужно подобное обращение? Судя по всему, данное правило является пережитком C, и его появление было как-то связано с необходимостью иметь в стандарте текст, явно разрешающий копирование структур. Т.е. тот код, что я привёл выше, вряд ли явился причиной появления данного пункта. Тем не менее этот пункт разрешает именно такой код, и это имеет свои последствия, которые мы рассмотрим чуть позже.

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

Пункт восьмой, последний и самый важный. Имея указатель на char*, unsigned char* или (только для C++) std::byte, мы можем обращаться к объекту любого типа. Т.е. если все предыдущие пункты нас ограничивали, этот разрешает просто всё. К примеру, мы можем делать так:

int value = 5;
auto leastByte = reinterpret_cast<std::byte*>(&value);
*leastByte = static_cast<std::byte>(0x8);

Т.е. мы взяли указатель на совершенно другой тип (std::byte) и обращаемся к исходному объекту как к массиву байт. Разве это не удивительно? Думаю, что для большинства моих читателей это совершенно не удивительно. Правда, я подозреваю, что не для всех причина «неудивителности» будет одной. Так, читателей можно поделить на тех, кто уже знает, что такое strict aliasing rule и всех остальных. Мы скоро вернёмся к этому правилу, а пока нам нужно закончить с тем, что начали.

Бывалые C++ программисты конечно же знают, что есть ещё указатель на void, в который можно превратить практически любой другой указатель. Но void* к нашей теме не имеет никакого отношения по одной простой причине: разыменовать такой указатель невозможно.

Итак, мы рассмотрели 8 пунктов, которые описывают все возможные ситуации, при которых мы можем обращаться к объекту через указатель на тип, который отличается (пусть только модификаторами) от исходного типа объекта. На самом деле 7 первых пунктов практически не важны, в том смысле, что они, по сути, самоочевидны и могут быть легко описаны одним неформальным предложением. Другое дело пункт 8 — этот действительно важен и ради него, в сущности, весь этот текст и написан.

Я привёл все пункты просто для полноты картины, чтобы интересующийся человек понимал, что правил чуть больше чем одно. Но было бы неверным не упомянуть о том, что эти правила несколько устарели, и в скором времени они могут быть подвергнуты коррекции. Точнее, устарела буква правил, а не их дух. Так, к примеру, в C предлагают исключить шестой пункт из правил, а в C++ идут ещё дальше и вообще предлагают вырезать большую часть этих правил.

Тем не менее коллективно эти правила составляют так называемое правило строго соответствия (ПСС, англ. strict aliasing rule), которое можно неформально сформулировать следующий образом: имея два указателя T* и U*, мы можем утверждать, что они не ссылаются на один объект если только T и U не являются совместимыми типами, либо же один из них не является всеядным типом. Где совместимые типы описаны в пунктах 1-7, а всеядные — в восьмом пункте. Но зачем нам знать об этом правиле, каковы его последствия?

Каламбур

Начнём этот подраздел с повторения кода, который мы привели в самом начале раздела:

string versionToStr(uint32_t version)
{
    uint16_t* values = (uint16_t*)&version;
    return to_string(values[0]) + "." + to_string(values[1]);
}

Там мы указали, что в коде содержится неопределённое поведение, а затем выяснили почему: мы обращаемся к объектам uint32_t через указатель на uint16_t, что нарушает ПСС. Но загвоздка в том, что хотя я и придумал этот пример за пару минут, он не выглядит излишне натянутым. Наоборот, программисты C++ настолько привыкли играть типами, что подобный код для многих является чуть ли не повседневной практикой. Особенно подобный код характерен для различных сетевых взаимодействий и прочих вещей, которые спускаются ниже типичного пользовательского слоя (UI и тому подобного). К примеру, гипотетический программист-сетевик, при написании кода сетевого протокола, мог воспользоваться вот такой простой «болванкой» для сообщений:

struct Message
{
    int id;
    std::vector<uint8_t> payload;
};

У нас есть идентификатор сообщения, и на основании его мы можем интерпретировать полезную нагрузку. Даже был использован тип, который гарантировано даёт набор восьмибитных байтов. Т.е. всё сделано по-науке (но максимально утрировано), в чём может быть проблема? В последующей интерпретации. Например, мы знаем, что у нас в сообщении содержится набор 16-битных слов, тогда мы делаем так:

auto payload = (uint16_t*)msg.payload.data();
//Дальше работаем с payload...

Этот код выглядит вполне невинным (в конце концов мы просто интерпретируем набор байт!), но мы-то уже знаем, что породили НП, и что так делать нельзя! Тем не менее подобный код содержится в массе проектов различного уровня сложности и не является чем-то необычным. Про ПСС слышало не так много людей, и в результате мы имеем кучу кода с бомбой замедленного действия: он может работать сейчас, но никто не гарантирует, что продолжит работать завтра. А отлавливать проблемы, вызванные НП, это то ещё приключение. Поэтому нужно запомнить раз и навсегда: в C++ игры с типами (type punning), через простые преобразования, весьма и весьма ограничены и, заигравшись, очень легко можно напороться на НП. Так что к любому явному преобразованию нужно подходить с умом; не лишним будет напомнить, что преобразованию в стиле C не место в C++ коде — его очень сложно отследить.

Однако, как вы могли заметить, проблема у нас именно в части интерпретации, а не в части нашей изначальной структуры. Так может можно как-то изменить процесс интерпретации, чтобы всё работало так, как нам нужно, но при этом не вызывало НП? Действительно, такой способ есть и он очень прост: используйте memcpy/memmove. Т.е. наш код должен выглядеть как-то так:

std::vector<uint16_t> payload(msg.payload.size());
std::memcpy(paload.data(), msg.payload.data(), msg.payload.size());
//Дальше работаем с payload...

Такой вариант возможен благодаря тому, что доступ к объекту любого типа разрешён через указатель на unsigned char (последний пункт правил), а именно так трактуют вышеозначенные функции исходную и результирующие последовательности: массивы unsigned char. Т.е. мы можем интерпретировать объект любого типа через другой тип, но для этого мы используем не просто преобразование, а побайтовое копирование. Очевидным минусом подобного подхода (помимо количества кода), является тот факт, что мы вынуждены гонять байты туда-сюда. В результате это может сказаться на производительности.

Внимательный читатель скорее всего заметил, что по сути пример с интерпретацией через преобразование мало чем отличается от примера с копированием. Просто в первом случае идёт интерпретация исходной последовательности байт, а во втором происходит та же самая интерпретация, только копии последовательности. У многих людей резонно возникает вопрос: а почему я должен платить производительностью за какие-то непонятные ограничения? Откуда и почему эти ограничения возникли? В результате код продолжается писаться с полным игнорированием ПСС. Думаю, что можно выделить три группы кода, который нарушает данное правило:

  • Старый код.

  • Код, который пишут люди, не знающие об этом правиле.

  • Код, который пишут люди, знающие об этом правиле, но сознательно его игнорирующие.

Интересно отметить, что в ядре Linux содержится код, который нарушает это правило. Я доподлинно не знаю, к какой конкретно группе отнести этот код, но склоняюсь к третьей: об этом известно и, насколько я знаю, с этим никто ничего делать не собирается. Более того, если вы поищите в интернете мнения касательно этого правила, то обнаружите, что лагерь его противников достаточно велик. Не думаю, что какое-то другое правило, превращающее код в НП, имеет столько сопротивления, сколько ПСС.

Разумеется, компиляторы не могли просто последовать слепо стандарту и проигнорировать огромную базу своих пользователей, которые отказываются подчиняться. В результате для clang и gcc есть следующие флаги: -fstrict-aliasing и -fno-strict-aliasing. Первая опция включается автоматически при любом уровне оптимизации, а вторая служит для того, чтобы отключить это правило на любом уровне оптимизации. Ядро Linux, как вы понимаете, собирается с -fno-strict-aliasing. Для MSVC никаких ключей нет.

Но откуда взялось это правило, почему оно появилось? Почему существует масса кода, который игнорирует это правило? Тут ответ довольно прост: оптимизация. Этому правилу уже очень много лет. Не берусь утверждать, но возможно оно было всегда; по крайней мере оно было в C++03 и C99. Просто компиляторы им не пользовались, до определённого момента. А потом всё резко изменилось. Об этом мы и продолжим разговор.

Оптимизация

Итак, как многие наверное знают, основная причина наличия многих «тёмных мест» (больше известных как НП) в стандарте C++ — оптимизация. C++ всегда позиционировался как довольно низкоуровневый язык программирования высокого уровня, поэтому очень часто его выбирают из тех соображений, что написанный на этом языке код будет выдавать максимальную производительность. Давайте рассмотрим такой простой код:

int easySum(int* in, int* out)
{
    for(int i = 0; i < 10; ++i)
        *out += *in;
    return *out;
}

Опустив опасность переполнения (ведь оно невозможно, иначе НП 😉) , что можно сказать об этом коде? Мы принимаем два указателя: один «входящий» (in), второй «исходящий» (out). Мы поочерёдно (10 раз) прибавляем к тому, на что указывает out то, на что указывает in. Затем мы возвращаем значение по указателю out.

Функция очень простая, так что давайте наденем очки компилятора и посмотрим на этот код его глазами (используя вольную терминологию, а не строгий язык стандарта): запускаем цикл, в каждой итерации которого читаем значение из in, потом значение из out, складываем первое со вторым и записываем в out. По окончании цикла возвращаем значение из out. Т.е. мы имеем 2 операции чтения и одну операцию записи памяти в цикле, а также одну операцию чтения после цикла. Итого: 10*2 + 1 = 21 операция чтения, и 10 операций записи.

Не нужно быть экспертом в оптимизации, чтобы понимать, что для такой простой функции мы что-то слишком сильно наседаем на память. А, как известно, память — это одно из самых медленных звеньев, когда мы говорим о вычислениях: процессор может очень быстро щёлкать числа и очень долго ждать запросы из памяти. Теперь давайте снимем очки компилятора и наденем очки оптимизатора, что можно сделать с этой функцией?

Первое, что бросается в глаза: мы каждый раз читаем значение из in, это никуда не годится. Давайте вынесем чтение из цикла:

int easySum(int* in, int* out)
{
    auto __tmpIn = *in;
    for(int i = 0; i < 10; ++i)
        *out += __tmpIn;
    return *out;
}

Теперь мы можем засунуть __tmpIn в регистр и получается, что мы только что сократили 10 чтений памяти до 1. Но идём дальше: зачем нам постоянно записывать в out, когда мы можем сначала всё сложить в какой-то временной переменной (которая тоже может размещаться в регистре), а потом разом все записать в out? Сказано — сделано:

int easySum(int* in, int* out)
{
    auto __tmpIn = *in;
    auto __tmpOut = *out;
    for(int i = 0; i < 10; ++i)
        __tmpOut += __tmpIn;
    *out = __tmpOut;
    return *out;
}

Это ещё минус 9 чтений памяти, и минус 9 операций записи. Наконец, уберём ещё одну операцию чтения:

int easySum(int* in, int* out)
{
    auto __tmpIn = *in;
    auto __tmpOut = *out;
    for(int i = 0; i < 10; ++i)
        __tmpOut += __tmpIn;
    *out = __tmpOut;
    return __tmpOut;
}

Теперь, убрав всё лишнее чтение и запись из функции, становится очевидным (если это кому-то было не очевидно в самом начале), что цикл здесь совершенно не нужен:

int easySum(int* in, int* out)
{
    return *out += *in*10;
}

И всё: два чтения и одна запись. Неплохо мы сократили взаимодействие с памятью? Теперь вызовем нашу функцию:

int a = 1;
int b = 0;
std::cout << easySum(&a, &b);

Получим свою 10 и удалимся довольные. Стоп, автор, мы тут вроде про указатели, оптимизации и сложные правила говорим, чего ты нам суёшь примеры для первоклассников? Действительно, немного увлёкся, давайте вернёмся к нашей теме и вызовем функцию так:

std::cout << easySum(&a, &a);

Мы рассчитываем получить в ответе 2^10 = 1024, а получаем... 10! Выходит, что надев очки оптимизатора, мы забыли их протереть и превратили корректную функцию непонятно во что. Поэтому отматываем полностью нашу оптимизацию, и функция возвращается в свой первозданный вид — её нельзя оптимизировать, т.к. если первый и второй аргументы указывают на один и тот же объект, то всё сломается! Мы обязаны в каждой итерации читать *in и *out из памяти и ничего нельзя с этим сделать, из-за пресловутой возможности обоих указателей ссылаться на один и тот же объект.

Но если чуть-чуть изменить нашу функцию:

long easySum(int* in, long* out)
{
    for(int i = 0; i < 10; ++i)
        *out += *in;
    return *out;
}

То мы спокойно можем превращать её в одну строчку: return *out += *in*10;, потому что out и in больше не могут указывать на один и тот же объект — язык это запрещает. Вот вам и плоды использования ПСС в свою пользу. Но выполняют ли подобную оптимизацию компиляторы? Давайте проверим. Вот во что превращает clang нашу функции с int (я сократил количество итераций до 4, чтобы меньше кода было):

mov     eax, dword ptr [rsi]
add     eax, dword ptr [rdi]
mov     dword ptr [rsi], eax
add     eax, dword ptr [rdi]
mov     dword ptr [rsi], eax
add     eax, dword ptr [rdi]
mov     dword ptr [rsi], eax
add     eax, dword ptr [rdi]
mov     dword ptr [rsi], eax

А вот вариант с long:

movsxd  rax, dword ptr [rdi]
shl     rax, 2
add     rax, qword ptr [rsi]
mov     qword ptr [rsi], rax

Т.е. компилятор выполнил ровно то, что мы описали выше, воспользовавшись ПСС. У GCC ассемблер несколько иной, но принцип тот же. На MSVC, к сожалению, разницы никакой нет — не умеет он использовать это правила в целях оптимизации. Полагаю, можно считать, что когда компилируешь с помощью MSVC компиляция всегда происходит с флагом -fno-strict-aliasing. Разумеется, никаких официальных заявлений в документации вы не найдёте, но, на мой взгляд, пока не заявлено обратного, мы можем это предполагать. ПСС это действительно очень спорная тема, поэтому Micrisoft не пойдёт на то, чтобы молча добавить НП к целой куче существующего кода, не добавив ключа для компилятора.

Итак, мы рассмотрели простейшую и очень утрированную функцию, благодаря которой смогли легко проследить, что же мы можем выжать из ПСС. Конечно, мы сами себе создали проблему, после чего героически её решили: изначально in нужно было передавать по значению, а не указателем, т.к. никакого смысла в указателе там нет. И тогда эта функция была бы оптимизирована на любом компиляторе, включая MSVC.

Но хочется отметить некоторый интересный момент: многие новички, узнав что в C++ все параметры нужно передавать по константной ссылке, берутся за дело слишком рьяно и начинают даже интегральные типы передавать по ссылке! Так что если код с указателем и выглядит чрезвычайно натянутым, то вот такая функция вполне может появиться: int easySum(const int& in, int& out). Выглядит по-другому, а суть та же самая — компилятор не может оптимизировать эту функцию, т.к. обе ссылки могут указывать на один объект. Поэтому, да, пример натянутый, но не так сильно оторван от реальности, как может показаться.

Предлагаю рассмотреть ещё один пример. Предположим, у нас есть такая вот структура, описывающая четырёхмерный вектор:

struct Vector4d
{
    int x;
    int y;
    int z;
    int w;
};

Для которой есть следующая операция сложения со скаляром:

void sumVec(Vector4d& vec, const int& val)
{
    vec.x += val;
    vec.y += val;
    vec.z += val;
    vec.w += val;
}

А вызываем мы всё это так:

long scalar = 10;
Vector4d vec{0, 1, 2, 3};
sumVec(vec, scalar);

Я не буду расписывать все мысли по оптимизации пошагово, как мы это сделал в прошлый раз, просто ограничусь некоторыми соображениями. Итак, функция суммы это, в сущности, одна SIMD инструкция, которая сложит все четыре компоненты вектора со скаляром. Но что-то идёт не так и мы получаем какой-то такой ассемблер:

mov     eax, dword ptr [rsi]
add     dword ptr [rdi], eax
mov     eax, dword ptr [rsi]
add     dword ptr [rdi + 4], eax
mov     eax, dword ptr [rsi]
add     dword ptr [rdi + 8], eax
mov     eax, dword ptr [rsi]
add     dword ptr [rdi + 12], eax

Примерно тот же код мы имели в предыдущей функции суммы. Но почему компилятор не оптимизировал нашу функции, у нас же разные типы, значит он должен был воспользоваться ПСС и использовать более эффективный код! На самом деле компилятор не может оптимизировать эту функцию, потому что обе ссылки могут указывать на один объект. Причём этого можно добиться двумя разными способами. К примеру, мы могли бы вызвать функцию так: sumVec(vec, vec.x), или вот так (маловероятно): sumVec(vec, reinterpret_cast<int&>(vec)). Первый способ очевиден и не требует пояснений, а вот второй возможен благодаря шестому пункту правил, который явно разрешает указателю на сложный тип указывать на объект простого, если в составе сложного типа есть этот самый простой. Т.к. наша структура состоит из объектов типа int, то мы можем, имея Vector4d&, сослаться на объект типа int, приведя исходную ссылку к int&.

Как мы уже говорили ранее, этот пункт правил является весьма противоречивым, и он будет удалён из будущих версий стандарта C++ [1]. Про C пока сказать сложно, но дело в том, что подобная интерпретация данного пункта вряд ли является причиной, по которой компилятор не стал оптимизировать наш код. Скорее всего, причина как раз в том, что скаляр может ссылаться на один из членов структуры. Т.е. тот факт, что данный пункт пропадает из C++20 ничего для этой функции не меняет — она не может быть оптимизирована. В противном случае, она попросту будет сломана для таких случаев sumVec(vec, vec.x). Но, как и в прошлый раз, небольшая смена типа в сигнатуре функции всё меняет: void sumVec(Vector4d& vec, const long& val); после чего мы получаем вот такой ассемблер:

movdqu  xmm0, xmmword ptr [rdi]
movd    xmm1, dword ptr [rsi] 
pshufd  xmm1, xmm1, 0        
paddd   xmm1, xmm0
movdqu  xmmword ptr [rdi], xmm1

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

Очевидно, что пример снова натянутый и там изначально не должно было быть ссылки у val, так для чего я это всё показываю? Первое, чтобы вы на простейших примерах видели, на что способен оптимизатор, когда у него развязаны руки. Второе, чтобы лучше понять ситуации, в которых компилятор может быть уверен, что разные указатели не могут указывать на один объект, а в каких не может. И, наконец, третье, чтобы вы понимали, что скрывается за НП, когда мы говорим о нарушении ПСС. В мире C++ любят говорить, что из-за НП может происходить всё, что угодно, вплоть до стирания жёсткого диска, но подобное описание мало что говорит о технической стороне вопроса, что, в свою очередь, не позволяет адекватно анализировать риски и строить предположения. Тем более, что, как я уже говорил, правило ПСС нарушалось, нарушается и будет нарушаться сплошь и рядом, и вы должны самостоятельно и сознательно решить, хотите вы его нарушать или нет.

Заключение

На просторах данного блога мы уже не раз затрагивали указатели (и кто знает сколько раз ещё затронем?). Крупнейшими можно считать цикл по указателям и эту статью. Так как цикл был предназначен в первую очередь новичкам в C++, он написан с очень большим количеством допущений и прибегает к объяснению указателей через призму реального мира. Другими словами, объясняя концепцию указателей, я использовал реальную (но очень упрощённую) модель памяти, которая используется в x86 архитектуре. Это простой и наглядный метод, который используется многими людьми, которые пытаются объяснить указатели тем, кто их не понимает.

И не только указатели объясняется через какие-то существующие архитектуры — практически любая сложная абстракция объясняется через существующие решения. Взять, к примеру, те же барьеры памяти, которыми оперируют знатоки многопоточного кода, хотя C++ никакими барьерами не оперирует! Все подобные примеры объединяет одно: рассмотрение программы C++ через призму конкретной реализации. И здесь программист попадает в ловушку восприятия, которую мы отчасти и продемонстрировали в настоящей статье. Его учили конкретным воплощениям абстракций, но язык так не работает!

Язык C++ работает с абстрактной, несуществующей моделью (архитектурой), поэтому, когда пишется программа на C++, применять можно только те знания, которые соответствуют этой модели. Таким образом, при написании C++-программы нужно оперировать концепцией отношений (синхронизируется с, происходит до и т.п) и указателями, вместо барьеров и адресов. Только так можно писать корректные и переносимые программы.

Разумеется, я прекрасно понимаю, что существует целая плеяда реализаций, которые используют, к примеру, какие-то биты адреса, в который (и из которого) превращают указатель и не имею ничего против этого. Но это должен быть сознательный переход мышления. Т.е. нужно понимать и разделять, когда ты пишешь на C++, а когда пишешь под конкретную(-ые) архитектуру. И нужно стараться не смешивать эти два мышления в одном потоке, иначе каша в голове обеспечена. Т.е. мой посыл таков: всегда пишите согласно абстракции C++, спускаясь на уровень архитектуры только тогда, когда это действительно необходимо, и осознавая, что вы делаете. В противном случае, проблем не оберёшься.

В качестве бонуса предлагаю ознакомиться со статьей, посвящённой особенностям различных архитектур, существовавших в истории: «C Portability Lessons from Weird Machines»


[1] Даже при отсутствии этого конкретного пункта, новый стандарт может разрешать подобный код какой-то другой строчкой. Поэтому утверждать, что так нельзя будет делать начиная с C++20 я не буду.