逻辑闭包和抽象概念定义

逻辑闭包

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

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

抽象概念定义

要能做好逻辑闭包的设计,我们必须先要学会严肃的抽象概念的定义。抽象定义的是一个 范围,比如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”的地步,就没有架构了,该有的伤害,该破坏的逻辑关系都已 经破坏了。

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

其他外延

闭包的属性和细节问题

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

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

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

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

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

高层闭包的设计

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

注解

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

::
  1. 发送方申请内存块,在获得内存块的同时,得到该内存块的访问权限
  2. 发送方对内存块进行读写
  3. 发送放使用队列ID调用enqueue,把内存发送出去,并失去内存的访问权
  4. 接收方使用队列ID调用dequeue,获得发送的内存
  5. 接收方使用内存
  6. 接收方释放内存

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

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

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

send_process(queue_id) {
     ptr = alloc_communiction_memory(queue_id, size)
     fill_data(ptr)
     ret = false
     i = 0
     while !ret && i<10:
             ret = enqueue(queue_id, ptr)
             assert_unaccessable(ptr)
             i+=1
     if (i>=0)
             handle_timeout()
}

receive_process(queue_id) {
     while true:
             ptr = dequeue(qeueue_id, &size)
             if ptr:
                     ret = read_data(ptr)
                     free_communication_memory(queue_id, ptr)
                     if ret != STOP
                             break;
}

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

逻辑空间交叉

还是上面这个设计,对于这个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虚拟地址的权限,设置为不可访问

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