Qt Creator 源码学习 07:D 指针

按照我们之前的习惯,我们会按照 libs.pro 的SUBDIRS部分的顺序进行阅读。前面一章我们读过 aggregation 子项目,下面则要开始重中之重,extensionsystem。我们曾经介绍过,Qt Creator 的核心是一个插件系统。Qt Creator 的所有功能都是以插件的形式提供的。这个插件系统的实现,就是 extensionsystem。在这一阶段,我们将了解到 Qt Creator 的整个插件系统的基础,以便在我们自己的项目中使用类似的实现。

在 extensionsystem.pro 中,我们可以看到下面的代码:

这里有四个带有图形界面的类,分别是PluginViewPluginErrorViewPluginDetailsViewPluginErrorOverview。顾名思义,这些是有关插件的信息、错误信息、详细列表以及错误概览。目前,这些图形界面类不在我们的探讨范围之内。

Qt Creator 在源代码中使用了大量 D-Pointer 设计,被称为 D 指针,这里有必要先进行一些补充说明,以便更好理解。

D 指针是 C++ 特有的一种“设计模式”,并不适用于 Java 等其它语言(这是与另外一些设计模式区别的地方,因而在之前加上了引号)。D 指针主要为了解决二进制兼容的问题。二进制兼容与源代码兼容,在 KDE Wiki 上面是这样写的:

A library is binary compatible, if a program linked dynamically to a former version of the library continues running with newer versions of the library without the need to recompile.

If a program needs to be recompiled to run with a new version of library but doesn’t require any further modifications, the library is source compatible.

一个动态链接到较早版本的库的程序,在不经过重新编译的情况下能够继续运行在新版本的库,这样的库即“二进制兼容”。

一个程序不需要经过额外的修改,只需要重新编译就能够在新版本的库下继续运行,这样的库被称为“源代码兼容”。

二进制兼容对于库尤其重要,因为你不能指望每次升级了库之后,要求所有使用了这个库的应用程序都要重新编译。就 Qt 而言,假如一个程序使用 Qt 5.5 编译,当 Qt 5.6 发布之后,如果 Qt 的升级能够保持二进制兼容,那么这个程序应该可以直接能够使用 Qt 5.6 正常运行;否则,就需要这个程序重新编译,才能够运行在 Qt 5.6 平台。

要使一个库能够实现二进制兼容,就要求每一个结构以及每一个对象的数据模型保持不变。所谓“数据模型保持不变”,就是不能在类中增加、删除数据成员。这是 C++ 编译器要求的,其根本是保持数据成员在数据模型中的位移保持不变。

例如,我们有这样的类:

Widget包含了一个Rect成员变量,用于保存组件的大小和位置。我们将其编译为 WidgetLib 1.0。在 WidgetLib 1.1 中,有人灵机一动,说“让我们添加一个样式吧”,于是就有了下面的升级:

添加一行很简单,但是,这让我们原本使用 WidgetLib 1.0 能够正常运行的程序 CuteApp,未经编译时,在 WidgetLib 1.1 下运行崩溃了。

为什么?

原因在于,我们添加了一个新的数据成员,从而改变了WidgetLabel对象的大小。当 C++ 编译器生成目标代码时,它使用“偏移量”来访问对象中的数据。我们使用一个简化了的图示来表现这一点:

WidgetLib 1.0 中的 Label 对象 WidgetLib 1.1 中的 Label 对象
m_geometry <offset 0> m_geometry <offset 0>
m_stylesheet <offset 1>
m_text <offset 1>
m_text <offset 2>

在 WidgetLib 1.0 中,Labeltext属性位于偏移量为 1 的逻辑位置。在编译器生成的这个 CuteApp 应用程序代码中,Label::text()函数会返回偏移量为 1 的那个数据成员。在 WidgetLib 1.1 中,由于我们添加了一个成员,Labeltext属性的偏移量变成了 2!但是,我们并没有重新编译 CuteApp,程序依然会返回偏移量为 1 的那个成员,现在却返回了stylesheet

有人可能会问,为什么Label::text()函数计算的偏移量会固定在 CuteApp,而不是在 WidgetLib 中?如果在 WidgetLib 就能确定这个偏移量,岂不是没有这个问题了?因为如果这样,只需要重新编译 WidgetLib 库就可以了。答案是,Label::text()函数是在头文件中定义的,而头文件是要内联到最终的源代码的。

所以,是不是把Label::text()函数的内联属性去除就可以了?也就是把Label::text()函数的实现放到源代码文件中?答案还是不行。C++ 编译器依赖于在编译期和运行期,相同对象的大小是一致的。例如,如果我们在栈上创建一个Label对象,编译器需要在编译期依照Label的大小分配内存空间。但是应用程序在运行期使用的是 WidgetLib 1.1,Label的大小已经不一样了;Label的构造函数会覆盖已有的栈,于是就把栈给破坏了。

现在我们已经知道导致二进制不兼容的原因了,因而也就知道如何保持二进制兼容:那就是,不要改变成员变量。但是,对于一个库来说,这怎么可能呢?

好在我们能想到一个技巧,来规避这一问题。我们让所有的公共类都只持有一个指针,这个指针指向一个包含了所有数据的私有数据结构。这个私有的数据结构可以在未来的版本中任意修改,而且不会影响到使用了这个库的程序,因为这个数据结构是私有的,外部根本不能使用。外部只能看到一个不变的公共类,因为它只持有一个指针。这个指针就是 D 指针。

使用这种思路,可以把上面的代码修改为如下:

那么,我们就来一个 widget_p.h 吧:

于是,源代码文件可以是这样的:

那么,Label就可以是这样的:

Label的实现则是:

观察以上结构,CuteApp 不能直接访问 D 指针。因为 D 指针仅在 WidgetLib 内部使用,并且 WidgetLib 是在每次发布预先编译好的,私有的数据结构就可以任意修改,无需担心影响到 CuteApp。

使用 D 指针,除了二进制兼容,还有很多其它好处:

  • 隐藏实现细节:我们只需要发布 WidgetLib 的头文件和二进制文件,.cpp 文件可以是闭源的
  • 头文件很干净,没有实现细节的干扰,可以作为 API 实现
  • 由于供实现的头文件都被移动到了实现的源代码文件中,编译会更快

现在,我们有一个 D 指针,指向一个 C 风格的数据结构。在实际应用中,这个数据结构会包含私有函数,用于帮助它所在的类。例如,LabelPrivate可能会有getLinkTargetFromPoint()函数,用于获取鼠标点击的链接地址。在很多情况下,这种私有函数都要求访问它所在的公共类。例如,setTextAndUpdateWidget()就需要调用Widget::update(),后者是Widget类的公共函数。为了能够在私有的实现类中访问其所在公共类,我们引入一个 Q 指针:

通过新增加的 Q 指针,我们就可以访问到外部的公共类。

D 指针和 Q 指针的结合使用,才能够发挥其最大的威力。

如果你好奇地读过 Qt 的源代码,可以看到 Qt 的类中有很多Q_D以及Q_Q,这正是 Qt 实现的 D 指针和 Q 指针。Qt 实现的方式比我们上面所说的复杂,但是思路是一致的。感兴趣的话,可以在 Qt 的 Wiki 页面阅读相关内容。

上一篇
下一篇

Comments (8)

  1. Zt 2016年12月7日
    • 豆子 2016年12月7日
  2. blueshake 2017年1月23日
  3. 姜敏 2017年3月16日
    • 豆子 2017年3月16日
  4. clown 2017年3月21日
    • clown 2017年3月21日
  5. clown 2017年3月21日

Leave a Reply