原文链接 https://woboq.com/blog/how-qt-signals-slots-work-part2-qt5.html Olivier Goffart 于 2012年12月17日
这是《Qt 信号槽的工作原理(一)》的续篇。之前,我们已经介绍了信号与槽的总体原理,以及旧语法是如何工作的。
在这篇博客中,我们将介绍 Qt5 中 基于函数指针的新语法 背后的实现细节。
新的连接语法如下所示:
1QObject::connect(&a, &Counter::valueChanged, &b, &Counter::setValue);简而言之,新语法具有以下优点:
新语法允许在 编译期检查 信号和槽是否匹配
支持在参数类型不同但可转换的情况下进行 自动类型转换
支持 lambda 表达式
为了实现这些特性,只需要做少量改动。
核心思想是:为 QObject::connect 提供新的重载版本,参数不再是 char*,而是函数指针。
Qt 新增了三个 QObject::connect 的静态重载(以下为伪代码):
x1QObject::connect(const QObject *sender, PointerToMemberFunction signal,2 const QObject *receiver, PointerToMemberFunction slot,3 Qt::ConnectionType type)4
5QObject::connect(const QObject *sender, PointerToMemberFunction signal,6 PointerToFunction method)7
8QObject::connect(const QObject *sender, PointerToMemberFunction signal,9 Functor method)第一个重载与旧语法最接近:将发送者对象的一个信号连接到接收者对象的一个槽。
后两个重载用于将信号连接到 静态函数或仿函数(functor),不需要接收者对象。
它们非常相似,本文只分析第一个。
在继续之前,先简单介绍一下 成员函数指针。
下面是一个示例代码,展示了如何声明和调用成员函数指针:
xxxxxxxxxx91void (QPoint::*myFunctionPtr)(int); // 声明 myFunctionPtr 是一个指向成员函数的指针,2 // 该成员函数返回类型为 void,并接受一个 int 类型的参数。3myFunctionPtr = &QPoint::setX;4
5QPoint p;6QPoint *pp = &p;7
8(p.*myFunctionPtr)(5); // 调用 p.setX(5)9(pp->*myFunctionPtr)(5); // 调用 pp->setX(5)成员指针和成员函数指针属于 C++ 中较少被使用、也不太为人熟知的一部分。
好消息是:在使用 Qt 新语法时,你并不需要真正理解这些细节。你只需要记住:
在
connect中对信号使用&即可
你不需要直接处理 ::*、.* 或 ->* 这些看起来很 “诡异” 的运算符。
这些运算符用于声明或访问成员指针。成员函数指针的类型包含:
返回值类型
所属类
参数类型
const 修饰信息
你不能随意将成员函数指针转换为其他类型,尤其不能转换为 void*,因为它们的 sizeof 不同。
参见 https://isocpp.org/wiki/faq/pointers-to-members#cant-cvt-memfnptr-to-voidptr
即使函数签名稍有不同,也不能互相转换。例如:
xxxxxxxxxx11void (MyClass::*)(int) const不能转换为:
xxxxxxxxxx11void (MyClass::*)(int)(使用 reinterpret_cast 虽然可以编译,但调用时属于 未定义行为)。
普通函数指针只是函数代码地址。
而成员函数指针需要保存额外信息:
虚函数 相关信息
多重继承情况下 this 指针的偏移量
因此,不同类的成员函数指针大小可能不同,我们操作它们时必须格外小心。
接下来介绍 QtPrivate::FunctionPointer 类型萃取(type trait)。
xxxxxxxxxx121template<class Obj, typename Ret, typename... Args> struct FunctionPointer<Ret (Obj::*) (Args...)>2{3 typedef Obj Object;4 typedef List<Args...> Arguments;5 typedef Ret ReturnType;6 typedef Ret (Obj::*Function) (Args...);7 enum {ArgumentCount = sizeof...(Args), IsPointerToMemberFunction = true};8 template <typename SignalArgs, typename R>9 static void call(Function f, Obj *o, void **arg) {10 FunctorCall<typename Indexes<ArgumentCount>::Value, SignalArgs, R, Function>::call(f, o, arg);11 }12};类型萃取 是一种辅助模板,用来提供某个类型的元信息(类似 Qt 中的 QTypeInfo)。
FunctionPointer
ArgumentCount
参数的数量;如果无法确定,则为 -1
Object
成员函数指针所对应的对象类型
Arguments
参数列表(以 QtPrivate::List 的形式表示)
Function
模板参数 Func 的别名
call<Args, R>(f, o, args)
用于调用该槽函数
Args:信号的参数列表
R:信号的返回类型
f:函数指针
o:接收者对象
args:参数指针数组,其形式与 qt_metacall 中使用的一致
Functor<Func, N> 结构体是用于调用具有 N 个参数 的函数对象(functor)的辅助工具。
其 call 函数与 FunctionPointer::call 函数具有相同的调用方式。
由于 Qt 仍然支持 C++98 编译器,不能使用可变参数模板(variadic templates),因此:
为不同参数个数(最多 6 个)
为不同函数类型(普通函数、成员函数、const 成员函数、仿函数)
都做了专门的模板特化。
如果编译器支持可变参数模板,则可以支持任意数量的参数。
FunctionPointer 的实现位于 qobjectdefs_impl.h 中。
实现依赖大量模板代码。我不会全部解释。
这是第一个新重载的代码,来自 qobject.h:
xxxxxxxxxx311template <typename Func1, typename Func2>2static inline QMetaObject::Connection connect(3 const typename QtPrivate::FunctionPointer<Func1>::Object *sender, Func1 signal,4 const typename QtPrivate::FunctionPointer<Func2>::Object *receiver, Func2 slot,5 Qt::ConnectionType type = Qt::AutoConnection)6{7 typedef QtPrivate::FunctionPointer<Func1> SignalType;8 typedef QtPrivate::FunctionPointer<Func2> SlotType;9
10 //如果参数不匹配,则会发生编译错误。11 Q_STATIC_ASSERT_X(int(SignalType::ArgumentCount) >= int(SlotType::ArgumentCount),12 "The slot requires more arguments than the signal provides.");13 Q_STATIC_ASSERT_X((QtPrivate::CheckCompatibleArguments<typename SignalType::Arguments,14 typename SlotType::Arguments>::value),15 "Signal and slot arguments are not compatible.");16 Q_STATIC_ASSERT_X((QtPrivate::AreArgumentsCompatible<typename SlotType::ReturnType,17 typename SignalType::ReturnType>::value),18 "Return type of the slot is not compatible with the return type of the signal.");19
20 const int *types;21 /* ... 跳过了类型的初始化,用于 QueuedConnection ...*/22
23 QtPrivate::QSlotObjectBase *slotObj = new QtPrivate::QSlotObject<Func2,24 typename QtPrivate::List_Left<typename SignalType::Arguments, SlotType::ArgumentCount>::Value,25 typename SignalType::ReturnType>(slot);26
27
28 return connectImpl(sender, reinterpret_cast<void **>(&signal),29 receiver, reinterpret_cast<void **>(&slot), slotObj,30 type, types, &SignalType::Object::staticMetaObject);31}你会注意到,在这个函数签名中,sender 和 receiver 并不只是文档中所说的 QObject*。
实际上,它们是指向 typename FunctionPointer::Object 的指针。
这里利用了 SFINAE(Substitution Failure Is Not An Error,替换失败并非错误)机制,
使得这个重载只在类型是成员函数指针时才会被启用。原因在于:
只有当类型是成员函数指针时,FunctionPointer 中才会定义 Object 这个类型;
如果不是成员函数指针,替换过程中就会失败,从而该重载不会参与重载决议。
这些断言的目的,是在用户出错时生成清晰、可理解的编译期错误信息。
如果用户用法不正确,关键是要让错误在这里就暴露出来,而不是让用户掉进 _impl.h 文件里那一大堆模板代码“天书”般的报错中。
我们希望把底层实现细节对用户隐藏起来——用户并不需要、也不应该关心这些实现内部是如何完成的。
这意味着:如果你在实现细节中看到令人困惑的编译错误信息,那么这应该被视为一个需要报告的 Bug。
参见 https://qt-project.atlassian.net/jira/software/c/projects/QTBUG/issues
接下来我们会分配一个 QSlotObject,并将它传递给 connectImpl()。
QSlotObject 是对 槽 的一层封装,用来辅助对槽的调用。
同时,它还知道 信号参数的类型,因此能够进行 正确的类型转换。
我们使用 List_Left,只向槽函数传递与其参数数量相同的参数,这样就可以实现:参数较多的信号连接到参数较少的槽。
QObject::connectImpl 是一个私有的内部函数,用来真正执行连接操作。
它与原始的连接语法类似,不同之处在于:
它不再在 QObjectPrivate::Connection 结构中存储方法索引,而是存储一个指向 QSlotObjectBase 的指针。
之所以将 &slot 以 void** 的形式传递,只是为了在连接类型是 Qt::UniqueConnection 时,能够对其进行比较。
我们同样也将 &signal 以 void** 的形式传递。它是一个 指向成员函数指针的指针。
信号指针与信号索引之间的映射依赖 MOC。
这意味着:新语法 仍然依赖 MOC,并且目前没有计划取消它 🙂
MOC 会在 qt_static_metacall 中生成代码,用来 比较传入的参数并返回正确的索引。
connectImpl 会调用 qt_static_metacall,并把 函数指针的指针 传递给它。
xxxxxxxxxx271void Counter::qt_static_metacall(QObject *_o, QMetaObject::Call _c, int _id, void **_a)2{3 if (_c == QMetaObject::InvokeMetaMethod) {4 /* .... skipped ....*/5 default: ;6 }7 } else if (_c == QMetaObject::IndexOfMethod) {8 int *result = reinterpret_cast<int *>(_a[0]);9 void **func = reinterpret_cast<void **>(_a[1]);10 {11 typedef void (Counter::*_t)(int );12 if (*reinterpret_cast<_t *>(func) == static_cast<_t>(&Counter::valueChanged)) {13 *result = 0;14 }15 }16 {17 typedef QString (Counter::*_t)(const QString & );18 if (*reinterpret_cast<_t *>(func) == static_cast<_t>(&Counter::someOtherSignal)) {19 *result = 1;20 }21 }22 {23 typedef void (Counter::*_t)();24 if (*reinterpret_cast<_t *>(func) == static_cast<_t>(&Counter::anotherSignal)) {25 *result = 2;26 }27 }一旦我们拿到了信号索引,接下来的流程就可以和旧的连接语法完全一样地继续执行了。
QSlotObjectBase 是传递给 connectImpl 的用于表示槽函数的对象。
在展示真正的代码之前,先来看一下 Qt 5 alpha 版本中 QObject::QSlotObjectBase 的定义:
xxxxxxxxxx71struct QSlotObjectBase {2 QAtomicInt ref;3 QSlotObjectBase() : ref(1) {}4 virtual ~QSlotObjectBase();5 virtual void call(QObject *receiver, void **a) = 0;6 virtual bool compare(void **) { return false; }7};它本质上是一个 接口类,用于被模板类重新实现,从而完成 函数指针的调用 以及 函数指针之间的比较。
具体来说,它会被以下模板类之一重新实现:
QSlotObject
QStaticSlotObject
QFunctorSlotObject
这些模板类分别负责不同类型槽的封装与调用逻辑。
这样做的问题是:这些对象的每一次模板实例化都会生成一份虚函数表(vtable)。
而 vtable 里不仅包含虚函数指针,还会带上一些我们并不需要的信息,比如 RTTI 等。
这会导致二进制文件里出现大量多余的数据和重定位(relocation)。
为了避免这个问题,QSlotObjectBase 被改成 不再是一个 C++ 多态类,
而是通过 “手动模拟虚函数” 的方式来实现多态行为。
xxxxxxxxxx171class QSlotObjectBase {2 QAtomicInt m_ref;3 typedef void (*ImplFn)(int which, QSlotObjectBase* this_,4 QObject *receiver, void **args, bool *ret);5 const ImplFn m_impl;6protected:7 enum Operation { Destroy, Call, Compare };8public:9 explicit QSlotObjectBase(ImplFn fn) : m_ref(1), m_impl(fn) {}10 inline int ref() Q_DECL_NOTHROW { return m_ref.ref(); }11 inline void destroyIfLastRef() Q_DECL_NOTHROW {12 if (!m_ref.deref()) m_impl(Destroy, this, 0, 0, 0);13 }14
15 inline bool compare(void **a) { bool ret; m_impl(Compare, this, 0, a, &ret); return ret; }16 inline void call(QObject *r, void **a) { m_impl(Call, this, r, a, 0); }17};这里的 m_impl 是一个(普通的)函数指针,用来执行此前由虚函数完成的三个操作:Destroy、Call、Compare。
各个 “重新实现” 的类会在构造函数里把 m_impl 设置成自己的实现函数。
请不要 因为看到这里说这种方式 “好”,就回去把你代码里的虚函数全用这种 “黑魔法” 替换掉。
这里只在这个场景使用,是因为:
几乎每一次 connect 调用都会生成一个新的不同类型(因为 QSlotObject 的模板参数依赖于信号和槽的签名),
如果用常规虚函数机制会带来大量 vtable/RTTI 等额外开销。
在 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 文件中。它们大多是标准的、比较枯燥的模板代码。
本文不会深入探讨更多细节,但我会简要介绍一些值得一提的内容。
如前面提到的,FunctionPointer::Arguments 是一个“参数类型的列表”。
代码需要对这个列表进行操作:比如遍历每个元素、只取其中一部分、或者选出某个指定位置的元素。
因此 Qt 提供了 QtPrivate::List 来表示一个 类型列表。
同时还有一些辅助工具用于对它做操作,比如:
QtPrivate::List_Select:取出列表中的第 N 个元素
QtPrivate::List_Left:取出包含前 N 个元素的子列表
List 的实现会因编译器是否支持 可变参数模板(variadic templates)而不同。
这时 List 定义类似于:
xxxxxxxxxx11template<typename... T> struct List;也就是说,参数列表直接封装在模板参数里。
例如,一个包含参数 (int, QString, QObject*) 的列表类型就是:
xxxxxxxxxx11List<int, QString, QObject *>这时就用一种类似 LISP 的链表结构来实现:
xxxxxxxxxx11template<typename Head, typename Tail> struct List;其中 Tail 要么是另一个 List,要么用 void 表示链表结束。
同样的例子 (int, QString, QObject*) 会写成:
xxxxxxxxxx11List<int, List<QString, List<QObject *, void> > >在 FunctionPointer::call 里,args[0] 用来接收槽函数(slot)的返回值:
如果 signal 有返回值,那么 args[0] 是一个指向 “signal 返回类型对象” 的指针
如果 signal 没有返回值,那么 args[0] 就是 0
当 slot 有返回值 时,我们需要把这个返回值拷贝到 args[0] 指向的内存里;当 slot 返回 void 时,就什么都不做。
问题在于:对一个返回 void 的函数,你不能在语法上 “使用它的返回值”。
那要不要把本来就很庞大的代码再复制一份:一份处理 void 返回,一份处理非 void?
不,用 逗号运算符 来避免重复。
在 C++ 里你可以写:
xxxxxxxxxx11functionThatReturnsVoid(), somethingElse();这里其实把逗号换成分号也完全没问题。
有意思的是,当左边不是 void 时:
xxxxxxxxxx11functionThatReturnsInt(), somethingElse();此时逗号会调用一个真正的运算符(而且它还可以被重载)。Qt 在 qobjectdefs_impl.h 里就利用了这一点:
xxxxxxxxxx141template <typename T>2struct ApplyReturnValue {3 void *data;4 ApplyReturnValue(void *data_) : data(data_) {}5};6
7template<typename T, typename U>8void operator,(const T &value, const ApplyReturnValue<U> &container) {9 if (container.data)10 *reinterpret_cast<U*>(container.data) = value;11}12
13template<typename T>14void operator,(T, const ApplyReturnValue<void> &) {}ApplyReturnValue 本质上只是对一个 void* 的包装。这样它就可以在各种 helper 里通用地使用。
例如,一个没有参数的 functor 的调用可以写成:
xxxxxxxxxx31static void call(Function &f, void *, void **arg) {2 f(), ApplyReturnValue<SignalReturnType>(arg[0]);3}含义是:
先调用 f()
然后通过重载的 operator,:
若 SignalReturnType 不是 void 且 arg[0] 非空,就把 f() 的返回值写入 arg[0]
若是 void,就匹配到那个空操作的重载,什么也不做
这段代码会被内联,因此运行时基本没有额外开销。
这篇博客到这里就结束了。其实还有很多内容可以讲(我甚至还没提到 QueuedConnection 或线程安全),
但希望你觉得这些内容有意思,并且能从中学到一些对你作为程序员有所帮助的东西。