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 框架的灵魂。


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

相关推荐
AI人工智能+电脑小能手1 小时前
【大白话说Java面试题 第58题】【JVM篇】第18题:讲一下三色标记
java·开发语言·jvm
99乘法口诀万物皆可变1 小时前
面向电池管理系统(BMS)的 C++ 实时仿真内核
开发语言·c++
huaiixinsi1 小时前
Java 后端面试高频题整理(02)
java·开发语言·spring·面试·职场和发展·架构·maven
SilentSamsara1 小时前
自定义上下文管理器实战:数据库连接池、文件锁与超时控制
开发语言·python·算法·青少年编程
小短腿的代码世界1 小时前
从KB到字节:Qt行情数据压缩与传输优化的全链路透视——LZ4、Snappy与自定义二进制协议的极限压榨
开发语言·qt
灵机一物1 小时前
灵机一物AI原生电商小程序、PC端(已上线)-【技术深度解析】Bun 6 天 AI 重写 96 万行代码:从 Zig 迁移 Rust 全流程与行业影响
开发语言·人工智能·rust
Nontee1 小时前
Java 后端面试题目全集
java·开发语言·面试
lsx2024061 小时前
CSS 选择器
开发语言
Chase_______2 小时前
【Java杂项】0.1 + 0.2 为什么不等于 0.3?IEEE 754 与 BigDecimal 精度避坑
java·开发语言·python