小马的世界

读书笔记-软件设计的哲学【19】软件发展趋势

2026-06-27 · 16 min read

为了进一步说明本书讨论的设计原则,本章将分析过去几十年软件开发中流行起来的一些趋势和模式。对于每一种趋势,我都会讨论它与本书原则之间的关系,并利用这些原则来评估:这种趋势究竟是在帮助我们对抗软件复杂性,还是在加剧复杂性。

面向对象编程与继承(Object-oriented programming and inheritance)

面向对象编程(Object-Oriented Programming,OOP)是过去三四十年来软件开发中最重要的新思想之一。它引入了类(class)、继承(inheritance)、私有方法(private methods)以及实例变量(instance variables)等概念。如果合理地使用这些机制,它们能够帮助构建更好的软件设计。

例如,私有方法和私有变量可以用于实现 信息隐藏(information hiding) :类之外的代码既不能调用私有方法,也不能访问私有变量,因此外部代码不会对它们产生依赖。

继承是面向对象编程中的核心机制之一。继承主要有两种形式,它们对软件复杂性的影响完全不同。

第一种是 接口继承(interface inheritance)

在这种形式下,父类只定义一个或多个方法的接口(签名),而不提供具体实现。每个子类都必须实现这些接口,但不同子类可以采用不同的实现方式。

例如,一个接口可能定义了一组用于 I/O 操作的方法,一个子类可以将它们实现为磁盘文件操作,而另一个子类则可以实现为网络 Socket 操作。

接口继承能够通过 复用统一接口 来降低复杂性,使同一个接口能够服务于多个不同场景。它还能把在解决一个问题时积累的知识(例如如何通过 I/O 接口读写磁盘文件)迁移到其他问题(例如通过网络 Socket 通信)中。

另一种理解方式,是从 深度(depth) 来思考。

一个接口拥有的不同实现越多,它的抽象深度通常就越高。为了能够支持如此多种实现,一个优秀的接口必须能够抓住所有实现真正共有的本质特征,同时避开那些因具体实现而产生的细节差异。

而这,正是 抽象(abstraction) 思想的核心。

第二种继承形式是 实现继承(implementation inheritance)

在这种模式下,父类不仅定义方法接口,还提供默认实现。子类可以直接继承父类的方法实现,也可以重新定义同名方法来覆盖(override)父类实现。

如果没有实现继承,那么同一套方法实现可能不得不复制到多个子类中,从而导致这些子类之间形成依赖(例如,一个方法需要修改时,就必须同步修改所有副本)。

因此,实现继承减少了系统演进过程中需要修改的代码量。换句话说,它能够缓解第 2 章所介绍的**变更放大(change amplification)**问题。

然而,实现继承也会在父类与所有子类之间建立依赖关系。

父类中的实例变量通常会同时被父类和子类访问,这导致继承体系中的类之间发生 信息泄漏(information leakage) ,使得开发者很难只修改某一个类,而不去查看整个继承体系中的其他类。

例如,当开发者修改父类时,往往不得不检查所有子类,以确认这些修改不会破坏它们。

同样,如果某个子类覆盖了父类的方法,那么维护该子类的人通常也需要阅读父类中的实现。

最糟糕的情况下,为了修改继承体系中的任意一个类,开发者必须了解父类下面 整个类层次结构(class hierarchy)

大量使用实现继承的类层次通常都会具有较高的复杂性。

因此, 实现继承应该谨慎使用。

在采用实现继承之前,应首先思考: 是否可以通过组合(composition)实现同样的效果。

例如,可以利用一些较小的辅助类(helper classes)来实现公共功能。

与其从父类继承这些功能,不如让多个原始类都基于这些辅助类进行组合和构建。

如果确实没有可行方案能够替代实现继承,那么应尽量 将父类维护的状态与子类维护的状态分离

其中一种方法,就是让某些实例变量完全由父类的方法负责管理,而子类只能以只读方式访问这些变量,或者只能通过父类提供的方法访问。

这样实际上就是把 信息隐藏(information hiding) 应用到了整个类继承体系中,以减少类之间的依赖。

虽然面向对象提供的这些机制能够帮助我们设计出更好的软件,但 它们本身并不能保证优秀的设计。

例如,如果类本身缺乏深度(shallow)、接口设计复杂,或者允许外部代码直接访问内部状态,那么系统依然会具有很高的复杂性。

敏捷开发

敏捷开发(Agile Development)是一种软件开发方法,它起源于 20 世纪 90 年代末。当时,人们提出了一系列理念,希望让软件开发更加轻量、灵活,并且能够渐进式地推进。2001 年,这些理念经过一次实践者会议后,被正式定义为敏捷开发。

敏捷开发主要关注的是 软件开发过程 ,例如如何组织团队、如何安排开发计划、单元测试应扮演什么角色、如何与客户协作等,而不是软件设计本身。

尽管如此,它仍然与本书介绍的一些设计原则密切相关。

敏捷开发最重要的理念之一,是认为软件开发应该是 渐进式(incremental)迭代式(iterative) 的。

在敏捷开发中,一个软件系统通过一系列迭代不断演进。每次迭代都会增加少量新功能,并进行验证;每一次迭代都包含设计、测试以及客户反馈。

这一思想与本书倡导的渐进式设计非常相似。

正如第 1 章所讨论的那样,在项目刚开始时,我们几乎不可能准确预见整个复杂系统,并一次性设计出最佳方案。

获得优秀设计的最佳方式,是 逐步构建系统 :每一次迭代增加少量新的抽象,并依据实际经验不断重构已有抽象。

这与敏捷开发的方法基本一致。

然而,敏捷开发也存在一定风险,那就是它可能导致 战术式编程(tactical programming)

敏捷开发往往让开发者更加关注 功能(features) ,而不是 抽象(abstractions) ;它鼓励开发者推迟设计决策,以便尽快交付可以工作的软件。

例如,一些敏捷实践者认为,不应该一开始就设计通用机制,而应先实现一个满足当前需求的最小专用方案,等真正确定有需求时,再通过重构将其推广成更加通用的设计。

这些观点在一定程度上是合理的。

然而,它们同时也削弱了 投资式开发(investment approach) 的思想,并鼓励开发者采用更加偏向战术性的编程方式。

其结果,很可能是 复杂性迅速积累

渐进式开发总体来说是一种很好的思想,但开发过程中的“增量(increments)”应该是抽象(abstractions),而不是功能(features)。

完全可以在某个抽象真正被某项功能需要之前,不去考虑它。一旦确定需要这个抽象,就应该投入足够的时间,把它设计得足够干净,并遵循第 6 章中的建议,使它具有一定的通用性,而不是仅仅满足眼前需求。

单元测试(Unit tests)

过去,开发者很少自己编写测试。即使编写测试,通常也是由独立的 QA 团队负责。

然而,敏捷开发的一项核心理念认为:测试应该与开发紧密结合 ,程序员应当为自己编写的代码负责测试。这一实践如今已经十分普遍。

测试通常分为两类:单元测试(unit tests)系统测试(system tests)

单元测试是开发者最常编写的一类测试。它们通常规模较小、关注点单一,每个测试一般只验证某个方法中的一小段代码。

单元测试能够在隔离环境下运行,而无需搭建整个生产环境。

开发者通常还会结合 代码覆盖率(test coverage) 工具运行单元测试,以确保程序中的每一行代码都得到测试。

每当开发者新增代码或修改已有代码时,都需要同步维护相应的单元测试,以保证测试覆盖率不会下降。

另一类测试是 系统测试(system tests)(有时也称为 集成测试(integration tests) )。

系统测试用于验证应用程序各个部分是否能够正确协同工作。

它们通常需要在接近生产环境的条件下运行整个应用程序。

系统测试通常更可能由独立的 QA 或测试团队编写。

测试,尤其是 单元测试 ,对于软件设计具有十分重要的意义,因为它能够促进 重构(refactoring)

如果没有完善的测试套件,对系统进行大规模结构调整将会变得非常危险。

因为开发者没有简单可靠的方法发现 Bug,因此很多问题可能直到新代码部署之后才会暴露,而此时修复 Bug 的成本往往远高于开发阶段。

因此,在缺乏高质量测试的系统中,开发者通常会避免重构,而倾向于让每个新功能或 Bug 修复都尽可能少改动代码。

结果便是:复杂性不断积累,而设计中的错误始终得不到修正。

如果拥有完善的测试集,开发者在重构时就会更加有信心,因为测试能够帮助发现绝大多数由于修改而引入的新 Bug。

这鼓励开发者持续改善系统结构,从而获得更优秀的软件设计。

其中,单元测试尤其重要 ,因为它们通常比系统测试具有更高的代码覆盖率,因此更容易发现隐藏的问题。

例如,在开发 Tcl 脚本语言的过程中,我们曾决定通过 字节码编译器(byte-code compiler) 替换 Tcl 的解释器,以提升性能。

这是一次影响几乎整个 Tcl 核心引擎的大规模改动。

幸运的是,Tcl 拥有一套非常完善的单元测试,因此我们能够直接在新的字节码引擎上运行所有已有测试。

这些测试在发现新引擎中的缺陷方面极其有效,以至于在字节码编译器发布 Alpha 版本之后,只发现了一个 Bug。

测试驱动开发(Test-driven development)

测试驱动开发(TDD,Test-Driven Development)是一种软件开发方法,它要求程序员 先编写单元测试,再编写代码。

创建一个新类时,开发者首先根据该类预期的行为编写测试。

由于此时类还没有实现,因此所有测试都会失败。

随后,开发者逐个处理这些测试,每次只编写足够让当前测试通过的代码。

当所有测试全部通过时,这个类也就完成了。

虽然我是单元测试的坚定支持者,但我并不赞成测试驱动开发。

测试驱动开发最大的问题在于:它关注的是如何尽快让某个具体功能工作,而不是如何设计出最优秀的软件结构。

这实际上就是一种典型的 战术式编程(tactical programming) ,它继承了战术式编程所有的问题。

测试驱动开发过于强调渐进式开发。

在任何一个时间点,开发者都很容易只是“补上”下一项功能,使下一个测试通过。

由于整个过程中没有一个自然的时机去认真思考整体设计,因此最终的软件很容易变得混乱。

正如 19.2 节所讨论的,开发的基本单位应该是抽象,而不是功能。

一旦发现需要一个新的抽象,就不要在漫长的开发过程中一点一点地拼凑它,而应该一次性完成它(至少完成一个较完整的核心版本)。

这样更容易得到一个各部分彼此协调、整体一致的优秀设计。

真正适合采用“先写测试”的地方,是 修复 Bug

修复 Bug 之前,先写一个能够因为该 Bug 而失败的单元测试。

然后修复 Bug,并确认测试已经通过。

这是验证 Bug 是否真正修复的最佳方式。

如果你先修复 Bug,再补写测试,那么新的测试很可能实际上根本无法触发那个 Bug,这样它就无法证明问题是否真的已经被修复。

设计模式

设计模式(Design Pattern) 是解决某一类特定问题的一种常见方法,例如 迭代器(Iterator) 模式或 观察者(Observer) 模式。

设计模式这一概念因 Gamma、Helm、Johnson 和 Vlissides 合著的《 Design Patterns: Elements of Reusable Object-Oriented Software 》而广为流传,如今已经成为面向对象软件开发中的重要组成部分。

设计模式可以看作是自行设计机制的一种替代方案。

与其从零开始设计新的机制,不如直接采用已经广泛验证过的设计模式。

总体而言,这是一个不错的思路。

设计模式之所以出现,是因为它们能够解决常见问题,而且通常能够提供简洁优雅的解决方案。

如果某种设计模式已经非常适合你的场景,那么你往往很难再设计出比它更好的方案。

设计模式最大的风险在于 过度使用(over-application)

并不是所有问题都能够自然地使用现有设计模式解决;如果一个自定义方案更加简洁,就不要为了使用设计模式而强行套用设计模式。

使用设计模式 并不会自动提升软件质量 ;只有当设计模式真正适用于当前问题时,它才会带来收益。

与软件设计中的许多思想一样,设计模式是好的,并不意味着设计模式越多越好。

Getter 与 Setter

在 Java 社区中,GetterSetter 是一种非常流行的设计模式。

Getter 与 Setter 通常对应类中的某个实例变量。

它们一般采用 getFoosetFoo 这样的命名方式,其中 Foo 是变量名。

Getter 返回变量当前的值,而 Setter 修改变量的值。

严格来说,Getter 和 Setter 并不是必须存在,因为实例变量本身也可以直接设为 public

支持 Getter 和 Setter 的理由是,它们允许在读取或修改变量时执行额外逻辑,例如:

  • 当变量变化时同步更新相关数据;
  • 通知监听者(listeners)变量发生变化;
  • 对变量取值施加约束。

即使这些功能一开始并不需要,将来也可以在不改变接口的情况下添加进去。

然而,即使确实需要对实例变量提供访问接口,最好也不要在一开始就暴露实例变量。

实例变量一旦暴露,就意味着类实现的一部分细节也暴露到了外部。

这违反了**信息隐藏(information hiding)**原则,并增加了类接口的复杂性。

Getter 和 Setter 通常都是非常浅层(shallow)的方法(往往只有一行代码),几乎没有增加任何真正的功能,却不断膨胀类的接口。

因此,应当尽可能避免 Getter、Setter(以及任何形式的实现细节暴露)。

设计模式还有一个风险,那就是开发者容易认为:既然这是一个好模式,就应该尽可能多地使用。

Java 中 Getter 和 Setter 的泛滥,就是这种思维方式造成的典型例子。

总结

每当你遇到一种新的软件开发理念或开发范式时,都应该从 复杂性(complexity) 的角度去审视它。

它真的能够帮助大型软件系统降低复杂性吗?
很多理念表面上听起来都非常有吸引力。

但如果深入分析,你会发现,其中有不少实际上是在 增加复杂性,而不是减少复杂性。