Implementação preguiçosa de atravessar uma árvore de filhos da classe QObject

1. Introdução


O artigo descreve a implementação lenta de passagem de árvore em C ++ usando coroutines e intervalos usando o exemplo de aprimoramento da interface para trabalhar com filhos da classe QObject da estrutura Qt. A criação de uma exibição personalizada para trabalhar com elementos filho é considerada em detalhes, e implementações preguiçosas e clássicas são fornecidas. No final do artigo, há um link para o repositório com o código fonte completo.


Sobre o autor


Trabalho como desenvolvedor sênior no escritório norueguês da The Qt Company. Tenho desenvolvido widgets e elementos QtQuick, recentemente Qt Core. Eu uso C ++ e um pouco interessado em programação funcional. Às vezes, faço relatórios e escrevo artigos.


O que é Qt


Qt é uma estrutura de plataforma cruzada para a criação de interfaces gráficas de usuário (GUIs). Além dos módulos para criar uma GUI, o Qt contém muitos módulos para o desenvolvimento de aplicativos. A estrutura foi projetada principalmente na linguagem de programação C ++, alguns componentes usam QML e JavaScript .


Classe QObject


QObject é a classe em torno da qual o modelo de objeto Qt é construído. Classes herdadas de QObject podem ser usadas no modelo de sinal de slot e no loop de eventos. Além disso, o QObject permite acessar informações da classe de meta-objeto e organizar objetos em estruturas de árvore.


Estrutura em árvore QObject


Usar uma estrutura em árvore significa que cada objeto QObject pode ter um pai e zero ou mais filhos. O objeto pai controla a vida útil dos objetos filhos. No exemplo a seguir, dois filhos serão excluídos automaticamente:


 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); //       

Infelizmente, até agora a maior parte da API Qt funciona apenas com ponteiros brutos. Estamos trabalhando nisso, e talvez em breve a situação mude para melhor, pelo menos parcialmente.


A interface da classe QObject permite obter uma lista de todos os objetos filhos e pesquisar por alguns critérios. Considere o exemplo de como obter uma lista de todos os objetos filhos:


 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 

O método QObject::children retorna uma lista de todos os filhos do objeto especificado. No entanto, geralmente é necessária uma pesquisa entre toda a subárvore de objetos por algum critério:


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

O exemplo acima demonstra como obter uma lista de todos os elementos filhos do tipo QObject cujo nome termina em 0. Ao contrário do método children , o método findChildren percorre a árvore recursivamente, ou seja, pesquisa toda a hierarquia de objetos. Esse comportamento pode ser alterado passando o Qt::FindDirectChildrenOnly .


Desvantagens da interface para trabalhar com elementos filho


À primeira vista, pode parecer que a interface para trabalhar com crianças seja bem pensada e flexível. No entanto, ele não está isento de falhas. Vamos considerar alguns deles:


  • Interface redundante
    Existem dois métodos findChildren diferentes (havia três não muito tempo atrás): o método findChild para encontrar um item e o método children. Todos eles se sobrepõem parcialmente.
  • É difícil mudar a interface
    O Qt garante compatibilidade binária e compatibilidade no nível do código fonte em uma única versão principal. Portanto, você não pode simplesmente alterar a assinatura de um método ou adicionar novos métodos.
  • A interface é difícil de expandir
    Além da violação da compatibilidade, é impossível, por exemplo, obter uma lista de elementos filhos de acordo com um critério especificado. Para adicionar essa funcionalidade, você deve aguardar a próxima versão ou criar outro método.
  • Sobre copiar todos os itens
    Freqüentemente, você só precisa percorrer a lista de todos os elementos filhos filtrados por um determinado critério. Para fazer isso, não é necessário retornar um contêiner de ponteiros para todos esses elementos.
  • Possível violação de SRP
    Essa é uma questão bastante controversa, no entanto, a necessidade de alterar a interface da classe para mudar, digamos, um método para atravessar crianças, parece estranha.

Usando o range-v3 para corrigir algumas falhas


O range-v3 é uma biblioteca que fornece componentes para trabalhar com intervalos de elementos. De fato, essa é uma camada adicional de abstração em relação aos iteradores clássicos, que permitem compor operações e tirar proveito dos cálculos preguiçosos.


Uma biblioteca de terceiros é usada porque, no momento da redação, não existem compiladores conhecidos pelo autor com suporte interno para essa funcionalidade. Talvez a situação mude em breve.


Para o QObject uso dessa abordagem nos permitirá separar as operações de percorrer a árvore de elementos filhos da classe e criar uma interface flexível para pesquisar objetos de acordo com um determinado critério, que pode ser facilmente modificado.


Exemplo de intervalos-v3


Para começar, considere um exemplo simples de uso da biblioteca. Antes de prosseguir com o exemplo, introduzimos uma notação abreviada para namespaces:


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

Agora considere um exemplo de um programa que imprime cubos de todos os números ímpares no intervalo [1, 10) na ordem inversa:


 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); 

Deve-se notar que todos os cálculos ocorrem preguiçosamente, isto é, conjuntos de dados temporários não são criados ou copiados. O programa acima é equivalente a isso, com exceção da formatação da saída:


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

Como você pode ver no exemplo acima, a biblioteca permite compor normalmente várias operações. Mais exemplos de uso podem ser encontrados nos diretórios tests e examples do repositório range-v3 .


Classe para representar uma sequência de filhos


A biblioteca range-v3 fornece classes auxiliares para criar várias classes de wrapper personalizadas; entre eles estão as classes da categoria de view . Essas classes são projetadas para representar uma sequência de elementos de uma certa maneira sem transformar e copiar a própria sequência. No exemplo anterior, a classe de filter foi usada para considerar apenas os elementos da sequência que correspondem aos critérios especificados.


Para criar essa classe para trabalhar com elementos filho de QObject, ela deve ser herdada dos ranges::view_facade classe auxiliares 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 

Vale ressaltar que a classe define automaticamente o método end_cursor , que retorna o sinal do final da sequência. Se necessário, esse método pode ser substituído.


Em seguida, definimos a própria classe do cursor. Isso pode ser feito dentro da classe children_view e além:


 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(); } //  }; 

O cursor definido acima é de passagem única. Isso significa que a sequência pode se mover apenas em uma direção e apenas uma vez. Para esta implementação, isso não é necessário, porque armazenamos uma sequência de todos os objetos filhos e podemos passar por eles em qualquer direção quantas vezes você quiser. Para indicar que você pode passar por uma sequência várias vezes, você deve implementar o seguinte método na classe do cursor:


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

Agora você precisa adicionar para garantir que a visualização criada possa ser incluída na composição. Para fazer isso, use a função auxiliar 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 

Agora você pode escrever este código:


 for (auto &&c : root | qt::children) { //     () } for (auto &&c : root | qt::find_children(Qt::FindDirectChildrenOnly)) { //     } 

Implementando a funcionalidade existente da classe QObject


Após implementar a classe de apresentação, você pode implementar facilmente toda a funcionalidade para trabalhar com crianças. Para fazer isso, você precisa implementar três funções:


 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 

Como um exemplo de uso, considere o seguinte código:


 for (auto &&c : root | qt::children | qt::with_type<Foo*>) { //       Foo } 

Conclusões intermediárias


Como pode ser julgado pelo código, agora é bastante simples estender a funcionalidade sem alterar a interface da classe. Além disso, todas as operações são representadas por funções separadas e podem ser organizadas na sequência desejada. Isso, entre outras coisas, melhora a legibilidade do código e evita o uso de funções com vários parâmetros na interface da classe. Também vale a pena notar o descarregamento da interface da classe e a redução no número de razões para alterá-la.


De fato, essa implementação já elimina quase todas as desvantagens listadas da interface, exceto que ainda precisamos copiar todos os filhos no contêiner. Uma maneira de resolver esse problema é usar corotinas.


Implementação lenta da travessia da árvore de objetos usando corotinas


Corotinas (corotinas) permitem pausar a função e retomar mais tarde. Você pode considerar essa tecnologia como algum tipo de máquina de estado finito.


No momento da redação deste artigo, a biblioteca padrão carece de muitos elementos importantes necessários para o uso confortável das corotinas. Portanto, propõe-se o uso de uma biblioteca cppcoro de terceiros, que provavelmente entrará no padrão de uma forma ou de outra.


Para começar, escreveremos funções que retornarão o próximo filho sob demanda:


 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 

A instrução co_yield retorna o valor para o código de chamada e pausa a corotina.


Agora integre esse código na classe children_view . O código a seguir mostra apenas os elementos que foram alterados:


 //   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()); } 

O cursor também deve ser modificado:


 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; }; 

O cursor aqui simplesmente atua como um invólucro em torno de um iterador regular. O restante do código pode ser usado como está, sem alterações adicionais.


Os perigos dos passeios preguiçosos pelas árvores


Vale a pena notar que a travessia preguiçosa da árvore das crianças nem sempre é segura. Isso se refere principalmente ao desvio de hierarquias complexas de elementos gráficos, por exemplo, widgets. O fato é que, no processo de travessia, a hierarquia pode ser reconstruída e alguns elementos são completamente removidos. Se você usar uma solução alternativa lenta neste caso, poderá obter resultados muito interessantes e imprevisíveis do programa.


Isso significa que, em alguns casos, é útil copiar todos os elementos em um contêiner. Para fazer isso, você pode usar a seguinte função auxiliar:


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

A rigor, nesse caso, não há necessidade de usar corotinas e você pode usar a visualização desde a primeira iteração.


Será no Qt


Talvez, mas não no próximo lançamento. Existem várias razões para isso:


  • O próximo grande lançamento, o Qt 6, exigirá e dará suporte oficialmente ao C ++ 17, mas não superior.
  • Não há como implementá-lo sem bibliotecas de terceiros.
  • Será relativamente difícil adaptar a base de código existente.
    Provavelmente, eles voltarão a esse problema como parte do lançamento do Qt 7.

Conclusão


A implementação proposta de percorrer a árvore de elementos filho facilita a adição de novas funcionalidades. Devido à separação das operações, é possível escrever um código mais limpo e remover elementos desnecessários da interface da classe.


Vale ressaltar que as duas bibliotecas utilizadas (range-v3 e cpp-coro) são fornecidas como arquivos de cabeçalho, o que simplifica o processo de compilação. No futuro, será possível ficar sem bibliotecas de terceiros.


No entanto, a abordagem descrita tem algumas desvantagens. Entre eles, pode-se notar a sintaxe incomum para muitos desenvolvedores, a relativa complexidade de implementação e preguiça, que podem ser perigosas em alguns casos.


Opcional


Código fonte


Agradecimentos especiais a Misha Svetkin ( Trilla ) por sua contribuição para a implementação e discussão do projeto.

Source: https://habr.com/ru/post/pt481402/


All Articles