Ленивая реализация обхода дерева дочерних элементов класса QObject

в 18:04, , рубрики: c++, coroutines, qt, ranges

Введение

В статье описана ленивая реализация обхода дерева на языке C++ с использованием сопрограмм и диапазонов на примере улучшения интерфейса работы с дочерними элементами класса QObject из фреймворка Qt. Подробно рассмотрено создание пользовательского представления для работы с дочерними элементами и приведены ленивая и классическая его реализации. В конце статьи есть ссылка на репозиторий с полным исходным кодом.

Об авторе

Работаю старшим разработчиком в норвежском офисе The Qt Company. Разрабатываю виджеты и QtQuick элементы, с недавних пор и Qt Core. Использую C++ и немного интересуюсь функциональным программированием. Иногда делаю доклады и пишу статьи.

Что такое Qt

Qt — это кроссплатформенный фреймворк для создания графических интерфейсов пользователя (GUI). Помимо модулей для создания GUI, Qt содержит множество модулей для разработки прикладного программного обеспечения. Фреймворк разработан преимущественно на языке программирования C++, некоторые компоненты используют QML и JavaScript.

Класс QObject

QObject — это класс, вокруг которого построена объектная модель Qt. Классы, унаследованные от QObject, можно использовать в слот-сигнальной модели и цикле обработки событий. Кроме того, QObject позволяет получить доступ к мета-объектной информации класса и организовывать объекты в древовидные структуры.

Древовидная структура QObject

Использование древовидной структуры означает, что каждый объект типа QObject может иметь одного родителя и ноль или несколько детей. Родительский объект управляет временем жизни дочерних объектов. В следующем примере два дочерних объекта будут удалены автоматически:

auto parent = std::make_unique<QObject>();

auto onDestroyed = [](auto obj){ qDebug("Object %p destroyed.", obj); };
QObject::connect(new QObject(parent.get()), &QObject::destroyed, onDestroyed);
QObject::connect(new QObject(parent.get()), &QObject::destroyed, onDestroyed);

// Дважды выводит сообщение об удалении объекта

К сожалению, пока что большая часть API Qt работает только с сырыми указателями. Мы работаем над этим, и возможно скоро ситуация изменится к лучшему хотя бы частично.

Интерфейс класс QObject позволяет получить список всех дочерних объектов и осуществлять поиск по некоторым критериям. Рассмотрим пример получения списка всех дочерних объектов:

auto parent = std::make_unique<QObject>();

 // Создадим 10 дочерних объектов
 for (std::size_t i = 0; i < 10; ++i) {
    auto obj = new QObject(parent.get());
    obj->setObjectName(QStringLiteral("Object %1").arg(i));
}

const auto& children = parent->children();

qDebug() << children; // => (QObject(0x1f7ffa0, name = "Object 0"), ...)
qDebug() << children.count(); // => 10

Метод QObject::children возвращает список всех дочерних для данного объекта элементов. Однако часто требуется поиск среди всего поддерева объектов по некоторому критерию:

auto children = parent->findChildren<QObject>(QRegularExpression("0$"));
qDebug() << children.count();

Пример выше демонстрирует, как можно получить список всех дочерних элементов типа QObject, имя которых заканчивается на 0. В отличие от метода children, метод findChildren осуществляет рекурсивный обход дерева, то есть ищет по всей иерархии объектов. Это поведение можно изменить, передав флаг Qt::FindDirectChildrenOnly.

Недостатки интерфейса работы с дочерними элементами

На первый взгляд может показаться, что интерфейс работы с дочерними элементами достаточно продуман и гибок. Однако и он не лишён недостатков. Рассмотрим некоторые из них:

  • Избыточный интерфейс
    Есть два различных метода findChildren (не так давно их было три): метод findChild для поиска одного элемента и метод children. Все они частично дублируют друг друга.
  • Интерфейс сложно изменить
    Qt гарантируют бинарную совместимость и совместимость на уровне исходного кода в рамках одного мажорного релиза. Следовательно, нельзя так просто менять сигнатуру метода или добавлять новые методы.
  • Интерфейс сложно расширить
    Помимо нарушения совместимости, невозможно, например, получить список дочерних элементов по заданному критерию. Чтобы добавить такую функциональность, необходимо ждать следующего релиза или создавать ещё один метод.
  • Избыточное копирование всех элементов
    Зачастую необходимо просто пройтись по списку всех дочерних элементов, отфильтрованных по заданному критерию. Для этого совсем не обязательно возвращать контейнер указателей на все эти элементы.
  • Возможное нарушение SRP
    Это довольно спорный вопрос, однако же необходимость изменять интерфейс класса для изменения, скажем, метода обхода дочерних элементов выглядит странно.

Использование range-v3 для устранения некоторых недостатков

range-v3 — это библиотека, которая предоставляет компоненты для работы с диапазонами элементов. По сути, это дополнительный слой абстракции над классическими итераторами, который позволяет компоновать операции и пользоваться преимуществами ленивых вычислений.

Сторонняя библиотека используется потому, что на момент написания статьи не существует известных автору компиляторов со встроенной поддержкой этой функциональности. Возможно, ситуация изменится в скором времени.

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

Пример использования ranges-v3

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

namespace r = ranges;
namespace v = r::views;
namespace a = r::actions;

Теперь рассмотрим пример программы, которая напечатает кубы всех нечётных чисел в интервале [1, 10) в обратном порядке:

auto is_odd = [](int n) { return n % 2 != 0; };
auto pow3 = [](int n) { return std::pow(n, 3); };

// Выведет [729,343,125,27,1]
std::cout <<
    (v::ints(1, 10) | v::filter(is_odd) | v::transform(pow3) | v::reverse);

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

// Выведет 729 343 125 27 1
for (int i = 9; i > 0; --i) {
    if (i % 2 != 0) {
        std::cout << std::pow(i, 3) << " ";
    }
}

Как видно из примера выше, библиотека позволяет изящно компоновать различные операции. Больше примеров использования можно найти в директориях tests и examples репозитория range-v3.

Класс для представления последовательности дочерних элементов

Библиотека range-v3 предоставляет вспомогательные классы для создания различных пользовательских классов-обёрток; среди них и классы из категории view. Эти классы предназначены для представления последовательности элементов определённым образом без преобразования и копирования самой последовательности. В предыдущем примере был использован класс filter, чтобы рассматривать только те элементы последовательности, которые соответствуют заданному критерию.

Чтобы создать такой класс для работы с дочерними элементами QObject, его необходимо унаследовать от вспомогательного класса ranges::view_facade:

namespace qt::detail
{
    template <class T = QObject>
    class children_view : public r::view_facade<children_view<T>>
    {
        // Вспомогательная функциональность
        friend r::range_access;

        // Указатель на объект, с дочерними элементами которого планируется работать 
        T *obj;
        // Параметры поиска элементов (рекурсивно или нет)
        Qt::FindChildOptions opts;

        // Курсор -- это аналог итератора
        cursor begin_cursor() {  return cursor(obj, opts); }

    public:
        // Конструкторы
    };
} // namespace qt::detail

Стоит отметить, что класс автоматически определяет метод end_cursor, который возвращает признак конца последовательности. В случае необходимости этот метод можно переопределить.

Далее определим сам класс курсора. Это можно сделать как внутри класса children_view, так и за его пределами:

struct cursor
{
    // Контейнер, который содержит все дочерние элементы
    std::shared_ptr<ObjectVector> children;

    // Индекс текущего элемента
    std::size_t current_index = 0;

    // Метод получения доступа к текущему элементу
    decltype(auto) read() const { return (*children)[current_index];  }

    // Переход к следующему элементу
    void next() { ++current_index; }

     // Проверка признака конца последовательности
    auto equal(ranges::default_sentinel_t) const
    {
        return current_index == children->size();
    }

    // Конструкторы
};

Курсор, определённый выше, однопроходный (single-pass). Это означает, что по последовательности разрешается двигаться только в одном направлении и только один раз. Для данной реализации это не обязательно, т.к. мы храним последовательность всех дочерних объектов и можем проходить по ним в любом направлении сколько угодно раз. Чтобы указать, что по последовательности можно проходить несколько раз, необходимо реализовать следующий метод в класс cursor:

auto equal(const cursor &that) const
{
    return current_index == that.current_index;
}

Теперь необходимо добавить сделать так, чтобы созданное представление можно было включать в композицию. Для этого используем вспомогательную функцию ranges::make_pipeable:

namespace qt
{
    constexpr auto children = r::make_pipeable([](auto &&o) { return detail::children_view(o); });

    constexpr auto find_children(Qt::FindChildOptions opts = Qt::FindChildrenRecursively)
    {
        return r::make_pipeable([opts](auto &&o) { return detail::children_view(o, opts); });
    }
} // namespace qt

Теперь можно писать такой код:

for (auto &&c : root | qt::children) {
    // Обработка все дочерних объектов (рекурсивно)
}

for (auto &&c : root | qt::find_children(Qt::FindDirectChildrenOnly)) {
    // Обработка только прямых наследников
}

Реализация существующей функциональности класса QObject

После реализации класса представления можно легко реализовать всю функциональность по работе с дочерними элементами. Для этого нужно реализовать три функции:

namespace qt
{
    template <class T>
    const auto with_type = v::filter([](auto &&o) {
                               using ObjType = std::remove_cv_t<std::remove_pointer_t<T>>;
                               return ObjType::staticMetaObject.cast(o);
                           }) |
                           v::transform([](auto &&o){ return static_cast<T>(o); });

    auto by_name(const QString &name)
    {
        return v::filter([name](auto &&obj) { return obj->objectName() == name; });
    }

    auto by_re(const QRegularExpression &re)
    {
        return v::filter([re](auto &&obj) { return re.match(obj->objectName()).hasMatch(); });
    }
} // namespace qt

В качестве примера использования рассмотрим следующий код:

for (auto &&c : root | qt::children | qt::with_type<Foo*>) {
    // Обработка всех дочерних элементов с типом Foo
}

Промежуточные выводы

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

По сути, данная реализация уже устраняет практически все перечисленные недостатки интерфейса, за исключением того, что нам всё ещё приходится копировать все дочерние элементы в контейнер. Один из вариантов решения этой проблемы — использование сопрограмм.

Ленивая реализация обхода дерева объектов с использованием сопрограмм

Сопрограммы (coroutines) позволяют приостановить выполнение функции и возобновить его позднее. Можно рассматривать эту технологию как некоторый конечный автомат.

На момент написания статьи в стандартной библиотеке отсутствуют множество важных элементов, необходимых для комфортного использования сопрограмм. Поэтому предлагается использовать стороннюю библиотеку cppcoro, которая, вероятно, войдёт в стандарт в том или ином виде.

Для начала напишем функции, которые будут возвращать следующий дочерний элемент по требованию:

namespace qt::detail
{
    cppcoro::recursive_generator<QObject*> takeChildRecursivelyImpl(
        const QObjectList &children, Qt::FindChildOptions opts)
    {
        for (QObject *c : children) {
            if (opts == Qt::FindChildrenRecursively) {
                co_yield takeChildRecursivelyImpl(c->children(), opts);
            }
            co_yield c;
        }
    }

    cppcoro::recursive_generator<QObject*> takeChildRecursively(
        QObject *root, Qt::FindChildOptions opts = Qt::FindChildrenRecursively)
    {
        if (root) {
            co_yield takeChildRecursivelyImpl(root->children(), opts);
        }
    }
} // namespace qt::detail

Инструкция co_yield возвращает значение вызывающему коду и приостанавливает выполнение сопрограммы.

Теперь интегрируем этот код в класс children_view. В следующем коде приведены только изменившиеся элементы:

// В классе children_view

// Инициализируется как Data{obj, takeChildRecursively(obj, opts)}
struct Data {
    T *obj;
    cppcoro::recursive_generator<QObject*> gen;
};
std::shared_ptr<Data> m_data;

// ...

cursor begin_cursor() { return cursor(m_data->gen.begin()); }

Курсор тоже необходимо модифицировать:

template <class T>
struct children_view<T>::cursor
{
    cppcoro::recursive_generator<QObject*>::iterator it;

    decltype(auto) read() const { return *it; }

    void next() { ++it; }

    auto equal(ranges::default_sentinel_t) const
    {
        return it == cppcoro::recursive_generator<QObject*>::iterator(nullptr);
    }

    explicit cursor(cppcoro::recursive_generator<QObject*>::iterator it): it(it) {}

    cursor() = default;
};

Курсор здесь выступает просто в роли обёртки вокруг обычного итератора. Остальной код можно использовать как есть, без дополнительных изменений.

Опасности ленивого обхода дерева

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

Это означает, что в некоторых случаях полезно скопировать все элементы в контейнер. Для этого можно использовать следующую вспомогательную функцию:

auto children = ranges::to<std::vector>(root | qt::children);

Строго говоря, в этом случае в использовании сопрограмм нет необходимости и можно использовать представление из первой итерации.

Будет ли это в Qt

Возможно, но не в следующем релизе. Этому есть несколько причин:

  • Следующий мажорный релиз — Qt 6 — будет официально требовать и поддерживать C++17, но не выше.
  • Нет возможности реализовать без сторонних библиотек.
  • Относительно сложно будет адаптировать существующую кодовую базу.
    Скорее всего, к этому вопросу вернутся в рамках релиза Qt 7.

Заключение

Предлагаемая реализация обхода дерева дочерних элементов позволяет легко добавить новую функциональность. За счёт разделения операций достигается написание более чистого кода и удаление лишних элементов из интерфейса класса.

Стоит отметить, что обе использованные библиотеки (range-v3 и cpp-coro) поставляются в виде заголовочных файлов, что упрощает процесс сборки. В будущем возможно будет обойтись вообще без сторонних библиотек.

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

Дополнительно

Исходный код

Отдельное спасибо Мише Светкину (Trilla) за его вклад в реализацию и обсуждение проекта.

Автор: vt4a2h

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js