3.247. 逻辑闭包和抽象概念定义

3.247.1. 逻辑闭包

我尝试在本文中定义一个在架构设计讨论中经常要用到的概念,我把它称为逻辑闭包,Closure。取它在数学意义上的意象。

数学上的闭包的定义是这样的:

In mathematics, closure describes the case when the results
of a mathematical operation are always defined.

简单说,我在一个集合中定义了一组成员和针对这组成员的操作,这些操作的结果,仍在这个集合之内。

数学上这是非常严格的定义,但在构架设计上我们做不到这么严格,因为架构设计是个逐步发现边界的过程,什么东西放在A模块中,什么东西放在B模块中,或者什么东西放在层一,什么东西放在层二,这是逐步细化和维护中“决定”的。注意,这不是发现的,而是决定的。这个边界并非天然存在,是我们做了选择,后面在不断发现新的信息和引入的新需求在强化这个边界。所以,这里引入逻辑闭包这个概念,是要给出一个逻辑空间,这个空间引入的概念,进行逻辑推演后,结论的描述仍在这个逻辑空间的概念范围内,而不需要引入额外的信息去补充。

特别要指出的是,逻辑空间中的概念,只针对抽象概念本身,不包含被抽象的对象细节。比如我们说我们“每个模块都有模块名”,这个讨论上下文定义的逻辑空间就只包含模块和它的属性:模块名。但我们不包含这个模块的入口地址,也许这个模块确实存在入口地址,但我们的空间中没有抽象“入口地址”这个概念,“入口地址”就不属于这个逻辑空间,也不属于这个逻辑闭包。

比如一个链路层的逻辑空间,它包含节点,链路,报文,Payload这样一些概念。它可以构成一个逻辑闭包,因为我们讨论链路有关的东西,只需要用到这些概念就够了,我们决定是否重发一个报文,只需要知道报文的目标节点是否回了响应报文,而不需要额外的比如“Payload中的数据是管理数据还是业务数据”这个信息。我们在逻辑闭包之内构造了一个自洽的概念空间,在这个空间中,我们不需要额外的信息就可以进行各种推演,并得到一些演绎的结论。

我们引入逻辑闭包的概念,主要是解决恍惚的问题。我们在一个逻辑空间里面讨论问题,我们会用到这个逻辑空间的概念,但我们直接使用那个概念的结论,我们没有深入那个概念本身。所以那个概念的细节对我们来说,就是一个“恍惚”,那我们就要知道我们对这个恍惚做了什么假设。而这个假设本身,会成为我们是否信任那个恍惚的一个推演的需求。

比如前面这个例子中的链路层逻辑闭包,在这个逻辑闭包中,我们直接使用了Payload这个概念,但我们不关心Payload的内容,也不关心报文的格式是什么。在我们的逻辑链中,无论报文的格式是什么,我们的逻辑都可以成立。我们只要知道Node,知道报文的可以在物理层上发送,我们就可以决定重传,决定报文校验,这就足以支持链路层不丢包。我们可以在这个闭包的信息范围内挑破绽,把各种有可能出问题的逻辑破绽都填补了。这个闭包本身就可以成为一个恍惚,我们就可以直接使用比如“协议层通过链路层无丢失地发送消息给其他节点”这个概念,当我们在协议层讨论这个问题的时候,链路层就是一个恍惚,我们不进入它的细节,但我们知道它能保证不丢包。

但如果我们在逻辑闭包中引入了细节,这个逻辑可以被应用的范围就小了。比如在前面这个链路层闭包中,我们认知了Payload的格式,或者我们认为我们知道每条链路都只有1到2跳。在我们把它认作是恍惚的时候,我们就不知道细节逻辑是否依靠了这些信息。那我就只能认为这个链路层就只能是固定Payload格式的,只能是一跳或者两跳的。协议层要用3跳的网络,这个链路层闭包就要打开重新推演。或者你要把协议层和链路层的概念放到一起去推演,这个公共的闭包包含的概念就会多很多,你的逻辑就会变得非常复杂,甚至复杂到你的大脑无法判断它是否严密的地步。

很多工程师不希望基于逻辑闭包的抽象概念来考虑问题,是因为抽象思考的难度是大很多的,一个函数的输入大部分都是常量,肯定比大部分都是变量容易写得多。变量就是一种对“很多情形”的一个闭包。比如我们用字长为128,Lane长度为8写一个向量乘法的算法,这是比较好写的,但如果用字长为xlen,lane长度为vl写一个向量乘法的算法,那就复杂得多了。但显然后者的适用范围也大得多。一旦我们把这个乘法推演通了,无论我们用什么字长,我们都可以放心用这个算法。所以这是个平衡的问题,但很多时候你要解决的问题域有那么大,你不用抽象,你根本就无法解决你整个问题域的问题。

从逻辑闭包的角度看待一个逻辑设计,反过来还能让我们判断一个逻辑闭包的结论范围。比如你的链路层逻辑闭包没有包含QoS分类信息,那你在这个闭包内就不可能进行包调度。清晰定义逻辑闭包的范围,也让我们知道这个名称黑盒可以作用的范围。我经常看到有设计师为构架而构架会尝试设计“通用软硬件接口”,或者“万能模块间消息接口”这样的东西。从逻辑闭包的角度来看这个问题,一个逻辑空间没有约束就没有信息(本质上是约束),没有信息就不会产生衍生逻辑,没有衍生逻辑,这个逻辑空间就没有意义。所以,“通用软硬件接口”这个需求本身毫无意义,你首先要给定你的约束,你才会有建立一个独立逻辑闭包的驱动力。你的“因为”,“所以”都需要这些基本约束作为根。

备注

这本质上是个香农熵(\(H=-\sum_{i=0}^n\ P_ilogP_i\))的问题,当\(P_i=0\)或者\(P_i=1\)的时候,H=0,所以,如果我们要构造一个逻辑(“因为xx所以xx”),H=0,就意味着你没有什么可以“因为”的。

说到底,“逻辑闭包”是概念空间的另一个说法。因为实现“逻辑闭包”是我们建立一个概念空间的基本要求。否则我们随便建逻辑链就可以了,分成一个个独立“空间”干什么?我们强调逻辑闭包,只是强调建立概念空间在收缩信息范围上的那个要求而已。

3.247.2. 抽象概念定义

要能做好逻辑闭包的设计,我们必须先要学会严肃的抽象概念的定义。抽象定义的是一个范围,比如1,是一个具象,我们对它的取值有确切的理解。而“自然数”是一个抽象,它有众多的取值,1,2,3,4,......都是它的取值。当然,这是一个相对的概念。严格来说1也是一个抽象的概念,因为自然界只存在“一个苹果”,“一片树林”这样的“具象”,一这是对这些东西某个特定属性的一个“抽象”而已。但一般来说,我们可以在特定的上下文中上找到一个共识,确定什么是抽象,什么是具象。

当我们定义一个抽象,我们会有两个要求,第一,你必须给出明确的问题域,也就是你为什么要讨论这个问题。因为抽象本质上是一种“分类”,我们提出自然数这个概念,就是要区分具有特定性质的东西,区分不具有这种特质的东西(比如小数)。你没有目的地说一个概念,这个概念没有正确与否可言。

给出明确的问题域,你的逻辑就必须穷举它的所有可能性。你定义一个逻辑,只包含它一个子集,其他部分当看不见,我们讨论啥?这种“部分成立”的逻辑,就构不成抽象。

第二个问题是你必须对它包含的具象有明确的理解。这其实仍是前面这个穷举可能性的要求。你要穷举可能性,必然是用一个个的逻辑闭包分隔你的所有可能性,否则只能是一个“差不多”的东西,它的结果没法用。

今天我评审一份设计,里面有这样一个描述:

Master执行Slave.call命令,Slave从Idle状态变为Running状态。
Slave在执行中遇到halt指令,跳回Idle状态,Master从Slave.call命令
继续执行……

这个逻辑空间混合了Master和Slave的执行行为和Slave的状态变迁。我第一个反应是考虑Slave的状态机到底是个独立的闭包,还是属于当前逻辑定义上下文的闭包。是Master执行需要Slave的状态作为基础呢?还是Slave有一个完整的状态机管理,并用这个状态机的状态结果来控制Master的行为呢?这里的描述既没有构成Master对Slave状态机的一个“要求”,也没有提前推演Slave的状态机(让它形成一个闭包,这也可以通过索引其他定义决定),这段描述的信息熵就非常低。

这个例子和我们写程序很像,你写一个程序,循环打印hello world。你的程序这样写:

def print(str):
  f = open(stdout)
  for c in str:
    f = write(c)
  f.close()

def loop_print_hello_world(n):
  for i in range(0, n):
    print("hello world")

这里print和loop_print_hello_world就各自构成了一个独立的逻辑闭包,因为它的行为在它们内部是完全自洽的。loop_print_hello_world()的逻辑链中使用了一个完全封闭的print的概念,就算print修改成用putc()来实现,不用for循环改而使用递归……这些逻辑变化,都不改变loop_print_hello_world的逻辑。但如果你的程序是这样写的:

i=0
f=0
def print(str):
  i++
  for c in str:
    f = write(c)
  f.close()

def loop_print_hello_world(n):
  while i<n:
    f.open(stdout)
    print(__function_name__)

这就不是两个函数——你有本事不看另一个函数,独立维护其中一个函数试试?

很少人在写代码的时候犯这样的错误,主要是高级语言在语法上就enforce了很多所谓高内聚,低耦合的要求了。但架构设计是自然语言描述,人们就开始忘掉这个要求了(主要是它很烧脑),这样这些逻辑就全搅在一起了,但这样缺乏组织的逻辑根本就没有用。如果是代码,我们勉强可以靠测试来验证它。高层逻辑只能用人脑去“执行”,不能构成一个个相对独立和简单的闭包,你就没法校验这些逻辑是成立的还是不成立的。

实际上我上面提到的这个文档更大的问题是它在一开始就没有定义:为什么Master需要调用Slave?这解决的是个什么问题?大部分时候,我们都隐隐约约知道我们为什么要做这件事,但你要做一个严密的逻辑闭包,你还是需要严格(注意不是详细,而是严格,这里强调的是无二义,可穷举)定义整个问题域,你才能保证你的推演是合理的。

但说到底这两个问题都是一脉相承的,我们没有对逻辑闭包的认知,就不会在乎问题的边界,这样进行逻辑推演,其实跟不推演没有区别,不如直接编码呢。

很多人很喜欢拿Linus Torvalds那句“Talk is cheap, show me the code”来说事,说到底,我认为这句话是对逻辑空间被定义得支离破碎后无奈的抨击,你各个名称空间的关系都连不起来,这里找一个上下文来说有理,那里找一个上下文来说这也有理,到处都依赖细节,那只能让你把所有的细节都拿上来了。

但到了“Show me the code”的地步,就没有架构了,该有的伤害,该破坏的逻辑关系都已经破坏了。

这种东西,在做标准的时候就会显得越加的严重。因为做软件架构,你大不了不行就变成编码,虽然架构设计有点多余,至少你还可以通过测试来校验理解的细节是否有错的。做标准的时候,你不做出几个产品都不可能“测试”你的定义是否合理,架构设计白做,就真的什么都白做了。

备注

让我补充一个实际工程中常见的例子来加强这里想强调的问题:我在做设计评审的时候经常会问这样的问题:“在你这个逻辑中,XX为什么就能的到YY呢?”,然后我常常听到这样的回答:“这个地方我后面有解释,要不我们跳到那里去?”。这当然不行,我们现在聚焦在现在这个逻辑闭包上,我在点你这里的逻辑是不是有漏洞,等这里都点完了,我们才去确认你每个具体的抽象是不是可以成立,结果你这里没有控制住,你又跳到另一个闭包去,在那里常常也是不严密,然后你又要跳回来,那你的整个逻辑永远都不会严密的。关于这一点,我在这里:建模作进一步的论述。

3.247.3. 其他外延

3.247.3.1. 闭包的属性和细节问题

如果用《道德经》的概念空间,闭包的本质是一个名。只是当我们用“闭包”的概念的时候,更强调的是技术上的主动行为。当我们用马这个名字去抽象马这种动物的时候,强调的是一种自然观察的总结,但当我们用printf这个名字去抽象一种字符串格式化输出功能的时候,我们强调的是一种“设计约束”。后者我们才会强调性地把把它称为逻辑闭包。

闭包被整体使用的时候,它的细节在当前逻辑空间中就是一个恍惚,以为我们在当前逻辑空间中不使用它的细节,而使用它的总结,也就是一组“属性”。当我们用printf这个闭包的时候,我们不在乎我们用putc来输出字符,还是用fwrite来输出字符,也不在乎用while实现循环还是用for来实现循环,我们关心的是它可以把一个“格式化字符串”转化为一个“显示字符串”这个“属性”。

但在设计中,什么是细节,什么是属性,不一定那么清晰。比如说,我们在qemu中用virtio实现虚拟设备(guest)和模拟后端(Host)的通讯,对于guest的概念空间,“可以通讯”当然是Guest Virtio设备的一个属性,但“这个通讯是0拷贝的,Host驱动可以直接使用Guest的物理内存”,这算是细节还是属性呢?

这个判断标准不在于这个属性自己上,而在于Guest的概念空间是否需要使用这个逻辑。如果Guest认为,“如果这个拷贝不是0拷贝的,我就不用这个通道来走数据链路的数据了”。如果这个判断标准存在,那么这个就是属性,如果这个判断标准不存在,这个就不是属性,而是在恍惚中的“细节”。

这个例子向我们展示了,恍惚之所以是恍惚,就在于它的不确定性,恍惚只有在逻辑链上才是“精确”的。这有点像测不准原理,你不去建逻辑链的时候,所有细节都是恍惚,一旦你上逻辑链(观察它),它精确了,它就丢失属性之外的所有信息了。而逻辑链本身是一种“选择”,是一种“创造”,我们在这个过程中选择把什么细节提取上来当做属性,把什么属性放弃掉。选择不同的属性,会导致完全不同的逻辑链,最终就是完全不同的逻辑空间。所以,追求逻辑链完美是不可靠的,我们永远都需要冒险,决定把什么作为属性加入我们的逻辑空间,然后我们才有逻辑链的可靠。我们很多人很容易看见一个逻辑链,马上就开始进入逻辑链本身的研究,却忽略了这个逻辑链的“名”是如何提取的,以及它使用的那些闭包内部是否可靠。这是很多说得头头是道的逻辑链最终无法被实现(合道)的根本原因。

3.247.3.2. 高层闭包的设计

我们再看一个例子。有人这样描述一个高层逻辑:

备注

这个特性我纯是胡诌的,只是为了说明问题,请不要和任何实际的设计对应

::
  1. 发送方申请内存块,在获得内存块的同时,得到该内存块的访问权限

  2. 发送方对内存块进行读写

  3. 发送放使用队列ID调用enqueue,把内存发送出去,并失去内存的访问权

  4. 接收方使用队列ID调用dequeue,获得发送的内存

  5. 接收方使用内存

  6. 接收方释放内存

说起来,这里也确实定义了一个逻辑,好像可以认为它是一个逻辑闭包。但这个逻辑闭包其实没有什么用,因为我不知道“发送方”是什么意思,也不知道“内存块”是什么意思,更不知道“申请”是什么东西。你当然可以说,你可以在下层闭包中再定义这个概念,但我看你这个闭包本身,我如何校验它是否合理呢?这些概念指代的范围不确定,我在这个高层中验证什么呢?如果这一层的逻辑需要到看到下一层定义才能校验,这层逻辑就不构成逻辑闭包了。

“申请”,这种概念,在不同的上下文中完全不是一个意思。以Linux为例,你在用户态“申请”内存,是说你用glibc的桶算法获得一个虚拟内存空间的使用权,你在系统调用级别“申请”内存,是指brk或者mmap这样的调用扩展进程的虚拟空间,从内核的角度说申请用户内存,它表示在vma中留下内存分配的记录,从slab内存的角度说申请内存,是指把物理页面,标记为有用。我该用什么认识来认知你上面的描述呢?在高层逻辑中,你可以不描述细节的逻辑,但你不能没有细节的范围和属性的定义,否则这个逻辑空间不能被校验。

你看,我换一个方法来描述上面的逻辑:

这里我确切定义了发送方和接收方是两个process,同时声明queue_id是双方约定的。同时,我对内存分配的概念就是指用户进程可以直接访问的内存的分配。它怎么分配的我不管,但我对它有确切的要求,就是我拿到它了以后,我是当普通内存那样来访问的。我不知道它是不是可以实现,但我知道这是我的要求,你后面做具体设计的时候,你就好好告诉我,你怎么给我queue id,你怎么保证我在一个进程中分配的内存,可以在另一个进程中释放,这就好了。我给定的一组“名”,是有确切的要求的。你后面怎么打开它,我也是有确切的要求的。这样的模型就可以一路谈下去。否则你一路描述名字都是模模糊糊的,不但细节模模糊糊,属性也是模模糊糊,这种“文字”就无法构成逻辑闭包了。

3.247.3.3. 逻辑空间交叉

还是上面这个设计,对于这个enqueue,如果我们做成一条指令,有人会这样描述这条指令::

queuePush
        发送方释放内存块,指令将发送方写完的内存块放回池中,
        并取消发送方访问它的权限。

这句话我完全不知道它什么意思。你这是一条指令啊,指令能干什么?指令只能修改CPU的状态,导致CPU对外发出特定的信号(比如产生一个中断,发起一次内存操作等),你说CPU释放内存块,这句话应该怎么理解?

“释放内存块”这个描述应该属于的逻辑空间应该是软件API的上下文啊,你定义一个指令的行为,用一个软件API的概念是什么意思?是说指令会触发软件函数的调用吗?调用一个指令导致一个软件行为的发生,这到底应该如何理解?

原设计者的意思可能想说的是queuePush这条支持用于实现enqueue函数,调用后指定的内存块(假定它有定义)不再可以被发送进程访问。但这个描述同样没有什么信息上的意思,因为既然我校验这个指令的行为,我必然确切知道它在CPU这个概念空间中的意义。比如,这个queuePush指令的行为可能是这样的::

queuePush rd, rs1, rs2
        1. phy=当前CPU MMU对应的虚拟地址为rs1的虚拟地址
        2. 发消息给CPU ID等于rs2的CPU,要求目标CPU修改其MMU对应页表
           的物理地址等于phy的全部映射变更为可读写。如果有多个虚拟
           地址,全部变更为可读写
        3. 更新当前CPU MMU在rs1虚拟地址的权限,设置为不可访问

这才是指令概念空间里面应该有的描述。

3.247.3.4. 逻辑闭包的侵入问题

备注

说明:按惯例,本例子经过修整。目的是两个:其一,让问题更聚焦;其二,保密,避免和任何具体的设计联系在一起。但我会尽一切可能让它和现实的各种模式和细节等问题一致。

最近评审了一个设计,作者做了一个中断源,在特定条件成立的时候,可以激活虚拟机的特定程序,完成特定的功能。

该作者详细地描述了从收到中断,到投递给特定的CPU,到拉起虚拟机,到调用对应的中断向量的整个过程。

但看这个设计本身,逻辑上没有任何错误。但从架构评审的角度,这个设计是错误的。

因为这个设计侵入了中断子系统,Hypervisor调度器等多个逻辑闭包的逻辑,而推演过程没有讨论这些逻辑闭包为什么仍是自洽的。

一个中断被投递到CPU的中断处理系统,CPU要考虑运行状态,调度状态,决定进入软件的什么位置,软件要考虑当前的调度上下文,CPU状态,决定如何调度,各种中断向量,线程,softirq,fault,RCU Grace,全都依赖这些承诺来工作。这个调度方法,构成了一个CPU调度中断的逻辑闭包,它已经被过去证明基本上是成立的。

好了,现在你加入一个新的逻辑,如果你能让这个逻辑对这个闭包完全没有影响,你直接说你产生一个什么类型的中断,就好了,你不要说中断在CPU和软件中会如何调度。这才证明你对这个逻辑闭包没有侵入。没有侵入,这个逻辑闭包原来能成立,加上你这个东西还是可以成立。但你现在只谈你自己的逻辑可以成立,不去推演原来的逻辑闭包仍能保证自恰,我既不能说它一定不行,也不能说它一定行,我只能说“不知”。所以不知知病,我要“病病”(以病为病),就必须认为这个设计是错误的,这个方案是不能采纳的。

每个增量设计(现代软件设计,基本上全部都是增量设计),包括两个部分。一个部分是你相对独立的逻辑闭包,比如这里的中断源,你可以决定怎么设置它的成立条件,怎么设置它的发射参数等等,这都是你的自由设计空间,你必须保证它的所有逻辑自洽。另一部分,是你对其他设计空间的侵入,比如这里的对中断调度的新的要求。这个这个设计,你是和已有的设计融合在一起的,你必须重新证明,在加入你这个逻辑后,原来的逻辑全部成立,而不是仅仅你的逻辑可以成立。

不解决这个问题,这个设计就是不可靠的。

3.247.3.5. 闭包概念的来源

我提出闭包这个概念,其实纯粹是类比了函数的设计。我们对函数的设计要求就是这样的:

  1. 它必须把相同层级的逻辑放在一个函数平面内,比如你一个树查找的算法,会调用printf,调用memcpy,但你不会展开printf和memcpy的逻辑,因为在这个层级的逻辑上,我们关心的是树本身的逻辑。

    备注

    这个问题反过来理解,就是如果你的证据依赖下一层的细节,那个细节就不能属于下一层。比如你的树排序算法和printf当前的tty有关,你要根据tty的属性决定树允许的最大深度。tty这个概念就不能封装在printf里面。它需要和排序算法同层。或者你需要把它抽象为一个参数。

    这种问题也可以藏在细节分层上。比如你要我决策:“源文件第一行是否需要写注释”,这个上下文就缺乏条件获得我们需要的结论。我们得看到这个源代码是什么,再决定要不要在那个地方写注释。

  2. 它的规模必须足够小而且逻辑自洽,以便我们可以对它进行校验。它可以依赖其他细节(比如我们前面提到的对printf等函数的调用),但那个细节不属于本函数的逻辑。本函数的正确性依赖他调用的接口的正确性,但本函数的逻辑的正确性,完全在本函数都就已经描述了。

  3. 我们会从逻辑的角度对它进行优化,包括但不限于:把独立的逻辑压缩为子逻辑(好比前面我们用printf取代了一大段和console设备进行通讯的逻辑,因为那些逻辑和当前的逻辑完全正交,几乎没有互相依赖),合并重复的逻辑,消除多余的判断,等等。

说明这个来源,也许更有助于读者从一个具象的角度理解我们说的这个抽象的概念的实际含义。但也请读者注意到,闭包的概念是超越函数本身的。

3.247.3.6. 逻辑闭包的直接目的

我基本上认为,所有的设计本质最终都是逻辑闭包。用一个直观的比喻,这就好像我们要出去远足,要决定在一个背包里面装出门的东西:水啦,面包啦,衣服啦,药物啦,如果不考虑整个背包的大小,你应该带多少水?多少食物?——当然是多多益善啦,为什么不呢?但设计最终就需要一个限制在那里控制你的这些判断。每个闭包的目的就建立一个范围,要你在多带水还是多带食物之间做出取舍,任何一个设计都具有这个性质。

这个目的,不定义闭包的内涵,也没有考虑闭包在逻辑上的层次性,但这个是终极目标。很多人做设计的时候要这样,要那样,问“这难道不应该要吗?”,这就不是设计,谁不知道好处越多越好啊,但限制在哪里呢?每个得到都是失去,如果找不到这个失去,这个设计就没有办法权衡了。

这个问题在项目管理上是一样,我看到一些项目经理跟踪计划:XXX做了什么,XXX做了什么,某某技术取得了重要进展……这是项目管理吗?这是秘书的汇总工作啊。项目管理管的是:按计划现在我们应该到A位置,但我们现在只到了X位置,为了最终那个目的能达成,我们需要作出如下变化……这才是项目管理的工作。这最终就是构造一个背包,好把你的要素都可以放进去。

3.247.3.7. 逻辑闭包和数学证明的区别

其实逻辑闭包和数学证明很相似。或者我们应该这样说,数学证明其实就是一种逻辑闭包,但反过来,逻辑闭包不一定是数学证明。

我们做数学证明,本质上是判断我们的条件,落在公理(或者已知定理)的范围内,从而证明符合那个条件的集合的所有对象,都具有公理所述的属性。比如我们要证明等腰三角形顶角平分线垂直底边。我们已知的条件是:三角形,两条边相等,从而落在底角相等的集合中,从而落在顶角平分线切出来两个三角形全等的集合内,所以得到角平分线落在平分底边的180度的位置上,所以它落在垂直定义的范围内。

三角形的大小,其实仍是是个变量,三角形的顶角等于多少度,也是个变量,但我们不管这些变量,我们仍有“顶角平分线垂直底边”这个结论。所以,这就是个逻辑闭包,我们用我们已经设计好的各种抽象(三角形,角平分线……),进行集合内的集合运算,最终建立一个推理的过程,证明我们的目标符合条件。

设计中的逻辑闭包也是一样的过程,但设计中我们使用的抽象和公理,不是严格的数学证明,而是我们个人或者整个peer review群体的“经验”。只要有一定的置信度,我们就决定是否对它进行冒险了。

我这里把逻辑闭包和数学证明对比,是想说明三点:

第一,逻辑闭包不能“运行”,很多人觉得不如代码。但想想为什么数学证明也不能“运行”,你却可以接纳它呢?和数学证明一样,逻辑闭包是一个抽象的建模,我们只是用这种方法提升我们的选择的置信度,而不是用它取代最后的代码。如果我们已经有最后的代码了,我何必建模呢?建模是把最容易冲突的要素提出来,消除逻辑冲突,增加最后成功的可能性(注意:最后的代码可不是发布时候的代码,包括维护周期内所有的更新的)。

第二,逻辑闭包像数学证明一样,需要有清晰的目标和条件的。而且这个目标和条件是一个建模。等腰三角形不是真实的东西,只是一个抽象,这个抽象是忽略其他细节的。逻辑闭包是一样的。举一个例子,我最近评审了一个中断控制器的设计,它包含很多的需求,比如把不同的中断调度到不同的CPU上,调度到CPU不同的虚拟机和特权级上,等等。其中有一个需求是支持中断优先级。为了做这个特性,作者在CPU上放了一个队列,如果有多个中断送到CPU上,根据优先级排队通知CPU的执行器。

我评审的时候就说,这个设计建模不好,没有从逻辑闭包的角度考虑问题。你看,如果我们要建模这个需求,我们首先应该问的是:我们分优先级调度是为了什么?可以直接生成利益的需求是什么?比如说,这个目的是用最快的速度调度最重要的中断。我们把这个作为证明目标,那么整个中断从发生,到CPU的程序响应它,经过多少个步骤?明显这不是CPU就可以决定的,我们必须让整个收集,路由,CPU,的路径都给这个中断特殊通道,对不对?在这个通道中,CPU是不是瓶颈都难说得很。这样,我们就会在这个路径的每个单元上都提供一些约束,从如何标识不同中断的优先级,每个结点如何认知,如果让它抢占来设计这个高层约束。这些约束甚至不一定包含细节(比如我们认为收集上什么都做不了,我们决定,收集器只提供特殊的信号线给高优先级中断,如何提供这种信号线,我们都可以留给细节设计,但如果我们的经验支持这是“高度可能”的,我们就可以采信它),这样我们就可以针对这一个特性,建立一个独立针对它的一个逻辑闭包。这个闭包抽象了实现这个特性的全部逻辑要素,同时也证明了我们的目标是可以达成的。

这样的设计,才能保证我们到细节的时候,不容易和其他逻辑自相矛盾。

第三,我还希望突出一点,我们说逻辑闭包的“公理”是所有Peer Reviewer的经验。所以,其实希望在一个逻辑闭包的描述中,把这些条件抽出来,这样我们才能更好进行这个Peer Review。还是用前面这个中断控制器为例,如果我们证明我们需要优先级这个功能是必须的,那们我们就必须明确给出这样的条件:“我们需要停止执行所有其他的中断,优先处理重要中断”,这一点是我们进一步证明“需要给中断,以中断源为标记,设定优先级,优先处理重要中断”的理由。那么,我们就需要明确把这个条件表述出来。那么我们进行Peer Review的时候,我们就有了明确的判断范围:

  1. 所给定的条件是否是我们的共识。也需要一个有很多使用经验的专家会告诉你:在目标的场景中,我们根本没有遇到过什么中断非要打断其他中断的处理,优先执行。那我们就可以立即删除你这个假设和所有依赖这个假设的逻辑链。

  2. 如果我们认可这个共识,我们马上可以排查在我们定义的逻辑闭包内部的逻辑计算是否可以得到最终的结论。

3.247.3.8. 一个严格描述代码的现实但所描述的不是设计的例子

下面是一个我看到的一个工程师写的关于Qemu goto tb特性在中断处理行为的总结:

中断写入cpu的时候,触发了跳过tb执行的行为。cpu_interrupt里会把
icount_descr.u16.high改成-1,在gen_tb_start会把这个值读出来,
和0比较,如果小于0,会直接跳到gen_tb_end里的lable上……

这段描述所描述的是“代码”,而不是设计,真正的设计是:

Qemu复用了使用icount功能实现中断的跳出,每个TB的JIT代码会判断
icount是否设置了退出参数,如果设置了,直接离开TB,回到
tb_lookup/exec循环”。

两者描述的都是当前Qemu代码的现实,但icount_descr.u16.high是否用-1代表退出,这不是问题的关键,它有很大的变化余地,gen_tb_start是不是读这个值,是不是和0比较,判断在不在gen_tb_start,都不重要,重要的是在整个tb_lookup/exec循环中,必须有一个地方判断icount的退出参数。

同一个问题,抽象不同的逻辑闭包,对架构起不同的控制作用。稳定住前者,整个软件的发展就不是被目标所作用,而是被无关紧要的之间左右。

3.247.3.9. 逻辑闭包是函数式编程的闭包的区别

这一段写给一位和我讨论函数式编程的闭包概念的读者,解释这里说的逻辑闭包和函数式编程中提到的闭包的区别。

在程序上,闭包又叫字面闭包(Texical Closure)或者函数闭包(Function Closure)。它通常表示一个指向一个或者多个binding的函数。binding主要是指一组参数。

如果仅从这个角度来说,类都可以认为是个闭包。但通常不是这样认为的,而是说,一个函数返回了另一个函数,但那个函数索引了本函数的局部变量,这样导致本函数不能被释放(释放会导致本函数的堆栈被释放)。

比如这样:

闭包通常出现在支持first-class function的语言中,在这些语言中,函数本身也可以作为the first class citizen。first class citizen主要就是指编程语言中的普通参数,它们可以存储,可以传参,可以当做返回值。不同语言有不同的first-class citizen的,比如把类型,循环,Proof等作为first class citizen,会产生不同的语言。

而我说的逻辑闭包,不是这个意思,我说的闭包是,用于证明一个结论的所有条件和和它相关的所有属性。

比如我定义闭包:“闭包是证明一个结论需要的所有相关条件和这些条件的属性的集合。”这构成一个闭包。而“李老师的闭包理论很好,所以我要用这个理论”,这构成另一个闭包。这两个闭包几乎没有关系。前者中,证明闭包是一个有确切范围的所有条件,包括结论,条件,属性这些概念。而后者中的信息是:闭包理论,闭包理论好不好,要不要用,这些信息是另一个空间的。两者没有互相证明关系。我的闭包理论主要是用来区分这两个信息空间的信息的隔离性的。