所謂信号槽,實際就是觀察者模式。當某個事件發生之後,比如,按鈕檢測到自己被點選了一下,它就會發出一個信号(signal)。這種發出是沒有目的的,類似廣播。如果有對象對這個信号感興趣,它就會使用連接配接(connect)函數,意思是,用自己的一個函數(稱為槽(slot))來處理這個信号。也就是說,當信号發出時,被連接配接的槽函數會自動被回調。這就類似觀察者模式:當發生了感興趣的事件,某一個操作就會被自動觸發。(這裡提一句,Qt 的信号槽使用了額外的處理來實作,并不是 GoF 經典的觀察者模式的實作方式。)
經典的觀察者模式在講解舉例的時候通常會舉報紙和訂閱者的例子。有一個報紙類 Newspaper,有一個訂閱者類 Subscriber。Subscriber 可以訂閱 Newspaper。這樣,當 Newspaper 有了新的内容的時候,Subscriber 可以立即得到通知。在這個例子中,觀察者是 Subscriber,被觀察者是 Newspaper。在經典的實作代碼中,觀察者會将自身注冊到被觀察者的一個容器中(比如 subscriber.registerTo(newspaper))。被觀察者發生了任何變化的時候,會主動周遊這個容器,依次通知各個觀察者(newspaper.notifyAllSubscribers())。
在 Qt 5 中,QObject::connect() 有五個重載:
QMetaObject::Connection connect(const QObject *, const char *,
const QObject *, const char *,
Qt::ConnectionType);
QMetaObject::Connection connect(const QObject *, const QMetaMethod &,
const QObject *, const QMetaMethod &,
Qt::ConnectionType);
QMetaObject::Connection connect(const QObject *, const char *,
const char *,
Qt::ConnectionType) const;
QMetaObject::Connection connect(const QObject *, PointerToMemberFunction,
const QObject *, PointerToMemberFunction,
Qt::ConnectionType)
QMetaObject::Connection connect(const QObject *, PointerToMemberFunction,
Functor);
下面我們先來看看 connect() 函數最常用的一般形式:
// !!! Qt 5
connect(sender, signal,
receiver, slot);
這是我們最常用的形式。connect() 一般會使用前面四個參數,第一個是發出信号的對象,第二個是發送對象發出的信号,第三個是接收信号的對象,第四個是接收對象在接收到信号之後所需要調用的函數。也就是說,當 sender 發出了 signal 信号之後,會自動調用 receiver 的 slot 函數。
這是最常用的形式,我們可以套用這個形式去分析上面給出的五個重載。第一個,sender 類型是 const QObject *,signal 的類型是 const char *,receiver 類型是 const QObject *,slot 類型是 const char *。這個函數将 signal 和 slot 作為字元串處理。第二個,sender 和 receiver 同樣是 const QObject *,但是 signal 和 slot 都是 const QMetaMethod &。我們可以将每個函數看做是 QMetaMethod 的子類。是以,這種寫法可以使用 QMetaMethod 進行類型比對。第三個,sender 同樣是 const QObject *,signal 和 slot 同樣是 const char *,但是卻缺少了 receiver。這個函數其實是将 this 指針作為 receiver。第四個,sender 和 receiver 也都存在,都是 const QObject *,但是 signal 和 slot 類型則是 PointerToMemberFunction。看這個名字就應該知道,這是指向成員函數的指針。第五個,前面兩個參數沒有什麼不同,最後一個參數是 Functor 類型。這個類型可以接受 static 函數、全局函數以及 Lambda 表達式。
由此我們可以看出,connect() 函數,sender 和 receiver 沒有什麼差別,都是 QObject 指針;主要是 signal 和 slot 形式的差別。具體到我們的示例,我們的 connect() 函數顯然是使用的第五個重載,最後一個參數是 QApplication 的 static 函數 quit()。也就是說,當我們的 button 發出了 clicked() 信号時,會調用 QApplication 的 quit() 函數,使程式退出。
信号槽要求信号和槽的參數一緻,所謂一緻,是參數類型一緻。如果不一緻,允許的情況是,槽函數的參數可以比信号的少,即便如此,槽函數存在的那些參數的順序也必須和信号的前面幾個一緻起來。這是因為,你可以在槽函數中選擇忽略信号傳來的資料(也就是槽函數的參數比信号的少),但是不能說信号根本沒有這個資料,你就要在槽函數中使用(就是槽函數的參數比信号的多,這是不允許的)。
借助 Qt 5 的信号槽文法,我們可以将一個對象的信号連接配接到 Lambda 表達式,例如:
// !!! Qt 5
#include <QApplication>
#include <QPushButton>
#include <QDebug>
int main(int argc, char *argv[])
{
QApplication app(argc, argv);
QPushButton button("Quit");
QObject::connect(&button, &QPushButton::clicked, [](bool) {
qDebug() << "You clicked me!";
});
button.show();
return app.exec();
}
注意這裡的 Lambda 表達式接收一個 bool 參數,這是因為 QPushButton 的 clicked() 信号實際上是有一個參數的。Lambda 表達式中的 qDebug() 類似于 cout,将後面的字元串列印到标準輸出。如果要編譯上面的代碼,你需要在 pro 檔案中添加這麼一句:
QMAKE_CXXFLAGS += -std=c++0x
然後正常編譯即可。
自定義信号槽
下面我們看看使用 Qt 的信号槽,如何實作上述報紙和訂閱者的觀察者模式。注意,這裡我們僅僅是使用這個案例,我們的代碼并不是去實作一個經典的觀察者模式。也就是說,我們使用 Qt 的信号槽機制來獲得同樣的效果。
下面先總結一下自定義信号槽需要注意的事項:
發送者和接收者都需要是 QObject 的子類(當然,槽函數是全局函數、Lambda 表達式等無需接收者的時候除外);
使用 signals 标記信号函數,信号是一個函數聲明,傳回 void,不需要實作函數代碼;
槽函數是普通的成員函數,作為成員函數,會受到 public、private、protected 的影響;
使用 emit 在恰當的位置發送信号;
使用 QObject::connect() 函數連接配接信号和槽。
//!!! Qt5
#include <QObject>
// newspaper.h
class Newspaper : public QObject
{
Q_OBJECT
public:
Newspaper(const QString & name) :
m_name(name)
{
}
void send()
{
emit newPaper(m_name);
}
signals:
void newPaper(const QString &name);
private:
QString m_name;
};
// reader.h
#include <QObject>
#include <QDebug>
class Reader : public QObject
{
Q_OBJECT
public:
Reader() {}
void receiveNewspaper(const QString & name)
{
qDebug() << "Receives Newspaper: " << name;
}
};
// main.cpp
#include <QCoreApplication>
#include "newspaper.h"
#include "reader.h"
int main(int argc, char *argv[])
{
QCoreApplication app(argc, argv);
Newspaper newspaper("Newspaper A");
Reader reader;
QObject::connect(&newspaper, &Newspaper::newPaper,
&reader, &Reader::receiveNewspaper);
newspaper.send();
return app.exec();
}
當我們運作上面的程式時,會看到終端輸出 Receives Newspaper: Newspaper A 這樣的字樣。
下面我們來分析下自定義信号槽的代碼。
這段代碼放在了三個檔案,分别是 newspaper.h,reader.h 和 main.cpp。為了減少檔案數量,可以把 newspaper.h 和 reader.h 都放在 main.cpp 的 main() 函數之前嗎?答案是,可以,但是需要有額外的操作。具體問題,我們在下面會詳細說明。
首先看 Newspaper 這個類。這個類繼承了 QObject 類。隻有繼承了 QObject 類的類,才具有信号槽的能力。是以,為了使用信号槽,必須繼承 QObject。凡是 QObject 類(不管是直接子類還是間接子類),都應該在第一行代碼寫上 Q_OBJECT。不管是不是使用信号槽,都應該添加這個宏。這個宏的展開将為我們的類提供信号槽機制、國際化機制以及 Qt 提供的不基于 C++ RTTI 的反射能力。是以,如果你覺得你的類不需要使用信号槽,就不添加這個宏,就是錯誤的。其它很多操作都會依賴于這個宏。注意,這個宏将由 moc(我們會在後面章節中介紹 moc。這裡你可以将其了解為一種預處理器,是比 C++ 預處理器更早執行的預處理器。) 做特殊處理,不僅僅是宏展開這麼簡單。moc 會讀取标記了 Q_OBJECT 的頭檔案,生成以 moc_ 為字首的檔案,比如 newspaper.h 将生成 moc_newspaper.cpp。你可以到建構目錄檢視這個檔案,看看到底增加了什麼内容。注意,由于 moc 隻處理頭檔案中的标記了 Q_OBJECT 的類聲明,不會處理 cpp 檔案中的類似聲明。是以,如果我們的 Newspaper 和 Reader 類位于 main.cpp 中,是無法得到 moc 的處理的。解決方法是,我們手動調用 moc 工具處理 main.cpp,并且将 main.cpp 中的 include “newspaper.h” 改為 include “moc_newspaper.h” 就可以了。不過,這是相當繁瑣的步驟,為了避免這樣修改,我們還是将其放在頭檔案中。許多初學者會遇到莫名其妙的錯誤,一加上 Q_OBJECT 就出錯,很大一部分是因為沒有注意到這個宏應該放在頭檔案中。
Newspaper 類的 public 和 private 代碼塊都比較簡單,隻不過它新加了一個 signals。signals 塊所列出的,就是該類的信号。信号就是一個個的函數名,傳回值是 void(因為無法獲得信号的傳回值,是以也就無需傳回任何值),參數是該類需要讓外界知道的資料。信号作為函數名,不需要在 cpp 函數中添加任何實作(我們曾經說過,Qt 程式能夠使用普通的 make 進行編譯。沒有實作的函數名怎麼會通過編譯?原因還是在 moc,moc 會幫我們實作信号函數所需要的函數體,是以說,moc 并不是單純的将 Q_OBJECT 展開,而是做了很多額外的操作)。
Newspaper 類的 send() 函數比較簡單,隻有一個語句 emit newPaper(m_name);。emit 是 Qt 對 C++ 的擴充,是一個關鍵字(其實也是一個宏)。emit 的含義是發出,也就是發出 newPaper() 信号。感興趣的接收者會關注這個信号,可能還需要知道是哪份報紙發出的信号?是以,我們将實際的報紙名字 m_name 當做參數傳給這個信号。當接收者連接配接這個信号時,就可以通過槽函數獲得實際值。這樣就完成了資料從發出者到接收者的一個轉移。
Reader 類更簡單。因為這個類需要接受信号,是以我們将其繼承了 QObject,并且添加了 Q_OBJECT 宏。後面則是預設構造函數和一個普通的成員函數。Qt 5 中,任何成員函數、static 函數、全局函數和 Lambda 表達式都可以作為槽函數。與信号函數不同,槽函數必須自己完成實作代碼。槽函數就是普通的成員函數,是以作為成員函數,也會受到 public、private 等通路控制符的影響。(我們沒有說信号也會受此影響,事實上,如果信号是 private 的,這個信号就不能在類的外面連接配接,也就沒有任何意義。)
main() 函數中,我們首先建立了 Newspaper 和 Reader 兩個對象,然後使用 QObject::connect() 函數。這個函數我們上一節已經詳細介紹過,這裡應該能夠看出這個連接配接的含義。然後我們調用 Newspaper 的 send() 函數。這個函數隻有一個語句:發出信号。由于我們的連接配接,當這個信号發出時,自動調用 reader 的槽函數,列印出語句。
這樣我們的示例程式講解完畢。我們基于 Qt 的信号槽機制,不需要觀察者的容器,不需要注冊對象,就實作了觀察者模式。