19.1. 面向对象的编程和继承

在过去的 30-40 年中,面向对象编程是软件开发中最重要的新思想之一。它引入了诸如类,继承,私有方法和实例变量之类的概念。如果仔细使用,这些机制可以帮助产生更好的软件设计。例如,私有方法和变量可用于确保信息隐藏:类外的任何代码都不能调用私有方法或访问私有变量,因此它们上没有任何外部依赖关系。

面向对象编程的关键要素之一是继承。继承有两种形式,它们对软件复杂性有不同的含义。继承的第一种形式是接口继承,其中父类定义一个或多个方法的签名,但不实现这些方法。每个子类都必须实现签名,但是不同的子类可以以不同的方式实现相同的方法。例如,该接口可能定义用于执行 I/O 的方法。一个子类可能对磁盘文件实现 I/O 操作,而另一个子类可能对网络套接字实现相同的操作。

接口继承通过出于多种目的重用同一接口,从而提供了针对复杂性的杠杆作用。它使解决一个问题(例如如何使用 I/O 接口读取和写入磁盘文件)中获得的知识可以用于解决其他问题(例如通过网络套接字进行通信)。关于深度的另一种思考方式是:接口的实现越不同,接口就越深入。为了使接口具有许多实现,它必须捕获所有基础实现的基本功能,同时避免实现之间的差异。这个概念是抽象的核心。

继承的第二种形式是实现继承。以这种形式,父类不仅定义了一个或多个方法的签名,而且还定义了默认实现。子类可以选择继承方法的父类实现,也可以通过定义具有相同签名的新方法来覆盖它。如果没有实现继承,则可能需要在几个子类中复制相同的方法实现,这将在这些子类之间创建依赖关系(修改需要在方法的所有副本中复制)。因此,实现继承减少了随着系统的发展而需要修改的代码量。换句话说,它减少了 第二章:复杂性的本质 中描述的变化放大问题。

但是,实现继承会在父类及其每个子类之间创建依赖关系。父类和子类通常都访问父类中的类实例变量。这会导致继承层次结构中的类之间的信息泄漏,并且使得在不查看其他类的情况下很难修改层次结构中的一个类。例如,对父类进行更改的开发人员可能需要检查所有子类,以确保所做的更改不会破坏任何内容。同样,如果子类覆盖父类中的方法,则子类的开发人员可能需要检查父类中的实现。在最坏的情况下,程序员将需要完全了解父类下的整个类层次结构,以便对任何类进行更改。

因此,应谨慎使用实现继承。在使用实现继承之前,请考虑基于组合的方法是否可以提供相同的好处。例如,可以使用小型帮助程序类来实现共享功能。原始类可以从辅助类的功能构建,而不是从父类继承函数。

如果没有实现继承的可行选择,请尝试将父类管理的状态与子类管理的状态分开。一种方法是,某些实例变量完全由父类中的方法管理,子类仅以只读方式或通过父类中的其他方法使用它们。这适用于隐藏在类层次结构中的信息的概念,以减少依赖性。

尽管面向对象编程提供的机制可以帮助实现干净的设计,但是它们本身不能保证良好的设计。例如,如果类很浅,或者具有复杂的接口,或者允许外部访问其内部状态,那么它们仍将导致很高的复杂性。

Object-oriented programming is one of the most important new ideas in software development over the last 30–40 years. It introduced notions such as classes, inheritance, private methods, and instance variables. If used carefully, these mechanisms can help to produce better software designs. For example, private methods and variables can be used to ensure information hiding: no code outside a class can invoke private methods or access private variables, so there can’t be any external dependencies on them.

One of the key elements of object-oriented programming is inheritance. Inheritance comes in two forms, which have different implications for software complexity. The first form of inheritance is interface inheritance, in which a parent class defines the signatures for one or more methods, but does not implement the methods. Each subclass must implement the signatures, but different subclasses can implement the same methods in different ways. For example, the interface might define methods for performing I/O; one subclass might implement the I/O operations for disk files, and another subclass might implement the same operations for network sockets.

Interface inheritance provides leverage against complexity by reusing the same interface for multiple purposes. It allows knowledge acquired in solving one problem (such as how to use an I/O interface to read and write disk files) to be used to solve other problems (such as communicating over a network socket). Another way of thinking about this is in terms of depth: the more different implementations there are of an interface, the deeper the interface becomes. In order for an interface to have many implementations, it must capture the essential features of all the underlying implementations while steering clear of the details that differ between the implementations; this notion is at the heart of abstraction.

The second form of inheritance is implementation inheritance. In this form, a parent class defines not only signatures for one or more methods, but also default implementations. Subclasses can choose to inherit the parent’s implementation of a method or override it by defining a new method with the same signature. Without implementation inheritance, the same method implementation might need to be duplicated in several subclasses, which would create dependencies between those subclasses (modifications would need to be duplicated in all copies of the method). Thus, implementation inheritance reduces the amount of code that needs to be modified as the system evolves; in other words, it reduces the change amplification problem described in Chapter 2.

However, implementation inheritance creates dependencies between the parent class and each of its subclasses. Class instance variables in the parent class are often accessed by both the parent and child classes; this results in information leakage between the classes in the inheritance hierarchy and makes it hard to modify one class in the hierarchy without looking at the others. For example, a developer making changes to the parent class may need to examine all of the subclasses to ensure that the changes don’t break anything. Similarly, if a subclass overrides a method in the parent class, the developer of the subclass may need to examine the implementation in the parent. In the worst case, programmers will need complete knowledge of the entire class hierarchy underneath the parent class in order to make changes to any of the classes. Class hierarchies that use implementation inheritance extensively tend to have high complexity.

Thus, implementation inheritance should be used with caution. Before using implementation inheritance, consider whether an approach based on composition can provide the same benefits. For instance, it may be possible to use small helper classes to implement the shared functionality. Rather than inheriting functions from a parent, the original classes can each build upon the features of the helper classes.

If there is no viable alternative to implementation inheritance, try to separate the state managed by the parent class from that managed by subclasses. One way to do this is for certain instance variables to be managed entirely by methods in the parent class, with subclasses using them only in a read-only fashion or through other methods in the parent class. This applies the notion of information hiding within the class hierarchy to reduce dependencies.

Although the mechanisms provided by object-oriented programming can assist in implementing clean designs, they do not, by themselves, guarantee good design. For example, if classes are shallow, or have complex interfaces, or permit external access to their internal state, then they will still result in high complexity.