Qt信号槽这套机制

Qt 信号槽这套机制,我用了好几天才真正理顺

上次 hello world 跑通之后,我想着是不是该让窗口能"动"起来------点个按钮做点事这种。看了一下 Qt 的玩法,核心机制叫信号槽

学校 Qt 课老师当时是这么讲的:"你就把信号当事件、槽当回调,connect 一下就完了。"我当时也觉得这有啥难的,结果自己上手才发现,坑全藏在细节里。这篇就把我这几天踩过的坑串起来记一下,主要给同样在自学 Qt 的同学一个避坑参考。

起手第一个 connect 我就栽了

最简单的版本谁都会写:

cpp 复制代码
QPushButton *btn = new QPushButton("关闭", this);
// Qt5 推荐的函数指针写法,编译期就能查拼写
connect(btn, &QPushButton::clicked, this, &QWidget::close);

clicked 是 QPushButton 自带的信号,close 是 QWidget 自带的槽。这一行写完点按钮窗口就关了,我点的时候还挺有成就感的。

但我想搞自定义的信号------比如做一个"老师"类发"下课"信号,"学生"类接收。这种东西我同学之前写过,看着也没啥复杂的:

cpp 复制代码
// 我一开始这么写,以为很简单
class Teacher : public QObject
{
    Q_OBJECT
signals:
    void classOver();
    void classOver(const QString &words);   // 重载,带句话
};

// 连接
connect(t, &Teacher::classOver, s, &Student::treatPlease);

编译报错:

复制代码
error: no matching function for call to 'QObject::connect(
    Teacher*&, <unresolved overloaded function type>, ...)'

我懵了好一会儿。看上去函数明明存在,为啥说找不到?

后来才反应过来------&Teacher::classOver 这个写法,在信号有重载的时候,编译器不知道你要哪个版本。无参版和带 QString 的版本同名,函数指针无法消歧。

Qt5.7 之后给了个 qOverload<> 解决这事:

cpp 复制代码
// 信号是无参版,显式写成 qOverload<>()
connect(t, qOverload<>(&Teacher::classOver),
        s, qOverload<>(&Student::treatPlease));

// 带参版
connect(t, qOverload<const QString &>(&Teacher::classOver),
        s, qOverload<const QString &>(&Student::treatPlease));

这玩意官方文档里其实有,但我第一次看完没记住,直到自己被报错卡到再去翻才印象深刻。

槽函数其实就是普通函数,我之前想复杂了

我一开始以为槽是 Qt 内部一种特殊东西,可能有什么"槽对象"或者"注册过的回调表"之类的玄学结构。实际我写出来才发现:

cpp 复制代码
class Student : public QObject
{
    Q_OBJECT
public slots:
    void treatPlease();                  // 就是个普通成员函数
    void treatPlease(const QString &w);  // 自己写实现就行
};

void Student::treatPlease()
{
    qDebug() << "老师下课了,走人";
}

public slots: 就是个标记,告诉 moc(Qt 的元对象编译器)"这个函数我打算让它能被信号当槽调用"。除此之外它和普通成员函数没区别------你直接 s->treatPlease() 调用一点毛病没有。

我之前总觉得"信号是某种发布订阅消息","槽是订阅者注册的回调"。现在看就是个普通函数调用,只不过 moc 帮我们在中间生成了一层胶水代码,让函数指针 connect 这套写法能跑起来。

emit 这玩意是个空宏

刚学到触发信号要写 emit signalName();,我一开始以为 emit 是 Qt 提供的一个真正的关键字或函数,负责做什么"派发""队列入队"之类的事。

后来在头文件里搜了一下,直接看到:

cpp 复制代码
// Qt 源码大致是这个意思
#define emit

是个空宏。也就是说下面两行是完全等价的:

cpp 复制代码
emit classOver();
classOver();          // 一模一样

那它存在的意义是啥呢?纯粹是给人看的可读性标记------一眼看出"这里在发信号",而不是在调一个普通成员函数。我现在写代码也一律加 emit,虽然知道它是空的。

Lambda 当槽真好用,但 this 那个参数别漏

接下来想试试 Lambda 当槽。Qt5 是支持的,而且很丝滑:

cpp 复制代码
connect(btn, &QPushButton::clicked, [this]() {
    qDebug() << "按下了";
});

但我自学的时候看了个老外的视频,他在 lambda 前面塞了一个 this,我当时心想"哦这不就是为了 capture 吧",随手就把第三个参数省了。

后来读 Qt 文档我才搞明白,第三个参数那个 this 不是 capture,是 context object:

cpp 复制代码
// 推荐写法:第三个参数传 context
connect(btn, &QPushButton::clicked, this, [this]() {
    qDebug() << "按下了";
});

context 干啥用的?它决定了 lambda 的生命周期------一旦 context 对象被析构,这条 connection 自动断开。如果你的 lambda 捕获了某些对象指针,而那个对象先死了,触发信号时直接野指针段错误。

我有次在写一个小作业,把 lambda 里捕获了一个临时对象,窗口关之后还跑了一个延迟信号,结果直接 crash。debug 半天发现就是 lambda 触发时捕获的对象已经没了。从那以后 context 我一律老老实实写上。

配套 demo 跑起来的样子

我把这些点做成一个 demo,左边 6 个按钮分别对应不同的信号槽用法,右边一个日志区,点哪个按钮就把过程写在日志里。

启动初态:

点第二个 Lambda 按钮,日志区多了一行:

点第四个"自定义信号槽(带参)",Teacher 那边 emit 完 Student 收到了句话:

第五个"一对多"------一次点击触发了 3 个 lambda,日志区一次冒出 3 行:

最后是 disconnect 演示。两次点击区别很明显------第一次正常两个槽都触发,但第一次结束后我会把 lambda 那条断开,所以第二次点只剩普通槽触发:

一对多的写法朴素到我没敢信

刚开始我以为"一个信号连多个槽"得用啥特殊 API,可能 connectMany 之类的。实际上就是 connect 多次而已:

cpp 复制代码
connect(btn, &QPushButton::clicked, this, &Widget::onLog);
connect(btn, &QPushButton::clicked, this, &Widget::onPlaySound);
connect(btn, &QPushButton::clicked, this, [this]() {
    qDebug() << "再来一个";
});

按 connect 顺序依次触发。多对一也一样------多个不同信号 connect 到同一个槽就行。

这设计简洁是真简洁,但我作为一个习惯 Java 的人一开始有点想不通"那它怎么记住的"。后来 Qt 文档说就是个内部链表,每个信号对象维护一个 connection 列表,触发时遍历。倒也没啥黑魔法。

disconnect 的两种姿势

我刚开始以为 disconnect 只能用 connect 时一模一样的签名重新写一遍来断:

cpp 复制代码
disconnect(btn, &QPushButton::clicked, this, &Widget::onClicked);

这写法没错。但更精确的玩法是保存 connect 的返回值------它返回一个 QMetaObject::Connection,这玩意就是这条连接的句柄。

cpp 复制代码
// 连的时候记下来
QMetaObject::Connection c = connect(btn, &QPushButton::clicked, this, [this]() {
    qDebug() << "lambda 槽";
});

// 想断时一行搞定,精确到刚才那条 lambda
QObject::disconnect(c);

这就解决了一个我之前以为没法办的问题:lambda 怎么单独断开?用普通签名版的 disconnect 你拿不到 lambda 的"名字",根本写不出来。句柄版完美解决。

我栽过的坑,一条条记下来

  • Q_OBJECT 宏漏写 :链接报 undefined reference to vtable for Teacher,看上去和信号槽八竿子打不着,实际就是 moc 没处理这个类。我大一时学 C++ 没见过类似错误,看到这条第一反应是"是不是没 include 库",结果是漏个宏。
  • 新加 Q_OBJECT 后编译找不到 moc 文件:得删 build_auto/ 目录重新 qmake。Qt Creator 的 shadow build 偶尔会缓存出错。
  • 槽参数表比信号多:连接静默失败,只在程序启动时 qWarning 一句。我有次写带参信号忘了实际只发了一个 int,槽里接收两个参数,结果按钮怎么按都没反应,以为是按钮坏了,折腾了 20 分钟才看 stderr 里那行 warning。
  • lambda 捕获悬空对象 :我之前提过,信号触发时崩溃。永远写 context 参数,这点我宿舍那位也学过 Qt 的室友说他也踩过同样的坑。
  • SIGNAL/SLOT 字符串宏写错信号名:运行时才知道。这种写法是 Qt4 时代的,新代码我已经全部换成函数指针写法了,编译期就能查出来。
  • Qt 信号槽连接的开销:有同学问我"信号槽这么神奇是不是很慢"。看 Qt 文档给的参考数据,同线程下信号槽比直接函数调用慢一个数量级------但还是 us 量级的事,GUI 场景完全感知不到。所以放心用,别为了所谓"性能"绕开它。

一点感想

学到现在我有个感受------Qt 把回调这件事做得比裸 C++ 优雅太多。

裸 C++ 写回调要面对:函数指针类型不安全、void* 上下文、多个监听者要自己维护列表、跨线程要自己加锁。这些问题 Qt 信号槽一套机制全解决了:

  • 类型安全:函数指针 connect 编译期就能查出错
  • 多监听:connect 多次自动支持一对多
  • 跨线程:换个 Qt::QueuedConnection 就行
  • 对象析构:接收方死了 connection 自动断开

代价是要继承 QObject、要 moc、要 Q_OBJECT 宏。但对比裸 C++ 那一坨手写胶水,我觉得这笔交易很值。

到此第一波信号槽的坑算是踩明白了。下一步打算搞搞常用控件------QLineEdit、QComboBox、QListWidget 这一类,据说每个控件都有一大堆自己的信号,正好把这一波学的连接套路再练手一遍。

如果你也是大三、想暑期投个实习把 Qt 写进简历,信号槽这块真的不要绕开,这是 Qt 区别于其他 GUI 框架的灵魂。


如果有同学也在折腾这一块,欢迎评论区交流踩过的坑。

相关推荐
Quz4 天前
QML Hello World 入门示例
qt
xcyxiner7 天前
DicomViewer (dcmtk读取dcm文件)5
qt
xcyxiner8 天前
DicomViewer (后台线程处理文件)4
qt
xcyxiner8 天前
DicomViewer (添加模型类)3
qt
xcyxiner9 天前
DicomViewer (目录调整) 2
qt
xcyxiner9 天前
dcmtk vtk vtk-dicom(gdcm) 编译(debug) v2
qt
LDR00611 天前
Type-C 快充全面升级!LDR6601 赋能个人护理便携电机,重塑剃须刀 / 理发器新体验
c语言·开发语言
雪碧聊技术11 天前
Tree.js是什么?一文讲透
开发语言·javascript·ecmascript
码云数智-园园11 天前
C++20 Modules 模块详解
java·开发语言·spring
swordbob11 天前
NIO的channel中什么是 fd(File Descriptor,文件描述符)
java·开发语言·nio