Заметки по языку с и с++

Q_OBJECT и метаобъектная система Qt: как работает moc «под капотом»

Введение

Каждый класс, использующий сигналы, слоты или систему свойств Qt, должен содержать макрос Q_OBJECT в своём объявлении — но что именно этот макрос делает, и почему без него код, использующий сигналы/слоты, просто не компилируется или не работает? Ответ — в метаобъектной системе Qt (Meta-Object System) и инструменте moc (Meta-Object Compiler), который генерирует дополнительный C++ код во время сборки. В статье разберём, что происходит «под капотом» при использовании Q_OBJECT.

Концепция

Стандарт C++ не предоставляет встроенной рефлексии — невозможно «на лету» узнать список методов класса, их сигнатуры или вызвать метод по имени без специальной поддержки. Qt решает эту проблему собственным препроцессором — moc, который анализирует заголовочные файлы, находящие макрос Q_OBJECT, и генерирует дополнительный исходный файл (moc_classname.cpp), содержащий реализацию QMetaObject для класса — таблицу сигналов, слотов, свойств и Q_INVOKABLE-методов с информацией об их именах, сигнатурах и адресах. Именно эта сгенерированная информация позволяет connect() находить нужный слот по указателю на функцию (в новом синтаксисе) или по строковому имени (в старом, SIGNAL/SLOT синтаксисе), а системе свойств — обращаться к Q_PROPERTY по имени из QML или QObject::setProperty().

Пример кода: Q_OBJECT и метаобъектная система Qt

// widget.h — класс с Q_OBJECT, сигналами и свойством
#pragma once
#include <QObject>

class Widget : public QObject
{
    Q_OBJECT
    Q_PROPERTY(int value READ value WRITE setValue NOTIFY valueChanged)

public:
    explicit Widget(QObject *parent = nullptr) : QObject(parent) {}

    int value() const { return m_value; }
    void setValue(int value)
    {
        if (m_value == value) return;
        m_value = value;
        emit valueChanged(value);
    }

signals:
    void valueChanged(int value);

public slots:
    void reset() { setValue(0); }

private:
    int m_value = 0;
};
// Демонстрация возможностей, предоставляемых метаобъектной системой
#include "widget.h"
#include <QMetaObject>
#include <QMetaMethod>
#include <QMetaProperty>
#include <QDebug>

void inspectMetaObject(Widget *widget)
{
    const QMetaObject *meta = widget->metaObject();

    qDebug() << "Имя класса:" << meta->className();

    qDebug() << "Методы:";
    for (int i = 0; i < meta->methodCount(); ++i) {
        QMetaMethod method = meta->method(i);
        qDebug() << "  " << method.methodSignature()
                  << "тип:" << (method.methodType() == QMetaMethod::Signal ? "сигнал" : "слот/метод");
    }

    qDebug() << "Свойства:";
    for (int i = 0; i < meta->propertyCount(); ++i) {
        QMetaProperty prop = meta->property(i);
        qDebug() << "  " << prop.name() << "=" << prop.read(widget);
    }

    // Вызов метода по имени без статической ссылки на него — то самое,
    // что невозможно в "чистом" C++ без рефлексии
    QMetaObject::invokeMethod(widget, "reset");

    // Динамическая установка свойства по строковому имени
    widget->setProperty("value", 42);
    qDebug() << "Значение после setProperty:" << widget->property("value").toInt();
}

Пояснения к коду

widget->metaObject() возвращает указатель на QMetaObject, сгенерированный moc для класса Widget — через него можно перечислить все методы (включая сигналы, слоты и обычные Q_INVOKABLE-методы) и все объявленные через Q_PROPERTY свойства, причём эта информация доступна в рантайме, без необходимости знать конкретные имена на этапе компиляции вызывающего кода.

QMetaObject::invokeMethod(widget, "reset") вызывает метод reset() по строковому имени — это именно тот механизм, на котором строится, например, динамическое связывание QML-кода с C++ объектами, или старый синтаксис connect(sender, SIGNAL(...), receiver, SLOT(...)). Аналогично, setProperty()/property() позволяют читать и писать значения свойств по имени без статической типизации на этапе компиляции — то, что используется системой привязок QML «под капотом» для связывания Q_PROPERTY с QML-выражениями.

Подводные камни

  • Забытый запуск moc для заголовочного файла с Q_OBJECT приводит к ошибкам линковки вида «undefined reference to vtable» — это случается, когда система сборки не настроена автоматически запускать moc для файла (например, при добавлении Q_OBJECT в файл, который система сборки ранее не considерировала нуждающимся в moc), и решается явным указанием файла в HEADERS CMake/qmake или включением AUTOMOC.
  • Q_OBJECT в классе без наследования от QObject (прямого или через цепочку наследования) не компилируется — макрос предполагает наличие виртуальных методов QObject, переопределяемых в сгенерированном moc-коде, и не может быть применён к произвольному классу самостоятельно.
  • Множественное наследование от двух классов, оба наследующих QObject, не поддерживается — Qt допускает только одиночное наследование от QObject (с возможностью дополнительного наследования от классов без QObject, через виртуальное наследование интерфейсов на основе чистых абстрактных классов, рассмотренное в одной из следующих статей).
  • Накладные расходы метаобъектной системы для очень «горячих» путей кода. Вызов через QMetaObject::invokeMethod() или setProperty()/property() по строковому имени медленнее прямого вызова метода или обращения к полю — для кода, чувствительного к производительности и не требующего динамической диспетчеризации (известный заранее тип и метод), прямой вызов через указатель на функцию предпочтительнее.