С++ — настоящий мужской язык программирования. Программируя на нем, ты легко можешь отстрелить себе ногу, да так, что узнаешь об этом только через добрые полгода. Что же делать? Читать стандарт! И эту статью, кстати, тоже.

 

Когда пишешь код на C++, всегда надо думать, как он будет использоваться. От этого зависит, какие возможности языка надо применить, чтобы этот код работал именно так, как задумано. Си с плюсами изобилует разнообразными ключевыми словами, которые вроде как не обязательны к использованию, и многие программисты их игнорируют. Еще С++ очень любит, чтобы кодер помнил и заботился о деталях, без должного уровня внимательности баги будут сыпаться один за другим. Для пущей убедительности приведем несколько примерчиков.

Конструктор, принимающий параметр

Все знают, что такое в C++ конструктор и для чего он нужен. Если инициализация какого-либо члена в классе ложится на плечи пользователя этого класса, то такую операцию можно провести через передачу в конструктор некоего параметра. Давай посмотрим на такой класс:

class MyClass
{
public:
    MyClass() :
        m_x(0),
        m_y(0)
    {
    }

    MyClass(int x) :
        m_x(x),
        m_y(0)
    {
    }

    void SetY(int y)
    {
        m_y = y;
    }

private:
    int m_x;
    int m_y;
};

Все просто до безобразия. Есть конструктор по умолчанию и конструктор, инициализирующий переменную член m_x значением x. Еще есть m_y, для инициализации которого служит функция член SetY. Этот класс можно использовать следующим образом:

MyClass myObject(7);
myObject.SetY(9);

// ...

myObject = 12;

Казалось бы, код совершенно безобиден. Сначала мы создаем объект myObject, инициализируя m_x значением 7. После этого присваиваем m_y значение 9. Спустя некоторое количество строк кода мы делаем myObject = 12;, и в этом заключается большая проблема. Так как для класса есть конструктор, принимающий в качестве параметра значение типа int, то во время присвоения компилятор, решив облегчить нам жизнь, автоматически преобразует 12 в объект типа MyClass и заменит им myObject. При этом значение в m_y будет безвозвратно потеряно. Если программист не понимает, что он делает, или просто допустил подобную ошибку по невнимательности, то это грозит нам большими проблемами. В некоторых случаях проблемы могут стать особо серьезными. Для многопоточного программирования был придуман паттерн Monitor, который позволяет защитить какой-либо объект мьютексом, чтобы впоследствии предоставлять к нему безопасный доступ в многопоточной среде. Код этого паттерна похож на код класса из предыдущего примера, разница лишь в том, что вместо члена int m_y будет std::mutex m_mutex, а функция SetYбудет заменена на AccessToX. Для того чтобы безопасно работать с защищаемыми данными, монитор позволяет передать в перегруженный operator() лямбда-функцию, которая будет исполняться под мьютексом. Теперь давай представим, что будет, если в многопоточной среде мы вместе с компилятором проведем такой трюк с неявным преобразованием и последующим присвоением монитору вновь созданного временного объекта.

Wikipedia про explicit
Статистический анализ кода в Visual Studio

В большинстве случаев все отработает, казалось бы, как надо, но если такой код будет вызван одновременно из разных потоков, то мы получим все прелести data race. Делая myObject = 12 для объекта монитора, программист, скорее, хотел переопределить защищаемое значение, но по каким-то причинам не воспользовался специально созданным для этого механизмом. Можно, конечно, сказать ему: «Вон из профессии», но на самом деле виноват именно тот, кто проектировал класс монитора. Чтобы подобных проблем не возникало, достаточно лишь объявить конструктор MyClass(int x) с ключевым словом explicit: оно отключит неявное преобразование аргумента в объект, и конструкция myObject = 12 не скомпилируется, что заставит невнимательного кодера задуматься.

Полиморфизм объектов

Полиморфизм в C++ используется по полной. Все программисты, когда-либо сталкивающиеся с ООП, знают, что это такое. Очень удобно, например, хранить объекты разных связанных между собой классов в контейнере, который работает с указателями на базовый класс. Благодаря виртуальным функциям выполняется именно тот код, который нам нужен. Это чрезвычайно удобно и привычно, и некоторые даже забывают о том, что доступ к функциям наследника можно получить только через указатель, но никак не через объект базового класса. Чтобы было понятней, взглянем на следующий код:

class A
{
public:
    A() :
        m_a(0)
    {
        std::cout << "Construct A" << std::endl;
    }

    A(const A& other)
    {
        std::cout << "Copy construct A" << std::endl;
        CopyFrom(other);
    }

    virtual ~A()
    {
    }

    virtual void Foo()
    {
        std::cout << "A::Foo" << std::endl;
    }

private:
    void CopyFrom(const A& other)
    {
        if (this != &other)
        {
            m_a = other.m_a;
        }
    }

private:
    int m_a;
};

class B : public A
{
  public:
    B() :
        A()
        m_b(0)
    {
        std::cout << "Construct B" << std::endl;
    }

    B(const B& other)
    {
        std::cout << "Copy construct B" << std::endl;
        A::CopyFrom(other);
        CopyFrom(other);
    }

    void Foo() override
    {
        std::cout << "B::Foo" << std::endl;
    }

private:
    void CopyFrom(const B& other)
    {
        if (this != &other)
        {
            m_b = other.m_b;
        }
    }

private:
    int m_b;  
};

У нас есть базовый класс A и его наследник B. Виртуальная функция Foo переопределена в дочернем классе. К тому же мы явно определили конструктор копирования в обоих классах, но это мы оставим на потом. Давай представим, будто у нас есть вектор, в котором нужно хранить объекты обоих этих типов, но по каким-то причинам мы решили, что в контейнере должны лежать сами объекты, а не указатели на них. Запушив туда несколько объектов, мы проходим по контейнеру и вызываем для каждого Foo.

std::vector<A> myVector;
myVector.push_back(B());
myVector.push_back(B());
myVector.push_back(A());

for (auto& object : myVector)
{
    object.Foo();
}

После выполнения цикла мы ожидаем увидеть в консольном выводе строки, свидетельствующие о вызове двух B::Foo и одной A::Foo, но вопреки этому все три вызова будут выводить в output A::Foo. Получается, полиморфизм не работает? Не совсем так.

При помещении объектов в вектор происходит их копирование. Мы специально переопределили конструктор копирования, чтобы понять, что происходит. А происходит то, что вызывается конструктор копирования только для класса A, так как именно он является шаблонным параметром контейнера. Все, что касается класса B, при этом не копируется, в том числе и таблица указателей на виртуальные функции. Таким образом, в векторе лежат обычные объекты класса A. Такая ошибка возможна как из-за незнания основ, так и по простой невнимательности. Самое опасное в этой ситуации заключается в том, что расплата может прийти спустя много дней и искать причину глюков будет сложно.

Вызов клиентского кода

Довольно часто программисты сталкиваются с задачей вызова клиентского кода из своего. Это могут быть простые callbacks или реализация сигналов/слотов. Не имеющие опыта в подобных вещах программисты наивно надеются, что их классами будут пользоваться идеальные кодеры, которые не совершают ошибок. Творение подобных программеров выглядит примерно так:

void SomeClass::Foo(std::function<void()> callback)
{
    m_mutex.lock()

    // Делаем что-нибудь доброе

    callback();
    m_mutex.unlock();
}

Для большей драматичности мы специально добавили мьютекс в наш код. Почувствовал весь трагизм происходящего? Дело в том, что при вызове callback() вполне может произойти исключение, которое преждевременно приведет к завершению функции SomeClass::Foo, и значит, мьютекс, который мы заблокировали в начале этой функции, останется в таком состоянии навсегда, что впоследствии неминуемо приведет к дедлоку.

Кусочек кода сигналов/слотов
Кусочек кода сигналов/слотов

Исправить положение дел довольно просто — достаточно лишь использовать управляющий объект для нашего мьютекса, в данном случае вполне подойдет std::lock_guard. Стандарт C++ гарантирует, что локальные объекты в случае срабатывания exception будут уничтожены, а следовательно, будут вызваны их деструкторы, деструктор lock_guard, в свою очередь, разблокирует мьютекс. Возможен и другой вариант развития событий. Допустим, наш коллбэк нужно вызвать не единожды, а несколько раз в цикле. В этом случае мы вполне можем рассчитывать на досрочное завершение нашего кода из-за срабатывания все того же исключения в callback. К счастью, и тут несложно справиться с выпавшими на нашу долю трудностями.

void SomeClass::Foo(std::function<void()> callback)
{
    while (/* пока я жив */)
    {
        // Трудимся на благо человечества

        try
        {
            callback();
        }
        catch (...)
        {
            // Обрабатываем исключения тут!
        }
    }
}

Обернув вызов пользовательского кода в try-catch блок, мы сможем без опасений молотить биты и байты в нашем цикле, не боясь, что какой-нибудь кодер-самоучка захочет поделить все это на ноль. Из моего большого и довольно-таки очевидного рассказа надо усвоить две вещи: всегда используй управляющие (RIIA) объекты для всех ресурсов, которые требуют деинициализации, и отлавливай исключения при вызовах клиентского кода, если не хочешь неприятных сюрпризов.

Мораль

Отстрелить себе ногу, занимаясь кодингом на плюсах, еще проще, чем проковырять дырку в бумажной стене японского дома. Следует помнить множество нюансов и проявлять особую бдительность. Чтобы не совершать досадные ошибки, нужно много опыта. А для профилактики можно периодически пролистывать стандарт C++.

  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    10 комментариев
    Старые
    Новые Популярные
    Межтекстовые Отзывы
    Посмотреть все комментарии