Как можно и как нельзя использовать нулевой указатель в С++



    Некоторым этот банальный вопрос уже набил оскомину, но мы взяли 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.


    Спасибо, что остались с нами до конца, чтобы проследить за приключениями нулевого указателя в С++! :-) Обычно мы делимся на Хабре кусками кода из реальных проектов по разработке встроенного ПО для электроники, но этот раз нас заинтересовали чисто «философские» вопросы, поэтому примеры синтетические.


    Если вы разделяете нашу любовь к противоречиям в С++, делитесь «наболевшим» в комментариях. 

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 18

      +2
      Они и сами путаются и стандарт всё дальше запутывают. Функция вызова невиртуального метода для указателя null нужна для реализации проверок в операторах преобразования типа, перегруженных операторов (при работе с коллекциями, например) и т.п. При нынешних формулировках этого сделать нельзя. Но можно при этом можно извратиться и получить похожий результат с дружественными функциями. Почему — загадка великая есмь.
      При том, что вызов невиртуального метода для NULL — совершенно нормальная с точки зрения компилятора вещь без всякого криминала — ну будет неявный параметр (this) равен NULL и что? Код внутри метода может корректно обрабатывать такие ситуации.
        +7
        При том, что вызов невиртуального метода для NULL — совершенно нормальная с точки зрения компилятора вещь без всякого криминала — ну будет неявный параметр (this) равен NULL и что? Код внутри метода может корректно обрабатывать такие ситуации.


        Вызов метода от nullptr это UB. Точнее, UB — это проверять this на nullptr. В теории, можно написать метод, который this не проверяет и не использует, как в примере в статье — и это будет работать. Но зачем вам метод класса который не использует this, сделайте его static.
        Теперь, собственно, почему проверка this это UB. Рассмотрим пример
        struct A 
        {
            static inline int globalInt = 0;
            int localInt = 0;
            int foo() { if (!this) return globalInt; else return localInt; }
            // сеттер не нужен для понимания идеи но пусть будет для полноты
            void setFoo(int i) { if (!this) globalInt = i; else localInt = i; }
        };
        

        вот так всё работает
        A *a = nullptr;
        int i = a->foo();
        


        Усложним пример, добавим немного наследования
        class B { int i{0}; int j{0}; };
        class С : public B, public A {};
        


        И попробуем сделать тоже самое
        C *c = nullptr;
        int i = c->foo();
        

        Упс, теперь this для базового класса A — это nullptr + sizeof(B). Немного не то, что ожидалось
          +3
          Вызов метода от nullptr это UB. Точнее, UB — это проверять this на nullptr.

          По стандарту, вызов метода от nullptr — это UB, а сравнение this с nullptr легально, всегда возвращает false, и на него уже давно ругаются компиляторы как на бессмысленный код.
            0
            Да, я неправильно выразился, должно быть «Вызов метода от nullptr это UB. А также, UB — это проверять this на nullptr.» Никогда особо не умел связно писать тексты=(
              0
              На сколько я знаю, не возвращают они false всегда, т.к. я видел всякий legacy-код в значимых количествах, который допускает вызов методов с this=nullptr. Ругаются ворнингами — да, но сравнение отрабатывают честно, т.к. иначе ломается legacy.
              Потому и возмущаюсь, что формально формулировки новых редакций стандарта ломают legacy на ровном месте, а уже разработчикам компиляторов приходится костыли подставлять. И то же время в стандарте разводят реальный ад в попытках сохранить легаси там, где его можно было смело поломать, а 0.00001% поломанного некорректного кода заставить добавив какую-нибудь прагму (привет безумию с конструкторами, где половина безумия не нужна, а вторая половина ещё и умудряется ломать старый код).
                0
                Я такое только у MFC помню, и кажется MS специально поддерживала this=0 в компиляторе, чтобы старый код работал. Я не знаю, поддерживается ли это до сих пор последними версиями студии, но gcc точно убирает все проверки `if (this)` начиная еще с 6-й версии (хотел написать «недавно начала», но потом понял, что уже несколько лет прошло с даты релиза).
                  0
                  Нет. Только что проверил — «if (this==nullptr)» отрабатывает корректно в GCC 7.1.1
                  Уверен, что и в девятой будет работать. Там реально есть популярный легаси код, который с этим работает.
                    0
                      0
                      Или это баг в оптимизаторе компилятора или я чего-то не понимаю.
                      На GCC 7.1.1 вот это работает правильно:
                      #include <iostream>
                      
                      using namespace std;
                      
                      class A {
                      public:
                      	void non_virtual_mem_fn() {
                                  if (this==nullptr)
                                      cout << "NULL" << endl; 
                                  else
                                      cout << "NOT NULL" << endl; 
                          }
                      };
                      
                      int main() {
                          A* ptr_null = nullptr;
                          ptr_null->non_virtual_mem_fn();
                          A* ptr_not_null = new A();
                          ptr_not_null->non_virtual_mem_fn();
                          delete(ptr_not_null);
                          return 0;
                      }

                        0
                        Не баг, а неожиданная фича ;-)
                        +1
                        Ага, опция -O1/-O2 меняет поведение компилятора на противоположное.
                        +1
                        Проверил на godbolt на минимальном примере, проверка выполняется только при отключенной оптимизации (-O0):
                        godbolt.org/z/4qdbzq
                          +1
                          Чудеса — мой код, который выше там же работает при "-O1". Прямо, эталонное UB :)
                  –3
                  При линейном наследовании объекты «растут» в сторону увеличения адресов, и указатель на объект всегда указывает на самого первого предка, а потомки «знают», что их данные располагаются в памяти после данных предка.
                  И, да, только в случае множественного наследования может быть преобразование указателя с интересными результатами.
                  Но множественное наследование и виртуальные функции это уже частные случаи, где UB действительно неизбежно. Зачем было распространять его на линейное наследование и невиртуальные функции?
                  И именно за такой идиотизм с UB на ровном месте авторам стандарта и нужно гореть в аду.
                +2
                Чем дальше в лес, тем меньше света…
                  +8
                  Диву даёшься, как чтение стандарта C++ всё дальше превращается в гуманитарную дисциплину с анализом значений синонимов и мнений авторитетных толкователей.
                    +1
                    Скажите ещё спасибо, что пока гадания не завезли. Ну или в сонники не добавили «что делать, если приснился нулевой указатель».
                      +1

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                    Самое читаемое