Qt 信号槽的工作原理(二)——Qt5 新语法

原文链接 https://woboq.com/blog/how-qt-signals-slots-work-part2-qt5.html Olivier Goffart 于 2012年12月17日

这是《Qt 信号槽的工作原理(一)》的续篇。之前,我们已经介绍了信号与槽的总体原理,以及旧语法是如何工作的。

在这篇博客中,我们将介绍 Qt5 中 基于函数指针的新语法 背后的实现细节。


Qt5 中的新语法

新的连接语法如下所示:

为什么要引入新语法?

简而言之,新语法具有以下优点:

新的重载

为了实现这些特性,只需要做少量改动。

核心思想是:为 QObject::connect 提供新的重载版本,参数不再是 char*,而是函数指针

Qt 新增了三个 QObject::connect 的静态重载(以下为伪代码):

第一个重载与旧语法最接近:将发送者对象的一个信号连接到接收者对象的一个槽。

后两个重载用于将信号连接到 静态函数仿函数(functor),不需要接收者对象。

它们非常相似,本文只分析第一个。

成员函数指针(Pointer to Member Functions)

在继续之前,先简单介绍一下 成员函数指针

下面是一个示例代码,展示了如何声明和调用成员函数指针:

成员指针和成员函数指针属于 C++ 中较少被使用、也不太为人熟知的一部分。

好消息是:在使用 Qt 新语法时,你并不需要真正理解这些细节。你只需要记住:

connect 中对信号使用 & 即可

你不需要直接处理 ::*.*->* 这些看起来很 “诡异” 的运算符。

这些运算符用于声明或访问成员指针。成员函数指针的类型包含:

你不能随意将成员函数指针转换为其他类型,尤其不能转换为 void*,因为它们的 sizeof 不同。

参见 https://isocpp.org/wiki/faq/pointers-to-members#cant-cvt-memfnptr-to-voidptr

即使函数签名稍有不同,也不能互相转换。例如:

不能转换为:

(使用 reinterpret_cast 虽然可以编译,但调用时属于 未定义行为)。

为什么成员函数指针更复杂?

普通函数指针只是函数代码地址。

而成员函数指针需要保存额外信息:

因此,不同类的成员函数指针大小可能不同,我们操作它们时必须格外小心。

类型萃取:QtPrivate::FunctionPointer

接下来介绍 QtPrivate::FunctionPointer 类型萃取(type trait)。

类型萃取 是一种辅助模板,用来提供某个类型的元信息(类似 Qt 中的 QTypeInfo)。

FunctionPointer 结构体用于函数指针的类型萃取(type trait)。

由于 Qt 仍然支持 C++98 编译器,不能使用可变参数模板(variadic templates),因此:

都做了专门的模板特化。

如果编译器支持可变参数模板,则可以支持任意数量的参数。

FunctionPointer 的实现位于 qobjectdefs_impl.h 中。

QObject::connect

实现依赖大量模板代码。我不会全部解释。

这是第一个新重载的代码,来自 qobject.h

关键点说明

你会注意到,在这个函数签名中,senderreceiver 并不只是文档中所说的 QObject*

实际上,它们是指向 typename FunctionPointer::Object 的指针。

这里利用了 SFINAE(Substitution Failure Is Not An Error,替换失败并非错误)机制,

参见 https://zh.wikipedia.org/wiki/%E6%9B%BF%E6%8D%A2%E5%A4%B1%E8%B4%A5%E5%B9%B6%E9%9D%9E%E9%94%99%E8%AF%AF

使得这个重载只在类型是成员函数指针时才会被启用。原因在于:

只有当类型是成员函数指针时,FunctionPointer 中才会定义 Object 这个类型;

如果不是成员函数指针,替换过程中就会失败,从而该重载不会参与重载决议。

静态断言(Q_STATIC_ASSERT)

这些断言的目的,是在用户出错时生成清晰、可理解的编译期错误信息

如果用户用法不正确,关键是要让错误在这里就暴露出来,而不是让用户掉进 _impl.h 文件里那一大堆模板代码“天书”般的报错中。

我们希望把底层实现细节对用户隐藏起来——用户并不需要、也不应该关心这些实现内部是如何完成的。

这意味着:如果你在实现细节中看到令人困惑的编译错误信息,那么这应该被视为一个需要报告的 Bug。

参见 https://qt-project.atlassian.net/jira/software/c/projects/QTBUG/issues

QSlotObjectBase 与 connectImpl

接下来我们会分配一个 QSlotObject,并将它传递给 connectImpl()

QSlotObject 是对 的一层封装,用来辅助对槽的调用。

同时,它还知道 信号参数的类型,因此能够进行 正确的类型转换

我们使用 List_Left,只向槽函数传递与其参数数量相同的参数,这样就可以实现:参数较多的信号连接到参数较少的槽

QObject::connectImpl 是一个私有的内部函数,用来真正执行连接操作。

它与原始的连接语法类似,不同之处在于:

它不再在 QObjectPrivate::Connection 结构中存储方法索引,而是存储一个指向 QSlotObjectBase 的指针

之所以将 &slotvoid** 的形式传递,只是为了在连接类型是 Qt::UniqueConnection 时,能够对其进行比较。

我们同样也将 &signalvoid** 的形式传递。它是一个 指向成员函数指针的指针

信号索引(Signal Index)

信号指针与信号索引之间的映射依赖 MOC

这意味着:新语法 仍然依赖 MOC,并且目前没有计划取消它 🙂

MOC 会在 qt_static_metacall 中生成代码,用来 比较传入的参数并返回正确的索引

connectImpl 会调用 qt_static_metacall,并把 函数指针的指针 传递给它。

一旦我们拿到了信号索引,接下来的流程就可以和旧的连接语法完全一样地继续执行了。

QSlotObjectBase

QSlotObjectBase 是传递给 connectImpl 的用于表示槽函数的对象。

在展示真正的代码之前,先来看一下 Qt 5 alpha 版本中 QObject::QSlotObjectBase 的定义:

它本质上是一个 接口类,用于被模板类重新实现,从而完成 函数指针的调用 以及 函数指针之间的比较

具体来说,它会被以下模板类之一重新实现:

这些模板类分别负责不同类型槽的封装与调用逻辑。

伪虚表(Fake Virtual Table)

这样做的问题是:这些对象的每一次模板实例化都会生成一份虚函数表(vtable)。

而 vtable 里不仅包含虚函数指针,还会带上一些我们并不需要的信息,比如 RTTI 等。

这会导致二进制文件里出现大量多余的数据和重定位(relocation)。

为了避免这个问题,QSlotObjectBase 被改成 不再是一个 C++ 多态类

而是通过 “手动模拟虚函数” 的方式来实现多态行为。

这里的 m_impl 是一个(普通的)函数指针,用来执行此前由虚函数完成的三个操作:DestroyCallCompare

各个 “重新实现” 的类会在构造函数里把 m_impl 设置成自己的实现函数。

请不要 因为看到这里说这种方式 “好”,就回去把你代码里的虚函数全用这种 “黑魔法” 替换掉。

这里只在这个场景使用,是因为:

几乎每一次 connect 调用都会生成一个新的不同类型(因为 QSlotObject 的模板参数依赖于信号和槽的签名),

如果用常规虚函数机制会带来大量 vtable/RTTI 等额外开销。

Protected, Public, or Private Signals.

在 Qt4 以及更早版本里,signals 是 protected 的。这是一个设计选择:信号应该由对象在自身状态变化时发出,不应该从对象外部发射;对另一个对象去“调用/发射”它的信号几乎总是个坏主意。

但是在新语法里,你需要在建立连接的地方获取信号的地址。只有当你对该信号有访问权限时,编译器才允许你这么做。如果信号不是 public,那么写 &Counter::valueChanged 就会产生编译错误。

因此在 Qt 5 中我们不得不把 signals 从 protected 改成 public。这很遗憾,因为这意味着任何人都可以发射这些信号。我们没找到绕开的办法:尝试过利用 emit 关键字做技巧、尝试过让信号返回特殊值,但都不行。我认为新语法带来的优势足以抵消 signals 变成 public 这一问题。

有时候,甚至希望某些信号是 private 的。比如 QAbstractItemModel:否则开发者往往会在派生类里随意发射某些信号,这并不是该 API 所期望的用法。过去曾有一个预处理器技巧能把 signals “变成 private”,但它会破坏新的连接语法。

于是引入了一个新的“黑科技”:QPrivateSignal。它是一个(空的)哑结构体,在 Q_OBJECT 宏里以 private 方式声明。它可以作为信号的最后一个参数。因为它是 private 的,只有该对象自身才有权构造它并调用该信号。与此同时,MOC 在生成签名信息时会忽略这个最后的 QPrivateSignal 参数。示例可以看 qabstractitemmodel.h

更多模板代码

其余代码位于 qobjectdefs_impl.h 和 qobject_impl.h 文件中。它们大多是标准的、比较枯燥的模板代码。

本文不会深入探讨更多细节,但我会简要介绍一些值得一提的内容。

Meta-Programming List

如前面提到的,FunctionPointer::Arguments 是一个“参数类型的列表”。

代码需要对这个列表进行操作:比如遍历每个元素、只取其中一部分、或者选出某个指定位置的元素。

因此 Qt 提供了 QtPrivate::List 来表示一个 类型列表

同时还有一些辅助工具用于对它做操作,比如:

List 的实现会因编译器是否支持 可变参数模板(variadic templates)而不同。

支持 variadic templates 的情况

这时 List 定义类似于:

也就是说,参数列表直接封装在模板参数里。

例如,一个包含参数 (int, QString, QObject*) 的列表类型就是:

不支持 variadic templates 的情况

这时就用一种类似 LISP 的链表结构来实现:

其中 Tail 要么是另一个 List,要么用 void 表示链表结束。

同样的例子 (int, QString, QObject*) 会写成:

ApplyReturnValue 技巧

FunctionPointer::call 里,args[0] 用来接收槽函数(slot)的返回值:

slot 有返回值 时,我们需要把这个返回值拷贝到 args[0] 指向的内存里;当 slot 返回 void 时,就什么都不做。

问题在于:对一个返回 void 的函数,你不能在语法上 “使用它的返回值”。

那要不要把本来就很庞大的代码再复制一份:一份处理 void 返回,一份处理非 void

不,用 逗号运算符 来避免重复。

在 C++ 里你可以写:

这里其实把逗号换成分号也完全没问题。

有意思的是,当左边不是 void 时:

此时逗号会调用一个真正的运算符(而且它还可以被重载)。Qt 在 qobjectdefs_impl.h 里就利用了这一点:

ApplyReturnValue 本质上只是对一个 void* 的包装。这样它就可以在各种 helper 里通用地使用。

例如,一个没有参数的 functor 的调用可以写成:

含义是:

这段代码会被内联,因此运行时基本没有额外开销。

总结

这篇博客到这里就结束了。其实还有很多内容可以讲(我甚至还没提到 QueuedConnection 或线程安全),

但希望你觉得这些内容有意思,并且能从中学到一些对你作为程序员有所帮助的东西。