既然您已经了解了复杂性的高级症状以及为什么复杂性会使软件开发变得困难,那么下一步就是了解导致复杂性的原因,以便我们设计系统来避免这些问题。复杂性是由两件事引起的:依赖性和模糊性。本节从高层次讨论这些因素。随后的章节将讨论它们与低级设计决策之间的关系。
就本书而言,当无法孤立地理解和修改给定的一段代码时,便存在依赖关系。该代码以某种方式与其他代码相关,如果更改了给定代码,则必须考虑和/或修改其他代码。在图 2.1(a)的网站示例中,背景色在所有页面之间创建了依赖关系。所有页面都必须具有相同的背景,因此,如果更改一页的背景,则必须更改所有背景。依赖关系的另一个示例发生在网络协议中。通常,协议的发送方和接收方有单独的代码,但是它们必须分别符合协议。更改发送方的代码几乎总是需要在接收方进行相应的更改,反之亦然。
依赖关系是软件的基本组成部分,不能完全消除。实际上,我们在软件设计过程中有意引入了依赖性。每次编写新类时,都会围绕该类的 API 创建依赖关系。但是,软件设计的目标之一是减少依赖关系的数量,并使依赖关系保持尽可能简单和明显。
考虑网站示例。在每个页面分别指定背景的旧网站中,所有网页都是相互依赖的。新的网站通过在中心位置指定背景色并提供一个 API,供各个页面在呈现它们时检索该颜色,从而解决了该问题。新的网站消除了页面之间的依赖关系,但是它围绕 API 创建了一个新的依赖关系以检索背景色。幸运的是,新的依赖性更加明显:很明显,每个单独的网页都取决于 bannerBg 颜色,并且开发人员可以通过搜索其名称轻松找到使用该变量的所有位置。此外,编译器还有助于管理 API 依赖性:如果共享变量的名称发生变化,任何仍使用旧名称的代码都将发生编译错误。新的网站用一种更简单,更明显的方式代替了一种不明显且难以管理的依赖性。
复杂性的第二个原因是晦涩。当重要的信息不明显时,就会发生模糊。一个简单的例子是一个变量名,它是如此的通用,以至于它没有携带太多有用的信息(例如,时间)。或者,一个变量的文档可能没有指定它的单位,所以找到它的惟一方法是扫描代码,查找使用该变量的位置。晦涩常常与依赖项相关联,在这种情况下,依赖项的存在并不明显。例如,如果向系统添加了一个新的错误状态,可能需要向一个包含每个状态的字符串消息的表添加一个条目,但是对于查看状态声明的程序员来说,消息表的存在可能并不明显。不一致性也是造成不透明性的一个主要原因:如果同一个变量名用于两个不同的目的,那么开发人员就无法清楚地知道某个特定变量的目的是什么。
在许多情况下,由于文档不足而导致模糊不清。 第十三章:注释应该描述代码中不明显的内容 讨论了这个主题。但是,模糊性也是设计问题。如果系统设计简洁明了,则所需的文档将更少。对大量文档的需求通常是一个警告,即设计不正确。减少模糊性的最佳方法是简化系统设计。
依赖性和模糊性共同构成了 2.2. 复杂性的症状 中描述的三种复杂性表现。依赖性导致变化放大和高认知负荷。晦涩会产生未知的未知数,还会增加认知负担。如果我们找到最小化依赖关系和模糊性的设计技术,那么我们就可以降低软件的复杂性。
Now that you know the high-level symptoms of complexity and why complexity makes software development difficult, the next step is to understand what causes complexity, so that we can design systems to avoid the problems. Complexity is caused by two things: dependencies and obscurity. This section discusses these factors at a high level; subsequent chapters will discuss how they relate to lower-level design decisions.
For the purposes of this book, a dependency exists when a given piece of code cannot be understood and modified in isolation; the code relates in some way to other code, and the other code must be considered and/or modified if the given code is changed. In the Web site example of Figure 2.1(a), the background color creates dependencies between all of the pages. All of the pages need to have the same background, so if the background is changed for one page, then it must be changed for all of them. Another example of dependencies occurs in network protocols. Typically there is separate code for the sender and receiver for the protocol, but they must each conform to the protocol; changing the code for the sender almost always requires corresponding changes at the receiver, and vice versa. The signature of a method creates a dependency between the implementation of that method and the code that invokes it: if a new parameter is added to a method, all of the invocations of that method must be modified to specify that parameter.
Dependencies are a fundamental part of software and can’t be completely eliminated. In fact, we intentionally introduce dependencies as part of the software design process. Every time you write a new class you create dependencies around the API for that class. However, one of the goals of software design is to reduce the number of dependencies and to make the dependencies that remain as simple and obvious as possible.
Consider the Web site example. In the old Web site with the background specified separately on each page, all of the Web pages were dependent on each other. The new Web site fixed this problem by specifying the background color in a central place and providing an API that individual pages use to retrieve that color when they are rendered. The new Web site eliminated the dependency between the pages, but it created a new dependency around the API for retrieving the background color. Fortunately, the new dependency is more obvious: it is clear that each individual Web page depends on the bannerBg color, and a developer can easily find all the places where the variable is used by searching for its name. Furthermore, compilers help to manage API dependencies: if the name of the shared variable changes, compilation errors will occur in any code that still uses the old name. The new Web site replaced a nonobvious and difficult-to-manage dependency with a simpler and more obvious one.
The second cause of complexity is obscurity. Obscurity occurs when important information is not obvious. A simple example is a variable name that is so generic that it doesn’t carry much useful information (e.g., time). Or, the documentation for a variable might not specify its units, so the only way to find out is to scan code for places where the variable is used. Obscurity is often associated with dependencies, where it is not obvious that a dependency exists. For example, if a new error status is added to a system, it may be necessary to add an entry to a table holding string messages for each status, but the existence of the message table might not be obvious to a programmer looking at the status declaration. Inconsistency is also a major contributor to obscurity: if the same variable name is used for two different purposes, it won’t be obvious to developer which of these purposes a particular variable serves.
In many cases, obscurity comes about because of inadequate documentation; Chapter 13 deals with this topic. However, obscurity is also a design issue. If a system has a clean and obvious design, then it will need less documentation. The need for extensive documentation is often a red flag that the design isn’t quite right. The best way to reduce obscurity is by simplifying the system design.
Together, dependencies and obscurity account for the three manifestations of complexity described in Section 2.2. Dependencies lead to change amplification and a high cognitive load. Obscurity creates unknown unknowns, and also contributes to cognitive load. If we can find design techniques that minimize dependencies and obscurity, then we can reduce the complexity of software.