5.3 面向对象设计要略
本节介绍 Java 面向对象设计的几个相关技术,但不是很全面,只是为了展示一些示例。建议读者再阅读其他资料,例如前面提到的 Joshua Bloch 写的 Effective Java 一书。
本节先介绍 Java 定义常量时使用的良好实践,然后介绍使用 Java 的面向对象能力进行建模和领域对象设计的几种方式,最后介绍 Java 中一些常用设计模式的实现方式。
5.3.1 常量
前面说过,常量可以在接口中定义。实现某个接口的任何类都会继承这个接口中定义的常量,而且使用起来就像是直接在类中定义的一样。重点是,这么做不需要在常量前加上接口的名称,也不需要以任何形式实现常量。
如果要在多个类中使用一组常量,更适合在一个接口中定义这些常量,需要使用这些常量的类实现这个接口即可。例如,客户端类和服务器类在实现网络协议时,就可以把细节(例如连接和监听的端口号)存储在一些符号常量中。举个实例,java.io.ObjectStreamConstants 接口。这个接口为对象序列化协议定义了一些常量,ObjectInputStream 和 ObjectOutputStream 类都实现了这个接口。
从接口中继承常量的主要好处是,能减少输入的代码量,因为无需指定定义常量的类型。但是,除了 ObjectStreamConstants 接口之外,并不推荐这么做。常量是实现细节,不该在类签名的 implements 子句中声明。
更好的方式是在类中定义常量,而且使用时要输入完整的类名和常量名。使用 import static 指令从定义常量的类中导入常量,可以减少输入的代码量。详情参见 2.10 节。
5.3.2 用接口还是抽象类
Java 8 的出现从根本上改变了 Java 的面向对象编程模型。在 Java 8 以前,接口纯粹是 API 规范,不包含实现。如果接口有大量实现,往往会导致代码重复。
为了解决这个问题,Java 设计者开发了一种代码模式。这个模式实现的基础是,抽象类无需完全抽象,可以包含部分实现,供子类使用。某些情况下,很多子类都可以沿用抽象超类提供的方法实现。
这种模式由两部分组成:一部分是一个接口,为基本方法制定 API 规范;另一部分是一个抽象类,初步实现这些方法。java.util.List 接口和与之匹配的 java.util.AbstractList 类是个很好的例子。JDK 提供的 List 接口的两个主要实现(ArrayList 和 LinkedList),都是 AbstractList 类的子类。下面再举个例子:
// 这是个简单的接口,表示可以放入一个矩形框中的形状// 只要类想被当成RectangularShape类型,就可以从零开始实现这些方法public interface RectangularShape {void setSize(double width, double height);void setPosition(double x, double y);void translate(double dx, double dy);double area();boolean isInside();}// 这是上述接口的部分实现// 很多子类都可以以这些实现为基础public abstract class AbstractRectangularShapeimplements RectangularShape {// 形状的位置和尺寸protected double x, y, w, h;// 接口中部分方法的默认实现public void setSize(double width, double height) {w = width; h = height;}public void setPosition(double x, double y) {this.x = x; this.y = y;}public void translate (double dx, double dy) { x += dx; y += dy; }}
Java 8 引入的默认方法显著改变了这种情况。4.1.5 节说过,现在,接口可以包含实现代码。这意味着,如果定义的抽象类型(例如 Shape)可能有多个子类型(例如 Circle、Rectangle、Square),要面临一个抉择:用接口还是抽象类。因为接口和抽象类有很多类似的特性,所以有时并不确定应该使用哪个。
记住,如果一个类扩展了抽象类就不能再扩展其他类,而且接口依然不能包含任何非常量字段。也就是说,在 Java 中如何使用面向对象技术,仍有一些限制。
接口和抽象类之间的另一个重要区别和兼容性有关。如果你定义的一个接口是公开 API 的一部分,而后来想在接口中添加一个新的强制方法,那么已经实现这个接口的所有类都会出问题——也就是说,接口中添加的新方法必须声明为默认方法,并提供实现。但是,如果使用抽象类,可以放心添加非抽象方法,而不用修改已经扩展这个抽象类的类。
在这两种情况下,添加新方法都可能与子类中名称和签名相同的方法起冲突——此时,子类中的方法优先级更高。鉴于此,添加新方法时一定要谨慎,如果方法名对某个类型而言“显而易见”,或者方法可能有多个意义,尤其要小心。
一般来说,需要制定 API 规范时,推荐选择接口。接口中的强制方法不是默认方法,因为它们是 API 的一部分,实现方要提供有效的实现。当方法是真正可选的,或者只有一种可能的实现方式时,才应该使用默认方法。提供函数组合功能的 java.util.function.Function 接口是第二种情况的一例——函数只能使用一种标准方式组合,而且令人难以置信的是,只要方式合理就能覆盖默认的 compose() 方法。
最后,我要说一下,以前只注明接口中哪些方法是“可选的”,如果程序员不想实现这些方法就直接抛出 java.lang.UnsupportedOperationException 异常。这种做法问题多多,不要在新代码中使用。
5.3.3 实例方法还是类方法
实例方法是面向对象编程的关键特性之一,但并不是说应该避免使用类方法。很多情况下,完全有理由定义类方法。
记住,在 Java 中,类方法使用关键字
static声明,而且“静态方法”和“类方法”这两个术语指的是同一个概念。
例如,对 Circle 类而言,你可能经常要计算圆的面积。此时只需要半径,而不用创建一个 Circle 对象来表示这个圆。因此,使用类方法更便利:
public static double area(double r) { return PI * r * r; }
一个类完全可以定义多个同名方法,只要参数不同就行。上述 area() 方法是个类方法,因此没有表示 this 的隐式参数,但必须有一个参数用于指定圆的半径——就是这个参数把这个方法和同名实例方法区分开的。
下面再举个例子,说明应该使用实例方法还是类方法。假如我们要定义一个名为 bigger() 的方法,比较两个 Circle 对象,看哪一个半径较大。我们可以把 bigger() 定义为实例方法,如下所示:
// 比较隐式参数“this”表示的圆和显示参数“that”表示的圆// 返回较大的那个圆public Circle bigger(Circle that) {if (this.r > that.r) return this;else return that;}
我们还可以把 bigger() 定义为类方法,如下所示:
// 比较圆a和b,返回半径较大的那个public static Circle bigger(Circle a, Circle b) {if (a.r > b.r) return a;else return b;}
如果有两个 Circle 对象,x 和 y,我们既可以使用实例方法,也可以使用类方法判断哪个圆较大。不过,调用这两个方法的句法有显著区别:
// 实例方法,也可以使用y.bigger(x)Circle biggest = x.bigger(y);Circle biggest = Circle.bigger(x, y); // 静态方法
两个方法都能很好地完成比较操作,而且从面向对象设计的角度来看,没有哪个方法“更正确”。从外观上看,实例方法更像是面向对象,但调用句法有点不对称。遇到这种情况时,使用实例方法还是类方法完全由设计方式而定。在实际情况中,应该有一种方式更自然。
关于 System.out.println()
前面,我们多次遇到 System.out.println() 方法。这个方法的作用是把输出显示在终端窗口或控制台中。我们还没说明为什么这个方法的名称这么长、这么笨拙,也没有说明两个点号的作用。现在,你已经理解了类字段和实例字段,以及类方法和实例方法,那么再理解这个方法就容易了。System 是一个类,这个类有一个公开的类字段 out,这个字段的值是一个类型为 java.io.PrintStream 的对象,而这个对象有一个名为 println() 的实例方法。
我们可以使用静态导入指令 import static java.lang.System.out;,把这个方法的名称稍微变短一点儿,使用 out.println() 引用这个打印方法。不过,既然这是个实例方法,其名称还可以进一步缩短。
5.3.4 合成还是继承
面向对象设计时,继承不是唯一可选择的技术。对象可以包含其他对象的引用,因此,一个大型概念单元可以由多个小型组件组成——这种技术叫合成(composition)。与此有关的一个重要技术是委托(delegation):某个特定类型的对象保存一个引用,指向一个兼容类型的附属对象,而且把所有操作都交给这个附属对象完成。这种技术一般使用接口类型实现,如下面的示例所示,这个示例构建软件公司的雇员架构模型:
public interface Employee {void work();}public class Programmer implements Employee {public void work() { /* 计算机编程 */ }}public class Manager implements Employee {private Employee report;public Manager(Employee staff) {report = staff;}public Employee setReport(Employee staff) {report = staff;}public void work() {report.work();}}
在这个示例中,Manager 类把 work() 操作委托给直接下属完成,Manager 对象没有做任何实际工作。这种模式有些变体,发出委托的类完成一些工作,委托对象只完成部分工作。
另一个有用的相关技术是修饰模式(decorator pattern)。这种模式提供了扩展对象功能的能力,在运行时也能扩展,但设计时要稍微付出一些额外劳动。下面举个例子说明修饰模式。这个例子为快餐店出售的墨西哥卷饼建模,简单起见,只修饰卷饼的一个属性——价格:
// 墨西哥卷饼的基本接口interface Burrito {double getPrice();}// 具体实现——标准尺寸卷饼public class StandardBurrito implements Burrito {private static final double BASE_PRICE = 5.99;public double getPrice() {return BASE_PRICE;}}// 超大尺寸卷饼public class SuperBurrito implements Burrito {private static final double BASE_PRICE = 6.99;public double getPrice() {return BASE_PRICE;}}
这个例子涵盖了在售的墨西哥卷饼——两种不同尺寸、不同价格的卷饼。下面我们来增强这个例子,提供两种可选的配料——墨西哥辣椒和鳄梨酱。设计的关键是使用一个抽象类,让这两个可选的配料扩展:
/** 这个类是Burrito接口的修饰器* 表示墨西哥卷饼可选的配料*/public abstract class BurritoOptionalExtra implements Burrito {private final Burrito burrito;private final double price;// 这个构造方法声明为protected,目的是保护默认构造方法// 以及避免劣质的客户端代码直接实例化这个基类protected BurritoOptionalExtra(Burrito toDecorate,double myPrice) {burrito = toDecorate;price = myPrice;}public final double getPrice() {return (burrito.getPrice() + price);}}
把
BurritoOptionalExtra类声明为abstract,并把构造方法声明为protected,这样只有创建子类的实例才能获得有效的BurritoOptionalExtra对象,因为子类提供了公开的构造方法(这样也能避免客户端代码设定配料的价格)。
下面测试一下上述实现方式:
Burrito lunch = new Jalapeno(new Guacamole(new SuperBurrito()));// 这个墨西哥卷饼的总价应该是$8.09System.out.println("Lunch cost: "+ lunch.getPrice());
修饰模式使用非常广泛,不仅局限于 JDK 中的实用类。第 10 章介绍 Java I/O 时会见到更多使用修饰器的示例。
5.3.5 字段继承和访问器
Java 为设计状态的继承时可能遇到的问题提供了多种解决方案。程序员可以选择用 protected 修饰字段,允许子类直接访问这些字段(也可以设定字段的值)。或者,可以提供访问器方法,直接读取对象的字段(如果需要,也可以设定字段的值),这么做仍能有效封装数据,而且可以把字段声明为 private。
我们再看一下第 3 章末尾举的 PlaneCircle 示例,这里明确展示了字段继承:
public class Circle {// 这是通用的常量,所以要保证声明为publicpublic static final double PI = 3.14159;protected double r; // 通过受保护字段继承的状态// 限制半径取值的方法protected void checkRadius(double radius) {if (radius < 0.0)throw new IllegalArgumentException("radius may not < 0");}// 非默认的构造方法public Circle(double r) {checkRadius(r);this.r = r;}// 公开的数据访问器方法public double getRadius() { return r; }public void setRadius(double r) {checkRadius(r);this.r = r;}// 操作实例字段的方法public double area() { return PI * r * r; }public double circumference() { return 2 * PI * r; }}public class PlaneCircle extends Circle {// 自动继承了Circle类的字段和方法// 因此只要在这里编写新代码// 新实例字段,存储圆心的位置private final double cx, cy;// 新构造方法,用于初始化新字段// 使用特殊的句法调用构造方法Circle()public PlaneCircle(double r, double x, double y) {super(r); // 调用超类的构造方法Circle()this.cx = x; // 初始化实例字段cxthis.cy = y; // 初始化实例字段cy}public double getCentreX() {return cx;}public double getCentreY() {return cy;}// area()和circumference()方法继承自Circle类// 新实例方法,检查点是否在圆内// 注意,这个方法使用了继承的实例字段rpublic boolean isInside(double x, double y) {double dx = x - cx, dy = y - cy;// 勾股定理double distance = Math.sqrt(dx*dx + dy*dy);return (distance < r); // 返回true或false}}
除了上述方式之外,还可以使用访问器方法重写 PlaneCircle 类,如下所示:
public class PlaneCircle extends Circle {// 其他代码和前面一样// 超类Circle的r字段可以声明为private,因为现在不直接访问r字段了// 注意,现在使用访问器方法getRadius()public boolean isInside(double x, double y) {double dx = x - cx, dy = y - cy; // 到圆心的距离double distance = Math.sqrt(dx*dx + dy*dy); // 勾股定理return (distance < getRadius());}}
上述两种方式都是合法的 Java 代码,但有一些区别。3.5 节说过,在类外部可写的字段,一般不是建模对象状态的正确方式。在 5.5 节和 6.5 节会看到,这么做其实会对程序的运行状态造成不可恢复的损坏。
糟糕的是,Java 中的 protected 关键字允许子类和同一个包中的类访问字段(和方法)。加之任何人都能把自己编写的类放入任何指定的包(不含系统包),这就意味着,在 Java 中使用继承的受保护状态有潜在缺陷。
Java 没有提供只能在声明成员的类和子类中访问成员的机制。
鉴于上述原因,在子类中一般最好使用(公开的或受保护的)访问器方法访问状态——除非把继承的状态声明为 final,才完全可以使用继承的受保护状态。
5.3.6 单例
单例模式(singleton pattern)是人们熟知的另一个设计模式,用来解决只需要为类创建一个实例这种设计问题。Java 为单例模式提供了多种实现方式。这里我们要使用一种稍微复杂的方式,但有个好处,它能十分明确地表明为了安全实现单例模式要做些什么:
public class Singleton {private final static Singleton instance = new Singleton();private static boolean initialized = false;// 构造方法private Singleton() {super();}private void init() {/* 做初始化操作 */}// 这个方法是获取实例引用的唯一方式public static synchronized Singleton getInstance() {if (initialized) return instance;instance.init();initialized = true;return instance;}}
为了有效实现单例模式,重点是要确保不能有超过一种创建实例的方式,而且要保证不能获取处于未初始化状态的对象引用(本章稍后会详细说明这一点)。为此,我们需要一个声明为 private 的构造方法,而且只调用一次。在这个 Singleton 类中,我们只在初始化私有静态变量 instance 时才调用构造方法。而且,我们还把创建唯一一个 Singleton 对象的操作和初始化操作分开,把初始化操作放入私有方法 init() 中。
使用这种机制,获取 Singleton 的唯一实例只有一种方式——通过静态辅助方法 getInstance()。getInstance() 方法检查标志 initialized,确认对象是否已经处于激活状态。如果没有激活,getInstance() 方法调用 init() 方法激活对象,然后把 initialized 设为 true,所以下次请求创建 Singleton 的实例时,不会再做进一步初始化操作。
最后还要注意,getInstance() 方法使用 synchronized 修饰。第 6 章会详细说明这么做的意义和原因。现在,你只需知道,加上 synchronized 是为了防止在多线程程序中使用 Singleton 时得到意外的结果。
单例虽然是最简单的模式之一,但经常过度使用。如果使用得当,单例是很有用的技术,但如果一个程序中有太多单例类,往往表明代码设计得不好。
单例模式有一些弊端:难测试,难与其他类分开。而且,在多线程代码中使用时需要小心。虽然如此,单例模式仍然很重要,开发者要熟练掌握,别不小心重新发明轮子。单例模式一般用于管理配置,但是现代的代码经常使用自动为程序员提供单例的框架(一般是依赖注入),而不是自己动手编写 Singleton 类(或类似的类)。
在这两种情况下,添加新方法都可能与子类中名称和签名相同的方法起冲突——此时,子类中的方法优先级更高。鉴于此,添加新方法时一定要谨慎,如果方法名对某个类型而言“显而易见”,或者方法可能有多个意义,尤其要小心。