软件设计的哲学:第七章 不同层不同抽象
软件系统是分层组成的,其中较高层使用较低层提供的功能。在一个设计良好的系统中,每一层都提供了不同于其上下层的抽象;如果您通过调用方法跟随单个操作在层中上下移动,那么抽象会随着每个方法调用而变化。例如:
- 在文件系统中,最上层实现文件抽象。文件由可变长度的字节数组组成,可以通过读取和写入可变长度的字节范围来更新该数组。文件系统的下一层在大小固定的磁盘块的内存中实现缓存;调用者可以假设经常使用的块将驻留在内存中,可以快速地访问它们。最底层由设备驱动程序组成,它们在辅助存储设备和内存之间移动块。
- 在网络传输协议(如TCP)中,最顶层提供的抽象是一个可靠地从一台机器传递到另一台机器的字节流。这个级别构建在一个较低的级别上,它在机器之间以最大的努力传输有限大小的包;大多数包将成功交付,但有些包可能丢失或无法按顺序交付。
如果系统包含具有类似抽象的相邻层,这是一个危险信号,表明类分解存在问题。本章讨论了这种情况发生的情况、产生的问题以及如何重构以消除问题。
7.1 传递方法
当相邻层具有相似的抽象时,问题通常以pass-hrough方法的形式表现出来。传递方法是一种除了调用另一个签名与调用方法的签名类似或相同的方法之外几乎不做其他事情的方法。例如,一个实现GUI文本编辑器的学生项目包含一个几乎完全由传递方法组成的类。以下是该课程的节选:
public class TextDocument ... {
private TextArea textArea;
private TextDocumentListener listener;
...
public Character getLastTypedCharacter() {
return textArea.getLastTypedCharacter();
}
public int getCursorOffset() {
return textArea.getCursorOffset();
}
public void insertString(String textToInsert,int offset) {
textArea.insertString(textToInsert, offset);
}
public void willInsertString(String stringToInsert, int offset) {
if (listener != null) {
listener.willInsertString(this, stringToInsert, offset);
}
}
...
}
该类中的15个公共方法中有13个是传递方法。
危险信号:传递方法
透传方法是一种除了将其参数传递给另一个方法外什么也不做的方法,通常使用与透传方法相同的API。这通常表示类之间没有明确的责任划分。
传递方法使类变得更简单:它们增加了类的接口复杂性,从而增加了复杂性,但是它们不会增加系统的总体功能。在上面的四个方法中,只有最后一个方法具有任何功能,而且即使在那里也很简单:该方法检查一个变量的有效性。传递方法还创建类之间的依赖关系:如果TextArea中的insertString方法的签名更改了,那么TextDocument中的insertString方法将必须更改以匹配。
传递方法表明在类之间的责任划分上存在混乱。在上面的例子中,TextDocument类提供了一个insertString方法,但是插入文本的功能完全在TextArea中实现。这通常是一个坏主意:功能块的接口应该与实现该功能的类在同一个类中。当您看到从一个类到另一个类的传递方法时,请考虑这两个类,并自问“这些类分别负责哪些特性和抽象?”“你可能会注意到两个Class的职责有重叠。
解决方案是重构类,使每个类具有一组不同的、一致的职责。图7.1演示了几种实现此目的的方法。如图7.1(b)所示,一种方法是直接将较低级别的类暴露给较高级别类的调用者,从而从较高级别的类中删除对该特性的所有职责。另一种方法是在类之间重新分配功能,如图7.1(c)所示。最后,如果类不能被分解,最好的解决方案可能是合并它们,如图7.1(d)所示。
在上面的示例中,有三个职责相互交织的类:TextDocument、TextArea和TextDocumentListener。该学生通过在类之间移动方法并将这三个类拆分成两个类来消除传递方法,这三个类的职责更加明确。
7.2 接口复制什么时候可以?
具有相同签名的方法并不总是坏事。重要的是,每个新方法都应该提供重要的功能。传递方法不好,因为它们没有贡献任何新功能。
一个方法调用另一个具有相同签名的方法很有用的一个例子是dispatcher。dispatcher是一种方法,它使用自己的参数来选择要调用的其他方法之一;然后它将大部分或全部参数传递给所选择的方法。dispatcher的签名通常与它调用的方法的签名相同。即便如此,dispatcher也提供了有用的功能:它可以选择其他几个方法中的哪一个来执行每个任务。
图7.1:传递方法。在(a)中,类C1包含三个传递方法,它们只是调用具有相同签名的方法(每个符号表示一个特定的方法签名)。可以通过让C1的调用者直接调用C2(如(b))、在C1和C2之间重新分配功能(如(c)中避免类之间的调用)或合并(如(d)中所示的类来消除传递方法。
例如,当Web服务器从Web浏览器接收到传入的HTTP请求时,它将调用一个dispatcher,该dispatcher检查传入请求中的URL并选择一个特定的方法来处理请求。有些url可以通过返回磁盘上文件的内容来处理;其他方法可以通过调用PHP或JavaScript等语言中的过程来处理。分派过程可能非常复杂,通常由一组与传入URL匹配的规则驱动。
只要每个方法都提供有用的和不同的功能,那么几个方法具有相同的签名是可以的。dispatcher调用的方法具有此属性。另一个例子是具有多个实现的接口,比如操作系统中的磁盘驱动程序。每个驱动程序都支持不同类型的磁盘,但是它们都具有相同的接口。当多个方法提供了相同接口的不同实现时,就减少了认知负荷。一旦您使用了这些方法中的一种,使用其他方法就更容易了,因为您不需要学习新的接口。像这样的方法通常在同一层中,它们不会相互调用。
7.3 修饰符
装饰器设计模式(也称为“包装器”)鼓励跨层的API复制。装饰对象接受现有对象并扩展其功能;它提供一个与底层对象相似或相同的API,它的方法调用底层对象的方法。在第4章的Java I/O示例中,BufferedInputStream类是一个装饰器:给定一个InputStream对象,它提供了相同的API,但是引入了缓冲。例如,当它的read方法被调用来读取单个字符时,它会调用底层InputStream上的read来读取更大的块,并保存额外的字符来满足未来的read调用。另一个例子出现在窗口系统中:Window类实现了一个不能滚动的窗口的简单形式,而ScrollableWindow类通过添加水平和垂直滚动条来装饰窗口类。
decorator的动机是将类的特殊用途扩展与更通用的核心分离开来。然而,decorator类往往很肤浅:它们为少量的新功能引入了大量的样板文件。装饰类通常包含许多传递方法。很容易过度使用decorator模式,为每个小的新特性创建一个新类。这将导致大量的浅类,例如Java I/O示例。
在创建装饰类之前,请考虑以下替代方案:
- 您可以直接将新功能添加到底层类中,而不是创建装饰类吗?如果新功能是相对通用的,或者它在逻辑上与底层类相关,或者底层类的大多数用途也将使用新功能,那么这样做是有意义的。例如,几乎每个创建Java InputStream的人都会创建一个bufferinputstream,而缓冲是I/O的一个自然组成部分,所以这些类应该被组合在一起。
- 如果新功能是专门针对特定用例的,那么将它与用例合并而不是创建一个单独的类是否有意义?
- 您是否可以将新功能与现有的装饰器合并,而不是创建一个新的装饰器?这将导致一个更深层次的装饰类,而不是多个浅层次的装饰类。
- 最后,问问自己新功能是否真的需要包装现有功能:能否将其实现为独立于基类的独立类?在窗口示例中,滚动条可以与主窗口单独实现,而无需包装其所有现有功能。
有时候装饰器是有意义的,但是通常有更好的选择。
7.4接口与实现
“不同的层,不同的抽象”规则的另一个应用是,类的接口通常应该与其实现不同:内部使用的表示应该不同于接口中出现的抽象。如果这两个类有相似的抽象,那么这个类可能不是很深奥。例如,在第6章讨论的文本编辑器项目中,大多数团队以文本行来实现文本模块,每个行单独存储。一些团队还围绕行设计了文本类的api,使用了getLine和putLine等方法。然而,这使得文本类很肤浅,使用起来很笨拙。在高级用户界面代码中,通常在一行中间插入文本(例如,当用户键入时),或者删除跨越行的文本范围。使用文本类的面向行的API,调用者不得不分割和连接行来实现用户界面操作。这段代码非常重要,它被复制并分散在用户界面的实现中。
文本类更容易使用时提供了一个面向字符的接口,如插入方法,插入任意字符串的文本(可能包括换行)在任意位置的文本和一个删除方法,删除文本中的两个任意位置之间的文本。在内部,文本仍然以行表示。面向字符的接口封装了text类内部的行分割和连接的复杂性,这使得text类更深入,并简化了使用该类的高级代码。使用这种方法,文本API与面向行的存储机制有很大的不同;差异代表类提供的有价值的功能。
7.5传递变量
跨层API复制的另一种形式是传递变量,它是通过长方法链传递的变量。图7.2(a)显示了一个来自数据中心服务的示例。命令行参数描述用于安全通信的证书。这个信息只需要一个低级方法m3,它调用一个库方法来打开一个套接字,但是它通过main和m3之间路径上的所有方法传递下去。cert变量出现在每个中间方法的签名中。
传递变量增加了复杂性,因为它们强制所有中间方法知道它们的存在,即使这些方法对变量没有用处。此外,如果出现了一个新变量(例如,一个系统最初构建时不支持证书,但后来您决定添加该支持),您可能必须修改大量的接口和方法,以便通过所有相关路径传递该变量。
消除传递变量可能具有挑战性。一种方法是查看最上层和最下层的方法之间是否已经共享了对象。在图7.2的数据中心服务示例中,可能有一个对象包含关于网络通信的其他信息,它对main和m3都可用。如果是这样,main可以将证书信息存储在该对象中,因此不需要通过m3路径上的所有中间方法来传递它(参见图7.2(b))。但是,如果存在这样一个对象,那么它本身可能是一个传递变量(否则m3如何访问它?)
另一种方法是将信息存储在全局变量中,如图7.2(c)所示。这避免了在方法之间传递信息的需要,但是全局变量几乎总是会产生其他问题。例如,全局变量使得不可能在同一个流程中创建同一个系统的两个独立实例,因为对全局变量的访问将发生冲突。在生产环境中可能不太可能需要多个实例,但是它们在测试中通常很有用。
我最常用的解决方案是引入一个上下文对象,如图7.2(d)所示。上下文存储应用程序的所有全局状态(任何可能是传递变量或全局变量的内容)。大多数应用程序的全局状态中都有多个变量,表示配置选项、共享子系统和性能计数器等内容。每个系统实例有一个上下文对象。上下文允许系统的多个实例在单个流程中共存,每个实例都有自己的上下文。
不幸的是,很多地方可能都需要上下文,所以它可能成为一个传递变量。为了减少必须知道它的方法的数量,可以在系统的大多数主要对象中保存对上下文的引用。在图7.2(d)的示例中,包含m3的类将对上下文的引用作为实例变量存储在其对象中。创建新对象时,创建方法从其对象检索上下文引用,并将其传递给新对象的构造函数。使用这种方法,上下文在任何地方都是可用的,但它只在构造函数中作为显式参数出现。
图7.2:处理传递变量的可能技术。在(a)中,cert通过方法m1和m2传递,即使它们不使用它。在(b)中,main和m3共享对对象的访问,因此变量可以存储在那里,而不是通过m1和m2传递。在(c)中,cert存储为全局变量。在(d)中,cert与其他系统范围内的信息(如超时值和性能计数器)一起存储在上下文对象中;对上下文的引用存储在其方法需要访问它的所有对象中。
上下文对象统一了对所有系统全局信息的处理,并消除了传递变量的需要。如果需要添加新变量,可以将其添加到上下文对象中;除了上下文的构造函数和析构函数外,不影响任何现有代码。上下文很容易识别和管理系统的全局状态,因为它都存储在一个地方。上下文对于测试也很方便:测试代码可以通过修改上下文中的字段来更改应用程序的全局配置。如果系统使用传递变量,则很难实现此类更改。
上下文远非理想的解决方案。存储在上下文中的变量具有全局变量的大部分缺点;例如,可能不清楚为什么会出现某个特定变量,或者在哪里使用它。如果没有规则,上下文可能会变成一个巨大的数据包,在整个系统中创建不明显的依赖关系。上下文还可能产生线程安全问题;避免问题的最好方法是让上下文中的变量是不可变的。不幸的是,我还没有找到比上下文更好的解决方案。
7.6 结论
添加到系统设计中的每一块基础设施,如一个接口,,参数,函数,类,或定义,都增加了系统复杂性,因为开发人员必须了解这个元素。为了使一个元素对复杂性提供净收益,它必须消除在缺少设计元素时可能出现的一些复杂性。否则,您最好在没有特定元素的情况下实现系统。例如,一个类可以通过封装功能来降低复杂性,这样类的用户就不需要知道它。
“不同的层,不同的抽象”规则只是这个概念的一个应用:如果不同的层具有相同的抽象,例如传递方法或装饰器,那么很可能它们没有提供足够的好处来补偿它们所代表的额外基础设施。类似地,传递参数需要几个方法中的每一个都知道它们的存在(这增加了复杂性),而不需要提供额外的功能。