Qt信號與槽實現原理

本文使用 ISO C++ 一步一步實現了一個極度簡化的信號與槽的系統 (整個程序4個文件共121行代碼) 。希望能有助於剛進入Qt世界的C++用戶理解Qt最核心的信號槽與元對象系統是如何工作的。

另:你可能會對 從 C++ 到 Qt   一文感興趣

dbzhang800 2011.04.30

注:Qt5 staging倉庫已經引入一種全新的信號與槽的語法:信號可以和普通的函數、類的普通成員函數、lambda函數連接(而不再侷限於信號函數和槽函數),詳見 信號與槽的新語法(Qt5) dbzhang800 2011.06.15

Qt信號與槽

GUI程序中,當我們我們點擊一個按鈕時,我們會期待我們自定義的某個函數被調用。對此,較老的工具集(toolkits)都是通過回調函數(callback)來實現的,Qt的神奇之處就在於,它使用信號(signal)與槽(slot)的技術來取代了回調。

在繼續之前,我們先看一眼最最常用的 connnect 函數:

connect(btn, "2clicked()", this, "1onBtnClicked()")

可能你會覺得稍有點眼生,因爲爲了清楚起見,我沒有直接使用大家很熟悉的SIGNAL和SLOT兩個宏,宏定義如下:

  •  # define SLOT(a)     "1"#a
     # define SIGNAL(a)   "2"#a

     

程序運行時,connect藉助兩個字符串,即可將信號與槽的關聯建立起來,那麼,它是如果做到的呢?C++的經驗可以告訴我們:

  • 類中應該保存有信號和槽的字符串信息
  • 字符串和信號槽函數要關聯

而這,就是通過神奇的元對象系統所實現的(Qt的元對象系統預處理器叫做moc,對文件預處理之後生成一個moc_xxx.cpp文件,然後和其他文件一塊編譯即可)。

接下來,我們不妨嘗試用純C++來實現自己的元對象系統(我們需要有一個自己的預處理器,本文中用雙手來代替了,預處理生成的文件是db_xxx.cpp)。

繼續之前,我們可以先看一下我們最終的類定義

 

class Object    
{    
    DB_OBJECT  
public:    
    Object();    
    virtual ~Object();    
    static void db_connect(Object *, const char *, Object *, const char *);    
    void testSignal();    
db_signals:    
    void sig1();    
public db_slots:    
    void slot1();    
friend class MetaObject;    
private:    
     ConnectionMap connections;    
};  

 

引入元對象系統

首先定義自己的信號和槽

  • 爲了和普通成員進行區別(以使得預處理器可以知道如何提取信息),我們需要創造一些"關鍵字"
  • db_signals
  • db_slots
class Object
{
public:
    Object();
    virtual ~Object();
db_signals:
    void sig1();
public db_slots:
    void slot1();
};
  • 通過自己的預處理器,將信息提取取來,放置到一個單獨的文件中(比如db_object.cpp):
  • 規則很簡單,將信號和槽的名字提取出來,放到字符串中。可以有多個信號或槽,按順序"sig1/nsig2/n"
static const char sig_names[] = "sig1/n";
static const char slts_names[] = "slot1/n";
  • 這些信號和槽的信息,如何才能與類建立關聯,如何被訪問呢?

我們可以定義一個類,來存放信息:

struct MetaObject
{
    const char * sig_names;
    const char * slts_names;
};

然後將其作爲一個Object的靜態成員(注意哦,這就是我們的元對象啦 ):

class Object
{
    static MetaObject meta;
...

這樣一來,我們的預處理器可以生成這樣的 db_object.cpp 文件:

#include "object.h"

static const char sig_names[] = "sig1/n";
static const char slts_names[] = "slot1/n";
MetaObject Object::meta = {sig_names, slts_names};

信息提取的問題解決了:可是,還有一個嚴重問題,我們定義的關鍵字 C++ 編譯器不認識啊,怎麼辦?

呵呵,好辦,通過定義一下宏,問題是不是解決了:

  • # define db_slots
    # define db_signals protected

     

建立信號槽鏈接

我們的最終目的就是:當信號被觸發的時候,能找到並觸發相應的槽。所以有了信號和槽的信息,我們就可以建立信號和槽的連接了。我們通過 db_connect 將信號和槽的對應關係保存到一個 mutlimap 中:

struct Connection
{
    Object * receiver;
    int method;
};

class Object
{
public:
...
    static void db_connect(Object*, const char*, Object*, const char*);
...
private:
    std::multimap<int, Connection> connections;

上面應該不需要什麼解釋了,我們直接看看db_connect該怎麼寫:

void Object::db_connect(Object* sender, const char* sig, Object* receiver, const char* slt)
{
    int sig_idx = find_string(sender->meta.sig_names, sig);
    int slt_idx = find_string(receiver->meta.slts_names, slt);
    if (sig_idx == -1 || slt_idx == -1) {
        perror("signal or slot not found!");
    } else {
        Connection c = {receiver, slt_idx};
        sender->connections.insert(std::pair<int, Connection>(sig_idx, c));
    }
}

首先從元對象信息中查找信號和槽的名字是否存在,如果存在,則將信號的索引和接收者的信息存入信號發送者的的一個map中。如果信號或槽無效,就什麼都不用做了。

我們這兒定義了一個find_string函數,就是個簡單的字符串查找(此處就不列出了)。

信號的激活

連接信息有了,我們看看信號到底是怎麼發出的。

在 Qt 中,我們都知道用 emit 來發射信號:

class Object
{
public:
    void testSignal()
...
};

void Object::testSignal()
{
    db_emit sig1();
}

這兒 db_emit 是神馬東西?C++編譯器不認識啊,沒關係,看仔細嘍,加一行就行了

#define db_emit

從前面我的Object定義中可以看到,所謂的信號或槽,都只是普普通通的C++類的成員函數。既然是成員函數,就需要函數定義:

  • 槽函數:由於它包含我們需要的功能代碼,我們都會想到在 object.cpp 文件中去定義它,不存在問題。
  • 信號函數:它的函數體不需要自己編寫。那麼它在哪兒呢?這就是本節的內容了

信號函數由我們的"預處理器"來生成,也就是它要定義在我們的 db_object.cpp 文件中:

void Object::sig1()
{
    MetaObject::active(this, 0);
}

我們預處理源文件時,就知道它是第幾個信號。所以根據它的索引去調用和它關聯的槽即可。具體工作交給了MetaObject類:

class Object;
struct MetaObject
{
    const char * sig_names;
    const char * slts_names;

    static void active(Object * sender, int idx);
};

這個函數該怎麼寫呢:思路很簡單

  • 從前面的保存連接的map中,找出與該信號關聯的對象和槽
  • 調用該對象這個槽
typedef std::multimap<int, Connection> ConnectionMap;
typedef std::multimap<int, Connection>::iterator ConnectionMapIt;

void MetaObject::active(Object* sender, int idx)
{
    ConnectionMapIt it;
    std::pair<ConnectionMapIt, ConnectionMapIt> ret;
    ret = sender->connections.equal_range(idx);
    for (it=ret.first; it!=ret.second; ++it) {
        Connection c = (*it).second;
        //c.receiver->metacall(c.method);
    }
}

補遺:

槽的調用

這個最後一個關鍵問題了,槽函數如何根據一個索引值進行調用。

  • 直接調用槽函數我們都知道了,就一個普通函數
  • 可現在通過索引調用了,那麼我們必須定義一個接口函數
class Object
{
    void metacall(int idx);
...

該函數如何實現呢?這個又回到我們的元對象預處理過程中了,因爲在預處理的過程,我們能將槽的索引和槽的調用關聯起來。

所以,在預處理生成的文件(db_object.cpp)中,我們很容易生成其定義:

void Object::metacall(int idx)
{
    switch (idx) {
        case 0:
            slot1();
            break;
        default:
            break;
    };
}

至此,我們已經實現的一個簡化的自己的信號與槽的程序。下面我們總體上看看程序的所有代碼:

全家福

  • 類定義文件 object.h
#ifndef DB_OBJECT  
#define DB_OBJECT  
#include <map>  
# define db_slots  
# define db_signals protected  
# define db_emit  
class Object;  
struct MetaObject  
{  
    const char * sig_names;  
    const char * slts_names;  
    static void active(Object * sender, int idx);  
};  
struct Connection  
{  
    Object * receiver;  
    int method;  
};  
typedef std::multimap<int, Connection> ConnectionMap;  
typedef std::multimap<int, Connection>::iterator ConnectionMapIt;  
class Object  
{  
    static MetaObject meta;  
    void metacall(int idx);  
public:  
    Object();  
    virtual ~Object();  
    static void db_connect(Object*, const char*, Object*, const char*);  
    void testSignal();  
db_signals:  
    void sig1();  
public db_slots:  
    void slot1();  
friend class MetaObject;  
private:  
     ConnectionMap connections;  
};  
#endif  
  • 類實現文件 object.cpp
#include <stdio.h>  
#include <string.h>  
#include "object.h"  
Object::Object()  
{  
}  
Object::~Object()  
{  
}  
static int find_string(const char * str, const char * substr)  
{  
    if (strlen(str) < strlen(substr))  
        return -1;  
    int idx = 0;  
    int len = strlen(substr);  
    bool start = true;  
    const char * pos = str;  
    while (*pos) {  
        if (start && !strncmp(pos, substr, len) && pos[len]=='/n')  
            return idx;  
        start = false;  
        if (*pos == '/n') {  
            idx++;  
            start = true;  
        }  
        pos++;  
    }  
    return -1;  
}  
void Object::db_connect(Object* sender, const char* sig, Object* receiver, const char* slt)  
{  
    int sig_idx = find_string(sender->meta.sig_names, sig);  
    int slt_idx = find_string(receiver->meta.slts_names, slt);  
    if (sig_idx == -1 || slt_idx == -1) {  
        perror("signal or slot not found!");  
    } else {  
        Connection c = {receiver, slt_idx};  
        sender->connections.insert(std::pair<int, Connection>(sig_idx, c));  
    }  
}  
void Object::slot1()  
{  
    printf("hello dbzhang800!");  
}  
void MetaObject::active(Object* sender, int idx)  
{  
    ConnectionMapIt it;  
    std::pair<ConnectionMapIt, ConnectionMapIt> ret;  
    ret = sender->connections.equal_range(idx);  
    for (it=ret.first; it!=ret.second; ++it) {  
        Connection c = (*it).second;  
        c.receiver->metacall(c.method);  
    }  
}  
void Object::testSignal()  
{  
    db_emit sig1();  
}  
  • 我們自己的預處理需要生成這樣一個文件 db_object.cpp
  • 注意看這個文件:其實內容非常簡單
    • 將信號和槽的信息存放到字符串中 ==>按順序排放,所以有了索引值
    • 信號發射 其實就是 信號函數==> 信號的索引
    • metacall 其實就是 槽的索引==> 槽函數
#include "object.h"  
static const char sig_names[] = "sig1/n";  
static const char slts_names[] = "slot1/n";  
MetaObject Object::meta = {sig_names, slts_names};  
void Object::sig1()  
{  
    MetaObject::active(this, 0);  
}  
void Object::metacall(int idx)  
{  
    switch (idx) {  
        case 0:  
            slot1();  
            break;  
        default:  
            break;  
    };  
}  
  • 最後,我們可以寫一個小小的例子main.cpp :
 #include "object.h"  
int main()  
{  
    Object obj1, obj2;  
    Object::db_connect(&obj1, "sig1", &obj2, "slot1");  
    obj1.testSignal();  
    return 0;;  
}  
  • 程序的編譯就不用多數了,用你熟悉的msvc或者g++
cl main.cpp object.cpp db_object.cpp -o dbzhang800
g++ main.cpp object.cpp db_object.cpp -o dbzhang800

零零散散,寫在後面

我不確定是不是已經元對象系統和信號槽最基本的概念表達清楚了。反正我想,如果你對Qt感興趣,相對Qt的信號和槽進一步的瞭解,但是目前尚對閱讀Qt的源碼覺得無比恐怖,本文可能會對你有幫助。

文中將東西精簡到我個人能做到的極限了,所以有很多很多沒提到的東西:

Q_OBJECT

用Qt,我們都知道這個宏,可是我們前面壓根沒提。因爲我怕打亂思路,這兒補上吧。我的前面的代碼可以替換爲:

# define DB_OBJECT static MetaObject meta; void metacall(int idx);

class Object
{
   DB_OBJECT

DB_OBJECT 還可以作爲一個標記:如果我們寫好了自己的類似於Qt中的moc的預處理器,如何判斷一個文件是否需要預處理來生成 db_object.cpp 文件呢?此時就可以根據類定義中是否有宏來判斷。

題外:  爲什麼添加宏後會容易遇到鏈接錯誤?你能看到原因麼?因爲它展開後就是類的成員,可是其定義要通過預處理進行生成。如果你沒有運行預處理器,也就沒有 db_object.cpp 這種文件,肯定要出錯了。

Connection

我們前面在Connection只保存了接收者的指針和槽的索引,我們可以保存更多一點的信息的:可以看看Qt保存了哪些東西

QObjectPrivate::Connection *c = new QObjectPrivate::Connection; 
c->sender = s; 
c->receiver = r; 
c->method = method_index; 
c->connectionType = type; 
c->argumentTypes = types; 
c->nextConnectionList = 0; 

應該很容易看懂,不做解釋了。

Qt中信號和槽主要有直接連接和隊列連接兩種方式,我們這兒只提到了前者,後者和Qt的事件系統攪和在一起。只要搞清楚了Qt事件系統,就會發現和直接連接沒有什麼區別了。

其他

信號和槽的參數

這個,例子中舉的都是信號和槽都是無參數的例子。加上參數,儘管概念上沒變化,但複雜度就大大提高了。所以本文對此不想涉及,也沒必要吧,直接去看Qt的源碼吧。

信號和信號連接

信號和槽一樣,都可以被調用,本例進行擴展也很容易,需要metacall那個函數,以及信號和槽要加個區別的標記(回到最前面不妨看看Qt的SLOT和SIGNAL究竟是神馬東西)。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章