第 6 章 通用的模块是更深的
在讲授软件设计课程的过程中,我一直在尝试找出学生代码中复杂性的来源,这也改变了我对软件设计的若干看法。其中最重要的变化,涉及通用性与专用化的权衡。我反复发现,专用化会导致复杂性;现在我认为,过度专用化可能是软件中最大的复杂性来源。相反,更通用的代码更简单、更整洁,也更容易理解。
本章概览接口要多通用才合适?
这个原则适用于软件设计的不同层级。设计类或方法等模块时,构建深 API 的最佳方法之一是使其通用化(通用 API 能隐藏更多信息)。编写具体代码时,简化代码的最有效方法之一是消除特殊情况,让处理常见情况的代码也能处理边界情况。消除特殊情况还可以提高代码的效率,第 20 章将介绍这一点。
本章讨论专用化带来的问题和通用性的好处。专用化无法完全消除,因此本章也会介绍如何将专用代码与通用代码分开。
6.1 使类的接口足够通用
设计新类时,最常见的决策之一就是采用通用还是专用的实现方式。有人可能主张采用通用方式,构建一种能解决多种问题的机制,而不局限于当前重要的问题。这样,新机制将来可能派上意想不到的用场,从而节省时间。通用方式似乎符合第 3 章讨论的投资思维:前期多花一点时间,以节省以后的时间。
另一方面,软件系统的未来需求很难预测,因此通用方案可能包含永远用不到的功能。如果实现过于通用,还可能无法很好地解决眼前的具体问题。因此,也有人可能主张只关注当前需求,只构建确定需要的功能,并针对预期的使用方式进行专用化处理。采用专用方式后,如果将来发现其他用途,仍可以通过重构使其通用。专用方式似乎符合增量软件开发的理念。
刚开始讲授软件设计课程时,我倾向于第二种方式(先构建专用实现),但教过几轮后,我改变了主意。审查学生项目时,我注意到通用类几乎总是优于专用类。尤其令我惊讶的是,通用接口不仅更简单、更深,实现所需的代码也更少。事实证明,即使一个类只用于特定用途,以通用方式构建它也更省力。将来把这个类用于其他用途时,通用设计还能节省更多时间。即便不复用这个类,通用方式仍然更好。
根据我的经验,最合适的做法是让新模块适度通用。“适度通用”意味着,模块的功能应反映当前需求,但接口不应局限于这些需求。接口应该足够通用,能够支持多种用途,同时又便于满足当前需求,而不与当前用途绑定。“适度”很重要:不要追求通用性过了头,反而让模块难以用于当前需求。
6.2 示例:为编辑器存储文本
来看软件设计课程中的一个示例:学生需要构建一个简单的图形界面文本编辑器。编辑器必须能显示文件,并允许用户通过指向、点击和输入来编辑文件。它还必须支持在不同窗口中同时显示同一文件的多个视图,以及对文件修改进行多级撤销和重做。
每个学生项目都包括一个管理文件文本的类。文本类通常提供以下方法:将文件加载到内存、读取和修改文件中的文本,以及将修改后的文本写回文件。
许多学生团队为文本类实现了专用的 API。他们知道这个类将用于交互式编辑器,因此根据编辑器必须提供的功能,定制了文本类的 API。例如,用户按下退格键时,编辑器会删除紧邻光标左侧的字符;按下删除键时,则删除紧邻光标右侧的字符。为此,一些团队在文本类中为每项功能分别创建了一个方法:
void backspace(Cursor cursor);
void delete(Cursor cursor);
这些方法都以光标位置为参数,用专用类型 Cursor 表示这一位置。编辑器还必须支持复制或删除所选区域。学生为此定义了 Selection 类,并在删除时将这个类的对象传给文本类:
void deleteSelection(Selection selection);
学生们可能认为,让文本类的方法与用户可见的功能相对应,会更容易实现用户界面。实际上,这种专用化对用户界面代码几乎没有好处,却给开发用户界面或文本类的人带来了很高的认知负荷。文本类最终包含大量浅方法,每个都只适用于一种用户界面操作。许多方法(例如 delete)只在一处调用。结果,用户界面开发人员必须学习文本类的大量方法。
这种方式造成了用户界面与文本类之间的信息泄露。区域选择、退格键等用户界面抽象出现在文本类中,增加了文本类开发人员的认知负荷。每增加一种用户界面操作,都需要在文本类中定义一个新方法,因此用户界面开发人员很可能还要修改文本类。类设计的目标之一是让每个类都能独立开发,但专用方式却将用户界面与文本类绑定在了一起。
6.3 更通用的 API
更好的方法是使文本类更通用。其 API 应仅根据基本的文本功能进行定义,而不应反映用其实现的更高层级的操作。例如,只需提供两个方法即可修改文本:
void insert(Position position, String newText);
void delete(Position start, Position end);
前一个方法在文本的任意位置插入任意字符串,后一个方法删除从开始位置(包含)到结束位置(不包含)之间的所有字符。这个 API 还用更通用的 Position 类型代替了针对特定用户界面的 Cursor 类型。文本类也应提供操作文本位置的通用方法,例如:
Position changePosition(Position position, int numChars);
此方法返回一个新位置,与给定位置相距指定的字符数。如果 numChars 参数为正,新位置就在给定位置之后;如果 numChars 为负,新位置就在给定位置之前。必要时,该方法会自动跳到下一行或上一行。借助这些方法,可以用以下代码实现删除键的行为(假定 cursor 变量保存当前光标位置):
text.delete(cursor, text.changePosition(cursor, 1));
类似地,可以按以下方式实现退格键的行为:
text.delete(text.changePosition(cursor, -1), cursor);
使用通用文本 API 实现删除、退格等用户界面功能,代码比原先使用专用文本 API 时稍长一些,但更易理解。用户界面开发人员可能需要知道退格键会删除哪些字符。在新代码中,这一点一目了然;而在旧代码中,开发人员必须阅读文本类中退格方法的文档或代码,才能确认其行为。此外,通用方式的总代码量比专用方式更少,因为它用少量通用方法替代了文本类中的大量专用方法。
使用通用接口实现的文本类,除了交互式编辑器,还可能用于其他场景。例如,假设你要构建一个应用程序,将指定文件中所有匹配某个字符串的内容替换为另一个字符串。专用文本类中的方法(例如 backspace 和 delete)对这个应用程序几乎没有价值,而通用文本类已经具备它所需的大部分功能。缺少的只是查找给定字符串下一个匹配项的方法,例如:
Position findNext(Position start, String string);
当然,交互式文本编辑器很可能已有搜索和替换机制,这样文本类也就已经包含这个方法了。
6.4 通用性可以更好地隐藏信息
通用方式让文本类与用户界面类的职责划分更清晰,从而更好地隐藏信息。文本类无需了解如何处理退格键等用户界面细节,这些细节都封装在用户界面类中。添加新的用户界面功能时,也无需在文本类中增加配套的方法。通用接口还减轻了认知负荷:用户界面开发人员只需学习几个简单的方法,就能将它们用于多种用途。
文本类原始版本中的 backspace 方法是错误的抽象。它试图隐藏“删除哪些字符”这一信息,但用户界面模块恰恰需要知道这一点。用户界面开发人员很可能会阅读 backspace 方法的代码,以确认其确切行为。把这个方法放在文本类中,只会让他们更难获得所需的信息。软件设计最重要的要素之一,就是确定谁需要在什么时候知道什么。重要的细节应当明确表达,并尽可能容易看出来,修改后的退格操作实现就是如此。把这些信息隐藏在接口后面,只会产生模糊性。
译者图解三个删除动作,一种文本机制
6.5 问自己一些问题
识别一个整洁的通用类设计,比自己设计出来容易。下面这些问题可以帮助你在接口的通用性与专用性之间找到适当的平衡。
满足当前所有需求的最简单的接口是什么? 如果能减少 API 中的方法数量而不降低其整体功能,那你可能正在创建更通用的方法。专用的文本 API 至少具有三个删除文本的方法:backspace、delete 和 deleteSelection。而更通用的 API 只有一个删除文本的方法,它可以同时满足所有三个目的。仅在每个方法的 API 都保持简单的前提下,减少方法的数量才有意义。如果你必须引入许多额外的参数才能减少方法数量,那么你可能并没有真正简化接口。
这个方法会在多少种情况下被使用? 如果一个方法只为某种特定用途而设计,例如 backspace 方法,这就是一个危险信号,表明它可能过于专用。看看是否可以用一个通用方法替换几个专用方法。
这个 API 对于当前的需求来说是否易于使用? 这个问题可以帮助你判断,是否过度追求 API 的简单和通用。如果为了满足当前用途,必须额外编写大量代码才能使用这个类,这就是接口未提供合适功能的危险信号。例如,可以围绕单字符操作设计文本类:用 insert 方法插入单个字符,用 delete 方法删除单个字符。这个 API 既简单又通用,但在文本编辑器中不太便于使用:高层级代码需要编写大量循环来插入或删除一段字符,处理大规模操作时效率也很低。因此,文本类最好内置对字符范围操作的支持。
6.6 将专用代码上移(或下移!)
大多数软件系统都不可避免地包含一些专用代码。例如,应用程序为用户提供的具体功能往往具有很强的专用性,因此通常无法完全消除专用化。不过,专用代码应与通用代码明确分开。将专用代码沿软件栈上移或下移,就能做到这一点。
分离专用代码的一种方式是将其上移。应用程序的顶层类提供具体功能,自然需要针对这些功能进行专用化,但这种专用化不必渗透到实现功能所用的底层类中。本章前面的编辑器示例就体现了这一点。学生的原始实现将专用的用户界面细节(如退格键的行为)泄露到了文本类的实现中。改进后的文本 API 将所有专用代码上移到用户界面中,文本类只保留通用代码。
有时,将专用代码下移才是最好的分离方式,设备驱动程序就是一个例子。操作系统通常必须支持数百或数千种设备,例如各种辅助存储设备。每种设备都有自己的专用命令集。为了防止设备的专用特征泄露到操作系统主体代码中,操作系统会定义一个接口,规定所有辅助存储设备都必须实现的通用操作,例如“读取块”和“写入块”。设备驱动程序模块利用各设备的专用功能来实现这一通用接口。这样,专用代码就下移到了设备驱动程序中,编写操作系统核心代码时,无需了解特定设备的特征。这也便于添加新设备:只要设备具备实现驱动程序接口所需的功能,就可以将其加入系统,而无需修改操作系统主体代码。
6.7 示例:编辑器撤销机制
图形界面编辑器项目的一项要求是支持多级撤销和重做,不仅针对文本修改,也包括所选区域、插入光标和视图的变化。例如,用户选中一些文本并删除,滚动到文件中的其他位置,再执行撤销时,编辑器必须恢复到删除前的状态。这包括恢复被删除的文本、重新选中它,并让所选文本在窗口中可见。
一些学生项目把整个撤销机制都放在文本类中实现。文本类维护一个列表,记录所有可撤销的更改,每次修改文本时都会自动添加记录。对于所选区域、插入光标和视图的变化,用户界面代码会调用文本类中的额外方法,将相应记录加入撤销列表。用户请求撤销或重做时,用户界面代码调用文本类中的方法,由它处理撤销列表中的记录。对于文本相关的记录,直接更新文本类的内部状态;对于所选区域等其他内容的记录,文本类则回调用户界面代码,执行撤销或重做。
这种做法让文本类包含了一组难以协调的功能。撤销和重做的核心是一个通用机制:管理已执行的动作列表,并在撤销或重做时逐个处理这些动作。这个核心与针对文本、所选区域等具体内容的专用撤销和重做处理程序,一起放在文本类中。所选区域和插入光标的专用撤销处理程序与文本类的其他内容毫无关系,却造成了文本类与用户界面之间的信息泄露,还要求两个模块增加方法,来回传递撤销信息。将来每增加一种可撤销的实体,都需要修改文本类,为该实体添加专用方法。此外,通用的撤销核心与类中的通用文本功能也几乎没有关系。
将撤销和重做机制中的通用核心提取出来,放到单独的类中,就能解决这些问题:
public class History {
public interface Action {
public void redo();
public void undo();
}
History() {...}
void addAction(Action action) {...}
void addFence() {...}
void undo() {...}
void redo() {...}
}
在这个设计中,History 类管理一组实现了 History.Action 接口的对象。每个 History.Action 描述一个操作,例如插入文本或更改光标位置,并提供撤销或重做该操作的方法。History 类无需了解这些操作存储了什么信息,也不必知道它们如何实现撤销和重做。History 类维护一个历史列表,记录应用程序生命周期内执行的所有操作;它提供的撤销和重做方法,会响应用户请求,向后或向前遍历列表,并调用 History.Actions 中的撤销和重做方法。
History.Actions 都是专用对象,每个对象只了解某种特定的可撤销操作。它们在 History 类之外、了解相应操作的模块中实现。文本类可以实现 UndoableInsert 和 UndoableDelete 对象,描述文本的插入和删除。每次插入文本时,文本类都会创建一个新的 UndoableInsert 对象来描述此次插入,再调用 History.addAction 将它加入历史列表。编辑器的用户界面代码则可以创建 UndoableSelection 和 UndoableCursor 对象,描述所选区域和插入光标的变化。
History 类还允许将操作分组。例如,用户的一次撤销请求可以同时恢复被删除的文本、重新选中这些文本,并重新定位插入光标。History 类用栅栏来实现分组:栅栏是历史列表中的标记,用于分隔各组相关操作。每次调用 History.redo 都会向后遍历历史列表,撤销操作,直到下一个栅栏。栅栏的位置由更高层级的代码通过调用 History.addFence 决定。
译者注(勘误):原文此处写作
History.redo,但描述的是向后遍历历史列表并撤销操作。根据本节对撤销和重做的定义,此处应为History.undo。正文保留原文写法。
这种方式将撤销功能分为三类,分别在不同的地方实现:
- 管理操作、对操作分组并调用撤销和重做操作的通用机制(由
History类实现)。 - 特定操作的细节(由多个类实现,每个类都理解少量的操作类型)。
- 分组操作的策略(由高层级用户界面代码实现,以提供正确的整体应用程序行为)。
实现每一类功能时,都不必了解其他类别。History 类不知道正在撤销哪种操作,因此可以用于多种应用程序。每个操作类只了解一种操作,而 History 类和操作类都无需了解操作分组的策略。
关键的设计决策是将撤销机制的通用部分与专用部分分开:为通用部分创建单独的类,将专用部分下移到 History.Action 的子类中。确定这一点后,其余设计就顺理成章了。
译者注(勘误):原文称这些类为
History.Action的“子类”,但本节代码将History.Action声明为接口,因此更准确的说法是“实现该接口的类”。前文的History.Actions指多个实现该接口的对象,并非代码中另行声明的类型。正文保留原文表述。
注意:将通用代码与专用代码分开的建议,针对的是同一种机制中的代码。例如,专用的撤销代码(如撤销文本插入的代码)应与通用的撤销代码(如管理历史列表的代码)分开。但将一种机制的专用代码与另一种机制的通用代码放在一起,可能是合理的。文本类就是这样的例子:它实现了通用的文本管理机制,也包含专用的撤销代码。这些撤销代码是专用的,因为它们只处理文本修改的撤销。将它们与 History 类中的通用撤销代码放在一起并不合理,而放在文本类中则合理,因为它们与其他文本功能密切相关。
译者图解撤销时,谁知道要恢复什么?
6.8 消除代码里的特殊情况
前面的讨论围绕类和方法设计中的专用化展开。方法体中的特殊情况,是专用化的另一种形式。特殊情况可能让代码充斥着 if 语句,既难理解,也容易引入缺陷,因此应尽可能消除。最好的做法是设计好正常情况的处理逻辑,让它无需额外代码就能自动处理边界情况。
在文本编辑器项目中,学生需要实现区域选择机制,并支持复制或删除所选内容。大多数学生在实现时引入一个状态变量,表示所选区域是否存在。他们可能是因为有时屏幕上没有可见的所选区域,才认为在实现中表示这一状态很自然。然而,这种做法导致代码中出现大量检查,专门识别和处理“没有所选区域”的情况。
消除“没有所选区域”这一特殊情况,让所选区域始终存在,就能简化区域选择的处理代码。屏幕上没有可见的所选区域时,可以在内部用起止位置相同的空选区表示。这样,管理所选区域的代码就不必检查“没有所选区域”的情况。复制所选内容时,如果所选区域为空,就会在新位置插入 0 字节;只要实现正确,就不必将 0 字节作为特殊情况进行检查。同样,删除所选区域的代码也可以设计成无需特殊检查,就能处理空区域。以所选区域完全位于同一行内为例:删除时,取出该行中位于所选区域之前和之后的两部分,将它们拼接成新行。如果所选区域为空,这种做法就会重新生成原来那一行。
第 10 章将讨论异常带来的更多特殊情况,以及如何减少需要处理异常的位置。
译者图解没有选区,也能走同一条路径
6.9 结论
不必要的专用化是软件复杂性的重要来源,无论它表现为专用的类和方法,还是代码中的特殊情况。专用化无法完全消除,但良好的设计能显著减少它,并将专用代码与通用代码分开。这样可以使类更深,更好地隐藏信息,让代码更简单、更易理解。