为何 Qt 使用 Moc 实现信号槽?

模板是 C++ 的内建机制,可以允许编译器基于传递的参数类型,在编译期生成代码。因此,框架编写者很喜欢使用模板,而我们也的确在 Qt 的许多地方使用了高阶的模板特性。然而,模板有限制的:有的东西可以用模板很轻易地表达,但同样也会有几乎无法用模板表达的东西。一个通用的向量容器类很容易用模板表达,即使它在针对指针类型时使用到了偏特化特性;然而一个基于 XML 字符串的内容描述来构建用户界面的函数,就无法用模板来表达。并且,在它俩之间还有一块灰色区域。强行使用模板来实现功能,会付出代码体积、可读性、可移植性、可用性、可扩展性、健壮性乃至最根本的设计美感上的代价。C++ 的模板和 C 的宏都可以用于扩展语法,来实现不可思议又难以置信的奇思妙想,但这只代表这些想法是可行的,而未必意味着这么做是正确的设计思路。很不幸,编写代码,并不是用来写进书籍(译者注:即阳春白雪般的纸上谈兵),而是在真实世界的操作系统中,被真实世界的编译器所编译。

这就是为什么 Qt 使用 moc 的原因:

语法很重要

语法并不只是:用于描述我们的算法的语法,会显著地影响我们的代码的可读性和可维护性。Qt 的信号槽语法被证明是非常成功的实践,它的语法非常直观,易于使用也易于阅读。学习 Qt 时,这种语法可以帮助人们更好地理解和使用信号槽这个概念——尽管它本质上是很高阶的通用抽象。这令开发者们在刚踏入门槛时,就能做出正确的设计,甚至都不需要去思考何为设计模式。

代码生成是好东西

Qt 的 moc(Meta Object Compiler,元对象编译器) 提供了一种简洁的方式,来超越目标语言本身的桎梏。它通过生成额外的 C++ 代码来达成目标,而这些代码可以被任何标准的 C++ 编译器所编译。moc 会阅读 C++ 源码文件,如果找到任何类声明中包含了 Q_OBJECT 宏,就会生成另一个 C++ 源码文件,在其中填充这些类的元对象相关的代码。moc 生成的 C++ 源码文件必须与对应类的实现文件共同编译和链接(也可以被#include包含到对应类的源文件中)。但通常来说,moc 并不会被手动调用,而是被编译系统自动调用,因此不需要开发者做额外工作。

moc 并不是 Qt 使用的唯一一个代码生成器。另一个典型范例则是 uic(User Interface Compiler,用户界面编译器),它接受 XML 格式的用户界面描述,来生成 C++ 代码并初始化界面。在 Qt 之外,代码生成器同样也被广泛应用,例如 rpcidl,可以支撑应用程序或对象跨越进程甚至跨机器进行通信。又比如,各种各样的词法分析工具,如大名鼎鼎的 lexyacc,它们将语法规范作为输入,来生成可以实现这些规范的状态机代码。代码生成器的其它替代品有改造编译器、专有语言或者可视化编程工具——后者提供单向的对话框/向导,用于在设计阶段而非编译阶段生成各种晦涩的代码。相比于将我们的客户绑定在专用的 C++ 编译器上,或者绑定到特定的集成开发环境(IDE)中,我们允许他们使用任何他们喜欢的工具。相较于强迫开发者将生成的代码添加到代码库中,我们更鼓励他们将我们的工具添加到他们的构建系统中:更加干净、更加安全、更具有 UNIX 精神。

译者注:UNIX 精神指的是用小型单一工具组合完成任务,而非庞大的、无所不能的复杂工具。

用户界面是动态的

C++ 是一门标准化的、强大的、经过精心设计的通用型语言,它是唯一一门被应用于如此广泛的软件开发领域的语言,囊括了所有种类的应用程序,包括完整的操作系统、数据库服务器、高级图形应用乃至于通用桌面应用。C++ 成功的关键之一,便是在提供可伸缩的语言特性的同时,聚焦于最大化性能和最小化内存占用,同时还保持了对 ANSI C 的兼容性。

除此之外,C++ 也有一些缺点。例如在面对基于组件的用户界面开发时,C++ 的静态对象模型相较于 Objective-C 的动态消息机制便是很显著的劣势。对于高级数据库服务器或者操作系统而言的优秀特性,却并非用户界面前端设计的正确选择。拥有了 moc 后,我们可以将这个劣势转化为优势,为满足安全又高效的用户界面开发提供了充足的灵活性。

我们的成果已经超越了通过模板能够做到的任何事情。例如,我们可以拥有对象属性,还可以重载信号槽,这在一门把重载作为关键特性的语言中,会令开发者感到无比自然。我们的信号机制不会让对象大小增加任何一个字节,这意味着我们在添加新的信号的同时不会破坏二进制兼容性。

另一个好处是我们可以在运行时检索一个对象的信号和槽。我们可以通过名称(译者注:字符串)来做到类型安全地建立连接,而并不需要知道我们连接的对象使用的具体类型(译者注:包括对象类型和参数类型),这对于基于模板的解决方案来说是不可能的。这种运行时的自省机制为我们开启了新的可能,例如,可以通过 Qt Designer 的 XML 界面文件来生成图形界面,并完成信号槽的连接。

调用性能并不代表一切

Qt 的信号槽实现并不能和基于模板的解决方案一样快。尽管发射一个信号大约只有四次常规模板函数的调用开销,整个信号槽执行的过程也只被 Qt 尽量压缩到10倍函数调用开销。这并不令人意外,因为 Qt 的机制包括了通用的序列化、自省、不同线程间的队列执行以及极致的脚本化。它并不需要激进的内联和代码展开,但却提供了远超于这些代价的运行时安全性。Qt 的信号分发机制是安全的,而那些速度更快的基于模板的系统则不是。即使在发送一个信号至多个不同的接收者的过程中,您依然可以安全地删除这些接收者,而不会引发程序崩溃。如果没有这份安全性,您的程序可能会偶发性地崩溃,并伴随一个痛苦的调试过程,来修复错误地对已经被释放掉的内存进行的读写操作。

译者注:

本段翻译有待商榷,其中一句

Qt 的信号分发机制是安全的,而那些速度更快的基于模板的系统则不是。

对应的原文是

Qt's iterators are safe while those of faster template-based systems are not.

从字面理解,该句是指 Qt 容器的迭代器。然而结合上下文,该句应该也是用于描述信号槽机制的安全性,所以此处理解为信号分发过程中,遍历分发目标的安全性,即 iteration 操作。

然而,从 Qt 容器安全性上理解也未尝不可。虽然迭代器算法是和容器数据结构强相关,对于相同类型的容器,Qt 迭代器与 STL 迭代器算法本质上并无区别,但 STL 容器是由标准库实现,而不同平台的标准库实现是针对该平台高度特化的,这导致了即使是很规范的 C++ 代码,移植到不同平台后依然可能因为平台差异(如大小端、位宽、对齐)产生 bug——而这不会出现在 Qt 中,因为 Qt 的设计是宁可舍弃平台特化的性能,也要保证兼容性。

尽管如此,难道就不能用基于模板的方案来提升使用信号槽的应用的性能吗?尽管 Qt 的确在信号槽调用中增加了一点点额外开销,这个开销对于槽的整个执行过程只占了很小的比例。只要在槽里做了任何有效的操作,例如一些简单的字符串处理,那么调用时的额外开销就可以忽略不计了。Qt 的系统已经经过充分优化,以至于任何需要new/delete的操作(例如字符串操作或向模板容器中插入/删除对象),都比发射一次信号有更可观的开销。

例外:如果您在某个性能敏感任务的内部循环中使用了信号槽,并且确认它成为性能瓶颈,那么可以考虑将其更换为监听者模式(译者注:更通用的称呼是发布-订阅或生产者-消费者)。在这类场景中,您可能只需要一对一的连接方式。例如,如果有一个对象从网络中下载数据,使用信号来标识需要的数据已经接收到,会是明智的设计;但如果需要将每个字节逐一发送至一个消费者,就应该使用监听者模式而非信号槽。

不受限制

因为有 moc 来实现信号槽,我们可以添加更多模板所不能做到的东西。其中便有带作用域的翻译器,可以通过生成的 tr() 函数使用;还有一个先进的属性系统,具备运行时的自省和类型信息。属性系统具有一个显著的优点:如 Qt Disigner 这类强大而且通用的可视化界面设计工具,如果没有强大的支持反射的属性系统支撑的话,会极其难以编写(如果的确能够编写出来的话)。但不止于此,我们还提供了一个 qobject_cast<T>() 动态转换机制,并且不需要依赖系统的 RTTI 特性,也不需要承担 RTTI 所受的限制,我们使用它来从动态加载的组件中安全地获取接口。另一个应用领域是动态的元对象,例如,我们可以获取一个 ActiveX 组件,并且在运行时为其生成对应的元对象;或者,我们也可以通过导出元对象的形式,将 Qt 组件导出为 ActiveX 组件。使用模板无法做到其中任意一个。

C++ 结合 moc 为我们提供了类似于 Objective-C 或者 JRE(Java Runtime Environment, Java 运行时环境) 的灵活性,但同时依然保留了 C++ 独有的性能与扩展性优势。这让我们如今使用的 Qt 成为了一个灵活而方便的工具。