软件设计的哲学 第五章 隐藏信息
第四章论述了模块的深度。本章以及随后的几章将讨论创建深度模块的技术。
5.1 信息隐藏
实现深度模块最重要的技术是信息隐藏。这种技术首先由David Parnas描述。基本思想是每个模块应该封装一些知识,这些知识表示设计决策。该知识嵌入到模块的实现中,但不出现在其接口中,因此其他模块无法看到它。
模块中隐藏的信息通常包含如何实现某种机制的细节。以下是一些可能隐藏在模块中的信息示例:
- 如何在b树中存储信息,以及如何有效地访问它。
- 如何识别文件中每个逻辑块对应的物理磁盘块。
- 如何实现TCP网络协议。
- 如何调度多核处理器上的线程。
- 如何解析JSON文档。
隐藏信息包括与该机制相关的数据结构和算法。它还可以包括低级的细节,如页面的大小,还可以包括更抽象的高级概念,如大多数文件都很小的假设。
信息隐藏在两个方面降低了复杂性。首先,它将接口简化为模块。 接口反映了模块功能的更简单、更抽象的视图,并隐藏了细节;这减少了使用该模块的开发人员的认知负担。例如,使用B-tree类的开发人员不必担心树中节点的理想扇出,也不必担心如何保持树的平衡。其次,信息隐藏使系统更容易演化。 如果隐藏了一段信息,那么在包含该信息的模块之外就不存在对该信息的依赖,因此与该信息相关的设计更改将只影响一个模块。例如,如果TCP协议发生了变化(例如,为了引入一种新的拥塞控制机制),协议的实现就必须进行修改,但是在使用TCP发送和接收数据的高级代码中不需要进行任何修改。
在设计新模块时,您应该仔细考虑哪些信息可以隐藏在该模块中。如果您可以隐藏更多的信息,您还应该能够简化模块的接口,这使得模块更加深入。
注意:通过将变量和方法声明为私有来隐藏它们与信息隐藏是不同的。私有元素可以帮助信息隐藏,因为它们使项不可能从类外部直接访问。但是,关于私有项的信息仍然可以通过公共方法(如getter和setter方法)公开。当这种情况发生时,变量的性质和用法就像公开变量一样公开。
信息隐藏的最佳形式是将信息完全隐藏在模块中,这样模块的用户就不会看到这些信息。 然而,部分信息隐藏也有其价值。例如,如果某个特定的特性或信息片段只由少数类用户需要,并且通过单独的方法访问它,因此在最常见的用例中不可见,那么该信息大部分是隐藏的。这样的信息创建的依赖关系要比每个类用户可见的信息少。
5.2 信息泄漏
信息隐藏的反面是信息泄露。当一个设计决策反映在多个模块中时,就会发生信息泄漏。这在模块之间创建了一个依赖关系:对设计决策的任何更改都需要对所有相关模块进行更改。如果一段信息反映在模块的接口中,那么根据定义,它已经被泄露了;因此,更简单的接口往往与更好的信息隐藏相关。然而,即使信息没有出现在模块的接口中,它也可能被泄露。假设两个类都知道某种特定的文件格式(可能一个类读取这种格式的文件,另一个类写入这些文件)。即使这两个类都没有在其接口中公开该信息,它们也都依赖于文件格式:如果格式更改,则需要修改这两个类。像这样的后门泄漏比通过接口泄漏更有害,因为它并不明显。
信息泄漏是软件设计中最重要的危险信号之一。 作为一个软件设计师,你能学到的最好的技能之一就是对信息泄露的高度敏感性。如果您在类之间遇到信息泄漏,请自问“我如何才能重新组织这些类,使这些特定的知识只影响一个类?”如果受影响的类相对较小,并且与泄漏的信息紧密相关,那么将它们合并到一个类中是有意义的。另一种可能的方法是从所有受影响的类中提取信息,并创建一个只封装这些信息的新类。但是,这种方法只有在您能够找到一个从细节中抽象出来的简单接口时才有效;如果新类通过其接口公开了大部分知识,那么它就不会提供太多的价值(您只是用通过接口的泄漏替换了后门泄漏)。
危险信号:信息泄漏
当在多个地方使用相同的知识时,例如两个不同的类都理解特定类型文件的格式,就会发生信息泄漏。
5.3 时间分解
信息泄漏的一个常见原因是我称之为时间分解的设计风格。在时间分解中,系统的结构与操作发生的时间顺序相对应。考虑这样一个应用程序,它以特定的格式读取文件,修改文件的内容,然后再次将文件写出来。使用临时分解,这个应用程序可以分为三个类:一个用于读取文件,另一个用于执行修改,第三个用于写出新版本。文件的读取和写入步骤都需要了解文件的格式,从而导致信息泄漏。解决方案是将读写文件的核心机制合并到一个类中。这个类将在应用程序的读写阶段使用。很容易陷入时间分解的陷阱,因为在编写代码时,必须考虑操作发生的顺序。然而,在应用程序的生命周期中,大多数设计决策都会在几个不同的时间出现;因此,时间分解常常导致信息泄漏。
顺序通常很重要,所以它将反映在应用程序的某个地方。但是,它不应该反映在模块结构中,除非该结构与信息隐藏一致(可能不同的阶段使用完全不同的信息)。在设计模块时,要关注执行每个任务所需的知识,而不是任务发生的顺序。
危险信号:时间分解
在时间分解中,执行顺序反映在代码结构中:在不同时间发生的操作位于不同的方法或类中。如果在不同的执行点使用相同的知识,它就会被编码到多个地方,从而导致信息泄漏。
5.4示例:HTTP服务器
为了说明信息隐藏中的问题,让我们考虑一下在软件设计课程中实现HTTP协议的学生所做的设计决策。看到他们做得好的地方和有问题的地方是很有用的。
HTTP是Web浏览器用来与Web服务器通信的一种机制。当用户单击Web浏览器中的链接或提交表单时,浏览器使用HTTP通过网络向Web服务器发送请求。一旦服务器处理了请求,它会向浏览器发送一个响应;响应通常包含要显示的新Web页面。HTTP协议指定请求和响应的格式,两者都以文本形式表示。图5.1显示了一个描述表单提交的示例HTTP请求。课程要求学生实现一个或多个类,以便Web服务器更容易地接收传入的HTTP请求并发送响应。
图5.1:HTTP协议中的POST请求由通过TCP套接字发送的文本组成。每个请求包含一个初始行、一个以空行结尾的报头集合和一个可选的主体。初始行包含请求类型(POST用于提交表单数据)、指示操作(/comments/create)的URL和可选参数(photo_id的值为246),以及发送方使用的HTTP协议版本。每个标题行由一个名称组成,例如Content-Length后跟它的值。对于这个请求,主体包含额外的参数(注释和优先级)。
5.5 示例:类太多
学生最常犯的错误是把代码分成大量的浅层类,导致类与类之间的信息泄露。一个团队使用两个不同的类来接收HTTP请求;第一个类将来自网络连接的请求读入一个字符串,第二个类解析该字符串。这是一个时间分解的例子(“首先我们读取请求,然后我们解析它”)。信息泄漏是由于HTTP请求在不解析大部分消息的情况下无法读取;例如,Content-Length报头指定请求体的长度,因此必须解析报头才能计算总请求长度。因此,这两个类都需要理解HTTP请求的大部分结构,解析代码在这两个类中都是重复的。这种方法还为调用者增加了额外的复杂性,他们必须以特定的顺序调用不同类中的两个方法才能接收请求。
因为这些类共享了如此多的信息,所以最好将它们合并到一个处理请求读取和解析的类中。这提供了更好的信息隐藏,因为它将所有关于请求格式的知识隔离在一个类中,而且它还为调用者提供了更简单的接口(只需调用一个方法)。
这个例子说明了软件设计中的一个普遍主题:信息隐藏通常可以通过使类稍微大一点来改进。这样做的一个原因是将与特定功能相关的所有代码(比如解析HTTP请求)放在一起,这样得到的类就包含了与该功能相关的所有内容。增加类大小的第二个原因是提高接口的级别;例如,不是为一个计算的三个步骤中的每个步骤使用单独的方法,而是使用单个方法来执行整个计算。这可以导致一个更简单的接口。这两个优点都适用于前一段的示例:组合这些类将所有与解析HTTP请求相关的代码放在一起,并将两个外部可见的方法替换为一个。合并的类比原来的类更深。
当然,大类的概念可能太过宽泛(例如对于整个应用程序只有一个类)。第9章将讨论在什么情况下将代码分成多个更小的类是有意义的。
5.6 示例:HTTP参数处理
服务器接收到HTTP请求后,服务器需要访问请求中的一些信息。处理图5.1中的请求的代码可能需要知道photo_id参数的值。参数可以在请求的第一行中指定(图5.1中的photo_id),有时也可以在正文中指定(图5.1中的注释和优先级)。每个参数都有一个名称和一个值。参数的值使用一种称为URL编码的特殊编码;例如,在图5.1中的comment值中,使用“+”表示空格字符,使用“%21”代替“!”。为了处理请求,服务器将需要一些参数的值,并希望它们以未编码的形式出现。
大多数学生项目在参数处理方面做出了两个很好的选择。首先,他们认识到服务器应用程序并不关心参数是在头行还是请求体中指定的,所以他们对调用者隐藏了这种区别,并将来自两个位置的参数合并在一起。其次,他们隐藏了URL编码的知识:HTTP解析器在将参数值返回到Web服务器之前对其进行解码,这样图5.1中的comment参数值将被返回为“多么可爱的婴儿啊!””,而不是“+ +可爱+婴儿% 21”)。在这两种情况下,信息隐藏导致使用HTTP模块的代码使用更简单的api。
然而,大多数学生使用的接口返回的参数太浅,这导致了信息隐藏机会的丢失。大多数项目使用HTTPRequest类型的对象来保存解析后的HTTP请求,而HTTPRequest类只有一个像下面这样的方法来返回参数:
public Map<String, String> getParams() {
return this.params;
}
该方法不是返回单个参数,而是返回内部用于存储所有参数的映射的引用。这个方法是浅层的,它公开HTTPRequest类用来存储参数的内部表示。对该表示的任何更改都将导致接口的更改,这将需要对所有调用者进行修改。在修改实现时,更改通常涉及关键数据结构表示的更改(例如,为了提高性能)。因此,尽量避免暴露内部数据结构是很重要的。这种方法还为调用者提供了更多的工作:调用者必须首先调用getParams,然后必须调用另一个方法从映射中检索特定的参数。最后,调用者必须意识到他们不应该修改getParams返回的映射,因为这会影响HTTPRequest的内部状态。
这里是一个更好的接口检索参数值:
public String getParameter(String name) { ... }
public int getIntParameter(String name) { ... }
getParameter以字符串的形式返回参数值。它提供了比上面的getParams稍微深一些的接口;更重要的是,它隐藏了参数的内部表示。getIntParameter将参数的值从HTTP请求中的字符串形式转换为整数(例如,图5.1中的photo_id参数)。这将使调用者不必分别请求字符串到整数的转换,并向调用者隐藏该机制。如果需要,可以定义其他数据类型的其他方法,如getDoubleParameter。(如果所需的参数不存在,或者不能转换为请求的类型,所有这些方法都会抛出异常;上面的代码中省略了异常声明)。
5.7 示例:HTTP响应中的默认值
HTTP项目还必须为生成HTTP响应提供支持。学生们在这方面最常犯的错误就是不充分的违约。每个HTTP响应必须指定一个HTTP协议版本;一个团队要求调用者在创建响应对象时显式地指定此版本。但是,响应版本必须与请求对象中的响应版本对应,并且在发送响应时必须已经将请求作为参数传递(它指示将响应发送到何处)。因此,HTTP类自动提供响应版本更有意义。调用者不太可能知道要指定什么版本,如果调用者指定了一个值,可能会导致HTTP库和调用者之间的信息泄漏。HTTP响应还包括一个日期标头,指定发送响应的时间;HTTP库也应该为此提供一个合理的缺省值。
默认值说明了这样一个原则,即接口的设计应该尽可能使普通情况变得简单。它们也是部分信息隐藏的一个例子:在正常情况下,调用者不需要知道缺省项的存在。在调用者需要覆盖默认值的少数情况下,它必须知道这个值,并且可以调用一个特殊的方法来修改它。
只要可能,类应该“做正确的事情”,而不是被显式地询问。默认值就是一个例子。第26页的Java I/O示例以一种消极的方式说明了这一点。文件I/O中的缓冲是普遍需要的,因此没有人需要显式地请求它,甚至不需要知道它的存在;I/O类应该做正确的事情并自动提供它。最好的功能是那些你甚至不知道它们存在的功能。
危险信号:过度曝光
如果一个常用特性的API迫使用户了解很少使用的其他特性,这将增加不需要这些很少使用的特性的用户的认知负担。
5.8 隐藏在类中的信息
本章中的示例主要关注与类的外部可见api相关的信息隐藏,但是信息隐藏也可以应用于系统中的其他级别,比如类。尝试在类中设计私有方法,以便每个方法都封装一些信息或功能,并对类的其他部分隐藏这些信息或功能。此外,尽量减少使用每个实例变量的数量。有些变量可能需要在整个类中广泛地访问,但其他变量可能只需要在少数地方访问;如果可以减少使用变量的位置数量,就可以消除类中的依赖性并降低其复杂性。
5.9 不要过度隐藏
信息隐藏只有在被隐藏的信息在其模块之外不需要时才有意义。如果需要模块之外的信息,则不能隐藏它。假设某个模块的性能受到某些配置参数的影响,并且模块的不同使用需要不同的参数设置。在这种情况下,在模块的接口中公开参数是很重要的,这样就可以对它们进行适当的转换。作为一名软件设计师,您的目标应该是最小化模块外所需的信息量;例如,如果模块可以自动调整其配置,这比公开配置参数要好。但是,重要的是要识别模块外部需要哪些信息,并确保它是公开的。
5.10 结论
信息隐藏与深度模块密切相关。如果一个模块隐藏了很多信息,就会增加模块提供的功能,同时也减少了它的接口。这使得模块更深入。相反,如果一个模块没有隐藏很多信息,那么要么它没有太多的功能,要么它有一个复杂的接口;不管怎样,这个模块都是浅层的。
在将系统分解成模块时,尽量不受运行时操作发生顺序的影响;这将引导您进入时间分解的路径,这将导致信息泄漏和浅模块。相反,请考虑执行应用程序任务所需的不同知识片段,并设计每个模块来封装这些知识片段中的一个或几个。这将产生一个干净和简单的设计与深模块。