Pull to refresh

Как работают сигналы и слоты в Qt (часть 2)

Reading time 11 min
Views 45K
Original author: Olivier Goffart


От переводчика: это вторая часть перевода статьи Olivier Goffart о внутренней архитектуре сигналов и слотов в Qt 5, перевод первой части тут.

Новый синтаксис в Qt5

Новый синтаксис выглядит так:

QObject::connect(&a, &Counter::valueChanged, &b, &Counter::setValue);

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

Новые перегруженные методы

Было сделано лишь несколько необходимых изменений, чтобы это работало. Основная идея заключается в новых перегрузках QObject::connect, которые в качестве аргументов принимают указатели на функции, вместо char*. Вот эти три новых метода (псевдокод):

QObject::connect(const QObject *sender, PointerToMemberFunction signal, const QObject *receiver, PointerToMemberFunction slot, Qt::ConnectionType type);
QObject::connect(const QObject *sender, PointerToMemberFunction signal, PointerToFunction method)
QObject::connect(const QObject *sender, PointerToMemberFunction signal, Functor method)

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

Указатель на функции-члены

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

// объявление myFunctionPtr указателем на функцию-член
// которая возвращает void и имеет один параметр int
void (QPoint::*myFunctionPtr)(int); 
myFunctionPtr = &QPoint::setX;
QPoint p;
QPoint *pp = &p;
(p.*myFunctionPtr)(5); // вызов p.setX(5);
(pp->*myFunctionPtr)(5); // вызов pp->setX(5);

Указатели на члены и указатели на функции-члены это обычная часть подмножества C++, которая не очень часто используется и поэтому менее известна. Хорошей новостью является то, что вам не нужно знать про это, чтобы использовать Qt и этот новый синтаксис. Всё, что вам необходимо запомнить, это то, что необходимо расположить & перед именем сигнала в вашем соединении. Вам не нужно справляться с магическими операторами ::*, .* или ->*. Эти магические операторы позволяют объявлять указатель на функцию-член и получать к нему доступ. Тип таких указателей включает возвращаемый тип, класс, которому принадлежит функция, типы всех аргументов и спецификатор const для функции.

Вы не можете конвертировать указатели на функции-члены во что-либо еще, в частности, к void, потому что они имеют различный sizeof. Если функция немного отличается в сигнатуре, у вас не получится конвертировать из одного в другое. К примеру, не допускается даже преобразование void (MyClass::*)(int) const в void (MyClass::*)(int) (вы можете это сделать с reinterpret_cast, но, в соответствии с стандартом, будет неопределённое поведение (undefined behaviour), если вы попробуете вызвать функцию).

Указатели на функции-члены это не только обычные указатели на функции. Обычный указатель на функцию это просто указатель с адресом, где расположен код функции. Но указателю на функцию-член нужно хранить больше информации: функция-член может быть виртуальной и также со смещением, если она будет скрытой, в случае множественного наследования. sizeof указателя на функцию-член может даже меняться, в зависимости от класса. Вот почему нам необходимо иметь особый случай для манипулирования ними.

Классы свойств типов (type traits): QtPrivate::FunctionPointer

Позвольте мне представить вам класс свойств типа QtPrivate::FunctionPointer. Класс свойств, в основном, это вспомогательный класс, который возвращает некоторые метаданные про данный тип. Другим примером класса свойств в Qt является QTypeInfo. То, что нужно нам знать в рамках реализации нового синтаксиса — это информация про указатель на функцию. template<typename T> struct FunctionPointer даст нам информацию о T через свои члены:
  • ArgumentCount — число, представляющее количество аргументов функции
  • Object — существует, только для указателей на функции-члены, это typedef класса, на функцию-член которого указывает указатель
  • Arguments — представляет список аргументов, typedef списка метапрограммирования
  • call(T &function, QObject *receiver, void **args) — статическая функция, которая вызывает функцию с переданными параметрами

Qt по прежнему поддерживает компилятор C++98, что означает, что мы, к сожалению, не можем требовать поддержку шаблонов с переменным числом аргументов (variadic template). Другими словами, мы должны специализировать нашу функцию для класса свойств для каждого числа аргументов. У нас есть четыре типа специализации: обычный указатель на функцию, указатель на функцию-член, указатель на константную функцию-член и функторы. Для каждого типа, нам необходима специализация для каждого числа аргументов. У нас есть поддержка до шести аргументов. У нас также есть специализация, которая использует шаблоны с переменным числом аргументов, для произвольного числа аргументов, если компилятор поддерживает шаблоны с переменным числом аргументов. Реализация FunctionPointer расположена в qobjectdefs_impl.h.

QObject::connect

Реализация зависит от большого количества шаблонного кода. Я не буду объяснять всё это. Вот код первой новой перегрузки из qobject.h:

template <typename Func1, typename Func2>
static inline QMetaObject::Connection connect(
    const typename QtPrivate::FunctionPointer<Func1>::Object *sender, Func1 signal,
    const typename QtPrivate::FunctionPointer<Func2>::Object *receiver, Func2 slot,
    Qt::ConnectionType type = Qt::AutoConnection)
{
  typedef QtPrivate::FunctionPointer<Func1> SignalType;
  typedef QtPrivate::FunctionPointer<Func2> SlotType;

  // ошибка при компиляции, если есть несоответствие аргументов
  Q_STATIC_ASSERT_X(int(SignalType::ArgumentCount) >= int(SlotType::ArgumentCount),
                    ""The slot requires more arguments than the signal provides."");
  Q_STATIC_ASSERT_X((QtPrivate::CheckCompatibleArguments<typename SignalType::Arguments,
                                                         typename SlotType::Arguments>::value),
                    ""Signal and slot arguments are not compatible."");
  Q_STATIC_ASSERT_X((QtPrivate::AreArgumentsCompatible<typename SlotType::ReturnType,
                                                       typename SignalType::ReturnType>::value),
                    ""Return type of the slot is not compatible with the return type of the signal."");

  const int *types;

  /* ... пропущена инициализация типов, используемых для QueuedConnection ...*/

  QtPrivate::QSlotObjectBase *slotObj = new QtPrivate::QSlotObject<Func2,
        typename QtPrivate::List_Left<typename SignalType::Arguments, SlotType::ArgumentCount>::Value,
        typename SignalType::ReturnType>(slot);


  return connectImpl(sender, reinterpret_cast<void **>(&signal),
                     receiver, reinterpret_cast<void **>(&slot), slotObj,
                     type, types, &SignalType::Object::staticMetaObject);
}


Вы заметили в сигнатуре функции, что sender и receiver не просто QObject* как указывает документация. На самом деле, это указатели на typename FunctionPointer::Object. Для создания перегрузки, которая включается только для указателей на функции-члены, используется SFINAE, потому что Object существует в FunctionPointer, только если тип будет указателем на функцию-член.

Затем мы начинаем с кучей Q_STATIC_ASSERT. Они должны генерировать осмысленные ошибки при компиляции, когда пользователь сделал ошибку. Если пользователь сделал что-то не так, важным будет чтобы он видел ошибку тут, а не в лапше шаблонного кода в _impl.h файлах. Мы хотим скрыть внутреннюю реализацию, чтобы пользователь не беспокоился о ней. Это означает, что если вы когда-то видите непонятную ошибку в деталях реализации, она должна быть рассмотрена как ошибка, о которой нужно сообщить.

Далее, мы создаем экземпляр QSlotObject, который затем будет передан в connectImpl(). QSlotObject это обёртка над слотом, которая поможет вызвать его. Она также знает тип аргументов сигнала и может сделать подходящее преобразование типа. Мы используем List_Left только передавая то же количество аргументов, как в слоте, что позволяет соединять сигнал со слотом, у которого количество аргументов меньше, чем у сигнала.

QObject::connectImpl это закрытая внутренняя функция, которая выполнит соединение. Она имеет синтаксис, похожий на оригинальный, с отличием, что вместо хранения индекса метода в структуре QObjectPrivate::Connection, мы храним указатель на QSlotObjectBase.

Причина, почему мы передаём &slot как void** в том, чтобы иметь возможность сравнить его, если тип Qt::UniqueConnection. Мы также передаём &signal как void**. Это указатель на указатель на функцию-член.

Индекс сигнала

Нам необходимо сделать связь между указателем на сигнал и индексом сигнала. Мы используем MOC для этого. Да, это означает, что этот новый синтаксис всё еще использует MOC и что нет планов избавиться от этого :-). MOC будет генерировать код в qt_static_metacall, который сравнивает параметр и возвращает правильный индекс. connectImpl будет вызывать функцию qt_static_metacall с указателем на указатель на функцию.

void Counter::qt_static_metacall(QObject *_o, QMetaObject::Call _c, int _id, void **_a)
{
    if (_c == QMetaObject::InvokeMetaMethod) {
        /* .... пропущено ....*/
    } else if (_c == QMetaObject::IndexOfMethod) {
        int *result = reinterpret_cast<int *>(_a[0]);
        void **func = reinterpret_cast<void **>(_a[1]);
        {
            typedef void (Counter::*_t)(int );
            if (*reinterpret_cast<_t *>(func) == static_cast<_t>(&Counter::valueChanged)) {
                *result = 0;
            }
        }
        {
            typedef QString (Counter::*_t)(const QString & );
            if (*reinterpret_cast<_t *>(func) == static_cast<_t>(&Counter::someOtherSignal)) {
                *result = 1;
            }
        }
        {
            typedef void (Counter::*_t)();
            if (*reinterpret_cast<_t *>(func) == static_cast<_t>(&Counter::anotherSignal)) {
                *result = 2;
            }
        }
    }
}

Теперь, имея индекс сигнала, мы может работать с синтаксисом, похожим на предыдущий.

QSlotObjectBase

QSlotObjectBase это объект, передаваемый в connectImpl, который отражает слот. Прежде чем показывать текущий код, вот QObject::QSlotObjectBase, который был в Qt5 alpha:

struct QSlotObjectBase {
    QAtomicInt ref;
    QSlotObjectBase() : ref(1) {}
    virtual ~QSlotObjectBase();
    virtual void call(QObject *receiver, void **a) = 0;
    virtual bool compare(void **) { return false; }
};

Это в основном интерфейс, который предназначен для повторной реализации через шаблонные классы, реализующие вызов и сравнение указателей на функции. Это реализовано одним из шаблонных классов QSlotObject, QStaticSlotObject или QFunctorSlotObject.

Фальшивая виртуальная таблица

Проблема в том, что при каждом инстанцировании такого объекта нужно создать виртуальную таблицу, которая будет содержать не только указатель на виртуальные функции но и много информации, нам не нужной, такой как RTTI. Это привело бы к большому количеству лишних данных и разрастанию двоичных файлов. Чтобы этого избежать, QSlotObjectBase был изменён, чтобы не быть полиморфным классом. Виртуальные функции эмулируются вручную.

class QSlotObjectBase {
  QAtomicInt m_ref;
  typedef void (*ImplFn)(int which, QSlotObjectBase* this_,
                         QObject *receiver, void **args, bool *ret);
  const ImplFn m_impl;
protected:
  enum Operation { Destroy, Call, Compare };
public:
  explicit QSlotObjectBase(ImplFn fn) : m_ref(1), m_impl(fn) {}
  inline int ref() Q_DECL_NOTHROW { return m_ref.ref(); }
  inline void destroyIfLastRef() Q_DECL_NOTHROW {
    if (!m_ref.deref()) m_impl(Destroy, this, 0, 0, 0);
  }

  inline bool compare(void **a) { bool ret; m_impl(Compare, this, 0, a, &ret); return ret; }
  inline void call(QObject *r, void **a) {  m_impl(Call,    this, r, a, 0); }
};

m_impl это обычный указатель на функцию, выполняющий три операции, которые ранее были предыдущими виртуальные функции. Повторные реализации устанавливаются для работы в конструкторе.

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

Защищённые, открытые и закрытые сигналы

Сигналы были защищены (protected) в Qt4 и ранее. Это был выбор дизайна, что сигналы должны передаваться объектом, когда изменяется его состояние. Они не должны вызыватся извне объекта и вызов сигнала из другого объекта почти всегда плохая идея.

Однако, с новым синтаксисом, вы должны быть в состоянии получить адрес сигнала в точке создания вами соединения. Компилятор будет позволять вам сделать это только если вы будете иметь доступ к сигналу. Написание &Counter::valueChanged будет генерировать ошибку при компиляции, если сигнал не был открытым.

В Qt5 нам пришлось изменить сигналы от защищённых к открытым. К сожалению, это означает, что каждый может испускать сигналы. Мы не нашли способ исправить это. Мы пробовали трюк с ключевым словом emit. Мы пытались возвращать специальное значение. Но ничего не работало. Я верю, что преимущества нового синтаксиса преодолеют проблемы, когда сигналы сейчас открыты.

Иногда это даже желательно иметь сигнал закрытым. Это тот случай, например, в QAbstractItemModel, где в противном случае, разработчики, как правило, испускают сигнал в производном классе, который не является тем, что хочет API. Они использовали трюк с препроцессором, который сделал сигналы закрытыми, но сломал новый синтаксис соединения.

Был введён новый хак. QPrivateSignal это пустая структура, объявленная закрытой в макросе Q_OBJECT. Она может быть использована в качестве последнего параметра сигнала. Так как она является закрытой, только объект имеет право на ее создания для вызова сигнала. MOC проигнорирует последний аргумент QPrivateSignal во время создания информации о сигнатуре. Посмотрите qabstractitemmodel.h для примера.

Больше шаблонного кода

Остаток кода в qobjectdefs_impl.h и qobject_impl.h. Это, в основном, скучный шаблонный код. Я не буду больше вдаваться глубоко в подробности в этом посте, но я пройдусь по нескольким пунктам, которые стоит упомянуть.

Список метапрограммирования

Как было указано ранее, FunctionPointer::Arguments это список аргументов. Код должен работать с этим списком: итерировать поэлементно, получить только часть его или выбрать данный элемент. Вот, почему QtPrivate::List может представлятся списком типов. Некоторыми вспомогательными классами для него есть QtPrivate::List_Select и QtPrivate::List_Left, которые возвращают N-ый элемент в списка и часть списка, содержащую первые N элементов.

Реализация List отличается для компиляторов, которые поддерживают шаблоны с переменным числом параметров и которые их не поддерживают. С шаблонами с переменным числом параметров:

template<typename... T> struct List;

Список аргументов просто скрывает шаблонные параметры. Для примера, тип списка, содержащего аргументы (int, Qstring, QObject*) будет таким:

List<int, QString, QObject *>

Без шаблонов с переменным числом параметров, это будет выглядеть в LISP-стиле:

template<typename Head, typename Tail > struct List;

Где Tail может быть любым другим List или void, для конца списка. Предыдущий пример в этом случае выглядит так:

List<int, List<QString, List<QObject *, void>>>

Уловка ApplyReturnValue

В функции FunctionPointer::call, args[0] предназначен для получения возвращаемого значения слота. Если сигнал возвращает значение, это будет указатель на объект с типом возвращаемого значения сигнала, в противном случае 0. Если слот возвращает значение, мы должны копировать его в arg[0]. Если же это void, мы ничего не делаем.

Проблема в том, что синтаксически некорректно использовать возвращаемое значение функции, которая возвращает void. Должен ли я дублировать огромное количество кода: один раз для возвращаемого значения void и другой – для значения, отличного от void? Нет, спасибо оператору «запятая».

В C++ вы можете делать так:

functionThatReturnsVoid(), somethingElse();

Вы можете заменить запятую на точку с запятой и всё это было бы хорошо. Интересным это становится, когда вы вызываете это с чем-то, отличным от void:

functionThatReturnsInt(), somethingElse();

Тут, запятая будет вызываемым оператором, который вы даже можете перегрузить. Это то, что мы делаем в qobjectdefs_impl.h:

template <typename T>
struct ApplyReturnValue {
    void *data;
    ApplyReturnValue(void *data_) : data(data_) {}
};

template<typename T, typename U>
void operator,(const T &value, const ApplyReturnValue<U> &container) {
    if (container.data)
        *reinterpret_cast<U*>(container.data) = value;
}
template<typename T>
void operator,(T, const ApplyReturnValue<void> &) {}

ApplyReturnValue это просто обёртка над void*. Теперь, это может быть использовано в нужной вспомогательной сущности (helper). Вот пример случая, для функтора без аргументов:

static void call(Function &f, void *, void **arg) {
    f(), ApplyReturnValue<SignalReturnType>(arg[0]);
}

Этот код встроенный (inline), поэтому не будет ничего стоить в плане производительности во время исполнения.
Tags:
Hubs:
+28
Comments 17
Comments Comments 17

Articles