元对象系统
元对象系统是 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++ 作为静态语言,想要获取这些编译期有关的信息,我们只能选择在编译时或者编译前来做这件事,直觉告诉我们,我们要在编译前来做这件事,有两个显而易见的原因
- 不要妄图修改编译器,成本巨大且危险
- 直接修改编译器显示不是用户能接受的方式
当然可以手动编写这个文件,把类的信息一个个提炼出来,但是那样太不程序员了,我们需要写一段程序,在编译器之前来做这个事情(你可以把它当成一段生成代码的脚本),我们可以这样做:
-
在我们写的类里面加上一个标记,来表示该类使用了元对象,需要处理并正确初始化 MetaObejct,我们这里假设就用
DEBUG_OBJ
来表示,DEBUG_OBJ
是一个宏,展开后就变成了元对象系统的一些定义,就像Q_OBJECT
做的那样:#define DEBUG_OBJ / static const QMetaObject staticMetaObject; / virtual const QMetaObject *metaObject() const;
-
运行我们的程序,如果在某个文件里面发现了标记,解析这个文件,获取他的类型信息(ClassInfo),方法信息(ClassMethod),继承信息等
-
脚本生成了一个 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 & slot
和Property
。
同时,我们也可以找到 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
的父类QObject
的staticMetaObject
指针 -
第二项应该是用一串字符存储了函数名和变量名:
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位开始,tag
与flag
应该是实现约定的一些值,分别代表其类型为method
,是一个signal
。
- 第一项代表类的版本,第二项代表类名的索引,其索引0表示它位于
至此,我们已经大致搞清楚了 Qt 的元对象系统里数据的组织形式,它的字符存储在一个数组中,并使用下标的形式访问,此外,它还有一个索引,保存数据类型等信息,当需要用到字符串时,它会提供一个对应的序号,表示字符串在字符数组里的位置。
这个例子中还有一些没有创建的属性类型,它们中的很多需要通过宏来创建,如果我们没有使用像 Q_ENUMS
, Q_CLASSINFO
, Q_PROPERTY
等类似的宏,元数据里面不会生产相应信息,这是为了避免数据过多引起的代码膨胀。另外,元对象系统中的数据是可以在运行时修改的,我们可以使用QObject::setProperty()
在运行时为类定义一个新的属性,也可以通过 QObject:property()
对动态属性进行查询。
那么到这里,Qt 就把一个类的元数据和元对象都构建好了,这套系统后面会被用于信号槽机制和属性系统等,我们下次再做讨论。