4

Есть структура:

struct S {
        char   m0;
        double m1;
        short  m2;
        char   m3;
    };

Вот так работает:

std::cout << &(((S*)0)->m1) << std::endl;

Вот так кидает исключение нарушение прав доступа...:

std::cout << (((S*)0)->m1) << std::endl;

Почему так происходит? Ведь в первом подвыражении так же имеется обращение к невыделенной памяти.

Harry
  • 221,325

3 Answers3

4

Вообще-то во втором случае вы обращаетесь к памяти (смотрите на значение но некорректному адресу), а в первом — нет: вычисляется адрес поля, т.е. просто выполняется простая арифметика (с учетом выравнивания), без реального обращения к памяти.

Не знаю, насколько это поведение корректно (нет ли какого UB с точки зрения стандарта), но на практике — это просто вычисление offsetof для поля, ничем не угрожающее.

Из stddef.h в VC++:

#if defined _MSC_VER && !defined _CRT_USE_BUILTIN_OFFSETOF
    #ifdef __cplusplus
        #define offsetof(s,m) ((::size_t)&reinterpret_cast<char const volatile&>((((s*)0)->m)))
    #else
        #define offsetof(s,m) ((size_t)&(((s*)0)->m))
    #endif
#else
    #define offsetof(s,m) __builtin_offsetof(s,m)
#endif

Сомневаюсь, что найдется компилятор, который реально будет выполнять разыменование в данной ситуации...

Harry
  • 221,325
  • 2
    UB есть - там есть оператор стрелка, а это разыменование указателя вникуда, которое по определению UB – gbg Dec 25 '21 at 11:55
  • @gbg ну такая конструкция используется в макросе offsetof – Barracudach Dec 25 '21 at 11:59
  • @Barracudach - про этот макрос на cppreference написано столько компромата, что лично я его просто не применяю. – gbg Dec 25 '21 at 12:01
  • 2
    Фразы offsetof cannot be implemented in standard C++ and requires compiler support уже достаточно для того, чтобы исключить данный костыль из своей практики. – gbg Dec 25 '21 at 12:04
  • Конечно, все равно не понятно, как это может работать. При разыменовывании у нас получается lvalue, которое имеет адрес и значение, а значение не может быть высчитано из неопределенного адреса. Единственное как я могу это понять для себя - выражение оптимизируется компилятором, и дело не доходит дальше оперирования адресами. Но все равно сбивает с толку синтаксис, противоречащий стандарту. – Barracudach Dec 25 '21 at 12:16
  • Да нигде это значение не используется. Вы что, хотите сказать, что значение &(((S*)0)->m1) зависит от того, что находится в памяти ((S*)0)->m1? Эдак вы скажете, что в int n = 5; int * p = &n; адрес в переменной p зависит от значения в переменной n... – Harry Dec 25 '21 at 12:20
  • @gbg Пересмотрел еще раз https://en.cppreference.com/w/cpp/types/offsetof — что-то особой ругани не заметил... – Harry Dec 25 '21 at 14:03
  • А offsetof это все-таки специальная конструкция, определенная стандартом. Так что не стоит надеятся, что похожий собственный код будет работать так же. Тем более, что обычно используется специальный встроенный оператор. – user7860670 Dec 25 '21 at 14:54
  • @user7860670 В данном случае это ответ на комментарий исключить данный костыль из своей практики. – Harry Dec 25 '21 at 14:55
4

При вычислении выражение вида E1->E2 преобразуется в эквивалентную форму (*(E1)).E2. expr.ref / 2:

The expression E1->E2 is converted to the equivalent form (*(E1)).E2;

Получаем, что выражение ((S*)0)->m1 интерпретируется как выражение ( *((S*)0) ).m1. Т.е. здесь происходит разыменование нулевого указателя объектного типа, что является неопределённым поведением. Данное утверждение основано на следующих пунктах стандарта языка.

expr.unary.op / 1:

The unary * operator performs indirection: the expression to which it is applied shall be a pointer to an object type, or a pointer to a function type and the result is an lvalue referring to the object or function to which the expression points.

В результате разыменования указателя получается lvalue ссылающееся на объект, но нулевой указатель не указывает ни на какой объект.

dcl.ref / Note 2:

In particular, a null reference cannot exist in a well-defined program, because the only way to create such a reference would be to bind it to the “object” obtained by indirection through a null pointer, which causes undefined behavior.

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

Связанные вопросы на enSO:


Таким образом поведение обоих выражений &(((S*)0)->m1) и (((S*)0)->m1) не определено, т.к. их вычисления требует разыменования нулевого объектного указателя. И при этом не важно, что компилятор в процессе оптимизации может избежать фактического обращения к «объекту», на который указывает указатель.

Стандарт языка не предъявляет требований к неопределённому поведению. Программа может успешно компилироваться и проявлять ожидаемое поведение, либо завершаться с ошибкой в процессе работы, либо может наблюдаться какое-либо иное поведение.


На самом деле ситуация с разыменованием указателя, не указывающего на объект, немного сложнее. Взглянем на следующий пример:

char a[10];
//Т.к. a[10] эквивалентно *(a+10), то UB - разыменовали указатель на гипотетический элемент за последним элементом массива.
char *b = &a[10];

Между этим примером и примером из вопроса

std::cout << &(((S*)0)->m1) << std::endl;

есть определённое сходство — фактически, значения «объектов», на которые указывают указатели не важны.

Здесь &a[10], нам не важно, какое значение находится за последним элементом массива — нам нужен указатель на элемент за последним элементом массива. Аналогично здесь &(((S*)0)->m1) нам не важно конкретное значение поля m1, нас интересует указатель.

Было бы неплохо доопределить поведение приведённых фрагментов кода в соответствии с интуитивными ожиданиями.

Более того в стандарте языка есть некоторая несогласованность. В описании оператора typeid определяется результат разыменования нулевого указателя. expr.typeid / 3:

When typeid is applied to a glvalue whose type is a polymorphic class type, the result refers to a std​::​type_­info object representing the type of the most derived object (that is, the dynamic type) to which the glvalue refers. If the glvalue is obtained by applying the unary ``* operator to a pointer57 and the pointer is a null pointer value, the typeid expression throws an exception of a type that would match a handler of type std​::​bad_­typeid exception.

Было предпринято несколько попыток легализовать разыменование указателя, не указывающего на объект в тех случаях, когда фактически не требуется доступ к значению.

В языке C операторы & и * в некоторых контекстах являются аннигилирующими по отношению друг к другу (см.: тонкости указателя на массив).

В языке C++ пытались ввести особую разновидность lvalue — empty lvalue, но эти попытки так и остались на стадии черновика (см.: Is indirection through a null pointer undefined behavior?).


Есть одна ситуация, когда стандарт языка требует довольно строгую проверку от реализаций на наличие неопределённого поведения в выражениях — это константные выражения.

Рассмотрим следующий код:

#include <iostream>

struct S { int m; };

int main() { static S s; constexpr const int* p = &(((S*) &s )->m); std::cout << p; }

Данный код успешно компилируется и выполняется (g++, clang).

Но если попытаться разыменовать нулевой указатель (даже если фактический доступ к значению не требуется), то получим ошибку компиляции.

constexpr const int* p = &(((S*) 0 )->m);

g++:

error: dereferencing a null pointer in '*0' 
constexpr const int* p = &(((S*) 0 )->m);

clang:

error: constexpr variable 'p' must be initialized by a constant expression
constexpr const int* p = &(((S*) 0 )->m);

note: cannot access field of null pointer constexpr const int* p = &(((S*) 0 )->m);

wololo
  • 6,221
3

Как это часто бывает в случаях, когда поведение не определено, ошибкой тут является само ожидание какого-то определенного результата. Нет даже оснований считать, что конструкция вида ((S*)0)->m1 обязана приводить в программе к разыменования нулевого адреса. Это разыменование присутствует в коде, а вот чем оно обернется в программе - это неизвестно. Оно может быть выполнено, а может быть пропущено (так как компилятор в праве предполагать, что в программе не бывает разыменования нулевых указателей), а может поломать кодогенерацию и приводить к совсем неожиданным последствиям.

user7860670
  • 29,796
  • А кстати, в стандарте сказано где-то, что 0 и NULL — это одно и то же? Или nullptr и 0? Нулевой адрес — это NULL/nullptr, а не 0, вроде же так? И, кстати, значит, в выражении &((S*)1234)->m1 все в порядке :) – Harry Dec 25 '21 at 14:13
  • @Harry, 0 и nullptr не одно и то же. Но преобразование выражений 0, NULL и nullptr в указательный тип формирует нулевое указательное значение (null pointer value). – wololo Dec 25 '21 at 14:16
  • @Harry Нет, 0 - это целочисленный литерал типа int и он может использоваться в качестве значения null pointer, nullptr - это уже литерал типа std::nullptr_t, а NULL - это вообще макрос. Хотя в этом контексте они все будут взаимозаменемы по идее. – user7860670 Dec 25 '21 at 14:17
  • @wololo А, ну, значит, в ((S*)1234)->m1 все нормально? Здесь же нет разыменования нулевого указательного значения? – Harry Dec 25 '21 at 14:17
  • @Harry, если указатель (S*)1234 не указывает ни на какой объект, то это также UB. Сможет ли компилятор понять куда указывает такой указатель — зависит только от фантазии разработчиков компилятора. А с учётом того, что функция отображения между целочисленными типами и указателями implementation-defined, то я бы таких штук (S*)1234 избегал. – wololo Dec 25 '21 at 14:22
  • @wololo Тут наверное будет UB даже если (S*)1234 действительно указывает на экземпляр живого объекта S, так как не использован std::launder – user7860670 Dec 25 '21 at 14:31