Введение
В статье описана ленивая реализация обхода дерева на языке 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) за его вклад в реализацию и обсуждение проекта.
ссылка на оригинал статьи https://habr.com/ru/post/481402/
Добавить комментарий