《Growing Object-Oriented Software Guided by Tests》笔记

第一部分 引言 - Part I. Introduction

“通过测试优先的开发方式,我们获得了双重收益。”

“促使我们明确下一项工作的验收标准——必须自问如何判断任务完成(设计阶段);”

“鼓励我们编写松耦合的组件,以便它们能够轻松地被单独测试,并在更高层级上组合在一起(设计);”

“添加对代码功能的可执行描述(设计);以及,”

“添加到完整的回归测试套件中(实现);”

“面向对象设计更侧重于对象之间的通信,而非对象本身。”

“对象通过消息进行通信:它从其他对象接收消息,并通过向其他对象发送消息来做出反应,或许还会向原始发送者返回一个值或异常。每个对象都有处理其理解的所有类型消息的方法,并且在大多数情况下,封装了一些内部状态,用于协调其与其他对象的通信。”

“在设计系统时,重要的是要区分那些模拟不变量或测量的值,以及具有身份、可能随时间改变状态并模拟计算过程的对象。在我们大多数人使用的面向对象语言中,混淆在于这两个概念都是由相同的语言结构实现的:类。”

“另一方面,对象使用可变状态来模拟它们随时间的行为。即使两个同类型的对象现在具有完全相同的状态,它们也有独立的身份,因为如果它们接收到不同的消息,它们的状态可能会分化。”

“只有当我们的对象设计得易于插拔时,我们才能从这种高级的、声明式的方法中受益。在实践中,这意味着它们遵循常见的通信模式,并且它们之间的依赖关系是明确的。通信模式是一组规则,这些规则规定了对象组之间如何相互交流:它们扮演的角色、它们可以发送的消息以及发送的时间等等。”

“在最好的情况下,类层次结构代表应用程序的一个维度,为对象之间共享实现细节提供了一种机制”

“在最坏的情况下,我们见过太多代码库(包括我们自己的)因为使用一种机制来表示多个概念而遭受复杂性和重复。”

“我们让对象互相发送消息,那么它们在说些什么呢?我们的经验是,调用对象应该根据其邻居所扮演的角色来描述它想要什么,然后让被调用的对象决定如何实现这一点。这通常被称为“告知而非询问”风格,或者更正式地说,迪米特法则。对象仅根据它们内部持有的信息或触发消息带来的信息做出决策;它们避免导航到其他对象来执行操作。始终如一地遵循这种风格,可以生成更灵活的代码,因为很容易替换扮演相同角色的对象。调用者看不到它们的内部结构或角色接口后面系统的其他结构。”

“除了隐藏信息外,“告诉,而不是询问”还有一个更微妙的好处。它迫使我们明确命名对象之间的交互,而不是在获取链中让它们保持隐式状态。”

“当然我们不会“告诉”所有信息; 1 在从值和集合获取信息,或使用工厂创建新对象时,我们会“询问”。”


第二部分:测试驱动开发流程 - Part II. The Process of Test-Driven Development

“在编写单元测试和集成测试时,我们会留意代码中难以测试的部分。当我们发现一个难以测试的功能时,我们不仅会思考如何测试它,还会探究为什么难以测试。”

“我们的经验是,当代码难以测试时,最可能的原因是设计需要改进。现在让代码难以测试的结构,将来也会让代码难以修改。等到将来,由于我们会忘记当初编写代码时的思路,修改将变得更加困难。”

“我们的应对方法是,将编写测试的过程视为潜在维护问题的早期预警,并利用这些线索在问题尚未解决时立即修复。”

“随着代码规模的扩大,我们唯一能够继续理解和维护它的方式是将功能结构化为对象,对象结构化为包,包结构化为程序,程序结构化为系统。我们使用两种主要的启发式方法来指导这种结构化:”

“关注点分离”

“当我们需要改变系统的行为时,我们希望尽可能少地修改代码。如果所有相关的更改都在代码的一个区域,我们就不必在整个系统中寻找解决方案。由于我们无法预测何时需要更改系统的任何特定部分,我们将因相同原因而改变的代码聚集在一起。”

“更高层次的抽象”

“人类处理复杂性的唯一方法就是避免复杂性,通过在更高层次的抽象上工作。如果我们通过组合有用的功能组件来编程,而不是操作变量和控制流,我们就能完成更多工作;”

“如果始终如一地应用这两种力量,它们将推动应用程序的结构朝着类似 Cockburn 的“端口和适配器”架构发展[Cockburn08],在这种架构中,业务域的代码与其对技术基础设施(如数据库和用户界面)的依赖被隔离。我们不希望技术概念泄漏到应用程序模型中,因此我们使用其术语(Cockburn 的端口)编写接口来描述其与外部世界的关系。然后我们编写应用程序核心与每个技术域之间的桥梁(Cockburn 的适配器)。”

“在组织系统时,我们必须决定每个对象内部和外部的内容,以便对象提供一个连贯的抽象和清晰的 API。如前所述,对象的主要目的之一是通过其 API 封装对其内部的访问,并隐藏这些细节于系统的其余部分。对象通过发送和接收消息与其他系统中的对象通信,如图 6.2 所示;它直接通信的对象是其同级。”

“这个决定很重要,因为它影响了一个对象的使用难度,从而有助于提升系统的内部质量。如果我们通过对象的 API 暴露太多内部细节,其客户端最终会承担部分对象的工作。我们会在太多对象之间分散行为(它们会相互耦合),增加维护成本,因为任何改动都会波及整个代码。”

“每个对象都应该有一个单一、明确的责任;这是“单一责任”原则[Martin02]。当我们向系统中添加行为时,这个原则帮助我们决定是扩展现有对象还是为对象创建一个新服务来调用。”

“我们的经验法则是,我们应该能够不使用任何连词(“和”、“或”)来描述对象的功能。如果我们发现自己需要在描述中添加从句,那么这个对象可能应该被拆分成协作对象,通常每个从句对应一个对象。”

“我们有一些具有单一职责的对象,它们通过干净的 API 通过消息与其同侪通信,但它们彼此之间说什么?”

“对象从其同伴处需要的服务,以便它能够履行其职责。没有这些服务,对象无法正常工作。没有它们,对象不应该能够被创建。例如,一个图形包需要类似屏幕或画布的东西来绘制——没有它是没有意义的。”

“需要了解对象活动状态的同伴。每当对象状态发生变化或执行重要操作时,它都会通知感兴趣的同伴。通知是“发后即忘”;对象既不知道也不知道哪些同伴在监听。通知之所以有用,是因为它们将对象彼此解耦。”

“调整对象行为以适应系统更广泛需求的同类。这包括代表对象做出决策的策略对象([Gamma94]中的策略模式)以及如果对象是组合对象,则其组件部分。”

“一个复合对象的 API 不应比其任何组件的 API 更复杂。”

“封装几乎总是好事,但有时信息可能被隐藏在错误的地方。这使得代码难以理解、集成或通过组合对象来构建行为。最好的防御是在讨论设计时明确区分这两个概念。”

“我们编写的代码越多,就越确信我们应该定义类型来表示领域中的值概念,即使它们不做什么实际操作。这有助于创建一个更自解释的、一致的领域模型。”

“当我们发现对象中的代码变得复杂时,这通常是一个信号,表明它实现了多个关注点,我们可以将连贯的行为单元分离到辅助类型中。在《整理翻译器》(第 135 页)中有一个例子,我们将处理传入消息的类拆分为两部分:一部分用于解析消息字符串,另一部分用于解释解析结果。”

“当我们想在代码中标记一个新的领域概念时,我们通常会引入一个占位符类型,它包装单个字段,或者甚至没有任何字段。随着代码的增长,我们通过添加字段和方法来在新类型中填充更多细节。我们添加的每个类型都在提高代码的抽象级别。”

“当我们注意到一组值总是被一起使用时,我们认为这是一个缺失结构的暗示。第一步可能是创建一个新的类型,具有固定的公共字段——仅仅给这组值命名就突出了缺失的概念。稍后我们可以将行为迁移到新的类型中,这最终可能允许我们将它的字段隐藏在一个干净的接口后面,满足“组合体比其各部分之和更简单”的规则。”

“我们还倾向于让接口尽可能窄,即使这意味着我们需要更多的接口。接口上的方法越少,它在调用对象中的角色就越明显。我们不必担心哪些其他方法是与特定调用相关的,哪些是为了方便而包含的。窄接口也更容易编写适配器和装饰器;要实现的内容较少,因此更容易编写能够良好组合的对象。”

“正如我们在“萌芽”中描述的,“拉取”接口的产生有助于我们保持它们尽可能窄。由客户端驱动接口可以避免泄露关于其实现者的过多信息,从而最小化对象之间的任何隐式耦合,因此保持代码的灵活性。”

“我们编写了一层适配器对象(如[Gamma94]所述),该层使用第三方 API 来实现这些接口,如图 8.1 所示。我们尽可能保持这一层尽可能薄,以减少潜在的易碎且难以测试的代码量。我们通过聚焦的集成测试来测试这些适配器,以确认我们对第三方 API 工作方式的理解。与单元测试数量相比,集成测试数量会相对较少,因此即使它们不如内存中的单元测试快,也不应妨碍构建过程。”

“始终如一地采用这种方法,可以产生一组接口,这些接口以我们应用程序的术语定义了我们的应用程序与外部世界之间的关系,并阻止低级技术概念泄漏到应用程序的领域模型中。”


第三部分 实例分析 - Part III. A Worked Example

“我们花了一些时间研究文档并与 Southabee 的在线支持人员交谈,并确定了一个状态机,该状态机显示了 Sniper 可以进行的转换。”

“我们喜欢先编写一个测试,仿佛其实现已经存在,然后补充所需的一切使其能够运行——这正是阿贝尔森和苏斯曼所说的“愿望编程” [Abelson96]。从测试出发有助于我们专注于系统应该做什么,而不是陷入如何实现的复杂性中。”


第四部分 可持续的测试驱动开发 - Part IV. Sustainable Test-Driven Development

“有时我们发现自己很难为代码中想要添加的功能编写测试。根据我们的经验,这通常意味着我们的设计可以改进——也许类与其环境耦合得太紧密,或者缺乏明确的职责。当这种情况发生时,我们会首先检查这是否是一个改进代码的机会,然后再通过使测试更复杂或使用更复杂的工具来绕过设计。我们发现,使对象易于测试的品质也使我们的代码更容易适应变化。”

“我们可以通过使用全局值来绕过封装,从而向组件调用者隐藏依赖,但这并不能让依赖消失——它只是使其无法访问。”

“作为结构化代码的技术,面向对象的目标之一是使对象的边界清晰可见。对象应该只处理局部值和实例——在其作用域内创建和管理——或显式传递的值,正如我们在“上下文独立性”(第 54 页)中强调的那样。”