Qt 元对象系统源码级理解

Qt 元对象系统:从零理解作用、实现与运行机制

本文以 Qt 6 为主,从普通 C++ 开始,一步一步解释 Qt Meta-Object System 为什么存在、moc 到底生成了什么,以及信号槽、属性、反射、QML 和跨线程调用为什么都离不开它。

这不是一篇只告诉你"宏怎么写"的 API 清单。全文会反复回答四个问题:

  1. 这个能力解决了什么实际问题?
  2. 如果不用 Qt,我们自己要写什么代码?
  3. Qt 在编译期和运行期分别做了什么?
  4. 工程中最容易在哪里理解错、写错和排查错?

阅读目标:即使此前只会普通 C++,读完也能建立完整的元对象心智模型;不仅会使用 Q_OBJECTsignalsQ_PROPERTY,还能从构建、生成代码、元数据表和运行期分发解释它们为什么有效。

0. 阅读前先记住三句话

第一次学习元对象系统时,最容易被 moc、反射、类型擦除、方法索引等术语绕晕。先只记住三句话:

  1. C++ 编译器认识 C++ 类,Qt 还需要一份自己能读取的"类型说明书"。
  2. moc 的工作就是根据类声明,自动生成这份说明书和配套分发代码。
  3. 运行期的 QMetaObject 读取说明书,完成按名称查找属性、调用方法、分发信号等工作。

可以先把一个 Qt 对象想成两部分:

text 复制代码
Device 对象
├── 普通 C++ 部分
│   ├── 成员变量 m_name
│   ├── name()
│   ├── setName()
│   └── open()
│
└── Qt 元对象部分
    ├── 类名是 Device
    ├── 有一个 name 属性
    ├── 有一个 nameChanged 信号
    ├── 有一个 open 可调用方法
    └── 知道这些成员对应的索引、参数类型和调用入口

普通 C++ 部分负责真正保存数据、执行算法。元对象部分负责描述这个类,让 Qt、QML、设计器、调试工具或其他不知道具体 C++ 类型的代码也能认识它。

1. 先从一个生活化问题理解"元数据"

假设公司里有一位员工:

text 复制代码
员工本人:张三

员工本人会工作,但人事系统不能把"张三本人"塞进数据库。人事系统需要另一份描述:

text 复制代码
姓名:张三
部门:研发部
工号:10086
技能:C++、Qt

这份描述不是张三本人,而是"关于张三的数据",也就是元数据。

把这个关系搬到 C++:

cpp 复制代码
class Device
{
public:
    QString name() const;
    bool open();

private:
    QString m_name;
};

Device 对象是真正工作的对象,而下面这些信息属于"关于 Device 的数据":

text 复制代码
类名:Device
方法:name()、open()
属性:name
信号:nameChanged(QString)
父类型:QObject

这就是元数据。

1.1 元对象又是什么

元数据是一堆描述信息。把这些描述组织成一个可查询的对象,就是元对象。

在 Qt 中,这个角色由 QMetaObject 承担:

cpp 复制代码
const QMetaObject *meta = device.metaObject();

qDebug() << meta->className();       // Device
qDebug() << meta->methodCount();     // 方法总数
qDebug() << meta->propertyCount();   // 属性总数

所以"元对象"不是另一个业务对象,也不是对象的副本。它更像一本由 Qt 读取的类型说明书。

1.2 每个实例都有一份元对象吗

通常不是。

同一个类的所有实例共享类级别的元对象信息:

cpp 复制代码
Device camera;
Device microphone;

camera.metaObject() == microphone.metaObject(); // 通常为 true

原因很简单:

  • 两个实例的 m_name 值可以不同。
  • 但它们都属于 Device 类。
  • 它们拥有的方法、属性和信号定义相同。

因此:

text 复制代码
实例数据:每个对象各自一份
类的元数据:同一个类型共享一份

这也是 staticMetaObject 名称中 static 的直觉来源。

2. 为什么普通 C++ 还需要 Qt 元对象系统

先说结论:普通 C++ 当然能够完成业务逻辑,Qt 元对象系统不是为了让 C++"能写程序",而是为了让对象在运行期被统一发现、查询、调用和连接

2.1 普通调用要求编译器提前知道类型

正常的 C++ 调用:

cpp 复制代码
Device device;
device.open();

编译器在编译时知道:

  • deviceDevice
  • Device 是否真的有 open()
  • 参数和返回值类型是否匹配。
  • 应生成什么机器指令完成调用。

这叫静态调用,优点是安全、直接、效率高。

但如果运行时只拿到两个字符串呢:

cpp 复制代码
QObject *object = loadObjectFromPlugin();
QString methodName = readMethodNameFromConfig(); // 例如 "open"

现在编译器不知道:

  • object 的真实派生类型。
  • 配置文件会写哪个方法名。
  • 这个方法有几个参数。

普通的 object->methodName() 当然不存在,因为 C++ 不能把变量内容直接当成员函数名。

Qt 元对象系统可以提供类似能力:

cpp 复制代码
QMetaObject::invokeMethod(object, "open");

这里不是编译器帮你找到 open(),而是 Qt 在运行期查元数据表。

2.2 普通 C++ 对"枚举成员"支持有限

假设要做一个通用属性编辑器:

text 复制代码
对象类型:Device
显示所有可编辑属性:
  name     QString
  enabled  bool
  volume   int

编辑器在编译时不可能为每个业务类手写界面。它希望拿到一个 QObject *,然后询问:

cpp 复制代码
const QMetaObject *meta = object->metaObject();

for (int i = 0; i < meta->propertyCount(); ++i) {
    QMetaProperty property = meta->property(i);
    // 根据属性名称和类型自动创建编辑控件
}

传统 C++ RTTI 能告诉你对象大致是什么类型,却不能标准化地枚举:

  • 这个类有哪些业务属性。
  • 属性有没有 getter 和 setter。
  • 属性变化时发哪个通知。
  • 哪些方法是信号,哪些是槽。

Qt 补上的正是这一层描述能力。

2.3 发送者不应该知道所有接收者

如果不用信号槽,一个下载器可能这样设计:

cpp 复制代码
class Downloader
{
public:
    void setProgressBar(QProgressBar *bar);
    void setLogger(Logger *logger);
    void setStatusLabel(QLabel *label);
};

问题是 Downloader 被迫知道 UI、日志、状态栏。以后增加接收者,还要修改下载器。

信号让发送者只描述事实:

cpp 复制代码
signals:
    void progressChanged(int percent);

谁关心进度,谁在外部连接:

cpp 复制代码
connect(downloader, &Downloader::progressChanged,
        progressBar, &QProgressBar::setValue);

connect(downloader, &Downloader::progressChanged,
        logger, &Logger::recordProgress);

发送者不需要知道有几个接收者,也不需要包含接收者头文件。这种低耦合连接依赖元对象系统保存信号身份和连接信息。

2.4 跨线程调用不能只是普通函数调用

假设 worker 属于工作线程:

cpp 复制代码
worker->process(); // 仍然在当前调用线程执行

C++ 普通调用不会因为对象属于另一个线程就自动切换线程。

Qt 可以把调用包装成消息,投递到对象所属线程:

cpp 复制代码
QMetaObject::invokeMethod(
    worker,
    &Worker::process,
    Qt::QueuedConnection);

为了稍后执行,Qt 必须知道:

  • 要调用哪个方法。
  • 参数是什么类型。
  • 参数如何复制和销毁。
  • 目标对象属于哪个线程。

这里会同时用到元对象、元类型和事件循环。

2.5 QML 和工具无法直接理解任意 C++ 实现

QML 中可以这样访问 C++ 对象:

qml 复制代码
Text {
    text: device.name
}

QML 引擎并不会像 C++ 编译器一样编译 Device 头文件。它需要在运行期询问:

text 复制代码
device 有 name 属性吗?
name 是什么类型?
怎么读取?
变化时监听哪个信号?

Q_PROPERTYQMetaObject 为它提供统一答案。

3. 如果没有 moc,我们自己要写多少代码

理解 moc 最好的方式,不是先研究它生成的复杂数组,而是先尝试手写一个极简版本。

3.1 手写一个按名称调用的方法表

普通类:

cpp 复制代码
class Device
{
public:
    void open()
    {
        qDebug() << "open";
    }

    void close()
    {
        qDebug() << "close";
    }
};

为了支持按字符串调用,可以自己写:

cpp 复制代码
bool invokeByName(Device &device, const QString &methodName)
{
    if (methodName == "open") {
        device.open();
        return true;
    }

    if (methodName == "close") {
        device.close();
        return true;
    }

    return false;
}

调用:

cpp 复制代码
Device device;
invokeByName(device, "open");

这已经是一个非常简陋的"元调用系统":

  • 字符串表示方法名。
  • if 分支负责查找。
  • 找到后转发到真实 C++ 方法。

问题也很明显:

  • 每增加一个方法都要手工修改。
  • 参数和返回值处理困难。
  • 每个类都要重复写。
  • 无法统一查询方法列表。
  • 继承关系更复杂。

3.2 用方法索引代替反复比较字符串

可以先把名称转换为整数:

cpp 复制代码
enum MethodId {
    OpenMethod = 0,
    CloseMethod = 1
};

int methodIndex(const QString &name)
{
    if (name == "open")
        return OpenMethod;
    if (name == "close")
        return CloseMethod;
    return -1;
}

bool invokeByIndex(Device &device, int id)
{
    switch (id) {
    case OpenMethod:
        device.open();
        return true;
    case CloseMethod:
        device.close();
        return true;
    default:
        return false;
    }
}

这和 Qt 的基本思路已经很接近:

  1. 元数据表保存"名称对应哪个方法索引"。
  2. 找到索引后,不再反复进行字符串比较。
  3. 分发函数根据索引进入真实成员函数。

后面看到 indexOfMethod()qt_metacall()switch(id) 时,可以回想这个手写版本。

3.3 手写一个通用属性表

为了支持按名称读写属性,也可以自己写:

cpp 复制代码
QVariant readProperty(Device &device, const QString &name)
{
    if (name == "name")
        return device.name();

    if (name == "enabled")
        return device.isEnabled();

    return {};
}

bool writeProperty(
    Device &device,
    const QString &name,
    const QVariant &value)
{
    if (name == "name") {
        device.setName(value.toString());
        return true;
    }

    if (name == "enabled") {
        device.setEnabled(value.toBool());
        return true;
    }

    return false;
}

这就是 QObject::property()QObject::setProperty()QMetaProperty::read()/write() 所解决问题的简化版。

3.4 moc 的本质

现在可以给出一个更容易理解的定义:

moc 是自动代码生成器。它读取类声明,把我们原本可能手写的方法表、属性表、信号函数体和索引分发代码自动生成出来。

它不是魔法,也不会修改 C++ 编译器。它做的是:

text 复制代码
输入:带 Qt 宏的 C++ 类声明
输出:额外的标准 C++ 源文件

4. 三个基础支柱分别负责什么

Qt 官方将元对象系统概括为三个基础部分:

  1. QObject:提供对象身份、线程亲和性、父子对象树、事件入口和连接管理等运行期基础设施。
  2. Q_OBJECT:在类声明中告诉工具链,这个类需要完整元对象能力。
  3. moc:扫描相关声明,生成元数据和额外的标准 C++ 代码。

可以用一张职责表区分:

角色 类比 主要职责
QObject 可被系统管理的对象基座 对象身份、连接、事件、父子关系、线程亲和性
Q_OBJECT 类声明上的登记标记 声明该类需要自己的元对象成员
moc 自动填写登记资料的生成器 生成字符串表、方法表、属性表和分发代码
QMetaObject 运行期可查阅的类型说明书 查询类名、方法、属性、枚举和继承关系
QMetaType 值类型的搬运说明书 描述值如何识别、复制、移动和销毁

4.1 贯穿全文的 Device

后面所有概念都围绕这个类讲解:

cpp 复制代码
#include <QObject>
#include <QString>

class Device : public QObject
{
    Q_OBJECT
    Q_PROPERTY(QString name READ name WRITE setName NOTIFY nameChanged)

public:
    explicit Device(QObject *parent = nullptr);

    QString name() const;
    void setName(const QString &name);

    Q_INVOKABLE bool open();

signals:
    void nameChanged(const QString &name);
    void errorOccurred(int code, const QString &message);

public slots:
    void close();

private:
    QString m_name;
};

4.2 逐行读懂这个类

public QObject
cpp 复制代码
class Device : public QObject

表示 Device 进入 Qt 对象模型。它由此获得:

  • metaObject() 查询入口。
  • connect() / disconnect() 连接基础。
  • 父子对象所有权。
  • event() 事件入口。
  • 线程亲和性。
  • objectName、动态属性等 QObject 自带能力。

仅继承 QObject 不等于当前派生类已经拥有完整的自定义元数据,当前类还需要 Q_OBJECT

Q_OBJECT
cpp 复制代码
Q_OBJECT

它没有参数,看起来像一个普通宏,但意义非常重要:

  • 声明 Device::staticMetaObject
  • 声明元对象查询和分发所需成员。
  • mocDevice 生成专属元数据。
  • Device 自己声明的信号、槽、属性和类名进入元对象系统。

可以把它读成:

text 复制代码
"请为 Device 生成一份完整的 Qt 类型说明书。"
Q_PROPERTY
cpp 复制代码
Q_PROPERTY(QString name READ name WRITE setName NOTIFY nameChanged)

它不是在声明成员变量,而是在声明一条属性元数据:

text 复制代码
属性类型:QString
属性名称:name
读取方法:name()
写入方法:setName(...)
变化通知:nameChanged(...)

真实数据仍保存在:

cpp 复制代码
QString m_name;

这是初学者最容易混淆的地方:

text 复制代码
m_name       是存储
name()       是读取逻辑
setName()    是写入逻辑
Q_PROPERTY   是对外描述
Q_INVOKABLE
cpp 复制代码
Q_INVOKABLE bool open();

open() 本来就是普通 C++ 成员函数。加上 Q_INVOKABLE 后,它还会进入元对象方法表,于是可以:

  • QMetaObject::invokeMethod() 按元对象规则调用。
  • 被 QML 或脚本环境发现和调用。
  • 被通用工具枚举。

直接写:

cpp 复制代码
device.open();

不需要 Q_INVOKABLE。只有需要动态系统"看见"它时才加。

signals
cpp 复制代码
signals:
    void nameChanged(const QString &name);

这看起来像只声明未实现的成员函数。区别是:

  • C++ 编译器负责检查函数声明是否合法。
  • moc 负责生成信号函数体。
  • 调用该函数时,函数体会进入 Qt 的连接分发系统。

因此不需要自己实现 Device::nameChanged()

public slots
cpp 复制代码
public slots:
    void close();

close() 仍需要我们自己实现。slots 只是告诉 moc

text 复制代码
"除了作为普通 C++ 方法,close() 还应该进入可动态调用的方法表。"

现代函数指针 connect() 可以连接普通成员函数,因此并非所有接收函数都必须写成槽。需要旧字符串连接、QML 调用或元对象动态调用时,槽声明才有额外意义。

4.3 同一个声明会被两个工具读取

这个类声明同时面向两个读者:

text 复制代码
Device.h
├── C++ 编译器读取
│   ├── 检查继承、类型和访问权限
│   ├── 编译普通成员函数调用
│   └── 生成对象布局相关代码
│
└── moc 读取
    ├── 发现 Q_OBJECT
    ├── 记录 Q_PROPERTY
    ├── 记录 signals、slots、Q_INVOKABLE
    └── 生成额外 C++ 文件

这解释了一个重要现象:

源码看起来只有一份,最终参与编译的 C++ 源码却包括我们写的文件和 moc 自动生成的文件。

5. 从源码到可执行文件的构建流程

5.1 完整流水线

#mermaid-svg-siwOoNkrFcplT5sb{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-siwOoNkrFcplT5sb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-siwOoNkrFcplT5sb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-siwOoNkrFcplT5sb .error-icon{fill:#552222;}#mermaid-svg-siwOoNkrFcplT5sb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-siwOoNkrFcplT5sb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-siwOoNkrFcplT5sb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-siwOoNkrFcplT5sb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-siwOoNkrFcplT5sb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-siwOoNkrFcplT5sb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-siwOoNkrFcplT5sb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-siwOoNkrFcplT5sb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-siwOoNkrFcplT5sb .marker.cross{stroke:#333333;}#mermaid-svg-siwOoNkrFcplT5sb svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-siwOoNkrFcplT5sb p{margin:0;}#mermaid-svg-siwOoNkrFcplT5sb .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-siwOoNkrFcplT5sb .cluster-label text{fill:#333;}#mermaid-svg-siwOoNkrFcplT5sb .cluster-label span{color:#333;}#mermaid-svg-siwOoNkrFcplT5sb .cluster-label span p{background-color:transparent;}#mermaid-svg-siwOoNkrFcplT5sb .label text,#mermaid-svg-siwOoNkrFcplT5sb span{fill:#333;color:#333;}#mermaid-svg-siwOoNkrFcplT5sb .node rect,#mermaid-svg-siwOoNkrFcplT5sb .node circle,#mermaid-svg-siwOoNkrFcplT5sb .node ellipse,#mermaid-svg-siwOoNkrFcplT5sb .node polygon,#mermaid-svg-siwOoNkrFcplT5sb .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-siwOoNkrFcplT5sb .rough-node .label text,#mermaid-svg-siwOoNkrFcplT5sb .node .label text,#mermaid-svg-siwOoNkrFcplT5sb .image-shape .label,#mermaid-svg-siwOoNkrFcplT5sb .icon-shape .label{text-anchor:middle;}#mermaid-svg-siwOoNkrFcplT5sb .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-siwOoNkrFcplT5sb .rough-node .label,#mermaid-svg-siwOoNkrFcplT5sb .node .label,#mermaid-svg-siwOoNkrFcplT5sb .image-shape .label,#mermaid-svg-siwOoNkrFcplT5sb .icon-shape .label{text-align:center;}#mermaid-svg-siwOoNkrFcplT5sb .node.clickable{cursor:pointer;}#mermaid-svg-siwOoNkrFcplT5sb .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-siwOoNkrFcplT5sb .arrowheadPath{fill:#333333;}#mermaid-svg-siwOoNkrFcplT5sb .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-siwOoNkrFcplT5sb .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-siwOoNkrFcplT5sb .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-siwOoNkrFcplT5sb .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-siwOoNkrFcplT5sb .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-siwOoNkrFcplT5sb .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-siwOoNkrFcplT5sb .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-siwOoNkrFcplT5sb .cluster text{fill:#333;}#mermaid-svg-siwOoNkrFcplT5sb .cluster span{color:#333;}#mermaid-svg-siwOoNkrFcplT5sb div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-siwOoNkrFcplT5sb .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-siwOoNkrFcplT5sb rect.text{fill:none;stroke-width:0;}#mermaid-svg-siwOoNkrFcplT5sb .icon-shape,#mermaid-svg-siwOoNkrFcplT5sb .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-siwOoNkrFcplT5sb .icon-shape p,#mermaid-svg-siwOoNkrFcplT5sb .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-siwOoNkrFcplT5sb .icon-shape .label rect,#mermaid-svg-siwOoNkrFcplT5sb .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-siwOoNkrFcplT5sb .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-siwOoNkrFcplT5sb .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-siwOoNkrFcplT5sb :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Device.h
moc 扫描 Q_OBJECT 等标记
生成 moc_Device.cpp
C++ 编译器
Device.cpp
目标文件
链接器
可执行文件或动态库

moc 不是另一个 C++ 编译器。它只识别 Qt 关心的声明,输出仍然是标准 C++ 代码,最终由正常的 C++ 编译器编译。

5.2 把构建过程拆成六步

Device 为例,实际过程可以按下面理解。

第一步:我们编写类声明
text 复制代码
device.h

里面同时包含普通 C++ 和 Qt 标记。

第二步:构建系统发现需要运行 moc

CMake 的 AUTOMOC 会扫描 target 中的源文件和头文件。如果发现 Q_OBJECT 等相关宏,就为它安排 moc 规则。

这里的"发现"很重要。头文件存在于磁盘上,不代表它一定属于当前构建目标:

cmake 复制代码
add_library(device_lib
    device.cpp
    device.h       # 明确列出最直观
)
第三步:moc 读取声明

概念命令类似:

text 复制代码
moc device.h -o moc_device.cpp

实际 CMake 命令会携带 include 路径、宏定义和 Qt 配置。moc 与 C++ 编译器看到的条件编译环境必须尽量一致,否则可能出现:

text 复制代码
C++ 编译器看到了某个成员
moc 却没有看到

或者反过来。

第四步:得到额外 C++ 源码

生成的文件不是二进制,也不是神秘中间格式,而是可以打开阅读的 .cpp

text 复制代码
moc_device.cpp

它大致包含:

  • 类名、方法名、属性名等字符串。
  • 方法、参数、属性和枚举的数字描述表。
  • Device::staticMetaObject 的定义。
  • Device::metaObject() 等函数定义。
  • 元调用分发函数。
  • 信号函数体。
第五步:C++ 编译器编译所有源码
text 复制代码
device.cpp      -> device.obj / device.o
moc_device.cpp  -> moc_device.obj / moc_device.o

moc 生成代码最终仍要接受同一个 C++ 编译器的语法和类型检查。

第六步:链接器把它们合并

如果 device.obj 参与链接,而 moc_device.obj 没参与,那么 Q_OBJECT 声明出的符号就找不到定义,于是出现 staticMetaObject、信号函数或 vtable 相关链接错误。

5.3 为什么 Qt 不直接修改 C++ 编译器

初学者常问:

为什么不让 C++ 编译器直接理解 signalsQ_PROPERTY

Qt 选择独立代码生成工具有几个历史和工程原因:

  • 不绑定单一编译器扩展。
  • GCC、Clang、MSVC 等平台行为可以保持一致。
  • 生成结果仍是标准 C++,后续使用正常编译和链接工具链。
  • 元数据格式和 Qt 运行库可以协同演进。

可以把它和其他工程里的代码生成类比:

  • Protocol Buffers 根据 .proto 生成 C++。
  • UI 工具根据 .ui 生成界面构造代码。
  • rcc 根据资源清单生成资源访问代码。
  • moc 根据 Qt 类声明生成元对象代码。

区别只是 moc 的输入仍然长得像 C++ 头文件。

5.4 CMake 中的自动处理

推荐让 CMake 自动发现需要运行 moc 的文件:

cmake 复制代码
cmake_minimum_required(VERSION 3.21)
project(MetaObjectDemo LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)

find_package(Qt6 REQUIRED COMPONENTS Core)

add_executable(meta_object_demo
    main.cpp
    device.cpp
    device.h
)

target_link_libraries(meta_object_demo PRIVATE Qt6::Core)

使用 qt_add_executable() 的现代 Qt CMake 工程通常也会正确设置自动化处理。关键是:含有 Q_OBJECT 的文件必须能被构建系统发现。

5.5 头文件中的类和 .cpp 中的类有什么区别

最常见写法是把类声明放在头文件:

text 复制代码
device.h
device.cpp

此时构建系统通常单独生成并编译 moc_device.cpp

如果带 Q_OBJECT 的类直接写在 .cpp

cpp 复制代码
class LocalWorker : public QObject
{
    Q_OBJECT
};

某些构建模式会要求在文件末尾:

cpp 复制代码
#include "main.moc"

这里不是包含手写头文件,而是把为当前 .cpp 生成的元对象代码纳入编译单元。是否需要手工包含取决于构建工具和组织方式。最稳妥的初学者实践是:

  • 正式的 QObject 类放到独立头文件。
  • target 中明确列出头文件。
  • 开启 CMAKE_AUTOMOC
  • 不手工编辑生成文件。

5.6 如何亲眼看到 moc 生成物

不要只背结论,可以在构建目录中搜索:

powershell 复制代码
rg --files build | rg "moc_.*Device|mocs_compilation"

CMake 自动生成目录常能看到类似文件:

text 复制代码
build/<target>_autogen/.../moc_device.cpp
build/<target>_autogen/mocs_compilation.cpp

打开后重点找:

text 复制代码
qt_meta_stringdata_Device
qt_meta_data_Device
Device::staticMetaObject
Device::qt_metacall
Device::nameChanged

真实文件会比本文伪代码复杂,因为它要处理版本兼容、类型信息、属性、继承和不同调用类别。第一次阅读只找这些"路标",不要试图一次看懂所有模板。

5.7 为什么会出现 undefined reference to vtable

常见错误形式:

text 复制代码
undefined reference to `vtable for Device'
undefined reference to `Device::staticMetaObject'
undefined reference to `Device::nameChanged(QString const&)'

这通常不是"虚函数写错了",而是以下原因之一:

  • 类中增加了 Q_OBJECT,但 moc 没有运行。
  • Q_OBJECT 的头文件没有加入 target,AUTOMOC 没发现它。
  • 新增宏后没有重新运行 CMake 配置。
  • 手工构建时生成的 moc_*.cpp 没有参与编译或链接。
  • .cpp 中定义局部 QObject 类,却没有按构建方式包含对应的 .moc 文件。
  • 声明了普通非纯虚函数或析构函数,但确实忘了提供定义。

排查顺序:

  1. 打开 verbose build,确认是否执行了 moc
  2. 在构建目录搜索 moc_Device.cpp 或自动生成目录。
  3. 确认生成文件被编译进当前 target。
  4. 清理并重新配置 CMake,而不是只重复链接。
  5. 最后再检查真正缺失的 C++ 函数定义。

5.8 为什么报错经常指向 vtable,而不是直接说 moc 丢了

链接器并不理解 Qt,也不知道什么叫 moc。它只知道:

text 复制代码
某个符号被引用了,但所有目标文件中都找不到定义。

Q_OBJECT 会引入虚函数覆盖和静态元对象等符号。C++ 编译器生成 vtable 时,会引用这些函数地址。如果定义它们的 moc 目标文件缺失,链接器看到的最终症状可能就是:

text 复制代码
vtable for Device 无法完整解析

所以排查思路应是:

text 复制代码
看到 vtable 错误
├── 先检查普通虚函数/析构函数是否缺定义
└── 如果类有 Q_OBJECT,再检查 moc 生成和链接链路

不是所有 vtable 错误都由 Qt 导致,但带 Q_OBJECT 的类中,moc 缺失是高频原因。

6. Q_OBJECT 展开后提供了什么

宏的具体展开会随 Qt 版本变化,不应依赖其文本形式。稳定的概念是它为类声明了以下能力:

  • 类级别的 staticMetaObject
  • 覆盖 metaObject(),获取对象实际类型的元对象。
  • qt_metacast(),按元对象类型名称进行转换和接口查询。
  • qt_metacall(),按方法或属性索引分发调用。
  • moc 生成代码约定的一组内部声明。

可以把它理解成下面的概念模型,而不是可复制的真实源码:

cpp 复制代码
class Device : public QObject
{
public:
    static const QMetaObject staticMetaObject;

    const QMetaObject *metaObject() const override;
    void *qt_metacast(const char *typeName) override;
    int qt_metacall(QMetaObject::Call call, int id, void **args) override;
};

下面逐个解释这些成员解决什么问题。

6.1 staticMetaObject:类级说明书

cpp 复制代码
Device::staticMetaObject

它属于类,不要求先创建实例:

cpp 复制代码
qDebug() << Device::staticMetaObject.className();

适合回答:

text 复制代码
Device 这个类型叫什么?
它的父元对象是谁?
它声明了哪些属性、方法和枚举?

因为类定义在程序运行期间通常不变,所以这份元数据基本是静态只读的。多个 Device 实例共享它。

6.2 metaObject():从对象找到真实类型说明书

cpp 复制代码
const QMetaObject *metaObject() const;

它解决的是多态场景:

cpp 复制代码
QObject *object = new Device;
qDebug() << object->metaObject()->className(); // Device

变量的静态类型是 QObject *,对象的动态类型是 Device。虚函数 metaObject() 返回真实派生类型对应的元对象。

对比:

cpp 复制代码
QObject::staticMetaObject.className(); // QObject
Device::staticMetaObject.className();  // Device
object->metaObject()->className();     // Device

可以记成:

text 复制代码
T::staticMetaObject 询问"这个类是什么"
obj->metaObject()   询问"这个对象实际是什么"

6.3 qt_metacast():Qt 对象体系中的动态类型匹配

它主要为 qobject_cast 和 Qt 接口查询服务。概念过程:

text 复制代码
要转换成 Device*
    ↓
询问对象的元对象链中是否存在 Device
    ↓
存在则返回调整后的对象地址
不存在则返回 nullptr

应用代码通常不直接调用 qt_metacast(),而是使用:

cpp 复制代码
Device *device = qobject_cast<Device *>(object);

6.4 qt_metacall():统一的索引分发入口

它可以把不同元操作统一描述为:

text 复制代码
调用类别 + 成员索引 + 参数数组

例如:

text 复制代码
InvokeMetaMethod + 方法索引 3 + 参数数组
ReadProperty     + 属性索引 0 + 返回值位置
WriteProperty    + 属性索引 0 + 新值位置

这样通用框架不需要写:

cpp 复制代码
if (object is Device) ...
else if (object is Button) ...
else if (object is Window) ...

它只需使用统一的元调用协议。

6.5 为什么需要"索引"

名称便于人阅读,但整数更适合内部定位:

text 复制代码
"nameChanged(QString)" -> 查表 -> 信号索引 0
"close()"              -> 查表 -> 方法索引 2

找到索引后,后续连接记录和分发可以保存整数,不必每次都比较完整字符串。

索引的思想并不神秘,类似:

  • 数组用下标访问元素。
  • 数据库索引加速定位记录。
  • 虚函数表用槽位定位函数地址。

但 Qt 的方法索引不是 C++ vtable 索引,两者用途和布局都不同,不应混为一谈。

7. moc 大致生成了什么

下面是为了理解而简化的伪代码。真实名称、表格式和私有辅助类型会随 Qt 版本变化。

7.1 先看全景图

针对 Device,可以把生成物理解成五块:

text 复制代码
moc_device.cpp
├── 1. 字符串区
│      "Device"、"nameChanged"、"name"、"open"...
├── 2. 数字元数据区
│      有几个方法、每个方法是什么类别、参数在哪里...
├── 3. staticMetaObject
│      把父元对象、字符串区、数字区和分发函数组织起来
├── 4. 分发代码
│      根据调用类别和索引执行方法或读写属性
└── 5. 信号函数体
       调用 Qt 的连接激活逻辑

这五块一起形成完整闭环:

text 复制代码
先用元数据"找到"
再用分发代码"执行"

7.2 字符串表

cpp 复制代码
// 概念示例:集中保存类名、方法名、参数名、属性名等字符串。
static const char qt_meta_stringdata_Device[] = {
    "Device\0"
    "nameChanged\0"
    "name\0"
    "errorOccurred\0"
    "code\0"
    "message\0"
    "close\0"
    "open\0"
};

集中字符串可以降低重复存储,并允许元数据表使用偏移量引用名称。

为什么不直接保存很多独立的 std::string

  • 元数据主要在编译时生成,运行时只读。
  • 连续静态存储更紧凑。
  • 数字表可以用偏移量指向字符串。
  • 避免大量小对象和动态分配。

你不需要记住真实存储格式,只需理解:

text 复制代码
字符串表负责保存"叫什么"
数字表负责保存"是什么、在哪里、怎么解释"

7.3 整数元数据表

cpp 复制代码
// 概念示例:记录 revision、方法数量、属性数量、各区域偏移等。
static const uint qt_meta_data_Device[] = {
    /* header */
    /* class info */
    /* methods: signal/slot/invokable */
    /* method parameters and meta types */
    /* properties */
    /* enums */
};

运行期查询并不需要解析 C++ 源码,只需要读取这些紧凑的静态表。

可以想象其中有这样的逻辑信息:

方法索引 名称 类别 返回值 参数
0 nameChanged Signal void QString
1 errorOccurred Signal void int, QString
2 close Slot void
3 open Invokable bool

真实表不会长成 Markdown 表格,但表达的是类似信息。

7.4 staticMetaObject

cpp 复制代码
const QMetaObject Device::staticMetaObject = {
    // 指向 QObject::staticMetaObject,形成元对象继承链
    // 指向字符串表和元数据表
    // 指向静态分发函数
};

元对象也有继承链。Device 的元对象知道自己的父元对象是 QObject,因此可以同时访问基类和派生类的方法、属性和枚举。

元对象继承链可以画成:

text 复制代码
QObject::staticMetaObject
          ↑
Device::staticMetaObject
          ↑
CameraDevice::staticMetaObject

因此 CameraDevice 可以查询到:

  • QObjectobjectName 等属性。
  • Devicename 属性。
  • CameraDevice 自己的 resolution 属性。

7.5 静态分发函数

cpp 复制代码
static void qt_static_metacall(
    QObject *object,
    QMetaObject::Call call,
    int id,
    void **arguments)
{
    auto *self = static_cast<Device *>(object);

    if (call == QMetaObject::InvokeMetaMethod) {
        switch (id) {
        case 0:
            self->nameChanged(*static_cast<QString *>(arguments[1]));
            break;
        case 1:
            self->errorOccurred(
                *static_cast<int *>(arguments[1]),
                *static_cast<QString *>(arguments[2]));
            break;
        case 2:
            self->close();
            break;
        case 3:
            *static_cast<bool *>(arguments[0]) = self->open();
            break;
        }
    }

    // 真实生成代码还会处理属性读写、参数元类型注册、
    // 方法索引查询等调用类别。
}

void **arguments 是一种类型擦除后的参数数组:

  • arguments[0] 通常用于返回值。
  • arguments[1] 开始对应第一个、第二个参数。
  • 元数据记录每个位置原本是什么类型。

7.6 void **arguments 为什么看起来这么危险

它确实绕过了普通函数签名的直接表达,所以不能随便手工构造。Qt 之所以能正确解释,是因为元数据和生成代码双方遵守同一协议。

例如调用:

cpp 复制代码
bool ok = device.open();

在元调用协议中可以抽象成:

text 复制代码
arguments[0] -> bool 返回值存放地址
arguments[1] -> 不存在,因为 open 无参数

调用:

cpp 复制代码
device.setName("camera");

可以抽象成:

text 复制代码
arguments[0] -> void 返回值,无实际存储
arguments[1] -> QString 参数地址

"类型擦除"不是把类型永远丢掉,而是:

  1. 统一通道只保存无类型地址。
  2. 元数据在旁边记录原始类型。
  3. 到正确分发点后再还原为具体类型。

类似快递运输:

text 复制代码
统一传送带:只搬箱子
箱子标签:记录里面是什么
目的地人员:按标签用正确方式拆箱

如果标签和内容不一致,就会产生严重问题。所以正常业务代码应使用公开的类型安全 API,不应直接操作这些私有参数数组。

7.7 信号函数体

信号只在类中声明,不需要用户在 .cpp 里实现。moc 会生成信号函数体,其概念行为如下:

cpp 复制代码
void Device::nameChanged(const QString &value)
{
    void *arguments[] = {
        nullptr,
        const_cast<void *>(static_cast<const void *>(&value))
    };

    QMetaObject::activate(this, &staticMetaObject, signalIndex, arguments);
}

因此:

cpp 复制代码
emit nameChanged(m_name);

最终会进入 Qt 的信号分发流程。emit 本身只是可读性标记,通常会被宏处理为空;真正有意义的是调用了由 moc 生成的信号成员函数。

7.8 从 emit 到槽执行的逐步过程

假设:

cpp 复制代码
connect(&device, &Device::nameChanged,
        &label, &QLabel::setText);

之后:

cpp 复制代码
emit device.nameChanged("camera-01");

概念过程是:

  1. emit 宏不负责调度,它主要用于表达语义。
  2. 代码调用 Device::nameChanged(...)
  3. 该函数体由 moc 生成。
  4. 函数体把当前对象、信号索引和参数地址交给 Qt。
  5. Qt 根据发送者和信号索引找到连接记录。
  6. Qt 找到接收者 label 和目标 setText
  7. 根据连接类型决定立即调用还是投递事件。
  8. 槽执行后,直接连接才从信号函数返回;排队连接则已提前返回。

元对象系统主要解决第 2 到第 6 步中的身份和分发问题。线程和事件循环部分见第二篇。

8. QMetaObject 如何组织反射信息

8.1 先理解什么叫反射

普通代码是"我知道类型,所以调用成员":

cpp 复制代码
Device device;
device.setName("camera");

反射式代码是"我先询问对象有什么,再决定如何操作":

cpp 复制代码
QObject *object = obtainObjectAtRuntime();
const QMetaObject *meta = object->metaObject();

const int index = meta->indexOfProperty("name");
if (index >= 0) {
    QMetaProperty property = meta->property(index);
    property.write(object, QStringLiteral("camera"));
}

区别可以概括为:

text 复制代码
普通调用:编译器知道类型和成员
反射调用:运行期通过描述信息发现类型和成员

反射不是为了替代所有普通调用。它适合编写"面向很多未知类型的通用框架",例如:

  • QML 引擎。
  • Qt Designer 和属性编辑器。
  • 插件系统。
  • 对象检查器。
  • 自动序列化工具。
  • 测试框架。

业务代码已经知道具体类型时,直接调用通常更简单、更安全。

8.2 五类常见元信息对象

每个启用元对象的类型都有一个 QMetaObject。常用查询对象包括:

  • QMetaMethod:信号、槽、Q_INVOKABLE 方法和构造方法元数据。
  • QMetaProperty:属性名称、类型、读写方法、通知信号等。
  • QMetaEnum:枚举名和值之间的转换。
  • QMetaClassInfo:附加的类级键值信息。
  • QMetaType:值类型的名称、构造、复制、移动和销毁等信息。

前四类可以理解为"某个类里面有什么",QMetaType 更侧重"某个值类型本身怎么处理"。第 10 章会专门区分。

可以用图书馆类比:

text 复制代码
QMetaObject    一本书的总目录
QMetaMethod    目录中的"方法"条目
QMetaProperty  目录中的"属性"条目
QMetaEnum      目录中的"枚举"条目
QMetaClassInfo 书封上的附加标签
QMetaType      某种资料该如何装箱、复制和销毁的规范

示例:

cpp 复制代码
#include <QDebug>
#include <QMetaMethod>
#include <QMetaObject>
#include <QMetaProperty>

void dumpMetaObject(const QObject *object)
{
    const QMetaObject *meta = object->metaObject();

    qDebug() << "class:" << meta->className();
    qDebug() << "super:"
             << (meta->superClass() ? meta->superClass()->className() : "<none>");

    // methodOffset() 之前是基类方法,只遍历当前类新增的方法。
    for (int i = meta->methodOffset(); i < meta->methodCount(); ++i) {
        const QMetaMethod method = meta->method(i);
        qDebug() << method.methodType() << method.methodSignature();
    }

    for (int i = meta->propertyOffset(); i < meta->propertyCount(); ++i) {
        const QMetaProperty property = meta->property(i);
        qDebug() << property.name()
                 << property.typeName()
                 << "readable:" << property.isReadable()
                 << "writable:" << property.isWritable();
    }
}

这段代码的关键不是死记 API,而是理解它完全不知道传进来的具体派生类型。无论传入 DeviceQPushButton 还是其他 QObject,都能用同一套接口枚举信息。

8.3 QMetaMethod 能告诉我们什么

cpp 复制代码
const QMetaObject *meta = device.metaObject();
const int index = meta->indexOfMethod("open()");

if (index >= 0) {
    const QMetaMethod method = meta->method(index);

    qDebug() << method.name();
    qDebug() << method.methodSignature();
    qDebug() << method.returnMetaType().name();
    qDebug() << method.parameterTypes();
    qDebug() << method.methodType();
    qDebug() << method.access();
}

方法名称和方法签名要区分:

text 复制代码
名称:open
签名:open()

名称:errorOccurred
签名:errorOccurred(int,QString)

存在重载时,名称相同,签名不同:

cpp 复制代码
void setValue(int value);
void setValue(double value);

因此动态查找通常需要完整规范化签名。

8.4 QMetaProperty 不是属性值

cpp 复制代码
QMetaProperty property = meta->property(index);

property 描述的是:

text 复制代码
属性叫什么
属性是什么类型
能否读取
能否写入
有没有通知信号
是不是常量或可绑定属性

它本身不保存某个 Devicename 值。读取值时还要传对象:

cpp 复制代码
QVariant firstName = property.read(&firstDevice);
QVariant secondName = property.read(&secondDevice);

同一个 QMetaProperty 描述,可以读取不同实例中的不同值。

类比:

text 复制代码
QMetaProperty 像表格的列定义
具体对象中的属性值像每一行的单元格

8.5 为什么有 offset 和 count

methodCount() 包含继承链上的全部方法,methodOffset() 是当前类新增方法的起点。

同理还有:

  • propertyOffset() / propertyCount()
  • enumeratorOffset() / enumeratorCount()
  • classInfoOffset() / classInfoCount()

这能同时满足两种需求:

  • 反射整个对象,包括继承来的成员。
  • 只检查当前类自己声明的成员。

用一个具体数字例子理解。假设:

text 复制代码
QObject 一共有 5 个元方法
Device 自己新增 4 个元方法

那么 Device 可能表现为:

text 复制代码
methodOffset() = 5
methodCount()  = 9

索引 0..4  来自 QObject
索引 5..8  来自 Device

遍历全部方法:

cpp 复制代码
for (int i = 0; i < meta->methodCount(); ++i) {
    // QObject + Device
}

只遍历当前类新增方法:

cpp 复制代码
for (int i = meta->methodOffset();
     i < meta->methodCount();
     ++i) {
    // 只有 Device
}

8.6 一个通用对象检查器

下面这个函数可以作为学习实验:

cpp 复制代码
void inspectObject(const QObject *object)
{
    if (!object)
        return;

    const QMetaObject *meta = object->metaObject();

    qDebug() << "class:" << meta->className();

    for (const QMetaObject *current = meta;
         current != nullptr;
         current = current->superClass()) {
        qDebug() << "inherits:" << current->className();
    }

    qDebug() << "methods:";
    for (int i = 0; i < meta->methodCount(); ++i) {
        const QMetaMethod method = meta->method(i);
        qDebug() << " " << i
                 << method.methodType()
                 << method.methodSignature();
    }

    qDebug() << "properties:";
    for (int i = 0; i < meta->propertyCount(); ++i) {
        const QMetaProperty property = meta->property(i);
        qDebug() << " " << i
                 << property.name()
                 << property.metaType().name()
                 << "value:" << property.read(object);
    }
}

分别传入:

cpp 复制代码
Device device;
QPushButton button;

inspectObject(&device);
inspectObject(&button);

你会直观看到同一套反射代码如何处理完全不同的类型。

9. 元对象系统实现了哪些核心功能

9.1 信号与槽

元数据记录信号和槽的方法签名、参数类型和索引。连接建立后,发送信号会查找连接记录,并按连接类型直接调用或投递到事件队列。

信号槽的完整运行机制见:

9.2 运行期类型信息

cpp 复制代码
QObject *object = new Device;

qDebug() << object->metaObject()->className();
qDebug() << object->inherits("Device");

if (auto *device = qobject_cast<Device *>(object)) {
    device->open();
}

这里有两个不同问题:

text 复制代码
object->metaObject()->className()
    回答"这个对象的 Qt 动态类型叫什么"

qobject_cast<Device *>(object)
    回答"这个对象能否按 Device 使用"

qobject_cast 基于 Qt 元对象信息工作,不要求编译器开启原生 RTTI。它适合 QObject 体系内转换,也支持通过 Q_INTERFACES 声明的 Qt 接口。

不要把它和所有 C++ 转换混为一谈:

  • qobject_cast<T *>:用于 QObject 继承体系和 Qt 接口。
  • dynamic_cast<T *>:使用 C++ RTTI,适用于一般多态类型。
  • static_cast<T *>:不做运行期类型验证,要求调用者已经证明转换正确。
为什么不能随便使用 static_cast
cpp 复制代码
QObject *object = new QLabel;
Device *device = static_cast<Device *>(object); // 编译可能允许某些下行转换形式
device->open();                                 // 实际对象不是 Device

这种错误可能导致未定义行为。安全的运行期检查应使用:

cpp 复制代码
Device *device = qobject_cast<Device *>(object);
if (!device) {
    // 类型不匹配
}
漏写 Q_OBJECT 会发生什么
cpp 复制代码
class Device : public QObject
{
    // 忘记 Q_OBJECT
};

它仍然是合法的 QObject 派生类,也仍然可以:

  • 设置 parent。
  • 接收普通事件。
  • 使用从 QObject 继承来的成员。

Device 没有自己的完整元对象描述。常见表现包括:

  • metaObject() 只能反映最近一个带元对象的基类信息。
  • 当前类新声明的属性、信号和槽不会按预期进入自己的元数据。
  • qobject_cast<Device *> 不能获得正常的目标类型支持。
  • 自动连接、QML 暴露等动态能力出现问题。

所以判断是否需要 Q_OBJECT,不要只问"我有没有写 signal"。还要问:

text 复制代码
这个派生类是否需要被 Qt 运行期识别为它自己?
是否声明属性、信号、槽、Q_INVOKABLE 或 Qt 接口?

9.3 属性系统

属性系统是元对象中最值得仔细理解的部分之一,因为它很容易和"成员变量"混淆。

9.3.1 成员变量、getter/setter 和属性不是一回事

先看普通 C++:

cpp 复制代码
class Device
{
public:
    QString name() const
    {
        return m_name;
    }

    void setName(const QString &name)
    {
        m_name = name;
    }

private:
    QString m_name;
};

这里有:

text 复制代码
m_name      数据存储
name()      读取接口
setName()   写入接口

但外部通用工具仍不知道这三者属于同一个逻辑概念"name"。C++ 并没有自动声明:

text 复制代码
name() 是 m_name 的 getter
setName() 是 m_name 的 setter

Q_PROPERTY 的作用就是把这些关系登记到元数据中:

cpp 复制代码
Q_PROPERTY(QString name READ name WRITE setName)

可以把它读成一句话:

定义一个名为 name、类型为 QString 的 Qt 属性,读取时调用 name(),写入时调用 setName()

属性是一种统一对外描述 ,并不限定内部必须怎样存储。下面三种实现都可以描述成 name 属性:

text 复制代码
1. 直接存储在 QString m_name
2. 从数据库临时读取
3. 根据 firstName 和 lastName 计算得到

这就是属性比公开成员变量更强的地方。

9.3.2 从最小属性逐步增加能力

只读属性:

cpp 复制代码
Q_PROPERTY(QString name READ name)

可读写属性:

cpp 复制代码
Q_PROPERTY(QString name READ name WRITE setName)

可观察属性:

cpp 复制代码
Q_PROPERTY(
    QString name
    READ name
    WRITE setName
    NOTIFY nameChanged
)

支持重置:

cpp 复制代码
Q_PROPERTY(
    QString name
    READ name
    WRITE setName
    RESET resetName
    NOTIFY nameChanged
)

属性声明越完整,通用工具获得的信息越多:

text 复制代码
READ    告诉工具怎样读取
WRITE   告诉工具怎样写入
RESET   告诉工具怎样恢复默认值
NOTIFY  告诉工具怎样观察变化

一个完整属性通常写成:

cpp 复制代码
class Device : public QObject
{
    Q_OBJECT
    Q_PROPERTY(
        QString name
        READ name
        WRITE setName
        NOTIFY nameChanged
        RESET resetName
        DESIGNABLE true
        SCRIPTABLE true
        STORED true
        USER true
        FINAL
    )

public:
    QString name() const;
    void setName(const QString &name);
    void resetName();

signals:
    void nameChanged(const QString &name);
};

常见字段:

字段 含义
READ getter
WRITE setter
MEMBER 直接关联成员变量,可替代简单读写函数
RESET 恢复默认值的方法
NOTIFY 属性改变通知信号
BINDABLE Qt 6 可绑定属性接口
CONSTANT 构造后不再变化
FINAL 不允许派生类覆盖该属性
REQUIRED QML 创建对象时要求调用方初始化
DESIGNABLE 是否对设计工具可见
SCRIPTABLE 是否能被脚本系统访问
STORED 序列化/设计器保存时是否应存储
USER 是否是该类型面向用户的主属性
9.3.3 按名称读写时内部发生什么

运行期读写:

cpp 复制代码
Device device;

device.setProperty("name", QStringLiteral("camera-01"));
qDebug() << device.property("name").toString();

const QMetaObject *meta = device.metaObject();
const int index = meta->indexOfProperty("name");
QMetaProperty property = meta->property(index);
property.write(&device, QStringLiteral("camera-02"));
qDebug() << property.read(&device);

第一种写法:

cpp 复制代码
device.setProperty("name", QStringLiteral("camera-01"));

概念过程:

  1. device.metaObject() 查找名为 name 的属性。
  2. 找到 QMetaProperty 描述。
  3. 检查传入 QVariant 能否转换为属性类型 QString
  4. 通过元调用分发到 setName()
  5. setName() 执行真正业务逻辑。

所以 setProperty() 不是偷偷修改 m_name。只要属性声明使用 WRITE setName,它就应通过 setter 进入。

这意味着 setter 中的校验仍然有效:

cpp 复制代码
void Device::setName(const QString &name)
{
    const QString trimmed = name.trimmed();

    if (trimmed.isEmpty()) {
        emit errorOccurred(1, QStringLiteral("name cannot be empty"));
        return;
    }

    if (m_name == trimmed)
        return;

    m_name = trimmed;
    emit nameChanged(m_name);
}

无论调用:

cpp 复制代码
device.setName(...);
device.setProperty("name", ...);
QMetaProperty::write(...);

最终都可以复用同一套业务约束。

9.3.4 静态属性与动态属性

Q_PROPERTY 声明的是类级静态元数据,每个实例共享属性定义。

QObject::setProperty() 还可以添加类声明中不存在的动态属性:

cpp 复制代码
device.setProperty("runtimeTag", 42);
qDebug() << device.dynamicPropertyNames();

动态属性:

  • 存储在具体对象实例上。
  • 值使用 QVariant
  • 第一次新增或后续修改时,目标对象可收到 QDynamicPropertyChangeEvent
  • 适合附加少量运行期标签,不适合替代正常的数据模型设计。

两者对比:

Q_PROPERTY 静态属性 动态属性
定义位置 类声明 对象运行期间
是否进入 QMetaObject 属性表
是否每个实例都有同样定义 不一定
类型约束 属性声明确定 QVariant 当前值承载
典型用途 正式业务 API、QML、设计器 临时标签、扩展信息

例如两个同类型对象可以拥有不同动态属性:

cpp 复制代码
Device first;
Device second;

first.setProperty("debugColor", "red");

qDebug() << first.dynamicPropertyNames();  // debugColor
qDebug() << second.dynamicPropertyNames(); // 空

但它们的静态 name 属性定义完全相同。

9.3.5 NOTIFY 信号的正确语义

setter 应只在值真正变化时发通知:

cpp 复制代码
void Device::setName(const QString &name)
{
    if (m_name == name)
        return;

    m_name = name;
    emit nameChanged(m_name);
}

否则会导致:

  • QML 绑定重复求值。
  • 不必要的 UI 刷新。
  • 观察者重复执行昂贵工作。
  • 双向绑定更容易形成反馈循环。

NOTIFY 信号不是 setter 自动生成的。下面的 setter不会自动发信号:

cpp 复制代码
void Device::setName(const QString &name)
{
    m_name = name; // 没有 emit
}

Q_PROPERTY(... NOTIFY nameChanged) 只是登记:

text 复制代码
"如果 name 发生变化,观察者应该监听 nameChanged。"

真正何时发信号仍由业务代码决定。

为什么 Qt 不自动发?

  • setter 可能拒绝非法值。
  • 输入值可能经过归一化后没有变化。
  • 一个操作可能同时修改多个字段,需要最后统一通知。
  • 属性可能是计算属性,不直接对应单个成员变量。
9.3.6 一个属性如何驱动 QML 更新

假设 QML:

qml 复制代码
Text {
    text: device.name
}

概念过程:

  1. QML 通过元对象找到 name 属性。
  2. 通过 READ name 调用 getter 得到初始值。
  3. 通过元数据发现 NOTIFY nameChanged
  4. QML 监听该信号。
  5. C++ 修改名称并发出 nameChanged
  6. QML 重新读取 name
  7. Text.text 更新。

如果漏掉 NOTIFY

  • QML 仍可能读到初始值。
  • C++ 后续修改后,QML 不知道何时重新读取。

如果声明了 NOTIFY 却忘记 emit,效果类似。

9.3.7 Qt 6 的可绑定属性解决什么问题

传统方式用信号槽手工维护依赖:

cpp 复制代码
connect(widthObject, &WidthObject::widthChanged,
        areaObject, &AreaObject::recalculateArea);

connect(heightObject, &HeightObject::heightChanged,
        areaObject, &AreaObject::recalculateArea);

可绑定属性允许表达"值之间的关系":

text 复制代码
area = width * height

当依赖项变化时,绑定系统重新求值。

Qt 6 中 BINDABLE 常与 QPropertyQObjectBindableProperty 等配合:

cpp 复制代码
class Device : public QObject
{
    Q_OBJECT
    Q_PROPERTY(
        QString name
        READ name
        WRITE setName
        BINDABLE bindableName
    )

public:
    QString name() const
    {
        return m_name.value();
    }

    void setName(const QString &name)
    {
        m_name = name;
    }

    QBindable<QString> bindableName()
    {
        return QBindable<QString>(&m_name);
    }

private:
    QProperty<QString> m_name;
};

初学阶段先掌握 READWRITENOTIFYBINDABLE 是 Qt 6 在此基础上提供的依赖绑定能力,不要一开始把两套写法混在一起。

9.4 动态方法调用

Q_INVOKABLE、槽和信号会进入可调用方法元数据。

cpp 复制代码
bool result = false;

const bool invoked = QMetaObject::invokeMethod(
    &device,
    "open",
    Qt::DirectConnection,
    Q_RETURN_ARG(bool, result));

qDebug() << invoked << result;

可以把这次调用拆开看:

text 复制代码
输入对象:&device
输入名称:"open"
调用方式:DirectConnection
返回类型:bool
返回位置:result

Qt 要完成:

  1. device.metaObject() 查找 open
  2. 确认参数列表匹配。
  3. 确认返回值能够写入 result
  4. 找到方法索引。
  5. 通过元调用分发到真正的 Device::open()
9.4.1 普通调用、虚函数和元调用如何选择
场景 推荐方式
静态类型已知,只是调用成员函数 普通 C++ 调用
需要通过基类接口实现多态 虚函数
方法名称或对象类型运行期才知道 元对象动态调用
需要把调用排队到对象线程 invokeMethod 或排队信号槽
需要一对多通知 信号槽

错误倾向是因为"反射很高级"就把所有调用写成字符串:

cpp 复制代码
QMetaObject::invokeMethod(&device, "setName", ...);

如果代码明明知道类型,更好的写法通常是:

cpp 复制代码
device.setName(name);

动态能力应该用在真正动态的边界,而不是降低普通业务代码的类型安全。

9.4.2 为什么字符串调用可能失败
cpp 复制代码
bool ok = QMetaObject::invokeMethod(
    &device,
    "open",
    Qt::DirectConnection);

ok == false 常见原因:

  • 方法没有进入元对象表。
  • 名称拼错。
  • 参数数量不一致。
  • 参数类型不完全匹配。
  • 返回值包装不匹配。
  • 目标对象为空。

例如方法需要 QString

cpp 复制代码
Q_INVOKABLE void rename(QString name);

应明确传入 QString,不能假设字符串字面量一定被动态系统按预期转换:

cpp 复制代码
QMetaObject::invokeMethod(
    &device,
    "rename",
    Qt::DirectConnection,
    Q_ARG(QString, QStringLiteral("camera")));
9.4.3 字符串重载和模板重载

Qt 6 提供多种 invokeMethod() 重载:

  • 传统字符串方法名。
  • 模板参数和 qReturnArg()
  • 较新版本中的 functor/member callable 形式。

它们解决的需求不完全相同:

text 复制代码
字符串形式:真正运行期动态发现
模板形式:减少手工 Q_ARG 包装
functor 形式:重点是把一个调用排队到目标上下文

现代 Qt 6 还提供模板化的 invokeMethod 重载,能减少字符串和手工参数包装。无论使用哪种重载,都要区分:

  • 找到一个方法:依赖方法是否出现在元对象中以及签名是否匹配。
  • 在哪里执行:由连接类型和对象线程亲和性决定。
  • 何时执行:直接连接立即执行,排队连接等待目标线程事件循环。

动态调用适合:

  • 插件或脚本桥接。
  • 测试工具和对象检查器。
  • 在不知道具体静态类型时调用已约定的接口。
  • 把调用排队到对象所属线程。

普通业务代码如果静态类型已知,优先使用正常 C++ 调用或函数指针语法,编译期检查更完整。

9.5 枚举和标志位反射

cpp 复制代码
class Device : public QObject
{
    Q_OBJECT

public:
    enum class State {
        Offline,
        Connecting,
        Online
    };
    Q_ENUM(State)

    enum Option {
        Read = 0x01,
        Write = 0x02
    };
    Q_DECLARE_FLAGS(Options, Option)
    Q_FLAG(Options)
};

Q_DECLARE_OPERATORS_FOR_FLAGS(Device::Options)

查询名称和值:

cpp 复制代码
#include <QMetaEnum>

const QMetaEnum stateEnum = QMetaEnum::fromType<Device::State>();

qDebug() << stateEnum.valueToKey(
    static_cast<int>(Device::State::Online));
qDebug() << stateEnum.keyToValue("Connecting");

价值包括:

  • 日志打印可读名称。
  • 配置和字符串转换。
  • 属性编辑器和设计工具展示枚举选项。
  • QML 访问枚举。

9.6 类附加信息

cpp 复制代码
class PluginObject : public QObject
{
    Q_OBJECT
    Q_CLASSINFO("Author", "Example Team")
    Q_CLASSINFO("Version", "2.0")
};

可通过 QMetaClassInfo 枚举。它适合少量静态描述信息;复杂插件协议仍应使用明确接口和版本协商。

9.7 自动按名称连接

QMetaObject::connectSlotsByName() 会按约定查找:

text 复制代码
on_<objectName>_<signalName>(...)

例如 UI 中按钮对象名为 saveButton

cpp 复制代码
private slots:
    void on_saveButton_clicked();

Qt Designer 生成的 setupUi() 通常会调用自动连接。

优点是样板代码少,缺点是:

  • 重命名对象或槽后容易在运行期静默失效。
  • 连接关系难以全文搜索和重构。
  • 大型项目中不如显式 connect() 清晰。

工程中可以使用,但应约定命名规则并通过测试覆盖关键交互。

9.8 国际化入口

Q_OBJECT 的类可使用:

cpp 复制代码
QString text = tr("Open device");

tr() 将类名作为翻译上下文的一部分。无 QObject 实例的普通类可以使用 Q_DECLARE_TR_FUNCTIONS(Context) 等方式获得明确上下文。

元对象系统并不负责完整翻译流程,但为字符串提取和按上下文查询提供了稳定入口。

9.9 QML 和工具链桥梁

QML 引擎可以通过元对象读取:

  • Q_PROPERTY
  • 信号
  • 槽和 Q_INVOKABLE
  • Q_ENUM
  • 类型继承关系

因此,元对象是 C++ 对象暴露给 QML 的基础桥梁。Qt 6 推荐使用 QML 类型注册宏和 qt_add_qml_module() 管理模块;不要把"有 Q_OBJECT"误解为"类型会自动出现在所有 QML 模块中",类型仍需按 QML 注册规则暴露。

10. QMetaType:让系统认识参数类型

QMetaObject 描述"某个类有什么",QMetaType 描述"某种值类型如何被识别和操作"。

这是全文第二个最容易混淆的地方。

10.1 用一句话区分

text 复制代码
QMetaObject:描述类的成员结构
QMetaType:描述某一种值本身

例如:

cpp 复制代码
class Device : public QObject
{
    Q_OBJECT
    Q_PROPERTY(QString name READ name)
};

这里:

  • Device 的类名、属性和方法由 QMetaObject 描述。
  • QString 这个值类型如何复制、销毁和按名称识别,由 QMetaType 描述。

用表格对比:

问题 谁回答
Device 有哪些属性? QMetaObject
Device 有哪些信号? QMetaObject
nameChanged 的参数是什么? QMetaMethod + QMetaType
QString 占多少空间、如何复制? QMetaType
QVariant 里当前装的是什么类型? QMetaType
排队调用如何保存一个参数副本? QMetaType

10.2 为什么仅知道一个地址还不够

假设统一运输层只拿到:

cpp 复制代码
void *data;

它不知道:

  • 地址指向 int 还是 QString
  • 需要复制多少字节。
  • 能不能直接按位复制。
  • 何时调用析构函数。
  • 如何创建默认值。

对于 int,简单复制字节往往可行。对于 QStringQImage 或自定义类,必须按类型规则构造和析构。

因此需要配套的 QMetaType

cpp 复制代码
QMetaType type = QMetaType::fromType<QString>();

qDebug() << type.name();
qDebug() << type.sizeOf();
qDebug() << type.isCopyConstructible();
qDebug() << type.isMoveConstructible();
qDebug() << type.isDestructible();

10.3 QVariant 为什么也依赖元类型

QVariant 可以装不同类型:

cpp 复制代码
QVariant first = 42;
QVariant second = QStringLiteral("camera");

同一个 QVariant 类要正确管理不同值,必须记录:

text 复制代码
值的数据
值的 QMetaType

读取时:

cpp 复制代码
qDebug() << second.metaType().name(); // QString
qDebug() << second.toString();

可以把 QVariant 理解成:

text 复制代码
一个通用盒子 + 一张类型标签

没有类型标签,盒子中的原始数据无法被安全解释。

典型用途:

  • QVariant 存取自定义类型。
  • 排队信号槽复制参数。
  • 动态构造、复制和销毁值。
  • 按类型名查找类型。

自定义值类型:

cpp 复制代码
#include <QMetaType>
#include <QString>

struct DeviceStatus
{
    int code = 0;
    QString message;
};

Q_DECLARE_METATYPE(DeviceStatus)

10.4 Q_DECLARE_METATYPE 做了什么

它把 C++ 类型接入 Qt 的模板化元类型系统,使下面这类代码能够在编译期获得类型信息:

cpp 复制代码
QMetaType type = QMetaType::fromType<DeviceStatus>();
QVariant value = QVariant::fromValue(DeviceStatus{});
DeviceStatus status = value.value<DeviceStatus>();

宏通常写在完整类型定义之后:

cpp 复制代码
namespace Model
{
struct DeviceStatus
{
    int code = 0;
    QString message;
};
}

Q_DECLARE_METATYPE(Model::DeviceStatus)

注意宏放在命名空间外,类型名写完整限定名。

10.5 qRegisterMetaType 又做了什么

Q_DECLARE_METATYPE 让模板代码"知道这个 C++ 类型可作为元类型"。qRegisterMetaType<T>() 进一步把它注册到全局名称/ID 注册表,使运行期可以按名称解析:

cpp 复制代码
qRegisterMetaType<DeviceStatus>("DeviceStatus");

QMetaType type = QMetaType::fromName("DeviceStatus");
qDebug() << type.isValid();

可以粗略记成:

text 复制代码
Q_DECLARE_METATYPE:编译期接入
qRegisterMetaType:运行期名称登记

实际 Qt 版本和调用 API 会自动完成一部分注册,但对于需要排队信号槽、按名称查找、兼容旧版本或跨模块使用的自定义类型,显式在初始化阶段注册是清晰稳妥的做法。

在需要按名称查找或用于排队连接的初始化阶段注册:

cpp 复制代码
int main(int argc, char *argv[])
{
    QCoreApplication app(argc, argv);

    qRegisterMetaType<DeviceStatus>("DeviceStatus");

    return app.exec();
}

然后可用于:

cpp 复制代码
signals:
    void statusChanged(DeviceStatus status);

10.6 为什么跨线程排队需要元类型

直接连接的参数只在当前调用栈中使用,和普通函数调用类似。

排队连接必须做到:

  1. 在发送线程记录参数。
  2. 必要时复制或移动参数到事件对象。
  3. 等目标线程稍后取出。
  4. 按正确类型调用槽。
  5. 销毁事件中的参数副本。

这要求 Qt 知道类型的大小、复制/移动和析构等信息。稳妥的工程实践是:

  • 自定义值类型使用 Q_DECLARE_METATYPE
  • 应用初始化时、建立相关连接前调用 qRegisterMetaType<T>()
  • 跨线程参数优先使用拥有数据的值类型或明确共享所有权的智能指针。
  • 避免传递指向临时对象、栈对象或无生命周期协议的裸指针。

10.7 用时间线理解参数为什么必须复制

发送线程:

cpp 复制代码
void Producer::produce()
{
    DeviceStatus status;
    status.code = 200;

    emit statusChanged(status);

} // 局部变量 status 在这里销毁

接收线程可能稍后才执行:

text 复制代码
时间 T1:发送者创建局部 status
时间 T2:emit,把调用投递到目标线程
时间 T3:produce() 返回,原始 status 销毁
时间 T4:目标线程事件循环取出调用
时间 T5:槽读取参数

如果 T2 只保存 &status,到 T5 就是悬垂指针。

所以排队连接必须在 T2 创建独立参数副本或拥有其数据,QMetaType 为这个复制和销毁过程提供类型规则。

10.8 "类型已注册"不等于"对象线程安全"

下面的类型可以注册:

cpp 复制代码
struct SharedData
{
    SomeObject *pointer;
};

Q_DECLARE_METATYPE(SharedData)

但注册只表示 Qt 能复制 SharedData 这个外壳。它不会自动保证:

  • pointer 指向的对象仍然存活。
  • 多线程同时访问 pointee 是安全的。
  • pointee 被深拷贝。
  • 数据竞争自动消失。

跨线程消息最好使用拥有型值:

cpp 复制代码
struct DeviceStatus
{
    int code;
    QString message;
    QByteArray payload;
};

而不是不明确所有权的视图:

cpp 复制代码
struct DangerousStatus
{
    const char *message;
    const void *payload;
};

10.9 常见注册错误

类型不完整

只有前置声明:

cpp 复制代码
struct DeviceStatus;
Q_DECLARE_METATYPE(DeviceStatus) // 通常不合适

元类型系统需要看到完整类型,才能获得构造、析构和大小等信息。

宏放错命名空间
cpp 复制代码
namespace Model
{
struct Status {};

// 不推荐在这里直接写 Q_DECLARE_METATYPE(Status)
}

更清晰:

cpp 复制代码
Q_DECLARE_METATYPE(Model::Status)
连接建立后才注册

对需要运行期名称解析的排队连接,应在建立连接前完成注册,通常放在 main() 或模块初始化位置。

注册了裸指针,却没有生命周期协议
cpp 复制代码
qRegisterMetaType<Device *>();

这只能让 Qt 传递地址,不能让地址指向的对象自动延长寿命。

11. Q_OBJECTQ_GADGETQ_NAMESPACE

这三个宏都能增加元数据,但服务的对象不同:

text 复制代码
Q_OBJECT    服务于有身份、有生命周期、有线程归属的 QObject 实例
Q_GADGET    服务于普通类或值类型
Q_NAMESPACE 服务于命名空间中的静态枚举信息

11.1 Q_OBJECT

适用于需要完整对象能力的 QObject 派生类:

  • 信号和槽
  • 属性
  • 动态调用
  • 事件和线程亲和性
  • qobject_cast
  • 对象树和自动断连

代价是对象必须遵守 QObject 的语义,例如不可复制、具有线程亲和性。

典型例子:

text 复制代码
窗口、按钮、控制器、网络管理对象、工作线程对象

这些对象强调"它是哪一个对象":

text 复制代码
这个具体按钮
这个具体下载器
这个具体 worker

11.2 Q_GADGET

适用于不想继承 QObject,但需要部分静态元数据的值类型:

cpp 复制代码
class ErrorInfo
{
    Q_GADGET
    Q_PROPERTY(int code MEMBER m_code)

public:
    enum class Severity {
        Info,
        Warning,
        Critical
    };
    Q_ENUM(Severity)

    int m_code = 0;
};

它可以提供 staticMetaObject,支持属性、枚举和部分可调用元数据,但没有:

  • QObject 实例身份
  • 信号槽连接能力
  • 事件处理
  • 父子对象树
  • 线程亲和性

因此 Q_GADGET 不是"更轻量的 QObject 实例",而是"给普通类型附加静态元数据"。

典型例子:

text 复制代码
错误信息、配置值、颜色描述、协议数据、不可变结果对象

这些类型更强调"它包含什么值",通常希望:

  • 可以复制。
  • 可以放进容器。
  • 不需要父子对象树。
  • 不需要事件。
  • 不需要对象间连接身份。

例如:

cpp 复制代码
ErrorInfo first;
ErrorInfo second = first; // 普通值类型可以复制

QObject 不能这样复制。

11.3 Q_NAMESPACE

给命名空间增加静态元对象,常用于暴露枚举:

cpp 复制代码
namespace Protocol
{
Q_NAMESPACE

enum class Command {
    Start,
    Stop,
    Reset
};
Q_ENUM_NS(Command)
}

它不提供对象实例、信号、槽、属性或事件。

11.4 选择表

需求 推荐
对象通信、事件、线程亲和性 QObject + Q_OBJECT
普通值类型需要属性/枚举元数据 Q_GADGET
命名空间枚举需要反射/QML 可见性 Q_NAMESPACE
只需让值进入 QVariant 或排队参数 Q_DECLARE_METATYPE,必要时注册
纯 C++ 多态,不需要 Qt 动态能力 普通虚函数接口

11.5 用决策问题选择

按顺序问:

  1. 是否需要信号槽、事件或线程亲和性?
    • 是:选择 QObject + Q_OBJECT
  2. 是否只是普通值类型,但希望枚举或属性可反射?
    • 是:考虑 Q_GADGET
  3. 是否只是命名空间中的枚举需要反射?
    • 是:使用 Q_NAMESPACE
  4. 是否只需要放入 QVariant 或作为排队参数?
    • 使用 Q_DECLARE_METATYPE,并按用途注册。
  5. 是否完全不需要 Qt 动态能力?
    • 保持普通 C++ 类型,最简单。

不要因为项目使用 Qt,就让所有类都继承 QObject。业务算法、数学值、协议结构和纯数据模型通常保持普通 C++ 值类型更自然。

12. QObject 的一些重要对象语义

这些语义和元对象系统关系紧密,但不是同一个概念。

12.1 不可复制

QObject 禁止拷贝。原因包括:

  • 对象有唯一身份。
  • 内部可能存在连接关系。
  • 具有父子所有权关系。
  • 绑定到某个线程。
  • 可能有排队事件等待处理。

因此通常按指针管理 QObject,而不是按值放入容器。

更深一层的原因是 QObject 表示"对象身份",而不只是"一组值"。

假设复制一个按钮:

text 复制代码
新按钮是否继承旧按钮的 parent?
旧按钮上的连接是否复制?
排队给旧按钮的事件是否转给新按钮?
objectName 是否相同?
线程亲和性是否相同?

这些问题没有统一、自然的复制语义,所以 Qt 直接禁止复制。

对比普通值类型:

cpp 复制代码
struct Point
{
    int x;
    int y;
};

Point second = first; // 复制值,语义明确

可以记成:

text 复制代码
QObject 更像"员工本人"
值类型更像"员工填写的一张表"

表可以复制,本人身份不能简单复制。

12.2 父子对象树

cpp 复制代码
QObject *parent = new QObject;
QObject *child = new QObject(parent);

delete parent; // child 会随父对象析构

父子树解决的是对象所有权。元对象系统解决的是类型描述和动态调用。两者都在 QObject 上,所以容易被混为一谈。

父子关系不是 C++ 继承:

text 复制代码
C++ 继承:
    QPushButton is-a QWidget

QObject 父子关系:
    button is-owned-by window

代码:

cpp 复制代码
QWidget *window = new QWidget;
QPushButton *button = new QPushButton(window);

这里同时存在:

  • QPushButton 继承 QWidget,这是类型关系。
  • windowbutton 的 parent,这是对象所有权关系。

两个概念不要混淆。

12.3 自动断开连接

发送者或接收者析构时,Qt 会清理相关连接,避免之后通过该连接调用已销毁对象。

这不代表所有异步生命周期问题自动消失:

  • 队列里可能还有其他业务事件。
  • lambda 若没有 context 对象,捕获的外部指针可能悬垂。
  • 裸指针参数指向的数据可能早已失效。
  • 跨线程直接访问对象成员仍可能数据竞争。

13. 信号槽语法为什么能做编译期检查

现代写法:

cpp 复制代码
connect(
    producer,
    &Producer::statusChanged,
    consumer,
    &Consumer::handleStatus);

编译器能够检查:

  • 信号是否是合法成员。
  • 接收函数参数是否兼容。
  • 对象类型是否匹配成员函数所属类型。
  • 重载是否已明确消歧。

13.1 &Producer::statusChanged 到底是什么

它不是调用函数,而是在取得成员函数指针:

cpp 复制代码
&Producer::statusChanged

该表达式的类型中包含:

text 复制代码
所属类:Producer
返回值:void
参数:DeviceStatus

同理,&Consumer::handleStatus 也携带接收函数的真实类型信息。

connect() 是模板函数,可以在编译时比较两边参数是否兼容。因此错误能在构建阶段暴露,而不是等程序运行。

13.2 为什么普通成员函数也能作为接收端

现代成员函数指针语法已经拿到真实函数指针,所以接收函数不一定必须写在 slots: 下:

cpp 复制代码
class Consumer : public QObject
{
    Q_OBJECT

public:
    void handleStatus(DeviceStatus status);
};

下面仍可以连接:

cpp 复制代码
connect(
    producer,
    &Producer::statusChanged,
    consumer,
    &Consumer::handleStatus);

什么时候槽声明仍有额外价值?

  • 使用旧 SLOT(...) 字符串语法。
  • 需要通过名称动态调用。
  • 需要让 QML 或元对象工具发现该方法。

所以:

text 复制代码
能被现代 connect 连接
不完全等于
必须登记为元对象槽

13.3 参数兼容如何理解

信号可以比槽提供更多参数,槽只接收前面需要的部分:

cpp 复制代码
signals:
    void errorOccurred(int code, QString message);

public:
    void showErrorCode(int code);

可以连接:

cpp 复制代码
connect(
    device,
    &Device::errorOccurred,
    receiver,
    &Receiver::showErrorCode);

发出:

cpp 复制代码
emit errorOccurred(404, QStringLiteral("not found"));

槽只接收 404,忽略后面的消息。

反过来不行:

text 复制代码
信号只提供 int
槽却要求 int + QString

Qt 无法凭空构造槽缺少的参数。

13.4 重载为什么需要消歧

重载信号示例:

cpp 复制代码
connect(
    comboBox,
    qOverload<int>(&QComboBox::currentIndexChanged),
    receiver,
    &Receiver::setIndex);

如果类中有多个同名重载:

cpp 复制代码
void changed(int value);
void changed(QString value);

单独写:

cpp 复制代码
&Sender::changed

编译器不知道要取哪个函数地址。qOverload<int> 的作用是明确选择参数为 int 的重载。

13.5 为什么旧语法只能运行期检查

旧字符串语法:

cpp 复制代码
connect(
    producer,
    SIGNAL(statusChanged(DeviceStatus)),
    consumer,
    SLOT(handleStatus(DeviceStatus)));

字符串语法依赖运行期规范化签名和查表,错误往往只能在运行时警告。维护新代码时优先函数指针或 lambda 语法;只有确实需要按字符串动态连接时再使用旧语法。

例如拼错:

cpp 复制代码
connect(
    producer,
    SIGNAL(statusChagned(DeviceStatus)),
    consumer,
    SLOT(handleStatus(DeviceStatus)));

代码可能通过编译,但运行时连接失败。

现代写法如果写成:

cpp 复制代码
&Producer::statusChagned

成员不存在,编译器立即报错。

14. 运行期方法查找和调用链

以按名称调用为例,可以概括为:
#mermaid-svg-fzKiOlxZIWsyPUzC{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-fzKiOlxZIWsyPUzC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-fzKiOlxZIWsyPUzC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-fzKiOlxZIWsyPUzC .error-icon{fill:#552222;}#mermaid-svg-fzKiOlxZIWsyPUzC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-fzKiOlxZIWsyPUzC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-fzKiOlxZIWsyPUzC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-fzKiOlxZIWsyPUzC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-fzKiOlxZIWsyPUzC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-fzKiOlxZIWsyPUzC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-fzKiOlxZIWsyPUzC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-fzKiOlxZIWsyPUzC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-fzKiOlxZIWsyPUzC .marker.cross{stroke:#333333;}#mermaid-svg-fzKiOlxZIWsyPUzC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-fzKiOlxZIWsyPUzC p{margin:0;}#mermaid-svg-fzKiOlxZIWsyPUzC .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-fzKiOlxZIWsyPUzC .cluster-label text{fill:#333;}#mermaid-svg-fzKiOlxZIWsyPUzC .cluster-label span{color:#333;}#mermaid-svg-fzKiOlxZIWsyPUzC .cluster-label span p{background-color:transparent;}#mermaid-svg-fzKiOlxZIWsyPUzC .label text,#mermaid-svg-fzKiOlxZIWsyPUzC span{fill:#333;color:#333;}#mermaid-svg-fzKiOlxZIWsyPUzC .node rect,#mermaid-svg-fzKiOlxZIWsyPUzC .node circle,#mermaid-svg-fzKiOlxZIWsyPUzC .node ellipse,#mermaid-svg-fzKiOlxZIWsyPUzC .node polygon,#mermaid-svg-fzKiOlxZIWsyPUzC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-fzKiOlxZIWsyPUzC .rough-node .label text,#mermaid-svg-fzKiOlxZIWsyPUzC .node .label text,#mermaid-svg-fzKiOlxZIWsyPUzC .image-shape .label,#mermaid-svg-fzKiOlxZIWsyPUzC .icon-shape .label{text-anchor:middle;}#mermaid-svg-fzKiOlxZIWsyPUzC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-fzKiOlxZIWsyPUzC .rough-node .label,#mermaid-svg-fzKiOlxZIWsyPUzC .node .label,#mermaid-svg-fzKiOlxZIWsyPUzC .image-shape .label,#mermaid-svg-fzKiOlxZIWsyPUzC .icon-shape .label{text-align:center;}#mermaid-svg-fzKiOlxZIWsyPUzC .node.clickable{cursor:pointer;}#mermaid-svg-fzKiOlxZIWsyPUzC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-fzKiOlxZIWsyPUzC .arrowheadPath{fill:#333333;}#mermaid-svg-fzKiOlxZIWsyPUzC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-fzKiOlxZIWsyPUzC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-fzKiOlxZIWsyPUzC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fzKiOlxZIWsyPUzC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-fzKiOlxZIWsyPUzC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fzKiOlxZIWsyPUzC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-fzKiOlxZIWsyPUzC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-fzKiOlxZIWsyPUzC .cluster text{fill:#333;}#mermaid-svg-fzKiOlxZIWsyPUzC .cluster span{color:#333;}#mermaid-svg-fzKiOlxZIWsyPUzC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-fzKiOlxZIWsyPUzC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-fzKiOlxZIWsyPUzC rect.text{fill:none;stroke-width:0;}#mermaid-svg-fzKiOlxZIWsyPUzC .icon-shape,#mermaid-svg-fzKiOlxZIWsyPUzC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fzKiOlxZIWsyPUzC .icon-shape p,#mermaid-svg-fzKiOlxZIWsyPUzC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-fzKiOlxZIWsyPUzC .icon-shape .label rect,#mermaid-svg-fzKiOlxZIWsyPUzC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fzKiOlxZIWsyPUzC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-fzKiOlxZIWsyPUzC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-fzKiOlxZIWsyPUzC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 直接
排队
调用 invokeMethod 或查询 indexOfMethod
规范化/匹配方法签名
沿 QMetaObject 继承链查元数据
得到方法索引和参数元类型
调用方式
qt_metacall / 静态分发函数
复制参数并投递元调用事件
目标线程事件循环
执行真实 C++ 成员函数或 functor

这里最重要的认识是:

  • 元对象查找负责"调用哪个方法"。
  • 连接类型和事件循环负责"什么时候、在哪个线程调用"。
  • 元类型负责"擦除类型后如何保存和恢复参数"。

15. moc 的边界和常见限制

15.1 它不是完整 C++ 语义分析器

moc 只处理它需要的声明模式。过度复杂的宏、条件编译或不常见声明方式可能让自动发现和解析变困难。

工程建议:

  • 把带 Q_OBJECT 的类放在清晰的头文件中。
  • 避免用宏拼接 signals、槽和属性声明。
  • 保证 C++ 编译器和 moc 看到一致的预处理条件。

15.2 QObject 多继承约束

如果使用多继承,QObject 派生类通常应放在第一个基类位置,并且一条对象继承结构中不要出现多个 QObject 子对象:

cpp 复制代码
class DeviceWidget : public QWidget, public DeviceInterface
{
    Q_OBJECT
    Q_INTERFACES(DeviceInterface)
};

接口应是纯 C++ 抽象接口,通过 Q_DECLARE_INTERFACEQ_INTERFACES 暴露给 Qt 类型系统,而不是再继承一次 QObject

15.3 模板类和 Q_OBJECT

不要假设任意类模板都能直接像普通类一样使用 Q_OBJECT。常见替代方式:

  • QObject 能力放在非模板基类。
  • 模板类只承载类型安全的 C++ 逻辑。
  • 用具体非模板派生类声明信号和槽。
  • 对值类型反射考虑 Q_GADGET 或显式注册。

15.4 不能把私有生成格式当 ABI

qt_meta_data_*qt_static_metacall、连接表和内部事件类型是理解机制的重要线索,但它们不是应用层稳定 API。

可以依赖:

  • QMetaObjectQMetaMethodQMetaProperty 等公开 API。
  • 官方保证的信号槽、属性和线程行为。

不要依赖:

  • 生成数组的具体字段顺序。
  • 私有类布局。
  • 私有函数名。
  • 通过修改 moc 产物实现业务逻辑。

16. 常见误区

误区 1:继承 QObject 就自动拥有完整元对象能力

派生类即使不写 Q_OBJECT,仍继承了基类的部分行为,但不会获得自己声明的完整信号、槽、属性和动态类型信息。需要使用这些能力时应明确写 Q_OBJECT

误区 2:emit 会创建线程或异步任务

不会。emit 只是调用信号函数。是否异步取决于连接类型;排队调用还要求目标线程有运行中的事件循环。

误区 3:槽只能由信号调用

槽本质上是普通 C++ 成员函数,可以直接调用。通过信号触发时,Qt 额外提供连接管理和线程调度。

误区 4:QMetaObject 等于 C++ RTTI

两者有交集,但能力和适用范围不同。Qt 元对象还描述方法、属性、枚举和信号;C++ RTTI 主要支持 typeiddynamic_cast 等语言能力。

误区 5:注册了元类型就自动线程安全

元类型注册只让 Qt 知道如何保存和传递参数,不会:

  • 给对象成员自动加锁。
  • 防止两个线程同时修改共享对象。
  • 延长裸指针指向对象的生命周期。
  • 让非线程安全类型变成线程安全。

误区 6:所有反射调用都应该用字符串

静态类型已知时,普通调用和函数指针连接更易重构、性能更好、错误更早暴露。字符串反射应服务于真正的动态需求。

17. 性能如何理解

元对象调用通常比普通非虚函数调用多出:

  • 查找或读取方法索引。
  • 遍历连接记录。
  • 检查接收者生命周期。
  • 类型擦除和参数封送。
  • 排队连接中的参数复制、事件分配和线程唤醒。

但不能只得出"信号槽慢"的结论:

  • UI、网络和磁盘 I/O 的成本通常远大于一次连接分发。
  • 松耦合和自动生命周期管理减少了大量工程复杂度。
  • 真正高频的像素循环、音频采样循环或锁内热路径,不应每个元素发一次信号。

优化原则:

  1. 先测量调用频率和槽内耗时。
  2. 合并高频通知,例如批量发送结果。
  3. 大对象考虑隐式共享、移动或共享不可变数据。
  4. 不要在高频信号上连接大量昂贵槽。
  5. 不要为了省一次信号分发而破坏线程边界和模块解耦。

18. 一个可运行的综合示例

cpp 复制代码
#include <QCoreApplication>
#include <QDebug>
#include <QMetaEnum>
#include <QMetaProperty>
#include <QObject>
#include <QString>

class Device : public QObject
{
    Q_OBJECT
    Q_PROPERTY(QString name READ name WRITE setName NOTIFY nameChanged)

public:
    enum class State {
        Offline,
        Online
    };
    Q_ENUM(State)

    explicit Device(QObject *parent = nullptr)
        : QObject(parent)
    {
    }

    QString name() const
    {
        return m_name;
    }

    void setName(const QString &name)
    {
        if (m_name == name)
            return;

        m_name = name;
        emit nameChanged(m_name);
    }

    Q_INVOKABLE QString describe() const
    {
        return QStringLiteral("Device(%1)").arg(m_name);
    }

signals:
    void nameChanged(const QString &name);

private:
    QString m_name;
};

int main(int argc, char *argv[])
{
    QCoreApplication app(argc, argv);

    Device device;

    QObject::connect(
        &device,
        &Device::nameChanged,
        [](const QString &name) {
            qDebug() << "name changed:" << name;
        });

    device.setProperty("name", QStringLiteral("camera-01"));

    const QMetaObject *meta = device.metaObject();
    qDebug() << "class:" << meta->className();

    const int propertyIndex = meta->indexOfProperty("name");
    qDebug() << "property:"
             << meta->property(propertyIndex).read(&device);

    QString description;
    QMetaObject::invokeMethod(
        &device,
        "describe",
        Qt::DirectConnection,
        Q_RETURN_ARG(QString, description));
    qDebug() << description;

    const QMetaEnum stateEnum = QMetaEnum::fromType<Device::State>();
    qDebug() << stateEnum.valueToKey(
        static_cast<int>(Device::State::Online));

    return 0;
}

#include "main.moc"

如果类写在独立的 device.h 中,并由 CMake AUTOMOC 正常处理,通常不需要手工包含 "main.moc"。这里是因为示例把带 Q_OBJECT 的类直接放在 .cpp 中。

19. 排查清单

构建和链接

  • 是否真的运行了 moc
  • Q_OBJECT 的文件是否属于当前 target?
  • 生成的 moc 源码是否被编译链接?
  • 修改宏后是否重新配置 CMake?
  • 动态库导出宏是否覆盖类或 staticMetaObject

方法和连接

  • 方法是否声明为信号、槽或 Q_INVOKABLE
  • 旧字符串语法中的签名是否完全匹配?
  • 重载信号是否用 qOverloadstatic_cast 消歧?
  • 排队参数是否已注册为元类型?
  • 目标线程是否运行事件循环?

属性

  • READWRITE 方法签名是否正确?
  • 值未变化时是否错误发出 NOTIFY
  • QML 依赖的可变属性是否缺少 NOTIFYBINDABLE
  • 属性名字符串是否拼错?
  • 动态属性是否被误认为类声明属性?

生命周期

  • lambda 是否提供 context 对象?
  • 裸指针参数在排队调用执行时是否仍有效?
  • 是否跨线程直接访问 QObject
  • 是否应该使用 deleteLater()

20. 面试表达模板

问:Qt 元对象系统是什么?

可以这样回答:

Qt 元对象系统是在标准 C++ 之上通过 QObjectQ_OBJECTmoc 建立的一套运行期元数据与动态调用机制。moc 在编译前扫描类声明,生成类名、方法、属性、枚举等元数据,以及 staticMetaObjectqt_metacall 和信号函数体等分发代码。运行期 QMetaObject 使用这些数据支持信号槽、属性反射、qobject_cast、动态调用和 QML 交互。跨线程排队调用还会结合事件循环和 QMetaType 完成参数复制与延迟执行。

问:为什么 Q_OBJECT 容易引发 vtable 链接错误?

因为 Q_OBJECT 声明了一组需要由 moc 生成定义的成员。如果构建系统没有运行 moc,或者生成源码没有参与当前 target 的编译链接,这些符号就缺失,链接器常以 vtable、staticMetaObject 或信号函数未定义的形式报错。排查时先看自动生成目录和 verbose build,而不是只盯着虚函数代码。

问:QMetaObjectQMetaType 有什么区别?

QMetaObject 主要描述一个 QObject/Gadget 类型有哪些方法、属性、枚举和继承关系;QMetaType 主要描述一个值类型如何被识别、复制、移动和销毁。前者解决"对象有哪些可反射成员",后者解决"类型擦除后如何保存和传递一个值"。排队信号槽同时需要方法元数据和参数元类型。

21. 动手实验与自测

元对象系统只看文字很容易形成"好像懂了"的错觉。下面的实验都很小,但每个实验验证一个核心结论。

21.1 实验一:观察 moc 是否真的生成代码

目标

亲眼确认 Q_OBJECT 会带来额外 C++ 生成文件。

步骤
  1. 创建一个最小 Device 类并加入 CMake target。
  2. 开启 CMAKE_AUTOMOC
  3. 完成一次构建。
  4. 在构建目录搜索:
powershell 复制代码
rg --files build | rg "moc_|mocs_compilation"
  1. 打开匹配的 .cpp,搜索:
text 复制代码
Device::staticMetaObject
Device::qt_metacall
Device::nameChanged
思考

如果删掉 Q_OBJECT 再重新配置和构建:

  • Device 的专属生成代码有什么变化?
  • QObject 基类能力是否仍然存在?
  • 当前类声明的元属性和信号还能否正常工作?

21.2 实验二:故意制造一次 moc 链接错误

目标

理解 vtable 错误为什么首先要检查生成和链接流程。

步骤
  1. 保留 Q_OBJECT
  2. 临时关闭 CMAKE_AUTOMOC,或让含 Q_OBJECT 的文件脱离 target。
  3. 清理后重新构建。
  4. 记录链接器报出的符号。
  5. 恢复 AUTOMOC 并重新配置。
预期

可能看到:

text 复制代码
vtable for Device
Device::staticMetaObject
Device::nameChanged(...)

不同编译器的文字不同,但本质都是"声明存在,生成定义没有进入链接"。

21.3 实验三:比较静态类型和动态类型

cpp 复制代码
Device device;
QObject *base = &device;

qDebug() << QObject::staticMetaObject.className();
qDebug() << Device::staticMetaObject.className();
qDebug() << base->metaObject()->className();

预测输出:

text 复制代码
QObject
Device
Device

解释:

  • 第一行查询 QObject 类。
  • 第二行查询 Device 类。
  • 第三行通过虚函数查询 base 实际指向的对象。

21.4 实验四:枚举当前类和继承来的方法

cpp 复制代码
const QMetaObject *meta = device.metaObject();

qDebug() << "offset:" << meta->methodOffset();
qDebug() << "count:" << meta->methodCount();

for (int i = 0; i < meta->methodCount(); ++i) {
    const QMetaMethod method = meta->method(i);
    qDebug() << i << method.methodSignature();
}

然后把循环起点改为:

cpp 复制代码
int i = meta->methodOffset();

观察两次输出的差异。

你应该能回答:

text 复制代码
为什么第一次能看到 QObject 的 destroyed、deleteLater 等成员?
为什么第二次只看到 Device 新增成员?

21.5 实验五:验证属性不是直接访问成员变量

在 setter 中加入日志和校验:

cpp 复制代码
void Device::setName(const QString &name)
{
    qDebug() << "setter called with:" << name;

    const QString value = name.trimmed();
    if (value.isEmpty())
        return;

    if (m_name == value)
        return;

    m_name = value;
    emit nameChanged(m_name);
}

调用:

cpp 复制代码
device.setProperty("name", QStringLiteral("  camera  "));
qDebug() << device.property("name");

预期:

  • 控制台显示 setter 被调用。
  • 最终属性值是去除空格后的 camera

结论:

setProperty() 根据元数据找到了 WRITE setName,并没有绕过 setter 直接修改 m_name

21.6 实验六:观察 NOTIFY 是否只在真实变化时发出

cpp 复制代码
QObject::connect(
    &device,
    &Device::nameChanged,
    [](const QString &name) {
        qDebug() << "notified:" << name;
    });

device.setName("camera");
device.setName("camera");
device.setName("microphone");

正确 setter 应只通知两次:

text 复制代码
camera
microphone

如果通知三次,思考这种行为在 QML 大量绑定中会造成什么影响。

21.7 实验七:比较静态属性和动态属性

cpp 复制代码
Device first;
Device second;

first.setProperty("runtimeTag", 100);

qDebug() << first.metaObject()->indexOfProperty("runtimeTag");
qDebug() << first.dynamicPropertyNames();
qDebug() << second.dynamicPropertyNames();

预期:

  • indexOfProperty("runtimeTag") 返回负值,因为它不在类级元数据表中。
  • first 的动态属性列表包含它。
  • second 不包含它。

结论:

动态属性属于具体实例,不会修改 Device::staticMetaObject

21.8 实验八:观察动态调用成功和失败

cpp 复制代码
QString result;

bool first = QMetaObject::invokeMethod(
    &device,
    "describe",
    Qt::DirectConnection,
    Q_RETURN_ARG(QString, result));

bool second = QMetaObject::invokeMethod(
    &device,
    "describee",
    Qt::DirectConnection,
    Q_RETURN_ARG(QString, result));

qDebug() << first << second << result;

预期:

text 复制代码
first  为 true
second 为 false

然后去掉 describe() 前的 Q_INVOKABLE,重新构建并观察变化。

21.9 实验九:把自定义类型放入 QVariant

cpp 复制代码
DeviceStatus original;
original.code = 200;
original.message = QStringLiteral("ok");

QVariant box = QVariant::fromValue(original);

qDebug() << box.metaType().name();

DeviceStatus restored = box.value<DeviceStatus>();
qDebug() << restored.code << restored.message;

然后临时注释:

cpp 复制代码
Q_DECLARE_METATYPE(DeviceStatus)

观察编译错误。这个实验能直接说明 Q_DECLARE_METATYPE 主要解决编译期模板接入。

21.10 实验十:按名称查找元类型

注册前后分别检查:

cpp 复制代码
qDebug() << QMetaType::fromName("DeviceStatus").isValid();

qRegisterMetaType<DeviceStatus>("DeviceStatus");

qDebug() << QMetaType::fromName("DeviceStatus").isValid();

这个实验展示:

text 复制代码
Q_DECLARE_METATYPE 让模板知道类型
qRegisterMetaType 让运行期名称注册表知道类型

21.11 一张最终知识地图

#mermaid-svg-kxzF6bpIuSJ50Lmv{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-kxzF6bpIuSJ50Lmv .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-kxzF6bpIuSJ50Lmv .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-kxzF6bpIuSJ50Lmv .error-icon{fill:#552222;}#mermaid-svg-kxzF6bpIuSJ50Lmv .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-kxzF6bpIuSJ50Lmv .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-kxzF6bpIuSJ50Lmv .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-kxzF6bpIuSJ50Lmv .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-kxzF6bpIuSJ50Lmv .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-kxzF6bpIuSJ50Lmv .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-kxzF6bpIuSJ50Lmv .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-kxzF6bpIuSJ50Lmv .marker{fill:#333333;stroke:#333333;}#mermaid-svg-kxzF6bpIuSJ50Lmv .marker.cross{stroke:#333333;}#mermaid-svg-kxzF6bpIuSJ50Lmv svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-kxzF6bpIuSJ50Lmv p{margin:0;}#mermaid-svg-kxzF6bpIuSJ50Lmv .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-kxzF6bpIuSJ50Lmv .cluster-label text{fill:#333;}#mermaid-svg-kxzF6bpIuSJ50Lmv .cluster-label span{color:#333;}#mermaid-svg-kxzF6bpIuSJ50Lmv .cluster-label span p{background-color:transparent;}#mermaid-svg-kxzF6bpIuSJ50Lmv .label text,#mermaid-svg-kxzF6bpIuSJ50Lmv span{fill:#333;color:#333;}#mermaid-svg-kxzF6bpIuSJ50Lmv .node rect,#mermaid-svg-kxzF6bpIuSJ50Lmv .node circle,#mermaid-svg-kxzF6bpIuSJ50Lmv .node ellipse,#mermaid-svg-kxzF6bpIuSJ50Lmv .node polygon,#mermaid-svg-kxzF6bpIuSJ50Lmv .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-kxzF6bpIuSJ50Lmv .rough-node .label text,#mermaid-svg-kxzF6bpIuSJ50Lmv .node .label text,#mermaid-svg-kxzF6bpIuSJ50Lmv .image-shape .label,#mermaid-svg-kxzF6bpIuSJ50Lmv .icon-shape .label{text-anchor:middle;}#mermaid-svg-kxzF6bpIuSJ50Lmv .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-kxzF6bpIuSJ50Lmv .rough-node .label,#mermaid-svg-kxzF6bpIuSJ50Lmv .node .label,#mermaid-svg-kxzF6bpIuSJ50Lmv .image-shape .label,#mermaid-svg-kxzF6bpIuSJ50Lmv .icon-shape .label{text-align:center;}#mermaid-svg-kxzF6bpIuSJ50Lmv .node.clickable{cursor:pointer;}#mermaid-svg-kxzF6bpIuSJ50Lmv .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-kxzF6bpIuSJ50Lmv .arrowheadPath{fill:#333333;}#mermaid-svg-kxzF6bpIuSJ50Lmv .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-kxzF6bpIuSJ50Lmv .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-kxzF6bpIuSJ50Lmv .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kxzF6bpIuSJ50Lmv .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-kxzF6bpIuSJ50Lmv .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kxzF6bpIuSJ50Lmv .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-kxzF6bpIuSJ50Lmv .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-kxzF6bpIuSJ50Lmv .cluster text{fill:#333;}#mermaid-svg-kxzF6bpIuSJ50Lmv .cluster span{color:#333;}#mermaid-svg-kxzF6bpIuSJ50Lmv div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-kxzF6bpIuSJ50Lmv .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-kxzF6bpIuSJ50Lmv rect.text{fill:none;stroke-width:0;}#mermaid-svg-kxzF6bpIuSJ50Lmv .icon-shape,#mermaid-svg-kxzF6bpIuSJ50Lmv .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kxzF6bpIuSJ50Lmv .icon-shape p,#mermaid-svg-kxzF6bpIuSJ50Lmv .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-kxzF6bpIuSJ50Lmv .icon-shape .label rect,#mermaid-svg-kxzF6bpIuSJ50Lmv .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kxzF6bpIuSJ50Lmv .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-kxzF6bpIuSJ50Lmv .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-kxzF6bpIuSJ50Lmv :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 编写 QObject 派生类
Q_OBJECT / Q_PROPERTY / signals 等声明
CMake AUTOMOC 调用 moc
生成字符串表、元数据表、分发函数和信号函数体
C++ 编译器编译
链接进程序
运行期 QMetaObject 查询类成员
运行期 QMetaType 管理值类型
属性、枚举、动态调用、qobject_cast、QML
信号槽定位方法
QVariant、参数复制、按名称识别类型
直接调用或事件循环中的排队调用

21.12 自测题

先自己回答,再看下面的参考答案。

  1. Q_OBJECT 是不是 C++ 关键字?
  2. moc 生成的是机器码还是 C++ 源码?
  3. 为什么 Device::staticMetaObject 可以不创建对象就访问?
  4. Q_PROPERTY 会不会自动创建 m_name
  5. 声明 NOTIFY 后,setter 会不会自动发信号?
  6. QMetaProperty 保存的是属性定义还是某个实例的属性值?
  7. QMetaObjectQMetaType 各负责什么?
  8. 为什么排队调用不能只保存栈参数地址?
  9. 普通成员函数能否作为现代 connect() 的接收端?
  10. 注册一个裸指针元类型,能否保证指向对象的生命周期和线程安全?

21.13 参考答案

  1. 不是。它是 Qt 宏,展开后声明元对象相关成员。
  2. 标准 C++ 源码,之后仍由普通 C++ 编译器编译。
  3. 它是类级静态元对象,同一类型的实例共享。
  4. 不会。属性声明描述读取、写入和通知规则,实际存储由类自己实现。
  5. 不会。业务代码必须在真实变化时主动 emit
  6. 保存属性定义;读取具体值时还要提供对象实例。
  7. 前者描述类有哪些成员,后者描述值类型如何识别、复制和销毁。
  8. 发送函数返回后栈对象会销毁,稍后执行的槽将访问悬垂地址。
  9. 可以。成员函数指针语法已经提供真实调用入口;只有动态发现等场景才要求槽元数据。
  10. 不能。注册只让 Qt 认识和传递这个地址,不管理 pointee 的业务生命周期和并发访问。

22. 参考资料

相关推荐
读书札记20221 小时前
Qt中windeployqt.exe工具的使用:解决使用CMake创建的项目点击exe文件后系统提示0xc000007b的问题
开发语言·qt
luoyayun3612 小时前
Qt + FFmpeg 实战:实现音频格式转换功能
qt·ffmpeg·音频格式转换
郝学胜-神的一滴3 小时前
CMake 015:日志级别全解析
linux·开发语言·c++·qt·程序人生·软件构建·cmake
数据法师16 小时前
QuickSay :基于 Qt 的轻量级快捷短语管理工具
开发语言·qt
小短腿的代码世界17 小时前
行情快照与增量更新引擎:Qt在高频交易数据分发中的核心架构——你的行情推送为什么延迟了500ms?
开发语言·qt·架构
DogDaoDao18 小时前
深入理解 Qt:从原理到实战的全景指南
开发语言·qt·程序员
小短腿的代码世界20 小时前
Qt绘图引擎QPainter渲染管线:从光栅化到GPU加速的完整架构——为什么你的2D绘制慢了10倍?
开发语言·qt·架构
小鱼仙官1 天前
Windows Qt调用Vs库实现UDP双口接收数据
开发语言·qt
rit84324991 天前
基于Qt的串口上位机控制蓝牙小车程序
开发语言·qt