Речь в статье пойдет об основной жиле 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(); абсолютно идентична предыдущей.
Пользовательские типы данных в сигналах и слотах
Для начала, я приведу код, который будет использоваться в качестве примере на протяжении всего параграфа о пользовательских типах. Код будет изменяться и дополняться по мере продвижения по параграфу.
Класс 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.
Итоги
В качестве итога я хотел бы резюмировать основные моменты статьи:
- Всегда используйте каноническую форму сигнатур сигналов и слотов в функции connect
- Регистрируйте пользовательский тип для использования в QVariant и непрямых соединениях с помощью Q_DECLARE_METATYPE
- Регистрируйте имя типа для использования его в непрямых соединениях с помощью qRegisterMetaType
- Всегда указывайте пространство имен в аргументах сигнала, если тип принадлежит не глобальному пространству имен.