Некоторым этот банальный вопрос уже набил оскомину, но мы взяли 7 примеров и попытались объяснить их поведение при помощи стандарта:
struct A {
int data_mem;
void non_static_mem_fn() {}
static void static_mem_fn() {}
};
void foo(int) {}
A* p{nullptr};
/*1*/ *p;
/*2*/ foo((*p, 5));
/*3*/ A a{*p};
/*4*/ p->data_mem;
/*5*/ int b{p->data_mem};
/*6*/ p->non_static_mem_fn();
/*7*/ p->static_mem_fn();
Очевидная, но важная деталь: p, инициализированный нулевым указателем, не может указывать на объект типа А, потому что его значение отлично от значения любого указателя на объект типа А conv.ptr#1.
Disclaimer: статья содержит вольный перевод терминов и выдержек из стандарта на русский язык. Мы рекомендуем английскую версию статьи на dev.to, лишенную неточностей перевода.
Пример 1
struct A {
int data_mem;
void non_static_mem_fn() {}
static void static_mem_fn() {}
};
void foo(int) {}
A* p{nullptr};
*p;
Синтаксически это оператор выражения (expression statement, stmt.expr#1), в котором *p является выражением с отброшенным результатом, который, тем не менее, нужно вычислить. Определение унарного оператора * expr.unary.op#1 гласит, что этот оператор осуществляет косвенное обращение (indirection), и результатом является l-значение, которое обозначает объект или функцию, на которую указывает выражение. Его семантика понятна, чего не скажешь о том, должен ли объект существовать. Нулевой указатель в определении не упоминается ни разу.
Можно попробовать зацепиться за косвенное обращение, потому что есть basic.stc#4, в котором четко написано, что поведение при косвенном обращении через недопустимое значение указателя (indirection through an invalid pointer value) не определено. Но там же дается описание недопустимого значения указателя, под которое нулевой не подходит, и дается ссылка на basic.compound#3.4, где видно, что нулевой указатель и недопустимый — это различные значения указателя.
Еще есть примечание в dcl.ref#5, которое гласит, что «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», т.е. единственный способ создать такую ссылку — привязать ее к «объекту», полученному за счет косвенного обращения через нулевой указатель, что приводит к неопределенному поведению. Но придаточное в конце может относиться не только к косвенному обращению, но и к «привязать» (to bind), и в этом случае неопределенное поведение вызвано тем, что нулевой указатель не указывает на объект, о чем и говорится в основном тексте пункта dcl.ref#5.
Раз стандарт вместо однозначных формулировок оставляет пространство для интерпретаций в разрезе нашего вопроса, можно обратиться к списку дефектов языковой части стандарта, где Core Working Group среди прочего поясняет текст стандарта. Наш вопрос выделен в отдельный дефект, где CWG довольно давно пришла к неформальному консенсусу (так определен статус drafting), что неопределенное поведение влечет не разыменование само по себе, а конвертация результата разыменования из l-значения в r-значение. Если «неформальный консенсус CWG» звучит недостаточно весомо, то есть другой дефект, в котором рассматривается пример, аналогичный нашему примеру 7. Такой код назван корректным по этой же причине в официальной аргументации CWG.
В дальнейших рассуждениях мы будем опираться на этот консенсус. Если в будущем стандарт запретит разыменовывать нулевые указатели по примеру Си (N2176, 6.5.3.2 и сноска 104), значит, все примеры содержат неопределенное поведение, и на этом разговор можно закончить.
Пример 2
struct A {
int data_mem;
void non_static_mem_fn() {}
static void static_mem_fn() {}
};
void foo(int) {}
A* p{nullptr};
foo((*p, 5));
Чтобы вызвать foo, требуется проинициализировать его параметр, для чего нужно вычислить результат оператора «запятая». Его операнды вычисляются слева направо, причем все, кроме последнего, являются выражениями с отброшенным значением так же, как и в примере 1 (expr.comma#1). Следовательно, этот пример также корректен.
Пример 3
struct A {
int data_mem;
void non_static_mem_fn() {}
static void static_mem_fn() {}
};
void foo(int) {}
A* p{nullptr};
A a{*p};
Для инициализации a будет выбран неявный конструктор копирования, и для того, чтобы его вызвать, нужно проинициализировать параметр const A& допустимым объектом, в противном случае поведение не определено (dcl.ref#5). В нашем случае допустимого объекта нет.
Пример 4
struct A {
int data_mem;
void non_static_mem_fn() {}
static void static_mem_fn() {}
};
void foo(int) {}
A* p{nullptr};
p->data_mem;
Выражение этого оператора выражения при вычислении будет раскрыто в (*(p)).data_mem согласно expr.ref#2, которое обозначает (designate) соответствующий подобъект объекта, на который указывает выражение до точки (expr.ref#6.2). Параллели с примером 1 становятся особенно явными, если открыть, скажем, basic.lookup.qual#1, и увидеть, как to refer и to designate взаимозаменяемо используются в том же смысле, что и в expr.ref. Из чего мы делаем вывод, что это корректный код, однако некоторые компиляторы не согласны (см. про проверку константными выражениями в конце статьи).
Пример 5
struct A {
int data_mem;
void non_static_mem_fn() {}
static void static_mem_fn() {}
};
void foo(int) {}
A* p{nullptr};
int b{p->data_mem};
В продолжение предыдущего примера не будем отбрасывать результат, а проинициализируем им int. В этом случае результат нужно конвертировать в pr-значение, потому что выражения именно этой категории инициализируют объекты (basic.lval#1.2). Так как речь идет об int, будет осуществлен доступ к объекту результата (conv.lval#3.4), что в нашем случае ведет к неопределенному поведению, потому что ни одно из условий basic.lval#11 не соблюдается.
Пример 6
struct A {
int data_mem;
void non_static_mem_fn() {}
static void static_mem_fn() {}
};
void foo(int) {}
A* p{nullptr};
p->non_static_mem_fn();
class.mfct.non-static#1 гласит, что функции-члены разрешено вызывать для объекта типа, к которому они принадлежат (или унаследованного от него), или напрямую из определений функций-членов класса. Именно «разрешено» — такой смысл вкладывается в глагол «may be» в директивах ИСО/МЭК, которым следуют все стандарты ИСО. Раз объекта нет, то и поведение при таком вызове не определено.
Пример 7
struct A {
int data_mem;
void non_static_mem_fn() {}
static void static_mem_fn() {}
};
void foo(int) {}
A* p{nullptr};
p->static_mem_fn();
Как говорилось в рассуждениях к примеру 1, Core Working Group считает этот код корректным. Добавить можно лишь то, что согласно сноске 59, выражение слева от оператора -> разыменовывается, даже если его результат не требуется.
Проверка с помощью constexpr
Раз константные выражения не могут полагаться на неопределенное поведение (expr.const#5), то можно узнать мнение компиляторов о наших примерах. Пусть они и несовершенны, но как минимум нередко правы. Мы взяли три популярных компилятора, подправили пример под constexpr и для наглядности закомментировали те примеры, которые не компилируются, потому что сообщения об ошибках что у GCC, что у MSVC оставляют желать лучшего на данных примерах: godbolt.
Что получилось в итоге:
# | Код | Предположение | GCC 10.1 | Clang 10 | MSVC 19.24 |
1 | *p; | + | + | + | + |
2 | foo((*p, 5)); | + | + | + | + |
3 | A a{*p}; | ||||
4 | p->data_mem; | + | + | ||
5 | int b{p->data_mem}; | ||||
6 | p->non_static_mem_fn(); | + | + | ||
7 | p->static_mem_fn(); | + | + | + | + |
Результаты заставляют несколько усомниться в выводе из примера 6 и в большей степени из примера 4. Но также интересно, что мы все сходимся во мнении о ключевом примере 1.
Спасибо, что остались с нами до конца, чтобы проследить за приключениями нулевого указателя в С++! :-) Обычно мы делимся на Хабре кусками кода из реальных проектов по разработке встроенного ПО для электроники, но этот раз нас заинтересовали чисто «философские» вопросы, поэтому примеры синтетические.
Если вы разделяете нашу любовь к противоречиям в С++, делитесь «наболевшим» в комментариях.
Автор: Команда компании Promwad