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

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

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

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

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

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

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

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


Ответы (3 шт):

Автор решения: Harry

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

Не знаю, насколько это поведение корректно (нет ли какого 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

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

→ Ссылка
Автор решения: user7860670

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

→ Ссылка
Автор решения: wololo

При вычислении выражение вида 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);
→ Ссылка