元对象系统

元对象系统是 QObject 很多特性的基石,它为 Qt 提供了信号与槽机制、运行时类型信息和动态属性系统

楔子

是否好奇过,为什么在 Qt 的框架下,我们只需要通过简单的信号槽宏连接两个对象的方法,就可以实现类似观察者的通信方式——甚至当前类并没有存另一个类的任何信息。

查看经典的SINGAL()SLOT()宏定义,可以发现这个宏就做了一个事情,把我们的信号和槽的方法包装为一个字符串!那个qFlagLocation可以看到,就是进去转了一圈。

# define SLOT(a)     qFlagLocation("1"#a QLOCATION)
# define SIGNAL(a)   qFlagLocation("2"#a QLOCATION)

const char *qFlagLocation(const char *method)
{
    QThreadData *currentThreadData = QThreadData::current(false);
    if (currentThreadData != nullptr)
        currentThreadData->flaggedSignatures.store(method);
    return method;
}

这里没有发现猫腻,那么猫腻是不是在connect方法中呢?

static QMetaObject::Connection connect(const QObject *sender, const char *signal,
                        const QObject *receiver, const char *member, Qt::ConnectionType = Qt::AutoConnection);

可以看到,这里面确实只利用了前面包装的字符串——即函数名,问题是,你见过 C++ 中有如下的调用吗?

pMyclass->"method1";

//或者
myClass."method2";

那么,Qt 只是拿两个方法名就能完成调用,是怎么做到的呢?素朴的想法是,一定是根据某种方法把字符串转换为对应对象方法,在通过方法调用来完成,但是 C++ 本身显然不提供这个能力,Java 中有类似反射的概念可以完成这个任务。

所以推测,Qt 大概率是采用某种方法拿到了方法和函数名的映射数据,从而完成转换,这部分数据我们暂且称为元数据。

什么是元数据

元数据是描述数据的数据,试想一下,我们会怎么描述一个类 MyClass

class MyClass : public Object
{
public:
    MyClass();
    ~MyClass();
    enum Type
    {
       //... 
    };
public:
    virtual void fool() override;
    void bar();
    //...
};
  • 这个类的类名为MyClass
  • 继承了一个基类 Object
  • 有一个无参的构造函数和一个析构函数
  • 实现了继承来的一个虚方法
  • 自己有一个名为bar的public方法
  • 内定义了一个枚举类型
  • ...

上述描述内容就是元数据,用来描述我们声明的一个class,如果我们把以上数据封装为一个类,我们简单的认为这个类就是元对象。

最简单的元对象系统

Qt 的元对象系统发展这么久,已经很是完善了,在迷失于复杂繁琐的源代码中之前,不妨先来设计一个简单的元对象系统来帮助我们理解思想。

元对象声明

我们在前面已经定义了一个类 MyClass ,不妨设想一下,如果我们需要获取这个类中的变量和方法,应该怎么做?

首先,我们需要对 MyClass 类进行一些拓展,在里面添加一个元对象类,并添加一个获取元对象的方法,此处的元对象应该是一个静态成员变量:

class MyClass : public Object
{
    // ... 和之前一样
    
    // 重写一个来自Object的虚方法,用于获取元对象
    virtual const MetaObject *metaObject() const override;
    static const MetaObject staticMetaObject;   // 一个静态成员
};

现在,只要这个数据能够正确初始化,如果我们需要,我们就可以借助多态的特性,通过接口来获得这个类的相关信息了。

初始化元对象

那么问题来了,怎么初始化这个变量呢,C++ 作为静态语言,想要获取这些编译期有关的信息,我们只能选择在编译时或者编译前来做这件事,直觉告诉我们,我们要在编译前来做这件事,有两个显而易见的原因

  1. 不要妄图修改编译器,成本巨大且危险
  2. 直接修改编译器显示不是用户能接受的方式

当然可以手动编写这个文件,把类的信息一个个提炼出来,但是那样太不程序员了,我们需要写一段程序,在编译器之前来做这个事情(你可以把它当成一段生成代码的脚本),我们可以这样做:

  1. 在我们写的类里面加上一个标记,来表示该类使用了元对象,需要处理并正确初始化 MetaObejct,我们这里假设就用 DEBUG_OBJ 来表示,DEBUG_OBJ 是一个宏,展开后就变成了元对象系统的一些定义,就像 Q_OBJECT 做的那样:

    #define DEBUG_OBJ /
        static const QMetaObject staticMetaObject; /
        virtual const QMetaObject *metaObject() const; 
        
    
  2. 运行我们的程序,如果在某个文件里面发现了标记,解析这个文件,获取他的类型信息(ClassInfo),方法信息(ClassMethod),继承信息等

  3. 脚本生成了一个 moc_MyClass.cpp 文件,用上述信息初始化 MetaObject,类似于下面这样

// 由脚本生成的文件
// moc_MyClass.cpp
#include "MyClass.h"

// 这里是脚本解析原来头文件生成的数据
// 解析了类的名称,成员,继承关系等等
// ...

const MetaObject MyClass::staticMetaObject = {
    // 用解析来的数据来初始化元对象内容
};

const MetaObject *MyClass::metaObject() const
{
    return &staticMetaObject;
}

Done!

然后把这个文件也为做源文件一起编译就行了。

使用元对象

现在,我们可以通过虚函数多态的性质拿到该类的 MetaObject ,并获取元数据。

例如,我们可以直接将代表类名的字符串保存在 staticMetaObject 中,然后通过一些方法获取元对象中的属性,这里我们可以通过定义一个 className() 方法获取类名。

至此,一个简陋的元对象系统就设计好了!

Qt 的元对象系统

有了前面的分析,我们可以对元对象的结构也有了比较清晰的认知。接下来试着分析一下Qt的元对象模型,Qt 的元对象系统相对来说复杂了许多,在 moc_ 文件中,生成了一个方法 metaObject() 用于返回元对象:

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

如果一个类从 QObject 派生,却没有声明 Q_OBJECT 宏,那么这个类的 metaobject 对象不会被生成,当试图获取 metaobject 时,这个类实例获得的元数据其实都是父类的数据,这显然给你的代码埋下隐患。因此如果一个类从 QObject 派生,它都应该声明 Q_OBJECT 宏,不管这个类有没有定义 signal & slotProperty

同时,我们也可以找到 staticMetaObject 的构造:

QT_INIT_METAOBJECT const QMetaObject MyWidget::staticMetaObject = { {
    QMetaObject::SuperData::link<QObject::staticMetaObject>(),
    qt_meta_stringdata_MyWidget.data,
    qt_meta_data_MyWidget,
    qt_static_metacall,
    nullptr,
    nullptr
} };
  • 第一项 QMetaObject::SuperData::link<QObject::staticMetaObject>()MyWidget 的父类 QObjectstaticMetaObject 指针

  • 第二项应该是用一串字符存储了函数名和变量名:

    struct qt_meta_stringdata_MyWidget_t {
        QByteArrayData data[5];
        char stringdata0[38];
    };
    #define QT_MOC_LITERAL(idx, ofs, len) \
        Q_STATIC_BYTE_ARRAY_DATA_HEADER_INITIALIZER_WITH_OFFSET(len, \
        qptrdiff(offsetof(qt_meta_stringdata_MyWidget_t, stringdata0) + ofs \
            - idx * sizeof(QByteArrayData)) \
        )
    static const qt_meta_stringdata_MyWidget_t qt_meta_stringdata_MyWidget = {
        {
    QT_MOC_LITERAL(0, 0, 8), // "MyWidget"
    QT_MOC_LITERAL(1, 9, 11), // "signal_test"
    QT_MOC_LITERAL(2, 21, 0), // ""
    QT_MOC_LITERAL(3, 22, 5), // "index"
    QT_MOC_LITERAL(4, 28, 9) // "slot_test"
    
        },
        "MyWidget\0signal_test\0\0index\0slot_test" // char stringdata0[38]
    };
    

    首先看 qt_meta_stringdata_Widget_t,可以发现它的一个成员保存了一个索引的数组,一个成员保存了一个字符串数组;后面的 QT_MOC_LITERAL 宏会为 stringdata0 中保存的每一个名称创建一个 QByteArrayData,宏参数包含了索引值,偏移量和长度。在 staticMetaObject 初始化时,传入了一个长度为5的 QByteArrayData 类型的数组作为参数。

  • 在前面已经传入了数据,但是我们还未知道每个数据代表的是什么内容,这些信息保存在索引中,接下来的参数就是元数据的索引信息:

    static const uint qt_meta_data_MyWidget[] = {
    
     // content:
           8,       // revision
           0,       // classname
           0,    0, // classinfo
           2,   14, // methods
           0,    0, // properties
           0,    0, // enums/sets
           0,    0, // constructors
           0,       // flags
           1,       // signalCount
    
     // signals: name, argc, parameters, tag, flags
           1,    1,   24,    2, 0x06 /* Public */,
    
     // slots: name, argc, parameters, tag, flags
           4,    1,   27,    2, 0x0a /* Public */,
    
     // signals: parameters
        QMetaType::Void, QMetaType::Int,    3,
    
     // slots: parameters
        QMetaType::Void, QMetaType::Int,    3,
    
           0        // eod
    };
    
    • 第一项代表类的版本,第二项代表类名的索引,其索引0表示它位于 qt_meta_stringdata_MyWidget 的首个。
    • 后面几行,第一个数字表示其数量,第二个参数表示其描述开始的位置,例如 methods 一行的 2, 14 表示有2个方法,它的描述从 qt_meta_data_MyWidget 的第14位开始
    • 再来到 signals 这一行,第一个数字 1 ,代表它的名称在 qt_meta_stringdata_MyWidget 中的位置为1,我们回过去看前面的定义,确实如此,第二个数字代表参数的数量,24表示参数的描述从 qt_meta_data_MyWidget 的第24位开始,tagflag 应该是实现约定的一些值,分别代表其类型为 method ,是一个 signal

至此,我们已经大致搞清楚了 Qt 的元对象系统里数据的组织形式,它的字符存储在一个数组中,并使用下标的形式访问,此外,它还有一个索引,保存数据类型等信息,当需要用到字符串时,它会提供一个对应的序号,表示字符串在字符数组里的位置。

这个例子中还有一些没有创建的属性类型,它们中的很多需要通过宏来创建,如果我们没有使用像 Q_ENUMS, Q_CLASSINFO, Q_PROPERTY 等类似的宏,元数据里面不会生产相应信息,这是为了避免数据过多引起的代码膨胀。另外,元对象系统中的数据是可以在运行时修改的,我们可以使用QObject::setProperty() 在运行时为类定义一个新的属性,也可以通过 QObject:property() 对动态属性进行查询。

那么到这里,Qt 就把一个类的元数据和元对象都构建好了,这套系统后面会被用于信号槽机制和属性系统等,我们下次再做讨论。