Интересные места метасистемы Qt

Речь в статье пойдет об основной жиле Qt фреймворка – метасистеме. А точнее той её части, которая показалось мне неочевидной и вызывающей вопросы. Часть ответов дана в документации, но, зачастую, не открытым текстом: да и искать эти ответы, размазанные по документации работа не очень благодарная. Все ниже описанное справедливо для пользовательских типов любой сложности. Материал подразумевает понимание парадигмы слотов и сигналов в Qt, а также некоторый опыт работы с ними.

Сигналы и слоты, что же это на самом деле?

Сигналы и слоты являются простыми функциями из языка С++, без какой-либо магии. Работа с сигналами и слотами сводиться к следующим шагам:

  • Объявление сигнала/слота в интерфейсе класса
  • Соединение слота с сигналом
  • Вызов сигнала

Рассмотрим каждый пункт по отдельности:

Объявление сигнала/слота в интерфейсе класса

Выглядит это, обычно, следующим образом:

class Foo: public QObject
{
    Q_OBJECT
public slots:
    int fooSlot(const std::vector<int>& Arg);
signals: 
    void fooSignal();    
};

Что же этот синтаксис значит на деле? slots используется MOC’ом(прилржение из Qt фреймворка ответственное за формирования метаданных) для формирования списка слотов класса и в C++ превращается в пустое место. Таким образом fooSlot есть ни что иное как публичный метод класса Foo. signals используется MOC’ом для формирования списка сигналов класса и в С++ превращается в ключевое слово protected.Таким образом, fooSignal является protected методом класса Foo.

#   define slots
#   define signals protected

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

Есть еще одно свойство, которое необходимо учитывать в процессе объявления слотов и сигналов: при разборе сигнатуры сигналов и слотов MOC копирует сигнатуру аргументов буквально, отбрасывая все модификаторы C++ типа: const, volatile. & и т.д. Следовательно, сигнатура сигнала fooSlot будет выглядеть следующим образом в метаданных: int fooSlot(std::vector<int>), назовем эту форму канонической. Очень важно помнить и понимать, что сигнатура слотов и сигналов должна полностью совпадать по канонической форме. Исходя из этого, если вы используете в класса Foo using namespace std; и записываете слот как  int fooSlot(const vector<int>& Arg); вы должны сделать тоже самое для сигнала, к которому будет присоединён этот слот. На деле, конечно же, слоты надо адаптировать к сигналу, а не наоборот; но в моём примере именно так. Если этот пример выглядит надуманным, то читайте дальше, вы увидите более реальный пример.

Соединение слота с сигналом

Для соединения слотов и сигналов используется функция connect, чья сигнатура, в вольной интерпретации выглядит следующим образом: connect(отправитель, сигнал, получатель, слот, тип соединения). Следует упомянуть, что вместо слота, может быть поставлен сигнал, что позволит прокидывать сигнал дальше; очень удобная техника: connect(отправитель, сигнал, получатель, сигнал, тип соединения).

Отправитель и получатель ничем не примечательны и я опущу их описание. Остановимся же на слоте, сигнале и типе соединения. Слот передаётся в функцию с помощью макроса SLOT, сигнал передается с помощью макроса SIGNAL. Эти макросы просты до безобразия:

# define SLOT(a)     "1"#a
# define SIGNAL(a)   "2"#a

Из этих макросов видно, что все, что в них передается в результате превращается в строку. Т.е. чтобы вы не написали между скобок макроса будет, впоследствии, использовано буквально. Но и это еще не все: переданная строка, уже в функции connect, приводится к канонической форме, после чего происходит поиск сигнала или слота(в зависимости от прикрепленной цифры). А это значит, что лучше всего сразу писать в канонической форме. Это позволит как улучшить читабельность записи с connect, так и улучшить производительность за счет избавления Qt от необходимости нормализировать сигнатуру(сомнительно). Если взять fooSlot из предыдущего примера, то макрос для него будет выглядеть следующим образом:  SLOT(fooSlot(std::vector<int>). Это читается гораздо проще, чем SLOT(fooSlot(const std::vector<int>&), не правда ли? Ну и экстраполируя это на 2-4 параметра, мы получаем несомненный выигрыш в читабельности.

Тип соединения влияет на то, как слот(или сигнал) получателя будет вызван; есть два варианта: прямой и отложенный соединения(на самом деле их больше, но нас интересуют только эти два типа, т.к. остальные являются их модификациями, в той или иной мере). За них отвечают флаги Qt::DirectConnection и Qt::QueuedConnection соответственно. При прямом соединении слот вызывается прямо из вызова сигнала, т.е. является блокирующим по своей природе. При отложенном соединении при вызове сигнала происходит создание события типа QMetaCallEvent и посредством QCoreApplication::postEvent событие передается получателю. Для этого аргументы сигнала будут скопированы и метасистеме Qt требуется знать имена типов объектов, которые переданы в аргументах сигнала, для их корректной обработки, впоследствии. Этот случай мы рассмотрим более подробно чуть позже.

Вызов сигнала

Тут все просто. Для вызова сигнала используется ключевое слово emit: emit fooSignal(); Но на деле, emit является макросом раскрывающимся в ничто Улыбка Следовательно, запись fooSignal();  абсолютно идентична предыдущей.

# define emit

 

Пользовательские типы данных в сигналах и слотах

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

Класс Accepter мы будем использовать в качестве получателя сигнала:

Accepter.h

#pragma once

#include <QObject>
#include <QtDebug>
#include "Emitter.h"

namespace accepter
{
    class Accepter: public QObject
    {
        Q_OBJECT
    public slots:
        void Accept(const emitter::A& ObjA)
        {
            qDebug() << ObjA.m_i;
        }
    };
}

Класс Emitter мы будем использовать в качестве источника сигнала:

Emitter.h

namespace emitter
{
    struct A
    {
        int m_i;
        A(int i = 0): m_i(i)
        {}
    };

    class Emitter: public QObject
    {
        Q_OBJECT
    public:
        void EmitSignal()
        {
            A a(3);
            emit Signal(a);
        }
    signals:
        void Signal(const A& ObjA);
    };
}

Наконец, файл содержащий точку входа main - для собрания, ранее объявленных, классов воедино.

main.cpp

#include <QApplication>
#include <QtDebug>
#include "Accepter.h"
#include "Emitter.h"

int main(int argc, char**argv)
{
    QApplication App(argc, argv);

    emitter::Emitter Em;
    accepter::Accepter Acc;
    bool IsConnected = false;
    IsConnected = QObject::connect(&Em, SIGNAL(Signal(A)), 
        &Acc, SLOT(Accept(A)));
    qDebug() << IsConnected;
   
    return App.exec();
}

Если собрать и запустить этот проект, то мы получим следующую запись в консоле вывода:

Object::connect: No such slot accepter::Accepter::Accept(A)

Так как мы уже знаем о том, как разбираются сигнатуры сигналов и слотов, мы можем быстро обнаружить ошибку: она заключается в том, что в сигнатуре сигнала отсутствует указание пространства имен(namespace) emitter, в то время как в сигнатуре слота указание пространства имен присутствует. От этого никуда не уйти, т.к. Emitter и Acceptor находятся в разных пространствах имени мы вынуждены использовать указание оного в слоте(да я знаю о using namespace. но это противоречит самому смыслу пространств имен). В связи с этим я хочу ввести правило: Если аргумент сигнала находится в каком-либо именованном пространстве имен всегда указывайте его в сигнале, даже если в этом нет необходимости.

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

Emitter.h

void Signal(const emitter::A& ObjA);

main.cpp

IsConnected = QObject::connect(&Em, SIGNAL(Signal(emitter::A)), 
	&Acc, SLOT(Accept(emitter::A)));

Теперь, собрав проект, мы не получим никаких проблем.

QVariant и пользовательские типы

Часто требуется преобразование между QVariant и пользовательским типом, т.к. объекты этого класса широко используются в Qt. Исправим код функции main, добавив преобразование объекта класса A в объект класса QVariant:

main,cpp

emitter::A a;
QVariant Var = QVariant::fromValue(a);

Попробовав собрать проект мы получим ошибку компиляции, т.к. QVariant не знает как обрабатывать наш тип. Для того, чтобы QVariant знал, как работать с типом его необходимо сделать известным для метасистемы Qt. Для этого существует макрос Q_DECLARE_METATYPE. Для использования в метасистеме Qt тип должен обладать публичными конструктором по умолчанию, конструктором копирования и деструктором.

Добавим заголовок #include <QMetaType> в Emitter.h и

Q_DECLARE_METATYPE(emitter::A);

в конце файла. Важно знать, что Q_DECLARE_METATYPE должен располагаться вне пространств имен.

После добавления регистрации, мы можем использовать объекты типа emitter::A в преобразованиях в и из QVariant.

 

Итак, объекты нашего типа emitter::A могут быть использованы в QVariant. могут быть использованы в сигналах… Стоп, мы использовали только прямое соединение сигнала со слотом, т.к. значение по умолчанию типа соединения  есть флаг Qt::AutoConnection. Этот флаг означает Qt::DirectConnection если поток-источник сигнала  не отличается от потока объекта получателя и Qt::QueuedConnection в противном случае. А это означает, что необходимо проверить еще и отложенное соединение. Изменим код соответствующим образом:

main.cpp

IsConnected = QObject::connect(&Em, SIGNAL(Signal(emitter::A)), 
	&Acc, SLOT(Accept(emitter::A)), Qt::QueuedConnection);

Соберем проект и получим следующий вывод в консоле:

QObject::connect: Cannot queue arguments of type 'emitter::A'
(Make sure 'emitter::A' is registered using qRegisterMetaType().)
false

Сообщение предельно ясно и решение написано прямо в описании ошибки, молодцы разработчики. Меняем код согласно инструкции:

Emitter.h

Emitter()
{
    qRegisterMetaType<A>();
}

Я предпочитаю регистрировать типы в конструкторе того класса, в сигналах которого эти типы используются. Поэтому я зарегистрировал тип A в конструкторе класса Emitter. Поcле этого все работает как часы. И, казалось бы, на этом можно закончить статью, но у функции

qRegisterMetaType<Type>() 

есть перегруженная версия

qRegisterMetaType<Type> (const char * typeName)

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

Я думал также, и в результате получил несколько часов не очень приятного времяпрепровождения с дебагером.

Чтобы понять почему может понадобиться использовать qRegisterMetaType (const char * typeName) приведу измененный код:

Emitter.h

#pragma once

#include <QObject>
#include <QMetaType>

namespace emitter
{
    struct A
    {
        int m_i;
        A(int i = 0): m_i(i)
        {}
    };

    class Emitter: public QObject
    {
        Q_OBJECT
    public:
        Emitter()
        {
            qRegisterMetaType<A>();
        }
        void EmitSignal()
        {
            A a(3);
            emit Signal(a);
            emit Signal2(a);
        }
    signals:
        void Signal(const emitter::A& ObjA);
        void Signal2(const A& ObjA);
    };
}

Q_DECLARE_METATYPE(emitter::A);

main.cpp

#include <QApplication>
#include <QtDebug>
#include <QtTest/QSignalSpy>
#include "Emitter.h"

using namespace emitter;

int main(int argc, char**argv)
{
    QApplication App(argc, argv);
    emitter::Emitter Em;
   
    QSignalSpy Spy(&Em, SIGNAL(Signal2(A)));
    Em.EmitSignal();
    
    auto Arguments = Spy.takeFirst();
    qDebug() << "Rcvd value:" << Arguments[0].value<A>().m_i;
   
    return App.exec();
}

Собираем проект, запускаем и… получаем в выводе следующее:

Don't know how to handle 'A', use qRegisterMetaType to register it.
Rcvd value: 0

Ух ты, а мы ведь зарегистрировали этот тип! Что ты, тупое Qt, от меня хочешь? – примерно так я думал во время дебага.

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

Emitter.h

qRegisterMetaType<A>("A");

И вывод будет:

Rcvd value: 3

Внимательный читатель уже, наверное, понял в чем тут проблема. А проблема все в том, что мы имеем сигнал void Signal2(const A& ObjA), тогда как qRegisterMetaType<A> регистрирует тип с именем emitter::A. Qt все делает верно! А вот я наступил на грабли, по имени пространство имен. Т.к. я не написал еще класс, который использует этот сигнал, а использовал его лишь в тестах, то и не обратил внимания на пространство имен в аргументе сигнала.

Такое может случиться и в случае использования using namespace в заголовке. Что позволит не прописывать пространство имен в слоте явно. Это еще одно доказательство того, что всегда нужно прописывать пространство имен в аргументах сигнала явно, даже если в этом нет необходимости прямо сейчас. При соблюдении этого правила вы будете избавлены от неприятных ошибок, а также от необходимости использования расширенной версии qRegisterMetaType.

Итоги

В качестве итога я хотел бы резюмировать основные моменты статьи:

  1. Всегда используйте каноническую форму сигнатур сигналов и слотов в функции connect
  2. Регистрируйте пользовательский тип для использования в QVariant и непрямых соединениях с помощью Q_DECLARE_METATYPE
  3. Регистрируйте имя типа для использования его в непрямых соединениях с помощью qRegisterMetaType
  4. Всегда указывайте пространство имен в аргументах сигнала, если тип принадлежит не глобальному пространству имен.