写给程序员的可逆计算理论辨析
可逆计算理论是 Docker、React、Kustomize 等一系列基于差量的技术实践背后存在的统一的软件构造规律,它的理论内容相对比较抽象,导致一些程序员理解起来存在很多误解,难以理解这个理论和软件开发到底有什么关系,能够解决哪些实际的软件开发问题。在本文中,我将尽量采用程序员熟悉的概念讲解差量以及差量合并的概念,并分析一些常见的理解为什么是错误的。
如果对可逆计算理论不了解,请先阅读文章
一. 从 Delta 差量的角度去理解类继承
首先,Java 的类继承机制是内置在 Java 语言中的一种对原有逻辑进行 Delta 修正的技术手段。例如
很多人对可逆的概念都感到很难理解,什么是差量,到底怎么逆,逆向执行吗?这里说的可逆不是运行期的逆向执行,而是在程序的编译期所进行的结构变换。比如说我们可以通过类继承的方式在不修改基类源码的情况下改变类的行为。
同样的 CrudBizModel 类型,如果实际对应的 Java 类不同,则执行的业务逻辑就不同。继承可以看作是向已经存在的基类补充 Delta 信息。
B = A + Delta, 所谓的 Delta,就是在不修改 A 的情况下向 A 补充一些信息,将它转化为 B。
可逆指的是我们可以通过 Delta 来删除基类中已经存在的结构。
当然 Java 本身并不支持通过继承来删除基类中的方法,但是我们可以通过继承将该函数重载为空函数,然后可以寄希望于运行期的 JIT 编译器能够识别这个情况,从而在 JIT 编译结果中完全删除这个空函数的调用,最终达到完全删除基类结构的效果。
还可以举另外一个例子,假设我们已经编写了一个银行核心系统,在某个银行部署的时候客户要求进行定制化开发,要求删除账户上某些多余的字段,同时要增加一些行内业务要用到的定制字段。此时,如果不允许修改基础产品中的代码,我们可以采用如下方案
我们可以增加一个扩展账户对象,它从原有的账户对象继承,从而具有原有账户对象的所有字段,然后在扩展对象上我们可以引入扩展字段。然后我们在 ORM 配置中使用扩展对象,
以上配置表示保持原有的实体名不变,将实体所对应的 Java 实体类改成 BankAccountExt。这样的话,如果我们此前编程中创建实体对象的时候都是使用如下方法
则我们实际创建的实体对象是扩展类对象。而且因为 ORM 引擎内部知道每个实体类名所对应的具体的实现类,所以通过关联对象语法加载的所有 Account 对象也是扩展类型。
原有的代码使用 BankAccount 类型不需要发生改变,而新写的代码如果用到扩展字段,则可以将 account 强制转型为 BankAccountEx 来使用。
关于删除字段的需求,Java 并不支持删除基类中的字段,我们该怎么做呢?实际上我们可以通过定制 ORM 模型来实现某种删除字段的效果。
根节点上的x:extends="super"
表示继承基础产品中的 ORM 模型文件(如果不写,则表示新建一个模型,完全放弃此前的配置)。字段 phone3 上标记了x:override="remove"
,它表示从基础模型中删除这个字段。
如果在 ORM 模型中删除了字段,则 ORM 引擎就会忽略 Java 实体类上的对应字段,不会为它生成建表语句、Insert 语句、Update 语句等。这样的话,从实际的效果上说,就达到了删除字段的效果。
无论如何操作被删除的字段 phone3,我们都观测不到系统中有任何的变化,而一个无法被观测、对外部世界也没有影响的量,我们可以认为它是不存在的。
更进一步的,Nop 平台中的 GraphQL 引擎会自动根据 ORM 模型来生成 GraphQL 类型的基类,因此如果 ORM 模型中删除了某个字段,则自动的在 GraphQL 服务中也会自动删除这个字段,不会为它生成 DataLoader。
Trait: 独立存在的 Delta 差量
class B extends A
是在类 A 的基础上补充 Delta 信息,但是这个 Delta 是依附于 A 而存在的,也就是说 B 中所定义的 Delta 是只针对 A 而实现的,脱离了 A 的 Delta 没有任何意义。 鉴于这种情况,有些程序员可能对"可逆计算理论要求差量独立存在"这一点感到疑惑,差量不是对 base 的修改吗,它怎么可能脱离 base 而独立存在呢?
带着这个疑问,我们来看一下 Scala 语言的核心创新之一:Trait 机制,关于它的介绍可以参见网上的文章,例如 Scala Trait 详解(实例)。
trait HasRefId 相当于是一种 Delta,它表示为基础对象增加一个 refAccountId 属性,我们在声明 BankAccountEx 类时只需要混入这个 trait 就可以自动实现在 BankAccount 类的基础上增加属性。
需要特别注意的是, HasRefId 这个 trait 是独立编译、独立管理的。也就是说,即使编译的时候没有 BankAccount 对象,HasRefId 这个 trait 也是具有自己的业务含义,可以被分析、存放的。而且我们注意到,同样的 trait 可以作用于多个不同的基础对象,它并不和某个基类绑定。例如上面的 BanCardEx 也混入了同样的 HasRefId。
在编程的时候,我们也可以针对 trait 类型编程,不需要使用到任何 base 对象的信息
上面的函数接收一个参数 acc,只要求 acc 满足两个 trait 的结构要求。
如果从数学的角度上分析一下,我们会发现,类的继承对应于 B > A , 表示 B 比 A 多,但是多出来的东西并没有办法被独立出来。但是 Scala 的 trait 相当于是 B = A with C, 这个被明确抽象出来的 C 可以应用到多个不同的基类上,比如 D = E with C 等。在这个意义上,我们当然可以说 C 是独立于 A 或者 E 而独立存在的。
Scala 的这个 trait 机制后来被 Rust 语言继承并发扬光大,成为这个当红炸子鸡的所谓零开销抽象的独门秘技之一。
DeltaJ: 具备删除语义的 Delta 差量
从 Delta 差量的角度看,Scala 的 trait 的功能并不完整,它无法实现删除字段或者函数的功能。德国的一个教授 Shaefer 察觉到软件工程领域中删除语义的缺乏,提出了一种包含删除操作的 Delta 定义方式:DeltaJ语言,并提出了 Delta Oriented Programming 的概念。
详细介绍可以参见 从可逆计算看Delta Oriented Programming
Delta 合并与继承的区别
可逆计算理论中提出的 Delta 合并算子类似于继承概念,但是又有着一些本质性的区别。
传统的编程理论非常强调封装性,但是可逆计算是面向演化的,而演化必然是破坏封装性的。在可逆计算理论中,封装性并没有那么重要,所以 Delta 合并可以具有删除语义,并且是把基础模型作为白盒结构来看待,而不是不可被分析的黑盒对象。Delta 合并对封装性的最终破坏程度由 XDef 元模型约束来限制,避免它突破最终的形式约束。
继承会产生新的类名,而原有类型指向的对象结构并不会发生变化。但是根据可逆计算理论设计的 Delta 定制机制相当于是直接修改模型路径所对应的模型结构,并不会产生新的模型路径。例如对于模型文件/bank/orm/app.orm.xml,我们可以在 delta 目录下增加一个相同子路径的文件来覆盖它,然后在这个文件中再通过x:extends="super"
来继承原有的模型。所有使用/bank/orm/app.orm.xml 的地方实际装载的将是定制后的模型。
因为 Delta 定制并不会改变模型路径,所以所有根据模型路径和对象名建立的概念网络都不会因为定制而产生扭曲和移动,它保证了定制是一种完全局域化的操作。可以想见,如果是一般的面向对象继承,在不修改源码的情况下我们不可能在局部把硬编码的基类名替换成派生类的类名,这样就只能扩大重载范围,比如重载整个函数,替换整个页面等。很多情况下,我们都无法有效控制局部需求变化的影响范围,我们还为这种现象起了一个名字:抽象泄露。一旦抽象泄露,就可能出现影响范围不断扩大,最终甚至导致架构崩溃。
Delta 定制与继承的第三个区别在于继承定义在短程关联之上。面向对象的继承在结构层面可以被看作是 Map 之间的覆盖:每个类相当于是一个 Map,它的 key 是属性名和方法名。Map 是一种典型的短程关联,它只有容器-元素这样一级结构关系。而 Delta 定制是定义在树形结构这一典型的长程关联之上:父节点控制着所有递归包含的子节点,如果删除了父节点,所有递归包含的子节点都会被删除。Delta 定制在结构层面可以被看作是树形结构之间的覆盖:Tree = Tree x-extends Tree。在后面的章节中,我会在理论层面解释树形结构相比于 Map 结构的优势之处。
二. Docker 作为可逆计算理论的实例
可逆计算理论指出,在图灵机理论和 Lambda 演算理论之外,存在着第三条通向图灵完备的中间路径,我们可以用一个公式来表达这条技术路线
x-extends 是一个词,它表示对面向对象的 extends 机制的一种扩展。有些人可能把它误认为 x 减去 extends,结果导致非常困惑。
Generator<DSL>
是一种类似泛型的写法,它表示 Generator 采用类似C++模板元编程的技术,在编译期将 DSL 作为数据对象进行加工转换,动态生成 Delta 所将要覆盖的基类。一个复杂的结构化的类型声明如果进一步引入执行语义就会自动成为 DSL(Domain Specific Language),所以 Generator 相当于是一个模板宏函数,它接受一个类似类型定义的 DSL,在编译期动态生成一个基类。
Docker 镜像的整体构造模式可以看作是
DockerFile 就是一种 DSL 语言,而 Docker 镜像的 build 工具相当于是一种生成器,它解释 DockerFile 中定义的 apt install 等 DSL 语句,动态将它们展开为硬盘上对文件系统的一种差量化修改(新建文件、修改文件、删除文件等)。
OverlayFS 是一种堆叠文件系统,它依赖并建立在其它的文件系统之上(例如 ext4fs 和 xfs 等等),并不直接参与磁盘空间结构的划分,仅仅将原来底层文件系统中不同的目录进行 “合并”,然后向用户呈现,这也就是联合挂载技术。OverlayFS 在查找文件的时候会先在上层找,找不到的情况下再到下层找。如果需要列举文件夹内的所有文件,则会合并上层目录和下层目录的所有文件统一返回。如果用 Java 语言实现一种类似 OverlayFS 的虚拟文件系统,结果代码就类似于Nop平台中的DeltaResourceStore。OverlayFS 的这种合并过程就是一种标准的树状结构差量合并过程,特别是我们可以通过增加一个 Whiteout 文件来表示删除一个文件或者目录,所以它符合x-extends
算子的要求。
Docker 镜像与虚拟机增量备份的对比
有些程序员对于 Docker 技术存在误解,认为它就是一种轻量级的虚拟化封装技术或者说是一种很方便的应用打包工具,与 Delta 差量有什么关系呢?当然,从使用层面上说 Docker 确实相当于是一种轻量级的虚拟机,但关键是它是凭借什么技术实现轻量级的?它作为一种打包工具与其他的打包工具相比有什么本质上的优势?
在 Docker 之前,虚拟机技术就可以实现增量备份,但是虚拟机的增量是定义在字节空间中,虚拟机的增量文件在脱离了基础镜像的情况下是没有业务含义的,也不能为独立的被构造、独立的被管理。而 Docker 则不同,Docker 镜像的 Delta 差量是定义在文件系统空间中,所谓的 Delta 的最小单位不是字节而是文件。比如说,如果我们现在有一个 10M 的文件,如果我们为这个文件增加一个字节,则镜像会增大 10M,因为 OverlayFS 要经历一个copy up过程,将下层的整个文件拷贝到上层,然后再在上层进行修改。
在 Docker 所定义的差量空间中,半个文件 A+半个文件 B 不是一个合法定义的差量,我们也不可能用 Docker 的工具构造出这样一个差量镜像出来。所有的镜像中包含的都是完整的文件,它的特殊性在于还包含某种负文件。例如,某个镜像可以表示(+A,-B)
,增加文件 A,同时删除文件 B,而修改文件的某个部分这一概念无法被直接表达,它将被替换为增加文件 A2。A2 对应于修改后产生的结果文件。
Docker 镜像是独立于基础镜像存在的 Delta 差量,我们可以在完全不下载基础镜像的情况下独立的制作一个应用镜像。实际上 Docker 镜像就是一个 tar 文件,用 zip 工具打开之后我们会看到每一层都对应于一个目录,我们只要通过拷贝操作将文件拷贝到对应目录中,然后计算 hash 码,生成元数据,再调用 tar 打包就可以生成一个镜像文件了。对比虚拟机的字节空间中的差量备份文件,我们缺少合适的、针对虚拟机字节空间的、稳定可靠的技术操作手段。而在文件系统空间中,所有的生成、转换、删除文件的命令行程序都自动成为这个 Delta 差量空间中的变换算子。
在数学上,不同的数学结构空间中它们允许存在的算子的丰富性是不同的。如果是一个贫瘠的结构空间,例如字节空间,那么我们就缺少一些强大的结构变换手段。
Docker 镜像与 Git 版本的对比
有些同学可能会疑惑,Docker 的这种差量是否与 Git 是类似的?确实,Git 也是一种差量管理技术,但是它所管理的差量是定义在文本行空间中的。这两种技术的区别从可逆计算的角度去看,只在于它们对应的差量空间不同,进而导致这两个空间中存在的算子(Operator)不同。Docker 所对应的文件系统空间可用的算子特别多,每个命令行工具都自动成为这个差量空间上的合法操作。而在文本行空间,如果我们随便操作,很容易就导致产生的源码文件语法结构出现混乱,无法通过编译。因此,在 Docker 的差量空间中,我们有很多的 Generator 可以生成 Delta,而在 Git 的差量空间中,所有的变更操作都是由人手工完成的,而不是由某个程序自动生成的。比如说我们要给某个表增加字段 A,我们是手工修改源码文件,而不是期待通过某个 Git 集成的工具自动对源码进行修改。有趣的一点是,如果我们为 Git 配备一个结构化的比较与合并工具,比如集成 Nop 平台的 Delta 合并工具等,则可以将 Git 所面对的 Delta 空间修改为 DSL 所在的领域模型空间,而不再是文本行空间。在领域模型空间中,通过 Delta 合并算子进行的变换可以保证结果格式一定是合法的 XML 格式,而且所有的属性名、节点名都是 XDef 元模型文件中定义的合法名称。
再次强调一下,差量概念是否有用关键在于它到底定义在哪个差量模型空间中,在这个空间中我们能够建立多少有用的差量运算关系。在一个贫瘠的结构空间中定义的差量并没有多大的价值,差量与差量并不是生而平等的。
有些人可能怀疑 Delta 差量是不是就是给一个 json 加上版本号,然后用新版本替换旧版本?问题没有这么简单。差量化处理首先要定义差量所在的空间。在 Nop 平台中差量是定义在领域模型空间,而不是文件空间中的,不是说整个 JSON 文件加个版本号,然后将整个 JSON 文件替换为新的版本,而是在文件内部我们可以对每一个节点、每一个属性进行单独的差量定制,也就是说在 Nop 平台的差量空间中,每一个最小的元素都是具有领域语义的业务层面上稳定的概念。另外一个区别在于,根据 XDSL 规范,Nop 平台中所有的领域模型都支持x:gen-extends
和x:post-extends
编译期元编程机制,可以在编译期动态生成领域模型,然后再进行差量合并。这样整体上可以满足 DSL = Delta x-extends Geneator<DSL0>
的计算范式要求。很显然,一般的 JSON 相关技术,包括 JSON Patch 技术并没有内置的 Generator 的概念。
三. Delta 定制的理论和实践意义
基于可逆计算理论的指导,Nop 平台在实操中可以做到的效果就是: 一个复杂的银行核心应用可以在完全不修改基础产品源码的情况下,通过 Delta 定制进行定制化开发,为特定的银行实现完全定制的数据结构、后台逻辑、前端界面等。
从软件工程的角度去理解,可逆计算理论解决了粗粒度的系统级软件复用的问题,即我们可以复用整个软件系统,不需要把它拆解为分立的模块、组件。组件技术在理论方面存在缺陷,所谓的组件复用是相同可以复用,我们复用的是 A 和 B 之间的公共的部分,但是 A 和 B 的公共部分是比 A 和 B 都要小的,这直接导致组件技术的复用粒度无法扩展到比较宏观的层次。因为一个东西的粒度越大就越难找到和它完全一样的东西。
可逆计算理论指出 X = A + B + C, Y = A + B + D = X + (-C + D) = X + Delta,在引入逆元的情况下,任意的 X 和任意的 Y 都可以建立运算关系,从而在不修改 X 的情况下,可以通过补充 Delta 信息实现对 X 的复用。也就是说,可逆计算理论将软件的复用原理从"相同可复用"扩展到了"相关可复用"。组件的复用是基于整体-部分之间的组合关系,而可逆计算指出对象之间除了组合关系之外还可以建立更灵活的转化关系。
Y = X + Delta, 在可逆计算的视角下, Y 是在 X 上补充 Delta 信息得到的,但是它可能比 X 更小,而不是说它一定比 X 要大,这个观点和组件理论有着本质性区别。想象一下 Y = X + (- C) 表示从 X 中删除一个 C 得到 Y。实际得到的 Y 是比 X 更小的结构。
美国的卡内基梅隆大学软件工程研究所是软件工程领域的权威机构,它们提出了一个所谓的软件产品线工程理论,指出软件工程的发展历程就是不断提升软件复用度,从函数级复用、对象级复用、组件级复用、模块级复用,最终实现系统级复用的发展历程。软件产品线理论试图为软件的工业化生产建立理论基础,可以像工业生产线一样源源不断的生成软件产品,但是它并没有能够找到一种很好的技术手段能够以很低廉的成本实现系统级复用。软件产品线传统的构建方式要使用到类似 C 语言的宏开关的机制,维护成本很高。而可逆计算理论相当于是为落实软件产品线工程的技术目标提供了一条可行的技术路线。具体分析可以参见 从可逆计算看Delta Oriented Programming
为了具体说明如何进行 Delta 定制,我提供了一个示例工程 nop-app-mall, 介绍文章 如何在不修改基础产品源码的情况下实现定制化开发演示视频 B站
Delta 定制与插件化的区别
有些程序员一直有疑问,我们传统的"正交分解"、"模块化"、"插件系统"按照功能进行聚类,将相关功能集合为一个库、包等,不也能实现复用吗?可逆计算的复用有什么特殊之处?差别就在于复用的粒度不同。传统的复用方式无法实现系统级别的复用。想象一下,系统中包含 1000 多个页面,某个客户说我要在页面 A 上增加按钮 B,删除按钮 C,使用传统的复用技术怎么做?为每一个按钮都写一个运行时控制开关吗?如果不修改页面对应的源码,能实现客户的需求吗?如果后来基础产品升级了,它在前台页面中增加了一个新的快捷键操作方式,我们的定制代码是否能够自动继承基础产品已经实现的功能,还是必须由程序员手工进行代码合并?
传统的可扩展技术依赖于我们对未来变化点的可靠预测,比如插件系统我们必须要定义插件到底挂接在哪些扩展点。但现实情况是,我们不可能把系统中所有可能扩展的地方都设计成扩展点,比如说我们很难为每个按钮的每个属性都设计一个开关控制变量。缺少细粒度的开关很容易导致我们的扩展粒度因为技术受限而变大,比如客户只是要修改一下某个界面上的某个字段的显示控件,结果我们必须要定制整个页面,本来是一个字段级别的定制问题因为系统缺少灵活定制的能力而不得不上升为页面级别的定制问题。
K8s 在 1.14 版本之后力推所谓的 Kustomize 声明式配置管理技术,这是为了解决类似的可扩展性问题而发明的一种解决方案,它可以被看作是可逆计算理论的一个应用实例,而且基于可逆计算理论我们还很容易的看出 Kustomize 技术未来可能的改进方向。具体参见 从可逆计算看kustomize
Delta 差量与数据差量处理的关系
Delta 差量的思想其实在数据处理领域并不罕见。比如说
数据存储领域使用的LSM树(Log-Structured-Merge-Tree),它就相当于是按照分层的方式进行差量管理,每次查询数据的时候都会检查所有的层,合并差量运算结果之后返回给调用者。而 LSM 树的压缩操作可以看作是对 Delta 进行合并运算的过程。
MapReduce 算法中的Map端Combiner优化可以看作是利用运算的结合律对 Delta 差量进行了预合并,从而减轻 Reduce 阶段的负担
事件溯源(Event Sourceing)架构模式中我们将针对某个对象的修改历史记录下来,然后在查询当前状态数据时通过 Aggregate 聚合操作合并所有 Delta 修改记录,得到最终结果。
大数据领域中目前最热门的所谓流批一体,流表二象性(Stream Table Duality)。我们对表的修改将会通过 binlog 成为 Delta 变更数据流,而把这些 Delta 合并在一起得到的快照就是所谓的动态表。
数仓领域的 Apache Doris 内置了所谓的Aggregate数据模型,在导入数据的时候就执行 Delta 差量预合并计算,从而极大的减轻查询时的计算量。而 DataBricks 公司直接把它的数据湖技术核心命名为Delta Lake,在存储层直接支持增量数据处理。
甚至在前端编程领域,所谓的 Redux 框架,它的具体做法也就是把一个个的 action 看作是对 State 的差量化变更,通过记录所有这些 Delta 实现时光旅行。
程序员们现在已经习惯了不可变数据的概念,因此在不可变数据的基础上发生的变化的数据很自然的就成为了 Delta。但是正如我在此前的文章中指出的,数据和函数是对偶的关系,数据可以看作是作用于函数之上的泛函(函数的函数),我们同样需要建立不可变逻辑的概念。如果我们把代码看作是逻辑的一种资源化表示,那么我们应该也可以对逻辑结构进行 Delta 修正。大部分程序员现在并没有意识到逻辑结构也是像数据一样可以被程序操纵,并通过 Delta 修正来调整的。 Lisp 语言虽然很早就确立了“代码即数据”的设计思想,但是它并没有进一步提出一种系统化的支持可逆差量运算的技术方案。
在软件领域的实践中,Delta、差量、可逆等概念的应用正越来越多,在 5 到 10 年内,我们可以期待整个业界发生一次从全量到差量的概念范式转换,我愿将它称之为差量革命。
有趣的是,在深度学习领域,可逆、残差连接等概念已经成为标准理论的一部分,而神经网络的每一层结构都可以看作是 Y = F(X) + Delta 这样一种计算模式。
四. 可逆计算理论的概念辨析
什么是领域模型坐标系
我在介绍可逆计算理论的时候会反复提及领域模型坐标系的概念,Delta 差量的独立存在隐含的要求领域坐标的稳定存在。那么,什么是领域坐标?一般的程序员所接触到的坐标只有平面坐标、三维坐标等,可能对于抽象的、数学意义上的坐标概念感到难以理解。下面,我将详细解释一下可逆计算理论中的领域坐标概念到底包含什么内容,它的引入又会给我们的世界观造成什么不一样的影响。
首先,在可逆计算理论中我们谈到坐标,指的是存取值的时候所使用的某种唯一标识,对于任何支持如下两个运算的唯一标识,我们都可以认为它是一个坐标:
value = get(path)
set(path, value)
而所谓的一个坐标系统,就是为系统中涉及到的每一个值都赋予一个唯一的坐标。
具体来说,对于如下的一个 XML 结构,我们可以将它展平后写成一个 Map 形式
对应于
每一个属性值都有一个唯一的对应的 XPath 可以直接定位到它。通过调用 get(rootNode, xpath)我们可以读取到对应属性的值。在 MangoDB 这种支持 JSON 格式字段的数据库中,JSON 对象实际上就是被展平成类似的 Map 结构来存储,从而可以为 JSON 对象中的值建立索引。只不过 JSON 对象的索引中使用的是 JSON Path 而不是 XPath。这里的 XPath 就是我们所谓的领域坐标。
XPath 规范中规定的 XPath 具备匹配多个节点的能力,但是在 Nop 平台中我们只使用具有唯一定位功能的 XPath,而且对于集合元素我们只支持根据唯一键字段来定位子元素。
对于上面的 Map 结构,我们也可以把它简写为多维向量的形式:
我们只需要记住这个向量的第一个维度对应于/@name
处的值,而第二个维度对应于/@table
处的值,依此类推。
可以想象一下,所有可能的 DSL 所构成的坐标系实际上是一个无限维的向量空间。例如,一个列表中可以增加任意多条子元素,那么对应到领域坐标系的向量表示中就可能对应无穷多个不同的变化维度。
如果把 DSL 模型对象看作是定义了一个领域语义空间,那么 DSL 描述中的每个值现在就是在这个语义空间中的某个位置处的值,而这个位置所对应的坐标就是 XPath,它的每个部分都是领域内部有意义的概念,因为整个 XPath 在领域语义空间中也具有明确的业务含义,所以我们将它简称为领域坐标,强调它是在领域语义空间中具有领域含义的坐标表示。与此相反,Git Diff 中我们定位差异时使用的坐标是哪个文件的哪一行,这个坐标表示是与具体业务领域中的领域概念完全无关的,因此我们说 Git 所使用的 Delta 差量空间不是领域语义空间,它所使用的定位标识也不是领域坐标。
在物理学中,当我们为相空间中的每一点都指定一个坐标以后,就从牛顿力学的基于质点的世界观转向了所谓的场论的世界观。后续电动力学、相对论、量子力学的发展所采用的都是场论的世界观。简单的说,在场论的世界观下,我们关注的重点不再是单个对象怎么和其他对象发生相互作用,而是在一个无所不在的坐标系中观察对象上的属性值如何在给定坐标点处发生变化。
基于领域坐标系的概念,无论业务逻辑如何发展,我们描述业务所用的 DSL 对象在领域坐标系中一定是具有唯一的表示的。比如最初 DSL 对应的表示是 ['MyEntity','MY_ENTITY', 'status','VARCHAR',10],后来演化成了 ['MyEntity','MY_ENTITY', 'status','VARCHAR',20],这个 20 所对应的坐标是 "/columns/column[@name='status']/@length",因此它表示我们将 status 字段的长度值调整到了 20。
当我们需要对已有的系统进行定制的时候,只需要在领域模型向量中找到对应的位置,直接修改它的值就可以了。这就类似于我们在一个平面上根据 x-y 坐标找到对应的点,然后修改这个位置处的值。这种定制方式完全不依赖于系统内部是否已经内置了某种扩展接口、插件体系。因为所有的业务逻辑都是在领域坐标系中进行定义的,所有的业务逻辑变化都是建立在领域坐标基础上的一个 Delta 差量。
Delta 合并满足结合律的证明
函数式编程语言中有一个出身很高贵的概念:Monad,是否理解这个概念是判断函数式爱好者是否已经入门的标志性事件。Monad 从抽象数学的角度去理解,基本对应于数学和物理学中的半群概念,即具有单位元且满足结合律。所谓的结合律指的是运算关系的结合顺序不影响最终结果:
这里的运算关系采用加号来表示有些误导,因为加法满足交换律(a + b = b + a),但是一般的结合运算并不需要满足交换律,比如函数之间的复合关系就满足结合律。为了避免误解,下面我会使用符号⊕ 来表示两个量之间的结合运算关系。
关于 Monad 的知识,可以参考我的文章 写给小白的Monad指北。曾有网友反映这是全网最通俗易懂的关于 State Monad 的介绍。
首先,我们可以证明:如果一个向量的每个维度都满足结合律,则整个向量之间的运算也满足结合律。
考虑到上一节中我们对于领域坐标系的定义,为了证明 Delta 合并满足结合律,我们只需要证明在单个坐标处的合并运算满足结合律即可。
最简单的情况是我们常见的覆盖更新:每次运算都是用后面的值覆盖前面的值。我们可以选择用一个特殊的值来表示删除,这样的话就可以将删除也纳入到覆盖更新的情况中来。如果后面的值表示删除,则无论前面的值是什么,最终的结果都是删除。数据库领域的 BinLog 机制其实就是采用的这种做法:每次对数据库行的修改都会产生一条变更记录,变更记录中记录了行的最新的值,只要接收到变更记录,我们就可以放弃此前的值。在数学上它对应于 A ⊕ B = B ,显然
覆盖操作是满足结合律的。
另外一个稍微复杂一些的结合运算是类似 AOP 的运算,我们可以在基础结构的前面和后面追加一些内容。
B 通过 super 引用基础结构,然后在基础结构的前面增加 a, 而在后面增加 b
以上就证明了 Delta 合并运算是满足结合律的。
如何理解差量是独立的
有些程序员对于 Delta 差量是独立存在的这一概念始终感到费解,难道删除操作能独立于基础结构存在吗?如果基础表上压根没有这个字段,删除字段不就报错了吗?如果一个 Delta 表示修改基础表中某个字段的类型,难道它能独立于基础表存在吗?如果将它应用到一个压根就没有这个字段的表上,不就报错了吗?
出现这种疑问很正常,因为作为逆元存在的负差量是很难理解的。在科学领域,对于负数的认知也是很晚近的事情。连 17 世纪微积分的发明人莱布尼兹都在信件中抱怨负数的逻辑基础不牢靠。参见 负数简史:承认负数是一次思想的飞跃
为了认识这一概念,我们首先要区分抽象的逻辑世界,以及我们真实所在的物理世界。在抽象的逻辑世界中,我们可以承认如下定义合法:
即使表 A 上没有字段 B 和字段 C 也不会影响这个定义的合法性。如果接受了这一点,我们就可以证明在表 A 上应用任何差量运算得到的结果都是这个空间中的合法存在的一个元素(这在数学上称为是封闭律)。
在完全不考虑表 A 中具有什么字段的前提下,我们在逻辑空间中可以合并多个对表 A 进行操作的 Delta,例如
这种做法有些类似于函数式语言中的延迟处理。函数式语言中 range(0, Infinity).take(5).take(2)的第一步就无法执行,但实际上 take(5)和 take(2)可以无视这一点,先行复合在一起,然后再作用到 range(0,Infinity)上得到有限的结果。
在一个存在单位元的差量化空间中,全量可以被看作是差量的特例,例如
那么如何解决在现实中我们无法从不存在字段 C 的表中执行删除字段 C 的操作这一难题呢?答案很简单,我们引入一个观测投影算符,规定从逻辑空间投影到物理空间中时自动删除所有不存在的字段。例如
也就是说,如果是修改或者删除操作,但是表 A 上没有对应的字段,则可以直接忽略这个操作。
这种说法听起来可能有些抽象。具体在 Nop 平台中的做法如下:
Nop 平台中的 Delta 合并算法规定,所有的差量合并完毕之后,检查所有具有x:override="remove"
属性的节点,自动删除这些节点。另外,也检查所有具有x:virtual="true"
的节点,因为合并过程中只要覆盖到基础节点上,就会自动删除 x:virtual 属性,所以如果最后仍然保留了x:virtual
属性,就表明合并过程中最终也没有在基础模型中找到对应的节点,那么这些节点也会被自动删除。
差量运算的结果所张成的空间是一个很大的空间,我们可以认为它是所有可行的运算所导致的结果空间。但是我们实际能够观测到的物理世界仅仅是这个可行的空间的一个投影结果。这个视角有些类似于量子力学中的波包塌缩的概念:量子态的演化是在一个抽象的数学空间中,但是我们所观测的所有物理事实是波包塌缩以后的结果,也就是说在数学空间中薛定谔的猫可以处在既死又活的量子叠加态中,但是我们实际观测到的物理结果只能是猫死了或者猫活着。
基于可逆计算理论设计的低代码平台 NopPlatform 已开源:
github: entropy-cloud/nop-entropy
版权声明: 本文为 InfoQ 作者【canonical】的原创文章。
原文链接:【http://xie.infoq.cn/article/01d62e99b9b267e4364dcdcc7】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论