3.286. 逻辑闭包V2
3.286.1. 介绍
本文是我关于逻辑闭包定义的一个新的版本。
我原来写过另一个版本,在这里:逻辑闭包和抽象概念定义。因为不断补充逻辑,那个版本的组织已经变得很松散了,新的版本我尝试综合这些松散的信息,写一个逻辑上更聚焦的版本,但原来的逻辑我又舍不得丢弃。所以,那个版本我会继续维护,大部分不好组织的信息都会补充在那个版本上,这个版本作为一个真正的Release,用做一个“正式的定义”。
3.286.2. 定义
“逻辑闭包”这个定义是我所有架构设计概念和方法的基础,没有这个基础,我们就无法讨论其他设计方法。当然,我个人也认为它是所有设计方法的理论基础,是否承认,就看你自己看过我的观点后如何认知了。
“Logical Closure”(逻辑闭包),这个概念是我为了说明问题而创造的,也许别的理论有其他的名称,但这里我们就用这个名字了。
它最初来自数学中Closure的定义:
In mathematics, closure describes the case when the resultsof a mathematical operation are always defined.-- Wikipedia
这个定义描述的其实是一个计算系统,包括一个元素集合和一组对于这个集合的计算方法。比如,自然数配合加法,就构成一个闭包:自然数构成一个集合,在这个集合中任选两个元素,经过加法计算以后,结果还是在自然数这个集合中。
同理,分数这个集合,加上加减乘除这些运算,也构成一个闭包。
我们把这个概念引入到逻辑推理中:如果我们引入了一组定义,用这一组定义进行逻辑推理,这个推理的结果仍在这组定义中,那么,我们就认为我们这个推理,构成一个逻辑闭包。
定义的内涵本身很简单,但其实这个问题比数学上的问题复杂得多。因为数学系统的概念基本上都是有共识的,自然数表示什么,实数是什么,这在讨论的人群有共同的认知。但一个逻辑定义的概念是什么,其实很难有共识。
想象一下,“线程”是什么?内核线程算不算线程?线程和线程间的调度代码算不算线程?线程触发的信号处理向量算不算线程?执行线程的时候临时切换一下堆栈指针那段时间算不算线程?实现线程的那些静态的代码算不算线程?……
要让线程有“共识”,比数学中互相共识“自然数”,难得多。但这个问题虽然麻烦,但很多时候我们还是可以在某种程度上达成共识的。只要你不要强求世俗的“精确”(精确这个问题,我们后面专门讨论)。
我们这里首先解决一个更麻烦的问题:如何分清楚我们提到的一个名字的“名字本身”和“名字的内容”这两部分信息。
当我们提出“猫”这个概念,我们觉得我们说了一只有毛,有尾巴,能抓老鼠的四条腿的哺乳动物。但如果从信息论的角度来说。猫这个字上可没有毛,也没有尾巴。
这样就导致我们在沟通上会产生混乱,我们觉得我们说了猫这个字,有时仅仅就是这个字本身的意思,有时又觉得,我们包含了这个名字背后代表的所有东西。
所以,逻辑哲学论里,引入了这样一个概念解决这个问题:
3.1 3 proposition contains the form, but not the content of itssense.-- Tractatus Logico-Philosophicsu
请看看我下面这个(组合)命题:
由于猫喜欢吃老鼠,所以把猫和老鼠关在同一个笼子中,老鼠可能被猫吃掉。
按逻辑哲学论的定义,在这个命题中,猫只有一个属性:喜欢吃老鼠。不包含它有四条腿,有尾巴,是哺乳动物这些信息。
在这个命题中,明说的:猫,猫喜欢吃老鼠;和没有明说的:猫比老鼠强大,都是这个命题的一部分,但猫是四条腿的,这不是命题的一部分。
对应到我们定义的逻辑闭包,在这个“封闭空间”中,只包含了猫喜欢吃老鼠和猫比老鼠强大这些信息,不包含猫是四条腿的信息。你来问我:猫难道不是四条腿的?我的回答是,在我的逻辑闭包中,我不知道,也不关心,猫是否有四条腿。它是不是有四条腿,都不改变它有机会把老鼠吃掉这个命题。
我们说它封闭,就是说它里面不包含那些没有被引入命题的信息。你用闭包内的概念进行推理,使用的只有引入命题的信息,不包括那些被抛弃的下一层细节。
用数学或者逻辑哲学论的观点去理解,一个闭包包含的信息相当于一个定义的变量,而它没有定义的部分是常量。
3.3 12 It is therefore presented by means of the general form ofthe propositions that it characterizes.In fact, in this form the expression will be constant andeverything else variable.-- Tractatus Logico-Philosophicsu
比如我们用x=2n(n是整数)定义偶数,我们在这个定义中,2是常数,n属于整数集合也是常数(逻辑上的常数),但n等于多少,这是变量。在表达上,我们在命题中用到的对象和属性是常量,没有提的部分是变量。所以前面的例子中,猫喜欢吃老鼠是常量,猫有几条腿是变量。我们的推理约束在常量上,这个推理才是有意义的。
这个约束,才是逻辑闭包这个定义最核心的地方。我们做架构设计是为了什么?是我们要“抽象”我们的目标系统。什么是抽象呢?就是我们忽略了很多细节(把那些作为变量),提取我们认知的一些特征作为代表。如果我们的抽象包含了目标系统的所有信息,这就不是抽象了。
而我们抽象又是为了什么?就是我们可以根据这些抽象进行“推理”,证明某些结论是可以成立的,所以,我们需要点清我们把什么信息放进了一个逻辑闭包,然后把对这些信息进行逻辑推理,最终在推理中证明我们的目标是可以成立的。
考虑一个简单的例子:我们筹划从深圳去北京。我们定义:我们要坐飞机经上海转机去,所以先要去机场,买联程机票,然后上飞机……这个闭包中,去机场,买联程机票,是常量,但是打的还是坐地铁去机场,这是变量。如果我给你一个行程表,精确到你每秒应该出现在什么位置上,这个就不是高层设计。这个例子说明,包含更多细节的不是高层设计,高层设计的语义空间中仅包含那些“常量”,不包含“变量”的具体取值信息。你做一个DFD建模,包含的仅仅就是数据的充要性证明,不包含这个数据的接口。如果DFD中包含了接口信息,这就不是DFD模型了。一个类图,两个类之间有一个关联,这里之表示两者有数据相关性,如果你认为这个关联必须是函数,这个就不是关于关联的建模了。
所以,其实逻辑闭包本质上就是建模,每个模型,都是一个独立的逻辑闭包,证明某个结论,这些结论,为我们进行下一级设计,乃至最终编码,提供约束,使我们设计每个逻辑闭包的时候都面对有限的概念,可以进行完整的推理。这种建模和数学上,我们把赌博游戏抽象为概率模型,把交通管理问题抽象为排队问题这样的建模是一样的。
考虑一个简单的Hello World程序:
#include <stdio.h>
#include <stdlib.h>
int main(void) {
printf("Hello World\n");
return EXIT_SUCCESS;
}
这一段简单的代码就构成一个逻辑闭包。这个闭包包括这样一些定义:
main函数是整个程序的入口,它的返回值决定程序的返回值。
程序正常返回应该返回EXIT_SUCCESS。
printf可以让程序输出一个字符串,我们按要求输出了"Hello World"
printf和EXIT_SUCCESS由stdio.h和stdlib.h定义
……
但它不包含这些信息:
stdio.h中定义了ssize_t
printf实际上就是puts
EXIT_SUCCESS等于0
……
你说EXIT_SUCCESS是不是等于0呢?很多平台上是,但我们这个闭包不关心,不在乎这一点。正因为它不在乎这一点,所以它的复杂度才不高,所以它才能够复用。多一个关联,它就多一个限制。比如,你把程序写成这样:
#include <stdio.h>
#include <stdlib.h>
#ifndef ssize_t
# error Haaa... where my ssize_t? give me back.
#endif
int main(void) {
if (EXIT_SUCCSS==0)
printf("Hello World\n");
return EXIT_SUCCESS;
}
(这个语法不完全对,反正就这意思吧。)
这就不再是原来的概念空间了,它的复用能力,可维护性也完全不如原来的定义了。
所以,逻辑闭包强调的是信息封锁在一个空间之内,让我们总能看到。
学习架构设计,最怕的是抽象太像代码了。人们会把抽象当成是代码本身。就好像有些人学习UML,就想着用UML来编码,这就完全误会架构设计的目的了。所以,允许我再举一个抽象更高的例子来说明这个问题。
还是前面这个Hello World程序,我现在需要用最短的时间输出100个Hello World。应该怎么设计?下面是一个对这个问题的闭包设计:
根据对目标系统的Profile分析,打印的瓶颈在于字符串编码为tty控制台命令的时间太长,编码过程占整个执行过程的99%,而我们有十个CPU,所以只要把打印分散到10个线程或者进程中,每个打印10个,这样的时间就是最短的。
这个定义和代码完全没有关系,是不是用C语言写的我们也不关心,我们这个空间中,只抽象这些概念:
目标系统编码时间很长,占整个执行过程的99%。
我们有10个CPU。
操作系统调度器,会自动把多个进程分布到不同的CPU上。
……
这些概念你可以不同意,但这不影响它构成一个独立的逻辑闭包。而能让你不同意,恰恰是建立这个逻辑闭包的目的。因为这样我们就有一个有限的空间让我们评估我们的判断是不是合理的:比如我们可以这样质疑这个定义空间:
你这个目标系统的编码时间测试有问题,你用的不是我们的主流平台,在主流平台上,这只是80%。
我们的程序可不只跑在这个平台上,我们的平台的CPU的数量是个变量n,(n在1-128范围内)。
我们用的OS,如果你不主动调度,有些是只在CPU0上调度的。
……
不要紧,因为这个逻辑闭包是经过化简的,所以可能可以把它换成这样:
根据对目标系统的Profile分析,打印的瓶颈在于字符串编码为tty控制台命令的时间太长,编码过程占整个执行过程的80%到99%,而我们可能有n个CPU,所以只要分成n个线程,分别绑定到不同的CPU上,分散打印,就能达成目的。
我们就可以在这个基础上继续完善我们的逻辑闭包了。
除了质疑闭包集合的元素,你同样可以质疑推理过程,比如你可以说:分散打印其实是有问题的,这样不同的打印会交叉在一起,出现在tty上的就不是一个个独立的Hello World了。如果我们的共识是承认这个质疑成立,我们一样可以优化这个推理,让结论成立。比如我们可能需要把打印的过程分成“编码”和“输出”两个阶段。这个闭包的名字空间就变大了。在我们的抽象中,原来是看不见“编码”和“输出”两个过程的,都被抽象为“打印”了。但如果这个空间的逻辑推理无法成立,那么我们就需要看到它,那这个下一层的“名字的内容”,就成为本闭包信息集合的一部分。
备注
《道德经》里有一种说法,叫“不为天下先”,又叫“不敢为主而为客”,说的就是这里的策略:我们定义一个逻辑空间的时候,尽量不加入新的概念,直到我们的推理碰到了障碍,我们被动要把概念从下一层提上来,这会让我们的逻辑闭包更复杂,但这是没有办法的事情。设计的目标恰好是这个:我们希望在能达成目标的情况下,最大程度化简系统。功能性能是我们的目标,化简同样是我们的目标,我们需要两者同时成立,就有了权衡的动力。整个设计的目的,就是为了这个权衡。
在实践中,我发现在复杂设计中,最容易出问题的是这个推理,因为细节是无限大的,你可以抽任何细节上来当作是抽象(抽象的本质是用某个细节来“代表”整体)。比如前面这个推理如果写成这样:
根据对目标系统的Profile分析,打印的瓶颈在于字符串编码为tty控制台命令的时间太长,整个printf居然用了3分钟,而我们可能有n个CPU,这些CPU都是5nm的工艺加工的,成本同比达到其他CPU的2倍,所以我们要多用一些CPU打印,问题就可以解决了。
这种,你说它不对呢,每句话都是对的,但这个推理就是没有意义的。我这里写得很荒谬,而且只有一小段,所以还是能一眼看出来,但如果这些内容分布在十几页的文档中,就很难说了。
所以,其实说起来,我们是不希望一个逻辑闭包横跨十几页的。我们需要每个闭包在一两页中就能独立成形。就好像写程序一样,我们希望一个函数一两百行,不希望设计上千行的函数,因为人脑根本就没有办法,连续处理那么长的逻辑链。所以,如果你定义一个逻辑闭包,却不断需要从几十页外拿另一个设计定义出来说:你看,我这里说过了。那你这个肯定就不是个闭包。我们分解闭包,就是为了正交地分解每个独立逻辑,让每个封闭空间和其他空间只有少数的关联。这样整个系统仍是可推理的,而不是横跨十几页。
严格来说,横跨十几页,这十几页的信息,共同构成一个无法思考的封闭空间。我把这称为一个“黑盒”,表示一个无法校验的,我只能无条件认为它正确的一个“名称”。前面例子中这个黑盒,我只能得到这么一个信息“这个问题可以解决”,它是否最终成了,就看我们对作出判断的那个人自己的可信度了。
黑盒不是设计,黑盒是无条件成立的一种“信任”,就好像前面正面的例子中,我们信任“打印的瓶颈是tty编码”,“OS能调度线程和进程”一样。它是闭包的“原子元素”(概念来自逻辑哲学论的Atomic Proposition)。
所以,如果你的封闭空间太大,我们就需要要求你创建更小的闭包去抽象它。这是我们不能直接编码而需要进行设计的理由。
设想一下4+1视图。你的代码很多,说不定是十几万行代码,你怎么“思考”这个代码符合你的预期?4+1视图就是一种分类方法,比如开发视图,我们不管运行的时候有多少类,不管创建了多少线程,也不管分发到多少节点上,我们从“开发”这个角度来抽象它:我们分成多少个源代码目录?编译出多少个exe文件?我们从这个角度来单独建模它的组织。这就简单多了吧?
然后我们换到部署视图,我们单独谈把不同的exe跑多少个instance到不同的计算机上,分别创建什么通讯端口,这也简单多了吧?
其他视图同理,每个视图本身,还可以分层。
但所有这些要进行推理,我们需要一个“目标”去保证我们的推理,这就是那个1了。我们有Use Case图。比如,在那个图里面,我们说,“我们需要让用户看到一个前端”。这样我们就可以把这个目标落到我们的开发视图上了:你说你需要一个前端,为什么你的代码里面看不见前端的代码?这个代码在哪里?
再落到部署视图上:哪个节点负责运行这个前端?它是否有通讯通道获得它需要的那些信息?
如此类推。
整个设计,最终就是不同层次,不同角度的,一个个收缩得最小,让人脑可以校验的封闭空间。模块设计,架构设计,这些设计的本质,就是大量分层的,交叉的逻辑闭包的设计。每个闭包抽象出来的名字,又成为其他闭包的原子命题来使用。而我们人脑能在这其中起作用的,就是保证在闭包内,推理是穷举的,严密的,挑不到破绽的,而不能是过大的,忽略性的,对外依赖的。
3.286.3. 精确性问题
精确性问题是理解逻辑闭包思想一个非常关键的哲学问题,我们在这里单独讨论它。
按逻辑哲学论:
What can be said at all can be said clearly, and what we cannottalk about we must pass over in silence。-- Tractatus Logico-Philosophicsu
所以,Can be said的,就是精确的,是Clear的。而we cannot talk的,就是模糊的,只能pass over in silence的。
在逻辑闭包的定义中,我们说下的每个判断,如果可以用于进行真值判断(选择其中一个结论),这些都可以认为是精确的。猫,精确吗?不精确,波斯猫是不是猫?咖菲猫是不是猫?猫会抓老鼠,精不精确?所有猫都抓老鼠吗?不见得吧?
但在逻辑闭包中,我们放进去的都是共识(你可以不同意,并且清晰地反对它)。在这个封闭的空间中,它就是精确的,有明确的判断标准。这是“数字化”的本意,一个数字化的wav文件能明确代表一段模拟信号吗?不能,但一旦数字化。它就是精确的,1等于1, 1不等于2,这些都有明确的判断标准。
所以,逻辑闭包中的定义就是精确的,猫就是可以抓老鼠,如果定义猫部分可以抓老鼠,这句话也是精确的。你们可以在pass over insilence中觉得这个定义的结果是false,但这个定义本身是精确的。如果你承认它,那么就意味着,它可以覆盖你在其他地方使用它时的所有语义范围,而不能是我在这个逻辑闭包中就这么说说,换一个逻辑闭包的定义和原来不同。一旦这样,这个设计也就不成立了。你可以覆盖很大的一个范围,比如你说,“中断,是中断收集器上在某个时刻检测到的一次事件”,这很模糊,但它确实可以覆盖所有什么电平中断,边缘触发中断,消息中断。这就是精确的。但你不能说,“中断,就是某条中断引线上的电平变化”。这不覆盖所有的情况,除非你认为你的定义空间中,就只有这种中断。你不能在另一个地方突然插入一个消息中断,然后引用你前面获得的那些结论。
我们很多人容易把“精细”,理解成了“精确”了。因为我们确实会说,1mm刻度的尺子比1cm刻度的“精确度”更高。但在逻辑上,我们关心的是集合,如果我们的判断模型中,可以明确给出A在集合中,或者不在集合中,那么,我们就认为这个判断是“精确”的。
我们可以写一个每日打卡程序吗?“这种程序以前写过,我们团队有10个人,只要提供足够的服务器,应该可以”。这些话很模糊,但在逻辑上,它是严格的,因为它输出一个结论:“可以”。你不要看它说什么“应该可以”,也不要管“以前写过的程序和现在的程序不一定相同”,它用一定的信息,作出了一定的判断,结论是唯一的,这就是精确。你可以质疑它的原子命题本身的真假,但那个没有逻辑判断在里面,那个是Pass over in silence的。那个没得争的,你说是就是,我们互相不同意,就只能用其他手段说话(比如权力,利益,暴力等等,当然包括打开它下一层的逻辑),这是辩论的终点。
模糊只是扩大了可能性边界而已。我们说“猫可以抓老鼠”,这对猫和抓老鼠的定义都很模糊,但就算我们收缩了猫的范围,我们说的也是一个可能性,在某只猫抓到某只老鼠前,这都只是可能性,我们本来就是定义一个可能性的范围,而不是在描述一个事实。这在逻辑哲学论中称为“Truth Posibility”,表示我们只是设定了一个“有可能变成事实的所有可能性的集合”。
这样,也许我们现在更容易理解为什么设计的本质就是逻辑闭包了:
我们对确切性的感知,都来自我们进行逻辑推理时把分类条件确切化了。你看到一个大汉,你觉得他“很能打”。这个推理过程其实是Pass Over In Silence的。为什么你觉得他很能打,你的判断条件其实是可以找出来的,比如你是通过这三个条件找到的:
他比你高,看起来有2米
你过桥的时候桥在震动,说明他很重
他的面相很像电影里很能打的人的样子
换一个人判断,可能他会得出完全相反的结论,因为他取的条件和你不同:
他脸色苍白,可能刚病好
他很重,大肚腩,应该缺乏锻炼
他走路的样子不沉稳,和练过武的明显不同,肯定没有练过
你看,我们进行技术判断,希望我们的判断是有逻辑的,其实就是把我们Pass Over In Silence得到的结论,减少黑盒,用更多的逻辑推理取代Pass Over In Silence的Atomic Proposition。这其中,逻辑闭包起了最大的作用,因为只有一个逻辑闭包的完整的,我们可以看到所有的条件,也看到用这些条件运算的到的结论符合集合运算的条件了。我们才会认为我们进行了一个“理智的判断”。
而没有逻辑闭包,我们看到的就是“黑盒”,我们只能考虑“信任”,还是“不信任”你。
3.286.4. 如何在设计中应用逻辑闭包
闭包的目的是建立那些“常量”。比如前面深圳去北京的例子,我们就是要把“去机场”,和“买深圳转上海去北京”的机票变成一个确切的判断。这样我们进入复杂的细节的时候可以有一个目标,我们知道出门要想办法先去机场,而不是直接拿个指北针直接往北走。
所以,选择什么作为常量是闭包设计成功的关键。你不选择去机场作为特征,选择“过马路要左右看”作为常量,这也“没有错”,但“没有错”不能帮助你能到北京。
高层设计(也就是逻辑闭包设计)不是你达成目标的细节镜像,而是达成目标的一个抽象。它永远不能证明目标必然成立,它只是(大幅)提高目标成立的可能性。要从深圳去北京,你只考虑五分钟之内干什么,这个事情是不可能成的。同样,你建一栋大厦,只考虑每天干什么,是肯定建不出来的。所以人能建大厦,能飞上太空,动物永远都做不到,因为人具有分层抽象能力的大脑。
所以,逻辑闭包是一个用于收集达成目标的要素的工具。在设计中,我们一般先把所有我们要达成的目标分成很多个子目标,在这里推理所有这些子目标加起来,是否就是我们的实际目标。如果这一点成立。我们就可以为每个这些子目标建立各自的闭包。以这个子目标为中心收集为达成这个目标的关键要素,这样一路收集下去,一边收集一边反复Review我们是否破坏已经建立的其他闭包,我们就完成整个设计了。
这里的关键在于,每个独立的选择都需要是自洽的,没有其他更好选择的。很多做维护为主的工程师不一定能理解这里的意思。你做一个中断控制器,你习惯维护ARM的架构,觉得GICD是必须的,ATS显然是应该的,CPU_IF是不能没有的……但ARM决定用这个架构,是被他的需求和已有的其他条件控制的,你在RISCV再做一个中断控制器,你能肯定你也必须有GICD,有ATS吗?
你要再做一个中断控制器,你需要Review你新的条件,然后用这些条件去控制你“不得不作出的选择”,我们才敢肯定我们不会事后后悔,因为所有的选择都是不得不选择的。否则我们到后面才会发现你给自己制造了额外的限制了。就好比在深圳去北京的例子中,人家选择上海转机是因为成本上有要求,而你的目标是尽快到北京,本来就应该直飞,你最终就会发现你的方案一开始就约束你了。
最后一个值得说明的问题:逻辑闭包的目的是选择路线,更严格说,是在没有路线前选择路线上不得不走的关键点,避免没有经过这些关键点导致到达不了目的。是在没有结论的时候希望得到这组约束,而不是为了完成“建立逻辑闭包”这个仪式而却做这个约束。我常常看到有人为已经设想好的对象关系画类图,为分配好功能的模块画DFD图,你细节都已经决定了,还“建模”干什么呢?北京都到了,思考从哪条路线去北京好?CPU都做出来了,为这个CPU做一个Simulator模拟它的性能模型?这有意义吗?
所以,架构设计无法“强迫”,如果架构设计没有实现“提前进行有效选择”这个目的,它和没有做就没有区别了。而不做架构设计,是很多项目失败的基本原因。