Вы все еще кипятите и сравниваете this с нулем?




Статью написал сотрудник компании ABBYY Дмитрий Мещеряков, впервые опубликована: "Блог компании ABBYY. Вы все еще кипятите и сравниваете this с нулем?" Публикуется здесь с разрешения правообладателя.

Picture 1

Давным-давно в далекой-далекой галактике широко использовалась библиотека MFC, в которой у ряда классов были методы, сравнивающие this с нулем. Примерно так:

class CWindow {
    HWND handle;
    HWND GetSafeHandle() const
    {
         return this == 0 ? 0 : handle;
    }
};

"Это же не имеет смысла" - возразит читатель. Еще как "имеет": этот код "позволяет" вызывать метод GetSafeHandle() через нулевой указатель CWindow*. Такой прием время от времени используется в разных проектах. Рассмотрим, почему на самом деле это плохая идея.

Нужно начать с того, что, согласно Стандарту C++ (следует из 5.2.5/3 стандарта ISO/IEC 14882:2003(E)), вызов любого нестатического метода любого класса через нулевой указатель приводит к неопределенному поведению. Тем не менее, в ряде реализаций вот такой код вполне может работать:

class Class {
public:
    void DontAccessMembers()
    {
        ::Sleep(0);
    }
};

int main()
{
    Class* object = 0;
    object->DontAccessMembers();
}

Это происходит благодаря тому, что во время работы метода нет попыток получить доступ к членам класса, а для вызова метода не используется позднее связывание. Компилятор знает, какой именно метод какого именно класса нужно вызвать, и просто добавляет вызов этого метода. При этом this передается как параметр. Эффект тот же, как если бы метод был статическим:

class Class {
public:
    static void DontAccessMembers(Class* currentObject)
    {
        ::Sleep(0);
    }
};

int main()
{
    Class* object = 0;
    Class::DontAccessMembers(object);
}

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

Но мы же знаем, что наш метод никогда не будет вызываться виртуально, правда? И вообще этот код уже сколько-то там лет работает.

Проблема в том, что компилятор может использовать неопределенное поведение для оптимизации. Вот например:

int divideBy = ...;
whatever = 3 / divideBy;
if( divideBy == 0 ) {
    // THIS IS IMPOSSIBLE
}

В коде выше выполняется целочисленное деление на divideBy. Целочисленное деление на ноль приводит к неопределенному поведению (обычно к аварийному завершению программы). Значит, можно считать, что переменная divideBy не равна нулю, и на этапе компиляции исключить проверку и соответствующим образом оптимизировать код.

Точно так же компилятор может оптимизировать и код, сравнивающий this с нулем. В соответствии со Стандартом, this не может быть нулевым, соответственно, проверки и соответствующие ветви кода можно исключить, а это существенно повлияет на код, зависящий от сравнения this с нулем. Компилятор имеет полное право "сломать" (на самом деле — доломать) код CWindow::GetSafeHandle() и сгенерировать машинный код, в котором сравнения нет, а всегда считывается поле класса.

Пока даже самые новые версии распространенных компиляторов (можно проверить с помощью сервиса GCC Explorer) не выполняют таких оптимизаций, так что пока "все работает", правда же?

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

Во-вторых,

class FirstBase {
    int firstBaseData;
};

class SecondBase {
public:
    void Method()
    {
        if( this == 0 ) {
            printf( "this == 0");
        } else {
            printf( "this != 0 (value: %p)", this );
        }
    }
};

class Composed1 : public FirstBase, public SecondBase {
};

int main()
{
    Composed1* object = 0;
    object->Method();
}

НУ НАДО ЖЕ, при компиляции на Visual C++ 9 указатель this на входе в метод равен 0x00000004, потому что изначально нулевой указатель корректируется так, чтобы указывать на начало подобъекта соответствующего класса.

А если поменять порядок следования базовых классов

class Composed2 : public SecondBase, public FirstBase {
};
    
int main()
{
    Composed2* object = 0;
    object->Method();
}

при тех же самых условиях this будет нулевым, потому что начало подобъекта совпадает с началом объекта, в который он включен. Получается замечательный класс, метод которого работает только при условии "правильного" использования этого класса в составных объектах. Счастливой отладки, премия Дарвина давно не была так близко.

Нетрудно заметить, что в случае класса Composed1 неявное преобразование указателя на объект к указателю на подобъект работает "неправильно" - для нулевого указателя на объект преобразование дает ненулевой указатель на подобъект. Обычно при реализации такого же по смыслу преобразования компилятор добавляет проверку указателя на равенство нулю. Например, компиляция вот такого кода с неопределенным поведением (класс Composed1 тот же, что выше):

SecondBase* object = reinterpret_cast<Composed1*>( rand() );
object->Method();

на Visual C++ 9 дает такой машинный код:

SecondBase* object = reinterpret_cast<Composed1*>( rand() );
010C1000  call        dword ptr [__imp__rand (10C209Ch)] 
010C1006  test        eax,eax
010C1008  je          wmain+0Fh (10C100Fh) 
010C100A  add         eax,4 
object->Method();
010C100D  jne         wmain+20h (10C1020h) 
010C100F  push        offset string "this == 0" (10C20F4h) 
010C1014  call        dword ptr [__imp__printf (10C20A4h)] 
010C101A  add         esp,4 

В этом машинном коде вторая инструкция - это сравнение указателя на объект с нулем, при равенстве указателя нулю управление не проходит через инструкцию add eax,4, которая сдвигает указатель. Здесь неявное преобразование реализовано с проверкой, хотя тоже можно было воспользоваться последующим вызовом метода через указатель и считать указатель ненулевым.

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

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



Найдите ошибки в своем C, C++, C# и Java коде

Предлагаем попробовать проверить код вашего проекта с помощью анализатора кода PVS-Studio. Одна найденная в нём ошибка скажет вам о пользе методологии статического анализа кода больше, чем десяток статей.

goto PVS-Studio;


Найденные ошибки

Проверено проектов
346
Собрано ошибок
13 188

А ты совершаешь ошибки в коде?

Проверь с помощью
PVS-Studio

Статический анализ
кода для C, C++, C#
и Java

goto PVS-Studio;