Когда вызов функции-члена в экземпляре NULL приводит к неопределенному поведению?

120

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

#include <iostream>

struct foo
{
    // (a):
    void bar() { std::cout << "gman was here" << std::endl; }

    // (b):
    void baz() { x = 5; }

    int x;
};

int main()
{
    foo* f = 0;

    f->bar(); // (a)
    f->baz(); // (b)
}

Мы ожидаем (b)сбоя, потому что xдля нулевого указателя нет соответствующего члена . На практике (a)не вылетает, потому что thisуказатель никогда не используется.

Поскольку (b)разыменование thisуказателя ( (*this).x = 5;) thisявляется нулевым, программа переходит в неопределенное поведение, поскольку разыменование нулевого значения всегда считается неопределенным поведением.

Приводит ли (a)к неопределенному поведению? Что если обе функции (и x) статичны?

GManNickG
источник
Если обе функции статичны , как можно указать x внутри baz ? (x - нестатическая переменная-член)
legends2k
4
@ legends2k: Притворство xтоже стало статичным. :)
GManNickG
Конечно, но для случая (а) он работает одинаково во всех случаях, т.е. функция вызывается. Однако при замене значения указателя с 0 на 1 (скажем, через reinterpret_cast) он почти всегда дает сбой. Представляет ли присвоение значения 0 и, следовательно, NULL, как в случае a, что-то особенное для компилятора? Почему он всегда вылетает из-за любого другого присвоенного ему значения?
Сиддхарт Шанкаран
5
Интересно: выйдет следующая ревизия C ++, разыменования указателей больше не будет. Теперь мы выполним косвенное обращение через указатели. Чтобы узнать больше, выполните косвенное обращение
Джеймс МакНеллис
3
Вызов функции-члена для нулевого указателя всегда является неопределенным поведением. Просто взглянув на ваш код, я уже чувствую, как неопределенное поведение медленно ползет по моей шее!
fredoverflow

Ответы:

113

Оба (a)и (b)приводят к неопределенному поведению. Вызов функции-члена через нулевой указатель всегда является неопределенным. Если функция статическая, она также технически не определена, но есть некоторые споры.


Прежде всего необходимо понять, почему разыменование нулевого указателя является неопределенным. В C ++ 03 здесь действительно есть некоторая двусмысленность.

Хотя «разыменование нулевого указателя приводит к неопределенному поведению» упоминается в примечаниях как в §1.9 / 4, так и в §8.3.2 / 4, это никогда не указывается явно. (Примечания не являются нормативными.)

Однако можно попытаться вывести его из §3.10 / 2:

Lvalue относится к объекту или функции.

При разыменовании результатом является lvalue. Нулевой указатель не относится к объекту, поэтому, когда мы используем lvalue, мы имеем неопределенное поведение. Проблема в том, что предыдущее предложение никогда не формулируется, так что же значит «использовать» lvalue? Просто даже сгенерировать его вообще или использовать в более формальном смысле для преобразования lvalue-to-rvalue?

Тем не менее, его определенно нельзя преобразовать в rvalue (§4.1 / 1):

Если объект, на который ссылается lvalue, не является объектом типа T и не является объектом типа, производного от T, или если объект неинициализирован, программа, которая требует этого преобразования, имеет неопределенное поведение.

Здесь определенно неопределенное поведение.

Неопределенность возникает из-за того, является ли неопределенное поведение уважительным, но не использует ли значение из недопустимого указателя (то есть получить lvalue, но не преобразовать его в rvalue). Если нет, то int *i = 0; *i; &(*i);это хорошо определено. Это активный вопрос .

Итак, у нас есть строгое представление «разыменовать нулевой указатель, получить неопределенное поведение» и слабое представление «использовать разыменованный нулевой указатель, получить неопределенное поведение».

Теперь рассмотрим вопрос.


Да, (a)приводит к неопределенному поведению. Фактически, если thisимеет значение null, то независимо от содержимого функции результат не определен.

Это следует из §5.2.5 / 3:

Если E1имеет тип «указатель на класс X», то выражение E1->E2преобразуется в эквивалентную форму(*(E1)).E2;

*(E1)приведет к неопределенному поведению со строгой интерпретацией и .E2преобразует его в rvalue, делая неопределенное поведение для слабой интерпретации.

Отсюда также следует, что это неопределенное поведение непосредственно из (§9.3.1 / 1):

Если нестатическая функция-член класса X вызывается для объекта, который не относится к типу X или типу, производному от X, поведение не определено.


Что касается статических функций, разница между строгой и слабой интерпретацией. Строго говоря, это не определено:

На статический член можно ссылаться с использованием синтаксиса доступа к члену класса, и в этом случае вычисляется объект-выражение.

То есть он оценивается так же, как если бы он был нестатическим, и мы снова разыменовываем нулевой указатель с помощью (*(E1)).E2.

Однако, поскольку E1он не используется в вызове статической функции-члена, если мы используем слабую интерпретацию, вызов будет четко определен. *(E1)приводит к lvalue, статическая функция разрешается, *(E1)отбрасывается и функция вызывается. Преобразование lvalue-to-rvalue отсутствует, поэтому неопределенное поведение отсутствует.

В C ++ 0x, начиная с n3126, неоднозначность сохраняется. А пока будьте осторожны: используйте строгую интерпретацию.

GManNickG
источник
5
+1. Продолжая педантизм, согласно «слабому определению» нестатическая функция-член не вызывается «для объекта, не относящегося к типу X». Он был вызван для lvalue, которое вообще не является объектом. Таким образом, предлагаемое решение добавляет текст «или, если lvalue является пустым lvalue», в предложение, которое вы цитируете.
Стив Джессоп,
Не могли бы вы немного уточнить? В частности, каковы номера проблем с вашими ссылками «закрытая проблема» и «активная проблема»? Кроме того, если это закрытая проблема, каков именно ответ «да» / «нет» для статических функций? Я чувствую, что упускаю последний шаг в попытке понять ваш ответ.
Brooks Moses,
4
Я не думаю, что дефект 315 CWG настолько "закрыт", как предполагает его присутствие на странице "закрытые вопросы". Обоснование гласит, что это должно быть разрешено, потому что « *pне является ошибкой, если значение pnull, если lvalue не преобразовано в rvalue». Однако это основано на концепции «пустого lvalue», которая является частью предложенного решения по дефекту 232 CWG , но не была принята. Таким образом, с языком как C ++ 03, так и C ++ 0x разыменование нулевого указателя все еще не определено, даже если нет преобразования lvalue-to-rvalue.
Джеймс МакНеллис
1
@JamesMcNellis: Насколько я понимаю, если бы pбыл аппаратный адрес, который запускал бы какое-то действие при чтении, но не был объявлен volatile, оператор *p;не потребовался бы, но ему было бы разрешено фактически прочитать этот адрес; заявлению &(*p);, однако, это будет запрещено. Если бы *pбыли volatile, то чтение потребовалось бы. В любом случае, если указатель недействителен, я не вижу, как первый оператор не будет неопределенным поведением, но я также не понимаю, почему второй оператор будет.
supercat
1
«.E2 преобразует его в rvalue», - Э, нет, это не так
MM
30

Очевидно, что undefined означает, что он не определен , но иногда это можно предсказать. На информацию, которую я собираюсь предоставить, никогда нельзя полагаться на рабочий код, поскольку это, конечно, не гарантируется, но может пригодиться при отладке.

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

Функция Microsoft MFC GetSafeHwnd фактически полагается на это поведение. Я не знаю, что они курили.

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

Марк Рэнсом
источник
1
GetSafeHwnd сначала выполняет эту проверку и, если это правда, возвращает NULL. Затем он начинает фрейм SEH и разыменовывает указатель. если есть нарушение доступа к памяти (0xc0000005), это перехватывается, и вызывающей стороне возвращается NULL :) В противном случае возвращается HWND.
Петър Петров 08
@ ПетърПетров прошло довольно много лет с тех пор, как я смотрел код GetSafeHwnd, возможно, с тех пор они его улучшили. И не забывайте, что у них есть инсайдерские знания о работе компилятора!
Марк Рэнсом
Я заявляю пример возможной реализации, которая имеет тот же эффект, что на самом деле делает, так это реконструировать с помощью отладчика :)
Петър Петров 08
1
"у них есть инсайдерские знания о работе компилятора!" - причина вечных проблем для таких проектов, как MinGW, которые пытаются разрешить g ++ скомпилировать код, вызывающий Windows API
ММ,
@MM Думаю, мы все согласимся, что это несправедливо. И из-за этого я также думаю, что есть закон о совместимости, который делает его немного незаконным.
v.oddou