Ссылки на статьи

Предисловие

В предыдущей статье я описал способ вызова слота посредством очереди обработки сигнально-слотовых соединений Qt (она же очередь событий). Но совсем забыл про такую штуку, как QMetaObject::invokeMethod. А ведь эта штука позволяет добиться такого же эффекта (вызов метода в потоке-владельце QObject), но без необходимости создания сигнала.

Реализация

Самое простое, что можно сделать

Самый простой способ исправить мою оплошность — удалить из класса сигнал, и заменить его излучение вызовом QMetaObject::invokeMethod.

Заголовок
class MetaInvokeBasedAsyncQDebugPrinter : public QObject {
private:
    Q_OBJECT
    class PrinterMessage { /*...*/ };
private:
    std::queue<PrinterMessage> m_messages;
    std::mutex m_mutex;
public:
    explicit MetaInvokeBasedAsyncQDebugPrinter(QObject *parent = nullptr);
    QFuture<void> print(const QString& message);
private slots:
    void handleNextMessage();
private:
    void emplaceTaskInQueue(PrinterMessage&& task);
    std::queue<PrinterMessage> takeTaskQueue();
};

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

Реализация

MetaInvokeBasedAsyncQDebugPrinter::MetaInvokeBasedAsyncQDebugPrinter(QObject *parent)
    :QObject{ parent } { }

QFuture<void> MetaInvokeBasedAsyncQDebugPrinter::print(const QString &message) {
    auto task = PrinterMessage{ message };
    auto future = task.promise().future();
    emplaceTaskInQueue(std::move(task));
    QMetaObject::invokeMethod(this, &MetaInvokeBasedAsyncQDebugPrinter::handleNextMessage, Qt::ConnectionType::QueuedConnection);
    return future;
}
void MetaInvokeBasedAsyncQDebugPrinter::handleNextMessage() {
    auto buffer{ takeTaskQueue() };
    while(not buffer.empty()) {
        qDebug() << buffer.front().message();
        buffer.front().promise().finish();
        buffer.pop();
    }
}

void MetaInvokeBasedAsyncQDebugPrinter::emplaceTaskInQueue(PrinterMessage &&task) {
    std::lock_guard locker{ m_mutex };
    m_messages.emplace(std::move(task));
}

std::queue<MetaInvokeBasedAsyncQDebugPrinter::PrinterMessage> MetaInvokeBasedAsyncQDebugPrinter::takeTaskQueue() {
    std::queue<PrinterMessage> buffer;
    std::lock_guard locker{ m_mutex };
    m_messages.swap(buffer);
    return buffer;
}

На самом деле это — отличный подход. В отличие от варианта с сигналами, он не содержит лишних сущностей, и при этом делает всё тоже самое.

Немного поиграемся

А теперь почитаем документацию, и увидим, что QMetaObject::invokeMethod позволяет передавать параметры методов, что очень удобно для нас.

И если искушённые читатели думают, что мы сейчас опустимся в развитии до уровня Qt4, и будем использовать строковые литералы и макросы Q_ARG, то они правы. К сожалению, нормального способа передавать аргументы я не знаю, поэтому воспользуемся несколько некрасивым, зато рабочим и лаконичным.

Это позволит нам в принципе отказаться от очереди, но т.к. вся метаобъектная система Qt построена на десятках тысяч копирований (предполагается, что передаваемые типы либо лёгкие, либо обладают cow-оптимизацией), то небходимо будет обернуть некопируемый PrinterMessage в std::shared_ptr, дабы тот мог быть передан посредством мета-вызова.

.h-файл
class MetaInvokeBasedAsyncQDebugPrinter : public QObject {
private:
    Q_OBJECT
    class PrinterMessage { 
      static const bool m_isMetatypeRegistred;
      /**/ 
    };
public:
    explicit MetaInvokeBasedAsyncQDebugPrinter(QObject *parent = nullptr);
    QFuture<void> print(const QString& message);
private slots:
    void handleNextMessage(std::shared_ptr<PrinterMessage> message);
};

Отметим, что интерфейс класса сильно упростился. Теперь в нём только конструктор, метод print, добавляющий задание на вывод и метод handleNextMessage, это задание выполняющий.

Также в классе PrintMessage добавляется статическое поле m_isMetatypeRegistred. Это поле нужно, чтобы зарегистрировать std::shared_ptr<PrinterMessage> как тип, доступный для передачи в метасистеме.

Регистрация метатипа std::shared_ptr<PrinterMessage>
const bool MetaInvokeBasedAsyncQDebugPrinter::PrinterMessage::m_isMetatypeRegistred = []() -> bool {
    qRegisterMetaType<std::shared_ptr<MetaInvokeBasedAsyncQDebugPrinter::PrinterMessage>>("std::shared_ptr<PrinterMessage>");
    return true;
}();//тут же происходит вызов лямбды 

Эта регистрация строится на основе того, что статические поля инициализируются один, и ровно один раз.

Реализация Active object
MetaInvokeBasedAsyncQDebugPrinter::MetaInvokeBasedAsyncQDebugPrinter(QObject *parent)
    :QObject{ parent } {}

QFuture<void> MetaInvokeBasedAsyncQDebugPrinter::print(const QString &message) {
    auto task = std::make_shared<PrinterMessage>(message);
    auto future = task->promise().future();

    QMetaObject::invokeMethod(this,
        "handleNextMessage",
        Qt::ConnectionType::QueuedConnection,
        Q_ARG(std::shared_ptr<PrinterMessage>, task));

    return future;
}
void MetaInvokeBasedAsyncQDebugPrinter::handleNextMessage(std::shared_ptr<PrinterMessage> message) {
    qDebug() << message->message();
    message->promise().finish();
}

Как видим, всё стало куда проще.
1. Конструктор просто задаёт родителя.
2. handleNextMessage обрабатывает задачу (исключён этап работы с очередью задач)
3. print создаёт задачу, достаёт future на неё, и вызывает QMetaObject::invokeMethod.

Заключение

Таким образом мы пришли к одной из самых лаконичных реализаций Active object, возможных в Qt.

Это не самая быстрая реализация, как раз за счёт копирований std::shared_ptr в разных потоках, что будет дёргать кучу атомарных переменных с семантикой acquire/release.

Но тем не менее, этой реализации вам более чем хватит для абсолютно любых прикладных задач, где необходим Active object.

Можно дополнительно покопаться в мета-системе Qt, доставать метод-обработчик в классе QMetaMethod, и вызывать его там. Но лично я считаю, что это лишнее. Да и от использования строковых литералов это нас не избавит.

Комментарии (2)


  1. freedbrt
    24.01.2023 00:43
    +1

    К сожалению, нормального способа передавать аргументы я не знаю

    Это делается через лямбду

    QMetaObject::invokeMethod(this, [=]() {
      handleNextMessage(task);
    }, Qt::QueuedConnection);

    Если нужен возврат то так

    int someResult;
    QMetaObject::invokeMethod(this, [=]() {
      return handleNextMessage(task);
    }, Qt::QueuedConnection, &someResult);


    1. TheGast Автор
      24.01.2023 02:20

      Лямбду можно использовать. Только надо не забыть несколько пунктов:

      • task — move-only класс. Поэтому просто захватит всё по значению — не вариант.

      • Не забываем про mutable, т.к. handleNextMessage изменяет состояние task (вызывает task.promise().finish()).

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

      • Qt::DirectConnection — вызывать callable сразу из потока, который дёрнул QMetaObject::invokeMethod. Нам не подходит, т.к. Active object предполагает разделение вызова операции, и её выполнения.

      • Qt::BlockingQueuedConnection — callable исполняется в том потоке, который владеет context (указатель this в вызове QMetaObject::invokeMethod), а поток, вызвавший метод будет заблокирован до конца выполнения callable. Крайне отвратительный подход.

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

      Метод print
      QFuture<void> MetaInvokeBasedAsyncQDebugPrinter::print(const QString &message) {
          auto task = PrinterMessage{ message };
          auto future = task.promise().future();
      
          QMetaObject::invokeMethod(this,
          [task_object = std::move(task), this]() mutable {
              return this->handleNextMessage(task_object);
          }, Qt::QueuedConnection);
      
          return future;
      }

      Ну и теперь у нас прекратит собираться. Причина тому — попытка moc обернуть handleNextMessage в метасистему. Поэтому нужен новый подход в передаче данных. Есть два способа исправить это:

      • Пусть handleNextMessage принимает task по ссылке из лямбды.

      • Убрать пометку "slots" с handleNextMessage. Макроса Q_INVOKABLE тоже быть не должно. Но т.к. это приватный метод, и никто, нигде и никогда его вызывать не должен, то это разумно. Ну и можно принимать task хоть по значению (ибо всё равно move-only), хоть по r-value ссылке.

      Ну и если возникнет идея обернуть этот callable в какой-нибудь std::bind для красоты — не вариант, потому что std::move_only_function нам пока не дали.