编者按:本文节选自周爱民著《程序原本》一书中的部分章节。
模块化的精髓不在于外在形式的分离,而在于内在逻辑的延续
图 1 展示了在稍早一些的应用开发语言中,从“代码的粒度”出发的抽象概念。
图 1 从“代码的粒度”出发的抽象概念12
1 语句与行的不同,通常也被称为逻辑行与物理行概念上的不同。此外,有些语言是强制要求以物理行来表达“语句”这一概念的,即一行语句必须书写于一行代码中。
2 单元与模块除了称谓的不同,很多时候其抽象概念也并不完全相同或者互相覆盖,例如一个单元可以是(或不是)一个模块。我们这里只取在某些语言中、将模块特指为“一系列函数”的这一概念。
其中,单元或模块用于组织一系列函数,而一个应用3则是由单元或模块构成。在这样的体系中,“化整为零”的问题会变得相对简单,即如何有规则或有逻辑地将一堆函数组织成单元。这里的“规则”与“逻辑”阐述了组织法则的两个方向。
3 早期的应用开发语言也直接将应用称为“程序”(program)。
其一,我们可以设定一个简单的分类依据,使得位于同一个单元中的函数表现出一定的相似性。例如开发一个图形库,我们可以将与图形设备相关的功能放在 device 库中,将绘制功能放在 graph 库中,将渲染功能放在 render 库中,将与图形库无关但又与计算机基础环境相关的功能放在 base 库中,如此等等。最后,我们将一些杂乱无章的功能放在 misc 库中。请注意,这一切的分类依据是“功能的归属与使用者”。类似地,我们也可以依据数据的位置来建立分类依据。例如同样是这个图形库,我们可以将基础数据运算放在 bits 库中,并基于此建立关于图形运算的类型抽象库 types。接下来我们定义在不同设备上适用的数据结构,比如在存储设备中的种种文件格式 fileTypes、在内存中复制和运算的 dibs(设备无关位图,Device-Independent Bitmap)以及在某种具体显示设备中适用的 cudaTypes(CUDA,Compute Unified Device Architecture)等。这样依据数据(所处的)位置以及需要进行的计算进行分类,也便于将数据及其副本放在不同的环境下开发。
这一类的方案或试图交付一个可以被使用甚至被共用的功能集,或通过抽取不同层次(例如面向不同设备或不同场景)的代码,使之可以“或多或少”应用于不同的环境。与这个组织原则密不可分的一个问题是:如何使一个“单元/模块”向外公布它所具有的功能集。这形成了著名的“开放细节”与“公开功能但隐藏细节”之争4,如今后者已成为应用接口设计思想的主流,前者则部分地影响并推进了开放源代码这一思想。
4 参见《人月神话》中“关于信息隐藏,Parnas 是正确的,我是错误的”小节,以及 David Parnas 关于信息隐蔽理论的著名论文:《论将系统分解为模块的准则》、《设计易于扩展和收缩的软件》和《复杂系统的模块化架构》。
但总的来说,这个组织法则只解决了一个应用中能被静态规则化的部分。无论如何,它无法满足“让程序运行起来”之后可能带来的种种变化。
其二,我们可以使得一个单元或多个单元中的函数存有某种逻辑关系。著名的“自顶向下程序设计”的思想,就处于这一组织法则所代表的方向上。例如:
设有一个逻辑(X),功能是将 m 变换为 n,如图 2 所示;
图 2 基本功能:将 m 变换为 n
由于 X 的规模巨大,我们将它分成三个逻辑步骤(顺序逻辑 1→2→3)来实现,如图 3 所示。
图 3 分解:三个步骤
虽然向下一层的分解并不限定各步骤之间的关系,但我们注意到此前讨论过的一个事实,即所有逻辑都可以被理解为顺序逻辑中的一个步骤。也就是说,步骤 2 依赖于步骤 1,步骤 3 依赖于步骤 2。随后我们继续向下一层分解,如图 4 所示。
图 4 持续分解:更多的步骤、逻辑或子系统
在图 4 中:
步骤1的子步骤1.1分成了1.2和1.3两个分支,最终以1.3为出口,传出数据n’;
步骤2则被分解为三个子步骤的循环,并总是以步骤2.3为出口,传出数据n“;
步骤3分解为两个顺序的子步骤,得到转换结果n。
由此无论是针对顶层 X 这个逻辑,还是针对第二、三层各分解的逻辑,整体的逻辑关系都没有变化。所有的逻辑关系在函数与函数之间,以及在“一堆函数”与“另一堆函数”之间都可以被简单地抽象为“顺序依赖”。即使将这些单元/模块之间的关系映射到最终子系统的划分之上,这种逻辑关系也不会变化。例如从子系统划分上看:
子系统1被设计为“预处理器”(preProcessor);
子系统2被设计为“分析器”(analyzer)或“过滤器”(filter);
子系统3被设计为(下一阶段的)“数据供应器”(dataProvider)。
这三个子系统以及它们的组织关系就可以构成某种数据处理系统的、整体的、面向运行期的逻辑架构。
进一步地说,通常单元/模块之间的逻辑关系只是简单的依赖关系。这一关系足以支撑由结构化程序设计带来的计算需求,包括支持数据流转与逻辑执行。5
5 DFD(DataFlow Diagram,数据流图)通常用于解释在上述执行过程中 m-n 之间的转换关系不变(即数据单一入口与单一出口)。但在本例中,它亦用于解释自顶向下过程中的逻辑关系不变,整体保持着顺序执行关系。这一过程是抽象概念——从程序语言中逻辑的结构化,到应用系统中组织的模块化——的延伸。
“没有坏味道”的诀窍:如何更好地组织代码
将数据或逻辑具有类似性质的代码放在一起,或者将逻辑之间存有关系的代码放在一起,这两种思路与面向对象用封装性来解决的问题是相类似的。一个对象,其属性是一系列(具有同类抽象性质的)相关数据,其方法是一系列与上述属性相关或相互间存有依赖的逻辑;对象的封装性决定了对象对外或对某个范围公布的接口。因此,一个对象或这个对象的类,其实有着与“单元”(unit)相同的抽象意义。
所以当面向对象出现之后,“一个单元中应该放多少个类”成了一个问题:如果一个单元可以放多个类,那么它与“库(library)6是用来容纳多个类的组织单元”这一抽象概念又重叠了。因此在早期面向对象语言的设计中,对这个问题的解释是含糊不清的,例如 Pascal/Delphi 允许在一个单元中放任何多个类,这导致“单元的组织原则”变得更加简单而含混:如果类之间相关或相似,就放在同一个单元吧。而晚一些的面向对象语言则较好地解释了这个问题:一个类即是一个单元/模块,或干脆进一步地取消了单元/模块概念。在具体表现上,例如 JAVA 或 C#就推荐在一个文件中存放一个(可以公开的)类,这个文件——或包含许多函数与数据的单元——将作为一个独立的组织单位存在。
6 这里指的是对象库(object library)或类库(class library),而普遍意义上的“库”是下一小节讨论的重点。
图 5 说明了在面向对象的设计观念中,“类”其实是用来替代“function/unit/ module…”等组织方式。不过在某些多范式语言中,例如 pascal 或 javascript,通常也允许这两类组织方式同时存在。但从本质上来说,这只是代码组织方式决定了一个“代码集”(source code package)在形式上有所不同,其内部的逻辑、数据以及更为底层的算法观念其实是大同小异的。
图 5 “类”的价值与局限:对传统组织方式的一种替代
随着系统规模的扩大,应用产品对“引入第三方代码”的需求也越来越明显。而“类”作为一个组织单位,其实是将逻辑和与其相关的数据、相关的抽象目标集中在一起发布,因此面向对象技术提供了相当高的可复用性。这一点正好迎合了上述需求(当然,换个角度也可以说,是需求推动了面向对象复用技术),因而如何在类的基础上进行更大规模的代码组织,成了一个重要的问题。
名字空间(命名空间)的出现与对象复用的思想有着密不可分的关系,但究其本质而言,名字空间下是否包含一个“类簇(class cluster)”却并不要紧。因为名字空间本身只是用于隔离不同的软件厂商、产品和子项目之间的代码,以及这些代码对外交付的接口。这种隔离需求原本是来自于交付物的名字冲突(例如 A 公司与 B 公司的代码库中都存在 TDynamicArray 类),而不是缘于这些交付物的类型或结构抽象冲突。所以无论面向对象是否出现,在“函数/单元/函数库”这样的组织单位持续进化之后,必然会由于跨公司、跨领域的复用而出现与“名字空间”相类似的代码组织方式。
常见的名字空间的命名规范为:
例如:
名字空间可以由更复杂的分类规则构成。例如:
通常其具体规则是由不同的公司/产品/产品线来决定的。例如:
如同所有的代码组织形式一样,名字空间通常也与作用域相关。由此带来的效果,也就是它解决的需求是:A 公司与 B 公司代码库中的 TDynamicArray 类之所以存在“不同”,是因为它们所处的名字空间不同。这一点与用“单元内、单元外”来隔离标识符系统,以及用函数、语句甚至表达式的“作用域”来隔离标识符系统的性质是完全相同的。它们只是组织规模上的差异,而其抽象概念以及目的是一致的,只是自然地随着规模扩张而延伸罢了。
图书简介:https://www.ituring.com.cn/book/2429
相关阅读
评论