Пишем свой пул объектов и наступаем на грабли

3 мин на чтение

Что из себя представляет пул объектов? Обычно это некий список заранее созданных и переиспользуемых объектов. Нужен он для того, чтобы не тратить время на создание этих самых объектов во время выполнения программы. Т.е. схема работы такая: взяли объект из пула, поиспользовали, выкинули обратно в пул.

Наивная реализация

Пойдем простым путём. Возьмем std::stack и создадим вокруг него нашу функциональную обертку:

// pool.hpp

#include <memory>
#include <stack>

/**
 * @brief The Pool class simple pool of elements
 */
template <typename T>
class Pool {
  public:
    using item_ptr = std::shared_ptr<T>;

  protected:
    std::stack<item_ptr> pool;  // active pool of items

  protected:
    item_ptr create() {
        return std::make_shared<T>();
    }

  public:
    item_ptr pop() {
        if (this->pool.empty()) {
            return this->create();
        } else {
            auto t = this->pool.top();
            this->pool.pop();
            return t;
        }
    }

    void push(item_ptr && item) {
        this->pool.push(item);
    }
};

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

Наступаем на грабли, раз

Начинаем использовать Pool в проекте и замечаем, что иногда в релизной сборке мы крашимся. Благо у нас на проекте собираются дампы и можно понять, что там произошло.

Перейдем ближе к делу и рассмотрим пример использования, который приводит к проблеме:

class Widget: public QDialog {
 ...
};

Pool<Widget> pool;

void foo(int count) {
    for (int i = 0; i < count; ++i) {
        auto w = pool.pop();

        w->setParent(qApp);
        w->exec();

        w->push(w);
    }
}

У нас имеется глобальный доступ к пулу. Когда мы закрываем приложение - получаем двойное освобождение памяти. Тот, кто имеет опыт с Qt сразу заметит очень важную строку w->setParent(qApp);. Причина, естественно, именно в ней.

Недолго думая добавляем специализацию пула для наследников QObject:

template <typename T>
class QPool : public Pool<T> {
  protected:
    template <typename = typename std::enable_if<
                      std::is_base_of<QObject, T>::value>::type>
    typename Pool<T>::item_ptr create() {
        return std::shared_ptr<T>(new T(), [](T * t) {  // set custom deleter
            auto tmp = qobject_cast<QObject *>(t);

            if (tmp) {
                tmp->setParent(nullptr);  // disable Qt parent system
                tmp->deleteLater();
            } else {
                delete tmp;
            }
        });
    }

  public:
    template <typename = typename std::enable_if<
                      std::is_base_of<QObject, T>::value>::type>
    typename Pool<T>::item_ptr pop() {
        if (this->pool.empty()) {
            return this->create();
        }

        return Pool<T>::pop();
    }

    template <typename = typename std::enable_if<
                      std::is_base_of<QObject, T>::value>::type>
    void push(typename Pool<T>::item_ptr && ptr) {
        ptr->setParent(nullptr);  // disable Qt parent system

        Pool<T>::push(std::move(ptr));
    }
};

Видно два основных дополнения:

  • свой deleter;
  • сброс родителя при возвращении объекта в пул (если не вернули - разбирайтесь сами);
  • стало еще молодежней, даешь SFINAE в массы!

Что же тут вообще происходит? Методы QPool::pop, QPool::push, QPool::create затеняют методы из Pool, причем произойдет это только, если T наследник QObject. Если параметр шаблона не удовлетворяет этим условиям - получаем обычный Pool А не лучше ли ошибку компиляции?

Выпускаем новую версию пула - не падает. Радуемся и пилим проект дальше.

Наступаем на грабли, два

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

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

Между прочим, эта реализация без проблем отработала почти год - аргумент, как-никак. Но исправлять все равно надо. Еще пара часов медитаций и гуглений и вот он ответ: QWidget значительно переопределяет метод QObject::setParent, а метод-то этот не виртуальный! Выходит что пул вызывает совсем не то, что нужно для QWidget, а это уже похоже на UB…

Добавим еще одну специализацию и вот оно, счастье!

Выводы, размышления и почему так получилось

Тут надо задать вопрос: “Эй, тимлид, а где тесты?” А их не было и сейчас нет на этот функционал… Юнит-тестирование, возможно, выявило бы эти проблемы на более ранних этапах. А может и нет, кто бы догадался еще разные типы в тестах проверять?

В общем, история довольно поучительная. Проверять код нужно, любой код. Только тогда можно говорить о том, что он работает корректно. Но как точно удостовериться в корректности всех тестов? На этот вопрос тоже уже давно придумали ответ - формальные методы.

Дата изменения: