こんにちは。田原です。

Qtの重要な仕組みSignalとSlotについて今回からの数回で解説しようと思います。Signal/Slotはイベントを通知する仕組みです。イベント(Signal)を受取る側がイベントを発行する側へイベント・リスナー(Slot)を登録(connect)します。この仕組を使ってモジュール間の依存性を双方向から片方向へ容易にシュリンクすることができます。また意外なことにお手軽なスレッド間通信機能も提供しています。この2つの全く異なる機能を1つのconnectで実現するので意外に分かりにくいです。その辺りに光を当てれるようトライしてみます。

今回は前半のSignal/Slot/connectの基本的な使い方を説明します。

1.Signal/Slot/connectの基本

まず、Signalとは(原則として)QObjectを継承したクラス(A)が発行するイベントです。何らかの情報を外部へ伝達したい時に発行します。そしてクラスAを使っているクラス(B)は自身のSlot関数へSignalを接続しておく(connect)ことで、クラスAがSignalを発行するとクラスBのSlotが実行されるという仕組みです。

1-1. Signalについて

Signalは signals: に続けてメンバ関数として宣言します。
実体はMOC(注1)により自動生成されるので記述してはいけません。また signals: に続いて宣言したメンバ関数は全てpublicです。クラス内部だけで用いるSignalを使いたい時もあるのでprivateにしたい時もあるのですが、残念ながらできないようです。

#include <QObject>
#include <QTimer>

class SubModule : public QObject
{
    Q_OBJECT

public:
    explicit SubModule();
    void startTimer();

signals:
    void signal0();

private:
    QTimer      mTimer0;
};

上記のsignal0を発行するには、下記のように emit signal0(); と書きます。(注2)

#include "SubModule.h"

SubModule::SubModule()
{
    // Timer0設定
    mTimer0.setSingleShot(true);    // 1回だけ計測
    mTimer0.setInterval(1000);      // 1秒タイマ
    connect(&mTimer0, &QTimer::timeout,
        [this]()
        {
            emit signal0();
        });
}

void SubModule::startTimer()
{
    mTimer0.start();
}

(これらは、後述するサンプル・ソースの一部です。)

(注1) MOCについて
MOCとはMeta-Object Compilerの略で、あなたのソース・コードを解析して自動的に補完ソースを生成するプリコンパイラです。上述のSubModuleクラスのようにQ_OBJECTマクロを含むクラスについて補完ソースが自動生成されます。

例えば、上記SubModule.hに対応する補完ソースは以下の通りです。signal0()の実体が定義されています。

/****************************************************************************
** Meta object code from reading C++ file 'SubModule.h'
**
** Created by: The Qt Meta Object Compiler version 67 (Qt 5.15.0)
**
** WARNING! All changes made in this file will be lost!
*****************************************************************************/

#include <memory>
#include "../../05.signal-slot-01/SubModule.h"
#include <QtCore/qbytearray.h>
#include <QtCore/qmetatype.h>
#if !defined(Q_MOC_OUTPUT_REVISION)
#error "The header file 'SubModule.h' doesn't include <QObject>."
#elif Q_MOC_OUTPUT_REVISION != 67
#error "This file was generated using the moc from 5.15.0. It"
#error "cannot be used with the include files from this version of Qt."
#error "(The moc has changed too much.)"
#endif

QT_BEGIN_MOC_NAMESPACE
QT_WARNING_PUSH
QT_WARNING_DISABLE_DEPRECATED
struct qt_meta_stringdata_SubModule_t {
    QByteArrayData data[3];
    char stringdata0[19];
};
#define QT_MOC_LITERAL(idx, ofs, len) \
    Q_STATIC_BYTE_ARRAY_DATA_HEADER_INITIALIZER_WITH_OFFSET(len, \
    qptrdiff(offsetof(qt_meta_stringdata_SubModule_t, stringdata0) + ofs \
        - idx * sizeof(QByteArrayData)) \
    )
static const qt_meta_stringdata_SubModule_t qt_meta_stringdata_SubModule = {
    {
QT_MOC_LITERAL(0, 0, 9), // "SubModule"
QT_MOC_LITERAL(1, 10, 7), // "signal0"
QT_MOC_LITERAL(2, 18, 0) // ""

    },
    "SubModule\0signal0\0"
};
#undef QT_MOC_LITERAL

static const uint qt_meta_data_SubModule[] = {

 // content:
       8,       // revision
       0,       // classname
       0,    0, // classinfo
       1,   14, // methods
       0,    0, // properties
       0,    0, // enums/sets
       0,    0, // constructors
       0,       // flags
       1,       // signalCount

 // signals: name, argc, parameters, tag, flags
       1,    0,   19,    2, 0x06 /* Public */,

 // signals: parameters
    QMetaType::Void,

       0        // eod
};

void SubModule::qt_static_metacall(QObject *_o, QMetaObject::Call _c, int _id, void **_a)
{
    if (_c == QMetaObject::InvokeMetaMethod) {
        auto *_t = static_cast<SubModule *>(_o);
        Q_UNUSED(_t)
        switch (_id) {
        case 0: _t->signal0(); break;
        default: ;
        }
    } else if (_c == QMetaObject::IndexOfMethod) {
        int *result = reinterpret_cast<int *>(_a[0]);
        {
            using _t = void (SubModule::*)();
            if (*reinterpret_cast<_t *>(_a[1]) == static_cast<_t>(&SubModule::signal0)) {
                *result = 0;
                return;
            }
        }
    }
    Q_UNUSED(_a);
}

QT_INIT_METAOBJECT const QMetaObject SubModule::staticMetaObject = { {
    QMetaObject::SuperData::link<QObject::staticMetaObject>(),
    qt_meta_stringdata_SubModule.data,
    qt_meta_data_SubModule,
    qt_static_metacall,
    nullptr,
    nullptr
} };


const QMetaObject *SubModule::metaObject() const
{
    return QObject::d_ptr->metaObject ? QObject::d_ptr->dynamicMetaObject() : &staticMetaObject;
}

void *SubModule::qt_metacast(const char *_clname)
{
    if (!_clname) return nullptr;
    if (!strcmp(_clname, qt_meta_stringdata_SubModule.stringdata0))
        return static_cast<void*>(this);
    return QObject::qt_metacast(_clname);
}

int SubModule::qt_metacall(QMetaObject::Call _c, int _id, void **_a)
{
    _id = QObject::qt_metacall(_c, _id, _a);
    if (_id < 0)
        return _id;
    if (_c == QMetaObject::InvokeMetaMethod) {
        if (_id < 1)
            qt_static_metacall(this, _c, _id, _a);
        _id -= 1;
    } else if (_c == QMetaObject::RegisterMethodArgumentMetaType) {
        if (_id < 1)
            *reinterpret_cast<int*>(_a[0]) = -1;
        _id -= 1;
    }
    return _id;
}

// SIGNAL 0
void SubModule::signal0()
{
    QMetaObject::activate(this, &staticMetaObject, 0, nullptr);
}
QT_WARNING_POP
QT_END_MOC_NAMESPACE

(注2) emitについて
emitは仕組み的にsignal関数を呼び出すだけなので他のクラスが発行することもできます。しかし、冒頭に記述したようにsignalはそれを定義しているクラスのイベントを伝達するものですので、特殊な事情が無い限り「signalを宣言したクラスだけが当該signalを発行する」のが望ましいと思います。Qtの公式でもそのように推奨されています

1-2. Slotについて

(非staticな)メンバ関数、もしくは、関数オブジェクトをSlotとして指定できます。メンバ関数の場合は、private slots:protected slots:public slots: に続けて通常のメンバ関数宣言と同じように記述します。private, protected, publicは通常のprivate, protected, publicと同じ意味です。
Slot関数はSlot関数を宣言したクラスでconnectして使うケースが多いと思います。この時はprivateで十分です。しかし、稀には他のクラスでconnect先へ指定するケースも無いわけでは有りません。そのような時はpublicを指定します。同様にprotectedに呼び出したい(基底クラスのSlot関数へ派生クラス側からconnect)時もあるでしょう。
また、通常のメンバ関数としても呼び出せますので、その時は通常のメンバ関数と同様にprivate/public/protectedを指定することになります。

以下は、通常のメンバ関数型のSlotを SubModuleのsignal0 へ接続(connect)するサンプルです。

#include "WindowBase.h"
#include "SubModule.h"

class MainWindow : public WindowBase
{
    Q_OBJECT

public:
    explicit MainWindow();

private slots:
    void signal0Slot();

private:
    SubModule   mSubModule;
    void setLabelTimeout(QString const iText);
};

上記のsignal0を発行するには、下記のように emit signal0();と書きます。(注2)

#include "MainWindow.h"

MainWindow::MainWindow() :
    WindowBase("qrc:/MainWindow.qml")
{
    connect(&mSubModule, &SubModule::signal0, this, &MainWindow::signal0Slot);
}

void MainWindow::signal0Slot()
{
    setLabelTimeout("signal0");
}

void MainWindow::setLabelTimeout(QString const iText)
{
    bool ret = QMetaObject::invokeMethod
        (
            mQmlWindow, "setLabelTimeout",
            Qt::DirectConnection,
            Q_ARG(QVariant, iText)
        );
    qDebug() << "setLabelTimeout():" << iText << ret;
}

(これらは、後述するサンプル・ソースの一部です。)

1-3. connectについて

connectはざっくり3種類あります。(正確には6種類ですが細かい相違を説明すると混乱するのでここでは割愛します。)

種別 Signal Slot
①文字列型 SIGNAL(“メンバ関数名(引数の型の並び)”) SLOT(“メンバ関数名(引数の型の並び)”)
②メンバ関数型 メンバ関数ポインタ メンバ関数ポインタ
③関数オブジェクト型 メンバ関数ポインタ 関数オブジェクト

(この3つの分類は田原オリジナルです。他では通じないと思いますが、ご容赦下さい。)

メンバ関数を指定する時は、そのメンバ関数が属するクラスのインスタンスも一緒に指定します。
関数オブジェクトの時はインスタンスを指定する必要はありませんが、指定することもできます。(指定するケースについてはスレッドが絡むので次回以降で解説します。)

そして、どの指定方法でもSignalが発行されたらSlotが呼び出されるという機能は同じです。その際に使われる仕組みが2種類(直接呼び出し or キュー経由で実行)ありますが、ここでは単にSignalされたらSlotが呼ばれるとご理解下さい。(詳しくは次回)

C++の強力な型チェック機能を使えるので、ほとんどのケースでは②メンバ関数型か③関数オブジェクト型を使うのが望ましいと思います。ただ、QMLからのSignalを接続する際は①文字列型しか使えません。(QMLの関数はJavaScriptで定義されるため、C++の型を持たないからだと思います。)そのようなケースでは文字列型を使うことになります。

②メンバ関数型と③関数オブジェクト型は機能的にはほぼ同じです。Slotの定義方法が異なるだけです。
ラムダ式は関数内で定義できるので結構便利です。そして、ラムダ式は関数オブジェクトですから、③関数オブジェクト型を使えばラムダ式でSlotを定義できます。なかなか便利です。

最後に、connectは、QObjectのメンバ関数やstaticメンバ関数です。

1-4. disconnectについて(おまけ)

connectがあるのなら、disconnectもあります。ただ、ほとんどのケースで関連するインスタンスがデストラクトされる際に自動的にdisconnectされるので明示的にdisconnectすることは少ないです。
しかし、もし明示的にdisconnectしたい場合はconnectの戻り値を指定してdisconnectすると便利です。(connect時と全く同じ記述を行ってdisconnectすることもできますが、結構面倒です。)

1-5. 「モジュール間の依存性を双方向から片方向へ」とは

クラスAがクラスBの機能を使う時、クラスAがクラスBのメンバ関数を呼び出すと思います。そして、クラスBで非同期処理を行っているような場合、クラスBからクラスAへ何か報告(例えば処理終了とか状態変化通知とか)を上げたいことがあります。
クラスAはクラスBを使うのでクラスBのヘッダをインクルードするのは当然ですね。そして、クラスBはクラスAへ報告を上げますのでクラスAの報告用関数を呼び出すためにクラスAのヘッダをインクルードしたくなります。するとヘッダの相互参照となります。クラスBのヘッダでクラスAを前方宣言したり、クラスBはクラスAのインスタンスへのポインタや参照を保持する必要があり、あれやこれやと本当に面倒です。

しかし、QtのSignal/Slotは比較的お手軽にこれを避けることが出来ます。
1-2, 1-3のサンプルではMainWindowはSubModule.hをインクルードしますが、SubModuleはMainWindow.hをインクルードしないで済みます。そして非同期報告をMainWindowへ上げることができています。

更に、お手軽に記述できることも嬉しいです。
一般に、クラスBからクラスAの報告関数呼び出しを実装する際にクラスBがクラスAのヘッダをインクルードしないことは可能ですが、ちょっとばかり面倒です。少なくとも1つのコールバック関数を登録できる関数ポインタを用意し、初期化/登録/登録解除/多重登録対処などを実装する必要があります。
これに対して、上記のようにSignalを定義するのは非常に簡単です。signal: に続いてメンバ関数の宣言文を記述し、イベント発生時にそれをemitするだけですから。

2.サンプル・ソース

以上のSignal/Slot、および、3種のconnectを使う簡単なサンプル・ソースを作ってみました。(前々回前回のサンプルを微修正して作ってます。)

MainWindowがSubModuleを使用します。Startボタンを押すと”waitting”と表示して、SubModuleのstartTimer()を呼び出します。SubModuleは1秒後にsignal0、2秒後にsignal1を発行します。MainWindowはそれぞれのsignalを受取ると”signal0″、”signal1″と表示します。

QML側でStartボタンを押した時に、startButtonSignal()が発行されます。これを①文字列型のconnectで接続しています。
signal0は②関数ポインタ型connect、signal1は③関数オブジェクト型connectで接続しています。

今回のサンプルの主要部分のソースです。全体はGistにアップロードしていますので参考にして下さい。(Windows/Mac/Ubuntuでテストしています。)




MainWindowはSubModuleを使ってますのでSubModule.hをインクルードしていますが、SubModuleはMainWindow.hをインクルードしないで済んでいることに注目下さい。もしも、安易に相互参照する場合はどのように記述するか、もしくは、Signal/Slotを使わないで相互参照を回避するならどのように記述するかについて、ちょっとだけ思いを馳せて頂けると幸いです。

3.最後に

C#等の言語ならばお手軽に相互参照することもできますが、C++はC言語と同じく何らかのシンボルを使用する時はソース・コード上で「先に」に宣言なり定義なりしておかないと使えません。そのため、相互参照する時にはそれなりに工夫する必要があり、いつも頭の痛い思いをしていました。しかし、QtのSignal/Slotを使うことでお手軽に相互参照(相互依存)を(しかも)本質的に回避できます。この性質はSignal/Slotの大きな有用性の一つと思います。
次回はもう一つのSignal/Slotの有用性について解説したいと思います。

それでは今回はこの辺で終わります。お疲れさまでした。