第 19 章 软件发展趋势
为了说明本书讨论的原则,本章将考察过去几十年来软件开发中流行的几种趋势和模式。对于每种趋势,我会说明它与本书原则的关系,并用这些原则评估它是否有助于降低软件复杂性。
本章概览流行的开发趋势真的降低复杂性吗?
19.1 面向对象编程和继承
在过去的三四十年间,面向对象编程是软件开发中最重要的新思想之一。它引入了类、继承、私有方法和实例变量等概念。如果谨慎使用,这些机制可以帮助我们做出更好的软件设计。例如,私有方法和变量可用于确保信息隐藏:类外部的任何代码都不能调用私有方法或访问私有变量,因此外部代码不会依赖它们。
面向对象编程的关键要素之一是继承。继承有两种形式,它们对软件复杂性的影响不同。第一种形式是接口继承:父类定义一个或多个方法的签名,但不实现这些方法。每个子类都必须实现这些方法,但不同子类可以采用不同的实现方式。例如,某个接口可能定义用于执行 I/O 的方法。一个子类可能对磁盘文件实现 I/O 操作,而另一个子类可能对网络套接字实现相同的操作。
接口继承让同一接口适用于多种用途,从而帮助降低复杂性。解决一个问题时获得的知识(例如如何使用 I/O 接口读取和写入磁盘文件),可以用于解决其他问题(例如通过网络套接字进行通信)。也可以从接口深浅的角度来理解:一个接口的不同实现越多,这个接口就越深。要支持多种实现,接口就必须体现所有底层实现共有的本质特性,同时不涉及各个实现之间的具体差异。这正是抽象的核心。
继承的第二种形式是实现继承。在这种形式下,父类不仅定义一个或多个方法的签名,还提供默认实现。子类可以选择继承父类提供的方法实现,也可以定义具有相同签名的新方法来覆盖它。如果没有实现继承,就可能需要在几个子类中重复相同的方法实现,从而在这些子类之间形成依赖关系(修改时需要同步更新方法的所有副本)。因此,实现继承减少了系统演进过程中需要修改的代码量。换句话说,它减轻了第 2 章中描述的变更放大问题。
但是,实现继承会在父类及其每个子类之间建立依赖关系。父类中的实例变量经常由父类和子类共同访问,导致继承层次中的类之间出现信息泄露,使我们很难在修改一个类时不考虑其他类。例如,修改父类的开发人员可能需要检查所有子类,以确保修改不会破坏现有行为。同样,如果子类覆盖了父类的方法,子类的开发人员可能需要检查父类中的实现。在最坏的情况下,程序员无论要修改哪个类,都必须完全了解父类下的整个类层次结构。广泛使用实现继承的类层次结构往往具有很高的复杂性。
因此,应谨慎使用实现继承。在使用之前,请考虑基于组合的方法是否可以提供相同的好处。例如,可以使用小型辅助类来实现共享功能。原有的类可以各自利用辅助类提供的功能,而不必从父类继承这些功能。
如果没有可行的方案来替代实现继承,请尝试将父类管理的状态与子类管理的状态分开。一种方法是让某些实例变量完全由父类中的方法管理,子类仅以只读方式或通过父类中的其他方法使用它们。这是在类层次结构中应用信息隐藏,以减少依赖性。
尽管面向对象编程提供的机制可能有助于实现整洁的设计,但是它们本身不能保证良好的设计。例如,如果类很浅,或者具有复杂的接口,或者允许从外部访问其内部状态,那么它们仍将导致很高的复杂性。
译者图解少复制代码,不等于少依赖
19.2 敏捷开发
敏捷开发是 20 世纪 90 年代末出现的一种软件开发方法,包含一系列让软件开发更轻量、更灵活、以增量方式进行的想法。它在 2001 年的一次从业者会议上得到正式定义。敏捷开发主要关注软件开发过程(团队组织、进度管理、单元测试的作用、与客户的交互等),而不是软件设计本身。不过,它与本书中的一些设计原则有关。
敏捷开发最重要的理念之一,是以增量和迭代的方式进行开发。在敏捷方法中,软件系统通过一系列迭代逐步开发,每次迭代都添加并评估一些新功能。每次迭代都包括设计、测试和客户反馈,这与本书提倡的增量方法相似。如第 1 章所述,不可能在项目开始时就把复杂系统构想得足够清楚,从而确定最佳设计。获得良好设计的最佳方法是逐步开发系统,在每次增量中添加一些新抽象,并根据经验重构现有抽象。这与敏捷开发方法相似。
敏捷开发的风险之一是它可能导致战术式编程。敏捷开发倾向于将开发人员的注意力集中在功能而非抽象上,并鼓励他们推迟设计决策,以便尽快产出可以工作的软件。例如,一些敏捷实践者认为,不应该太早实现通用机制;应该先实现一个最小的专用机制,等确定有需要时,再将其重构为更通用的机制。尽管这些论点有一定道理,但它们与投资思维相悖,并鼓励采用更偏向战术式编程的做法。这可能导致复杂性快速累积。
增量式开发通常是一个好主意,但是 软件开发的增量应该是抽象而不是功能。在有功能需要某个抽象之前,可以暂不考虑这个抽象。一旦需要,就应花一些时间做出整洁的设计,并遵循第 6 章的建议,使其具有一定的通用性。
译者图解共同的编码规则,才是复用的理由
19.3 单元测试
过去,开发人员很少编写测试。即使有测试,一般也是由独立的 QA 团队编写。然而,敏捷开发的原则之一是测试应与开发紧密结合,程序员应为自己的代码编写测试。这种做法现在已经很普遍。测试通常分为两类:单元测试和系统测试。单元测试是开发人员最常编写的测试。它们规模小、重点突出:每个测试通常验证单个方法中的一小段代码。单元测试可以独立运行,不需要为系统搭建生产环境。单元测试通常与测试覆盖率工具一起运行,以确保应用程序中的每一行代码都经过测试。每当开发人员编写新代码或修改现有代码时,都要负责更新单元测试,保持适当的测试覆盖率。
第二类是系统测试(有时称为集成测试),用于确保应用程序的不同部分能正常协同工作。这类测试通常需要在与生产环境类似的条件下运行整个应用程序。系统测试更有可能由独立的 QA 或测试小组编写。
测试,尤其是单元测试,在软件设计中起着重要作用,因为它们有助于重构。没有测试套件,对系统进行重大结构调整很危险:缺少便捷的缺陷检测手段,代码缺陷很可能直到新代码部署后才被发现,而此时查找和修复它们的成本要高得多。因此,在缺少良好测试套件的系统中,开发人员往往会避免重构。他们尽量减少实现新功能或修复缺陷所需的代码变更,导致复杂性不断累积,设计错误也得不到纠正。如果有良好的测试套件,开发人员在重构时就会更有信心,因为测试能发现大多数新引入的代码缺陷。这会鼓励开发人员改进系统结构,获得更好的设计。单元测试尤其有价值:与系统测试相比,它们提供更高的代码覆盖率,因此更有可能发现代码缺陷。
例如,在开发 Tcl 脚本语言期间,我们决定将 Tcl 的解释器替换为字节码编译器,以提高性能。这次变更规模很大,几乎影响了核心 Tcl 引擎的每个部分。幸运的是,Tcl 有一个出色的单元测试套件,我们在新的字节码引擎上运行了这些测试。现有测试在发现新引擎中的缺陷方面非常有效,字节码编译器的 alpha 版本发布后仅发现了一个缺陷。
19.4 测试驱动开发
测试驱动开发是一种软件开发方法,程序员先编写单元测试,再编写代码。创建一个新的类时,开发人员首先根据其预期行为为该类编写单元测试。因为该类还没有代码,没有一个测试能通过。然后,开发人员一次处理一个测试,编写足够的代码以使该测试通过。所有测试通过后,这个类就完成了。
尽管我是单元测试的坚决拥护者,但我并不热衷于测试驱动开发。测试驱动开发的问题在于,它把注意力放在让特定功能正常工作上,而不是寻找最佳设计。 这是纯粹的战术式编程,带有它的所有弊端。测试驱动开发过于强调逐步推进:开发人员随时都容易为了让下一个测试通过,而匆忙补上下一个功能。没有明确的时机来做设计,因此很容易使设计变得混乱。
如第 19.2 节所述,增量开发的单元应该是抽象,而不是功能。一旦发现对某个抽象的需求,就不要零散地构建它,而应一次性完成其设计(或至少做到能提供一组较为完整的核心功能)。这样更有可能做出整洁的设计,让各个部分相互契合。
有一种情况下,先编写测试很有意义,那就是修复代码缺陷时。修复之前,请先编写一个会因该缺陷而失败的单元测试,然后修复缺陷,并确保相应的单元测试能够通过。这是确认你已真正修复缺陷的最佳方法。如果先修复缺陷再编写测试,新写的单元测试可能根本不会触发该缺陷,也就无法确认问题是否真的得到修复。
译者图解通过的测试,真的测到了那个缺陷吗?
19.5 设计模式
设计模式是解决特定类型问题的常用方法,例如迭代器或观察者模式。设计模式这一概念因 Gamma、Helm、Johnson 和 Vlissides 合著的《设计模式:可复用的面向对象软件的基础》一书而普及,如今已广泛用于面向对象的软件开发。
设计模式为设计提供了另一种选择:与其从头设计新机制,不如采用一种广为人知的设计模式。大多数情况下,这是个好办法:设计模式能够解决常见问题,其解决方案也被普遍认为是整洁的。如果某种设计模式适合当前情况,你可能很难想出更好的方法。
设计模式最大的风险是被过度使用。并非每个问题都能用现有的设计模式整洁地解决。如果自行设计的方案更整洁,就不要硬套设计模式。采用设计模式并不会自动改善软件系统,只有模式适合当前问题时才有效。与软件设计中的许多想法一样,设计模式是好方法,并不意味着用得越多越好。
19.6 Getters 和 Setters
在 Java 编程社区中,Getter 和 Setter 方法是一种流行的设计模式。Getter 和 Setter 与一个类的实例变量相关联。它们具有类似 getFoo 和 setFoo 的名称,其中 Foo 是变量的名称。Getter 方法返回变量的当前值,Setter 方法修改该值。
由于实例变量可以是公有的,并非一定要使用 Getter 和 Setter 方法。使用 Getter 和 Setter 的理由是,它们允许在获取和设置变量值时执行额外操作,例如在变量更改时更新相关的值、将变化通知监听器,或者对值施加约束。即使最初不需要这些功能,以后也可以在不更改接口的情况下添加。
如果必须公开实例变量,使用 Getter 和 Setter 方法可能是合理的,但最好从一开始就避免公开实例变量。公开实例变量意味着类的部分实现在外部可见,这违反了信息隐藏的原则,也增加了类接口的复杂性。Getter 和 Setter 是浅方法(通常只有一行),提供的功能不多,却使类的接口变得杂乱。最好尽量避免使用 Getter 和 Setter,也尽量避免以其他方式公开实现层面的数据。
确立一种设计模式的风险之一是,开发人员一旦认为它是好方法,就会试图尽可能多地使用它。这导致了 Java 中 Getter 和 Setter 的过度使用。
19.7 结论
每当遇到新的软件开发范式提案,都应从复杂性的角度审视它:这项提案真的有助于尽量降低大型软件系统的复杂性吗?许多提案表面上听起来不错,但深入研究后就会发现,其中一些反而会增加复杂性。