两样东西共用代码,是关于代码的一个事实。它们该不该共用一个包,则是一个分发上的决定。而只要「抽出来」开始显得像是理所当然的下一步——干净、整齐,是那种评审的人点点头、不会追问「为什么是现在」的改动——这两件事就会被混为一谈。
讲解引擎需要的渲染管线,和同一个仓库里那个桌面批量渲染 GUI 已经在用的是同一条:同一套作业模型,同一个子进程 runner,同一份命令行构造逻辑。下意识的动作,是开一个新的顶层包——两边都安装它,两边的应用层都不再直接互相引用。我去算了一下这件事今天究竟能换来什么,结果比它看上去的要少。
抽出来要付什么,又换不到什么
把这些共用代码从 GUI 的包底下挪出来,是纯机械的操作:动几行 import,零行为变化。它换不到的,是安装层面的解耦。今天这两个使用方是从同一个分发包里出来的,所以下游没有任何办法只装渲染管线而不把 GUI 的 Qt 依赖一起拖进来——不管 import 路径指向的是 GUI 自己的命名空间,还是一个中立的新名字。改名字治的是坏味道,不是约束。作为一次重构的动机,这是真实的;但要在有什么东西逼着你回答这个问题之前,专门花一整个工作段去做,它就太单薄了。
这个决定真正钉住的是什么
真正值得写下来的那部分,不是「就先放着不动」——那是个非决定,和压根没想过这件事没有区别。值得写下来的,是把边界划到足够精确、精确到可以被强制执行,并且把那唯一一个例外指名道姓写出来,而不是让它继续心照不宣地待着。这个仓库是私有的,所以不像公开仓库那样有一行代码可以点进去看;下面这几段是逐字引用、而不是转述的(原文为英文)——因为对一个别人根本无从自行核对的说法来说,转述是最不该拿出来充当证据的东西:
app/render是共用的渲染核心。app/ui和explainer/可以依赖它;任何东西都不得反向依赖app/ui或explainer/。这个核心不含 Qt,唯一的例外是
app/render/worker.py——它被明确定义为 GUI 侧的 Qt 适配层(QThread)。explainer/永远不得 importapp.render.worker,核心里其他任何模块也同样不得 import 它(它不属于核心对外的 API)。
「不含 Qt,只有一个指名的例外」和「我们尽量让核心不含 Qt」,是两种不同性质的说法:前者可核对,后者只是一种态度。一个自动化检查会解析每个文件里真实的 import——普通的 import 语句、from ... import、以及参数是常量字符串的动态 import——一旦 explainer/ 伸手去拿渲染核心以外、属于 GUI 命名空间的东西,一旦核心里除那个指名的例外之外有谁 import 了 Qt,一旦核心里有谁反向伸回 GUI 层,构建就失败。这条边界不是一句谁都能悄悄绕过去的注释;它是一条谁想绕过去、就得先让构建红掉的规则。
触发条件:在需要它之前就定好
比边界更要紧的,是「当边界不够用了会怎样」。与其把这个判断留给第一个撞上它的人——还得在对方当时正顶着的任何截止日期底下做——这个决定选择现在就把触发条件写明:
触发条件(事先定好,不再重开辩论):当渲染核心出现现有两个(
app/ui、explainer/)之外的新使用方,或者分发本身发生拆分(例如要发一个不带 Qt 依赖的 explainer)时,就抽出render_core/。这大约是一小时的机械搬运。
两个条件都没有触发。第三个使用方并不临近,而一份打包配置至今同时发布着两个入口。所以代码就留在原处,引擎也继续从一个明显属于 GUI 的命名空间里 import——在触发条件真的成立之前,这一点读起来都会有点别扭。这是一项已经交代过的代价,不是疏忽:另一个选项是今天就把真实的时间花在机械性的搬运上,去换一份现在还兑不出来的收益。
这笔权衡的适用范围超出 Qt 这些具体细节。「这个该不该抽出来」有一个比它平时被问到的形式更锋利的问法——不是「这样是不是更干净」(答案几乎永远是「是」),而是「具体要有什么变成真的,现在这个形状才算错」。趁着心平气和的时候定一次,好让日后的某一次工作只需要去核对一个条件,而不必在某种让这个问题显得紧急的压力底下,从头把一套架构重新辩论一遍。