第四章:模块应该是深的

管理软件复杂性最终的技术之一就是设计系统,以便开发人员在任何给定时间只需要面对整体复杂性的一小部分。这种方法称为模块化设计。

模块化设计

在模块化设计中,软件系统被分解为相对独立的模块集合。
模块可以有多种形式,例如类、子系统或服务。
在理想的世界中,每个模都将完全能独立与其他模块:开发人员可以在任何模块中工作,而无需了解任何其他模块。

不幸的是,这种理想是无法实现的。模块必须通过调用彼此的函数或方法协同工作。结果,模块必须相互了解。模块之间将存在依赖关系:如果一个模块发生更改,则可能需要更改其他模块以进行匹配。
模块化设计的目标是最大程度地减少模块之间的依赖性。

模块的依赖性是不可避免的,只能通过设计减少依赖性

为了管理依赖关系,我们将每个模块分为两个部分:接口和实现。
接口包含使用其他模块的开发人员必须知道的所有内容,才能使用给定的模块。接口描述模块做什么,而不描述模块如何做。
该实现由执行接口所承诺的代码组成。
在特定模块中工作的开发人员必须了解该模块的接口和实现,以及由给定模块调用的任何其他模块的接口。除了正在使用的模块之外,开发人员无需了解其他模块的实现。

模块的使用者,只要关注自己需要的模块接口和能力,降低了对模块的认知成本。

面向对象编程语言中的每个类都是一个模块。类中的方法或非面向对象语言中的函数也可以视为模块。

最好的模块是那些接口比其实现简单得多的模块。
这样的模块具有两个优点:

  • 一个简单的接口可以降低该模块对系统其他模块的复杂度
  • 如果以不更改其接口的方式修改模块,则该修改不会影响其他模块。
什么是简单的接口

接口是什么

模块的接口包含两种信息:正式信息和非正式信息。
接口的形式部分在代码中明确指定,并且其中一些可以通过编程语言检查其正确性....类的形式接口包括其所有公共方法的签名以及任何公共变量的名称和类型。

每个接口还包括非正式元素。这些没有以编程语言可以理解或执行的方式指定。

  • 包括接口高级行为,例如,函数删除由其参数之一命名的文件的事实(对字段值存在依赖)。
  • 对类的使用存在限制,也许必须先调用一种方法,则这些约束也是类接口的一部分
  • 如果开发人员需要了解特定信息才能使用模块,则该信息是模块接口的一部分

接口非正式方面只能通过注释来描述,而编程语言不能确保描述是完整或准确的。

对于大多数接口,非正式方面比正式方面更大,更复杂。

明确指定接口的好处之一是,它可以准确指示开发人员使用关联模块所需要知道的内容。
有助于消除“未知的未知”问题。

接口包含了两种信息:
一种接口定义本身,包含入参,返回,方法名等等,通过编程语言控制
另一种是接口实现的业务本身,需要通过注释等方式告知业务或者限制。

抽象

抽象与模块化设计的思想紧密相关。
抽象是实体的简化视图,其中省略了不重要的细节。
抽象是有用的,使我们更容易思考和操作复杂的事物。

每个模块以其接口的形式提供抽象

  • 提供模块功能的简化视图
  • 从模块抽象的角度来看,实现的细节并不重要,故在 接口中将其省略。

抽象会通过两种方式出错:

  • 包含并非真正重要的细节。当这种情况发生时,它会使抽象变得不必要的复杂,从而增加了使用抽象的开发人员的认知负担。
  • 抽象忽略了真正重要的细节。这导致模糊不清:仅查看抽象的开发人员将不会获得正确使用抽象所需的全部信息。它可能看起来很简单,但实际上并非如此。

深模块

深浅模块
最好的模块很深:它们允许通过简单的接口访问许多功能。
浅层模块是具有相对复杂的接口模块,但功能不多:它不会掩盖太多的复杂性。

模块深度是考虑成本和好处的一种方式。
模块提供的好处是其功能。
模块的成本(就系统复杂性而言)是其接口。
模块的接口代表了模块带给系统其余模块的复杂性:接口越小越简单,引入的复杂性就越小。

最好的模块是那些好处最大,成本最低的模块。

Unix I/O接口的现代实现需要成千上万行代码.....所有这些问题,以及更多问题,都有Unix文件系统实现来解决。
对于调用系统的接口的程序员来说,它们是不可见的。多年来,Unix I/O接口的实现已经发生了根本的变化,但是五个基本内核调用接口并没有改变。

Go或Java之类的语言中垃圾收集器。
这个模块根本没有接口。在后台进行隐形操作以回收未使用的内存。
垃圾收集器的实现非常复杂,但是使用该语言的程序员无法发现这种复杂性。

诸如Unix I/O和垃圾收集器之类的深层模块提供了强大的抽象,因为它们易于使用,但隐藏了巨大的实现复杂性。

浅模块

浅层模块是 其接口与其提供的功能 相比 相对复杂的模块。
例如,实现链表的类很浅。操作链表不需要太多代码(插入或删除元素仅需几行),因此链表抽象不会隐藏很多细节。链表接口的复杂度几乎与其实现的复杂度一样高。

浅类有时是不可避免的,但是它们在管理复杂性方面没有提供太多帮助。

浅层模块是一个接口相对于其提供的功能而言复杂的模块。浅层模块在对抗复杂性方面无济于事,因为它们提供的好处(不必了解它们在内部如何工作)被学习和使用其接口的成本所抵消。小模块往往很浅。

private void addNullValueForAttribute(String attribute) {
data.put(attribute, null);
}

从管理复杂性的角度来看,此方法会使情况变得更糟,而不是更好。
其所有功能都可以通过其接口看到。考虑接口并不比考虑完整实现简单。如果正确记录了该方法,则文档将比该方法的代码长。
与调用方直接操作数据变量相比,调用该方法所花费的时间甚至更多。
该方法增加了复杂性(以供开发人员学习的新接口的形式),但没有提供任何补偿。

现在的代码中很多模块的定义是浅模块的,在调用时需要仔细分辨根据自己的场景应该使用哪个接口。导致开发的复杂性和后续理解的复杂性增加。且为了更好的区分各个接口能力的不同,才在接口定义层面提高了认知成本。但是浅模块的实现可以更加明确,且更有针对性。

有时,考虑是定义一个大而全的接口还是一个小而精的接口是一个问题。

经典主义

不幸的是,深度类的价值在今天并未得到广泛认可。
编程的传统观点是,类应该小而不是深。
类设计中最重要的事是将较大的类分成较小的类。对于方法,通常会给出相同的建议:“任何长于N行的方法都应分为多种方法”(N可以低至10)。这种方法导致了大量的浅类和方法,这增加了整体系统的复杂性。

“类应该小”的极端做法是我称之为“类炎”的综合征,这是由于错误地认为“类是好的,所以类越多越好”....鼓励开发人员最小化每个新类的功能:如果您想要更多的功能,请引入更多的类。
“类炎”可能导致个别地简单的分类,但是却增加了整个系统的复杂性....每个小类都有自己的接口。这些接口的积累会在系统级别产生巨大的复杂性。小类也导致冗长的编程风格,这是由于每个类都需要样板。

现在所感知到的编程方法都是缩减方法长度,这样每个方法都需要在理解实现的基础上进行调用,增加了系统的复杂度。
如果不缩短方法长度,过长的方法实现,也会增加系统的认知成本。

看到上面的内容时,想起了之前周会上提到过的,需要保持代码的可阅读性。如果方法过长,可以通过增加方法的方式,使得每个方法的长度是在一定范围内的,从而使其他同学能直接了解到这个方法的逻辑,同时将独立的逻辑内聚到自己的方法块中。
虽然某种程度上来说,类和方法的拆分会增加系统的复杂度(增加修改成本和认知成本)。但又觉得模块的接口的定义和类的接口定义功能点是不一样的。
上述的说法主要是针对模块而言,需要提供深模块。模块的接口定义是给外部调用,意味着需要尽可能隐藏复杂度,简单的模块接口让调用方清晰认知到调用后执行的核心逻辑是什么。
类的接口(方法)定义某种程度上是给内部人员使用,相对而言上下文更加丰富,更能轻易了解每个拆分之后的方法执行的要点。这些浅接口(浅方法)(未隐藏调用和实现的复杂度)从使用上需要拥有更高的认知后才能合理使用。对于新上手的同学存在认知负担。

Java and Unix I/O示例

如今,最常见的分类病实例之一是Java类库。Java语言不需要很多小类,但是分类文化似乎已在Java编程社区中扎根
打开文件以便从文件中读取序列化的对象,必须创建三个不同的对象:

  • FileInputStream
  • BufferedInputStream
  • ObjectInputStream
    令人烦恼(并且容易出错)的是,必须通过创建一个单独的BufferedInputStream对象来显式请求缓冲....提供选择是好的,但是应该设计接口以常见情况尽可能简单。几乎每个文件I/O用户都希望缓冲,因此默认情况应提供缓冲。对于不需要缓冲的少数情况,该库可以提供一种禁用它的机制。

Unix系统调用的设计者使常见情况变得简单....如果一个接口具有许多功能,但是大多数开发人员只需要了解其中的一些功能,那么该接口的有效复杂性就是常用功能的复杂性。

结论

通过将模块的接口与其实现分开,我们可以将实现的复杂性从系统的其余部分中隐藏起来。模块的用户只需要了解其接口提供的抽象。

设计类和其他模块时,最重要的问题是使它们更深,以使它们具有适用于常见用例的简单接口,但仍提供重要的功能。这使隐藏的复杂性最大化。

用英语描述的接口比使用正式规范语言编写的接口对开发人员来说更直观和易于理解。

个人感觉,模块对于复杂度的隐藏要求与类甚至方法存在差异。

一个模块需要尽可能的隐藏其实现的复杂度,使得外部对于整个模块的理解和调用尽可能的简单。比如交易模块,主要输出的是创建交易单,提交交易单,取消交易单等动作。而不是输出只更新交易单一个字段的的接口,使得外部对于交易模块的理解成本增加。

但是类和方法,某种程度上很难平衡实现深度和认知成本。
相同的一套方法实现,
如果此方法短了,就需要更多的方法定义来概括一段段逻辑,从而增加了浅方法,会增加这个方法的修改成本,需要判断此次改动的地方,并寻找是否会影响其他逻辑;
如果方法长了,代码都挤在一起,导致可能难以一下子概括出某几块方法实现的目标是什么,因为方法名可能是`updateOrder`,但会有种种更新后的业务逻辑,从而导致方法理解成本增加,其他同学难以一下子理解更新订单的操作需要分成N步。

如何平衡深浅和认知成本是一个会经常碰到但难以明说的问题。