欢迎光临
我们一直在努力

Qt 信号与槽全梳理:从三个要素、connect,到自定义信号、disconnect 与 lambda

Qt 信号与槽全梳理:从三个要素、connect,到自定义信号、disconnect 与 lambda

写在前面

图形界面程序说到底只有一件事要做:用户做了什么操作,程序就给出什么反应。这个"什么操作"和"什么反应"之间的对应关系,在 Qt 里被抽象成了一套叫信号与槽 (signal and slot) 的机制。刚接触它的时候我最大的困惑不是"怎么用",而是"为什么要多绕一层"——我在按钮的回调里直接写一句关窗口不就行了,为什么还要先 connect 一下、再让一个叫"信号"的东西去通知一个叫"槽"的函数?把这个问题想通之后,剩下的细节其实都是围绕它展开的。

这篇笔记按两条主线走。第一条是是什么、为什么这样设计:信号与槽的三个要素、槽为什么本质上是回调函数、QObject 继承体系为什么是整个机制的底座、connect 的每个参数各自在描述什么、以及多对多连接和数据库里关联表的对应关系。第二条是代码里到底怎么写、写错了会怎样:Qt4 与 Qt5 两代 connect 写法的差别、怎么自定义槽、怎么让界面编辑器自动连上槽、怎么自定义信号并把它发射出去、信号与槽之间的参数怎么匹配、disconnect 和 lambda 槽又该怎么用。

每一处代码我都会说明它是能编译运行、故意写错用来观察失败现象,还是完成度只有一半。这一点很重要:把错误的写法当成正确写法背下来,比不学更麻烦。

知识框架

整篇内容围绕"是什么、怎么连、怎么发、怎么断"四件事展开,下面这张图是后面每一节的骨架,章节编号和文字目录一一对应。

在这里插入图片描述

从左到右五个知识块,正好对应"我要用信号槽,需要依次搞清楚什么":

  • 是什么——三个要素、QObject 继承体系、以及槽函数在 C++ 回调函数这条脉络里的位置;
  • connect 连接——两代写法的差别,以及 Qt5 为什么能在编译期就发现参数写错;
  • 内置信号与槽——怎么从帮助文档里翻出一个控件自带的信号和槽;
  • 自定义信号与槽——signals 声明规范、emit 发射、以及参数传递必须遵守的规则;
  • 连接关系——一对一、一对多、多对一、多对多,disconnect 怎么断开,lambda 怎么写。
  • 目录

    • 一、信号与槽是什么:三个要素
    • 二、槽函数的本质是回调函数
    • 三、Qt 的类继承体系:QObject 与控件
    • 四、connect:四个参数描述一组因果关系
    • 五、跑通第一个例子:点击按钮关闭窗口
    • 六、内置信号和槽从哪里查:多看文档,多翻父类
    • 七、Qt4 与 Qt5 两代 connect 写法
    • 八、自定义槽函数
    • 九、转到槽:不写 connect 也能连上
    • 十、自定义信号与 emit 发射
    • 十一、带参数的信号与槽
    • 十二、Q_OBJECT 宏的位置很关键
    • 十三、连接方式:从一对一走到关联表
    • 十四、disconnect:想让信号换绑一个槽
    • 十五、用 lambda 表达式写槽函数
    • 十六、容易踩错的地方与技术边界
    • 总结
    • 标签

    一、信号与槽是什么:三个要素

    1.1 从一个更早见过的"信号"说起

    "信号"这个词我不是第一次见了。在更早学的系统编程里就有一个同名的概念,英文同样写作 signal,它指的是操作系统内部的一种通知机制:某个事件发生了,内核就往进程身上发一个信号,进程按预先注册好的方式去响应。

    举两个当时印象很深的例子。程序里出现了越界访问内存这类问题,内核会向这个进程发送 SIGSEGV,也就是段错误 (segmentation fault),进程通常直接异常终止;再比如用管道通信时,管道对端已经关闭,你还在往里写,就会收到 SIGPIPE。这两个名字里的 SIG 就是 signal 的缩写。

    Qt 的信号和操作系统里的信号不是同一个东西,两者没有直接联系,但结构上确实很像。像在哪里?像在它们都用同样的三个要素来描述一次通知。

    1.2 三个要素

    第一是信号源,也就是"谁发出了这个信号"。操作系统里的信号,发出主体是内核或者另一个进程;而在 Qt 里,发出信号的主体是图形界面里的某个控件——是按钮发的,还是输入框发的,还是别的什么部件发的,这就是信号源。这里要留意"控件"指的是具体对象:信号是由某一个实例化出来的对象发出的,而不是由类发出的。所以后面写 connect 时,第一个参数传进去的永远是一个对象的指针。

    第二是信号的类型,也就是"这是哪一类信号"。操作系统里 SIGSEGV 和 SIGPIPE 是不同类型的信号,代表不同的事件;Qt 里同理,信号类型实际上是在区分用户做了哪种操作:点一下按钮是一种操作,在输入框里移动光标是另一种操作,勾选复选框、拉开下拉框又各是不同操作。图形界面程序存在的意义就是和用户交互,所以必然要能区分用户当前到底干了什么,才能针对不同操作做不同处理。

    第三是信号的处理方式,也就是"收到这个信号之后要做什么"。操作系统里我们通过注册一个信号处理函数来完成,信号触发时由系统自动执行它。

    Qt 在这里换了一套专门的说法:处理方式被叫做槽 (slot)。所谓槽,本质就是一个函数,只是 Qt 给了它一个专门的术语。我们用 connect 这个函数把某个信号和某个槽关联起来,关联关系一旦建立,之后只要那个信号被触发,Qt 就会自动执行对应的槽函数。

    下面这张图把三个要素和它们在 Qt 里的落点放在一起对照:

    在这里插入图片描述

    这里有个容易被忽略的细节:"信号"在代码里是用函数表示的。用户点了按钮,Qt 框架就去调用这个按钮对应的那个信号函数,用"调用一个函数"来通知外界"有人点了我"。所以后面写代码时,第二个参数总是取某个类成员的地址,它取的就是这个信号函数。

    1.3 用一个生活场景记住三个要素

    抽象的概念用场景过一遍会牢得多。假设我下班回家,一开门看到媳妇儿坐在沙发上,面色不善。这时候:

    • 信号源是谁发出的信号——媳妇儿;
    • 信号类型是哪一类信号——“面色不善”;
    • 信号的处理方式是什么——脑子飞速检索最近是不是忘了纪念日、是不是昨天打游戏闹了矛盾,定位到原因之后,去冲一杯红糖水递过去。

    换个场景,还是回家开门,这回媳妇儿非常热情地扑过来。信号源没变,信号类型变成了"热情得有点吓人",处理方式也随之切换:检索最近她是不是看上了某个包或某件衣服,然后回一个字——买。

    这两个场景里有一点特别值得记:我的反应不是临场现想的。能在看到"面色不善"的瞬间就想到冲红糖水,是因为这个处理方式在很多年的相处里早就准备好了。换句话说,处理方式一定是提前准备好的,等信号来了才能立刻接上。这一点直接对应到代码里就是一条硬规矩。

    1.4 顺序不能颠倒:先连接,再发射

    把上面的场景翻译成代码就是:

  • 先用 connect 把信号和处理它的槽绑定好;
  • 之后再让信号被发射出来。
  • 顺序反了会怎样?如果先发射信号,而此时槽还没绑上,这一次信号就没人处理,直接错过。这一点在自定义信号上体现得最明显——我会在第 10 节亲手复现这个"连了但没反应"的现象。

    💡 提示:connect 这个名字和网络编程里 TCP 建立连接的那个 connect没有任何关系,只是恰好同名,不要做过度联想。

    二、槽函数的本质是回调函数

    "槽函数的本质是一种回调函数 (callback)"这句话如果只是记住,用起来还是虚的。我按时间顺序把学过的东西捋了一遍,发现回调这个概念其实出现过很多次,只是每次换了个壳。

    最早是 C 语言的函数指针。 在那个阶段讲函数指针有两个主要用途:一是做转移表,把一串 if-else 或者 switch 换成查表调用,降低圈复杂度、让代码更好读;二是实现回调——你把函数的地址交出去,什么时候调用不归你管,由别人在合适的时机替你调。

    后来是 C++ 的仿函数 (functor),也叫函数对象。 做法是重载 operator(),让一个对象可以像函数一样被调用。有了它,标准库的算法就能把"对每个元素做什么"这件事交出去:for_each 遍历区间时,由你提供的仿函数决定对每个元素的操作规则;find 默认按相等比较,想按别的规则找,也把比较规则写成仿函数传进去;set、map 这类要求元素有序的容器,甚至"什么叫大、什么叫小"都得靠一个比较器来约定,这个比较器同样是仿函数。

    这些例子的共同点是:那些函数都不需要我手动调用。我只是把它"交给"某个算法或容器,具体什么时候执行由对方决定。

    再后来是 lambda 表达式。 它天生就是为了当回调用的。回调往往是一次性的,用完就不再用,与其在外面单独定义一个具名函数或者仿函数,不如就地写一个匿名函数来得省事;再借助变量捕获,还能免掉一堆繁琐的传参。本质不变,还是回调。

    再往后是系统编程里的几处。 最典型的是信号处理函数;创建线程时给 pthread_create 指定的那个入口函数也是——它不是我们手动调的,而是线程被创建好之后由系统自动执行;IO 多路复用的内核实现里同样大量依赖回调机制。

    把这些串起来看,槽函数的位置就很清楚了:它是同一类东西在 Qt 里的又一次出现,只不过 Qt 给"注册回调"这一步单独取了个名字叫 connect,给"回调函数本身"取了个名字叫槽。

    在这里插入图片描述

    三、Qt 的类继承体系:QObject 与控件

    connect 不是凭空出现的函数,它是某个类提供的静态成员函数。要理解"为什么随便拎一个控件都能调用它",就得先看 Qt 的类是怎么组织起来的。

    Qt 里提供的类之间存在继承关系,这一点和面向对象里讲的继承是一回事:子类继承父类的属性和方法。我们已经用过的 QPushButton、QLineEdit 都是控件类,它们有一个共同的父类 QWidget。

    QWidget 这个词直译过来是"小部件"或者"组件",放到图形界面编程里通常译作控件——屏幕上你能看到、能点的每一个东西,本质上都是一个 QWidget。

    再往上还有一层:QWidget 又继承自 QObject。而 QObject 不只是控件的祖先——Qt 里除了控件这一大系列之外还有很多别的内置类,它们并不是 QWidget 的子类,而是和 QWidget 并列,同样直接或间接继承自 QObject。所以可以说,QObject 是 Qt 所有内置类的祖宗类。

    下面这张图把这条继承链画了出来:QObject 之下分出 QWidget 这一支(也和别的非控件类并列),QWidget 再往下是 QAbstractButton 这个抽象按钮类,从它又派生出几种具体按钮——QPushButton(普通按钮)、QCheckBox(复选框)、QRadioButton(单选框)、QToolButton(工具按钮)。

    在这里插入图片描述

    这张图后面还会用到:第六节要找的 clicked 信号声明在 QAbstractButton 里,close 槽来自 QWidget,而 connect 能力来自最顶端的 QObject。

    这套设定看着眼熟——Java 里所有类都继承自 Object 类,哪怕自己随手写一个类,默认也是它的子类。至于谁先谁后,有个常被提起的说法是 Qt 诞生于 1991 年、比 Java 更早,所以应该是 Java 借鉴了 Qt。这个说法其实站不住:Qt 最早的版本并没有 QObject 这套机制,它是后来某个大版本才引入的。谁借鉴谁在这里并不重要,好的设计被多方采用是常态;对写代码的人来说,真正有用的是下面这个结论。

    由于 QObject 提供了 connect,而所有 Qt 内置类都直接或间接继承自 QObject,所以 Qt 里随便拿出一个类都能使用 connect。 我们自己写的 Widget 类之所以能直接调 connect,原因也是它继承自 QWidget、QWidget 继承自 QObject。

    四、connect:四个参数描述一组因果关系

    connect 的函数原型是这样的:

    connect (const QObject *sender,
    const char * signal ,
    const QObject * receiver ,
    const char * method ,
    Qt::ConnectionType type = Qt::AutoConnection )

    一共五个参数。最后一个 type 带默认值 Qt::AutoConnection,作用是指定关联方式,绝大多数场景不需要手动设定,可以先放一边。真正要写的是前面四个,而它们恰好就是在描述第 1 节说的那三个要素,外加一个"谁来处理"的指定。

    在这里插入图片描述

    第一个参数 sender,类型 const QObject *。 它指定信号源,也就是"这个信号是哪个控件发出来的"。类型写作 QObject 的指针,而实际往往传一个 QPushButton * 进去——子类指针赋给父类指针是允许的。之所以参数类型能收得这么宽,正是第 3 节那套继承体系带来的便利。

    第二个参数 signal。 它描述信号的类型,也就是"发的是哪一种信号"——是点击信号、按键信号,还是别的什么信号。

    第三个参数 receiver,类型同样是 const QObject *。 它指定"哪个对象负责处理"这个信号。这个对象可以任意指定,不一定非要和信号源是同一个。

    第四个参数 method。 它描述"这个对象具体怎么处理",也就是由哪个成员函数来完成响应。成员函数必须有对象才能调用,所以第三、第四个参数其实是不可分割的一对:先指明是哪个对象的,再指明是这个对象里的哪个函数。

    用一句话概括 connect 在做什么:把"对某个对象做某个动作"和"另一个对象执行某个成员函数"这两件事绑定成因果关系。

    4.1 一对一:一个信号连接一个槽

    最简单的连接关系就是一对一:一个信号只连一个槽,触发信号就执行那一个函数。

    在这里插入图片描述

    实际写代码时不会真的写成上面原型里那种 const char * 形式——那是旧版本的声明。第二、第四个参数真正传进去的,是两个函数指针。这一点值得单独讲清楚,因为它直接引出了第 7 节两代写法的差别。

    4.2 第一批例子:界面上的按钮与"关闭"这个动作

    第 1 节提到过,Qt 里的信号在代码中就是用函数表示、用取地址的方式拿到它的。那么一个按钮到底有哪些信号可用?一个窗口又有没有现成的槽可以接?这些问题在第 6 节统一回答,这里先把两个结论摆出来:QPushButton 有 clicked 信号,QWidget 有 close 槽。第 5 节的完整例子就是把这两者连起来。

    五、跑通第一个例子:点击按钮关闭窗口

    需求很小:界面上放一个按钮,用户点它,窗口关闭。下面是这个例子的完整工程,工程名 Signal_1。它是本篇所有例子的起点,后面的工程都在它的基础上改动,所以我把五个文件完整放在这里。

    5.1 工程文件

    Signal_1.pro 是 qmake 的工程描述文件,.pro 后缀表示 project,它告诉构建系统"这个工程由哪些源文件组成、要链接哪些模块"。

    文件名:Signal_1.pro

    QT += core gui

    greaterThan(QT_MAJOR_VERSION, 4): QT += widgets

    CONFIG += c++11

    # The following define makes your compiler emit warnings if you use
    # any Qt feature that has been marked deprecated (the exact warnings
    # depend on your compiler). Please consult the documentation of the
    # deprecated API in order to know how to port your code away from it.
    DEFINES += QT_DEPRECATED_WARNINGS

    # You can also make your code fail to compile if it uses deprecated APIs.
    # In order to do so, uncomment the following line.
    # You can also select to disable deprecated APIs only up to a certain version of Qt.
    #DEFINES += QT_DISABLE_DEPRECATED_BEFORE=0x060000 # disables all the APIs deprecated before Qt 6.0.0

    SOURCES += \\
    main.cpp \\
    widget.cpp

    HEADERS += \\
    widget.h

    FORMS += \\
    widget.ui

    # Default rules for deployment.
    qnx: target.path = /tmp/$${TARGET}/bin
    else: unix:!android: target.path = /opt/$${TARGET}/bin
    !isEmpty(target.path): INSTALLS += target

    这个文件里每一行都不是样板噪音,值得逐项说清:

    • QT += core gui:往工程里追加 Qt 的 core 和 gui 两个模块。+= 是 qmake 的追加赋值;core 提供 QObject、信号槽机制、容器等非界面基础能力,gui 提供图形界面相关的基础类。
    • greaterThan(QT_MAJOR_VERSION, 4): QT += widgets:这是一个条件判断,greaterThan(a, b) 在 a 大于 b 时成立,冒号后面才是成立时执行的语句。整句的意思是"如果 Qt 主版本号大于 4,就再追加 widgets 模块"。原因是 Qt5 把界面控件从 gui 模块里拆了出去单独成模块,而 Qt4 没有这个模块。这样写能让同一份 .pro 同时兼容两代版本。
    • CONFIG += c++11:告诉编译器按 C++11 标准编译。这一句是后面第 15 节能用 lambda 表达式写槽的前提,Qt5 新建项目时会自动带上,Qt4 的老项目需要手动加。
    • DEFINES += QT_DEPRECATED_WARNINGS:定义一个同名宏,作用是让编译器在使用到被标记为过时的 API 时给出警告。DEFINES += 就是"往预处理宏列表里追加一个宏"。
    • #DEFINES += QT_DISABLE_DEPRECATED_BEFORE=0x060000:这一行是注释掉的,属于可选项,解开后会让使用过时 API 直接变成编译错误,而不是只警告。0x060000 是十六进制版本号写法,代表 6.0.0。
    • SOURCES += \\、HEADERS += \\、FORMS += \\:分别声明源文件、头文件和界面文件。行尾的 \\ 是续行符,表示这一行没写完,下一行接着算同一条语句。SOURCES 里列了 main.cpp 和 widget.cpp,HEADERS 里是 widget.h,FORMS 里是 widget.ui——注意 FORMS 接收的是界面文件,qmake 会先把它转成 C++ 代码再参与编译。
    • 最后几行是部署规则。qnx: target.path = … 和 else: unix:!android: target.path = … 里的 qnx、unix、android 都是平台条件,!android 表示"非 android 平台";target.path 指定安装路径。!isEmpty(target.path): INSTALLS += target 的意思是"如果 target.path 不为空,就把 target 加入安装目标列表"。isEmpty() 是判断是否为空的函数。这些是 qmake 模板自带的默认规则,本地开发用不到,但知道它们在做什么比当成黑盒跳过要好。
    • ${TARGET} 是 qmake 变量展开写法,表示工程目标名。

    5.2 程序入口

    main.cpp 是整个程序的入口,main 函数从这里开始执行。

    文件名:main.cpp

    #include "widget.h"

    #include <QApplication>

    int main(int argc, char *argv[])
    {
    QApplication a(argc, argv);
    Widget w;
    w.show();
    return a.exec();
    }

    代码导航:

  • #include "widget.h" 引入自定义窗口类的声明,#include <QApplication> 引入应用类。尖括号形式用于系统或库头文件,双引号形式用于当前工程里的头文件。
  • int main(int argc, char *argv[]) 是标准入口签名,两个形参由运行环境填入命令行参数,这里只是原样转交给下面。
  • QApplication a(argc, argv); 创建应用对象。每一个使用图形界面的 Qt 程序都必须有一个 QApplication 实例,而且必须是先于所有界面对象创建的第一个对象,因为它负责初始化事件循环、字体、样式等全局资源。
  • Widget w; 创建自定义窗口对象。这里的 Widget 就是 widget.h 里声明的那个类,本工程的所有界面逻辑都写在它里面。
  • w.show(); 把窗口显示出来。不调用它窗口不会出现在屏幕上。
  • return a.exec(); 进入事件循环。这一句会阻塞在这里不断等待用户操作,把每次操作分发给对应的处理函数,直到窗口关闭才返回。程序里那些点击、按键之所以能被响应,全靠这个循环在转。它的返回值作为进程退出码返回给系统。
  • 5.3 窗口类的声明

    widget.h 声明了窗口类 Widget:

    文件名:widget.h

    #ifndef WIDGET_H
    #define WIDGET_H

    #include <QWidget>

    QT_BEGIN_NAMESPACE
    namespace Ui { class Widget; }
    QT_END_NAMESPACE

    class Widget : public QWidget
    {
    Q_OBJECT

    public:
    Widget(QWidget *parent = nullptr);
    ~Widget();

    private:
    Ui::Widget *ui;
    };
    #endif // WIDGET_H

    代码导航:

    • #ifndef WIDGET_H / #define WIDGET_H / #endif // WIDGET_H 是头文件保护,作用是防止同一个头文件被重复包含导致重复定义。第一次包含时 WIDGET_H 未被定义,内容生效;之后再包含时条件不成立,内容被跳过。
    • QT_BEGIN_NAMESPACE 与 QT_END_NAMESPACE 包住了 namespace Ui { class Widget; }。这对宏在默认配置下会展开成 namespace Qt { … },作用是让这段声明跟随 Qt 的命名空间设置。里面的 namespace Ui { class Widget; } 是一句前向声明:告诉编译器"存在一个叫 Ui 的命名空间,里面有一个叫 Widget 的类",但暂时不给出它的定义。真正生成这个类的代码在编译期由界面文件转换而来,所以这里只需要知道有这么个类型就够。
    • class Widget : public QWidget 表示 Widget 公有继承自 QWidget,因此它天生就是一个窗口。
    • Q_OBJECT 是一个宏,它决定这个类能不能使用信号槽——这一点在第 12 节会单独展开,暂时先记住"要用信号槽就必须写它"。
    • Widget(QWidget *parent = nullptr); 是构造函数,形参 parent 表示父对象,带默认值 nullptr(空指针,表示"没有父对象")。第 5.2 节 main.cpp 里写的是 Widget w;,没有传参,用的就是这个默认值。
    • ~Widget(); 是析构函数,在对象销毁时调用。
    • Ui::Widget *ui; 是一个指向界面对象的指针成员。界面文件里拖出来的那些控件都挂在它下面。

    5.4 信号与槽连接写在这里

    widget.cpp 是构造函数所在处,也是本工程唯一一处信号与槽的连接:

    文件名:widget.cpp

    #include "widget.h"
    #include "ui_widget.h"
    #include <QPushButton>

    Widget::Widget(QWidget *parent)
    : QWidget(parent)
    , ui(new Ui::Widget)
    {
    ui->setupUi(this);

    QPushButton* button = new QPushButton(this);
    button->setText("关闭");
    button->move(200, 200);

    connect(button, &QPushButton::clicked, this, &Widget::close);
    }

    Widget::~Widget()
    {
    delete ui;
    }

    代码导航,按执行顺序看:

  • #include "ui_widget.h" 引入由 widget.ui 转换生成的头文件,Ui::Widget 的完整定义来自这里。
  • #include <QPushButton> 引入按钮类的定义。之所以必须显式包含它,是因为下面要用 new QPushButton 创建对象、还要用 &QPushButton::clicked 取信号地址,编译器必须看到完整的类声明才行。只包含 QWidget 是不够的。
  • 构造函数的初始化列表 : QWidget(parent), ui(new Ui::Widget) 做两件事:把 parent 转交给父类 QWidget 的构造函数;给成员指针 ui 分配一个 Ui::Widget 对象。成员在初始化列表里分配,是为了在构造函数的函数体开始执行之前就准备好,避免函数体里用到还没初始化的成员。
  • ui->setupUi(this); 是最关键的一句:它按照界面文件的描述,把窗口上的所有控件依次创建出来并摆好位置,同时挂到当前对象 this 上。跳过它,界面上什么都没有。
  • QPushButton* button = new QPushButton(this); 创建一个按钮对象。用 new 在堆上创建,并把 this 作为父对象传进去。这一步不只是形式——父对象决定了生命周期管理:当窗口被销毁时,Qt 会自动销毁它的所有子对象,所以这个按钮不需要我们手动 delete。
  • button->setText("关闭"); 设置按钮上显示的文字。
  • button->move(200, 200); 把按钮移动到相对父容器的坐标 (200, 200) 处。move 接收的是横纵坐标两个参数,单位是像素。
  • connect(button, &QPushButton::clicked, this, &Widget::close); 就是这一行建立了因果关系。四个参数依次是:信号源 button;信号 &QPushButton::clicked(取成员函数地址,得到一个函数指针);接收者 this(当前这个窗口对象);处理函数 &Widget::close(窗口类的 close 槽)。这一行的含义读出来是"针对这个按钮进行点击,窗口就关闭"。
  • 析构函数里 delete ui; 释放界面对象。这里的 ui 是构造时 new 出来的,所以必须手动释放;而那个按钮由父对象负责,不用在这里管——谁的资源谁负责,是 Qt 里的一贯规则。
  • 把 #include <QPushButton> 换成别的头文件、把 clicked 拼错、或者把 close 写成一个不存在的成员,都会导致编译失败。原因就在于 Qt5 的 connect 会在编译期检查这些参数是否真的匹配,这一点在第 7 节展开。

    5.5 界面文件

    widget.ui 是界面描述文件,本质是一个 XML 文档,由界面设计器生成、也可以由人直接编辑。它描述窗口上有什么控件、各自叫什么名字、摆在什么位置。

    文件名:widget.ui

    <?xml version="1.0" encoding="UTF-8"?>
    <ui version="4.0">
    <class>Widget</class>
    <widget class="QWidget" name="Widget">
    <property name="geometry">
    <rect>
    <x>0</x>
    <y>0</y>
    <width>800</width>
    <height>600</height>
    </rect>
    </property>
    <property name="windowTitle">
    <string>Widget</string>
    </property>
    </widget>
    <resources/>
    <connections/>
    </ui>

    代码导航:

    • 第一行是 XML 声明,version="1.0" 和 encoding="UTF-8" 分别指定版本与字符编码。
    • <ui version="4.0"> 是根节点,4.0 是这份界面文件格式自身的版本号,不是 Qt 的版本。
    • <class>Widget</class> 声明这份界面属于哪个类——正是第 5.3 节里前向声明的那个 Ui::Widget,两处名字必须一致。
    • <widget class="QWidget" name="Widget"> 描述顶层控件:它的类名是 QWidget,对象名 (name) 是 Widget。这个 name 会变成代码里通过 ui-> 访问它时用的名字。
    • <property name="geometry"> 里是一组 <rect>,<x>、<y> 是相对父容器的左上角坐标,<width>、<height> 是宽高,单位都是像素。这里把窗口设成 800×600。
    • <property name="windowTitle"> 里的字符串就是窗口标题栏上显示的文字。
    • <resources/> 和 <connections/> 是两个空节点,分别用于声明资源文件和在设计器里直接连信号槽。这个工程都是空的,因为信号槽是在代码里用 connect 连的。

    后面几个工程会往这里加控件,我会用差异的方式展示改动,不重复贴整份文件。

    5.6 怎么编译运行

    这个工程用 qmake 构建,命令行下典型的流程是:

    # 进入工程文件所在目录,用 qmake 生成构建规则文件 Makefile
    qmake Signal_1.pro

    # 按 Makefile 编译工程
    make

    在这里插入图片描述 在这里插入图片描述

    块后的拆解:

    • 执行位置是 Signal_1.pro 所在的那个目录,也就是六个文件同级的目录。
    • 第一条命令里的 qmake 是 Qt 提供的构建工具,它读取 .pro 文件,把工程里声明的模块、源文件、界面文件翻译成一份 Makefile。Signal_1.pro 是输入,它右边的文件名要和实际文件同名。
    • 第二条命令 make 是沿着 Makefile 真正去编译和链接。它会先处理 FORMS += widget.ui 这一项——把界面文件转换成一个 C++ 头文件(通常叫 ui_widget.h),再依次编译各源文件。
    • 成功后会在输出目录里得到一个可执行文件。运行时应当看到标题为 Widget 的 800×600 窗口,中间偏左上位置有一个写着"关闭"的按钮;点击它,窗口关闭、程序退出。
    • 如果 make 报找不到 qmake,说明 Qt 的开发环境没有加入命令行 PATH,需要先跑对应版本的初始化脚本(Linux 下常见的是 source 一个 qtenv 或 qtvars 脚本);如果报的错是"某个类未定义",优先检查 #include 是否缺了对应的头文件。

    在集成开发环境里通常不需要手敲这两条命令,点一下"构建并运行"就会自动完成同样的过程,但知道背后发生了什么,出问题时才知道该看哪里。

    六、内置信号和槽从哪里查:多看文档,多翻父类

    第 5 节的例子跑通之后,有两个问题需要回答。第一个问题是:我怎么知道 QPushButton 有一个 clicked 信号、QWidget 有一个 close 槽? Qt 里到底提供了哪些可以直接拿来用的信号和槽?

    答案只有四个字:多看文档。Qt 提供的类非常多,每个类里又有大量属性、方法、信号和槽,想全部背下来既不现实也没必要——经验再丰富的人也是靠查。真正需要掌握的是一项本领:知道该去哪里查、查不到时往哪翻。 在这里插入图片描述

    以按钮为例。打开Qt助手,在索引里输入 QPushButton,进到它的类页面。页面的结构基本是固定的:先是属性列表,然后是公有的成员函数,接着是可重写的函数,再然后是槽,最后是信号。把 QPushButton 的这一页从头翻到尾,会发现它自己提供的槽只有一个 showMenu(),而 clicked 这个信号根本不在这里。

    这时候不要觉得自己查错了,要想起来第 3 节讲过的继承关系:一个控件能提供的功能,不一定都是它自己实现的,很可能是从父类继承来的。所以在当前类的页面上找不到目标,就去翻它的父类;父类里还没有,就继续往上翻父类的父类,多翻几层,总能找到。

    在这里插入图片描述

    QPushButton 的父类是 QAbstractButton。进到这一页再找 signals 小节,clicked 就出现了。这也解释了为什么 QPushButton 自己看起来"很简单"——按钮这一类控件的共性都被提取到 QAbstractButton 里了,复选框、单选框、普通按钮等好几种按钮都从这个类继承,各自只需要再补充自己的个性部分。这种"把共性上提到抽象父类"的用法,正是面向对象继承最典型的应用。 在这里插入图片描述

    在 QAbstractButton 里,除了 clicked 还能看到另外几个相关信号:

    • pressed()——鼠标按下时触发;
    • released()——鼠标松开时触发;
    • toggled(bool)——选中状态发生变化时触发。

    pressed 和 released 合起来构成一次完整的点击动作,所以 clicked 可以理解为把这两个动作合并成了一个更符合直觉的事件。而 toggled 面向的是"有选中状态"的按钮,普通按钮没有这个概念,用不上。

    6.1 clicked 的参数:为什么有时带 bool

    细看会发现 clicked 有两个版本:一个是无参的 clicked(),一个是带参数的 clicked(bool checked = false)。这个 bool 表示"按钮当前是否处于选中状态"。

    对普通按钮来说,它永远不会保持选中状态,所以这个参数取不到有意义的值,直接用无参版本即可。但是对复选框这类控件就完全不一样了:复选框可以打勾、也可以取消打勾,checked 为 true 表示有勾,为 false 表示没有勾,槽函数靠这个参数就能知道用户是勾上了还是取消了。

    由于这个参数带默认值,使用时只写 clicked 也能匹配,前面第 5 节的代码就是这么写的。

    6.2 查文档时最该看什么

    打开某个信号的说明页,会看到一句话告诉我们这个信号什么时候会被发射。以 clicked 为例,它的说明大意是:当按钮被激活 (be activated) 时才发射;具体来说,是鼠标在按钮内部按下并释放,或者通过快捷键触发,或者直接在代码里调用 click()、animateClick() 时都会发射。 在这里插入图片描述

    看信号的发射时机,是阅读文档最关键的部分。 因为写信号槽的时候,我真正需要判断的就是"用户做了什么样的操作,才会走到我这段逻辑"。Qt 文档里描述这个时机常常用 emit 这个词,它就是"发射"的意思,和"触发了某个信号"是同一件事。

    槽的查法完全一样,只不过要找的关键字从 signals 换成 slots。比如 close() 就在 QWidget 的槽小节里,它的作用是关闭当前窗口或控件。

    ⚠️ 易错点:查文档时如果只盯着当前这一个类,很容易得出"这个类没有我需要的信号"的错误结论。养成习惯:当前类找不到就往上翻父类,一路翻到 QObject 为止。

    七、Qt4 与 Qt5 两代 connect 写法

    回到第 4 节的疑问:connect 原型里第二、第四个参数的类型写的是 const char *,可实际写代码时传进去的明明是函数指针。char * 和函数指针是同一个东西吗?

    显然不是。指针这个词是个统称,char *、int *、结构体指针、函数指针都是不同的类型,彼此之间不能随意赋值。函数传参在本质上就是赋值,类型不一致就应当编译失败——可它偏偏编译通过了。这里面一定有环节我没看到。

    7.1 Qt4 的做法:用宏把函数指针转成字符串

    那个 const char * 版本的原型是旧版本的声明。在旧版本里,用了两个宏来配合:

    • 给信号参数套上 SIGNAL() 宏;
    • 给槽参数套上 SLOT() 宏。

    这两个宏的作用是把传进去的函数指针转换成一个 const char *,本质上转成的是函数签名的字符串形式。所以同一次连接在旧写法下要写成这样:

    // 旧写法(Qt4 风格):用 SIGNAL / SLOT 宏包住函数指针
    connect(button, SIGNAL(clicked()), this, SLOT(close()));

    这样类型就对上了,编译自然能过。但代价也很明显:把函数指针变成字符串之后,编译器就失去了检查能力。字符串对不对、名字有没有拼错、参数类型是否匹配,编译阶段一概看不出来,只有运行起来才会发现"这个连接没生效"。这类问题排查起来非常麻烦——程序能启动、不出错、就是没反应。

    7.2 Qt5 的做法:泛型参数加类型萃取

    从 Qt5 开始,connect 增加了一个重载版本,把这套宏的使用整个省掉了。在这个版本里,第二和第四个参数变成了泛型参数(模板参数),因此可以接收任意类型的函数指针,不再需要宏去转换。 在这里插入图片描述

    更重要的是,这个版本顺带加上了编译期类型检查。它的实现思路是:利用第一个参数 sender 的类型做类型萃取 (type traits),反推出"这个对象身上应该有哪些成员函数",然后要求第二个参数给出的函数指针必须是这个对象的成员函数;第三、第四个参数之间同理。类型萃取这类工具本身是基于模板偏特化实现的——也就是针对不同类型的实参,模板给出不同的处理结果,从而在编译期就拿到需要的类型信息。这一块属于 C++ 泛型编程的内容,这里只需要知道它能达到什么效果。

    这样一来,只要信号源和信号不匹配,或者接收者和槽不匹配,代码就直接编译不过,根本轮不到运行时才发现。把检查工作从"靠程序员自觉"转移到"靠编译器强制",代码的可靠性就上了一个台阶。

    在这里插入图片描述

    7.3 三个参数不匹配的例子

    对照着写一下就很清楚。同样是"点击按钮关闭窗口",新旧两代写法分别是:

    // Qt4 风格:宏把函数指针转成 const char *
    connect(button, SIGNAL(clicked()), this, SLOT(close()));

    // Qt5 风格:直接取成员函数地址,编译期校验类型
    connect(button, &QPushButton::clicked, this, &Widget::close);

    把第二、第四个参数换成一个八竿子打不着的类身上的函数(比如拿一个输入框的信号去配一个按钮),Qt5 写法会在编译时直接报错;而 Qt4 写法可能一路编译通过,直到运行时才发现连接没生效。

    💡 提示:两种写法都能在 Qt5 里编译,但没有任何理由继续用旧写法。参数直观这一点不值得用失去编译期检查来换。

    八、自定义槽函数

    内置的槽虽然不少,但它不可能覆盖实际业务。写代码时绝大部分响应逻辑都要自己写——因为槽函数承载的是"用户触发操作之后要执行的业务逻辑",业务逻辑因程序而异,别人替不了。

    所谓的自定义槽函数,操作上和一个普通成员函数没有任何区别。下面是第二个工程 Signal_2,它在 Signal_1 的基础上把"关闭窗口"换成了"改窗口标题"。

    8.1 头文件里的改动

    Signal_2/widget.h 相对 Signal_1/widget.h 只多了一行函数声明。下面是改动后的完整内容:

    文件名:widget.h

    #ifndef WIDGET_H
    #define WIDGET_H

    #include <QWidget>

    QT_BEGIN_NAMESPACE
    namespace Ui { class Widget; }
    QT_END_NAMESPACE

    class Widget : public QWidget
    {
    Q_OBJECT

    public:
    Widget(QWidget *parent = nullptr);
    ~Widget();

    void handleClicked();

    private:
    Ui::Widget *ui;
    };
    #endif // WIDGET_H

    void handleClicked(); 就是新加的成员函数声明,位置在 public 段里。函数名由 handle 加 clicked 拼成,读起来就是"处理一下点击"。没有参数也没有返回值。

    8.2 实现文件里的改动

    Signal_2/widget.cpp 的改动稍多一些。下面是改动后的完整内容:

    文件名:widget.cpp

    #include "widget.h"
    #include "ui_widget.h"
    #include <QPushButton>

    Widget::Widget(QWidget *parent)
    : QWidget(parent)
    , ui(new Ui::Widget)
    {
    ui->setupUi(this);

    QPushButton* button = new QPushButton(this);
    button->setText("按钮");
    button->move(100, 100);

    connect(button, &QPushButton::clicked, this, &Widget::handleClicked);
    }

    Widget::~Widget()
    {
    delete ui;
    }

    void Widget::handleClicked()
    {
    // 按下按钮, 修改一下窗口标题.
    this->setWindowTitle("按钮已经按下!");
    }

    块后的解释,按改动逐项说:

    • button->setText("按钮"); 把按钮文字从"关闭"改成"按钮"。move(100, 100) 把位置挪到左上角,只是为了让界面看起来协调一些,和信号槽无关。
    • connect(button, &QPushButton::clicked, this, &Widget::handleClicked); 这是核心改动:第四个参数从 &Widget::close 换成了 &Widget::handleClicked。信号源和信号都没变,变的只是"怎么处理"。
    • 文件末尾新增了 handleClicked 的函数定义。函数体只有一句 this->setWindowTitle("按钮已经按下!");,把窗口标题改成这段文字。this 指向当前窗口对象,setWindowTitle 是 QWidget 提供的成员函数,接收一个字符串作为新的标题。 在这里插入图片描述 在这里插入图片描述

    运行效果:程序启动后窗口标题是默认的 Widget;点击按钮,标题立刻变成"按钮已经按下!"。这说明自定义的槽函数已经被成功执行到了。

    8.3 Qt4 与 Qt5 在这里的差别

    有个历史细节值得留意。在老版本的 Qt(比如 Qt4)里,槽函数必须写在 public slots:、private slots: 或 protected slots: 这样的段落声明后面,slots 是必须写的。而到了 Qt5,这个限制放开了,直接当普通成员函数写在 public 段里就能用,slots 可以省略。

    slots 和 signals 一样,都不是 C++ 标准里的关键字,而是 Qt 自己扩展出来的。扩展的方式是元编程 (Meta Programming)——用代码生成代码。构建工程时,qmake 会调用专门的扫描器去扫描代码里的这些特定关键字,扫到之后就据此自动生成一大批相关代码,把 slots 这类扩展语法翻译成标准的 C++ 代码,从而替我们完成一部分框架侧的配合工作。

    对使用者来说结论很简单:哪种写法更省事就用哪种。Qt5 里直接写普通成员函数即可,没必要拘泥于老写法。

    关于槽函数,还有几条性质需要记住,它们描述的是"槽到底是什么样的函数":

    • 它可以定义在类的任何位置——public、protected 或 private 都可以;
    • 它可以带任意参数,也可以重载;
    • 它可以被直接调用,就像一个普通成员函数那样;
    • 它与普通函数真正不同的地方只有两点:一是它可以和信号关联,信号被发射时由框架自动执行它;二是它不能带默认参数(这一点在第 16 节还会再提一次)。

    九、转到槽:不写 connect 也能连上

    自定义槽还有第二种做法,比手写更省事,而且它会带出一个隐藏在框架里的机制。

    9.1 在界面上拖一个按钮

    第三个工程 Signal_3 先在界面编辑器里拖了一个按钮上去,然后给它取好了对象名。Signal_3/widget.ui 相对 Signal_1/widget.ui 的改动就是新增了这个按钮节点。下面是改动后的完整内容:

    文件名:widget.ui

    <?xml version="1.0" encoding="UTF-8"?>
    <ui version="4.0">
    <class>Widget</class>
    <widget class="QWidget" name="Widget">
    <property name="geometry">
    <rect>
    <x>0</x>
    <y>0</y>
    <width>800</width>
    <height>600</height>
    </rect>
    </property>
    <property name="windowTitle">
    <string>Widget</string>
    </property>
    <widget class="QPushButton" name="pushButton">
    <property name="geometry">
    <rect>
    <x>280</x>
    <y>210</y>
    <width>201</width>
    <height>81</height>
    </rect>
    </property>
    <property name="text">
    <string>按钮</string>
    </property>
    </widget>
    </widget>
    <resources/>
    <connections/>
    </ui>

    块后的解释:

    • 新增的 <widget class="QPushButton" name="pushButton"> 就是拖上去的那个按钮。注意它嵌套在顶层 <widget> 内部,这正是"父容器包含子控件"在界面文件里的表现形式。
    • class="QPushButton" 指明这是按钮控件;name="pushButton" 是它的对象名,后面代码里靠这个名字找到它,所以这个名字非常关键。
    • 里面的 geometry 用 <x>280</x>、<y>210</y>、<width>201</width>、<height>81</height> 确定了它在窗口上的位置和大小。
    • <property name="text"> 里的 <string>按钮</string> 是按钮上显示的文字。注意它和对象名 pushButton 是两回事:一个是给用户看的,一个是给代码用的。

    有了对象名之后,代码里就能通过 ui->pushButton 拿到这个按钮对象的指针。这一点和第 5 节在代码里 new 一个按钮再手动 move 的方式不同——这里是界面编辑器替我们创建对象,代码里只需要按名字取用。

    9.2 转到槽生成的代码

    接下来右键点击界面上的这个按钮,选择"转到槽",会弹出一个对话框,列出这个按钮所有可用的信号。这个列表其实是把按钮自己以及它各级父类提供的信号全部汇总在一起了。对按钮来说,最常用的就是 clicked。 在这里插入图片描述 在这里插入图片描述

    选中 clicked 确定之后,编辑器会自动生成好两部分代码,一部分是头文件里的函数声明,另一部分是源文件里的函数定义。先看头文件里生成的那段声明——为保留上下文,这里给出改动后的整份头文件:

    文件名:widget.h

    #ifndef WIDGET_H
    #define WIDGET_H

    #include <QWidget>

    QT_BEGIN_NAMESPACE
    namespace Ui { class Widget; }
    QT_END_NAMESPACE

    class Widget : public QWidget
    {
    Q_OBJECT

    public:
    Widget(QWidget *parent = nullptr);
    ~Widget();

    private slots:
    void on_pushButton_click();

    private:
    Ui::Widget *ui;
    };
    #endif // WIDGET_H

    新增的是一个 private slots: 段,里面声明了 void on_pushButton_click();。这里有个细节要注意:private slots 这种写法在 Qt5 里其实不是必须的,但编辑器生成代码时沿用了老风格,写成 private 加函数声明同样能工作。

    再看实现文件,同样是改动后的完整内容:

    文件名:widget.cpp

    #include "widget.h"
    #include "ui_widget.h"

    Widget::Widget(QWidget *parent)
    : QWidget(parent)
    , ui(new Ui::Widget)
    {
    ui->setupUi(this);

    // 通过 ui->pushButton 获取到界面上拖上去的这个按钮.
    // connect(ui->pushButton, &QPushButton::clicked, this, &);
    }

    Widget::~Widget()
    {
    delete ui;
    }

    void Widget::on_pushButton_click()
    {
    this->setWindowTitle("按钮已经按下!");
    }

    改动有三处:一是 #include <QPushButton> 被删掉了,因为这个文件里不再直接用 QPushButton 这个类型;二是构造函数里原来的三行创建按钮代码和 connect 都被删掉,换成两行注释;三是文件末尾新增了 on_pushButton_click 的函数定义,函数体依旧是设置窗口标题。

    构造函数里保留的那两行注释值得读一下:

    // 通过 ui->pushButton 获取到界面上拖上去的这个按钮.
    // connect(ui->pushButton, &QPushButton::clicked, this, &);

    第一行点明了访问界面控件的方式 ui->pushButton;第二行是一个写了一半的 connect——函数指针的地址写成了 &,后面是空的。这句话本身是语法不完整的,它被注释掉不影响编译,保留在这里是为了提示"如果要用显式连接,参数该怎么起头"。

    注意这个工程的完成度:它并不能直接运行出预期效果。 原因是函数名写成了 on_pushButton_click,比正确形式少了一个字母——这个错误是有意留下的,下面 9.4 节会用它来做一个对照实验。

    9.3 自动连接的命名规则

    生成的代码里没有任何一处 connect。那信号是怎么连上的?

    关键在函数名。生成的名字是 on_pushButton_click,可以拆成三段:

  • 固定前缀 on;
  • 中间是控件的对象名,也就是界面文件里那个 name="pushButton";
  • 最后一段是信号名。
  • 规则的完整形式是 on_<对象名>_<信号名>,各段之间用下划线连起来。名字里已经把"哪个控件的哪个信号"这两条信息都写全了,所以 Qt 只要按这条规则去比对函数名,就能自动完成连接。当函数名符合这个规则时,不需要写 connect,连接也会建立。

    在这里插入图片描述

    9.4 把这个机制验证一次

    既然规则是靠函数名生效的,那把名字改错,连接就应该失效。这就是 Signal_3 当前的状态:名字写成了 on_pushButton_click,而按钮的信号叫 clicked——按钮有一个叫 click 的槽,但没有叫 click 的信号,所以第三段和信号名对不上(Signal_1 到 Signal_2 里用过的 clicked 才是正确的信号名)。

    实际现象是:程序能正常启动,窗口正常显示,按钮也正常显示,但点击按钮没有任何反应,标题不会变化。这个现象恰好说明那个"少了一个字母的名字"没有匹配上任何信号。

    反过来说,名字对上了,连接就建立;名字错了,连接就静默失效。这就是自动连接这套机制的边界。

    9.5 背后的实现:一次按名字的批量连接

    自动连接不是魔法,它背后是一次函数调用。界面文件转换生成的头文件里,会有一句按名字连接所有槽的调用——它的名字是 QMetaObject::connectSlotsByName。

    这句话的作用是:在界面初始化的时候,按上面那套命名规则扫描当前对象,把所有名字符合 on_<对象名>_<信号名> 的成员函数,逐个连接到对应控件的对应信号上。

    QMetaObject 是 Qt 里负责元对象系统的类,也就是支撑信号槽、属性系统那一套运行时信息的基础设施;connectSlotsByName 从名字上就能读出来——“按名字连接槽”。

    由此可以推出两个结论:

    • 如果这句调用不存在,那么即使函数名写得完全正确,自动连接同样不会生效——这就是"机制生效的前提"。
    • 这句调用是界面文件转换生成的头文件里带的。也就是说,只有用界面编辑器创建的控件才享受这套便利。如果控件是我自己在代码里 new 出来的,生成的代码里没有这句调用,就必须老老实实手写 connect。

    💡 提示:用界面编辑器拖出来的控件,用转到槽自动生成连接更省事;自己在代码里创建的控件,只能显式写 connect。另外,自动连接依赖函数名和信号名都不写错,一旦拼错就是静默失效,排查时要先看名字。显式 connect 虽然多敲几个字,但连接关系一眼可见、写错还能在编译期被拦住,两种方式各有取舍。

    十、自定义信号与 emit 发射

    先说结论,因为这直接决定了精力该往哪放:自定义槽函数是高频操作,自定义信号则很少见。

    槽函数为什么高频,第 8 节已经说清了——它承载业务逻辑,业务逻辑只能自己写。那信号为什么少见?因为信号对应的东西是用户的操作。在图形界面里,用户能做的操作是可以穷举的:点按钮、移动光标、选中文本、按键盘、拖鼠标……左右翻不出这些花样。Qt 内置的信号已经把这些可能的操作覆盖得差不多了,所以在绝大部分场景里,直接用内置信号就够。

    但"大部分"不等于"全部",总有些特殊情况内置信号不合适,这时候就需要自定义信号。Qt 也为此留了口子。下面的工程 Signal_4 就是自定义信号的完整例子。

    10.1 声明信号的语法

    Signal_4/widget.h 相对 Signal_1/widget.h 的改动。下面是改动后的完整内容:

    文件名:widget.h

    #ifndef WIDGET_H
    #define WIDGET_H

    #include <QWidget>

    QT_BEGIN_NAMESPACE
    namespace Ui { class Widget; }
    QT_END_NAMESPACE

    class Widget : public QWidget
    {
    Q_OBJECT

    public:
    Widget(QWidget *parent = nullptr);
    ~Widget();

    signals:
    void mySignal(int text);

    public:
    void handleMySignal(const QString& text);

    private slots:
    void on_pushButton_clicked();

    void on_pushButton_2_clicked();

    private:
    Ui::Widget *ui;
    };
    #endif // WIDGET_H

    块后的解释,逐段看:

    • 新增的 signals: 段里写了一句 void mySignal(int text);。信号必须写在 signals: 下面,这是硬性规范。signals 同样是 Qt 扩展出来的关键字,不属于 C++ 标准。构建时 qmake 会扫描到它,把这一段下面声明的函数都认定为信号,并自动为它们生成函数体。
    • void mySignal(int text); 里 mySignal 是信号名,形参 int text 是这个信号要携带的数据。只写声明,不写实现——实现由 Qt 在编译过程中自动生成,这个过程程序员无法干预。原因也不难理解:信号函数的实现需要和框架做大量既定配合,一旦允许人插手改,反而容易让配合失效。
    • 紧随其后又写了一个 public:,里面声明了 void handleMySignal(const QString& text);。这是准备用来接收信号的槽函数。这里的 QString 是 Qt 提供的字符串类;const QString& 表示以常量引用的方式接收一个字符串——& 声明它是引用,用引用可以避免把字符串整个复制一份的开销,const 则承诺函数内部不会修改它。注意这里的参数类型写成了 const QString&,而信号的参数类型是 int——这两个类型对不上,第 11 节会专门讨论这个有意留下的错配。
    • private slots: 段里新增了两个声明:on_pushButton_clicked(); 和 on_pushButton_2_clicked();。名字符合第 9 节的命名规则,说明它们是界面上两个按钮的点击槽函数,会由 connectSlotsByName 自动连接上。

    自定义信号要记住三条规范:

  • 必须写在 signals: 之下;
  • 返回类型必须是 void,不能是别的类型;参数则不受限制,可以没有也可以有,甚至允许重载——只要参数的类型或个数不同,就可以存在多个同名信号,Qt 能正确处理;
  • 只需要声明,不需要实现。
  • 10.2 界面上的两个按钮

    Signal_4/widget.ui 相对 Signal_1/widget.ui 的改动是新增两个按钮。下面是改动后的完整内容:

    文件名:widget.ui

    <?xml version="1.0" encoding="UTF-8"?>
    <ui version="4.0">
    <class>Widget</class>
    <widget class="QWidget" name="Widget">
    <property name="geometry">
    <rect>
    <x>0</x>
    <y>0</y>
    <width>800</width>
    <height>600</height>
    </rect>
    </property>
    <property name="windowTitle">
    <string>Widget</string>
    </property>
    <widget class="QPushButton" name="pushButton">
    <property name="geometry">
    <rect>
    <x>260</x>
    <y>80</y>
    <width>271</width>
    <height>101</height>
    </rect>
    </property>
    <property name="text">
    <string>设置文本1为标题</string>
    </property>
    </widget>
    <widget class="QPushButton" name="pushButton_2">
    <property name="geometry">
    <rect>
    <x>270</x>
    <y>270</y>
    <width>261</width>
    <height>101</height>
    </rect>
    </property>
    <property name="text">
    <string>设置文本2为标题</string>
    </property>
    </widget>
    </widget>
    <resources/>
    <connections/>
    </ui>

    两个按钮的对象名分别是 pushButton 和 pushButton_2,文字分别是"设置文本1为标题"和"设置文本2为标题"。

    顺带说一下对象名的生成规律:在界面编辑器里新拖一个同类型控件时,它会按"类型名加序号后缀"的方式起名,第一个叫 pushButton,第二个就叫 pushButton_2,再往后依次是 _3、_4。这个名字随时可以改成你想要的样子,改名之后记得同步改代码里用到它的地方——因为代码是通过对象名访问控件的。

    10.3 连接、实现与发射

    Signal_4/widget.cpp 相对 Signal_1/widget.cpp 的改动。下面是改动后的完整内容:

    文件名:widget.cpp

    #include "widget.h"
    #include "ui_widget.h"

    Widget::Widget(QWidget *parent)
    : QWidget(parent)
    , ui(new Ui::Widget)
    {
    ui->setupUi(this);

    connect(this, &Widget::mySignal, this, &Widget::handleMySignal);

    }

    Widget::~Widget()
    {
    delete ui;
    }

    void Widget::handleMySignal(const QString& text)
    {
    this->setWindowTitle(text);
    }

    void Widget::on_pushButton_clicked()
    {
    // 发送出自定义的信号
    // 发送信号的操作, 也可以在任意合适的代码中. 不一定非得在构造函数里.

    // 此时就是点击按钮的时候, 发送自定义信号了.
    emit mySignal(10);
    }

    void Widget::on_pushButton_2_clicked()
    {
    emit mySignal(20);
    }

    块后的解释,按执行顺序:

    • #include <QPushButton> 被删掉了,因为本工程不再直接用 QPushButton 类型——按钮全部来自界面文件。
    • 构造函数里的三行创建按钮代码被替换成一句 connect(this, &Widget::mySignal, this, &Widget::handleMySignal);。这里的信号源和接收者都是 this,也就是窗口自己给自己发信号、自己处理,用于演示最小可用的自定义信号。
    • 新增的 handleMySignal 函数体是 this->setWindowTitle(text);,把收到的文本直接当作窗口标题。
    • 新增的 on_pushButton_clicked() 函数体里只有一句 emit mySignal(10);,也就是点击第一个按钮时发射自定义信号。上面两行注释说明了两件事:信号可以在任意合适的代码位置发射,不一定非得写在构造函数里;此刻正是点击按钮的时机。
    • 新增的 on_pushButton_2_clicked() 里是 emit mySignal(20);,点击第二个按钮时发射同一个信号,只是携带的数据不同。

    关于 emit 有几件事需要说清:

    emit 是发射信号的关键字,用法是 emit 后面跟信号函数调用。它也是 Qt 扩展出来的关键字。

    emit 本身是一个空宏。 也就是说,写与不写,编译出来的结果完全一样——真正干活的是 Qt 为那个信号自动生成的函数体。所以如果只写 mySignal(10); 而不写 emit,信号照样能发射出去。

    既然如此为什么还要写?为了可读性。加上 emit 之后,一眼就能看出这一行是在发射自定义信号,而不是在调用一个普通函数。建议一直保留。

    另外,emit 不只用于自定义信号。内置信号如果需要手动发射,同样可以用 emit,本质上还是调用对应的信号函数。

    10.4 一个必须记住的顺序

    如果只连接、不发射,会发生什么?

    把 emit 那两句去掉,程序照样能正常编译、正常启动,窗口标题却始终不变——因为信号从头到尾没有被发射过,那个槽函数自然也不会被执行。

    由此得到两条相互印证的结论:

    • 建立连接不等于发射信号。 connect 只负责登记"这两件事有因果关系",并不触发任何动作。发射必须另外完成。
    • 连接一定要在发射之前准备好。 第 1 节那个"回家看到面色不善"的场景讲的就是这件事:处理方式必须是提前准备好的,等信号来了才能立刻接上。顺序颠倒,这一次信号就没人处理。

    十一、带参数的信号与槽

    信号和槽都可以带参数,带参数之后就能实现"信号把数据传给槽"的效果。规则不复杂,但有一条特别容易记反。

    11.1 两条规则

    规则一:类型必须一致。 两边对应位置的参数类型要对得上。原因很直接——发射信号时传进去的实参,最终要赋给槽函数的形参,这中间就是一次赋值,类型不匹配自然编译不过。

    规则二:信号的参数个数 ≥ 槽的参数个数,不能反过来。 信号这边可以"多给",槽那边不能"多要"。当信号参数比槽参数多时,槽按顺序拿到信号的前 N 个参数:槽只要一个参数,就拿信号的第 1 个参数;槽要两个,就拿前两个。反过来,如果槽要的参数比信号给的多,那些多出来的形参就分不到值,代码直接编译失败。

    在这里插入图片描述

    这条规则有个很好记的说法:穷家富路。在家可以省着点,出门在外身上要多带点钱备用——你可以给我多,但是不能给少。少给的结果就是"参数不够分,饭都不够吃",程序直接编不过。

    11.2 为什么要允许"多给"

    按直觉,严格要求两边参数个数完全一致似乎最合理。Qt 之所以放宽成"允许多给",是为了让一个槽能被更多信号连上。

    想一下这个场景:有一个只收一个参数的槽,而外面有一堆信号想连它,这些信号的参数个数却各不相同——有的一个、有的两个、有的三个。如果要求严格一致,那这一堆信号里只有参数个数刚好为一个的那些能连上,其余全部连不上。而允许"信号多给",所有这些信号就都能连到这个槽上。

    换句话说,放宽这一条换来的是连接的灵活性:更多信号可以接上同一个槽。代价是槽必须保证自己手上的每一个参数都真的分到了值,所以"少给"是不被允许的。

    💡 提示:虽然规则允许信号多给参数,但实际写代码时最好还是让两边的参数个数也保持一致。多给虽然合法,但会让"哪个参数对应哪里"变得不那么直观。

    11.3 类型不匹配会怎样

    第 10 节留下的那个错配,正好可以用来说明规则一。Signal_4/widget.h 里信号声明是 void mySignal(int text);,槽声明是 void handleMySignal(const QString& text);——一个 int,一个字符串引用,类型不一致。

    现象是编译直接报错。原因就是赋值那一步走不通:一个整数没法直接变成一个字符串对象。这类错误不用担心漏看,编译器会明确指出来。 在这里插入图片描述

    参照写法是让两边口径统一:信号的形参写成 const QString& text,槽的形参也写成 const QString& text。QString 是 Qt 的字符串类,这里的 const QString& 表示"以常量引用的方式接收一个字符串",引用可以避免复制开销,const 保证函数内不会修改它。

    11.4 用一个槽处理两种数据

    带参数最实用的价值是复用代码:多个逻辑整体一致、只是数据不同的场景,可以合并成同一个函数,靠参数区分。

    Signal_4 里的两个按钮就是这个思路:界面上有两个按钮,都把"设置窗口标题"这件事交给同一个信号和同一个槽,区别只在于按钮里发射信号时给的数据不同——第一个按钮发 10,第二个按钮发 20。如果把信号的参数类型调整成统一口径,两个按钮就会把标题分别设置成各自的文本,而槽函数只需要写一份。这也解释了为什么那个参数类型不匹配的写法必须要改掉:不改,"用参数区别数据"这个目的就达不到。

    11.5 内置信号也带参数

    带参数的信号不是自定义信号才有的特性。Qt 里很多内置信号本身就带参数,只是这些参数不由我们填写,而是由框架在发射时填好。理解这些参数的含义,同样能解决实际问题。

    最典型的就是第 6 节讲过的 clicked(bool checked):对复选框这类有选中状态的控件,checked 为 true 表示勾上了,为 false 表示取消勾选,槽函数据此就能知道用户当前的操作到底是选中还是取消。

    十二、Q_OBJECT 宏的位置很关键

    前面几个工程的类声明里都有这么一行:

    class Widget : public QWidget
    {
    Q_OBJECT
    ...

    Q_OBJECT 是一个宏,它决定了这个类能不能使用信号槽。规则很干脆:只要这个类里要定义信号或槽函数,就必须在类的最开始处写上这个宏,这是 Qt 的硬性要求。

    这个宏展开之后会为当前类自动添加一大堆额外代码。在编辑器里点进去可以看到它引出了更多的宏,那些宏还会继续展开,最终得到一系列相当复杂的代码。这些代码属于 Qt 的内部实现,对于"怎么用"并没有直接影响,不必逐层追下去。需要记住的是它的作用:它是信号槽机制的入场券。

    如果不写会怎样?分两种情况:

    • 如果这个类里完全没有信号槽相关的操作——不写 signals、不写 slots、不做 connect——那倒也没事;
    • 只要做了上面任何一件事,编译就会失败,而且错误信息会明确提示"这个类看起来缺少 Q_OBJECT 宏"。

    手工敲这个宏容易忘,好在用集成开发环境创建类的时候它会让你勾选是否带 Q_OBJECT,多数情况下不需要自己写。但规则本身要知道。

    十三、连接方式:从一对一走到关联表

    前面几节用到的连接都是一对一:一个信号连一个槽。这一节把信号槽支持的全部连接方式过一遍,最后落到设计意图上。

    13.1 一对一

    一对一有两种形式。第一种是一个信号连接一个槽,也就是第 4 节那张图讲的情形,一个信号触发就执行一个函数。

    第二种形式比较特别:一个信号连接另一个信号。

    在这里插入图片描述

    连着两个信号,意思是"当这个信号被触发时,顺手把那个信号也发射出去"。它没有引入任何新的处理逻辑,只是把一次通知接力传递下去。这种用法在需要把底层信号的语义"翻译"成上层信号时比较有用。

    13.2 一对多与多对一

    一对多是一个信号连接多个槽:

    在这里插入图片描述

    一个信号被触发时,挂在它上面的所有槽函数都会被执行。这里要注意执行范围:一旦某个信号连了多个槽,触发一次就会把每一个都跑到,不存在"只跑第一个"的情况。

    多对一是多个信号连接同一个槽:

    在这里插入图片描述

    不同的信号触发时,都走同一个处理函数。这正是第 11 节讲的参数规则要服务的目标——多个信号的参数个数未必相同,允许"信号多给"才能让它们都接到同一个槽上。

    13.3 用代码建立多对多

    把一对一、一对多、多对一叠在一起,就是多对多:一个信号可以连到多个槽,一个槽也可以被多个信号连接。下面的工程 Signal_5 演示了这件事。

    Signal_5/widget.h 相对 Signal_1/widget.h 的改动。下面是改动后的完整内容:

    文件名:widget.h

    #ifndef WIDGET_H
    #define WIDGET_H

    #include <QWidget>

    QT_BEGIN_NAMESPACE
    namespace Ui { class Widget; }
    QT_END_NAMESPACE

    class Widget : public QWidget
    {
    Q_OBJECT

    public:
    Widget(QWidget *parent = nullptr);
    ~Widget();

    signals:
    void mySignal1();
    void mySignal2();
    void mySignal3();

    public slots:
    void mySlot1();
    void mySlot2();
    void mySlot3();

    private:
    Ui::Widget *ui;
    };
    #endif // WIDGET_H

    块后的解释:

    • signals: 段里声明了三个信号:mySignal1()、mySignal2()、mySignal3(),都不带参数。
    • public slots: 段里声明了三个槽:mySlot1()、mySlot2()、mySlot3()。这里显式写了 slots——Qt5 里可以省,但写上更清楚地标明"这些是给信号用的",不算错。
    • 三个信号的声明只写不实现,由 Qt 生成;三个槽则需要自己实现。

    Signal_5/widget.cpp 相对 Signal_1/widget.cpp 的改动。下面是改动后的完整内容:

    文件名:widget.cpp

    #include "widget.h"
    #include "ui_widget.h"
    #include <QDebug>

    Widget::Widget(QWidget *parent)
    : QWidget(parent)
    , ui(new Ui::Widget)
    {
    ui->setupUi(this);

    connect(this, &Widget::mySignal1, this, &Widget::mySlot1);
    connect(this, &Widget::mySignal1, this, &Widget::mySlot2);
    connect(this, &Widget::mySignal2, this, &Widget::mySlot1);
    connect(this, &Widget::mySignal2, this, &Widget::mySlot3);
    emit mySignal1();
    emit mySignal2();
    }

    Widget::~Widget()
    {
    delete ui;
    }

    void Widget::mySlot1()
    {
    qDebug() << "mySlot1";
    }

    void Widget::mySlot2()
    {
    qDebug() << "mySlot2";
    }

    void Widget::mySlot3()
    {
    qDebug() << "mySlot3";
    }

    块后的解释:

    • #include <QPushButton> 换成了 #include <QDebug>。QDebug 是 Qt 的日志输出类,配合 qDebug() 往控制台打印调试信息。
    • 构造函数里写下了四条 connect:mySignal1 连 mySlot1、mySignal1 连 mySlot2、mySignal2 连 mySlot1、mySignal2 连 mySlot3。这四条连起来看,mySignal1 身上挂了两个槽,mySlot1 身上也挂两个两个信号——这就是多对多,连接后别忘记发射信号,如果没发射信号,控制台也不会有输出。
    • 三个槽的实现分别是 qDebug() << "mySlot1"; 这样的打印语句。qDebug() 返回一个流对象,<< 是流插入运算符,作用是把右边的内容送进输出流。用它在控制台打印日志比用标准库的输出更合适:对字符编码的处理更好,而且可以通过宏开关统一关闭,不想看日志时不必删代码。 在这里插入图片描述

    编译运行后,触发 mySignal1 会在控制台看到 mySlot1 和 mySlot2 两行输出,触发 mySignal2 会看到 mySlot1 和 mySlot3。一次触发,多个槽依次执行,这就是一对多的效果。

    13.4 和数据库里的多对多对照着理解

    信号槽里的"一个信号对应多个槽、一个槽对应多个信号",和数据库里实体之间的多对多关系是同一类问题。

    先明确一下实体这个词:数据库里说的实体就是对象,只是换了个叫法,指的是把现实问题里的关键名词抽象出来之后形成的东西。比如建一张学生表,表里每一条记录就是一个学生实体;再建一张课程表,每条记录就是一门课程实体。

    学生和课程之间的关系,可以用两句话分别描述:

    • 一个学生可以选多门课程;
    • 一门课程可以被多个学生选。

    请注意这两句话是站在两个相反的角度说的:一句以学生为主体,一句以课程为主体。两句都成立,合起来就是多对多关系。

    多对多怎么在表结构里表达?办法是引入第三张表作为关联表,把关系本身也存成一张表。比如建一张"学生课程表",里面只有两列:一列存学号,一列存课程编号。往这张表里写记录的时候:

    • 写一条"学号1 + 课程100",意思是张三选了那门编号为 100 的课;
    • 再写一条"学号1 + 课程101",意思是张三又选了一门;
    • 再写一条"学号2 + 课程100",意思是李四也选了编号 100 那门课;
    • 再写一条"学号2 + 课程102",李四还选了另一门。

    四条记录看下来:从学号 1 这一列纵向看,一个学生选了多门课;从课程 100 这一列横向看,一门课被多个学生选了。两个方向的关系都能从这张表里读出来,这就是关联表的作用。

    在这里插入图片描述

    回到信号槽。connect 的作用就相当于这张关联表:信号和槽是两类独立的东西,connect 把它们之间的对应关系登记下来;一堆 connect 叠在一起,就构成了信号与槽之间的关联关系。Signal_5 里那四行 connect,用表格的形式写出来,就是一张"信号—槽"关联记录。

    顺带说一个和写代码有关的观念,叫高内聚低耦合,这两个词经常成对出现,方向不能记反:内聚越高越好,耦合越低越好。

    耦合说的是模块之间的牵连程度。举个例子:家里人病了需要住院,我作为家属必须请假全程照顾,手头所有工作都得推掉——一个人出了状况,另一个人的正常安排全被打乱,这种"一个模块出问题,另一个模块受很大影响"就是高耦合。反过来,如果是普通朋友住院,我照常上班、该做什么做什么,顶多问候一声——对方出状况基本不影响我,这就是低耦合。代码里如果出现高耦合,一处小问题就可能拖垮整个程序。

    内聚说的是同一个功能的代码有没有聚在一起。设想家里东西用完随手丢,衣服可能落在沙发上、床上、椅子上、沙发缝里,就是不在衣柜;哪天要找一件衣服,就得把每个角落都排查一遍,还不一定找得到。代码里也一样:实现一个功能的代码如果散落在成百上千个文件的角落里,想梳理清楚就得挨个地方翻找——这叫低内聚。反之,如果衣服固定放衣柜(干净的)和脏衣篓(脏的),找起来成本就低;代码里把一个功能相关的代码集中在一起,维护性就高得多——这叫高内聚。

    信号槽在这里贡献的正是解耦:发射信号的控件不需要知道是谁在处理,处理信号的槽也不需要知道信号是从哪来的,两者之间靠 connect 登记关系,各自独立变化。这正是它存在的意义之一。

    13.5 其他图形界面框架是怎么做的

    信号槽在多对多这一点上确实比较特别。其他图形界面开发方案很少做到这个程度。

    以网页开发为例:它本质上也是图形界面开发,最基本的方式是用脚本配合 DOM 接口,把处理函数直接挂到控件上,写法上就是给某个属性赋一个函数值——比如把一个点击事件的处理函数直接赋给它。这种方式干净直接,处理函数看起来就像是控件的一个属性。

    它的能力边界也很清楚:一个事件对应一个处理函数,一个处理函数对应一个事件,就是一对一。想让一个事件触发多个处理函数,就需要在函数里自己挨个调;想让多个事件走同一段逻辑,就得把这个函数分别赋给每个事件。

    而 Qt 想要的是一对一之上的多对多,所以才引入 connect 这样一层额外的登记机制。

    13.6 关于"多对多"的实话

    多对多听起来很美,但实践下来的结论是:它在实际开发中很少真的被用到。 绝大部分场景里一对一就够了——一个信号连一个槽,逻辑清楚、维护方便。

    这不代表设计者当初想错了。框架诞生的年代,程序员在图形界面开发上的经验积累还不充分,对需求的预判出现偏差很正常,这是时代的局限。而后来新出现的图形界面框架,也很少再花大力气去支持多对多,因为既然用不上,就没有必要为此付出复杂度。

    所以对信号槽的评价可以客观一点:它是 Qt 的一个有特色的机制,也确实解决了部分问题,但它不是 Qt 唯一的、也不是最值得称道的优点。Qt 还提供了设计得很顺手的接口、质量很高的帮助文档、丰富的内置控件、强大的功能以及跨平台能力,这些都同样值得学习。

    十四、disconnect:想让信号换绑一个槽

    前面反复强调信号槽支持多对多,这带来一个不太直观的后果:connect 是"追加登记",不是"覆盖赋值"。

    也就是说,如果对同一个信号先后写了两次 connect,指向两个不同的槽,那么两个槽都会被保留,触发一次信号两个都会执行——这就从"一对一"变成了"一对多",而不是"从槽1切换到槽2"。想让一个信号改绑到另一个槽上,就必须先把原来的连接断开,这就需要用到 disconnect。

    disconnect 的用法和 connect 基本一致,只是功能相反。下面的工程 Signal_6 演示了断开后重新绑定的完整过程。

    14.1 界面上的两个按钮

    Signal_6/widget.ui 相对 Signal_1/widget.ui 的改动。下面是改动后的完整内容:

    文件名:widget.ui

    <?xml version="1.0" encoding="UTF-8"?>
    <ui version="4.0">
    <class>Widget</class>
    <widget class="QWidget" name="Widget">
    <property name="geometry">
    <rect>
    <x>0</x>
    <y>0</y>
    <width>800</width>
    <height>600</height>
    </rect>
    </property>
    <property name="windowTitle">
    <string>Widget</string>
    </property>
    <widget class="QPushButton" name="pushButton">
    <property name="geometry">
    <rect>
    <x>270</x>
    <y>190</y>
    <width>171</width>
    <height>91</height>
    </rect>
    </property>
    <property name="text">
    <string>修改窗口标题</string>
    </property>
    </widget>
    <widget class="QPushButton" name="pushButton_2">
    <property name="geometry">
    <rect>
    <x>280</x>
    <y>350</y>
    <width>151</width>
    <height>71</height>
    </rect>
    </property>
    <property name="text">
    <string>使用这个按钮切换槽函数</string>
    </property>
    </widget>
    </widget>
    <resources/>
    <connections/>
    </ui>

    新增两个按钮:pushButton 的文字是"修改窗口标题",pushButton_2 的文字是"使用这个按钮切换槽函数"。第一个按钮是被切换的对象,第二个按钮是触发切换的开关。

    14.2 头文件声明

    Signal_6/widget.h 相对 Signal_1/widget.h 的改动。下面是改动后的完整内容:

    文件名:widget.h

    #ifndef WIDGET_H
    #define WIDGET_H

    #include <QWidget>

    QT_BEGIN_NAMESPACE
    namespace Ui { class Widget; }
    QT_END_NAMESPACE

    class Widget : public QWidget
    {
    Q_OBJECT

    public:
    Widget(QWidget *parent = nullptr);
    ~Widget();

    void handleClick();
    void handleClick2();
    private slots:
    void on_pushButton_2_clicked();

    private:
    Ui::Widget *ui;
    };
    #endif // WIDGET_H

    新增了三个声明:两个候选槽函数 handleClick()、handleClick2(),以及 pushButton_2 的点击槽函数 on_pushButton_2_clicked()。

    14.3 实现文件里的断开与重连

    Signal_6/widget.cpp 相对 Signal_1/widget.cpp 的改动。下面是改动后的完整内容:

    文件名:widget.cpp

    #include "widget.h"
    #include "ui_widget.h"
    #include <QDebug>

    Widget::Widget(QWidget *parent)
    : QWidget(parent)
    , ui(new Ui::Widget)
    {
    ui->setupUi(this);

    connect(ui->pushButton, &QPushButton::clicked, this, &Widget::handleClick);
    }

    Widget::~Widget()
    {
    delete ui;
    }

    void Widget::handleClick()
    {
    this->setWindowTitle("修改窗口的标题");
    qDebug() << "handleClick";
    }

    void Widget::handleClick2()
    {
    this->setWindowTitle("修改窗口的标题2");
    qDebug() << "handleClick2";
    }

    void Widget::on_pushButton_2_clicked()
    {
    // 1. 先断开 pushButton 原来的信号槽
    // 如果没有 disconnect, 就会构成 一个信号绑定了两个槽函数. 触发信号的时候, 两个槽函数都会执行.
    // disconnect(ui->pushButton, &QPushButton::clicked, this, &Widget::handleClick);
    // 2. 重新绑定信号槽
    connect(ui->pushButton, &QPushButton::clicked, this, &Widget::handleClick2);
    }

    块后的解释,这是这一节的核心:

    • #include <QPushButton> 换成了 #include <QDebug>,因为要用日志观察"到底哪个槽被执行了"。

    • 构造函数里的连接变成 connect(ui->pushButton, &QPushButton::clicked, this, &Widget::handleClick);,信号源是界面上的那个按钮,第一次绑定的槽是 handleClick。

    • handleClick 把窗口标题设为"修改窗口的标题",handleClick2 设为"修改窗口的标题2"。两者都额外打印一行日志,这样即使标题的变化在界面上不够醒目,也能从控制台确认到底执行了哪个。

    • 在这里插入图片描述

    • on_pushButton_2_clicked() 是切换开关,里面有两步。第一步是把原来的连接断开:

    disconnect(ui->pushButton, &QPushButton::clicked, this, &Widget::handleClick);

    参数形式和 connect 完全对应:信号源、信号、接收者、槽,四者必须和当初连接时一模一样才能准确匹配到那一条登记。注意这一行在当前文件里是被注释掉的,下面 14.4 节会说为什么这样安排。

    第二步是重新绑定:

    connect(ui->pushButton, &QPushButton::clicked, this, &Widget::handleClick2);

    同样是追加登记,把新的槽挂上去。 在这里插入图片描述

    下面这张图把"只加不清"和"先清再加"两种写法的代码、以及各自会看到的输出放在一起对照:

    在这里插入图片描述

    14.4 不写 disconnect 会看到什么

    把 disconnect 那一行继续留在注释状态运行程序,会发生这样的事:

    • 直接点第一个按钮,控制台出现一行 handleClick;
    • 点一下第二个按钮"切换";
    • 再点第一个按钮,控制台出现两行——handleClick 和 handleClick2 都执行了;
    • 之后每点一次第一个按钮,都是两行日志。

    标题方面,因为 handleClick2 排在后面执行,标题会显示成"修改窗口的标题2",看起来像是"切换成功"了,但日志暴露了真相:旧的那个槽根本没有被解绑,只是又加了一个新的。 于是这个信号从一对一变成了一对多。

    Signal_6/widget.cpp 保留的就是这个状态——disconnect 被注释掉,程序能跑,但"切换"的语义没有真正实现。要让它成为真正的切换,把那行注释解开即可:

    // 先把原来的连接断开,避免旧槽依然挂在信号上
    disconnect(ui->pushButton, &QPushButton::clicked, this, &Widget::handleClick);

    // 再绑定新的槽;因为是追加登记,必须先断开才能真正完成"切换"
    connect(ui->pushButton, &QPushButton::clicked, this, &Widget::handleClick2);

    解开之后重新运行,现象就变了:切换后每点一次第一个按钮只出现一行 handleClick2,标题也只按新的逻辑设置。这才叫"换绑"。

    ⚠️ 易错点:connect 不会覆盖上一次的连接。只要不写 disconnect,旧连接就一直有效,触发信号时新老槽一起执行。想真正做"切换",一定要先断开再重连。

    另外顺带说一句:disconnect 在实际开发里用得并不多。大多数情况下连接建好之后就不去管它,主动断开通常只出现在"要让这个信号改绑到另一个槽"这种场景。

    十五、用 lambda 表达式写槽函数

    定义槽函数的第三种方式是用 lambda 表达式。它可以把第 8 节和第 9 节那套"先在头文件里写声明、再在源文件里写定义、最后去 connect"的三步,压缩成一步——就地写一个匿名函数,直接放在 connect 的参数位置上。

    lambda 是 C++11 引入的语法,很多语言都有类似的东西。它本质上就是一个匿名函数,最典型的用途就是当回调函数:回调往往是一次性使用,用完就丢掉,与其在外面单独定义一个具名函数或者仿函数,不如就地写一个匿名函数来得省事。用它的场合判断也很简单:槽函数逻辑比较简单、而且只会用这一次,就可以写成 lambda;如果逻辑复杂或者要在多处复用,还是老老实实写成员函数更合适。

    15.1 语法结构

    lambda 的完整语法形式是:

    [ capture ] ( params ) opt -> ret { Function body; }

    五部分依次是:

    • capture——捕获列表,用一对方括号表示。它的作用决定这个匿名函数能不能访问外层的局部变量,也是 lambda 的"起手式",只要看到方括号就知道这里是 lambda;
    • params——参数表,用圆括号表示,写法和平常定义函数的形参列表一样。这一部分可以省略,省略后就相当于无参函数。对槽函数来说,这里的参数要和它连接的信号保持一致;
    • opt——函数选项,是可选项,可以整体省略。最常用的是 mutable 声明;
    • ret——返回值类型,用 -> 引出。不写的话编译器会根据函数体自动推导返回类型;如果函数体没有返回值,这一部分可以直接忽略;
    • Function body——函数体,用花括号表示。这一部分不能省略,但内容可以为空。

    圈起来看会发现,lambda 把能用上的括号都用了一遍:方括号、圆括号、花括号,各管一段。因为 opt 和 ret 这两段都可以省掉,很多示意图里只画出捕获列表、形参列表和函数体这三段,这并不冲突——省略掉的就是可选部分。

    15.2 一个 lambda 槽

    Signal_7/widget.cpp 相对 Signal_1/widget.cpp 的改动。下面是改动后的完整内容:

    文件名:widget.cpp

    #include "widget.h"
    #include "ui_widget.h"
    #include <QPushButton>
    #include <QDebug>

    Widget::Widget(QWidget *parent)
    : QWidget(parent)
    , ui(new Ui::Widget)
    {
    ui->setupUi(this);

    QPushButton* button = new QPushButton(this);
    button->setText("按钮");
    button->move(200, 200);

    // 如果你在这里自己创建一些对象, lambda 捕获.
    // 关于对象生命周期管理, C++ 程序员的必修课!!

    connect(button, &QPushButton::clicked, this, [=]() {
    qDebug() << "lambda 被执行了!";
    button->move(300, 300);
    this->move(100, 100);
    });
    }

    Widget::~Widget()
    {
    delete ui;
    }

    块后的解释,重点在 connect 的第四个参数:

    • 新增 #include <QDebug>,用于打印日志。
    • 按钮的创建方式和 Signal_1 一样,只是文字改成了"按钮"。
    • 保留了两行注释,提示"如果在这里自己创建一些对象,要注意捕获和生命周期":lambda 捕获的对象必须在它被执行时仍然有效,而对象生命周期管理是 C++ 里绕不开的功课。
    • connect 的第四个参数位置直接写了一个 lambda:connect(button, &QPushButton::clicked, this, [=]() { … });。方括号里的 = 是捕获方式,表示按值捕获外层作用域中的全部变量;圆括号为空,因为 clicked 信号在这里用的是无参版本;花括号里是三句代码——打印一行日志、把按钮移到 (300, 300)、把窗口移到 (100, 100)。
    • button->move(300, 300); 和 this->move(100, 100); 都是移动操作,move 接收两个坐标值。这两句能写在 lambda 里面,前提是 button 和 this 都能被访问到——这正是捕获要解决的问题,见 15.3。

    15.3 捕获:lambda 为什么要"抓"外面的变量

    如果直接在 lambda 里写 button->move(…) 而不做任何捕获,代码是编译不过的,编译器会提示找不到 button 的定义。

    原因在于 lambda 自成一套作用域:它是一个独立的函数体,不能直接访问外层作用域里的局部变量。有些语言的匿名函数天生能访问外层变量,但 C++ 里必须先声明捕获。解决办法就是在方括号里"抓"一下需要的变量:把 button 写进方括号,这个变量就进入了 lambda 内部,可以正常使用了。同理,要对 this 动手,就把 this 也写进去。

    这就是 Signal_7 里那个 [=] 的来由。如果捕获的变量只有一两个,可以直接写名字,比如 [button] 表示只捕获 button,[button, this] 表示两个都捕获。而当要用到的外层变量比较多时,逐个写太麻烦,这时就可以用简写形式:[=] 表示按值捕获外层所有变量,[&] 表示按引用捕获外层所有变量。

    15.4 捕获列表的全部写法

    方括号里可以写的内容有几类,逐项列一下:

    • []——空捕获。表示这个 lambda 不捕获任何外层局部变量,因此内部只能使用自己的参数和自己定义的东西。方括号本身不能省略,因为它标志着 lambda 的开始。
    • [a]——按值捕获 a。函数体内使用的是 a 的一个副本,改动不会影响外面那个 a。
    • [&b]——按引用捕获 b。函数体内拿到的是 b 本身,改动会影响外面。引用捕获的代价是:如果 b 在外层已经销毁,这个引用就是悬垂的。
    • [=]——按值捕获外层所有变量。函数体内用的都是副本。
    • [&]——按引用捕获外层所有变量。
    • [=, &foo]——混合:foo 用引用捕获,其余变量按值捕获。
    • [&, foo]——混合:foo 按值捕获,其余变量按引用捕获。
    • [this]——捕获当前对象的 this 指针,于是函数体内可以使用这个类的成员函数和成员变量。用 = 或 & 的形式时,this 通常也会被一并引入。

    在这里插入图片描述

    15.5 捕获变量的生命周期:这才是真正的坑

    捕获解决了"能不能用"的问题,但用不用得对,取决于被捕获的东西在那个时刻还活着没有。

    关键在于 lambda 作为回调,执行时机是不确定的——用户什么时候点按钮完全不可预知。所以必须保证:无论用户何时点击,捕获进来的变量都还是有效的。如果用户点击的时候那个变量已经销毁了,程序就会出问题。

    Signal_7 里的两个捕获对象恰好都是安全的:

    • button 是用 new 在堆上创建、并挂到对象树上的,它的生命周期跟随整个窗口,窗口关闭才会释放。所以只要程序还在跑,它随时可用。
    • this 指向的是窗口对象,它在 main 函数里以局部变量的形式创建。局部变量随函数结束而销毁,而 main 只有在进程结束时才会返回——也就是说,只要进程不结束,这个对象就一直可用。

    按值捕获在这里也更稳妥:副本是独立的,即使外部变量名失效,副本本身仍然在 lambda 里有效。

    如果换成按引用捕获,风险就大了。比如引用捕获一个在构造函数内部定义的局部变量,构造函数一返回,那个变量就没了,之后 lambda 再去用它的引用,拿到的就是非法内容。所以在 Qt 里一般推荐按值捕获:需要捕获的通常就是各种控件的指针,指针按值传递还是按引用传递差别不大,但按值能避免悬垂风险。

    ⚠️ 易错点:在 lambda 里自己 new 对象再捕获时,一定要确认 lambda 执行到那一刻这个对象还有效。千万不要访问已经释放的对象——对象生命周期管理是 C++ 里必须掌握的一课。

    15.6 使用 lambda 的前提

    lambda 是 C++11 的特性。Qt5 及更高版本默认就按 C++11 编译,所以直接写没问题;但如果项目用的是 Qt4 或更早的版本,就需要在 .pro 文件里手动加上 C++11 的编译选项:

    # 在 .pro 文件里追加这一行,让工程按 C++11 标准编译
    CONFIG += c++11

    CONFIG 是 qmake 里的配置变量,+= 是追加赋值。Signal_1.pro 里正好就有这一行——Qt5 新建项目时会自动带上,Qt6 同样自带,所以通常看不到它的必要性;只有接手老项目时才会真正需要手动补上。

    具体能用哪个版本的 Qt、能用哪些语法,不同公司往往有明确规定。遇到 Qt4 的老项目不必意外,按项目现有的规范写就行。

    十六、容易踩错的地方与技术边界

    前面每节都带了一部分注意事项,这里把需要单独强调的集中在几处,同时把几条容易被说绝对的地方讲清楚边界。

    16.1 信号槽比直接调用慢,但慢的量级要看清

    从实现上看,信号与槽确实比直接调用回调函数要慢。原因是中间多了几步开销:定位信号由谁接收、遍历该信号上的所有关联、对传递的参数做编组与解组,以及在跨线程场景下信号可能需要排队。

    有一组常被引用的数据是:通过信号调用的槽函数,比直接调用的速度慢约 10 倍。看到这个数字先不要紧张,要把它放回语境里:

    • 这个倍数描述的是调用开销本身的对比,不是整个程序的耗时对比;
    • 放在实际程序里,这个差距意味着"原本 10 微秒的调用变成 100 微秒"这种量级,而一个客户端程序里最慢的环节往往是坐在屏幕前的那个人——用户对这点差别的感知基本为零。

    合理的结论是:在对性能要求不极端的场景里,这点开销可以忽略,信号槽能满足绝大部分需求。真正需要在意的是高频调用路径,比如每秒被触发成千上万次的内部逻辑,那种场合才值得考虑绕开信号槽。把"信号槽慢"当成一个绝对缺点,属于把相对结论说成了绝对结论。

    16.2 多对多很少真用,但机制仍然是基础

    多对多这个特性在实际开发中用得不多,常见的就是一对一。但理解多对多的存在,对理解 connect 的行为方式有直接帮助——正因为信号槽支持一对多,connect 才是追加登记而非覆盖赋值,也正因为如此才需要 disconnect。这两件事是连在一起的。

    16.3 高频的是自定义槽,不是自定义信号

    如果只挑一件事优先练熟,那应该是自定义槽函数。它承载业务逻辑,几乎每个界面功能都要写。自定义信号用到的场合少得多,但至少要留个印象:需要的时候知道 Qt 提供了这个手段。

    16.4 顺序:先连接,再发射

    这条在第 1 节、第 10 节各强调过一次,因为它造成的故障最隐蔽:代码能编译、程序能启动、界面正常显示,就是点了没反应。出现这种"完全没反应"的现象,第一件事就是检查连接到底建立了没有、信号到底发射了没有。

    16.5 自动连接依赖名字,写错就是静默失效

    第 9 节那个"少了一个字母"的例子说明得很清楚:用命名规则自动连接时,函数名里任何一段写错,连接都不会建立,而且不会有任何编译错误或运行提示。所以用这种方式时,函数名最好交给编辑器生成,不要手敲。

    16.6 一句关于取舍的话

    关于"显式 connect 好还是靠命名规则自动连接好",本质上就是配置大于约定,还是约定大于配置的经典争论,业界并没有统一答案。

    从个人角度更倾向于显式 connect,理由有两条:一是它把连接关系明确写在代码里,读代码时一眼就能看到"这个信号会触发哪个函数",比靠名字去推断直观;二是显式 connect 有编译期检查兜底,名字拼错会被编译器拦住,而命名规则出错只会在运行时表现为"没反应"。

    两者也不冲突,实践中常见的做法是:界面编辑器拖出来的控件用自动连接图省事,代码里创建的控件用显式 connect 保证可靠。

    16.7 槽函数的一条小限制

    槽函数可以被直接调用,也可以重载,但不能带默认参数。这一点和信号不同——信号的参数可以带默认值(第 6 节的 clicked(bool checked = false) 就是),而槽函数不允许。

    总结

    把这一节的内容收一下。信号与槽要解决的问题只有一个:响应用户的操作。用户做了什么,程序就执行哪段逻辑给出回应,这是图形界面开发的基本盘。

    围绕这个目标,整条线索是这样的:

    • 三个要素是理解一切的基础——信号源、信号类型、处理方式。Qt 用控件承载信号源,用信号类型区分用户的不同操作,用槽作为处理方式。
    • 槽的本质是回调函数,它和 C 语言函数指针、C++ 仿函数、lambda、系统编程里的信号处理函数与线程入口函数是同一类东西,只是 Qt 给它单独命名并配了一套注册机制。
    • QObject 是底座。所有 Qt 内置类都直接或间接继承自它,而 connect 由它提供,所以任何 Qt 类都能使用信号槽;要用信号槽的类必须在声明里写上 Q_OBJECT。
    • connect 的四个参数分别对应:谁是信号源、发的是哪个信号、谁负责处理、用哪个函数处理。第三和第四个参数必须成对看——成员函数要有对象才能调用。
    • 两代写法的差别在于 Qt4 用 SIGNAL/SLOT 宏把函数指针转成字符串,因而失去编译期检查;Qt5 用泛型参数配合类型萃取,把类型校验提前到了编译阶段。
    • 自定义槽就是普通成员函数,高频使用;转到槽让界面编辑器自动生成代码,同时引入了按名字自动连接的规则 on_<对象名>_<信号名>,它在界面初始化时由 connectSlotsByName 统一完成。
    • 自定义信号需要写在 signals: 下、只声明不实现、返回类型必须是 void,用 emit 发射(emit 本身是空宏,写它主要为可读性)。
    • 参数传递遵守两条规则:类型必须一致,信号的参数个数不少于槽的参数个数——可以有富余,不能短缺。
    • 连接方式有一对一、一对多、多对一和多对多。多对多在结构上和数据库的关联表是同一个思路,而 connect 就相当于那张关联表。它的作用是解耦,代价是比直接回调多一些开销,但在大多数场景下可以忽略。
    • disconnect 用来断开连接。要记住 connect 是追加登记,想让信号改绑,必须先断再连。
    • lambda 把"声明 + 定义 + 连接"三步压缩成一步,写一次性槽函数最省事。使用它的关键在捕获列表,而捕获之后必须确认被捕获变量的生命周期足够长。

    信号槽并不是 Qt 的全部,它只是这套框架里比较有特色的一个机制。往下走会越来越多地用到多线程、网络这些内容,而它们会反复和更早学过的东西打交道——C++ 语法、数据结构、操作系统、网络、数据库,哪一块基础扎实,这里就理解得越顺。反过来说,如果想靠框架速成来绕开基础,面试时遇到稍微深入一点的问题就会卡住,因为很多题目的落点恰恰在那些基础上。

    标签

    Qt 信号与槽 signal slot connect QObject Q_OBJECT emit signals slots disconnect lambda QPushButton clicked QAbstractButton QWidget 元编程 回调函数 多对多 解耦合 高内聚低耦合 C++11 qmake

    赞(0)
    未经允许不得转载:171主机测评 » Qt 信号与槽全梳理:从三个要素、connect,到自定义信号、disconnect 与 lambda
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址