Почему обращение к нулевой памяти в выражении не дает исключение?
Есть структура:
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 шт):
Вообще-то во втором случае вы обращаетесь к памяти (смотрите на значение но некорректному адресу), а в первом — нет: вычисляется адрес поля, т.е. просто выполняется простая арифметика (с учетом выравнивания), без реального обращения к памяти.
Не знаю, насколько это поведение корректно (нет ли какого 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
Сомневаюсь, что найдется компилятор, который реально будет выполнять разыменование в данной ситуации...
Как это часто бывает в случаях, когда поведение не определено, ошибкой тут является само ожидание какого-то определенного результата. Нет даже оснований считать, что конструкция вида ((S*)0)->m1 обязана приводить в программе к разыменования нулевого адреса. Это разыменование присутствует в коде, а вот чем оно обернется в программе - это неизвестно. Оно может быть выполнено, а может быть пропущено (так как компилятор в праве предполагать, что в программе не бывает разыменования нулевых указателей), а может поломать кодогенерацию и приводить к совсем неожиданным последствиям.
При вычислении выражение вида E1->E2 преобразуется в эквивалентную форму (*(E1)).E2. expr.ref / 2:
The expression
E1->E2is converted to the equivalent form(*(E1)).E2;
Получаем, что выражение ((S*)0)->m1 интерпретируется как выражение ( *((S*)0) ).m1. Т.е. здесь происходит разыменование нулевого указателя объектного типа, что является неопределённым поведением. Данное утверждение основано на следующих пунктах стандарта языка.
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 anlvaluereferring to the object or function to which the expression points.
В результате разыменования указателя получается lvalue ссылающееся на объект, но нулевой указатель не указывает ни на какой объект.
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:
- Is null reference possible?
- C++ standard: dereferencing NULL pointer to get a reference?
- Is dereferencing a NULL pointer considered unspecified or undefined behaviour?
- The apparent underspecification of one-past-the-end subscripting: for both raw arrays and std::vector. Has it been resolved decisively already?
Таким образом поведение обоих выражений &(((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
typeidis applied to a glvalue whose type is a polymorphic class type, the result refers to astd::type_infoobject 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, thetypeidexpression throws an exception of a type that would match a handler of typestd::bad_typeidexception.
Было предпринято несколько попыток легализовать разыменование указателя, не указывающего на объект в тех случаях, когда фактически не требуется доступ к значению.
В языке 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);
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);