第18.8章 Qt、MFC,以及为什么 GUI 是 C++ 走歪的地方
1. 最被滥用的领域里最被滥用的特性
virtual 和 new 是 C++ 里被滥用得最厉害的特性。
它们被滥用得最厉害的地方是图形用户界面,
而把这件事正常化的两个库就是 Qt 和 MFC。
如果你学 C++ 是为了写桌面应用,你最先被教的几乎肯定是这种风格,
而本章存在的意义就是反对它。
原因是历史性的,而且在 1992 年是合理的:窗口是一种行为随种类变化的东西,
事件从外面到来,最自然的建模工具就是一个带虚事件处理器的基类,
再加上 new,因为控件的寿命比创建它的函数长。
三十年之后,这个决定的代价看得见了,而替代方案(Web 前端加原生服务)当时还不存在。
2. Qt 具体做了什么
把光鲜的部分剥掉,Qt 就是这个:
- 每个控件都继承
QObject, 一个带虚析构函数的多态基类。于是每个控件都在堆上、每个控件都带 vptr、 每个控件都活在父子拥有树里,由父对象替你删子对象 —— 一片手搭的unique_ptr森林,同样的问题,更长的生命周期。 Q_OBJECT和moc。 Qt 的元对象系统由一个外部的代码生成器产出:它解析你的头文件, 生成staticMetaObject—— 一张类名字符串表、一张方法表、 一张信号/槽表,以及一个指向基类元对象的指针。 那是一套手写的、基于字符串的 RTTI,存在于你每一个类里, 而且它依赖一个必须在编译器之前跑完的构建步骤。qobject_cast就是 Qt 的down_cast。 公平地说,它比dynamic_cast便宜:它沿着staticMetaObject指针链走,而不是 C++ 的 typeinfo 图, 方向是对的(第18.5章)。 它同时是以字符串为键的、只能作用于QObject、 而且需要 moc 表存在 —— 于是你的程序里有了第二套分派和类型系统, 就摆在 C++ 那套旁边。- 信号与槽是在这一切之上的动态绑定层,每个对象一张运行时连接表。
- 整个程序一个 ABI。Qt 不是 header-only, 也不能当值库消费:你的二进制链接到一个固定版本的 Qt、用一个编译器、 一套选项构建,而在一个进程里混两个 Qt 构建在构造上就不被支持。
这些都不是无能。这就是一个 1995 年的设计被维护到 2026 年的代价, 而语言至今仍然表达不出“一列回调和一枚类型标签”。
3. MFC 具体做了什么
CObject和CRuntimeClass: MFC 手写的 RTTI,由DECLARE_DYNAMIC声明、IMPLEMENT_DYNAMIC定义,用IsKindOf()查询。 那是一串类描述符链表,在运行时按名字比较。- 消息映射而不是虚函数来处理窗口消息:宏构建出把消息 ID
映射到成员函数的静态表。第二套分派机制摆在 vtable 旁边,
因为虚函数太慢、也太不灵活,没法路由
WM_*消息。 - 文档/视图、到处
new、并且在构造上只能跑在 Windows 上。 - 没有分离。UI 类就是逻辑类,于是整个程序没有消息泵就没法测试。
MFC 是这门语言里最清晰的警示故事:它发明了自己的 RTTI
(CRuntimeClass)、自己的分派(消息映射)、自己的拥有模型,
每一个都是为了绕开 C++ 对象模型的某一项代价,
而每一个都变成了永久的、不可移植的、不可测试的一层。
4. 账单
| 代价 | 来自哪里 |
|---|---|
| 二进制在你写下第一行代码之前就有几十 MB | 框架连同它所有控件、资源、元对象,以及每个虚函数(不管调没调用) 一起被静态链接进来(第18.4章) |
| 每个控件一次堆对象,再加一棵父树来释放它们 | 每个构造函数里一个 new,拥有关系被推进基类 |
| 一个 ABI、一个编译器、一套选项,永远 | 带虚接口的、非 header-only 的 C++ |
| 两套类型系统:C++ 的和框架的 | moc/staticMetaObject 与 CRuntimeClass |
| 不可测的 UI 逻辑 | 逻辑住在需要窗口、设备和消息泵的虚处理器里 |
| UI 不重新发一个二进制就没法更新 | 界面被编译进程序里 |
5. 我们改做什么:PWA 加原生服务
沿着一条和 C++ 毫无关系的线,把程序切成两半:
- 用户界面是一个 Web 应用 —— HTML、CSS、JavaScript, 一个像原生应用一样安装、并且能自我更新的渐进式 Web 应用(PWA)。 里面没有 C++、没有框架 ABI、没有虚分派, 而且它跑在一个比任何原生工具包都安全得多的沙箱里。
- C++ 程序是一个服务 —— 一个本地或远程进程,
带一个窄接口(HTTP、socket 协议、共享内存环形缓冲区),
由值、类型擦除和
fast_io搭建起来。 它能用文本客户端测试,毫秒级启动,而且换掉 UI 不用重编它。
这不是妥协,在本章测量过的每一条轴上它都严格更好:
没有每个控件一次 new、没有虚层次、没有 RTTI、
没有框架 ABI、没有额外的类型系统,而且界面独立于二进制发版。
第21章讲的是把后半部分 —— 服务 —— 做成一个能安装、能离线工作的 PWA
(第21章 PWA)。
如果你确实需要一个原生窗口,就直接用平台 API
(Windows 上的 CreateWindowEx 加消息循环,Linux 上的
Wayland/X11),放在你自己的薄包装后面。那是几百行,
没有对象模型、没有 moc、没有第二套 RTTI,
而 fast_io 已经给了你驱动它所需的文件句柄和
io_uring/IOCP 管道
(第11.14章)。
6. 经验法则
- 不要在新的程序上搭 Qt 或 MFC。两者都是靠惯性活着的 1990 年代对象模型; 你会继承本章所有的问题,而拿不到任何性能。
- 如果职位描述写着“Qt 开发”,请理解你会被雇去维护一个虚层次和一个 moc 构建步骤 —— 而这些技能在 C++ 的其他地方都不通用。
- 如果今天需要跨平台 UI:Web 前端加原生服务。如果需要原生 UI: 平台 API 加薄包装。
- 把 UI 完全挡在 C++ 程序的类型系统之外。一旦
QWidget *出现在函数签名里,你的库就依赖 Qt,你的测试就需要显示器。 - 当你必须消费某个框架时,把它关起来:一个翻译单元、一个适配器类、 边界上是值。永远不要让它的指针逃进你自己的 API。