Введение


В статье описана ленивая реализация обхода дерева на языке 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) за его вклад в реализацию и обсуждение проекта.

Комментарии (4)


  1. mapron
    25.12.2019 22:31
    +2

    Я аж настолько удивился качеству статьи что полез проматывать в начало, не пропустил ли плашку «перевод». Не пропустил.

    Спасибо.

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


    1. vt4a2h Автор
      25.12.2019 23:22
      +1

      Если я верно понял вопрос, то имеется ввиду контейнер, который содержит указатели на все узлы дерева. Так работает настоящая реализация QObject::findChildren и первичная реализация без использования сопрограмм.


      Такой подход имеет смысл, если дерево может быть перестроено в процессе по-настоящему ленивого обхода. В случае использования сопрограмм, неявно будут сохранены ссылки на некоторые узлы. Корректнее будет сказать, что ссылки на контейнеры, которые содержат дочерние элементы узла. Если они "подвиснут" может случиться беда.
      Это довольно редкое явление, но внутри Qt я знаю пару мест, где так может случиться.


      Но и даже в случае ленивого обхода уже созданного контейнера, который содержит все узлы, есть польза. Если коротко, то мы получаем более чистый и гибкий код. Эффективность там ничуть не хуже чем в старом коде.


      В репозитории на гитхабе есть тесты, которые можно погонять.


      1. mapron
        27.12.2019 02:39
        +1

        Хорошо, понял, спасибо за ответ.


    1. RPG18
      26.12.2019 13:06

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