5

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

#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, однако мой вопрос не о нём.

LShadow77
  • 2,157
  • 1
    Я всегда так делаю и это работает (значит можно, а если в каком-нибудь компайлере нельзя, значит просто не нужно такой компайлер использовать) – avp Dec 30 '23 at 16:17
  • Можно жить с UB если в документации на конкретный компилятор сказано "это UB из стандарта мы доопределяем так то". Или если вы проверили сгенерированный код и убедились что он соответствует вашим ожиданиям. Это нормально, если каждый раз когда вы компилируете код, вы проверяете результат. – Stanislav Volodarskiy Dec 30 '23 at 16:36
  • 1
    Почему обращение к нулевой памяти в выражении не дает исключение? Вот пример кода, где использование конструкции &(((TEST_STRUCT*)nullptr)->field) приводит к некоторым проблемам. Даже если игнорировать разыменование нулевого указателя, есть ещё одна проблема: вычисление разности двух указателей, которые не указывают на элементы одного массива (или гипотетический элемент за последним), и не являются оба нулевыми, что также вызывает UB. – wololo Dec 30 '23 at 18:58
  • 2
    P.S. Разность двух указателей имеет тип std::ptrdiff_t, но в функции printf используется спецификатор преобразования %u, что снова вызывает UB. Лучше использовать %td. P.P.S. При вычислении разности двух указателей в некоторых экстремальных случаях возможно переполнение типа std::ptrdiff_t, что опять таки есть UB. – wololo Dec 30 '23 at 18:58
  • @wololo я на это, кстати, обратил внимание, и первоначально использовал %zu. Но меня сбило то, что аргументы записываются в младшие 32 бит регистров. Как-то упустил тот факт, что в x64 запись в младшую часть регистров обнуляет старшую. Бывает, особенно когда лежишь с температурой 38+)) – LShadow77 Dec 30 '23 at 20:11
  • @wololo Интересно. GCC не дает скомпилировать такой жирный объект, а кланг - не против... – HolyBlackCat Dec 31 '23 at 12:09
  • @wololo "При вычислении разности двух указателей в некоторых экстремальных случаях возможно переполнение типа std::ptrdiff_t" - ну это проблема не переполнения std::ptrdiff_t, а проблема переполнения разрядной сетки в целом. Но честно, я не могу представить себе такие монструозные структуры в реальной жизни)) На практике, их даже не получится использовать, т.к. попытка выделить под них память приведёт к исключению bad_alloc. И кстати, я однажды объявил структуру не такую экстремально большую, но в ней тоже был массив на несколько сотен мегабайт. Так мой MinGW завис при компиляции)) – LShadow77 Dec 31 '23 at 18:12
  • @LShadow77, а вообще-то, конечно, можно и не ломать копья, а использовать встроенный offsetof (думаю, это вполне устроит всех пуристов) – avp Dec 31 '23 at 19:01
  • @avp проблема в том, что мне надо использовать offsetof для поля-параметра, который передаётся шаблону. Её суть в этом вопросе: https://stackoverflow.com/questions/22359535/gcc-can-i-use-offsetof-with-templated-pointer-to-member. В ответе рекомендовали разыменование nullptr! И это, на мой взгляд, показательный пример того, как разработчики стандарта сами толкают программистов на нарушения их правил и внедрение UB в свой код. К слову, раз в стандарт добавили "указатели" на поля, то могли бы разрешить приводить их к size_t - получилась бы отличная замена offsetof. Но нет, не их путь! – LShadow77 Jan 01 '24 at 06:50
  • @LShadow77, imho вы на правильном пути, ведущем вообще к отказу от С++ ... – avp Jan 01 '24 at 13:09
  • @avp я может и отказался бы, да только есть ли альтернатива?)) Вот появился бы эдакий Че Гевара от мира кодинга, пришёл бы к разработчикам стандарта и устроил революцию, реформировал систему, отменил все UB...))) Но кроме шуток... – LShadow77 Jan 01 '24 at 14:02
  • @LShadow77, если кроме шуток, то я просто пишу на Си программы, которые решают конкретные задачи для конкретной аппаратуры (и при случае делюсь своими знаниями) – avp Jan 01 '24 at 14:08

2 Answers2

6

"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 - краш.

HolyBlackCat
  • 27,445
  • 3
  • 27
  • 40
  • 1
    А мне кажется, что эти многочисленные UB просто признание разработчиков в своей криворукости, невозможности разобраться во внутреннем устройстве сделанного ими монстра под названием С++... – avp Dec 30 '23 at 16:46
  • @avp Вот, у меня складывается такое же мнение. Причём очень многие UB декларированы в стандарте по-сути искусственно, отрезая программистам хорошие возможности для оптимизации, в том числе процессоро-ориентированной. Многие возможности искусственно ограничены (тот же offsetof, или VLA не приняты стандартом) .Причём компилятор, как правило, никак не уведомляет программиста о том, что он нарушил очередной запрет, который разрабы стандарта добавили в свой многотомный талмуд! Я слышал мысль, что любой серьёзный проект на C++ содержит UB, о которых не догазыватся разработчики... – LShadow77 Dec 30 '23 at 19:56
  • 2
    @LShadow77 Все эти многочисленные UB (разыменование нулевого указателя, разность указателей, не указывающих на элементы одного массива, переполнение ptrdiff_t, некорректное использование спецификаторов преобразования в printf, узкоспециализированный offsetof и т.д. и т.п.) есть тяжелейшее наследство, пришедшее из языка C. – wololo Dec 30 '23 at 20:02
  • 1
    @wololo а разработчики стандарта языка C++, получается, это наследство только усугубляют всевозможными запретами, игнорирование которых, однако, в 99% случаев приводит к работоспособному хорошо оптимизированному коду. Но в какой-то момент - бац!.. и новая версия компилятора этот код ломает. – LShadow77 Dec 30 '23 at 20:26
  • 2
    @LShadow77 Чего они там усугубили? Все UB, которые я указал в комментариях к вопросу, есть и в C. Скоро пол века будет, как стандартизаторы C не могут их устранить. – wololo Dec 30 '23 at 20:41
  • 1
    @wololo, десятки лет (пока в дело не включилось новое поколение разработчиков, воспитанных на С++) эти UB в Си никак себя не проявляли, будучи вполне легальными фичами языка – avp Dec 30 '23 at 21:19
  • @LShadow77 "никак не уведомляет программиста о том, что он нарушил очередной запрет" В идеальном мире нужно на каждый пул-реквест собирать билд с санитайзерами и гонять тесты (-fsanitize=address -fsanitize=undefined -D_GLIBCXX_DEBUG, или что-то подобное). Тогда, например, на ваш макрос будет красивый ворнинг в рантайме. "отрезая программистам хорошие возможности для оптимизации" Примеры бы не помешали... – HolyBlackCat Dec 31 '23 at 04:24
  • @wololo Про printf у современных компиляторов отличные ворнинги, если сделать их ошибками - проблем никогда не будет. Про offsetof - что вы имеете в виду? Что его не на всех классах можно использовать? (Тут согласен, это муть какая-то. Все компиляторы его принимают на всех классах, только иногда нужно ворнинги выключать.) Про ptrdiff_t - а что с ним? – HolyBlackCat Dec 31 '23 at 05:10
  • @LShadow77 Я даже рад, что VLA не приняли. В том виде, в котором они есть в С, они добавляют очень много сложности. Они только кажутся простой фичей. https://gcc.godbolt.org/z/8dd6s7nT5 В каком-то более простом виде - да, было бы полезно. Но в любом случае можно руками звать alloca(). – HolyBlackCat Dec 31 '23 at 05:15
  • Про offsetof - что вы имеете в виду? Что его не на всех классах можно использовать? Да. Про ptrdiff_t - а что с ним? Если разность двух указателей не представима типом ptrdiff_t, то UB. Я в общем-то не оправдываю обилие UB в C/C++ (как раз наоборот, я за то бы их все вырезали по максимуму везде, где только можно) и даже не ваш ответ критикую. Я возражаю против нападок в комментариях на C++ в данном конкретном случае. Да, в C++ есть свои специфичные UB, но обсуждаемые в данном вопросе UB были уже как минимум в C89: ... – wololo Dec 31 '23 at 10:46
  • ... If an invalid value has been assigned to the pointer, the behavior of the unary * operator is undefined... Among the invalid values for dereferencing a pointer by the unary * operator are a null pointer... Permissible undefined behavior ranges from ignoring the situation completely with unpredictable results... Никаких послаблений на случай, когда фактическое обращение к объекту не требуется, никаких послаблений на случай, когда вычисления можно провести во время компиляции. Максимально жёсткие формулировки. Сколько на тот момент лет ещё оставалось до начала стандартизации C++? – wololo Dec 31 '23 at 10:46
  • @avp десятки лет эти UB в Си никак себя не проявляли, будучи вполне легальными фичами языка. Десятки лет от стандарта к стандарту вас предупреждают об опасности таких языковых конструкций. Но вы почему-то решили, что раз все предыдущие годы никаких последствий на практике не было (а их точно не было?..), то так будет и впредь. Не вижу предпосылок для таких выводов. – wololo Dec 31 '23 at 11:18
  • @wololo, `Among the invalid values for dereferencing a pointer by the unary operator are a null pointer`* -- надо быть законченным формалистом, оторванным от реальности (это я скорее не о вас, а об авторах стандартов), чтобы использовать *dereferencing* в вычислении offset_of (более общо -- в вычислении адреса). Что же касается ptrdiff_t (и заодно уж других signed -- ssize_t и off_f), то проблемы есть, с самого начала мне они представлялись какими-то убогими (а значит не нужными). – avp Dec 31 '23 at 12:05
  • @avp про язык Си я добавлю, что с самого начала он позиционировался, как язык т.н. среднего уровня, сочетающий в себе удобство языка высокого уровня и низкоуровневые возможности, характерные для ассемблера. Отсюда и такая свобода в переопределении типов указателей и наличие пространства для разного рода трюков. А С++ появился, как всего-лишь улучшенная версия С, поддерживающая ООП. Да, у такой свободы есть проблема - ответственность программиста за свои ошибки. Но это проблема криворукости программиста, а не языка. Но вот пришли разработчики стандарта, и эту свободу убили на корню! – LShadow77 Dec 31 '23 at 17:48
  • @HolyBlackCat вот тут я реализовал новый вариант со множественным наследованием: https://disk.yandex.ru/d/EQHGqI4T0UyBbw. Плюс переделал иерархию, добавил итератор и функцию сортировки. Буду признателен, если проверите. А то с этими UB из коробки я как-то подрастерял уверенности (надеюсь, это пройдёт). – LShadow77 Jan 03 '24 at 19:19
  • 1
    @LShadow77 Ух, много кода... UB вроде нет (если работает и не падает с -fsanitize=address -fsanitize=undefined, я бы не беспокоился). В class NodePointer можно выкинуть operator= и operator!= и все равно будет работать так же (второе автоматически через ==, первое через аналогичные конструкторы). На будущее, лучше кладите на гитхаб или pastebin, так удобнее... – HolyBlackCat Jan 04 '24 at 03:46
  • @HolyBlackCat, большое спасибо за неоценимую помощь! Буду развивать дальше... – LShadow77 Jan 04 '24 at 06:47
1

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

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

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

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

user7860670
  • 29,796
  • Комментарии были перемещены в чат; пожалуйста, не продолжайте дискуссию здесь. Прежде чем разместить комментарий ниже этого, пожалуйста, ознакомьтесь с назначением комментариев. Комментарии, которые не запрашивают уточнения или не предлагают улучшения, скорее всего должны быть ответами, размещены на [meta] или написаны в [chat]. Комментарии, продолжающие дискуссию, могут быть удалены. – aepot Jan 01 '24 at 16:37