14
3

The Dependency Inversion Principle (DIP) tells us that the most flexible systems are those in which source code dependencies refer only to abstractions, not to concretions.

依赖反转原则(DIP)主要想告诉我们的是,如果想要设计一个灵活的系统,在源代码层次的依赖关系中就应该多引用抽象类型,而用具体实现。

In a statically typed language, like Java, this means that the use, import, and include statements should refer only to source modules containing interfaces, abstract classes, or some other kind of abstract declaration. Nothing concrete should be depended on.

也就是说,在 Java 这类静态类型的编程语言中,在使用 use、import、include 这些语句时应该只引用那些包含接口、抽象类或者其他抽象类型声明的源文件,不应该引用任何具体实现。

The same rule applies for dynamically typed languages, like Ruby and Python. Source code dependencies should not refer to concrete modules. However, in these languages it is a bit harder to define what a concrete module is. In particular, it is any module in which the functions being called are implemented.

同样的,在 Ruby、Python 这类动态类型的编程语言中,我们也不应该在源代码 层次上引用包含具体实现的模块。当然,在这类语言中,事实上很难清晰界定某个模块是否属于“具体实现”。

Clearly, treating this idea as a rule is unrealistic, because software systems must depend on many concrete facilities. For example, the String class in Java is concrete, and it would be unrealistic to try to force it to be abstract. The source code dependency on the concrete java.lang.string cannot, and should not, be avoided.

显而易见,把这条设计原则当成金科玉律来加以严格执行是不现实的,因为软件系统在实际构造中不可避免地需要依赖到一些具体实现。例如,Java 中的 String 类就是这样一个具体实现,我们将其强迫转化为抽象类是不现实的,而在源代码层次上也无法避免对 java.lang.String 的依赖,并且也不应该尝试去避免。

By comparison, the String class is very stable. Changes to that class are very rare and tightly controlled. Programmers and architects do not have to worry about frequent and capricious changes to String.

但 String 类本身是非常稳定的,因为这个类被修改的情况是非常罕见的,而且可修改的内容也受到严格的控制,所以程序员和软件架构师完全不必担心 String 类上会发生经常性的或意料之外的修改。

For these reasons, we tend to ignore the stable background of operating system and platform facilities when it comes to DIP. We tolerate those concrete dependencies because we know we can rely on them not to change.

同理,在应用 DIP 时,我们也不必考虑稳定的操作系统或者平台设施,因为这些系统接口很少会有变动。

It is the volatile concrete elements of our system that we want to avoid depending on. Those are the modules that we are actively developing, and that are undergoing frequent change.

我们主要应该关注的是软件系统内部那些会经常变动的(volatile)具体实现模块,这些模块是不停开发的,也就会经常出现变更。

STABLE ABSTRACTIONS 稳定的抽象层
Every change to an abstract interface corresponds to a change to its concrete implementations. Conversely, changes to concrete implementations do not always, or even usually, require changes to the interfaces that they implement. Therefore interfaces are less volatile than implementations.

我们每次修改抽象接口的时候,一定也会去修改对应的具体实现。但反过来,当我们修改具体实现时,却很少需要去修改相应的抽象接口。所以我们可以认为接口比实现更稳定。

Indeed, good software designers and architects work hard to reduce the volatility of interfaces. They try to find ways to add functionality to implementations without making changes to the interfaces. This is Software Design 101.

的确,优秀的软件设计师和架构师会花费很大精力来设计接口,以减少未来对其进行改动。毕竟争取在不修改接口的情况下为软件增加新的功能是软件设计的基础常识。

The implication, then, is that stable software architectures are those that avoid depending on volatile concretions, and that favor the use of stable abstract interfaces. This implication boils down to a set of very specific coding practices:

也就是说,如果想要在软件架构设计上追求稳定,就必须多使用稳定的抽象接口,少依赖多变的具体实现。下面,我们将该设计原则归结为以下几条具体的编码守则:

Don’t refer to volatile concrete classes. Refer to abstract interfaces instead. This rule applies in all languages, whether statically or dynamically typed. It also puts severe constraints on the creation of objects and generally enforces the use of Abstract Factories.
Don’t derive from volatile concrete classes. This is a corollary to the previous rule, but it bears special mention. In statically typed languages, inheritance is the strongest, and most rigid, of all the source code relationships; consequently, it should be used with great care. In dynamically typed languages, inheritance is less of a problem, but it is still a dependency—and caution is always the wisest choice.
Don’t override concrete functions. Concrete functions often require source code dependencies. When you override those functions, you do not eliminate those dependencies—indeed, you inherit them. To manage those dependencies, you should make the function abstract and create multiple implementations.
Never mention the name of anything concrete and volatile. This is really just a restatement of the principle itself.
应在代码中多使用抽象接口,尽量避免使用那些多变的具体实现类。这条守则适用于所有编程语言,无论静态类型语言还是动态类型语言。同时,对象的创建过程也应该受到严格限制,对此,我们通常会选择用抽象工厂(abstract factory)这个设计模式。
不要在具体实现类上创建衍生类。上一条守则虽然也隐含了这层意思,但它还是值得被单独拿出来做一次详细声明。在静态类型的编程语言中,继承关系是所有一切源代码依赖关系中最强的、最难被修改的,所以我们对继承的使用应该格外小心。即使是在稍微便于修改的动态类型语言中,这条守则也应该被认真考虑。
不要覆盖(override)包含具体实现的函数。调用包含具体实现的函数通常 就意味着引入了源代码级别的依赖。即使覆盖了这些函数,我们也无法消除这其中的依赖——这些函数继承了那些依赖关系。在这里,控制依赖关系的唯一办法,就是创建一个抽象函数,然后再为该函数提供多种具体实现。
应避免在代码中写入与任何具体实现相关的名字,或者是其他容易变动的事物的名字。这基本上是 DIP 原则的另外一个表达方式。
FACTORIES 工厂模式
To comply with these rules, the creation of volatile concrete objects requires special handling. This caution is warranted because, in virtually all languages, the creation of an object requires a source code dependency on the concrete definition of that object.

如果想要遵守上述编码守则,我们就必须要对那些易变对象的创建过程做一些特殊处理,这样的谨慎是很有必要的,因为基本在所有的编程语言中,创建对象的操作都免不了需要在源代码层次上依赖对象的具体实现。

In most object-oriented languages, such as Java, we would use an Abstract Factory to manage this undesirable dependency.

在大部分面向对象编程语言中,人们都会选择用抽象工厂模式来解决这个源代码依赖的问题。

The diagram in Figure 11.1 shows the structure. The Application uses the ConcreteImpl through the Service interface. However, the Application must somehow create instances of the ConcreteImpl. To achieve this without creating a source code dependency on the ConcreteImpl, the Application calls the makeSvc method of the ServiceFactory interface. This method is implemented by the ServiceFactoryImpl class, which derives from ServiceFactory. That implementation instantiates the ConcreteImpl and returns it as a Service.

下面,我们通过图 11.1 来描述一下该设计模式的结构。如你所见 Application 类是通过 Service 接口来使用 ConcreteImpl 类的。然而,Application 类还是必须要构造 ConcreteImpl 类实例。于是,为了避免在源代码层次上引入对 ConcreteImpl 类具体实现的依赖,我们现在让 Application 类去调用 ServiceFactory 接口的 makeSvc 方法。这个方法就由 ServiceFactoryImpl 类来具体提供,它是 ServiceFactory 的一个衍生类。该方法的具体实现就是初始化一个 ConcreteImpl 类的实例,并且将其以 Service 类型返回。

Use of the Abstract Factory pattern to manage the dependency

The curved line in Figure 11.1 is an architectural boundary. It separates the abstract from the concrete. All source code dependencies cross that curved line pointing in the same direction, toward the abstract side.

图 11.1 中间的那条曲线代表了软件架构中的抽象层与具体实现层的边界。在这里,所有跨越这条边界源代码级别的依赖关系都应该是单向的,即具体实现层依赖抽象层。

The curved line divides the system into two components: one abstract and the other concrete. The abstract component contains all the high-level business rules of the application. The concrete component contains all the implementation details that those business rules manipulate.

这条曲线将整个系统划分为两部分组件:抽象接口与其具体实现。抽象接口组件中包含了应用的所有高阶业务规则,而具体实现组件中则包括了所有这些业务规则所需要做的具体操作及其相关的细节信息。

Note that the flow of control crosses the curved line in the opposite direction of the source code dependencies. The source code dependencies are inverted against the flow of control—which is why we refer to this principle as Dependency Inversion.

请注意,这里的控制流跨越架构边界的方向与源代码依赖关系跨越该边界的方向正好相反,源代码依赖方向永远是控制流方向的反转——这就是 DIP 被称为依赖反转原则的原因。

CONCRETE COMPONENTS 具体实现组件
The concrete component in Figure 11.1 contains a single dependency, so it violates the DIP. This is typical. DIP violations cannot be entirely removed, but they can be gathered into a small number of concrete components and kept separate from the rest of the system.

在图 11.1 中,具体实现组件的内部仅有一条依赖关系,这条关系其实是违反 DIP 的。这种情况很常见,我们在软件系统中并不可能完全消除违反 DIP 的情见通常只需要把它们集中于少部分的具体实现组件中,将其与系统的其他部分隔离即可。

Most systems will contain at least one such concrete component—often called main because it contains the main1 function. In the case illustrated in Figure 11.1, the main function would instantiate the ServiceFactoryImpl and place that instance in a global variable of type ServiceFactory. The Application would then access the factory through that global variable.

绝大部分系统中都至少存在一个具体实现组件 我们一般称之为 main 组化 因为它们通常是 main 函数所在之处。在 图 11.1 中,函数应该负责创建 ServiceFactoryImpl 实例,并将其赋值给类型为 ServiceFactory 的全局变量,以便让 Application 类通过这个全局变量来进行相关调用。

CONCLUSION 本章小结
As we move forward in this book and cover higher-level architectural principles, the DIP will show up again and again. It will be the most visible organizing principle in our architecture diagrams. The curved line in Figure 11.1 will become the architectural boundaries in later chapters. The way the dependencies cross that curved line in one direction, and toward more abstract entities, will become a new rule that we will call the Dependency Rule.

随着本书内容的进一步深入,以及我们对高级系统架构理论的进一步讨论,DIP 出现的频率将会越来越高。在系统架构图中,DIP 通常是最显而易见的组织原此我们在后续章节中会把图 11.1 中的那条曲线称为架构边界,而跨越边界的、朝向抽象层的单向依赖关系则会成为一个设计守则——依赖守则。

13
3

The Interface Segregation Principle (ISP) derives its name from the diagram shown in Figure 10.1.

“接口隔离原则(ISP)”这个名字来自图 10.1 所示的这种软件结构。

The Interface Segregation Principle

In the situation illustrated in Figure 10.1, there are several users who use the operations of the OPS class. Let’s assume that User1 uses only op1, User2 uses only op2, and User3 uses only op3.

在图 10.1 所描绘的应用中,有多个用户需要操作 OPS 类。现在,我们假设这里的 User1 只需要使用 op1,User2 只需要使用 op2,User3 只需要使用 op3。

Now imagine that OPS is a class written in a language like Java. Clearly, in that case, the source code of User1 will inadvertently depend on op2 and op3, even though it doesn’t call them. This dependence means that a change to the source code of op2 in OPS will force User1 to be recompiled and redeployed, even though nothing that it cared about has actually changed.

在这种情况下,如果 OPS 类是用 Java 编程语言编写的,那么很明显,User1 虽然不需要调用 op2、op3,但在源代码层次上也与它们形成依赖关系。这种依赖意味着我们对 OPS 代码中 op2 所做的任何修改,即使不会影响到 User1 的功能,也会导致它需要被重新编译和部署。

This problem can be resolved by segregating the operations into interfaces as shown in Figure 10.2.

Again, if we imagine that this is implemented in a statically typed language like Java, then the source code of User1 will depend on U1Ops, and op1, but will not depend on OPS. Thus a change to OPS that User1 does not care about will not cause User1 to be recompiled and redeployed.

这个问题可以通过将不同的操作隔离成接口来解决,具体如图 10.2 所示。同样,我们也假设这个例子是用 Java 这种静态类型语言来实现的,那么现在 User1 的源代码会依赖于 U1Ops 和 op1,但不会依赖于 OPS。这样一来,我们之后对 OPS 做的修改只要不影响到 User1 的功能,就不需要重新编译和部署 User1 了。

Segregated operations

ISP AND LANGUAGE ISP 与编程语言
Clearly, the previously given description depends critically on language type. Statically typed languages like Java force programmers to create declarations that users must import, or use, or otherwise include. It is these included declarations in source code that create the source code dependencies that force recompilation and redeployment.

很明显,上述例子很大程度上也依赖于我们所釆用的编程语言:对于 Java 这样的静态类型语言来说,它们需要程序员显式地 import、use 或者 include 其实现功能所需要的源代码。而正是这些语句带来了源代码之间的依赖关系,这也就导致了某些模块需要被重新编译和重新部署。

In dynamically typed languages like Ruby and Python, such declarations don’t exist in source code. Instead, they are inferred at runtime. Thus there are no source code dependencies to force recompilation and redeployment. This is the primary reason that dynamically typed languages create systems that are more flexible and less tightly coupled than statically typed languages.

而对于 Ruby 和 Python 这样的动态类型语言来说,源代码中就不存在这样的声明,它们所用对象的类型会在运行时被推演出来,所以也就不存在强制重新编译重新部署的必要性。这就是动态类型语言要比静态类型语言更灵活、耦合度更松的原因。

This fact could lead you to conclude that the ISP is a language issue, rather than an architecture issue.

当然,如果仅仅就这样说的话,读者可能会误以为 ISP 只是一个与编程语言的选择紧密相关的设计原则,而非软件架构问题,这就错了。

ISP AND ARCHITECTURE ISP 与软件架构
If you take a step back and look at the root motivations of the ISP, you can see a deeper concern lurking there. In general, it is harmful to depend on modules that contain more than you need. This is obviously true for source code dependencies that can force unnecessary recompilation and redeployment—but it is also true at a much higher, architectural level.

回顾一下 ISP 最初的成因:在一般情况下,任何层次的软件设计如果依赖于不需要的东西,都会是有害的。从源代码层次来说,这样的依赖关系会导致不必要的重新编译和重新部署,对更高层次的软件架构设计来说,问题也是类似的。

Consider, for example, an architect working on a system, S. He wants to include a certain framework, F, into the system. Now suppose that the authors of F have bound it to a particular database, D. So S depends on F. which depends on D (Figure 10.3).

例如,我们假设某位软件架构师在设计系统 S 时,想要在该系统中引入某个框架 F。这时候,假设框架 F 的作者又将其捆绑在一个特定的数据库 D 上,那么就形成了 S 依赖于 F,F 又依赖于 D 的关系(参见图 10.3)。

A problematic architecture

Now suppose that D contains features that F does not use and, therefore, that S does not care about. Changes to those features within D may well force the redeployment of F and, therefore, the redeployment of S. Even worse, a failure of one of the features within D may cause failures in F and S.

在这种情况下,如果 D 中包含了 F 不需要的功能,那么这些功能同样也会是 S 不需要的。而我们对 D 中的这些功能的修改将会导致 F 需要被重新部署,后者又会导致 S 的重新部署。更糟糕的是,D 中一个无关功能的错误也可能会导致 F 和 S 运行出错。

CONCLUSION 本章小结
The lesson here is that depending on something that carries baggage that you don’t need can cause you troubles that you didn’t expect.

本章所讨论的设计原则告诉我们:任何层次的软件设计如果依赖了它并不需要的东西,就会带来意料之外的麻烦。

We’ll explore this idea in more detail when we discuss the Common Reuse Principle in Chapter 13, “Component Cohesion.”

我们将会在第 13 章“组件聚合”中讨论共同复用原则的时候再来深入探讨更多相关的细节。

12
3

In 1988, Barbara Liskov wrote the following as a way of defining subtypes.

1988 年,Barbara Liskov 在描述如何定义子类型时写下了这样一段话:

What is wanted here is something like the following substitution property: If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.1

这里需要的是一种可替换性:如果对于每个类型是 S 的对象 o1 都存在一个类型为 T 的对象 o2,能使操作 T 类型的程序 P 在用 o2 替换 o1 时行为保持不变,我们就可以将 S 称为 T 的子类型。

To understand this idea, which is known as the Liskov Substitution Principle (LSP), let’s look at some examples.

为了让读者理解这段话中所体现的设计理念,也就是里氏替换原则(LSP),我们可以来看几个例子。

GUIDING THE USE OF INHERITANCE 继承的使用指导
Imagine that we have a class named License, as shown in Figure 9.1. This class has a method named calcFee(), which is called by the Billing application. There are two “subtypes” of License: PersonalLicense and BusinessLicense. They use different algorithms to calculate the license fee.

假设我们有一个 License 类,其结构如图 9.1 所示。该类中有一个名为 calcFee() 的方法,该方法将由 Billing 应用程序来调用。而 License 类有两个“子类型”:PersonalLicense 与 BusinessLicense,这两个类会用不同的算法来计算授权费用。

License, and its derivatives, conform to LSP

This design conforms to the LSP because the behavior of the Billing application does not depend, in any way, on which of the two subtypes it uses. Both of the subtypes are substitutable for the License type.

上述设计是符合 LSP 原则的,因为 Billing 应用程序的行为并不依赖于其使用的任何一个衍生类。也就是说,这两个衍生类的对象都是可以用来替换 License 类对象的。

THE SQUARE/RECTANGLE PROBLEM 正方形/长方形问题
The canonical example of a violation of the LSP is the famed (or infamous, depending on your perspective) square/rectangle problem (Figure 9.2).

正方形/长方形问题是个著名(或者说臭名远扬)的违反 LSP 的设计案例(这个问题的结构如图 9.2 所示)。

The infamous square/rectangle problem

In this example, Square is not a proper subtype of Rectangle because the height and width of the Rectangle are independently mutable; in contrast, the height and width of the Square must change together. Since the User believes it is communicating with a Rectangle, it could easily get confused. The following code shows why:

在这个案例中,Square 类并不是 Rectangle 类的子类型,因为 Rectangle 类的高和宽可以分别修改,而 Square 类的高和宽则必须一同修改。由于 User 类 始终认为自己在操作 Rectangle 类,因此会带来一些混淆。例如在下面的代码中:

Rectangle r = …
r.setW(5);
r.setH(2);
assert(r.area() == 10);
If the … code produced a Square, then the assertion would fail.

很显然,如果上述代码在…处返回的是 Square 类,则最后的这个 assert 是不会成立的。

The only way to defend against this kind of LSP violation is to add mechanisms to the User (such as an if statement) that detects whether the Rectangle is, in fact, a Square. Since the behavior of the User depends on the types it uses, those types are not substitutable.

如果想要防范这种违反 LSP 的行为,唯一的办法就是在 user 类中增加用于区分 Rectangle 和 Square 的检测逻辑(例如增加 if 语句)。但这样一来,user 为的行为又将依赖于它所使用的类,这两个类就不能互相替换了。

LSP AND ARCHITECTURE LSP 与软件架构
In the early years of the object-oriented revolution, we thought of the LSP as a way to guide the use of inheritance, as shown in the previous sections. However, over the years the LSP has morphed into a broader principle of software design that pertains to interfaces and implementations.

在面向对象这场编程革命兴起的早期,我们的普遍认知正如上文所说,认为 LSP 只不过是指导如何使用继承关系的一种方法,然而随着时间的推移,LSP 逐渐演变成了一种更广泛的、指导接口与其实现方式的设计原则。

The interfaces in question can be of many forms. We might have a Java-style interface, implemented by several classes. Or we might have several Ruby classes that share the same method signatures. Or we might have a set of services that all respond to the same REST interface.

这里提到的接口可以有多种形式——可以是 Java 风格的接口,具有多个实现类;也可以像 Ruby 一样,几个类共用一样的方法签名,甚至可以是几个服务响应同一个 REST 接口。

In all of these situations, and more, the LSP is applicable because there are users who depend on well-defined interfaces, and on the substitutability of the implementations of those interfaces.

LSP 适用于上述所有的应用场景,因为这些场景中的用户都依赖于一种接口,并且都期待实现该接口的类之间能具有可替换性。

The best way to understand the LSP from an architectural viewpoint is to look at what happens to the architecture of a system when the principle is violated.

想要从软件架构的角度来理解 LSP 的意义,最好的办法还是来看几个反面案例。

EXAMPLE LSP VIOLATION 违反 LSP 的案例
Assume that we are building an aggregator for many taxi dispatch services. Customers use our website to find the most appropriate taxi to use, regardless of taxi company. Once the customer makes a decision, our system dispatches the chosen taxi by using a restful service.

假设我们现在正在构建一个提供出租车调度服务的系统。在该系统中,用户可以通过访问我们的网站,从多个出租车公司内寻找最适合自己的出租车。当用户选定车子时,该系统会通过调用 restful 服务接口来调度这辆车。

Now assume that the URI for the restful dispatch service is part of the information contained in the driver database. Once our system has chosen a driver appropriate for the customer, it gets that URI from the driver record and then uses it to dispatch the driver.

接下来,我们再假设该 restful 调度服务接口的 URI 被存储在司机数据库中。一旦该系统选中了最合适的出租车司机,它就会从司机数据库的记录中读取相应的 URI 信息,并通过调用这个 URI 来调度汽车。

Suppose Driver Bob has a dispatch URI that looks like this:

也就是说,如果司机 Bob 的记录中包含如下调度 URI:

purplecab.com/driver/Bob
Our system will append the dispatch information onto this URI and send it with a PUT, as follows:

那么,我们的系统就会将调度信息附加在这个 URI 上,并发送这样一个 PUT 请求:

purplecab.com/driver/Bob
/pickupAddress/24 Maple St.
/pickupTime/153
/destination/ORD
Clearly, this means that all the dispatch services, for all the different companies, must conform to the same REST interface. They must treat the pickupAddress, pickupTime, and destination fields identically.

很显然,这意味着所存参与该调度服务的公司都必须遵守同样的 REST 接口,它们必须用同样的方式处理 pickupAddress、pickupTime 和 destination 字段。

Now suppose the Acme taxi company hired some programmers who didn’t read the spec very carefully. They abbreviated the destination field to just dest. Acme is the largest taxi company in our area, and Acme’s CEO’s ex-wife is our CEO’s new wife, and … Well, you get the picture. What would happen to the architecture of our system?

接下来,我们再假设 Acme 出租车公司现在招聘的程序员由于没有仔细阅读上述接口定义,结果将 destination 字段缩写成了 dest。而 Acme 又是本地最大的出租车公司,另外,Acme CEO 的前妻不巧还是我们 CEO 的新欢……你懂的!这这会对系统的架构造成什么影响呢?

Obviously, we would need to add a special case. The dispatch request for any Acme driver would have to be constructed using a different set of rules from all the other drivers.

显然,我们需要为系统增加一类特殊用例,以应对 Acme 司机的调度请求。这必须要用另外一套规则来构建。

The simplest way to accomplish this goal would be to add an if statement to the module that constructed the dispatch command:

最简单的做法当然是增加一条 if 语句:

if (driver.getDispatchUri().startsWith("acme.com"))…
But, of course, no architect worth his or her salt would allow such a construction to exist in the system. Putting the word “acme” into the code itself creates an opportunity for all kinds of horrible and mysterious errors, not to mention security breaches.

然而很明显,任何一个称职的软件架构师都不会允许这样一条语句出现在自己的系统中。因为直接将“acme”这样的字串写入代码会留下各种各样神奇又可怕的错误隐患,甚至会导致安全问题。

For example, what if Acme became even more successful and bought the Purple Taxi company. What if the merged company maintained the separate brands and the separate websites, but unified all of the original companies’ systems? Would we have to add another if statement for “purple”?

例如,Acme 也许会变得更加成功,最终收购了 Purple 出租车公司。然后,它们在保留了各自名字的同时却统一了彼此的计算机系统。在这种情况下,系统中难道还要再增加一条“purple”的特例吗?

Our architect would have to insulate the system from bugs like this by creating some kind of dispatch command creation module that was driven by a configuration database keyed by the dispatch URI. The configuration data might look something like this:

软件架构师应该创建一个调度请求创建组件,并让该组件使用一个配置数据库来保存 URI 组装格式,这样的方式可以保护系统不受外界因素变化的影响。例如其配置信息可以如下:

URI Dispatch Format
Acme.com /pickupAddress/%s/pickupTime/%s/dest/%s
. /pickupAddress/%s/pickupTime/%s/destination/%s
And so our architect has had to add a significant and complex mechanism to deal with the fact that the interfaces of the restful services are not all substitutable.

但这样一来,软件架构师就需要通过增加一个复杂的组件来应对并不完全能实现互相替换的 restful 服务接口。

CONCLUSION 本章小结
The LSP can, and should, be extended to the level of architecture. A simple violation of substitutability, can cause a system’s architecture to be polluted with a significant amount of extra mechanisms.

LSP 可以且应该被应用于软件架构层面,因为一旦违背了可替换也该系统架构就不得不为此增添大量复杂的应对机制。

11
3

The Open-Closed Principle (OCP) was coined in 1988 by Bertrand Meyer.1 It says:

开闭原则(OCP)是 Bertrand Meyer 在 1988 年提出的,该设计原则认为:

A software artifact should be open for extension but closed for modification.

设计良好的计算机软件应该易于扩展,同时抗拒修改。

In other words, the behavior of a software artifact ought to be extendible, without having to modify that artifact.

换句话说,一个设计良好的计算机系统应该在不需要修改的前提下就可以轻易被扩展。

This, of course, is the most fundamental reason that we study software architecture. Clearly, if simple extensions to the requirements force massive changes to the software, then the architects of that software system have engaged in a spectacular failure.

其实这也是我们研究软件架构的根本目的。如果对原始需求的小小延伸就需要对原有的软件系统进行大幅修改,那么这个系统的架构设计显然是失败的。

Most students of software design recognize the OCP as a principle that guides them in the design of classes and modules. But the principle takes on even greater significance when we consider the level of architectural components.

尽管大部分软件设计师都已经认可了 OCP 是设计类与模块时的重要原则,但是在软件架构层面,这项原则的意义则更为重大。

A thought experiment will make this clear.

下面,让我们用一个思想实验来做一些说明。

A THOUGHT EXPERIMENT 思想实验
Imagine, for a moment, that we have a system that displays a financial summary on a web page. The data on the page is scrollable, and negative numbers are rendered in red.

假设我们现在要设计一个在 Web 页面上展示财务数据的系统,页面上的数据要可以滚动显示,其中负值应显示为红色。

Now imagine that the stakeholders ask that this same information be turned into a report to be printed on a black-and-white printer. The report should be properly paginated, with appropriate page headers, page footers, and column labels. Negative numbers should be surrounded by parentheses.

接下来,该系统的所有者又要求同样的数据需要形成一个报表,该报表要能用黑白打印机打印,并且其报表格式要得到合理分页,每页都要包含页头、页尾及栏目名。同时,负值应该以括号表示。

Clearly, some new code must be written. But how much old code will have to change?

显然,我们需要增加一些代码来完成这个要求。但在这里我们更关注的问题是,满足新的要求需要更改多少旧代码。

A good software architecture would reduce the amount of changed code to the barest minimum. Ideally, zero.

一个好的软件架构设计师会努力将旧代码的修改需求量降至最小,甚至为 0。

How? By properly separating the things that change for different reasons (the Single Responsibility Principle), and then organizing the dependencies between those things properly (the Dependency Inversion Principle).

但该如何实现这一点呢?我们可以先将满足不同需求的代码分组(即 SRP),然后再来调整这些分组之间的依赖关系(即 DIP)。

By applying the SRP, we might come up with the data-flow view shown in Figure 8.1. Some analysis procedure inspects the financial data and produces reportable data, which is then formatted appropriately by the two reporter processes.

利用 SRP,我们可以按图 8.1 中所展示的方式来处理数据流。即先用一段分析程序处理原始的财务数据,以形成报表的数据结构,最后再用两个不同的报表生成器来产生报表。

Applying the SRP

The essential insight here is that generating the report involves two separate responsibilities: the calculation of the reported data, and the presentation of that data into a web- and printer-friendly form.

这里的核心就是将应用生成报表的过程拆成两个不同的操作。即先计算出报表数据,再生成具体的展示报表(分别以网页及纸质的形式展示)。

Having made this separation, we need to organize the source code dependencies to ensure that changes to one of those responsibilities do not cause changes in the other. Also, the new organization should ensure that the behavior can be extended without undo modification.

接下来,我们就该修改其源代码之间的依赖关系了。这样做的目的是保证其中一个操作被修改之后不会影响到另外一个操作。同时,我们所构建的新的组织形式应该保证该程序后续在行为上的扩展都无须修改现有代码。

We accomplish this by partitioning the processes into classes, and separating those classes into components, as shown by the double lines in the diagram in Figure 8.2. In this figure, the component at the upper left is the Controller. At the upper right, we have the Interactor. At the lower right, there is the Database. Finally, at the lower left, there are four components that represent the Presenters and the Views.

在具体实现上,我们会将整个程序进程划分成一系列的类,然后再将这些类分割成不同的组件。下面,我们用图 8.2 中的那些双线框来具体描述一下整个实现。在这个图中,左上角的组件是 Controller,右上角是 Interactor,右下角是 Database,左下角则有四个组件分别用于代表不同的 Presenter 和 View。

Partitioning the processes into classes and separating the classes into components

Classes marked with are interfaces; those marked with are data structures. Open arrowheads are using relationships. Closed arrowheads are implements or inheritance relationships.

在图 8.2 中,用 标记的类代表接口,用 标记的则代表数据结构;开放箭头指代的是使用关系,闭合箭头则指代了实现与继承关系。

The first thing to notice is that all the dependencies are source code dependencies. An arrow pointing from class A to class B means that the source code of class A mentions the name of class B, but class B mentions nothing about class A. Thus, in Figure 8.2, FinancialDataMapper knows about FinancialDataGateway through an implements relationship, but FinancialDataGateway knows nothing at all about FinancialDataMapper.

首先,我们在图 8.2 中看到的所有依赖关系都是其源代码中存在的依赖关系。这里,从类 A 指向类 B 的箭头意味着 A 的源代码中涉及了 B,但 是 B 的源代码中并不涉及 A。因此在图 8.2 中,FinancialDataMapper 在实现接口时需要知道 FinancialDataGateway 的实现,而 FinancialDataGateway 则完全知道 FinancialDataMapper 的实现。

The next thing to notice is that each double line is crossed in one direction only. This means that all component relationships are unidirectional, as shown in the component graph in Figure 8.3. These arrows point toward the components that we want to protect from change.

其次,这里很重要的一点是这些双线框的边界都是单向跨越的。也就是说,上图中所有组件之间的关系都是单向依赖的,如图 8.3 所示,图中的箭头都指向那些我们不想经常更改的组件。

The component relationships are unidirectional

Let me say that again: If component A should be protected from changes in component B, then component B should depend on component A.

让我们再来复述一下这里的设计原则:如果 A 组件不想被 B 组件上发生的修改所影响,那么就应该让 B 组件依赖于 A 组件。

We want to protect the Controller from changes in the Presenters. We want to protect the Presenters from changes in the Views. We want to protect the Interactor from changes in—well, anything.

所以现在的情况是,我们不想让发生在 Presenter 上的修改影响到 Controller,也不想让发生在 View 上的修改影响到 Presenter。而最关键的是,我们不想让任何修改影响到 Interactor。

The Interactor is in the position that best conforms to the OCP. Changes to the Database, or the Controller, or the Presenters, or the Views, will have no impact on the Interactor.

其中,Interactor 组件是整个系统中最符合 OCP 的。发生在 Database、Controller、Presenter 甚至 View 上的修改都不会影响到 Interactor。

Why should the Interactor hold such a privileged position? Because it contains the business rules. The Interactor contains the highest-level policies of the application. All the other components are dealing with peripheral concerns. The Interactor deals with the central concern.

为什么 Interactor 会被放在这么重要的位置上呢?因为它是该程序的业务逻辑所在之处,Interactor 中包含了其最高层次的应用策略。其他组件都只是负责处理周边的辅助逻辑,只有 Interactor 才是核心组件。

Even though the Controller is peripheral to the Interactor, it is nevertheless central to the Presenters and Views. And while the Presenters might be peripheral to the Controller, they are central to the Views.

虽然 Controller 组件只是 Interactor 的附属品,但它却是 Presenter 和 View 所服务的核心。同样的,虽然 Presenter 组件是 Controller 的附属品,但它却是 View 所服务的核心。

Notice how this creates a hierarchy of protection based on the notion of “level.” Interactors are the highest-level concept, so they are the most protected. Views are among the lowest-level concepts, so they are the least protected. Presenters are higher level than Views, but lower level than the Controller or the Interactor.

另外需要注意的是,这里利用“层级”这个概念创造了一系列不同的保护层级。譬如,Interactor 是最高层的抽象,所以它被保护得最严密,而 Presenter 比 View 时层级高,但比 Controller 和 Interactor 的层级低。

This is how the OCP works at the architectural level. Architects separate functionality based on how, why, and when it changes, and then organize that separated functionality into a hierarchy of components. Higher-level components in that hierarchy are protected from the changes made to lower-level components.

以上就是我们在软件架构层次上对 OCP 这一设计原则的应用。软件架构师可以根据相关函数被修改的原因、修改的方式及修改的时间来对其进行分组隔离,并将这些互相隔离的函数分组整理成组件结构,使得高阶组件不会因低阶组件被修改而受到影响。

DIRECTIONAL CONTROL 依赖方向的控制
If you recoiled in horror from the class design shown earlier, look again. Much of the complexity in that diagram was intended to make sure that the dependencies between the components pointed in the correct direction.

如果刚刚的类设计把你吓着了,别害怕!你刚刚在图表中所看到的复杂度是我们想要对组件之间的依赖方向进行控制而产生的。

For example, the FinancialDataGateway interface between the FinancialReportGenerator and the FinancialDataMapper exists to invert the dependency that would otherwise have pointed from the Interactor component to the Database component. The same is true of the FinancialReportPresenter interface, and the two View interfaces.

例如,FinanciaIReportGenerator 和 FinancialDataMapper 之间的 Financial Da taGateway 接口是为了反转 Interactor 与 Database 之间的依赖关系而产生的。同样的,FinancialReportPresenter 接口与两个 View 接口之间也类似于这种情况。

INFORMATION HIDING 信息隐藏
The FinancialReportRequester interface serves a different purpose. It is there to protect the FinancialReportController from knowing too much about the internals of the Interactor. If that interface were not there, then the Controller would have transitive dependencies on the FinancialEntities.

当然,FinancialReportRequester 接口的作用则完全不同,它的作用是保护 FinancialReportController 不过度依赖于 Interactor 的内部细节。如果没有这个接口,则 Controller 将会传递性地依赖于 FinancialEntities。

Transitive dependencies are a violation of the general principle that software entities should not depend on things they don’t directly use. We’ll encounter that principle again when we talk about the Interface Segregation Principle and the Common Reuse Principle.

这种传递性依赖违反了“软件系统不应该依赖其不直接使用的组件”这一基本原则。之后,我们会在讨论接口隔离原则和共同复用原则的时候再次提到这一点。

So, even though our first priority is to protect the Interactor from changes to the Controller, we also want to protect the Controller from changes to the Interactor by hiding the internals of the Interactor.

所以,虽然我们的首要目的是为了让 Interactor 屏蔽掉发生在 Controller 上的修改,但也需要通过隐藏 Interactor 内部细节的方法来让其屏蔽掉来自 Controller 的依赖。

CONCLUSION 本章小结
The OCP is one of the driving forces behind the architecture of systems. The goal is to make the system easy to extend without incurring a high impact of change. This goal is accomplished by partitioning the system into components, and arranging those components into a dependency hierarchy that protects higher-level components from changes in lower-level components.

OCP 是我们进行系统架构设计的主导原则,其主要目标是让系统易于扩展,同时限制其每次被修改所影响的范围。实现方式是通过将系统划分为一系列组件,并且将这些组件间的依赖关系按层次结构进行组织,使得高阶组件不会因低阶组件被修改而受到影响。

10
3

Of all the SOLID principles, the Single Responsibility Principle (SRP) might be the least well understood. That’s likely because it has a particularly inappropriate name. It is too easy for programmers to hear the name and then assume that it means that every module should do just one thing.

SRP 是 SOLID 五大设计原则中最容易被误解的一个。也许是名字的原因,很多程序员根据 SRP 这个名字想当然地认为这个原则就是指:每个模块都应该只做一件事。

Make no mistake, there is a principle like that. A function should do one, and only one, thing. We use that principle when we are refactoring large functions into smaller functions; we use it at the lowest levels. But it is not one of the SOLID principles—it is not the SRP.

没错,后者的确也是一个设计原则,即确保一个函数只完成一个功能。我们在将大型函数重构成小函数时经常会用到这个原则,但这只是一个面向底层实现细节的设计原则,并不是 SRP 的全部。

Historically, the SRP has been described this way:

在历史上,我们曾经这样描述 SRP 这一设计原则:

A module should have one, and only one, reason to change.

任何一个软件模块都应该有且仅有一个被修改的原因。

Software systems are changed to satisfy users and stakeholders; those users and stakeholders are the “reason to change” that the principle is talking about. Indeed, we can rephrase the principle to say this:

在现实环境中,软件系统为了满足用户和所有者的要求,必然要经常做出这样那样的修改。而该系统的用户或者所有者就是该设计原则中所指的“被修改的原因”。所以,我们也可以这样描述 SRP:

A module should be responsible to one, and only one, user or stakeholder.

任何一个软件模块都应该只对一个用户(User)或系统利益相关者(Stakeholder)负责。

Unfortunately, the words “user” and “stakeholder” aren’t really the right words to use here. There will likely be more than one user or stakeholder who wants the system changed in the same way. Instead, we’re really referring to a group—one or more people who require that change. We’ll refer to that group as an actor.

不过,这里的“用户”和 “系统利益相关者”在用词上也并不完全准确,它们很有可能指的是一个或多个用户和利益相关者,只要这些人希望对系统进行的变更是相似的,就可以归为一类——一个或多个有共同需求的人。在这里,我们将其称为行为者(actor)。

Thus the final version of the SRP is:

所以,对于 SRP 的最终描述就变成了:

A module should be responsible to one, and only one, actor.

任何一个软件模块都应该只对某一类行为者负责。

Now, what do we mean by the word “module”? The simplest definition is just a source file. Most of the time that definition works fine. Some languages and development environments, though, don’t use source files to contain their code. In those cases a module is just a cohesive set of functions and data structures.

那么,上文中提到的“软件模块”究竟又是在指什么呢?大部分情况下,其最简单的定义就是指一个源代码文件。然而,有些编程语言和编程环境并不是用源代码文件来存储程序的。在这些情况下,“软件模块”指的就是一组紧密相关的函数和数据结构。

That word “cohesive” implies the SRP. Cohesion is the force that binds together the code responsible to a single actor.

在这里,“相关”这个词实际上就隐含了 SRP 这一原则。代码与数据就是靠着与某一类行为者的相关性被组合在一起的。

Perhaps the best way to understand this principle is by looking at the symptoms of violating it.

或许,理解这个设计原则最好的办法就是计大家来看一些反面案例。

SYMPTOM 1: ACCIDENTAL DUPLICATION 反面案例 1:重复的假象
My favorite example is the Employee class from a payroll application. It has three methods: calculatePay(), reportHours(), and save() (Figure 7.1).

这是我最喜欢举的一个例子:某个工资管理程序中的 Employee 类有三个函数 calculatePay()、reportHours() 和 save()(见图 7.1)。

The Employee class

This class violates the SRP because those three methods are responsible to three very different actors.

如你所见,这个类的三个函数分别对应的是三类非常不同的行为者,违反了 SRP 设计原则。

The calculatePay() method is specified by the accounting department, which reports to the CFO.
The reportHours() method is specified and used by the human resources department, which reports to the COO.
The save() method is specified by the database administrators (DBAs), who report to the CTO.
calculatePay() 函数是由财务部门制定的,他们负责向 CFO 汇报。
reportHours() 函数是由人力资源部门制定并使用的,他们负责向 COO 汇报。
save() 函数是由 DBA 制定的,他们负责向 CTO 汇报。
By putting the source code for these three methods into a single Employee class, the developers have coupled each of these actors to the others. This coupling can cause the actions of the CFO’s team to affect something that the COO’s team depends on.

这三个函数被放在同一个源代码文件,即同一个 Employee 类中,程序员这样 做实际上就等于使三类行为者的行为耦合在了一起,这有可能会导致 CFO 团队的命令影响到 C 00 团队所依赖的功能。

For example, suppose that the calculatePay() function and the reportHours() function share a common algorithm for calculating non-overtime hours. Suppose also that the developers, who are careful not to duplicate code, put that algorithm into a function named regularHours() (Figure 7.2).

例如,calculatePay() 函数和 reportHours() 函数使用同样的逻辑来计算正常工作时数。程序员为了避免重复编码,通常会将该算法单独实现为一个名为 regularHours() 的函数(见图 7.2)。

Shared algorithm

Now suppose that the CFO’s team decides that the way non-overtime hours are calculated needs to be tweaked. In contrast, the COO’s team in HR does not want that particular tweak because they use non-overtime hours for a different purpose.

接下来,假设 CFO 团队需要修改正常工作时数的计算方法,而 COO 带领的 HR 团队不需要这个修改,因为他们对数据的用法是不同的。

A developer is tasked to make the change, and sees the convenient regularHours() function called by the calculatePay() method. Unfortunately, that developer does not notice that the function is also called by the reportHours() function.

这时候,负责这项修改的程序员会注意到 calculatePay() 函数调用了 regularHours() 函数,但可能不会注意到该函数会同时被 reportHours() 调用。

The developer makes the required change and carefully tests it. The CFO’s team validates that the new function works as desired, and the system is deployed.

于是,该程序员就这样按照要求进行了修改,同时 CFO 团队的成员验证了新算法工作正常。这项修改最终被成功部署上线了。

Of course, the COO’s team doesn’t know that this is happening. The HR personnel continue to use the reports generated by the reportHours() function—but now they contain incorrect numbers. Eventually the problem is discovered, and the COO is livid because the bad data has cost his budget millions of dollars.

但是,COO 团队显然完全不知道这些事情的发生,HR 仍然在使用 reportHours() 产生的报表,随后就会发现他们的数据出错了!最终这个问题让 COO 十分愤怒,因为这些错误的数据给公司造成了几百万美元的损失。

We’ve all seen things like this happen. These problems occur because we put code that different actors depend on into close proximity. The SRP says to separate the code that different actors depend on.

与此类似的事情我们肯定多多少少都经历过。这类问题发生的根源就是因为我们将不同行为者所依赖的代码强凑到了一起。对此,SRP 强调这类代码一定要被分开。

SYMPTOM 2: MERGES 反面案例 2:代码合井
It’s not hard to imagine that merges will be common in source files that contain many different methods. This situation is especially likely if those methods are responsible to different actors.

一个拥有很多函数的源代码文件必然会经历很多次代码合并,该文件中的这些函数分别服务不同行为者的情况就更常见了。

For example, suppose that the CTO’s team of DBAs decides that there should be a simple schema change to the Employee table of the database. Suppose also that the COO’s team of HR clerks decides that they need a change in the format of the hours report.

例如,CTO 团队的 DBA 决定要对 Employee 数据库表结构进行简单修改。与此同时,COO 团队的 HR 需要修改工作时数报表的格式。

Two different developers, possibly from two different teams, check out the Employee class and begin to make changes. Unfortunately their changes collide. The result is a merge.

这样一来,就很可能出现两个来自不同团队的程序员分别对 Employee 类进行 修改的情况。不出意外的话,他们各自的修改一定会互相冲突,这就必须要进行代码合并。

I probably don’t need to tell you that merges are risky affairs. Our tools are pretty good nowadays, but no tool can deal with every merge case. In the end, there is always risk.

In our example, the merge puts both the CTO and the COO at risk. It’s not inconceivable that the CFO could be affected as well.

在这个例子中,这次代码合并不仅有可能让 CTO 和 COO 要求的功能出错,甚至连 CFO 原本正常的功能也可能收到影响。

There are many other symptoms that we could investigate, but they all involve multiple people changing the same source file for different reasons.

事实上,这样的案例还有很多,我们就不一一列举了。它们的一个共同点是,多人为了不同的目的修改了同一份源代码,这很容易造成问题的产生。

Once again, the way to avoid this problem is to separate code that supports different actors.

而避免这种问题产生的方法就是将服务不同行为者的代码进行切分。

SOLUTIONS 解决方案
There are many different solutions to this problem. Each moves the functions into different classes.

我们有很多不同的方法可以用来解决上面的问题,每一种方法都需要将相关的函数划分成不同的类。

Perhaps the most obvious way to solve the problem is to separate the data from the functions. The three classes share access to EmployeeData, which is a simple data structure with no methods (Figure 7.3). Each class holds only the source code necessary for its particular function. The three classes are not allowed to know about each other. Thus any accidental duplication is avoided.

其中,最简单直接的办法是将数据与函数分离,设计三个类共同使用一个不包括函数的、十分简单的 EmployeeData 类(见图 7.3),每个类只包含与之相关的函数代码,互相不可见,这样就不存在互相依赖的情况了。

The three classes do not know about each other

The downside of this solution is that the developers now have three classes that they have to instantiate and track. A common solution to this dilemma is to use the Facade pattern (Figure 7.4).

这种解决方案的坏处在于:程序员现在需要在程序里处理三个类。另一种解决办法是使用 Facade 设计模式(见图 7.4)。

The Facade pattern

The EmployeeFacade contains very little code. It is responsible for instantiating and delegating to the classes with the functions.

这样一来,EmployeeFacade 类所需要的代码量就很少了,它仅仅包含了初始化和调用三个具体实现类的函数。

Some developers prefer to keep the most important business rules closer to the data. This can be done by keeping the most important method in the original Employee class and then using that class as a Facade for the lesser functions (Figure 7.5).

当然,也有些程序员更倾向于把最重要的业务逻辑与数据放在一起,那么我们也可以选择将最重要的函数保留在 Employee 类中,同时用这个类来调用其他没那么重要的函数(见图 7.5)。

The most important method is kept in the original Employee class and used as a Facade for the lesser functions

You might object to these solutions on the basis that every class would contain just one function. This is hardly the case. The number of functions required to calculate pay, generate a report, or save the data is likely to be large in each case. Each of those classes would have many private methods in them.

读者也许会反对上面这些解决方案,因为看上去这里的每个类中都只有一个函数。事实上并非如此,因为无论是计算工资、生成报表还是保存数据都是一个很复杂的过程,每个类都可能包含了许多私有函数。

Each of the classes that contain such a family of methods is a scope. Outside of that scope, no one knows that the private members of the family exist.

总而言之,上面的每一个类都分别容纳了一组作用于相同作用域的函数,而在该作用域之外,它们各自的私有函数是互相不可见的。

CONCLUSION 本章小结
The Single Responsibility Principle is about functions and classes—but it reappears in a different form at two more levels. At the level of components, it becomes the Common Closure Principle. At the architectural level, it becomes the Axis of Change responsible for the creation of Architectural Boundaries. We’ll be studying all of these ideas in the chapters to come.

单一职责原则主要讨论的是函数和类之间的关系——但是它在两个讨论层面上会以不同的形式出现。在组件层面,我们可以将其称为共同闭包原则(Common Closure Principle),在软件架构层面,它则是用于奠定架构边界的变更轴心(Axis of Change)。我们在接下来的章节中会深入学习这些原则。

09
3

Good software systems begin with clean code. On the one hand, if the bricks aren’t well made, the architecture of the building doesn’t matter much. On the other hand, you can make a substantial mess with well-made bricks. This is where the SOLID principles come in.

通常来说,要想构建一个好的软件系统,应该从写整洁的代码开始做起。毕竟,如果建筑所使用的砖头质量不佳,那么架构所能起到的作用也会很有限。反之亦然,如果建筑的架构设计不佳,那么其所用的砖头质量再好也没有用。这就是 SOLID 设计原则所要解决的问题。

The SOLID principles tell us how to arrange our functions and data structures into classes, and how those classes should be interconnected. The use of the word “class” does not imply that these principles are applicable only to object-oriented software. A class is simply a coupled grouping of functions and data. Every software system has such groupings, whether they are called classes or not. The SOLID principles apply to those groupings.

SOLID 原则的主要作用就是告诉我们如何将数据和函数组织成为类,以及如将这些类链接起来成为程序。请注意,这里虽然用到了 “类”这个词,但是并不意味着我们将要讨论的这些设计原则仅仅适用于面向对象编程。这里的类仅仅代表一种数据和函数的分组,每个软件系统都会有自己的分类系统,不管它们各自是不是将其称为“类”,事实上都是 SOLID 原则的适用领域。

The goal of the principles is the creation of mid-level software structures that:

一般情况下,我们为软件构建中层结构的主要目标如下:

Tolerate change,
Are easy to understand, and
Are the basis of components that can be used in many software systems.
使软件可容忍被改动。
使软件更容易被理解。
构建可在多个软件系统中复用的组件。
The term “mid-level” refers to the fact that these principles are applied by programmers working at the module level. They are applied just above the level of the code and help to define the kinds of software structures used within modules and components.

我们在这里之所以会使用“中层”这个词,是因为这些设计原则主要适用于那些进行模块级编程的程序员。SOLID 原则应该直接紧贴于具体的代码逻辑之上,这些原则是用来帮助我们定义软件架构中的组件和模块的。

Just as it is possible to create a substantial mess with well-made bricks, so it is also possible to create a system-wide mess with well-designed mid-level components. For this reason, once we have covered the SOLID principles, we will move on to their counterparts in the component world, and then to the principles of high-level architecture.

当然了,正如用好砖也会盖歪楼一样,采用设计良好的中层组件并不能保证系统的整体架构运作良好。正因为如此,我们在讲完 SOLID 原则之后,还会再继续针对组件的设计原则进行更进一步的讨论,将其推进到高级软件架构部分。

The history of the SOLID principles is long. I began to assemble them in the late 1980s while debating software design principles with others on USENET (an early kind of Facebook). Over the years, the principles have shifted and changed. Some were deleted. Others were merged. Still others were added. The final grouping stabilized in the early 2000s, although I presented them in a different order.

SOLID 原则的历史已经很悠久了,早在 20 世纪 80 年代末期,我在 USENET 新闻组 (该新闻组在当时就相当于今天的 Facebook)上和其他人辩论软件设计理念的时候,该设计原则就已经开始逐渐成型了。随着时间的推移,其中有一些原则得到了修改,有一些则被抛弃了,还有一些被合并了,另外也增加了一些。它们的最终形态是在 2000 年左右形成的,只不过当时采用的是另外一个展现顺序。

In 2004 or thereabouts, Michael Feathers sent me an email saying that if I rearranged the principles, their first words would spell the word SOLID—and thus the SOLID principles were born.

2004 年前后,Michael Feathers 的一封电子邮件提醒我:如果重新排列这些设计原则,那么它们的首字母可以排列成 SOLID——这就是 SOLID 原则诞生的故事。

The chapters that follow describe each principle more thoroughly. Here is the executive summary:

在这一部分中,我们会逐章地详细讨论每个设计原则,下面先来做一个简单摘要。

SRP: The Single Responsibility Principle An active corollary to Conway’s law: The best structure for a software system is heavily influenced by the social structure of the organization that uses it so that each software module has one, and only one, reason to change.
OCP: The Open-Closed Principle Bertrand Meyer made this principle famous in the 1980s. The gist is that for software systems to be easy to change, they must be designed to allow the behavior of those systems to be changed by adding new code, rather than changing existing code.
LSP: The Liskov Substitution Principle Barbara Liskov’s famous definition of subtypes, from 1988. In short, this principle says that to build software systems from interchangeable parts, those parts must adhere to a contract that allows those parts to be substituted one for another.
ISP: The Interface Segregation Principle This principle advises software designers to avoid depending on things that they don’t use.
DIP: The Dependency Inversion Principle The code that implements high-level policy should not depend on the code that implements low-level details. Rather, details should depend on policies.
SRP:单一职责原则。 该设计原则是某于康威圧律(Conway's Law)的一个推论——一个软件系统的最佳结构高度依赖于开发这个系统的组织的内部结构。这样,每个软件模块都有且只有一个需要被改变的理由。
OCP:开闭原则。 该设计原则是由 Bertrand Meyer 在 20 世纪 80 年代大力推广的,其核心要素是:如果软件系统想要更容易被改变,那么其设计就必须允许新增代码来修改系统行为,而非只能靠修改原来的代码。
LSP:里氏替换原则。 该设计原则是 Barbara Liskov 在 1988 年提出的一个著名的子类型定义。简单来说,这项原则的意思是如果想用可替换的组件来构建软件系统,那么这些组件就必须遵守同一个约定,以便让这些组件可以相互替换。
ISP:接口隔离原则。 这项设计原则主要告诫软件设计师应该在设计中避免不必要的依赖。
DIP:依赖反转原则。 该设计原则指出高层策略性的代码不应该依赖实现底层细节的代码,恰恰相反,那些实现底层细节的代码应该依赖高层策略性的代码。
These principles have been described in detail in many different publications1 over the years. The chapters that follow will focus on the architectural implications of these principles instead of repeating those detailed discussions. If you are not already familiar with these principles, what follows is insufficient to understand them in detail and you would be well advised to study them in the footnoted documents.

这些年来,这些设计原则在很多不同的出版物中都有过详细描述。在接下来的章节中,我们将会主要关注这些原则在软件架构上的意义,而不再重复其细节信息。如果你对这些原则并不是特别了解,那么我建议你先通过脚注中的文档熟悉一下它们,否则接下来的章节可能有点难以理解。

08
3

In many ways, the concepts of functional programming predate programming itself. This paradigm is strongly based on the l-calculus invented by Alonzo Church in the 1930s.

函数式编程所依赖的原理,在很多方而其实是早于编程本身出现的。因为函数式编程这种范式强烈依赖于 Alonzo Church 在 20 世纪 30 年代发明的 λ 演算。

SQUARES OF INTEGERS 整数平方
To explain what functional programming is, it’s best to examine some examples. Let’s investigate a simple problem: printing the squares of the first 25 integers.

我们最好还是用一个例子来解释什么是函数式编程。请看下面的这个例子:这段代码想要输出前 25 个整数的平方值。

In a language like Java, we might write the following:

如果使用 Java 语言,代码如下:

public class Squint {
public static void main(String args[]) {
for (int i=0; i<25; i++)
System.out.println(i*i);
}
}
In a language like Clojure, which is a derivative of Lisp, and is functional, we might implement this same program as follows:

下面我们改用 Clojure 语言来写这个程序,Clojure 是 LISP 语言的一种衍生体,属于函数式编程语言。其代码如下:

(println (take 25 (map (fn [x] (* x x)) (range))))
If you don’t know Lisp, then this might look a little strange. So let me reformat it a bit and add some comments.

如果读者对 LISP 不熟悉,这段代码可能看起来很奇怪。没关系,让我们换一种格式,用注释来说明一下吧:

(println ;__ Print
(take 25 ;
the first 25
(map (fn [x] (* x x)) ;__ squares
(range)))) ;___ of Integers
It should be clear that println, take, map, and range are all functions. In Lisp, you call a function by putting it in parentheses. For example, (range) calls the range function.

很明显,这里的 println、take、map 和 range 都是函数。在 LISP 中,函数是通过括号来调用的,例如(range)表达式就是在调用 range 函数。

The expression (fn [x] (* x x)) is an anonymous function that calls the multiply function, passing its input argument in twice. In other words, it computes the square of its input.

而表达式 (fn [x] (* xx)) 则是一个匿名函数,该函数用同样的值作为参数调用了乘法函数。换句话说,该函数计算的是平方值。

Looking at the whole thing again, it’s best to start with the innermost function call.

现在让我们回过头再看一下这整句代码,从最内侧的函数调用开始:

The range function returns a never-ending list of integers starting with 0.
This list is passed into the map function, which calls the anonymous squaring function on each element, producing a new never-ending list of all the squares.
The list of squares is passed into the take function, which returns a new list with only the first 25 elements.
The println function prints its input, which is a list of the first 25 squares of integers.
range 函数会返回一个从 0 开始的整数无穷列表。
然后该列表会被传入 map 函数,并针对列表中的每个元素,调用求平方值的匿名函数,产生了一个无穷多的、包含平方值的列表。
接着再将这个列表传入 take 函数,后者会返回一个仅包含前 25 个元素的 新列表。
println 函数将它的参数输出,该参数就是上面这个包含了 25 个平方值的 列表。
If you find yourself terrified by the concept of never-ending lists, don’t worry. Only the first 25 elements of those never-ending lists are actually created. That’s because no element of a never-ending list is evaluated until it is accessed.

读者不用担心上面提到的无穷列表。因为这些列表中的元素只有在被访问时才会被创建,所以实际上只有前 25 个元素是真正被创建了的。

If you found all of that confusing, then you can look forward to a glorious time learning all about Clojure and functional programming. It is not my goal to teach you about these topics here.

如果上述内容还是让读者觉得云里雾里的话,可以自行学习一下 Clojure 和函数式编程,本书的目标并不是要教你学会这门语言,因此不再展开。

Instead, my goal here is to point out something very dramatic about the difference between the Clojure and Java programs. The Java program uses a mutable variable—a variable that changes state during the execution of the program. That variable is i—the loop control variable. No such mutable variable exists in the Clojure program. In the Clojure program, variables like x are initialized, but they are never modified.

相反,我们讨论它的主要目标是要突显出 Clojure 和 Java 这两种语言之间的巨大区别。在 Java 程序中,我们使用的是可变量,即变量 i,该变量的值会随着程序执行的过程而改变,故被称为循环控制变量。而 Clojure 程序中是不存在这种变量的,变量 x 一旦被初始化之后,就不会再被更改了。

This leads us to a surprising statement: Variables in functional languages do not vary.

这句话有点出人意料:函数式编程语言中的变量(Variable)是不可变(Vary)的。

IMMUTABILITY AND ARCHITECTURE 不可变性与软件架构
Why is this point important as an architectural consideration? Why would an architect be concerned with the mutability of variables? The answer is absurdly simple: All race conditions, deadlock conditions, and concurrent update problems are due to mutable variables. You cannot have a race condition or a concurrent update problem if no variable is ever updated. You cannot have deadlocks without mutable locks.

为什么不可变性是软件架构设计需要考虑的重点呢?为什么软件架构帅要操心变量的可变性呢?答案显而易见:所有的竞争问题、死锁问题、并发更新问题都是由可变变量导致的。如果变量永远不会被更改,那就不可能产生竞争或者并发更新问题。如果锁状态是不可变的,那就永远不会产生死锁问题。

In other words, all the problems that we face in concurrent applications—all the problems we face in applications that require multiple threads, and multiple processors—cannot happen if there are no mutable variables.

换句话说,一切并发应用遇到的问题,一切由于使用多线程、多处理器而引起的问题,如果没有可变变量的话都不对能发工。

As an architect, you should be very interested in issues of concurrency. You want to make sure that the systems you design will be robust in the presence of multiple threads and processors. The question you must be asking yourself, then, is whether immutability is practicable.

作为一个软件架构师,当然应该要对并发问题保持高度关注。我们需要确保自己设计的系统在多线程、多处理器环境中能稳定工作。所以在这里,我们实际应该要问的问题是:不可变性是否实际可行?

The answer to that question is affirmative, if you have infinite storage and infinite processor speed. Lacking those infinite resources, the answer is a bit more nuanced. Yes, immutability can be practicable, if certain compromises are made.

如果我们能忽略存储器与处理器在速度上的限制,那么答案是肯定的。否则的话,不可变性只有在一定情况下是可行的。

Let’s look at some of those compromises.

下面让我们来看一下它具体该如何做到可行。

SEGREGATION OF MUTABILITY 可变性的隔离
One of the most common compromises in regard to immutability is to segregate the application, or the services within the application, into mutable and immutable components. The immutable components perform their tasks in a purely functional way, without using any mutable variables. The immutable components communicate with one or more other components that are not purely functional, and allow for the state of variables to be mutated (Figure 6.1).

一种常见方式是将应用程序,或者是应用程序的内部服务进行切分,划分为可变的和不可变的两种组件。不可变组件用纯函数的方式来执行任务,期间不更改任何状态。这些不可变的组件将通过与一个或多个非函数式组件通信的方式来修改变量状态(参见图 6.1)。

Mutating state and transactional memory

Since mutating state exposes those components to all the problems of concurrency, it is common practice to use some kind of transactional memory to protect the mutable variables from concurrent updates and race conditions.

由于状态的修改会导致一系列并发问题的产生,所以我们通常会采用某种事务型内存来保护可变变量,避免同步更新和竞争状态的发生。

Transactional memory simply treats variables in memory the same way a database treats records on disk.1 It protects those variables with a transaction- or retry-based scheme.

事务型内存基本上与数据库保护磁盘数据的方式 1 类似,通常釆用的是事务或者重试机制。

A simple example of this approach is Clojure’s atom facility:

下面我们可以用 Clojure 中的 atom 机制来写一个简单的例子:

(def counter (atom 0)) ; initialize counter to 0
(swap! counter inc) ; safely increment counter.
In this code, the counter variable is defined as an atom. In Clojure, an atom is a special kind of variable whose value is allowed to mutate under very disciplined conditions that are enforced by the swap! function.

在这段代码中,counter 变量被定义为 atom 类型。在 Clojure 中,atom 是一类特殊的变量,它被允许在 swap!函数定义的严格条件下进行更改。

The swap! function, shown in the preceding code, takes two arguments: the atom to be mutated, and a function that computes the new value to be stored in the atom. In our example code, the counter atom will be changed to the value computed by the inc function, which simply increments its argument.

至于 swap! 函数,如同上面代码所写,它需要两个参数:一个是被用来修改的 atom 类型实例,另一个是用来计算新值的函数。在上面的代码中,inc 函数会将参数加 1 并存入 counter 这个 atom 实例。

The strategy used by swap! is a traditional compare and swap algorithm. The value of counter is read and passed to inc. When inc returns, the value of counter is locked and compared to the value that was passed to inc. If the value is the same, then the value returned by inc is stored in counter and the lock is released. Otherwise, the lock is released, and the strategy is retried from the beginning.

在这里,swap!所采用的策略是传统的比较+替换算法。即先读取 counter 变量的值,再将其传入 inc 函数。然后当 inc 函数返回时,将原先用锁保护起来的 counter 值与传入 inc 时的值进行比较。如果两边的值一致,则将 inc 函数返回的值存入 counter,释放锁。否则,先释放锁,再从头进行重试。

The atom facility is adequate for simple applications. Unfortunately, it cannot completely safeguard against concurrent updates and deadlocks when multiple dependent variables come into play. In those instances, more elaborate facilities can be used.

当然,atom 这个机制只适用于上面这种简单的应用程序,它并不适用于解决由多个相关变量同时需要更改所引发的并发更新问题和死锁问题,要想解决这些问题,我们就需要用到更复杂的机制。

The point is that well-structured applications will be segregated into those components that do not mutate variables and those that do. This kind of segregation is supported by the use of appropriate disciplines to protect those mutated variables.

这里的要点是:一个架构设计良好的应用程序应该将状态修改的部分和不需要修改状态的部分隔离成单独的组件,然后用合适的机制来保护可变量。

Architects would be wise to push as much processing as possible into the immutable components, and to drive as much code as possible out of those components that must allow mutation.

软件架构师应该着力于将大部分处理逻辑都归于不可变组件中,可变状态组件的逻辑应该越少越好。

EVENT SOURCING 事件溯源
The limits of storage and processing power have been rapidly receding from view. Nowadays it is common for processors to execute billions of instructions per second and to have billions of bytes of RAM. The more memory we have, and the faster our machines are, the less we need mutable state.

随着存储和处理能力的大幅进步,现在拥有每秒可以执行数十亿条指令的处理器,以及数十亿字节内存的计算机已经很常见了。而内存越大,处理速度越快,我们对可变状态的依赖就会越少。

As a simple example, imagine a banking application that maintains the account balances of its customers. It mutates those balances when deposit and withdrawal transactions are executed.

举个简单的例子,假设某个银行应用程序需要维护客户账户余额信息,当它放行存取款事务时,就要同时负责修改余额记录。

Now imagine that instead of storing the account balances, we store only the transactions. Whenever anyone wants to know the balance of an account, we simply add up all the transactions for that account, from the beginning of time. This scheme requires no mutable variables.

如果我们不保存具体账户余额,仅仅保存事务日志,那么当有人想查询账户余额时。我们就将全部交易记录取出,并且每次都得从最开始到当下进行累计。当然,这样的设计就不需要维护任何可变变量了。

Obviously, this approach sounds absurd. Over time, the number of transactions would grow without bound, and the processing power required to compute the totals would become intolerable. To make this scheme work forever, we would need infinite storage and infinite processing power.

但显而易见,这种实现是有些不合理的。因为随着时间的推移,事务的数目会无限制增长,每次处理总额所需要的处理能力很快就会变得不能接受。如果想使这种设计永远可行的话,我们将需要无限容量的存储,以及无限的处理能力。

But perhaps we don’t have to make the scheme work forever. And perhaps we have enough storage and enough processing power to make the scheme work for the reasonable lifetime of the application.

但是可能我们并不需要这个设计永远可行,而且可能在整个程序的生命周期内,我们有足够的存储和处理能力来满足它。

This is the idea behind event sourcing.2 Event sourcing is a strategy wherein we store the transactions, but not the state. When state is required, we simply apply all the transactions from the beginning of time.

这就是事件溯源,在这种体系下,我们只存储事务记录,不存储具体状态。当需要具体状态时,我们只要从头开始计算所有的事务即可。

Of course, we can take shortcuts. For example, we can compute and save the state every midnight. Then, when the state information is required, we need compute only the transactions since midnight.

Now consider the data storage required for this scheme: We would need a lot of it. Realistically, offline data storage has been growing so fast that we now consider trillions of bytes to be small—so we have a lot of it.

在存储方面,这种架构的确需要很大的存储容量。如今离线数据存储器的增长是非常快的,现在 1 TB 对我们来说也已经不算什么了。

More importantly, nothing ever gets deleted or updated from such a data store. As a consequence, our applications are not CRUD; they are just CR. Also, because neither updates nor deletions occur in the data store, there cannot be any concurrent update issues.

更重要的是,这种数据存储模式中不存在删除和更新的情况,我们的应用程序不是 CRUD,而是 CR。因为更新和删除这两种操作都不存在了,自然也就不存在并发问题。

If we have enough storage and enough processor power, we can make our applications entirely immutable—and, therefore, entirely functional.

如果我们有足够大的存储量和处理能力,应用程序就可以用完全不可变的、纯函数式的方式来编程。

If this still sounds absurd, it might help if you remembered that this is precisely the way your source code control system works.

如果读者还是觉得这听起来不太靠谱,可以想想我们现在用的源代码管理程序,它们正是用这种方式工作的!

CONCLUSION 本章小结
To summarize:

下面我们来总结一下:

Structured programming is discipline imposed upon direct transfer of control.
Object-oriented programming is discipline imposed upon indirect transfer of control.
Functional programming is discipline imposed upon variable assignment.
结构化编程是多对程序控制权的直接转移的限制。
面向对象编程是对程序控制权的间接转移的限制。
函数式编程是对程序中赋值操作的限制。
Each of these three paradigms has taken something away from us. Each restricts some aspect of the way we write code. None of them has added to our power or our capabilities.

这三个编程范式都对程序员提出了新的限制。每个范式都约束了某种编写代码的方式,没有一个编程范式是在增加新能力。

What we have learned over the last half-century is what not to do.

也就是说,我们过去 50 年学到的东西主要是——什么不应该做。

With that realization, we have to face an unwelcome fact: Software is not a rapidly advancing technology. The rules of software are the same today as they were in 1946, when Alan Turing wrote the very first code that would execute in an electronic computer. The tools have changed, and the hardware has changed, but the essence of software remains the same.

我们必须面对这种不友好的现实:软件构建并不是一个迅速前进的技术。今天构建软件的规则和 1946 年阿兰·图灵写下电子计算机的第一行代码时是一样的。尽管工具变化了,硬件变化了,但是软件编程的核心没有变。

Software—the stuff of computer programs—is composed of sequence, selection, iteration, and indirection. Nothing more. Nothing less.

总而言之,软件,或者说计算机程序无一例外是由顺序结构、分支结构、循环结构和间接转移这几种行为组合而成的,无可增加,也缺一不可。

07
3

As we will see, the basis of a good architecture is the understanding and application of the principles of object-oriented design (OO). But just what is OO?

稍后我们会讲到,设计一个优秀的软件架构要基于对面向对象设计(Object-Oriented Design)的深入理解及应用。但我们首先得弄明白一个问题:究竟什么是面向对象?

One answer to this question is “The combination of data and function.” Although often cited, this is a very unsatisfying answer because it implies that o.f() is somehow different from f(o). This is absurd. Programmers were passing data structures into functions long before 1966, when Dahl and Nygaard moved the function call stack frame to the heap and invented OO.

对于这个问题,一种常见的回答是“数据与函数的组合”。这种说法虽然被广为引用,但总显得并不是那么贴切,因为它似乎暗示了 o.f() 与 f(o) 之间是有区别的,这显然不是事实。面向对象理论是在 1966 年提出的,当时 Dahl 和 Nygaard 主要是将函数调用栈迁移到了堆区域中。数据结构被用作函数的调用参数这件事情远比这发生的时间更早。

Another common answer to this question is “A way to model the real world.” This is an evasive answer at best. What does “modeling the real world” actually mean, and why is it something we would want to do? Perhaps this statement is intended to imply that OO makes software easier to understand because it has a closer relationship to the real world—but even that statement is evasive and too loosely defined. It does not tell us what OO is.

另一种常见的回答是“面向对象编程是一种对真实世界进行建模的方式”,这种回答只能算作避重就轻。“对真实世界的建模”到底要如何进行?我们为什么要这么做,有什么好处?也许这句话意味着是“由于采用面向对象方式构建的软件与真实世界的关系更紧密,所以面向对象编程可以使得软件开发更容易”——即使这样说,也仍然逃避了关键问题——面向对象编程究竟是什么?

Some folks fall back on three magic words to explain the nature of OO: encapsulation, inheritance, and polymorphism. The implication is that OO is the proper admixture of these three things, or at least that an OO language must support these three things.

还有些人在回答这个问题的时候,往往会搬出一些神秘的词语,譬如封装(encapsulation)、继承(inheritance)、多态(polymorphism)。其隐含意思就是说面向对象编程是这三项的有机组合,或者任何一种支持面向对象的编程语言必须支持这三个特性。

Let’s examine each of these concepts in turn.

那么,我们接下来可以逐个来分析一下这三个概念。

ENCAPSULATION? 封装
The reason encapsulation is cited as part of the definition of OO is that OO languages provide easy and effective encapsulation of data and function. As a result, a line can be drawn around a cohesive set of data and functions. Outside of that line, the data is hidden and only some of the functions are known. We see this concept in action as the private data members and the public member functions of a class.

导致封装这个概念经常被引用为面向对象编程定义的一部分。通过釆用封装特性,我们可以把一组相关联的数据和函数圈起来,便圈外血的代码只能看见部分函数,数据则完全不可见。譬如在实际应用中,类(class)中的公共函数和私有成员变量就是这样。

This idea is certainly not unique to OO. Indeed, we had perfect encapsulation in C. Consider this simple C program:

然而,这个特性其实并不是面向对象编程所独有的。其实,c 语言也支持完整的封装,下面来看一个简单的 c 程序:

point.h

struct Point;
struct Point makePoint(double x, double y);
double distance (struct Point
p1, struct Point *p2);
point.c

include "point.h"

include

include

struct Point {
double x,y;
};

struct Point makepoint(double x, double y) {
struct Point
p = malloc(sizeof(struct Point));
p->x = x;
p->y = y;
return p;
}

double distance(struct Point p1, struct Point p2) {
double dx = p1->x - p2->x;
double dy = p1->y - p2->y;
return sqrt(dxdx+dydy);
}
The users of point.h have no access whatsoever to the members of struct Point. They can call the makePoint() function, and the distance() function, but they have absolutely no knowledge of the implementation of either the Point data structure or the functions.

显然,使用 point.h 的程序是没有 Point 结构体成员的访问权限的。它们只能调用 makePoint() 函数和 distance() 函数,但对它们来说,Point 这个数据结构体的内部细节,以及函数的具体实现方式都是不可见的。

This is perfect encapsulation—in a non-OO language. C programmers used to do this kind of thing all the time. We would forward declare data structures and functions in header files, and then implement them in implementation files. Our users never had access to the elements in those implementation files.

这正是完美封装 虽然 C 语言是非面向对象的编程语言。上述 C 程序是很常见的。在头文件中进行数据结构以及函数定义的前置声明(forward declare),然后 在程序文件中具体实现。程序文件中的具体实现细节对使用者来说是不可见的。

But then came OO in the form of C++—and the perfect encapsulation of C was broken.

而 C++作为一种面向对象编程语言,反而破坏了 c 的完美封装性。

The C++ compiler, for technical reasons,1 needed the member variables of a class to be declared in the header file of that class. So our Point program changed to look like this:

由于一些技术原,C++编译器要求类的成员变量必须在该类的头文件中声明。这样一来,我们的 point.h 程序随之就改成了这样:

point.h

class Point {
public:
Point(double x, double y);
double distance(const Point& p) const;

private:
double x;
double y;
};
point.cc

include "point.h"

include

Point::Point(double x, double y)
: x(x), y(y)
{}

double Point::distance(const Point& p) const {
double dx = x-p.x;
double dy = y-p.y;
return sqrt(dxdx + dydy);
}
Clients of the header file point.h know about the member variables x and y! The compiler will prevent access to them, but the client still knows they exist. For example, if those member names are changed, the point.cc file must be recompiled! Encapsulation has been broken.

好了,point.h 文件的使用者现在知道了成员变量 x 和 y 的存在!虽然编译器会禁止对这两个变量的直接访问,但是使用者仍然知道了它们的存在。而且,如果 x 和 y 变量名称被改变了,point.cc 也必须重新编译才行!这样的封装性显然是不完美的。

Indeed, the way encapsulation is partially repaired is by introducing the public, private, and protected keywords into the language. This, however, was a hack necessitated by the technical need for the compiler to see those variables in the header file.

当然,C++通过在编程语言层面引入 public、private、protected 这些关键词,部分维护了封装性。但所有这些都是为了解决编译器自身的技术实现问题而引入的 hack——编译器由于技术实现原因必须在头文件中看到成员变量的定义。

Java and C# simply abolished the header/implementation split altogether, thereby weakening encapsulation even more. In these languages, it is impossible to separate the declaration and definition of a class.

而 Java 和 C# 则彻底抛弃了头文件与实现文件分离的编程方式,这其实进一步削弱了封装性。因为在这些语言中,我们是无法区分一个类的声明和定义的。

For these reasons, it is difficult to accept that OO depends on strong encapsulation. Indeed, many OO languages2 have little or no enforced encapsulation.

由于上述原因,我们很难说强封装是面向对象编程的必要条件。而事实上,有很多面向对象编程语言|对封装性并没有强制性的要求。

OO certainly does depend on the idea that programmers are well-behaved enough to not circumvent encapsulated data. Even so, the languages that claim to provide OO have only weakened the once perfect encapsulation we enjoyed with C.

面向对象编程在应用上确实会要求程序员尽量避免破坏数据的封装性。但实际情况是,那些声称自己提供面向对象编程支持的编程语言,相对于 C 这种完美封装的语言而言,其封装性都被削弱了,而不是加强了。

INHERITANCE? 继承
If OO languages did not give us better encapsulation, then they certainly gave us inheritance.

既然面向对象编程语言并没有提供更好的封装性,那么在继承性方面又如何呢?

Well—sort of. Inheritance is simply the redeclaration of a group of variables and functions within an enclosing scope. This is something C programmers3 were able to do manually long before there was an OO language.

嗯,其实也就一般般吧。简而言之,继承的主要作用是让我们可以在某个作用域内对外部定义的某一组变量与函数进行覆盖。这事实上也是 c 程序员早在面向对象编程语言发明之前就一直在做的事了。

Consider this addition to our original point.h C program:

下面,看一下刚才的 C 程序 point.h 的扩展版:

namedPoint.h

struct NamedPoint;

struct NamedPoint makeNamedPoint(double x, double y, char name);
void setName(struct NamedPoint np, char name);
char getName(struct NamedPoint np);
namedPoint.c

include "namedPoint.h"

include

struct NamedPoint {
double x,y;
char* name;
};

struct NamedPoint makeNamedPoint(double x, double y, char name) {
struct NamedPoint* p = malloc(sizeof(struct NamedPoint));
p->x = x;
p->y = y;
p->name = name;
return p;
}

void setName(struct NamedPoint np, char name) {
np->name = name;
}

char getName(struct NamedPoint np) {
return np->name;
}
main.c

include "point.h"

include "namedPoint.h"

include

int main(int ac, char* av) {
struct NamedPoint
origin = makeNamedPoint(0.0, 0.0, "origin");
struct NamedPoint upperRight = makeNamedPoint (1.0, 1.0, "upperRight");
printf("distance=%f\n",
distance(
(struct Point
) origin,
(struct Point*) upperRight));
}
If you look carefully at the main program, you’ll see that the NamedPoint data structure acts as though it is a derivative of the Point data structure. This is because the order of the first two fields in NamedPoint is the same as Point. In short, NamedPoint can masquerade as Point because NamedPoint is a pure superset of Point and maintains the ordering of the members that correspond to Point.

请仔细观察 main 函数,这里 NamedPoint 数据结构是被当作 Point 数据结构的一个衍生体來使用的。之所以可以这样做,是因为 NamedPoint 结构体的前两个成员的顺用与 Point 结构休的完全一致。简单来说,NamedPoint 之所以可以被伪装成 Point 来使用,是因为 NamedPoint 是 Point 结构体的一个超集,同两者共同成员的顺序也是一样的。

This kind of trickery was a common practice4 of programmers prior to the advent of OO. In fact, such trickery is how C++ implements single inheritance.

面这种编程方式虽然看上去有些投机取巧,但是在面向对象理论被提出之前,这已经很常见了。其实,C++内部就是这样实现单继承的。

Thus we might say that we had a kind of inheritance long before OO languages were invented. That statement wouldn’t quite be true, though. We had a trick, but it’s not nearly as convenient as true inheritance. Moreover, multiple inheritance is a considerably more difficult to achieve by such trickery.

因此,我们可以说,早在面向对象编程语言被发明之前,对继承性的支持就已经存在很久了。当然了,这种支持用了一些投机取巧的手段,并不像如今的继昼:样便利易用,而且,多重继承(multiple inheritance)如果还想用这种方法来实现,就更难了。

Note also that in main.c, I was forced to cast the NamedPoint arguments to Point. In a real OO language, such upcasting would be implicit.

同时应该注意的是,在 main.c 中,程序员必须强制将 NamedPoint 的参数类型转换为 Point,而在真正的面向对象编程语言中,这种类型的向上转换通常应该是隐性的。

It’s fair to say that while OO languages did not give us something completely brand new, it did make the masquerading of data structures significantly more convenient.

综上所述,我们可以认为,虽然面向对象编程在继承性方面并没有开创出新,但是的确在数据结构的伪装性上提供了相当程度的便利性。

To recap: We can award no point to OO for encapsulation, and perhaps a half-point for inheritance. So far, that’s not such a great score.

回顾一下到目前为止的分析,面向对象编程在封装性上得 0 分,在继承性上勉强可以得 0.5 分(满分为 1)。

But there’s one more attribute to consider.

下面,我们还有最后一个特性要讨论。

POLYMORPHISM? 多态
Did we have polymorphic behavior before OO languages? Of course we did. Consider this simple C copy program.

在面向编程对象语言被发明之前,我们所使用的编程语言能支持多态吗? 答案是肯定的,请注意看下面这段用 C 语言编写的 copy 程序:

include

void copy() {
int c;
while ((c=getchar()) != EOF)
putchar(c);
}
The function getchar() reads from STDIN. But which device is STDIN? The putchar() function writes to STDOUT. But which device is that? These functions are polymorphic—their behavior depends on the type of STDIN and STDOUT.

在上述程序中,函数 getchar() 主要负责从 STDTN 中读取数据。但是 STDLLN 究竟指代的是哪个设备呢?同样的道理,putchar() 主要负责将数据写入 STDOUT,而 STDOUT 又指代的是哪个设备呢?很显然,这类函数其实就具有多态性,因为它们的行为依赖于 STDIN 和 STDOUT 的具体类型。

It’s as though STDIN and STDOUT are Java-style interfaces that have implementations for each device. Of course, there are no interfaces in the example C program—so how does the call to getchar() actually get delivered to the device driver that reads the character?

这里的 STDIN 和 STDOUT 与 Java 中的接口类似,各种设备都有各自的实现。当然,这个 C 程序中是没有接口这个概念的,那么 getchar() 这个调用的动作是 如何真正传递到设备驱动程序中,从而读取到具体内容的呢?

The answer to that question is pretty straightforward. The UNIX operating system requires that every IO device driver provide five standard functions:5 open, close, read, write, and seek. The signatures of those functions must be identical for every IO driver.

其实很简单,UNIX 操作系统强制要求每个 IO 设备都要提供 open、close、read、write 和 seek 这 5 个标准函数。也就是说,每个 IO 设备驱动程序对这 5 种函数的实现在函数调用上必须保持一致。

The FILE data structure contains five pointers to functions. In our example, it might look like this:

首先,FILE 数据结构体中包含了相对应的 5 个函数指针,分别用于指向这些函数:

struct FILE {
void (open)(char name, int mode);
void (close)();
int (
read)();
void (write)(char);
void (
seek)(long index, int mode);
};
The IO driver for the console will define those functions and load up a FILE data structure with their addresses—something like this:

然后,譬如控制台设备的 IO 驱动程序就会提供这 5 个函数的实际定义,将 FILE 结构体的函数指针指向这些对应的实现函数:

include "file.h"

void open(char name, int mode) {/.../}
void close() {/
.../};
int read() {int c;/
.../ return c;}
void write(char c) {/
.../}
void seek(long index, int mode) {/
...*/}

struct FILE console = {open, close, read, write, seek};
Now if STDIN is defined as a FILE*, and if it points to the console data structure, then getchar() might be implemented this way:

现在,如果 STDIN 的定义是 FILE*,并同时指向了 console 这个数据结构,那么 getchar() 的实现方式就是这样的:

extern struct FILE* STDIN;

int getchar() {
return STDIN->read();
}
In other words, getchar() simply calls the function pointed to by the read pointer of the FILE data structure pointed to by STDIN.

换句话说,getchar() 只是调用了 STDIN 所指向的 FIL E 数据结构体中的 read 函数指针指向的函数。

This simple trick is the basis for all polymorphism in OO. In C++, for example, every virtual function within a class has a pointer in a table called a vtable, and all calls to virtual functions go through that table. Constructors of derivatives simply load their versions of those functions into the vtable of the object being created.

这个简单的编程技巧正是面向对象编程中多态的基础。例如在 C++中,类中的每个虚函数(virtual function)的地址都被记录在一个名叫 vtable 的数据结构里。我们对虚函数的每次调用都要先查询这个表,其衍生类的构造函数负责将该衍生类的虚函数地址加载到整个对象的 vtable 中。

The bottom line is that polymorphism is an application of pointers to functions. Programmers have been using pointers to functions to achieve polymorphic behavior since Von Neumann architectures were first implemented in the late 1940s. In other words, OO has provided nothing new.

归根结底,多态其实不过就是函数指针的一种应用。自从 20 世纪 40 年代末期冯·诺依曼架构诞生那天起,程序员们就一直在使用函数指针模拟多态了。也就是说,面向对象编程在多态方面没有提出任何新概念。

Ah, but that’s not quite correct. OO languages may not have given us polymorphism, but they have made it much safer and much more convenient.

当然了,面向对象编程语言虽然在多态上并没有理论创新,但它们也确实让多态变得更安全、更便于使用了。

The problem with explicitly using pointers to functions to create polymorphic behavior is that pointers to functions are dangerous. Such use is driven by a set of manual conventions. You have to remember to follow the convention to initialize those pointers. You have to remember to follow the convention to call all your functions through those pointers. If any programmer fails to remember these conventions, the resulting bug can be devilishly hard to track down and eliminate.

用函数指针显式实现多态的问题就在于函数指针的危险性。毕竟,函数指针的调用依赖于一系列需要人为遵守的约定。程序员必须严格按照固定约定来初始化函数指针,并同样严格地按照约定来调用这些指针。只要有一个程序员没有遵守这些约定,整个程序就会产生极其难以跟踪和消除的 Bug。

OO languages eliminate these conventions and, therefore, these dangers. Using an OO language makes polymorphism trivial. That fact provides an enormous power that old C programmers could only dream of. On this basis, we can conclude that OO imposes discipline on indirect transfer of control.

面向对象编程语言为我们消除人工遵守这些约定的必要,也就等于消除了这方面的危险性。采用面向对象编程语言让多态实现变得非常简单,让一个传统 C 程序员可以去做以前不敢想的事情。综上所述,我们认为面向对象编程其实是对程序间接控制权的转移进行了约束。

THE POWER OF POLYMORPHISM 多态的强大性
What’s so great about polymorphism? To better appreciate its charms, let’s reconsider the example copy program. What happens to that program if a new IO device is created? Suppose we want to use the copy program to copy data from a handwriting recognition device to a speech synthesizer device: How do we need to change the copy program to get it to work with those new devices?

那么多态的优势在哪里呢?为了让读者更好地理解多态的好处,我们需要再来看一下刚才的 copy 程序。如果要支持新的 IO 设备,该程序需要做什么改动呢?譬如,假设我们想要用该 copy 程序从一个手写识别设备将数据复制到另一个语音合成设备中,我们需要针对 copy 程序做什么改动,才能实现这个目标呢?

We don’t need any changes at all! Indeed, we don’t even need to recompile the copy program. Why? Because the source code of the copy program does not depend on the source code of the IO drivers. As long as those IO drivers implement the five standard functions defined by FILE, the copy program will be happy to use them.

答案是完全不需要做任何改动!确实,我们甚至不需要重新编译该 copy 程序。为什么?因为 copy 程序的源代码并不依赖于 IO 设备驱动程序的代码。只要 IO 设备驱动程序实现了 FILE 结构体中定义的 5 个标准函数,该 copy 程序就可以正常使用它们。

In short, the IO devices have become plugins to the copy program.

简单来说,IO 设备变成了 copy 程序的插件。

Why did the UNIX operating system make IO devices plugins? Because we learned, in the late 1950s, that our programs should be device independent. Why? Because we wrote lots of programs that were device dependent, only to discover that we really wanted those programs to do the same job but use a different device.

为什么 UNIX 操作系统会将 IO 设备设计成插件形式呢?因为自 20 世纪 50 年代末期以来,我们学到了一个重要经验:程序应该与设备无关。这个经验从何而来呢?因为一度所有程序都是设备相关的,但是后来我们发现自己其实真正需要的是在不同的设备上实现同样的功能。

For example, we often wrote programs that read input data from decks of cards,6 and then punched new decks of cards as output. Later, our customers stopped giving us decks of cards and started giving us reels of magnetic tape. This was very inconvenient, because it meant rewriting large portions of the original program. It would be very convenient if the same program worked interchangeably with cards or tape.

例如,我们曾经写过一些程序,需要从卡片盒中的打孔卡片读取数据,同时要通过在新的卡片上打孔来输出数据。后来,客户不再使用打孔卡片,而开始使用磁带卷了。这就给我们带来了很多麻烦,很多程序都需要重写。于是我们就会想,如果这段程序可以同时操作打孔卡片和磁带那该多好。

The plugin architecture was invented to support this kind of IO device independence, and has been implemented in almost every operating system since its introduction. Even so, most programmers did not extend the idea to their own programs, because using pointers to functions was dangerous.

插件式架构就是为了支持这种 IO 不相关性而发明的,它几乎在随后的所有系统中都有应用。但即使多态有如此多优点,大部分程序员还是没有将插件特性引入他们自己的程序中,因为函数指针实在是太危险了。

OO allows the plugin architecture to be used anywhere, for anything.

而面向对象编程的出现使得这种插件式架构可以在任何地方被安全地使用。

DEPENDENCY INVERSION 依赖反转
Imagine what software was like before a safe and convenient mechanism for polymorphism was available. In the typical calling tree, main functions called high-level functions, which called mid-level functions, which called low-level functions. In that calling tree, however, source code dependencies inexorably followed the flow of control (Figure 5.1).

我们可以想象一下在安全和便利的多态支持出现之前,软件是什么样子的。下面有一个典型的调用树的例子,main 函数调用了一些高层函数,这些高层函数又调用了一些中层函数,这些中层函数又继续调用了一些底层函数。在这里,源代码面的依赖不可避免地要跟随程序的控制流(详见图 5.1)。

Source code dependencies versus flow of control

For main functions to call one of the high-level functions, it had to mention the name of the module that contained that function In C, this was a #include. In Java, it was an import statement. In C#, it was a using statement. Indeed, every caller was forced to mention the name of the module that contained the callee.

如你所见,main 函数为了调用高层函数,它就必须能够看到这个函数所在模块。在 C 中,我们会通过 #include 来实现,在 Java 中则通过 import 来实现,而在 C# 中则用的是 using 语句。总之,每个函数的调用方都必须要引用被调用方所在的模块。

This requirement presented the software architect with few, if any, options. The flow of control was dictated by the behavior of the system, and the source code dependencies were dictated by that flow of control.

显然,这样做就导致了我们在软件架构上别无选择。在这里,系统行为决定了控制流,而控制流则决定了源代码依赖关系。

When polymorphism is brought into play, however, something very different can happen (Figure 5.2).

但一旦我们使用了多态,情况就不一样了(详见图 5.2)。

Dependency inversion

In Figure 5.2, module HL1 calls the F() function in module ML1. The fact that it calls this function through an interface is a source code contrivance. At runtime, the interface doesn’t exist. HL1 simply calls F() within ML1.7

在图 5.2 中,模块 HL1 调用了 ML1 模块中的 F() 函数,这里的调用是通过源代码级别的接口来实现的。当然在程序实际运行时,接口这个概念是不存在的,HL1 会调用 ML1 中的 F() 函数。

Note, however, that the source code dependency (the inheritance relationship) between ML1 and the interface I points in the opposite direction compared to the flow of control. This is called dependency inversion, and its implications for the software architect are profound.

请注意模块 ML1 和接口 I 在源代码上的依赖关系(或者叫继承关系),该关系的方向和控制流正好是相反的,我们称之为依赖反转。这种反转对软件架构设计的影响是非常大的。

The fact that OO languages provide safe and convenient polymorphism means that any source code dependency, no matter where it is, can be inverted.

事实上,通过利用面向编程语言所提供的这种安全便利的多态实现,无论我们面对怎样的源代码级别的依赖关系,都可以将其反转。

Now look back at that calling tree in Figure 5.1, and its many source code dependencies. Any of those source code dependencies can be turned around by inserting an interface between them.

现在,我们可以再回头来看图 5.1 中的调用树,就会发现其中的众多源代码依赖关系都可以通过引入接口的方式来进行反转。

With this approach, software architects working in systems written in OO languages have absolute control over the direction of all source code dependencies in the system. They are not constrained to align those dependencies with the flow of control. No matter which module does the calling and which module is called, the software architect can point the source code dependency in either direction.

通过这种方法,软件架构师可以完全控制采用了面向对象这种编程方式的系统中所有的源代码依赖关系,而不再受到系统控制流的限制。不管哪个模块调用或者被调用,软件架构师都可以随意更改源代码依赖关系。

That is power! That is the power that OO provides. That’s what OO is really all about—at least from the architect’s point of view.

这就是面向对象编程的好处,同时也是面向对象编程这种范式的核心本质至少对一个软件架构师来说是这样的。

What can you do with that power? As an example, you can rearrange the source code dependencies of your system so that the database and the user interface (UI) depend on the business rules (Figure 5.3), rather than the other way around.

这种能力有什么用呢?在下面的例子中,我们可以用它来让数据库模块和用户界面模块都依赖于业务逻辑模块(见图 5.3),而非相反。

The database and the user interface depend on the business rules

This means that the UI and the database can be plugins to the business rules. It means that the source code of the business rules never mentions the UI or the database.

这意味着我们让用户界面和数据库都成为业务逻辑的插件。也就是说,业务逻辑模块的源代码不需要引入用户界面和数据库这两个模块。

As a consequence, the business rules, the UI, and the database can be compiled into three separate components or deployment units (e.g., jar files, DLLs, or Gem files) that have the same dependencies as the source code. The component containing the business rules will not depend on the components containing the UI and database.

这样一来,业务逻辑、用户界面以及数据库就可以被编译成三个独立的组件或者部署单元(例如 jar 文件、DLL 文件、Gem 文件等)了,这些组件或者部署单元的依赖关系与源代码的依赖关系是一致的,业务逻辑组件也不会依赖于用户界面和数据库这两个组件。

In turn, the business rules can be deployed independently of the UI and the database. Changes to the UI or the database need not have any effect on the business rules. Those components can be deployed separately and independently.

于是,业务逻辑组件就可以独立于用户界面和数据库来进行部署了,我们对用户界面或者数据库的修改将不会对业务逻辑产生任何影响,这些组件都可以被分另独立地部署。

In short, when the source code in a component changes, only that component needs to be redeployed. This is independent deployability.

简单来说,当某个组件的源代码需要修改时,仅仅需要重新部署该组件,不需要更改其他组件,这就是独立部署能力。

If the modules in your system can be deployed independently, then they can be developed independently by different teams. That’s independent developability.

如果系统中的所有组件都可以独立部署,那它们就可以由不同的团队并行开发,这就是所谓的独立开发能力。

CONCLUSION 本章小结
What is OO? There are many opinions and many answers to this question. To the software architect, however, the answer is clear: OO is the ability, through the use of polymorphism, to gain absolute control over every source code dependency in the system. It allows the architect to create a plugin architecture, in which modules that contain high-level policies are independent of modules that contain low-level details. The low-level details are relegated to plugin modules that can be deployed and developed independently from the modules that contain high-level policies.

面向对象编程到底是什么?业界在这个问题上存在着很多不同的说法和意见。然而对一个软件架构师来说,其含义应该是非常明确的:面向对象编程就是以对象为手段来对源代码中的依赖关系进行控制的能力,这种能力让软件架构师可以构建出某种插件式架构,让高层策略性组件与底层实现性组件相分离,底层组件可必编译成插件,实现独立于高层组件的开发和部署。

公告栏

欢迎大家来到我的博客,我是dodoro,希望我的博客能给你带来帮助。