Разыменование nullptr без фактического обращения - почему нельзя?

Собственно, вот код:

#include <stdio.h>

#define OFFSET_OF(TData, field) ((char*)&(((TData*)nullptr)->field) - (char*)nullptr)


struct TEST_STRUCT
{
    char    field0;
    int     field1;
    double  field2;
    char    field3;
};


int main()
{
    printf(
        "offset of field1: %zu\n"
        "offset of field2: %zu\n"
        "offset of field3: %zu\n",
        OFFSET_OF(TEST_STRUCT, field1),
        OFFSET_OF(TEST_STRUCT, field2),
        OFFSET_OF(TEST_STRUCT, field3)
    );

    return 0;
}

Компилируем, запускаем:

offset of field1: 4
offset of field2: 8
offset of field3: 16

Дизассемблируем, смотрим, что сгенерировал компилятор:

.text:0000000140003370 ; =============== S U B R O U T I N E =======================================
.text:0000000140003370
.text:0000000140003370
.text:0000000140003370 ; int __cdecl main(int argc, const char **argv, const char **envp)
.text:0000000140003370                 public main
.text:0000000140003370 main            proc near               ; CODE XREF: __tmainCRTStartup+2EB↑p
.text:0000000140003370                                         ; DATA XREF: .pdata:00000001400072A0↓o
.text:0000000140003370                 sub     rsp, 28h
.text:0000000140003374                 call    __main
.text:0000000140003379                 mov     r9d, 16
.text:000000014000337F                 mov     r8d, 8
.text:0000000140003385                 mov     edx, 4
.text:000000014000338A                 lea     rcx, aOffsetOfField1 ; "offset of field1: %zu\noffset of field2"...
.text:0000000140003391                 call    printf
.text:0000000140003396                 xor     eax, eax
.text:0000000140003398                 add     rsp, 28h
.text:000000014000339C                 retn
.text:000000014000339C main            endp
.text:000000014000339C
.text:000000014000339C ; ---------------------------------------------------------------------------

Видим, что всё работает как надо. Компилятор, как и ожидалось, предвычислил значения смещений на этапе компиляции. Никакого явного UB не произошло.

Однако в моём предыдущем вопросе один не особо дружелюбный пользователь назвал такой код вакханалией и бредом. Мол, тут разыменуется nullptr, и по-этому так делать нельзя. Однако, хоть разыменование и происходит, но оно ведь тут чисто формальное! Реального обращение к памяти через nullptr тут ведь не происходит! Более того, код данного макроса выполняется ещё на этапе компиляции, и по факту его результат - константы!

Соответственно, у меня вопрос: почему так нельзя делать? Если такая конструкция работает и не приводит к UB, значит ответ прост - так можно делать. Если же такой код небезопасен, то просьба объяснить, чем именно. Приведите, пожалуйста, пример кода, где выполнение конструкции ((char*)&(((TData*)nullptr)->field) - (char*)nullptr) приведёт к реальному UB!

P.S. Я знаю, что существует "стандартный" макрос offsetof, однако мой вопрос не о нём.


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

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

"UB" - не то же самое, что "код не работает".

пример кода, где ... приведёт к реальному UB!

UB - это то, про что в стандарте написано, что это UB (либо то, про что в стандарте вообще ничего не написано - но это редкость).

Из этого следует, что наличие UB нельзя определить, глядя на вывод программы или в дизассемблер. Нужно смотреть в стандарт, а там написано вот что:

[expr.unary.op]/1

... If the operand points to an object or function, the result denotes that object or function; otherwise, the behavior is undefined except as specified in [expr.typeid].

То есть неважно, используется результат или нет - все равно UB. (Единственное место, где это можно делать без UB - в аргументе typeid(...)).


Почему в стандарте так написано? Предполагаю, что потому, что в компиляторах есть оптимизации, и для целей оптимизации удобно считать, что UB никогда не происходит (компилятор может не заходить в ветку if, в которой видит разыменовывание нулевого указателя, экономя на проверке условия, и т.п.).

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


Можно ли так делать на практике?

Пытаться оправдывать UB есть смысл, только когда оно дает какие-то новые возможности. Когда есть стандартный offsetof(...), незачем оправдывать свою реализацию.

Даже если на вашем компиляторе это вроде работает, вы уверены, что это не сломается в каком-то другом месте кода, при обновлении или смене компилятора, или при изменении флажков?

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


Если хотите реальные примеры, то вот. Не совсем то же самое, но похоже. При обновлении на GCC 6 сломались Хром, Qt и KDevelop, потому что GCC стал выбрасывать проверки this != nullptr, потому что вызов метода на нулевом указателе - UB, даже если никакие поля оттуда не читаются.

Ну и: (но это не столько про UB, сколько про недостаточные проверки в макросе)

#include <iostream>

#define OFFSET_OF(TData, field) ((char*)&(((TData*)nullptr)->field) - (char*)nullptr)

struct A {int field;};
struct B : virtual A {};

int main()
{
    std::cout << OFFSET_OF(B, field) << '\n';
}

На GCC ошибка компиляции, на Clang - краш.

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

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

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

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

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

→ Ссылка