Java-程序设计教程-全-
Java 程序设计教程(全)
一、模块化软件设计
当一个初学者编写程序时,只有一个目标:程序必须正确运行。然而,正确性只是程序优秀的一部分。另一个同样重要的部分是程序的可维护性。
也许你经历过安装某个软件新版本的挫败感,却发现它的性能降级了,你依赖的某个功能不再工作了。当一个新特性以其他特性没有预料到的方式改变了现有的软件时,就会出现这种情况。
好的软件是有意设计的,这样这些意想不到的交互就不会发生。本章讨论了设计良好的软件的特征,并介绍了几个有助于软件开发的规则。
为改变而设计
软件开发通常遵循迭代方法。您创建一个版本,让用户试用,并接收要在下一个版本中解决的更改请求。这些变更请求可能包括错误修复、对软件工作方式的误解的修正以及功能增强。
有两种常见的开发方法。在瀑布方法中,你首先为程序创建一个设计,反复修改设计直到用户满意。然后你编写整个程序,希望第一个版本能令人满意。很少是这样的。即使你设法完美地实现了设计,用户无疑会发现他们没有意识到他们想要的新特性。
在敏捷方法中,程序设计和实现是一前一后进行的。从实现程序的一个基本版本开始。每个后续版本都实现了少量的附加功能。这个想法是,每个版本包含的代码“刚好够”让所选的功能子集工作。
这两种方法都有自己的好处。但是不管使用哪种方法,一个程序在开发过程中都会经历几个版本。瀑布式开发通常迭代较少,但是每个版本变化的范围是不可预测的。敏捷开发计划以小的、可预测的变化进行频繁的迭代。
底线是程序总是变化的。如果一个程序不能像用户期望的那样工作,那么它就需要被修正。如果一个程序确实像用户期望的那样工作,那么他们会希望它得到增强。因此,重要的是设计您的程序,以便可以很容易地进行所需的更改,对现有代码进行最小的修改。
假设你需要修改程序中的一行代码。您还需要修改受此修改影响的其他代码行,然后是受这些修改影响的代码行,依此类推。随着这种扩散的增加,修改变得更加困难、耗时并且容易出错。因此,你的目标应该是设计一个程序,使得对它的任何部分的改变只会影响整个代码的一小部分。
这个想法可以用下面的设计原则来表达。因为这个原则是本书中几乎所有设计技术背后的驱动力,所以我称之为基本设计原则。
软件设计基本原理
一个程序的设计应该使得对它的任何改变只会影响一小部分可预测的代码。
对于基本设计原则的简单说明,考虑可变范围的概念。变量的范围是程序中可以合法引用该变量的区域。在 Java 中,变量的作用域是由它的声明位置决定的。如果变量是在类之外声明的,那么它可以从该类的任何方法中引用。据说它拥有全球范围。如果变量是在一个方法中声明的,那么它只能在声明它的代码块中被引用,也就是说它有局部作用域。
考虑清单 1-1 中的类ScopeDemo
。有四个变量:x
、z
和两个版本的y
。这些变量有不同的范围。变量x
范围最大;可以从类中的任何地方引用它。方法f
中的变量y
只能从该方法内部访问,对于g
中的变量y
也是如此。变量z
只能从f
的 for 循环中访问。
public class ScopeDemo {
private int x = 1;
public void f() {
int y = 2;
for (int z=3; z<10; z++) {
System.out.println(x+y+z);
}
...
}
public void g() {
int y = 7;
...
}
}
Listing 1-1The ScopeDemo Class
为什么程序员应该关心变量范围?为什么不全局定义所有变量呢?答案来自基本的设计原则。对变量定义或预期用途的任何更改都可能影响其范围内的每一行代码。假设我决定修改ScopeDemo
,让方法f
中的变量y
有一个不同的名字。由于y
的范围,我知道我只需要查看方法f
,尽管在方法g
中也提到了一个名为y
的变量。另一方面,如果我决定重命名变量x
,那么我将被迫查看整个类。
一般来说,变量的范围越小,受变化影响的代码行就越少。因此,基本的设计原则意味着每个变量应该有尽可能小的范围。
面向对象的基础
对象是 Java 程序的基本构件。每个对象都属于一个类,该类根据其公共变量和方法定义了对象的能力。这一节介绍了本章其余部分所需的一些面向对象的概念和术语。
API 和依赖项
一个类的公共变量和方法被称为它的应用程序接口(或 API)。一个类的设计者应该在 API 中记录每一项的含义。Java 有专门用于此目的的 Javadoc 工具。Java 9 类库中有大量的 Javadoc 页面,可以在 URL https://docs.oracle.com/javase/9/docs/api
找到。如果你想学习 Java 库中的一个类是如何工作的,那么这是第一个地方。
假设类X
的代码持有类Y
的对象,并使用它来调用Y
的方法之一。然后X
被称为Y
的客户端。清单 1-2 显示了一个简单的例子,其中StringClient
是String
的客户端。
public class StringClient {
public static void main(String[] args) {
String s = "abc";
System.out.println(s.length());
}
}
Listing 1-2The StringClient Class
一个类的 API 是该类和它的客户之间的契约。StringClient
的代码暗示类String
必须有一个满足其记录行为的方法length
。然而,StringClient
代码不知道也无法控制String
如何计算这个长度。这是一件好事,因为它允许 Java 库改变length
方法的实现,只要该方法继续满足契约。
如果X
是Y
的客户,那么Y
就是X
的依赖。这个想法是,X
依赖于Y
不改变其方法的行为。如果类Y
的 API 改变了,那么X
的代码也需要改变。
模块性
将 API 视为契约简化了大型程序的编写方式。一个大的程序被组织成多个类。每个类都是独立于其他类实现的,假设它调用的每个方法最终都将被实现并完成预期的任务。当所有的类都被编写和调试后,就可以组合起来创建最终的程序了。
这种设计策略有几个好处。每个类都有一个有限的范围,因此更容易编程和调试。此外,这些类可以由多人同时编写,从而使程序更快地完成。
我们说这样的程序是模块化。模块化是必须的;好的程序总是模块化的。然而,模块化是不够的。还有与每个类的设计和类之间的连接相关的重要问题。本章后面的设计规则将解决这些问题。
类图
一个类图描述了程序中每个类的功能以及这些类之间的依赖关系。类图中每个类都有一个矩形。矩形有三个部分:顶部包含类名,中间部分包含变量声明,底部包含方法声明。如果类Y
是类X
的依赖,那么X
的矩形将有一个箭头指向Y
的矩形。箭头可以读作“使用”,如“StringClient 使用字符串”图 1-1 显示了清单 1-2 代码的类图。
图 1-1
列表 1-2 的类图
类图属于被称为 UML 的标准符号系统(代表通用建模语言)。UML 类图可以有比这里描述的更多的特性。每个变量和方法都可以指定它的可见性(例如 public 或 private ),并且变量可以有默认值。此外,UML 的依赖概念更加广泛和微妙。这里给出的依赖定义实际上是一种特殊的 UML 依赖,称为关联。尽管这些额外的建模特性使 UML 类图能够更准确地指定设计,但是它们增加了复杂性,这在本书中是不需要的,将被忽略。
类图在程序开发的不同阶段有不同的用途。在实现阶段,类图记录了每个类的实现中使用的变量和方法。当它尽可能详细地显示每个类的所有公共和私有变量和方法时,它是最有用的。
在设计阶段,类图是一种交流工具。设计人员使用类图来快速传达每个类的功能及其在程序整体架构中的作用。无关的类、变量、方法和箭头可能会被省略,以突出关键的设计决策。通常,只有公共变量和方法放在这些类图中。图 1-1 是一个设计级类图的例子:省略了StringClient
类型的私有变量,就像String
中未被引用的方法一样。鉴于这本书是关于设计的,它专门使用了设计级类图。我们建模的大多数类没有公共变量,这意味着每个类矩形的中间部分通常是空的。
静态与非静态
静态变量是“属于”一个类的变量。它由该类的所有对象共享。如果一个对象改变了一个静态变量的值,那么所有的对象都会看到这个变化。另一方面,一个非静态变量“属于”该类的一个对象。每个对象都有自己的变量实例,其值的赋值独立于其他实例。
例如,考虑清单 1-3 中的类StaticTest
。一个StaticTest
对象有两个变量:静态变量x
和非静态变量y
。每次创建一个新的StaticTest
对象,它都会创建一个新的y
实例,并覆盖之前的x
值。
public class StaticTest {
private static int x;
private int y;
public StaticTest(int val) {
x = val;
y = val;
}
public void print() {
System.out.println(x + " " + y);
}
public static int getX() {
return x;
}
public static void main(String[] args) {
StaticTest s1 = new StaticTest(1);
s1.print(); //prints "1 1"
StaticTest s2 = new StaticTest(2);
s2.print(); //prints "2 2"
s1.print(); //prints "2 1"
}
}
Listing 1-3The StaticTest Class
方法也可以是静态的或非静态的。静态方法(如StaticTest
中的getX
)不与对象相关联。客户端可以通过使用类名作为前缀来调用静态方法。或者,它可以以常规方式调用一个静态方法,以该类的变量为前缀。
例如,下面代码中对getX
的两次调用是等效的。在我看来,第一次调用getX
更好,因为它清楚地向读者表明该方法是静态的。
StaticTest s1 = new StaticTest(1);
int y = StaticTest.getX();
int z = s1.getX();
因为静态方法没有关联的对象,所以不允许引用非静态变量。例如,StaticTest
中的print
方法作为静态方法是没有意义的,因为它没有唯一的变量y
可以引用。
银行演示
清单 1-4 给出了一个管理虚拟银行的简单程序的代码。这个程序将在整本书中作为一个运行的例子。清单 1-4 中的代码由一个名为BankProgram
的类组成,是演示的版本 1。
类BankProgram
保存了一个映射,该映射存储了银行持有的几个账户的余额。映射中的每个元素都是一个键值对。密钥是一个整数,表示账号,其值是该账户的余额,以美分为单位。
public class BankProgram {
private HashMap<Integer,Integer> accounts
= new HashMap<>();
private double rate = 0.01;
private int nextacct = 0;
private int current = -1;
private Scanner scanner;
private boolean done = false;
public static void main(String[] args) {
BankProgram program = new BankProgram();
program.run();
}
public void run() {
scanner = new Scanner(System.in);
while (!done) {
System.out.print("Enter command (0=quit, 1=new,
2=select, 3=deposit, 4=loan,
5=show, 6=interest): ");
int cmd = scanner.nextInt();
processCommand(cmd);
}
scanner.close();
}
private void processCommand(int cmd) {
if (cmd == 0) quit();
else if (cmd == 1) newAccount();
else if (cmd == 2) select();
else if (cmd == 3) deposit();
else if (cmd == 4) authorizeLoan();
else if (cmd == 5) showAll();
else if (cmd == 6) addInterest();
else
System.out.println("illegal command");
}
... //code for the seven command methods appears here
}
Listing 1-4Version 1 of the Banking Demo
程序的run
方法执行一个循环,从控制台重复读取命令并执行它们。共有七个命令,每个命令都有相应的方法。
quit
方法将全局变量done
设置为 true,这将导致循环终止。
private void quit() {
done = true;
System.out.println("Goodbye!");
}
全局变量current
跟踪当前帐户。newAccount
方法分配一个新的帐号,使其成为当前帐号,并将其分配给初始余额为 0 的 map。
private void newAccount() {
current = nextacct++;
accounts.put(current, 0);
System.out.println("Your new account number is "
+ current);
}
select
方法使现有账户成为当前账户。它还打印帐户余额。
private void select() {
System.out.print("Enter account#: ");
current = scanner.nextInt();
int balance = accounts.get(current);
System.out.println("The balance of account " + current
+ " is " + balance);
}
deposit
方法将当前帐户的余额增加指定数量的美分。
private void deposit() {
System.out.print("Enter deposit amount: ");
int amt = scanner.nextInt();
int balance = accounts.get(current);
accounts.put(current, balance+amt);
}
方法authorizeLoan
确定当前账户是否有足够的钱用作贷款的抵押品。标准是账户必须包含至少一半的贷款金额。
private void authorizeLoan() {
System.out.print("Enter loan amount: ");
int loanamt = scanner.nextInt();
int balance = accounts.get(current);
if (balance >= loanamt / 2)
System.out.println("Your loan is approved");
else
System.out.println("Your loan is denied");
}
方法打印每个账户的余额。
private void showAll() {
Set<Integer> accts = accounts.keySet();
System.out.println("The bank has " + accts.size()
+ " accounts.");
for (int i : accts)
System.out.println("\tBank account " + i
+ ": balance=" + accounts.get(i));
}
最后,addInterest
法以固定利率增加每个账户的余额。
private void addInterest() {
Set<Integer> accts = accounts.keySet();
for (int i : accts) {
int balance = accounts.get(i);
int newbalance = (int) (balance * (1 + rate));
accounts.put(i, newbalance);
}
}
单一责任规则
BankProgram
代码正确。但是这有什么好处吗?请注意,该程序有多个责任领域—例如,一个责任是处理 I/O 处理,另一个责任是管理帐户信息—这两个责任都由一个类来处理。
多用途类违反了基本的设计原则。问题是每个责任领域都有不同的改变原因。如果这些职责是由单个类实现的,那么当一个方面发生变化时,整个类都必须修改。另一方面,如果每个职责被分配给不同的类,那么当发生变化时,需要修改的程序部分就更少。
这一观察导致了一个被称为单一责任规则的设计规则。
单一责任规则
一个类应该只有一个目的,它的所有方法都应该与这个目的相关。
满足单一责任规则的程序将被组织成类,每个类都有自己独特的责任。
银行演示的版本 2 就是这种设计的一个例子。它包含三个类:类Bank
负责银行信息;类BankClient
负责 I/O 处理;而BankProgram
这个类负责把所有东西放在一起。该设计的类图如图 1-2 所示。
图 1-2
银行演示的第 2 版
Bank
的代码出现在清单 1-5 中。它包含版本 1 中与银行相关的三个变量,即帐户映射、利率和下一个帐号的值。其 API 中的 6 个方法对应版本 1 的命令方法(除了quit
)。他们的代码由这些方法的代码组成,去掉了输入/输出代码。例如,newAccount
方法的代码向地图中添加了一个新帐户,但没有将其信息打印到控制台。而是将账号返回给BankClient
,由其负责打印信息。
public class Bank {
private HashMap<Integer,Integer> accounts
= new HashMap<>();
private double rate = 0.01;
private int nextacct = 0;
public int newAccount() {
int acctnum = nextacct++;
accounts.put(acctnum, 0);
return acctnum;
}
public int getBalance(int acctnum) {
return accounts.get(acctnum);
}
public void deposit(int acctnum, int amt) {
int balance = accounts.get(acctnum);
accounts.put(acctnum, balance+amt);
}
public boolean authorizeLoan(int acctnum, int loanamt) {
int balance = accounts.get(acctnum);
return balance >= loanamt / 2;
}
public String toString() {
Set<Integer> accts = accounts.keySet();
String result = "The bank has " + accts.size()
+ " accounts.";
for (int i : accts)
result += "\n\tBank account " + i
+ ": balance=" + accounts.get(i);
return result;
}
public void addInterest() {
Set<Integer> accts = accounts.keySet();
for (int i : accts) {
int balance = accounts.get(i);
int newbalance = (int) (balance * (1 + rate));
accounts.put(i, newbalance);
}
}
}
Listing 1-5The Version 2 Bank Class
同样,deposit
方法也不负责向用户询问存款金额。相反,它期望方法的调用者(即BankClient
)将金额作为参数传递。
authorizeLoan
方法从相应的版本 1 方法中消除了输入和输出代码。它期望贷款金额作为参数传入,并以布尔值返回决策。
getBalance
方法对应于版本 1 的select
方法。该方法主要涉及选择一个活期账户,这是BankClient
的责任。其唯一的银行专用代码涉及获取所选账户的余额。因此,Bank
类有一个供select
调用的getBalance
方法。
版本 1 中的showAll
方法打印每个账户的信息。这个方法中特定于银行的部分是将这些信息收集到一个字符串中,这是Bank
的toString
方法的职责。
版本 1 中的addInterest
方法没有任何输入/输出组件。因此,它与Bank
中的相应方法相同。
清单 1-6 中显示了BankClient
的代码。它包含版本 1 中与输入/输出相关的三个全局变量,即当前帐户、扫描器和 am-I-done 标志;它还有一个额外的变量,保存对Bank
对象的引用。BankClient
有公有方法run
和私有方法processCommand
;这些方法与版本 1 中的相同。各个命令方法的代码是相似的;不同之处在于,所有特定于银行的代码都被对适当的方法Bank
的调用所取代。这些陈述在清单中以粗体显示。
public class BankClient {
private int current = -1;
private Scanner scanner = new Scanner(System.in);
private boolean done = false;
private Bank bank = new Bank();
public void run() {
... // unchanged from version 1
}
private void processCommand(int cmd) {
... // unchanged from version 1
}
private void quit() {
... // unchanged from version 1
}
private void newAccount() {
current = bank.newAccount();
System.out.println("Your new account number is "
+ current);
}
private void select() {
System.out.print("Enter acct#: ");
current = scanner.nextInt();
int balance = bank.getBalance(current);
System.out.println("The balance of account "
+ current + " is " + balance);
}
private void deposit() {
System.out.print("Enter deposit amt: ");
int amt = scanner.nextInt();
bank.deposit(current, amt);
}
private void authorizeLoan() {
System.out.print("Enter loan amt: ");
int loanamt = scanner.nextInt();
if (bank.authorizeLoan(current, loanamt))
System.out.println("Your loan is approved");
else
System.out.println("Your loan is denied");
}
private void showAll() {
System.out.println(bank.toString());
}
private void addInterest() {
bank.addInterest();
}
}
Listing 1-6The Version 2 BankClient Class
类BankProgram
包含main
方法,它与版本 1 的main
方法并行。它的代码出现在清单 1-7 中。
public class BankProgram {
public static void main(String[] args) {
BankClient client = new BankClient();
client.run();
}
}
Listing 1-7The Version 2 BankProgram Class
请注意,银行演示的版本 2 比版本 1 更容易修改。现在可以改变Bank
的实现,而不用担心破坏BankClient
的代码。同样,也可以改变BankClient
输入/输出的方式,而不影响Bank
或BankProgram
。
重构
版本 2 演示的一个有趣的特性是它包含了与版本 1 几乎相同的代码。事实上,当我编写版本 2 时,我开始在它的三个类之间重新分配现有的代码。这就是所谓的重构的一个例子。
一般来说,重构一个程序意味着在不改变其工作方式的情况下对其进行语法修改。重构的例子包括:重命名类、方法或变量;将变量的实现从一种数据类型更改为另一种数据类型;把一个班分成两个班。如果您使用 Eclipse IDE,那么您会注意到它有一个重构菜单,可以自动为您执行一些更简单的重构形式。
单元测试
在这一章的前面,我说过模块化程序的优点之一是每个类都可以单独实现和测试。这就引出了一个问题:如何测试一个独立于程序其余部分的类?
答案是给每个类写一个驱动程序。驱动程序调用该类的各种方法,向它们传递样本输入并检查返回值是否正确。这个想法是,驱动程序应该测试所有可能使用这些方法的方式。每种方式都被称为一个用例。
作为一个例子,考虑出现在清单 1-8 中的类BankTest
。这个类调用一些Bank
方法并测试它们是否返回预期值。这段代码只测试了几个用例,远没有它应有的全面,但是重点应该是清楚的。
public class BankTest {
private static Bank bank = new Bank();
private static int acct = bank.newAccount();
public static void main(String[] args) {
verifyBalance("initial amount", 0);
bank.deposit(acct, 10);
verifyBalance("after deposit", 10);
verifyLoan("authorize bad loan", 22, false);
verifyLoan("authorize good loan", 20, true);
}
private static void verifyBalance(String msg,
int expectedVal) {
int bal = bank.getBalance(acct);
boolean ok = (bal == expectedVal);
String result = ok ? "Good! " : "Bad! ";
System.out.println(msg + ": " + result);
}
private static void verifyLoan(String msg,
int loanAmt, boolean expectedVal) {
boolean answer = bank.authorizeLoan(acct, loanAmt);
boolean ok = (answer == expectedVal);
String result = ok ? "Good! " : "Bad! ";
System.out.println(msg + ": " + result);
}
}
Listing 1-8The BankTest Class
测试BankClient
类更加困难,原因有二。第一个是类调用另一个类的方法(即Bank
)。第二个是该类从控制台读取输入。让我们依次解决每个问题。
如何测试一个调用另一个类的方法的类?如果另一个类也在开发中,那么驱动程序将不能使用它。一般来说,一个驱动程序不应该使用另一个类,除非这个类是完全正确的;否则,如果测试失败,您不知道是哪个类导致了问题。
标准的方法是编写一个被引用类的简单实现,称为模拟类。通常,mock 类的方法打印有用的诊断信息并返回默认值。例如,清单 1-9 显示了Bank
的模拟类的一部分。
public class Bank {
public int newAccount() {
System.out.println("newAccount called, returning 10");
return 10;
}
public int getBalance(int acctnum) {
System.out.println("getBalance(" + acctnum
+ ") called, returning 50");
return 50;
}
public void deposit(int acctnum, int amt) {
System.out.println("deposit(" + acctnum + ", "
+ amt + ") called");
}
public boolean authorizeLoan(int acctnum,
int loanamt) {
System.out.println("authorizeLoan(" + acctnum
+ ", " + loanamt
+ ") called, returning true");
return true;
}
...
}
Listing 1-9A Mock Implementation of Bank
测试从控制台接受输入的类的最好方法是将其输入重定向到来自文件。通过将一组完整的输入值放入一个文件,您可以轻松地重新运行驱动程序,并保证每次输入都是相同的。根据您执行程序的方式,您可以用几种方式指定这种重定向。例如,在 Eclipse 中,您可以在程序的运行配置菜单中指定重定向。
类BankProgram
为BankClient
制作了一个非常好的驱动程序。您只需要创建一个输入文件来充分测试各种命令。
班级设计
满足单一责任规则的程序将为每个确定的责任提供一个类。但是你怎么知道你是否已经确定了所有的责任呢?
简单的回答是你不知道。有时候,看似单一的责任可以进一步分解。只有当程序中增加了额外的要求时,对单独的类的需求才变得明显。
例如,考虑银行演示的版本 2。类Bank
将其帐户信息存储在一个映射中,其中映射的键保存帐户号码,其值保存相关的余额。现在假设银行还想为每个账户存储额外的信息。特别是,假设银行想知道每个账户的所有人是外国人还是本国人。节目应该怎么改?
经过一些安静的思考,你会意识到这个程序需要一个明确的银行账户的概念。这个概念可以作为一个类来实现;称之为BankAccount
。然后,银行的映射可以将一个BankAccount
对象与每个账号关联起来。这些变化构成了银行演示的第 3 版。其类图如图 1-3 所示,新方法以粗体显示。
图 1-3
银行演示的第 3 版
清单 1-10 给出了新BankAccount
类的代码。它有三个全局变量,分别保存账号、余额和一个指示该账户是否为外国账户的标志。它有方法检索三个变量的值,并设置变量balance
和isforeign
的值。
public class BankAccount {
private int acctnum;
private int balance = 0;
private boolean isforeign = false;
public BankAccount(int a) {
acctnum = a;
}
public int getAcctNum() {
return acctnum;
}
public int getBalance() {
return balance;
}
public void setBalance(int amt) {
balance = amt;
}
public boolean isForeign() {
return isforeign;
}
public void setForeign(boolean b) {
isforeign = b;
}
}
Listing 1-10The Version 3 BankAccount Class
清单 1-11 给出了Bank
的修改代码。变化用粗体表示。这个类现在拥有一个BankAccount
对象的映射而不是一个整数映射,并且拥有新方法setForeign
的代码。
public class Bank {
private HashMap<Integer,BankAccount> accounts
= new HashMap<>();
private double rate = 0.01;
private int nextacct = 0;
public int newAccount(boolean isforeign) {
int acctnum = nextacct++;
BankAccount ba = new BankAccount(acctnum);
ba.setForeign(isforeign);
accounts.put(acctnum, ba);
return acctnum;
}
public int getBalance(int acctnum) {
BankAccount ba = accounts.get(acctnum);
return ba.getBalance();
}
public void deposit(int acctnum, int amt) {
BankAccount ba = accounts.get(acctnum);
int balance = ba.getBalance();
ba.setBalance(balance+amt);
}
public void setForeign(int acctnum,
boolean isforeign) {
BankAccount ba = accounts.get(acctnum);
ba.setForeign(isforeign);
}
public boolean authorizeLoan(int acctnum, int loanamt) {
BankAccount ba = accounts.get(acctnum);
int balance = ba.getBalance();
return balance >= loanamt / 2;
}
public String toString() {
String result = "The bank has " + accounts.size()
+ " accounts.";
for (BankAccount ba : accounts.values())
result += "\n\tBank account "
+ ba.getAcctNum() + ": balance="
+ ba.getBalance() + ", is "
+ (ba.isForeign() ? "foreign" : "domestic");
return result;
}
public void addInterest() {
for (BankAccount ba : accounts.values()) {
int balance = ba.getBalance();
balance += (int) (balance * rate);
ba.setBalance(balance);
}
}
}
Listing 1-11The Version 3 Bank Class
这些变化的结果是,从一个帐户获取信息变成了一个两步过程:一个方法首先从 map 中检索一个BankAccount
对象;然后,它对该对象调用所需的方法。另一个区别是方法toString
和addInterest
不再从映射键中单独获取每个帐户值。相反,他们使用 map 的values
方法将帐户检索到一个列表中,然后可以对其进行检查。
必须修改BankClient
类来利用Bank
的附加功能。特别是,它现在有一个新的命令(命令 7)允许用户指定帐户是国外的还是国内的,并且它修改了newAccount
方法来询问帐户的所有权状态。相关代码出现在清单 1-12 中。
public class BankClient {
...
public void run() {
while (!done) {
System.out.print("Enter command (0=quit, 1=new,
2=select, 3=deposit, 4=loan,
5=show, 6=interest, 7=setforeign): ");
int cmd = scanner.nextInt();
processCommand(cmd);
}
}
private void processCommand(int cmd) {
if (cmd == 0) quit();
else if (cmd == 1) newAccount();
else if (cmd == 2) select();
else if (cmd == 3) deposit();
else if (cmd == 4) authorizeLoan();
else if (cmd == 5) showAll();
else if (cmd == 6) addInterest();
else if (cmd == 7) setForeign();
else
System.out.println("illegal command");
}
private void newAccount() {
boolean isforeign = requestForeign();
current = bank.newAccount(isforeign);
System.out.println("Your new account number is "
+ current);
}
...
private void setForeign() {
bank.setForeign(current, requestForeign());
}
private boolean requestForeign() {
System.out.print("Enter 1 for foreign,
2 for domestic: ");
int val = scanner.nextInt();
return (val == 1);
}
}
Listing 1-12The Version 3 BankClient Class
对BankClient
的这个相对较小的改变指出了模块化的优势。即使Bank
类改变了它实现方法的方式,它与BankClient
的契约没有改变。唯一的变化来自增加的功能。
包装
让我们更仔细地看看清单 1-10 中BankAccount
的代码。它的方法由访问器和赋值器组成(也称为“getters”和“setters”)。为什么要用方法?为什么不直接使用公共变量,如清单 1-13 所示?有了这个类,客户端可以直接访问BankAccount
的变量,而不必调用它的方法。
public class BankAccount {
public int acctnum;
public int balance = 0;
public boolean isforeign = false;
public BankAccount(int a) {
acctnum = a;
}
}
Listing 1-13An Alternative BankAccount Class
虽然这种替代的BankAccount
级要紧凑得多,但它的设计却远不如人意。这里有三个比公共变量更喜欢方法的理由。
第一个原因是方法能够限制客户端的能力。公共变量相当于访问器和赋值器方法,同时拥有这两种方法通常是不合适的。例如,备选的BankAccount
类的客户将有权更改帐号,这不是一个好主意。
第二个原因是方法比变量提供了更多的灵活性。假设在部署该程序后的某个时刻,银行检测到以下问题:它每月添加到帐户中的利息被计算为一美分的一小部分,但这一小部分最终会从帐户中删除,因为余额存储在一个整数变量中。
银行决定通过将变量balance
改为浮点数而不是整数来纠正这个错误。如果使用了替代的BankAccount
类,那么这个变化就是对 API 的一个改变,这意味着所有引用这个变量的客户端也需要被修改。另一方面,如果使用版本 3 的BankAccount
类,对变量的更改是私有的,银行可以简单地如下更改方法getBalance
的实现:
public int getBalance() {
return (int) balance;
}
注意getBalance
不再返回账户的实际余额。相反,它返回可以从帐户中提取的金额,这与早期的 API 一致。因为BankAccount
的 API 没有改变,所以类的客户不知道实现的改变。
比起公共变量,更喜欢方法的第三个原因是方法可以执行额外的操作。例如,银行可能希望记录帐户余额的每次变化。如果BankAccount
是使用方法实现的,那么它的setBalance
方法可以被修改,以便写入日志文件。如果可以通过公共变量访问余额,则不可能进行日志记录。
使用公共方法而不是公共变量的可取性是被称为封装规则的设计规则的一个例子。
封装规则
一个类的实现细节应该尽可能对它的客户隐藏。
换句话说,客户越不知道一个类的实现,这个类就越容易改变而不影响它的客户。
重新分配责任
版本 3 银行演示的类是模块化和封装的。然而,他们的方法设计有些不尽人意。特别是,BankAccount
方法没有做任何有趣的事情。所有的工作都发生在Bank
。
例如,考虑将钱存入账户的行为。bank
的deposit
方法控制处理。BankAccount
对象管理银行余额的获取和设置,但它是在Bank
对象的严格监督下完成的。
这两个类别之间缺乏平衡暗示着违反了单一责任规则。版本 3 银行演示的目的是让Bank
类管理帐户映射,让BankAccount
类管理每个单独的帐户。然而,这并没有发生——Bank
类也在执行与银行账户相关的活动。考虑一下对BankAccount
对象负责存款意味着什么。它有自己的deposit
方法:
public void deposit(int amt) {
balance += amt;
}
并且Bank
的deposit
方法将被修改,以便它调用BankAccount
的deposit
方法:
public void deposit(int acctnum, int amt) {
BankAccount ba = accounts.get(acctnum);
ba.deposit(amt);
}
在这个版本中,Bank
不再知道如何做存款。相反,它将工作委托给适当的BankAccount
对象。
哪个版本的设计更好?BankAccount
对象是处理存款的更自然的地方,因为它保存帐户余额。与其让Bank
对象告诉BankAccount
对象做什么,不如让BankAccount
对象自己做工作。我们将这种想法表达为下面的设计规则,称为最合格类规则。
最合格的班规
工作应该分配给最知道如何做的班级。
银行演示的版本 4 修改了类Bank
和BankAccount
以满足最符合条件的类规则。在这些类中,只有BankAccount
的 API 需要修改。图 1-4 显示了这个类的修改后的类图(从版本 3 的变化用粗体表示)。
图 1-4
版本 4 银行帐户类
BankAccount
类现在有了对应于Bank
的deposit
、toString
和addInterest
方法的方法。该类还有方法hasEnoughCollateral
,它(我们将会看到)对应于Bank
的authorizeLoan
方法。此外,该类不再需要setBalance
方法。
类别BankAccount
和Bank
的代码需要更改。Bank
的相关修订代码出现在清单 1-14 中,更改以粗体显示。
public class Bank {
...
public void deposit(int acctnum, int amt) {
BankAccount ba = accounts.get(acctnum);
ba.deposit(amt);
}
public boolean authorizeLoan(int acctnum,
int loanamt) {
BankAccount ba = accounts.get(acctnum);
return ba.hasEnoughCollateral(loanamt);
}
public String toString() {
String result = "The bank has " + accounts.size()
+ " accounts.";
for (BankAccount ba : accounts.values())
result += "\n\t" + ba.toString();
return result;
}
public void addInterest() {
for (BankAccount ba : accounts.values())
ba.addInterest();
}
}
Listing 1-14The Version 4 Bank Class
如前所述,银行的deposit
方法不再负责更新帐户余额。相反,该方法调用BankAccount
中相应的方法来执行更新。
银行的toString
方法负责创建所有银行账户的字符串表示。但是,它不再负责格式化每个单独的帐户;而是在需要的时候调用每个账号的toString
方法。银行的addInterest
方法类似。该方法调用每个帐户的addInterest
方法,允许每个帐户更新自己的余额。
银行的authorizeLoan
方法的实现与其他方法略有不同。它调用银行帐户的hasEnoughCollateral
方法,传入贷款金额。这个想法是授权贷款的决策应该在Bank
和BankAccount
类之间共享。银行账户负责将贷款金额与其余额进行比较。然后,银行将这些信息作为决定是否批准贷款的标准之一。在第 4 版代码中,抵押品信息是唯一的标准,但在现实生活中,银行还会使用信用评分、就业历史等标准,所有这些都位于BankAccount
之外。BankAccount
类只负责“有足够的抵押品”标准,因为这是它最有资格评估的。
添加到BankAccount
类中的四个方法出现在清单 1-15 中。
public class BankAccount {
private double rate = 0.01;
...
public void deposit(int amt) {
balance += amt;
}
public boolean hasEnoughCollateral(int amt) {
return balance >= amt / 2;
}
public String toString() {
return "Bank account " + acctnum + ": balance="
+ balance + ", is "
+ (isforeign ? "foreign" : "domestic");
}
public void addInterest() {
balance += (int) (balance * rate);
}
}
Listing 1-15The Version 4 BankAccount Class
依赖注入
最具限定性的类规则也可以应用于如何初始化类的依赖关系的问题。例如,考虑一下BankClient
类,它依赖于Scanner
和Bank
。相关代码(摘自清单 1-6 )如下所示:
public class BankClient {
private Scanner scanner = new Scanner(System.in);
private Bank bank = new Bank();
...
}
当类创建它的Scanner
对象时,它使用System.in
作为源,表明输入应该来自控制台。但是为什么选择System.in
?还有其他选择。这个类可以从一个文件而不是控制台中读取它的输入,也可以从互联网上的某个地方获取它的输入。考虑到其余的BankClient
代码并不关心它的扫描仪连接到什么输入,限制它对System.in
的使用是不必要的,并且降低了类的灵活性。
对于bank
变量也可以进行类似的论证。假设程序被修改,这样它可以访问多个银行。BankClient
代码不关心它访问哪个银行,那么它如何决定使用哪个银行呢?
关键是BankClient
并不特别有资格做这些决定,因此不应该对这些决定负责。相反,一些其他更合格的类应该做出决定,并将结果对象引用传递给BankClient
。这种技术被称为依赖注入。
通常,创建对象的类最有资格初始化它的依赖项。在这种情况下,对象通过其构造器接收依赖值。这种形式的依赖注入被称为构造器注入。清单 1-16 给出了对BankClient
的相关修改。
public class BankClient {
private int current = -1;
private Scanner scanner;
private boolean done = false;
private Bank bank;
public BankClient(Scanner scanner, Bank bank) {
this.scanner = scanner;
this.bank = bank;
}
...
}
Listing 1-16The Version 4 BankClient Class
类Bank
也可以类似的改进。它有一个对其帐户映射的依赖项,它还决定了其变量nextacct
的初始值。相关代码(摘自清单 1-11 )如下所示:
public class Bank {
private HashMap<Integer,BankAccount> accounts
= new HashMap<>();
private int nextacct = 0;
...
}
Bank
对象创建一个空的账户映射,这是不现实的。在真实的程序中,帐户映射将通过读取文件或访问数据库来构建。与BankClient
一样,Bank
代码的其余部分并不关心账户映射来自哪里,因此Bank
并不是最有资格做出这个决定的类。更好的设计是使用依赖注入,通过构造器将映射和初始值nextacct
传递给Bank
。清单 1-17 给出了相关代码。
public class Bank {
private HashMap<Integer,BankAccount> accounts;
private int nextacct;
public Bank(HashMap<Integer,BankAccount> accounts,
int n) {
this.accounts = accounts;
nextacct = n;
}
...
}
Listing 1-17The Version 4 Bank Class
版本 4 的BankProgram
类负责创建Bank
和BankClient
类,因此也负责初始化它们的依赖关系。它的代码出现在清单 1-18 中。
public class BankProgram {
public static void main(String[] args) {
HashMap<Integer,BankAccount> accounts = new HashMap<>();
Bank bank = new Bank(accounts, 0);
Scanner scanner = new Scanner(System.in);
BankClient client = new BankClient(scanner, bank);
client.run();
}
}
Listing 1-18The Version 4 BankProgram Class
比较版本 3 和版本 4 的对象创建时间是很有趣的。在版本 3 中,BankClient
对象首先被创建,然后是它的Scanner
和Bank
对象。然后,Bank
对象创建账户映射。在版本 4 中,对象是以相反的顺序创建的:首先是地图,然后是银行、扫描仪,最后是客户端。这种现象被称为依赖倒置——每个对象在依赖它的对象之前被创建。
注意BankProgram
是如何做出关于程序初始状态的所有决定的。这样的类被称为配置类。配置类使用户能够通过简单地修改该类的代码来重新配置程序的行为。
将所有依赖决定放在一个类中的想法是强大而方便的。事实上,许多大型程序将这一思想推进了一步。它们放置所有配置细节(即,关于输入流的信息、存储的数据文件的名称等。)到一个配置文件中。配置类读取该文件,并使用它来创建适当的对象。
使用配置文件的优点是配置代码永远不需要更改。只有配置文件会发生变化。当程序由可能不知道如何编程的最终用户配置时,这个特性尤其重要。他们修改配置文件,程序执行适当的配置。
调解
版本 4 银行演示中的BankClient
类不知道BankAccount
对象。它只通过Bank
类的方法与账户交互。Bank
类被称为中介。
中介可以增强程序的模块化。如果Bank
类是唯一可以访问BankAccount
对象的类,那么BankAccount
本质上是Bank
的私有类。当版本 3 的BankAccount
类被修改为版本 4 时,这个特性非常重要;它确保了唯一需要修改的其他类是Bank
。这种愿望导致了下面的规则,称为低耦合规则。
低耦合法则
尽量减少类依赖的数量。
这条规则通常不太正式地表述为“不要和陌生人说话”这个想法是,如果一个概念对客户来说很陌生,或者很难理解,那么最好通过中介来访问它。
中介的另一个优点是中介可以跟踪被中介对象的活动。在银行演示中,Bank
当然必须协调BankAccount
对象的创建,否则它的账户映射将变得不准确。Bank
类也可以使用中介来跟踪特定帐户的活动。例如,银行可以通过将其deposit
方法更改为如下所示来跟踪外国账户的存款:
public void deposit(int acctnum, int amt) {
BankAccount ba = accounts.get(acctnum);
if (ba.isForeign())
writeToLog(acctnum, amt, new Date());
ba.deposit(amt);
}
设计权衡
低耦合和单一责任规则经常相互冲突。中介是提供低耦合的常用方法。但是中介类倾向于积累与其目的不相关的方法,这可能违反单一责任规则。
银行业演示提供了这种冲突的一个例子。Bank
类有方法getBalance
、deposit
和setForeign
,尽管这些方法是由BankAccount
负责的。但是Bank
需要这些方法,因为它是BankClient
和BankAccount
之间的中介。
另一种设计可能性是忘记中介,让BankClient
直接访问BankAccount
对象。最终架构的类图如图 1-5 所示。在这个设计中,BankClient
中的变量current
将是一个BankAccount
引用,而不是一个账号。因此,getBalance
、deposit
和setForeign
命令的代码可以直接调用BankAccount
的相应方法。因此,Bank
不需要这些方法,有一个更简单的 API。而且客户端可以把想要的银行账户的引用传递给银行的authorizeLoan
方法,而不是一个账号,提高了效率。
图 1-5
银行不再是调解人
这个新设计会是对版本 4 银行演示的改进吗?没有一个设计明显比另一个好。每一个都涉及不同的权衡:版本 4 具有较低的耦合性,而新的设计具有更简单的 API,更好地满足单一责任规则。出于本书的目的,我选择了第 4 版,因为我觉得对Bank
来说,能够协调对账户的访问是很重要的。
关键是设计规则只是指导方针。在任何重要的项目中,权衡几乎总是必要的。最好的设计可能会违反至少一条规则。设计师的角色是识别给定程序的可能设计,并准确分析它们的权衡。
Java 地图的设计
作为一些设计权衡的真实例子,考虑 Java 库中的Map
类。实现映射的典型方式是将每个键值对存储为一个节点。然后将节点插入哈希表(对于一个HashMap
对象)或搜索树(对于一个TreeMap
对象)。在 Java 中,这些节点的类型是Map.Entry
。
地图的客户端通常不与Map.Entry
对象交互。相反,客户调用Map
方法get
和put
。给定一个键,get
方法定位具有那个键的条目,并返回它的相关值;put
方法定位具有那个键的条目并改变它的值。如果客户机想要检查 map 中的所有条目,那么它可以调用方法keySet
来获取所有的键,然后重复调用get
来查找它们的相关值。清单 1-19 给出了一些示例代码。代码的第一部分将条目["a",1]
和["b",4]
放入映射,然后检索与键"a"
相关联的值。第二部分打印地图中的每个条目。
HashMap<String,Integer> m = new HashMap<>();
m.put("a", 1);
m.put("b", 4);
int x = m.get("a");
Set<String> keys = m.keySet();
for(String s: keys) {
int y = m.get(s);
System.out.println(s + " " + y);
}
Listing 1-19Typical Uses of a HashMap
HashMap
的这个设计对应于图 1-6 的类图。注意,每个HashMap
对象都是其底层Map.Entry
对象的中介。
图 1-6
HashMap 作为 Map 的中介。进入
不幸的是,这种中介会导致低效的代码。清单 1-19 中的循环就是这样一个例子。keySet
方法遍历整个数据结构来获取所有的键。然后,get
方法必须再次重复访问数据结构,以获得每个键的值。
如果客户端代码可以直接访问映射条目,那么代码会更高效。然后,它可以简单地遍历数据结构一次,获取每个条目并打印其内容。其实这样的方法在HashMap
中确实存在,叫做entrySet
。清单 1-20 中的代码相当于清单 1-19 中的代码,但效率更高。
HashMap<String,Integer> m = new HashMap<>();
m.put("a", 1);
m.put("b", 4);
int x = m.get("a");
Set<Map.Entry<String,Integer>> entries = m.entrySet();
for (Map.Entry<String,Integer> e : entries) {
String s = e.getKey();
int y = e.getValue();
System.out.println(s + " " + y);
}
Listing 1-20Accessing Map Entries Directly
方法entrySet
的存在改变了图 1-6 的类图。类HashMap
不再是Map.Entry
的中介,因为Map.Entry
现在对客户端可见。新的类图如图 1-7 所示。
图 1-7
HashMap 不再是 Map 的中介。进入
使Map.Entry
节点对客户端可见增加了使用地图的程序的复杂性。客户需要了解两个类,而不是一个。此外,Map.Entry
的 API 现在无法在不影响HashMap
客户的情况下进行更改。另一方面,复杂性也使得编写更高效的代码成为可能。
设计者不得不考虑这些相互冲突的需求。他们的解决方案是为需要的人保留复杂性,但是如果需要的话,可以忽略复杂的方法。
摘要
软件开发必须以对程序可修改性的关注为指导。基本的设计原则是,程序的设计应该使得对它的任何改变都只影响一小部分可预测的代码。有几条规则可以帮助设计师满足基本原则。
-
单一责任规则规定一个类应该有一个单一的目的,并且它的方法都应该与这个目的相关。
-
封装规则规定,一个类的实现细节应该尽可能对它的客户隐藏。
-
最合格的班级规则规定,工作应该分配给最知道如何做的班级。
-
低耦合规则规定类依赖的数量应该最小化。
这些规则只是指导方针。他们为大多数情况提出合理的设计决策。当你设计你的程序时,你必须总是理解遵循(或不遵循)一个特定规则所涉及的权衡。
二、多态
在一个设计良好的程序中,每个类都代表一个独特的概念,并有自己的一套职责。然而,两个(或更多)类共享一些公共功能是可能的。例如,Java 类HashMap
和TreeMap
是“映射”概念的不同实现,并且都支持方法get
、put
、keySet
等等。程序利用这种共性的能力被称为多态。
本章探索了 Java 中多态的使用。Java 通过接口的概念支持多态。本章中的所有技术都使用接口。事实上,多态非常有用,以至于本书中的大多数技术都以某种方式涉及到了接口。
可以说多态是面向对象编程中最重要的设计概念。对于任何优秀的 Java 程序员来说,对多态(和接口)的深刻理解都是至关重要的。
对多态的需求
假设您被要求修改银行演示的版本 4,以支持两种银行账户:储蓄账户和支票账户。储蓄账户对应于第 4 版的银行账户。支票账户与储蓄账户在以下三个方面不同:
-
批准贷款时,支票账户需要贷款金额的三分之二的余额,而储蓄账户只需要贷款金额的一半。
-
银行定期给储蓄账户利息,但不给支票账户利息。
-
account 的
toString
方法将根据情况返回“储蓄账户”或“支票账户”。
实现这两种类型的帐户的一个简单(有点天真)的方法是修改BankAccount
的代码。例如,BankAccount
可以有一个保存账户类型的变量:值 1 表示储蓄账户,值 2 表示支票账户。方法hasEnoughCollateral
、toString
和addInterest
将使用 if 语句来确定处理哪种账户类型。清单 2-1 展示了基本思想,相关代码以粗体显示。
public class BankAccount {
...
private int type;
public BankAccount(int acctnum, int type) {
this.acctnum = acctnum;
this.type = type;
}
...
public boolean hasEnoughCollateral(int loanamt) {
if (type == 1)
return balance >= loanamt / 2;
else
return balance >= 2 * loanamt / 3;
}
public String toString() {
String typename = (type == 1) ?
"Savings" : "Checking";
return typename + " Account " + acctnum
+ ": balance=" + balance + ", is "
+ (isforeign ? "foreign" : "domestic");
}
public void addInterest() {
if (type == 1)
balance += (int)(balance * rate);
}
}
Listing 2-1Using a Variable to Hold the Type of an Account
虽然这段代码是对BankAccount
的简单修改,但是它有两个明显的问题。首先,if 语句是低效的。每次调用修改后的方法时,它都必须检查 if 语句中的条件,以确定要执行什么代码。此外,增加帐户类型的数量会导致这些方法的执行速度越来越慢。
其次(也是更重要的),代码很难修改,因此违反了基本的设计原则。每次添加另一个帐户类型时,必须为每个 if 语句添加另一个条件。这是乏味的,耗时的,并且容易出错。如果您忘记正确地更新其中一个方法,那么产生的 bug 可能很难被发现。
避免这种 if 语句问题的方法是为每种类型的帐户使用单独的类。将这些类称为SavingsAccount
和CheckingAccount
。优点是每个类都有自己的方法实现,所以不需要 if 语句。此外,每当您需要添加另一种类型的银行帐户时,您可以简单地创建一个新类。
但是Bank
类如何处理多个账户类呢?您不希望银行为每种类型的帐户持有单独的映射,因为这只会引入其他可修改性问题。例如,假设有几个给出利息的帐户类型,每个帐户类型都有自己的映射。addInterest
方法的代码将需要单独遍历每个映射,这意味着每个新的帐户类型都需要您向该方法添加一个新的循环。
唯一好的解决方案是所有的帐户对象,不管它们的类别,都在一个映射中。这样的地图被称为多态。Java 使用接口来实现多态。这个想法是银行演示的第 5 版用一个BankAccount
接口替换BankAccount
类。也就是说,帐户映射仍将这样定义:
private HashMap<Integer,BankAccount> accounts;
然而,BankAccount
现在是一个接口,它的对象可以来自SavingsAccount
或者CheckingAccount
。下一节将解释如何用 Java 实现这样的多态映射。
接口
Java 接口主要是一组命名的方法头。(接口还有其他特性,将在本章后面讨论。)接口类似于类的 API。区别在于,类的 API 是从它的公共方法中推断出来的,而接口是显式地指定 API,而不提供任何代码。
清单 2-2 显示了版本 5 BankAccount
接口的代码。它包含了版本 4 BankAccount
类的每个公共方法的方法头,除了addInterest
。
public interface BankAccount {
public abstract int getAcctNum();
public abstract int getBalance();
public abstract boolean isForeign();
public abstract void setForeign(boolean isforeign);
public abstract void deposit(int amt);
public abstract boolean hasEnoughCollateral(int loanamt);
public abstract String toString();
}
Listing 2-2The Version 5 BankAccount Interface
关键字abstract
表示方法声明只包含方法头,并告诉编译器它的代码将在别处指定。abstract
和public
关键字在接口声明中是可选的,因为默认情况下接口方法是公共的和抽象的。在本书的其余部分,我将遵循通用约定,省略接口方法头中的public abstract
关键字。
接口方法的代码由实现接口的类提供。假设I
是一个接口。一个类通过将子句implements I
添加到其头部来表明其实现I
的意图。如果一个类实现了一个接口,那么它有义务实现该接口声明的所有方法。如果类不包含这些方法,编译器将生成错误。
在银行演示的版本 5 中,类CheckingAccount
和SavingsAccount
都实现了BankAccount
接口。它们的代码出现在清单 2-3 和 2-4 中。代码与清单 1-15 的版本 4 BankAccount
类几乎相同,因此省略了几个未修改的方法。修改用粗体表示。
public class CheckingAccount implements BankAccount {
// the rate variable is omitted
private int acctnum;
private int balance = 0;
private boolean isforeign = false;
public CheckingAccount(int acctnum) {
this.acctnum = acctnum;
}
...
public boolean hasEnoughCollateral(int loanamt) {
return balance >= 2 * loanamt / 3;
}
public String toString() {
return "Checking account " + acctnum + ": balance="
+ balance + ", is "
+ (isforeign ? "foreign" : "domestic");
}
// the addInterest method is omitted
}
Listing 2-4The Version 5 CheckingAccount Class
public class SavingsAccount implements BankAccount {
private double rate = 0.01;
private int acctnum;
private int balance = 0;
private boolean isforeign = false;
public SavingsAccount(int acctnum) {
this.acctnum = acctnum;
}
...
public boolean hasEnoughCollateral(int loanamt) {
return balance >= loanamt / 2;
}
public String toString() {
return "Savings account " + acctnum
+ ": balance=" + balance
+ ", is " + (isforeign ? "foreign" : "domestic");
}
public void addInterest() {
balance += (int) (balance * rate);
}
}
Listing 2-3The Version 5 SavingsAccount Class
通常,一个类可以实现任意数量的接口。它唯一的义务是为它实现的每个接口的每个方法编写代码。除了实现的接口所要求的方法之外,一个类还可以自由地拥有其他方法。例如,SavingsAccount
有一个公共方法addInterest
,它不是它的BankAccount
接口的一部分。
接口在类图中用矩形表示,类似于类。接口的名称放在矩形中。为了区分接口和类,注释“<
当一个类实现一个接口时,该类和它的接口之间的关系由一个有开口的箭头和一条虚线表示。类的矩形不需要提到接口的方法,因为它们的存在是隐含的。版本 5 代码的类图如图 2-1 所示。这个图断言CheckingAccount
和SavingsAccount
实现了BankAccount
接口的所有方法,并且SavingsAccount
也实现了方法addInterest
。Bank
依赖于BankAccount
、SavingsAccount
和CheckingAccount
,因为它的代码使用所有三种类型的变量,这将在下一节中看到。
图 2-1
银行演示的第 5 版
参考类型
本节研究接口如何影响 Java 程序中变量的类型。每个 Java 变量都有一个声明的类型,这个类型决定了变量可以保存的值的种类。如果一个变量包含一个基本值(比如 int 或 float ),那么它的类型就是一个原始类型。如果变量持有一个对象引用,那么它的类型就是一个引用类型。
每个类和每个接口定义一个引用类型。如果一个变量是类类型的,那么它可以保存对该类的任何对象的引用。如果一个变量是接口类型的,那么它可以保存对任何对象的引用,这些对象的类实现了那个接口。例如,考虑以下两条语句:
SavingsAccount sa = new SavingsAccount(1);
BankAccount ba = new SavingsAccount(2);
第一条语句在类类型变量sa
中存储了一个SavingsAccount
引用。这个语句是合法的,因为对象引用的类与变量的类型相同。第二条语句在接口类型变量ba
中存储了一个SavingsAccount
引用。这个语句也是合法的,因为对象引用的类实现了变量的类型。
变量的类型决定了程序可以对它调用哪些方法。类类型的变量只能调用该类的公共方法。接口类型的变量只能调用该接口定义的方法。继续前面的示例,考虑这四个语句:
sa.deposit(100);
sa.addInterest();
ba.deposit(100);
ba.addInterest(); // Illegal!
前两个语句是合法的,因为SavingsAccount
有公共方法deposit
和addInterest
。同样,第三个语句是合法的,因为deposit
是在BankAccount
中声明的。最后一个语句不合法,因为addInterest
不是BankAccount
接口的一部分。
这个例子指出,在接口类型变量中存储对象引用会“削弱”它。变量sa
和ba
都有相似的SavingsAccount
引用。然而,sa
可以调用addInterest
,而ba
则不能。
那么拥有接口类型的变量有什么意义呢?接口类型变量的主要优点是它可以保存对不同类中对象的引用。例如,考虑以下代码:
BankAccount ba = new SavingsAccount(1);
ba = new CheckingAccount(2);
在第一条语句中,变量ba
保存一个SavingsAccount
引用。在第二个语句中,它包含一个CheckingAccount
引用。这两个语句都是合法的,因为两个类都实现了BankAccount
。当一个变量可以保存多个元素时,这个特性特别有用。例如,考虑以下语句。
BankAccount[] accts = new BankAccount[2];
accts[0] = new SavingsAccount(1);
accts[1] = new CheckingAccount(2);
变量accts
是一个数组,其元素的类型为BankAccount
。它是多态的,因为它可以存储来自SavingsAccount
和CheckingAccount
的对象引用。例如,下面的循环将 100 存入accts
数组的每个账户,而不管它是什么类型。
for (int i=0; i<accts.length; i++)
accts[i].deposit(100);
现在可以检查版本 5 Bank
类的代码了。代码出现在清单 2-5 中,对版本 4 的修改以粗体显示。
public class Bank {
private HashMap<Integer,BankAccount> accounts;
private int nextacct;
public Bank(HashMap<Integer,BankAccount> accounts) {
this.accounts = accounts;
nextacct = n;
}
public int newAccount(int type, boolean isforeign) {
int acctnum = nextacct++;
BankAccount ba;
if (type == 1)
ba = new SavingsAccount(acctnum);
else
ba = new CheckingAccount(acctnum);
ba.setForeign(isforeign);
accounts.put(acctnum, ba);
return acctnum;
}
public int getBalance(int acctnum) {
BankAccount ba = accounts.get(acctnum);
return ba.getBalance();
}
...
public void addInterest() {
for (BankAccount ba : accounts.values())
if (ba instanceof SavingsAccount) {
SavingsAccount sa = (SavingsAccount) ba;
sa.addInterest();
}
}
}
Listing 2-5The Version 5 Bank Class
考虑方法newAccount
。现在它有了一个额外的参数,这是一个表示帐户类型的整数。数值 1 表示储蓄账户,数值 2 表示支票账户。该方法创建一个指定类的对象,并将对它的引用存储在变量ba
中。因为这个变量的类型是BankAccount
,它可以保存一个对SavingsAccount
或CheckingAccount
对象的引用。因此,储蓄和支票账户都可以存储在accounts
图中。
现在考虑方法getBalance
。因为它的变量ba
是接口类型的,所以该方法不知道它从映射中获得的账户是储蓄账户还是支票账户。但它不需要知道。该方法简单地调用ba.getBalance
,它将执行ba
引用的任何对象的代码。省略的方法同样是多态的。
方法addInterest
比其他方法更复杂。理解这个方法需要了解类型安全,这将在下面讨论。
类型安全
编译器负责确保每个变量持有正确类型的值。我们说编译器保证程序是类型安全的。如果编译器不能保证一个值具有正确的类型,那么它将拒绝编译该语句。例如,考虑清单 2-6 的代码。
SavingsAccount sa1 = new SavingsAccount(1);
BankAccount ba1 = new CheckingAccount(2);
BankAccount ba2 = sa1;
BankAccount ba3 = new Bank(...); // Unsafe
SavingsAccount sa2 = ba2; // Unsafe
Listing 2-6Testing Type Safety
第一条语句将对一个SavingsAccount
对象的引用存储在一个SavingsAccount
变量中。这显然是类型安全的。第二条语句将对一个CheckingAccount
对象的引用存储在一个BankAccount
变量中。这是类型安全的,因为CheckingAccount
实现了BankAccount
。第三条语句将由sa1
保存的引用存储到一个BankAccount
变量中。由于sa1
具有类型SavingsAccount
,编译器可以推断出它的引用一定是指向一个SavingsAccount
对象,因此可以安全地存储在ba2
中(因为SavingsAccount
实现了BankAccount
)。
第四条语句显然不是类型安全的,因为Bank
没有实现BankAccount
。第五条语句中的变量ba2
具有类型BankAccount
,因此编译器推断其对象引用可能来自SavingsAccount
或CheckingAccount
。由于CheckingAccount
引用不能存储在SavingsAccount
变量中,所以该语句不是类型安全的。事实上,ba2
实际上持有对SavingsAccount
对象的引用是不相关的。
铅字铸造
编译器在决策时非常保守。如果有任何机会,一个变量可以持有一个错误类型的值,那么它将产生一个编译器错误。例如,考虑以下代码:
BankAccount ba = new SavingsAccount(1);
SavingsAccount sa = ba;
应该清楚的是,第二条语句是类型安全的,因为这两条语句合在一起意味着变量sa
将保存一个SavingsAccount
引用。但是,编译器在编译第二条语句时不会查看第一条语句。它只知道变量ba
的类型是BankAccount
,因此可以保存一个CheckingAccount
值。因此,它会生成一个编译器错误。
在这种情况下,您可以使用类型转换来否决编译器。例如,前面的代码可以重写如下:
BankAccount ba = new SavingsAccount(1);
SavingsAccount sa = (SavingsAccount) ba;
类型转换向编译器保证代码确实是类型安全的,并且您对任何不正确的行为承担全部责任。然后,编译器遵从您的请求,编译该语句。如果你错了,那么程序将在运行时抛出一个ClassCastException
。
现在可以考虑清单 2-5 中的addInterest
方法。该方法遍历所有账户,但只对储蓄账户增加利息。因为变量accounts
的元素属于BankAccount
类型,而BankAccount
没有addInterest
方法,所以需要一些巧妙的方法来确保类型安全。
该方法调用 Java instanceof
操作符。如果左侧的对象引用可以转换为右侧的类型,则该运算符返回 true。通过在每个BankAccount
对象上调用instanceof
,该方法确定哪些对象属于类型SavingsAccount
。然后它使用一个类型转换来创建一个类型为SavingsAccount
的对象引用,然后这个对象引用可以调用addInterest
方法。
使用instanceof
和类型转换都是必要的。假设我省略了对instanceof
的调用,将方法写成这样:
public void addInterest() {
for (BankAccount ba : accounts.values()) {
SavingsAccount sa = (SavingsAccount) ba;
sa.addInterest();
}
}
这段代码可以正确编译,如果 map 只包含储蓄账户,那么这段代码就可以正确运行。然而,如果ba
引用了一个CheckingAccount
对象,那么类型转换将在运行时抛出一个ClassCastException
。
现在假设我省略了类型转换,将方法写成这样:
public void addInterest() {
for (BankAccount ba : accounts.values())
if (ba instanceof SavingsAccount)
ba.addInterest();
}
这段代码不会被编译,因为变量ba
的类型是BankAccount
,因此不允许调用addInterest
方法。编译器认为这个方法调用是不安全的,即使它只会在ba
引用SavingsAccount
对象时被调用。
透明度
将对instanceof
的调用与类型转换相结合的技术给出了正确的结果,但是它违反了基本的设计原则。问题是代码特别提到了类名。如果银行添加了另一种也提供利息的账户类型(比如“货币市场账户”),那么您需要修改addInterest
方法来处理它。
if 语句问题又出现了。每次创建一个新的账户时,你都需要检查程序的每个相关部分,以决定这个新的类是否需要添加到 if 语句中。对于大型程序来说,这是一项艰巨的任务,可能会产生许多 bug。
消除这些问题的方法是在BankAccount
接口中添加addInterest
方法。然后Bank
的addInterest
方法可以调用addInterest
的每个账户,而不关心它们属于哪个类。这样的设计被称为透明的,因为对象引用的类对于客户端是不可见的(即透明的)。我们用下面的规则来表达这些想法,叫做透明度规则:
透明度规则
客户端应该能够使用一个接口,而不需要知道实现该接口的类。
第 6 版银行演示修改了第 5 版,因此BankAccount
是透明的。这种透明性要求改变BankAccount
、CheckingAccount
和Bank
的代码。BankAccount
接口需要一个额外的addInterest
方法头:
public interface BankAccount {
...
void addInterest();
}
CheckingAccount
必须实现附加方法addInterest
。这样做证明是非常容易的。addInterest
方法不需要做任何事情:
public class CheckingAccount implements BankAccount {
...
public void addInterest() {
// do nothing
}
}
并且Bank
有一个新的透明的addInterest
实现:
public class Bank {
...
public void addInterest() {
for (BankAccount ba : accounts.values()) {
ba.addInterest();
}
}
透明性的一个重要副作用是它可以减少类之间的耦合。特别要注意的是,addInterest
方法不再导致对SavingsAccount
的依赖。newAccount
方法现在是Bank
中唯一提到SavingsAccount
和CheckingAccount
的地方。消除这些依赖性是一个有价值的目标,但是涉及到删除对构造器的调用的能力。这样做的技巧将在第五章中介绍。
开闭规则
透明接口的优点是添加新的实现类只需要对现有代码做很少的修改。例如,假设银行决定引入一个新的货币市场账户。考虑您必须如何更改版本 6 银行演示:
-
你可以编写一个新的类
MoneyMarketAccount
,它实现了BankAccount
接口。 -
您可以修改
BankClient
的newAccount
方法,向用户显示不同的消息,表明MoneyMarketAccount
的账户类型。 -
您可以修改
Bank
中的newAccount
方法来创建新的MoneyMarketAccount
对象。
这些变化分为两类:修改,现有类发生变化;和扩展,在其中编写新的类。一般来说,修改往往是错误的来源,而扩展导致相对无错误的“即插即用”情况。这种认识导致了以下规则,称为打开/关闭规则:
开/关规则
在可能的范围内,程序应该对扩展开放,但对修改关闭。
开/闭规则是一种理想。对一个程序的大部分修改都会涉及到某种形式的修改;目标是尽可能地限制这种修改。例如,在前面列出的三个任务中,第一个任务需要的工作量最大,但是可以使用扩展来实现。剩下的两个任务需要相对较小的修改。第五章的技术将使进一步减少这两项任务所需的修改成为可能。
可比接口
假设银行要求您修改银行演示,以便可以根据余额比较银行帐户。也就是说,如果ba1
比ba2
有钱,它就想要ba1
> ba2
。
Java 库有一个专门用于这个目的的接口,叫做Comparable<T>
。下面是 Java 库声明接口的方式:
public interface Comparable<T> {
int compareTo(T t);
}
如果x>y
,调用x.compareTo(y)
返回一个大于0
的数,如果x<y
,返回一个小于0
的值,如果x=y
,返回0
。Java 库中的许多类都是可比的。
一个这样的类是String
,它实现了Comparable<String>
。它的compareTo
方法按照字典顺序比较两个字符串。举个简单的例子,考虑下面的代码。执行后,变量result
将有一个负值,因为"abc"
在字典上比"x"
小。
String s1 = "abc";
String s2 = "x";
int result = s1.compareTo(s2);
银行演示的版本 6 修改了类SavingsAccount
和CheckingAccount
来实现Comparable<BankAccount>
。每个类现在都有一个compareTo
方法,它的头文件声明它实现了Comparable<BankAccount>
。清单 2-7 给出了SavingsAccount
的相关代码。CheckingAccount
的代码类似。
public class SavingsAccount implements BankAccount,
Comparable<BankAccount> {
...
public int compareTo(BankAccount ba) {
int bal1 = getBalance();
int bal2 = ba.getBalance();
if (bal1 == bal2)
return getAcctNum() - ba.getAcctNum();
else
return bal1 - bal2;
}
}
Listing 2-7The Version 6 SavingsAccount Class
如果bal1>bal2
,compareTo
方法需要返回一个正数,如果bal2>bal1
需要返回一个负数。减去两个余额就有了预期的效果。如果两个余额相等,则该方法使用它们的账号来任意打破平局。因此,只有在对应于同一帐户的对象之间进行比较时,该方法才会返回 0。这是任何compareTo
方法的预期行为。
清单 2-8 给出了演示程序CompareSavingsAccounts
的代码,展示了可比较对象的使用。程序首先调用方法initAccts
,该方法创建一些SavingsAccount
对象,将钱存入其中,并保存在一个列表中。然后,程序演示了两种方法来计算具有最大余额的帐户。
public class CompareSavingsAccounts {
public static void main(String[] args) {
ArrayList<SavingsAccount> accts = initAccts();
SavingsAccount maxacct1 = findMax(accts);
SavingsAccount maxacct2 = Collections.max(accts);
System.out.println("Acct with largest balance is "
+ maxacct1);
System.out.println("Acct with largest balance is "
+ maxacct2);
}
private static ArrayList<SavingsAccount> initAccts() {
ArrayList<SavingsAccount> accts =
new ArrayList<>();
accts.add(new SavingsAccount(0));
accts.get(0).deposit(100);
accts.add(new SavingsAccount(1));
accts.get(1).deposit(200);
accts.add(new SavingsAccount(2));
accts.get(2).deposit(50);
return accts;
}
private static SavingsAccount
findMax(ArrayList<SavingsAccount> a) {
SavingsAccount max = a.get(0);
for (int i=1; i<a.size(); i++) {
if (a.get(i).compareTo(max) > 0)
max = a.get(i);
}
return max;
}
}
Listing 2-8The CompareSavingsAccounts Class
查找最大账户的第一种方法是调用本地方法findMax
,该方法执行列表的线性搜索。它将当前最大值初始化为第一个元素。对compareTo
的调用将每个剩余元素与当前最大值进行比较;如果该元素更大,则它成为新的电流最大值。
寻找最大账户的第二种方法是使用 Java 库方法Collections.max
。该方法隐式地为列表中的每个元素调用compareTo
。
这个例子的要点是,程序能够找到具有最大余额的账户,而不需要明确提到账户余额。所有对余额的引用都出现在compareTo
方法中。
子类型
尽管版本 6 代码声明SavingsAccount
和CheckingAccount
是可比较的,但这并不等同于要求所有银行账户都是可比较的。这是一个严重的问题。例如,考虑以下语句。编译器将拒绝编译第三条语句,因为BankAccount
变量不需要有compareTo
方法。
BankAccount ba1 = new SavingsAccount(1);
BankAccount ba2 = new SavingsAccount(2);
int a = ba1.compareTo(ba2); // unsafe!
这个问题也出现在清单 2-9 中的类CompareBankAccounts
中。该类是对CompareSavingsAccounts
的重写版本,其中帐户列表的类型是BankAccount
而不是SavingsAccount
。与CompareSavingsAccounts
的区别用粗体表示。尽管变化相对较小,但这段代码将不再编译,因为编译器无法保证每个BankAccount
对象都实现了compareTo
方法。
public class CompareBankAccounts {
public static void main(String[] args) {
ArrayList<BankAccount> accts = initAccts();
BankAccount maxacct1 = findMax(accts);
BankAccount maxacct2 = Collections.max(accts);
...
}
private static BankAccount
findMax(ArrayList<BankAccount> a) {
BankAccount max = a.get(0);
for (int i=1; i<a.size(); i++) {
if (a.get(i).compareTo(max) > 0)
max = a.get(i);
}
return max;
}
Listing 2-9The CompareBankAccounts Class
两个例子的解决方案都是断言所有实现了BankAccount
的类也实现了Comparable<BankAccount>
。从形式上讲,我们说BankAccount
需要是Comparable<BankAccount>
的子类型。在 Java 中,通过使用关键字extends
来指定子类型。清单 2-10 显示了修改后的BankAccount
接口。
public interface BankAccount extends Comparable<BankAccount> {
...
}
Listing 2-10The Version 6 BankAccount Interface
关键字extends
表明如果一个类实现了BankAccount
,那么它也必须实现Comparable<BankAccount>
。因此,类SavingsAccount
和CheckingAccount
不再需要在它们的头文件中显式实现Comparable<BankAccount>
,因为它们现在从BankAccount
隐式实现接口。有了这个改变,CompareBankAccounts
可以正确地编译和执行。
在类图中,子类型关系由带实线的空心箭头表示。例如,版本 6 银行演示的类图如图 2-2 所示。
图 2-2
银行演示的第 6 版
Java 集合库
银行演示有一个子类型关系的例子。一般来说,一个程序可能有几个通过子类型关系连接的接口。子类型化的一个很好的例子可以在 Java 库的集合接口中找到。这些接口有助于管理表示一组元素的对象。图 2-3 描述了它们的类图和一些方法,以及实现它们的四个常用类。这些接口不仅值得深入了解,它们还阐明了一些重要的设计原则。
图 2-3
Java 集合接口
这些接口指定了一组元素可能具有的不同功能。
-
一个
Iterable
对象有一个方法iterator
,它使客户端能够遍历组中的元素。第六章详细讨论了迭代。 -
一个
Collection
对象是可迭代的,但是它也有添加、移除和搜索其元素的方法。 -
一个
List
对象是一个集合,它的元素具有线性顺序,类似于一个数组。它有在指定位置添加、移除和修改元素的方法。 -
一个
Queue
对象是一个集合,它的元素也有一个线性顺序。然而,它的方法只允许客户端在后面添加元素,并在前面移除和检查元素。 -
Set
对象是一个不能有重复元素的集合。它与Collection
有相同的方法,但是add
方法负责检查重复项。 -
一个
SortedSet
对象是一个集合,它的元素是有序排列的。它有根据这个顺序查找第一个和最后一个元素的方法,以及创建早于给定元素或在给定元素之后的元素的子集的方法。
Java 库还包含几个实现这些接口的类。图 2-3 显示了以下四类。
数组列表
类ArrayList
实现了List
,因此还有Collection
和Iterable
。它的代码使用一个底层数组来存储列表元素,该数组随着列表的扩展而调整大小。这个类有方法trimToSize
和ensureCapacity
,允许客户端手动调整底层数组的大小。
链接列表
像ArrayList
一样,LinkedList
类实现了List
(以及Collection
和Iterable
)。它使用底层节点链来存储列表元素。与ArrayList
不同的是,它还实现了Queue
。原因是它的链式实现允许从列表的前面快速移除,这对于Queue
的高效实现很重要。
哈希集
类HashSet
实现Set
(以及Collection
和Iterable
)。它使用哈希表来避免插入重复的元素。
TreeSet(树集)
类TreeSet
实现SortedSet
(以及Set
、Collection
和Iterable
)。它使用一个搜索树按排序顺序存储元素。
利斯科夫替代原理
图 2-3 的类型层次结构看起来很自然,甚至很明显。然而,重要的努力进入了等级的制作。Java Collections API Design FAQ 中对一些更微妙的设计问题进行了有趣的讨论,可以在 URL https://docs.oracle.com/javase/8/docs/technotes/guides/collections/designfaq.html
找到。
一般来说,如何着手设计类型层次结构呢?指导原则被称为利斯科夫替代原则(通常缩写为 LSP)。这条规则是以芭芭拉·利斯科夫的名字命名的,她首次提出了这条规则。
利斯科夫替代原理
如果类型 X 扩展了类型 Y,那么类型 X 的对象总是可以用在任何需要类型 Y 的对象的地方。
例如,考虑List
扩展Collection
的事实。LSP 意味着List
对象可以代替Collection
对象。换句话说,如果有人问你要一个集合,那么你可以合理地给他们一个列表,因为这个列表有它作为一个集合所需要的所有方法。相反,LSP 暗示Collection
不应该延长List
。如果有人向你要一个列表,你不能给他们一个集合,因为集合不一定是连续的,也不支持相应的列表方法。
理解 LSP 的另一种方法是检查接口和它扩展的接口之间的关系。例如,考虑我对集合接口的最初描述。我说过“集合是一个集合,它...," "有序集合是这样的集合,它...,“等等。
换句话说,每个接口“是”它所扩展的类型,并增加了功能。这样的关系叫做一个IS-一个关系,我们说“Set IS-一个集合”、“sorted Set IS-一个集合”等等。一个好的经验法则是,如果你能从 IS-A 关系的角度理解一个类型层次,那么它就满足 LSP。
测试您对 LSP 的理解的一个有用方法是尝试回答以下问题:
-
SortedSet
是否应该延长List
? -
为什么没有界面
SortedList
? -
Queue
是否应该延长List
?List
该不该延长Queue
? -
如果接口
Set
不提供任何附加功能,为什么还要有它呢?
以下是答案。
SortedSet 应该扩展列表吗?
乍一看,有序集合似乎与列表非常相似。毕竟,它的排序顺序决定了一个顺序性:给定一个值 n,有一个定义明确的第 n 个元素。list 的get
方法对于通过当前槽访问任何元素都很有用。
问题是一个有序集合的修改方法会有不良的副作用。例如,如果您使用set
方法来更改第n
个元素的值,那么该元素可能会改变其在排序顺序中的位置(可能连同其他几个元素一起)。使用add
方法将一个新元素插入特定的槽是没有意义的,因为每个新元素的槽是由其排序顺序决定的。
因此,有序集合不能做列表能做的一切,这意味着在不违反 LSP 的情况下,SortedSet
不能扩展List
。
为什么没有接口 SortedList?
这样的接口看似合理。唯一的问题是它在层级中的位置。如果SortedList
扩展了List
,那么你会遇到和SortedSet
一样的问题——也就是说,SortedList
没有好的方法来实现List
的set
和add
方法。最好的选择是让SortedList
扩展Collection
,并根据元素的位置提供额外的类似列表的方法来访问元素。这将满足 LSP。
那么为什么 Java 库没有接口SortedList
?最有可能的是,库的设计者认为这样的接口并不那么有用,省略它会导致更精简的层次结构。
队列应该扩展列表吗?列表应该扩展队列吗?
Queue
不能扩展List
,因为列表可以直接访问它的所有元素并在任何地方插入,而队列只能访问前面的元素并在后面插入。
一个更有趣的问题是List
是否应该扩展Queue
。List
方法可以做Queue
方法可以做的一切,甚至更多。因此,人们可以认为列表比队列更普遍;也就是说,List
是——一个Queue
。声明List
实现Queue
不会违反 LSP。
另一方面,设计者认为列表和队列之间的函数关系有点巧合,在实践中不太有用。概念上,列表和队列是不同的猛兽;没有人认为列表是一个功能更强的队列。因此,在 Java 中没有这样的 IS-A 关系,也没有这样的子类型声明。
如果接口不提供任何附加功能,为什么还要设置它呢?
这个问题和List
是相关的——一个Queue
问题。从概念上讲,Set
和Collection
是两种截然不同的类型,有着明确的 IS-A 关系:Set
是-A Collection
。尽管Set
没有引入任何新方法,但它确实改变了add
方法的含义,这一点足够重要(也足够有用)以保证一个独特的类型。
抽象的规则
清单 2-11 给出了一个名为DataManager1
的类的代码。这个类管理数据值的ArrayList
。列表被传递到它的构造器中,它的方法计算列表的一些简单的统计属性。
public class DataManager1 {
private ArrayList<Double> data;
public DataManager1(ArrayList<Double> d) {
data = d;
}
public double max() {
return Collections.max(data);
}
public double mean() {
double sum = 0.0;
for (int i=0; i<data.size(); i++)
sum += data.get(i);
return sum / data.size();
}
}
Listing 2-11The DataManager1 Class
虽然这个类执行正确,但它的设计很差。它的问题是它只对存储在ArrayList
对象中的数据有效。这个限制是不必要的,因为代码中没有任何内容只适用于数组列表。
很容易重写该类,使其适用于任意值列表。该代码出现在清单 2-12 中。
public class DataManager2 {
private List<Double> data;
public DataManager2(List<Double> d) {
data = d;
}
...
}
Listing 2-12The Class DataManager2 Class
DataManager1
和DataManager2
的代码完全相同,除了两个地方的ArrayList
被替换为List
。这两个类及其依赖关系可以用图 2-4 的类图来表示。
图 2-4
数据管理器 1 与数据管理器 2
类DataManager2
增加的灵活性源于它依赖于接口List
,这比DataManager1
依赖于ArrayList
更抽象。这种见解在一般情况下是正确的,并且可以表达为下面的抽象规则。
抽象的规则
一个类的依赖关系应该尽可能抽象。
这条规则建议设计者应该检查设计中的每一个依赖项,看看是否可以把它变得更抽象。这个规则的一个特例被称为“程序到接口”,它断言依赖一个接口总是比依赖一个类好。
虽然DataManager2
比DataManager1
好,但是如果把它对List
的依赖改成更抽象的东西,比如Collection
,它会变得更好吗?乍一看,您可能会说“不”,因为mean
方法的实现使用了基于List
的方法get
。如果你想让这个类为任何集合工作,那么你需要编写mean
,这样它只使用对集合可用的方法。幸运的是,这样的重写是可能的。清单 2-13 给出了更好的类DataManager3
的代码。
public class DataManager3 {
private Collection<Double> data;
public DataManager3(Collection<Double> d) {
data = d;
}
public double max() {
return Collections.max(data);
}
public double mean() {
double sum = 0.0;
for (double d : data)
sum += d;
return sum / data.size();
}
}
Listing 2-13The DataManager3 Class
抽象规则也可以应用到银行演示中。例如,考虑Bank
和HashMap
之间的相关性。一个Bank
对象有一个变量accounts
,它将一个账号映射到相应的BankAccount
对象。变量的类型是HashMap<Integer,BankAccount>
。抽象规则建议变量应该使用类型Map<Integer,BankAccount>
。在版本 6 的代码中,这句话被改变了。
向接口添加代码
在这一章的开始,我将接口定义为一组方法头,类似于 API。根据这个定义,接口不能包含代码。Java 8 版本放宽了这一限制,因此接口可以定义方法,尽管它仍然不能声明变量。本节将检验这一新能力的后果。
作为一个例子,清单 2-14 展示了版本 6 对BankAccount
接口的修改,增加了方法createSavingsWithDeposit
和isEmpty
。
public interface BankAccount extends Comparable<BankAccount> {
...
static BankAccount createSavingsWithDeposit(
int acctnum, int n) {
BankAccount ba = new SavingsAccount(acctnum);
ba.deposit(n);
return ba;
}
default boolean isEmpty() {
return getBalance() == 0;
}
}
Listing 2-14The Version 6 BankAccount Interface
这两种方法都是便利方法的例子。方便的方法不会引入任何新的功能。相反,它利用现有的功能来方便客户。方法createSavingsWithDeposit
创建具有指定初始余额的储蓄账户。如果账户余额为零,方法isEmpty
返回 true,否则返回 false。
接口方法或者是静态或者是默认。静态方法有关键字static
,意思和在类中一样。默认方法是非静态的。关键字default
表示一个实现类可以覆盖代码,如果它愿意的话。其思想是,接口提供了方法的一般实现,保证适用于所有实现类。但是特定的类可能能够提供更好、更有效的实现。例如,假设货币市场储蓄账户要求最低余额为 100 美元。然后它知道账户永远不会为空,因此它可以将默认的isEmpty
方法改写为立即返回 false 的方法,而不必检查余额。
对于默认方法的一个更有趣的例子,考虑如何对列表排序的问题。Java 库类Collections
有静态方法sort
。您向sort
方法传递两个参数——一个列表和一个比较器——它为您对列表进行排序。(比较器是一个指定排序顺序的对象,将在第四章中讨论。这里只要知道传递 null 作为比较器会导致sort
方法使用它们的compareTo
方法来比较列表元素就足够了。)例如,清单 2-15 的代码从标准输入中读取十个单词到一个列表中,然后对列表进行排序。
Scanner scanner = new Scanner(System.in);
List<String> words = new ArrayList<>();
for (int i=0; i<10; i++)
words.add(scanner.next());
Collections.sort(words, null);
Listing 2-15The Old Way to Sort a List
这个sort
方法的问题是,在不知道它是如何实现的情况下,没有好的方法对列表进行排序。Collections
类使用的解决方案是将列表的元素复制到一个数组中,对数组进行排序,然后将排序后的元素复制回列表。清单 2-16 给出了基本的想法。注意,toArray
方法返回一个Object
类型的数组,因为 Java 对泛型数组的限制使得它不可能返回一个T
类型的数组。对数组排序后,for 循环将每个数组元素存储回L
。这两种类型转换对于忽略编译器对类型安全的关注是必要的。
public class Collections {
...
static <T> void sort(List<T> L, Comparator<T> comp) {
Object[] a = L.toArray();
Arrays.sort(a, (Comparator)comp);
for (int i=0; i<L.size(); i++)
L.set(i, (T)a[i]);
}
}
Listing 2-16Code for the Sort Method
虽然这段代码适用于任何列表,但是它的开销是将列表元素复制到一个数组中,然后再复制回来。对于某些 list 实现来说,这种开销是浪费时间。例如,数组列表将其列表元素保存在一个数组中,因此直接对该数组进行排序会更有效。这种情况意味着真正高效的列表排序方法不可能是透明的。它需要确定列表是如何实现的,然后使用特定于该实现的排序算法。
Java 8 通过将sort
作为List
接口的默认方法来解决这个问题。List.sort
的代码是对Collections.sort
代码的重构;基本思想出现在清单 2-17 中。
public interface List<T> extends Collection<T> {
...
default void sort(Comparator<T> comp) {
Object[] a = toArray();
Arrays.sort(a, (Comparator)comp);
for (int i=0; i<size(); i++)
set(i, (T)a[i]);
}
}
Listing 2-17A Default Sort Method for List
这个默认的sort
方法有两个好处。首先是优雅:现在可以直接对列表进行排序,而不是通过Collections
中的静态方法。也就是说,下面两个语句现在是等价的:
Collections.sort(L, null);
L.sort(null);
第二个也是更重要的好处是列表可以被透明地处理。sort
的默认实现适用于List
的所有实现。然而,任何特定的List
实现(比如ArrayList
)都可以选择用自己更有效的实现来覆盖这个方法。
摘要
多态是程序利用类的公共功能的能力。Java 使用接口来支持多态——接口的方法指定了一些公共功能,支持这些方法的类可以选择实现该接口。例如,假设类C1
和C2
实现接口I
:
public interface I {...}
public class C1 implements I {...}
public class C2 implements I {...}
程序现在可以声明类型为I
的变量,这些变量可以保存对C1
或C2
对象的引用,而不用关心它们实际引用的是哪个类。
这一章研究了多态的力量,并给出了一些使用它的基本例子。它还介绍了适当使用多态的四个设计规则:
-
透明性规则规定,客户端应该能够使用一个接口,而不需要知道实现该接口的类。
-
开放/封闭规则规定,程序应该被结构化,以便可以通过创建新的类而不是修改现有的类来修改它们。
-
Liskov 替换原则(LSP)规定了一个接口成为另一个接口的子类型的时间。特别是,
X
应该是Y
的一个子类型,如果一个类型为X
的对象可以在任何需要类型为Y
的对象的地方使用。 -
抽象规则规定类的依赖关系应该尽可能抽象。
三、类层次结构
第二章研究了接口如何扩展其他接口,创建类型的层次结构。面向对象语言的特点之一是类可以扩展其他类,创建一个类层次。本章研究了类的层次结构以及有效使用它们的方法。
子类
Java 允许一个类扩展另一个类。如果类A
扩展了类B
,那么A
被认为是B
的子类,而B
是A
的超类。子类A
继承了其超类B
的所有公共变量和方法,以及这些方法的所有B
代码。
Java 中子类化最常见的例子是内置类Object
。根据定义,Java 中的每个类都是Object
的子类。也就是说,以下两个类定义是等效的:
class Bank { ... }
class Bank extends Object { ... }
因此,由Object
定义的方法被每个对象继承。在这些方法中,两种常用的方法是equals
和toString
。如果被比较的两个引用指向同一个对象,那么equals
方法返回 true。(也就是说,该方法等效于“==”运算。)方法toString
返回一个描述对象的类和它在内存中的位置的字符串。清单 3-1 展示了这些方法。
Object x = new Object();
Object y = new Object();
Object z = x;
boolean b1 = x.equals(y); // b1 is false
boolean b2 = x.equals(z); // b2 is true
System.out.println(x.toString());
// prints something like "java.lang.Object@42a57993"
Listing 3-1Demonstrating the Default Equals Method
一个类可以选择覆盖一个继承的方法。通常超类提供的代码过于通用,子类可以用更合适的代码覆盖方法。通常会覆盖toString
方法。例如,版本 6 银行演示中的Bank
、SavingsAccount
和CheckingAccount
类覆盖了toString
。
通常还会覆盖equals
方法。覆盖equals
方法的类通常会比较两个对象的状态,以确定它们是否表示同一个现实世界中的事物。例如,考虑一下SavingsAccount
这个类。假设储蓄账户有不同的账号,如果两个SavingsAccount
对象的账号相同,那么它们应该相等。但是,请考虑下面的代码。
SavingsAccount s1 = new SavingsAccount(123);
SavingsAccount s2 = new SavingsAccount(123);
boolean b = s1.equals(s2); // returns false
由于s1
和s2
引用不同的对象,使用默认的equals
方法比较它们将返回 false。如果你想让equals
方法在这种情况下返回 true,那么SavingsAccount
需要覆盖它。见清单 3-2 。
boolean equals(Object obj) {
if (! obj instanceof SavingsAccount)
return false;
SavingsAccount sa = (SavingsAccount) obj;
return getAcctNum() == sa.getAcctNum();
}
Listing 3-2The Version 6 Equals Method of SavingsAccount
这段代码可能比您预期的要复杂。原因是默认equals
方法的参数具有类型Object
,这意味着任何覆盖equals
的类也必须将其参数声明为类型Object
。也就是说,SavingsAccount
的equals
方法必须处理客户端将SavingsAccount
对象与其他类中的对象进行比较的可能性。清单 3-2 的代码通过使用instanceof
和类型转换克服了这个问题,如第二章所示。如果参数不是储蓄账户,则该方法立即返回 false。否则,它将参数转换为类型SavingsAccount
,并比较它们的帐号。
在类Object
中定义的方法永远不需要在接口中声明。例如,考虑下面的代码。
BankAccount ba = new SavingsAccount(123);
String s = ba.toString();
不管BankAccount
接口是否声明了toString
方法,这段代码都是合法的,因为如果没有被覆盖,每个实现类都将从Object
继承toString
。然而,让接口声明toString
还是有价值的——它要求它的每个实现类显式地覆盖这个方法。
要在类图中表示类-超类关系,请使用带实线的实心箭头。这与用于接口-超接口关系的箭头相同。例如,图 3-1 显示了类图中与版本 6 银行账户类相关的部分,修改后包括了Object
类。一般来说,类图通常省略Object
,因为它的存在是隐含的,添加它会使图变得不必要的复杂。
图 3-1
向类图中添加对象
第二章介绍了与接口相关的利斯科夫替代原理。这个原则也适用于班级。它声明如果类A
扩展了类B
,那么A
对象可以用在任何需要B
对象的地方。换句话说,如果A
延长了B
,那么A
就是-A B
。
例如,假设您想要修改银行演示,使其具有新的银行帐户类型“利息支票”利息支票账户和普通支票账户完全一样,只是它定期计息。调用这个类InterestChecking
。
InterestChecking
是否应该延长CheckingAccount
?当我描述利息检查时,我说它“完全像”常规检查。这表明是一种关系,但让我们确定一下。假设银行想要一份列出所有支票账户的报告。报告应该包括利息支票账户吗?如果答案是“是”,那么就有一个 IS-A 关系,并且InterestChecking
应该扩展CheckingAccount
。如果答案是“不”,那就不应该。
假设InterestChecking
确实应该是CheckingAccount
的子类。利息支票账户在两个方面不同于普通支票账户:它的toString
方法打印“利息支票”,它的addInterest
方法给出利息。因此,InterestChecking
的代码将覆盖toString
和addInterest
,并从其超类继承其余方法的代码。清单 3-3 中显示了该类的一个可能实现。
public class InterestChecking extends CheckingAccount {
private double rate = 0.01;
public InterestChecking(int acctnum) {
super(acctnum);
}
public String toString() {
return "Interest checking account " + getAcctNum()
+ ": balance=" + getBalance() + ", is "
+ (isForeign() ? "foreign" : "domestic");
}
public void addInterest() {
int newbalance = (int) (getBalance() * rate);
deposit(newbalance);
}
}
Listing 3-3A Proposed InterestChecking Class
注意,构造器调用方法super
。super
方法是对超类的构造器的调用,主要在子类需要超类处理其构造器的参数时使用。如果子类的构造器调用super
,那么 Java 要求这个调用必须是构造器的第一个语句。
一个类的私有变量对其他任何类都是不可见的,包括它的子类。这迫使子类代码通过调用超类的公共方法来访问它的继承状态。例如,再次考虑清单 3-3 中建议的InterestChecking
代码。toString
方法想要从它的超类中访问变量acctnum
、balance
和isforeign
。然而,这些变量是私有的,这迫使toString
调用超类方法getAcctNum
、getBalance
和isForeign
来获得相同的信息。同样的,addInterest
方法也要调用getBalance
和deposit
而不是简单的更新变量balance
。
尽可能多地从子类中封装一个类是一个好习惯。但是有时候(比如在addInterest
代码的情况下)结果会很尴尬。因此,Java 提供了修饰符protected
作为public
或private
的替代。受保护的变量可以被它在层次结构中的后代类访问,但不能被任何其他类访问。例如,如果CheckingAccount
声明变量balance
被保护,那么InterestChecking
的addInterest
方法可以写成如下:
public void addInterest() {
balance += (int) (balance * RATE);
}
抽象类
再次考虑银行演示的版本 6。CheckingAccount
和SavingsAccount
类目前有几个相同的方法。如果这些方法在将来不需要保持一致,那么这些类的设计是正确的。但是,假设银行的政策是,无论账户类型如何,存款总是表现相同。那么这两个deposit
方法将永远保持一致;换句话说,它们包含重复的代码。
程序中重复代码的存在是有问题的,因为当程序改变时,需要维护这种重复。例如,如果对CheckingAccount
的deposit
方法进行了错误修复,那么你需要记住对SavingsAccount
进行同样的错误修复。这种情况导致了下面的设计规则,叫做不要重复自己(或“干”):
“不要重复自己”规则
一段代码应该只存在于一个地方。
干规则与最有资格的类规则相关,这意味着一段代码应该只存在于最有资格执行它的类中。如果两个类看起来同样有资格执行代码,那么设计中可能有缺陷——最有可能的是,设计中缺少一个最有资格的类。在 Java 中,提供这个缺失类的一种常见方式是使用一个抽象类。
银行演示的版本 6 说明了重复代码的一个常见原因:两个相关的类实现了同一个接口。一个解决方案是创建一个CheckingAccount
和SavingsAccount
的超类,并将重复的方法以及它们使用的状态变量移到其中。称这个超类为AbstractBankAccount
。类CheckingAccount
和SavingsAccount
将各自持有它们自己的特定于类的代码,并将从AbstractBankAccount
继承它们剩余的代码。这个设计是银行演示的第 7 版。清单 3-4 中显示了AbstractBankAccount
的代码。该类包含
-
状态变量
acctnum
、balance
和isforeign
。这些变量有protected
修饰符,这样子类可以自由地访问它们。 -
初始化
acctnum
的构造器。这个构造器受到保护,因此它只能被它的子类调用(通过它们的super
方法)。 -
常用方法
getAcctNum
、getBalance
、deposit
、compareTo
、equals
的代码。
public abstract class AbstractBankAccount
implements BankAccount {
protected int acctnum;
protected int balance = 0;
protected boolean isforeign = false;
protected AbstractBankAccount(int acctnum) {
this.acctnum = acctnum;
}
public int getAcctNum() {
return acctnum;
}
public int getBalance() {
return balance;
}
public boolean isForeign() {
return isforeign;
}
public void setForeign(boolean b) {
isforeign = b;
}
public void deposit(int amt) {
balance += amt;
}
public int compareTo(BankAccount ba) {
int bal1 = getBalance();
int bal2 = ba.getBalance();
if (bal1 == bal2)
return getAcctNum() - ba.getAcctNum();
else
return bal1 - bal2;
}
public boolean equals(Object obj) {
if (! (obj instanceof BankAccount))
return false;
BankAccount ba = (BankAccount) obj;
return getAcctNum() == ba.getAcctNum();
}
public abstract boolean hasEnoughCollateral(int loanamt);
public abstract String toString();
public abstract void addInterest();
}
Listing 3-4The Version 7 AbstractBankAccount Class
注意方法hasEnoughCollateral
、toString
和addInterest
的声明。这些方法被声明为abstract
,并且没有关联的代码。问题是AbstractBankAccount
实现了BankAccount
,所以那些方法需要在它的 API 中;但是,该类没有有用的方法实现,因为代码是由其子类提供的。通过声明这些方法是抽象的,该类断言它的子类将为它们提供代码。
包含抽象方法的类被称为抽象类,并且必须在它的头中有abstract
关键字。抽象类不能直接实例化。相反,有必要实例化它的一个子类,这样它的抽象方法就会有一些代码。例如:
BankAccount xx = new AbstractBankAccount(123); // illegal
BankAccount ba = new SavingsAccount(123); // legal
清单 3-5 给出了SavingsAccount
的版本 7 代码;CheckingAccount
的代码类似。这段代码与版本 6 的代码基本相同,除了它只包含了AbstractBankAccount
的三个抽象方法的实现;BankAccount
的其他方法可以省略,因为它们继承自AbstractBankAccount
。抽象方法的实现能够引用变量balance
、acctnum
和isforeign
,因为它们在AbstractBankAccount
中受到保护。
public class SavingsAccount extends AbstractBankAccount {
private double rate = 0.01;
public SavingsAccount(int acctnum) {
super(acctnum);
}
public boolean hasEnoughCollateral(int loanamt) {
return balance >= loanamt / 2;
}
public String toString() {
return "Savings account " + acctnum + ": balance="
+ balance + ", is "
+ (isforeign ? "foreign" : "domestic");
}
public void addInterest() {
balance += (int) (balance * rate);
}
}
Listing 3-5The Version 7 SavingsAccount Class
InterestChecking
的版本 7 代码类似于清单 3-3 中的代码,除了它的方法引用了AbstractBankAccount
的受保护变量;因此没有显示它的代码。
版本 7 的BankClient
和Bank
类做了一些小的修改来处理InterestChecking
对象的创建。清单 3-6 给出了BankClient
中newAccount
方法的相关部分。清单 3-7 给出了Bank
中newAccount
的修改方法。变化用粗体表示。
public int newAccount(int type, boolean isforeign) {
int acctnum = nextacct++;
BankAccount ba;
if (type == 1)
ba = new SavingsAccount(acctnum);
else if (type == 2)
ba = new CheckingAccount(acctnum);
else
ba = new InterestChecking(acctnum);
ba.setForeign(isforeign);
accounts.put(acctnum, ba);
return acctnum;
}
Listing 3-7The Version 7 newAccount Method of Bank
private void newAccount() {
System.out.print("Enter account type(1=savings,
2=checking, 3=interest checking): ");
int type = scanner.nextInt();
boolean isforeign = requestForeign();
current = bank.newAccount(type, isforeign);
System.out.println("Your new account number is "
+ current);
}
Listing 3-6The Version 7 newAccount Method of BankClient
版本 7 银行账户类的类图如图 3-2 所示。从中可以推断出AbstractBankAccount
实现了BankAccount
中除了hasEnoughCollateral
、toString
和addInterest
之外的所有方法;CheckingAccount
和SavingsAccount
实现这三种方法;并且InterestChecking
超越toString
和addInterest
。注意,AbstractBankAccount
的矩形用斜体表示类名和抽象方法,表示它们是抽象的。
图 3-2
第 7 版银行帐户类别
抽象类定义了一类相关的类。例如,类AbstractBankAccount
定义了类别“银行账户”,其派生类——储蓄账户、支票账户和利息支票账户——都是该类别的成员。
另一方面,像CheckingAccount
这样的非抽象超类扮演着两个角色:它定义了类别“支票账户”(其中InterestChecking
是一个成员),它还表示该类别的一个特定成员(即“常规支票账户”)。CheckingAccount
的这种双重用法使得这个类不容易理解,也使得设计变得复杂。
解决这个问题的一个方法是将CheckingAccount
分成两部分:定义支票账户类别的抽象类和表示常规支票账户的子类。银行演示的版本 8 做出了这样的改变:抽象类是CheckingAccount
,子类是RegularChecking
。
CheckingAccount
实现所有支票账户通用的方法hasEnoughCollateral
。它的抽象方法是toString
和addInterest
,由子类RegularChecking
和InterestChecking
实现。图 3-3 为版本 8 类图。请注意这两个抽象类是如何形成对三个银行帐户类进行分类的分类法的。
图 3-3
版本 8 银行帐户分类
CheckingAccount
的修改代码出现在清单 3-8 中。方法toString
和addInterest
是抽象的,因为它的子类负责计算利息并知道它们的账户类型。它的构造器是受保护的,因为它只能由子类调用。
public abstract class CheckingAccount
extends AbstractBankAccount {
protected CheckingAccount(int acctnum) {
super(acctnum);
}
public boolean hasEnoughCollateral(int loanamt) {
return balance >= 2 * loanamt / 3;
}
public abstract String toString();
public abstract void addInterest();
}
Listing 3-8The Version 8 CheckingAccount Class
RegularChecking
的代码出现在清单 3-9 中;InterestChecking
的代码类似。版本 8 演示中的其他类与版本 7 相比基本没有变化。例如,Bank
唯一的变化是它的newAccount
方法,它需要创建一个RegularChecking
对象,而不是一个CheckingAccount
对象。
public class RegularChecking extends CheckingAccount {
public RegularChecking(int acctnum) {
super(acctnum);
}
public String toString() {
return "Regular checking account " + acctnum
+ ": balance=" + balance + ", is "
+ (isforeign ? "foreign" : "domestic");
}
public void addInterest() {
// do nothing
}
}
Listing 3-9The Version 8 RegularChecking Class
抽象类是子类化最常见的用法。Java 库包含了许多子类-超类关系的例子,但是几乎所有的超类都是抽象的。这个例子说明了为什么会这样:一个涉及非抽象超类的设计通常可以通过将其转化为抽象类来改进。
编写 Java 集合类
第二章介绍了 Java 集合库,它的接口,以及实现这些接口的类。这些类是通用的,适用于大多数情况。但是,程序可能对自定义集合类有特定的需求。问题是集合接口有很多方法,这使得编写定制类的任务变得复杂。此外,许多方法都有简单的实现,对于每个实现类都是一样的。结果是重复的代码,违反了 DRY 规则。
Java 集合库包含了解决这个问题的抽象类。大多数集合接口都有一个对应的抽象类,抽象类的名字是“abstract”,后面跟接口名。即List<E>
对应的类命名为AbstractList<E>
,以此类推。每个抽象类保留一些抽象的接口方法,并根据抽象方法实现其余的方法。
比如AbstractList<E>
的抽象方法是size
和get
。如果你想创建自己的实现List<E>
的类,那么扩展AbstractList<E>
并实现这两个方法就足够了。(如果您希望列表是可修改的,您还需要实现方法set
。)
例如,假设您想要创建一个实现了List<Integer>
的类RangeList
。一个RangeList
对象将表示一个集合,该集合包含从0
到n-1
的n
个整数,用于构造器中指定的值n
。清单 3-10 给出了一个程序RangeListTest
的代码,它使用一个RangeList
对象来打印从 0 到 19 的数字:
public class RangeListTest {
public static void main(String[] args) {
List<Integer> L = new RangeList(20);
for (int x : L)
System.out.print(x + " ");
System.out.println();
}
}
Listing 3-10The RangeListTest Class
RangeList
的代码出现在清单 3-11 中。注意一个RangeList
对象如何表现得好像它实际上包含一个值列表,尽管它并不包含。特别是,它的get
方法表现得好像列表的每个槽i
都包含值i
。这项技术是非凡而重要的。关键是,如果一个对象声明自己是一个列表,并且表现得像一个列表,那么它就是一个列表。不要求它实际包含列表的元素。
public class RangeList extends AbstractList<Integer> {
private int limit;
public RangeList(int limit) {
this.limit = limit;
}
public int size() {
return limit;
}
public Integer get(int n) {
return n;
}
}
Listing 3-11The RangeList Class
字节流
Java 库包含抽象类InputStream
,它表示可以作为字节序列读取的数据源的类别。这个类有几个子类。这里有三个例子:
-
类
FileInputStream
从指定的文件中读取字节。 -
类
PipedInputStream
从管道中读取字节。管道使不同的进程能够进行通信。例如,互联网套接字是使用管道实现的。 -
类
ByteArrayInputStream
从数组中读取字节。这个类使程序能够像访问文件一样访问字节数组的内容。
类似地,抽象类OutputStream
表示可以向其中写入字节序列的对象。Java 库有镜像InputStream
类的OutputStream
类。具体来说,FileOutputStream
写入指定文件,PipedOutputStream
写入管道,ByteArrayOutputStream
写入数组。这些类的类图如图 3-4 所示。
图 3-4
输入流和输出流的类图
公共变量System.in
属于扩展InputStream
的未指定类,默认情况下从控制台读取字节。例如,银行演示中的类BankProgram
包含以下语句:
Scanner sc = new Scanner(System.in);
该语句可以等价地写成如下形式:
InputStream is = System.in;
Scanner sc = new Scanner(is);
抽象类InputStream
和OutputStream
的最大价值之一是它们对多态的支持。使用InputStream
和OutputStream
的客户端类不需要依赖于它们使用的特定输入或输出源。《??》就是一个很好的例子。Scanner
的构造器的参数可以是任何输入流。例如,要创建一个从文件“testfile”中读取数据的扫描器,您可以编写:
InputStream is = new FileInputStream("testfile");
Scanner sc = new Scanner(is);
演示类EncryptDecrypt
展示了字节流的典型用法。这个类的代码出现在清单 3-12 中。它的encrypt
方法有三个参数:源文件和输出文件的名称,以及一个加密偏移量。它从源中读取每个字节,向其中添加偏移量,并将修改后的字节值写入输出。main
方法调用了encrypt
两次。第一次,它对文件“data.txt”的字节进行加密,写入文件“encrypted . txt”;第二次,它对“encrypted.txt”的字节进行加密,并将其写入“decrypted.txt”。由于第二次加密偏移量是第一次的负数,因此“decrypted.txt”中的字节将是“data.txt”的逐字节副本
public class EncryptDecrypt {
public static void main(String[] args) throws IOException {
int offset = 26; // any value will do
encrypt("data.txt", "encrypted.txt", offset);
encrypt("encrypted.txt", "decrypted.txt", -offset);
}
private static void encrypt(String source, String output,
int offset) throws IOException {
try ( InputStream is = new FileInputStream(source);
OutputStream os = new FileOutputStream(output) ) {
int x;
while ((x = is.read()) >= 0) {
byte b = (byte) x;
b += offset;
os.write(b);
}
}
}
}
Listing 3-12The EncryptDecrypt Class
请注意,无论加密偏移量如何,这种“双重加密解密”算法都能正常工作。原因与字节算法的特性有关。当算术运算导致字节值超出其范围时,溢出被丢弃;结果是加法和减法变成了循环。例如,255 是最大的字节值,因此 255+1 = 0。同样,0 是最小的字节值,所以 0-1 = 255。
encrypt
方法说明了read
和write
方法的使用。write
方法很简单;它将一个字节写入输出流。read
方法更加复杂。它返回一个整数,其值要么是输入流中的下一个字节(0 到 255 之间的值),要么是-1(如果流中没有更多的字节)。客户端代码通常循环调用read
,当返回值为负时停止。当返回值不是负数时,客户端应该在使用它之前将整数值转换为一个字节。
客户机不知道的是,输入和输出流经常代表它们向操作系统请求资源。因此,InputStream
和OutputStream
有方法close
,其目的是将那些资源返回给操作系统。客户端可以显式调用close
,或者可以指示 Java 自动关闭流。encrypt
方法说明了自动关闭特性。这些流作为try
子句的“参数”打开,并将在try
子句完成时自动关闭。
大多数流方法抛出 IO 异常。原因是输入和输出流通常由操作系统管理,因此会受到超出程序控制的环境的影响。流方法需要能够传达意外情况(比如丢失文件或网络不可用),以便它们的客户端有机会处理它们。为了简单起见,两个EncryptDecrypt
方法不处理异常,而是将它们扔回到调用链中。
除了清单 3-12 中使用的零参数读取方法之外,InputStream
还有两个一次读取多个字节的方法:
-
一个单参数
read
方法,其中参数是一个字节数组。方法读取足够的字节来填充数组。 -
一个三参数
read
方法,其中参数是一个字节数组,数组中第一个字节应该存储的偏移量,以及要读取的字节数。
这些方法返回的值是读取的字节数,如果没有字节可以读取,则为-1。
举个简单的例子,考虑以下语句:
byte[] a = new byte[16];
InputStream is = new FileInputStream("fname");
int howmany = is.read(a);
if (howmany == a.length)
howmany = is.read(a, 0, 4);
第三条语句试图将16
字节读入数组a
;变量howmany
包含实际读取的字节数(如果没有读取字节,则为-1
)。如果这个值小于16
,那么这个流一定是字节数用完了,代码不会采取进一步的动作。如果值是16
,那么下一条语句试图再读取四个字节,将它们存储在数组的槽0-3
中。同样,变量howmany
将包含实际读取的字节数。
类OutputStream
有类似的write
方法。write
和read
方法的主要区别在于write
方法返回 void。
对于使用多字节read
和write
方法的具体例子,考虑银行演示。假设您希望将银行的账户信息写入一个文件,以便在每次执行BankProgram
时可以恢复其状态。
修改后的BankProgram
代码出现在清单 3-13 中。该代码使用了一个行为如下的类SavedBankInfo
。它的构造器从指定的文件中读取帐户信息,并构造帐户映射。其方法getAccounts
返回账户映射,如果文件不存在则为空。它的方法nextAcctNum
返回下一个新帐户的号码,如果该文件不存在,该号码将为 0。它的方法saveMap
将当前账户信息写入文件,覆盖之前的所有信息。
public class BankProgram {
public static void main(String[] args) {
SavedBankInfo info = new SavedBankInfo("bank.info");
Map<Integer,BankAccount> accounts = info.getAccounts();
int nextacct = info.nextAcctNum();
Bank bank = new Bank(accounts, nextacct);
Scanner scanner = new Scanner(System.in);
BankClient client = new BankClient(scanner, bank);
client.run();
info.saveMap(accounts, bank.nextAcctNum());
}
}
Listing 3-13The Version 8 BankProgram Class
清单 3-14 中显示了SavedBankInfo
的代码。变量accounts
和nextaccount
为没有账户的银行初始化。构造器负责读取指定的文件;如果文件存在,它调用本地方法readMap
来使用保存的帐户信息初始化nextaccount
并填充映射。方法saveMap
打开文件的输出流,并调用writeMap
将账户信息写入该流。
public class SavedBankInfo {
private String fname;
private Map<Integer,BankAccount> accounts
= new HashMap<Integer,BankAccount>();
private int nextaccount = 0;
private ByteBuffer bb = ByteBuffer.allocate(16);
public SavedBankInfo(String fname) {
this.fname = fname;
if (!new File(fname).exists())
return;
try (InputStream is = new FileInputStream(fname)) {
readMap(is);
}
catch (IOException ex) {
throw new RuntimeException("file read exception");
}
}
public Map<Integer,BankAccount> getAccounts() {
return accounts;
}
public int nextAcctNum() {
return nextaccount;
}
public void saveMap(Map<Integer,BankAccount> map,
int nextaccount) {
try (OutputStream os = new FileOutputStream(fname)) {
writeMap(os, map, nextaccount);
}
catch (IOException ex) {
throw new RuntimeException("file write exception");
}
}
... // definitions for readMap and writeMap
}
Listing 3-14The Version 8 SavedBankInfo Class
SavedBankInfo
有一个ByteBuffer
类型的变量。ByteBuffer
类定义了值和字节之间的转换方法。一个ByteBuffer
对象有一个底层字节数组。它的方法putInt
将一个整数的 4 字节表示存储到数组中指定的偏移量处;它的方法getInt
将指定偏移量处的 4 个字节转换成一个整数。SavedBankInfo
创建一个 16 字节的ByteBuffer
对象,其底层数组将用于文件的所有读写操作。
清单 3-15 中显示了writeMap
和readMap
方法的代码。这些方法决定了数据文件的整体结构。首先,writeMap
写一个整数表示下一个账号;然后,它写入每个帐户的值。readMap
方法读回这些值。它首先读取一个整数,并将其保存在全局变量nextaccount
中。然后,它读取帐户信息,将每个帐户保存在地图中。
private void writeMap(OutputStream os,
Map<Integer,BankAccount> map,
int nextacct) throws IOException {
writeInt(os, nextacct);
for (BankAccount ba : map.values())
writeAccount(os, ba);
}
private void readMap(InputStream is) throws IOException {
nextaccount = readInt(is);
BankAccount ba = readAccount(is);
while (ba != null) {
accounts.put(ba.getAcctNum(), ba);
ba = readAccount(is);
}
}
Listing 3-15The Methods writeMap and readMap
writeInt
和readInt
的代码出现在清单 3-16 中。writeInt
方法将一个整数存储在字节缓冲区底层数组的前四个字节中,然后使用三参数write
方法将这些字节写入输出流。readInt
方法使用三参数read
方法将四个字节读入ByteBuffer
数组的开头,然后将这些字节转换成一个整数。
private void writeInt(OutputStream os, int n)
throws IOException {
bb.putInt(0, n);
os.write(bb.array(), 0, 4);
}
private int readInt(InputStream is) throws IOException {
is.read(bb.array(), 0, 4);
return bb.getInt(0);
}
Listing 3-16The writeInt and readInt Methods
writeAccount
和readAccount
的代码出现在清单 3-17 中。writeAccount
方法从银行账户中提取四个关键值(账号、类型、余额和 isforeign 标志),将它们转换成四个整数,放入字节缓冲区,然后将整个底层字节数组写入输出流。readAccount
方法将 16 个字节读入底层字节数组,并将其转换为 4 个整数。然后,它使用这些整数创建一个新帐户,并对其进行适当的配置。方法通过返回空值来指示流的结尾。
private void writeAccount(OutputStream os, BankAccount ba)
throws IOException {
int type = (ba instanceof SavingsAccount) ? 1
: (ba instanceof RegularChecking) ? 2 : 3;
bb.putInt(0, ba.getAcctNum());
bb.putInt(4, type);
bb.putInt(8, ba.getBalance());
bb.putInt(12, ba.isForeign() ? 1 : 2);
os.write(bb.array());
}
private BankAccount readAccount(InputStream is)
throws IOException {
int n = is.read(bb.array());
if (n < 0)
return null;
int num = bb.getInt(0);
int type = bb.getInt(4);
int balance = bb.getInt(8);
int isforeign = bb.getInt(12);
BankAccount ba;
if (type == 1)
ba = new SavingsAccount(num);
else if (type == 2)
ba = new RegularChecking(num);
else
ba = new InterestChecking(num);
ba.deposit(balance);
ba.setForeign(isforeign == 1);
return ba;
}
Listing 3-17The writeAccount and readAccount Methods
如你所见,这种保存账户信息的方式非常低级。保存信息需要将每个账户转换成特定的字节序列,而恢复信息需要反向操作。因此,编码很困难,而且有点痛苦。第七章将介绍对象流的概念,它使客户端能够直接读写对象,并让底层代码执行繁琐的字节转换。
现在您已经看到了如何使用字节流,是时候研究它们是如何实现的了。我将只考虑输入流。类似地实现输出流。
InputStream
是一个抽象类。它有一个抽象方法,即零参数read
方法,并提供其他方法的默认实现。清单 3-18 中出现了InputStream
代码的简化版本。
public abstract class InputStream {
public abstract int read() throws IOException;
public void close() { }
public int read(byte[] buf, int offset, int len)
throws IOException {
for (int i=0; i<len; i++) {
int x = read();
if (x < 0)
return (i==0) ? -1 : i;
buf[offset+i] = (byte) x;
}
return len;
}
public int read(byte[] buf) throws IOException {
read(buf, 0, buf.length);
}
...
}
Listing 3-18A Simplified InputStream Class
三个非抽象方法的默认实现非常简单。close
方法什么也不做。三参数read
方法通过重复调用零参数read
方法来填充数组的指定部分。而一论元read
法只是三论元法的一个特例。
InputStream
的每个子类都需要实现零参数read
方法,并且可以选择覆盖其他方法的默认实现。例如,如果一个子类获得了资源(比如由FileInputStream
获得的文件描述符),那么它应该覆盖close
方法来释放那些资源。
为了提高效率,子类可以选择覆盖三参数read
方法。例如,FileInputStream
和PipedInputStream
这样的类通过操作系统调用获得它们的字节。由于对操作系统的调用非常耗时,因此当这些类最大限度地减少这些调用的数量时,它们会更加高效。因此,它们通过对操作系统进行单个多字节调用的方法来覆盖默认的三参数read
方法。
ByteArrayInputStream
的代码提供了一个InputStream
子类的例子。一个简单的实现出现在清单 3-19 中。
public class ByteArrayInputStream extends InputStream {
private byte[] a;
private int pos = 0;
public ByteArrayInputStream(byte[] a) {
this.a = a;
}
public int read() throws IOException {
if (pos >= a.length)
return -1;
else {
pos++;
return a[pos-1];
}
}
}
Listing 3-19A Simplified ByteArrayInputStream Class
InputStream
方法作为子类默认值的方式类似于抽象集合类帮助它们的子类实现集合接口的方式。不同的是,集合库对一个抽象类(比如AbstractList
)和它对应的接口(比如List
)做了区别。抽象类InputStream
和OutputStream
没有对应的接口。实际上,它们充当自己的接口。
模板模式
抽象集合类和字节流类说明了使用抽象类的一种特殊方式:抽象类实现其 API 的一些方法,并将其他方法声明为抽象的。它的每个子类都将实现这些抽象的公共方法(并可能覆盖其他一些方法)。
这里有一个设计抽象类的更通用的方法。抽象类将实现其 API 的所有方法,但不一定完全实现。部分实现的方法称为“helper”方法,这些方法是受保护的(也就是说,它们在类层次结构之外是不可见的)和抽象的(也就是说,它们由子类实现)。
这种技术被称为模板模式。其思想是,API 方法的每个部分实现都提供了该方法应该如何工作的“模板”。助手方法使每个子类能够适当地定制 API 方法。
在文献中,抽象助手方法有时被称为“钩子”抽象类提供钩子,每个子类提供可以挂在钩子上的方法。
版本 8 BankAccount
的类层次结构可以通过使用模板模式来改进。版本 8 代码的问题是它仍然违反了 DRY 规则。考虑一下SavingsAccount
(清单 3-5 )和CheckingAccount
(清单 3-8 )类中方法hasEnoughCollateral
的代码。这两种方法几乎相同。他们都将账户余额乘以一个系数,并将该值与贷款金额进行比较。它们唯一的区别是它们乘以不同的因子。我们如何消除这种重复?
解决方案是将乘法和比较移到AbstractBankAccount
类中,并创建一个抽象的帮助器方法来返回要乘的因子。该解决方案在版本 9 代码中实现。AbstractBankAccount
中hasEnoughCollateral
方法的代码更改如下:
public boolean hasEnoughCollateral(int loanamt) {
double ratio = collateralRatio();
return balance >= loanamt * ratio;
}
protected abstract double collateralRatio();
也就是说,hasEnoughCollateral
方法不再是抽象的。相反,它是一个调用抽象助手方法collateralRatio
的模板,其代码由子类实现。例如,下面是SavingsAccount
中collateralRatio
方法的版本 9 代码。
protected double collateralRatio() {
return 1.0 / 2.0;
}
抽象方法addInterest
和toString
也包含重复的代码。与其让每个子类完整地实现这些方法,不如在AbstractBankAccount
中为它们创建一个模板。每个模板方法都可以调用抽象的帮助器方法,然后子类可以实现这些方法。具体来说,addInterest
方法调用抽象方法interestRate
,而toString
方法调用抽象方法accountType
。
图 3-5 显示了第 9 版银行演示的类图。从中你可以推断出:
图 3-5
版本 9 类图
-
AbstractBankAccount
实现了BankAccount
中的所有方法,但是它本身有抽象方法collateralRatio
、accountType
和interestRate
。 -
实现了所有这三种方法。
-
CheckingAccount
只实现了collateralRatio
,将另外两个方法留给了它的子类。 -
RegularChecking
和InterestChecking
执行accountType
和interestRate
。
下面的清单显示了版本 9 中修改后的类。AbstractBankAccount
的代码出现在清单 3-20 中;SavingsAccount
的代码出现在清单 3-21 中;CheckingAccount
的代码出现在清单 3-22 中;清单 3-23 中显示了RegularChecking
的代码。InterestChecking
的代码与RegularChecking
类似,在此省略。注意,由于模板模式,这些类非常紧凑。没有任何重复的代码!
public class RegularChecking extends CheckingAccount {
public RegularChecking(int acctnum) {
super(acctnum);
}
protected String accountType() {
return "Regular Checking";
}
protected double interestRate() {
return 0.0;
}
}
Listing 3-23The Version 9 RegularChecking Class
public abstract class CheckingAccount extends BankAccount {
public CheckingAccount(int acctnum) {
super(acctnum);
}
public double collateralRatio() {
return 2.0 / 3.0;
}
protected abstract String accountType();
protected abstract double interestRate();
}
Listing 3-22The Version 9 CheckingAccount Class
public class SavingsAccount extends BankAccount {
public SavingsAccount(int acctnum) {
super(acctnum);
}
public double collateralRatio() {
return 1.0 / 2.0;
}
public String accountType() {
return "Savings";
}
public double interestRate() {
return 0.01;
}
}
Listing 3-21The Version 9 SavingsAccount Class
public abstract class AbstractBankAccount
implements BankAccount {
protected int acctnum;
protected int balance;
...
public boolean hasEnoughCollateral(int loanamt) {
double ratio = collateralRatio();
return balance >= loanamt * ratio;
}
public String toString() {
String accttype = accountType();
return accttype + " account " + acctnum
+ ": balance=" + balance + ", is "
+ (isforeign ? "foreign" : "domestic");
}
public void addInterest() {
balance += (int) (balance * interestRate());
}
protected abstract double collateralRatio();
protected abstract String accountType();
protected abstract double interestRate();
}
Listing 3-20The Version 9 AbstractBankAccount Class
对于模板模式的另一个例子,考虑 Java 库类Thread
。这个类的目的是允许程序在新线程中执行代码。它的工作原理如下:
-
Thread
有两种方法:start
和run
。 -
start
方法要求操作系统创建一个新线程。然后它从那个线程执行对象的run
方法。 -
run
方法是抽象的,由一个子类实现。 -
一个客户端程序定义了一个类
X
,它扩展了Thread
并实现了run
方法。然后客户端创建一个新的X
-对象并调用它的start
方法。
清单 3-24 中的类ReadLine
是Thread
子类的一个例子。它的run
方法收效甚微。对sc.nextLine
的调用被阻塞,直到用户按下回车键。当这种情况发生时,run
方法将输入行存储在变量s
中,将其变量done
设置为真,然后退出。请注意,该方法对输入行不做任何事情。输入的唯一目的是当用户按回车键时将变量done
设置为真。
class ReadLine extends Thread {
private boolean done = false;
public void run() {
Scanner sc = new Scanner(System.in);
String s = sc.nextLine();
sc.close();
done = true;
}
public boolean isDone() {
return done;
}
}
Listing 3-24The ReadLine Class
清单 3-25 给出了类ThreadTest
的代码。该类创建一个ReadLine
对象并调用它的start
方法,导致它的run
方法从一个新线程中执行。然后,该类继续(从原始线程)以升序打印整数,直到ReadLine
的isDone
方法返回 true。换句话说,程序打印整数,直到用户按下回车键。新的线程使得用户能够交互地决定何时停止打印。
public class ThreadTest {
public static void main(String[] args) {
ReadLine r = new ReadLine();
r.start();
int i = 0;
while(!r.isDone()) {
System.out.println(i);
i++;
}
}
}
Listing 3-25The ThreadTest Class
注意Thread
类是如何使用模板模式的。它的start
方法是公共 API 的一部分,充当线程执行的模板。它的职责是创建并执行一个新线程,但它不知道要执行什么代码。run
方法是助手方法。每个Thread
子类通过指定run
的代码来定制模板。
使用线程时一个常见的错误是让客户端调用线程的run
方法,而不是它的start
方法。毕竟,Thread
子类包含方法run
,而start
方法是隐藏的。而且,调用run
是合法的;这样做的效果是运行线程代码,但不是在新线程中。(在将语句r.start()
改为r.run()
后,尝试执行清单 3-25 。会发生什么?)然而,一旦理解了线程使用模板模式,调用start
方法的原因就变得清楚了,并且Thread
类的设计最终也变得有意义了。
摘要
面向对象语言中的类可以形成子类-超类关系。这些关系的创建应该遵循 Liskov 替换原则:如果X
-对象可以用在任何需要Y
-对象的地方,那么X
类应该是Y
类的子类。子类继承其超类的代码。
创建超类-子类关系的一个原因是为了满足 DRY 规则,该规则规定一段代码应该只存在于一个地方。如果两个类包含公共代码,那么该公共代码可以放在这两个类的公共超类中。然后,这些类可以从它们的超类继承这些代码。
如果两个子类是同一接口的不同实现,那么它们的公共超类也应该实现该接口。在这种情况下,超类变成了一个抽象类,它没有实现的接口方法被声明为抽象的。抽象类不能被实例化,而是充当其实现类的类别。由抽象类的层次结构产生的分类被称为分类法。
抽象类有两种方法来实现它的接口。第一种方式以 Java 抽象集合类为例。抽象类声明一些接口方法是抽象的,然后根据抽象方法实现剩余的方法。每个子类只需要实现抽象方法,但是如果需要,可以覆盖任何其他方法。
第二种方式以 Java Thread
类为例。抽象类实现了所有的接口方法,在需要的时候调用抽象的“助手”方法。每个子类都实现这些助手方法。这种技术被称为模板模式。抽象类提供了每个接口方法应该如何工作的“模板”,每个子类提供了特定于子类的细节。
四、策略
类层次结构是面向对象编程语言的一个基本特征。第三章考察了他们的能力。本章考察了它们(通常是重大的)局限性,并介绍了更加灵活的策略层级概念。策略层次是几种设计技术的核心组成部分。本章考察了两种这样的技术——策略模式和命令模式——以及它们的用途。
策略模式
让我们从回顾第三章的模板模式开始。其中,一个抽象类(称为模板)提供了每个公共方法的框架实现,并依靠非公共抽象方法来提供特定于实现的细节。这些抽象方法由模板子类实现。每个子类被称为一个策略类,因为它为实现模板的抽象方法提供了一个特定的策略。
清单 4-1 给出了一个简单的例子。类IntProcessor
是模板类。它有一个抽象方法f
,从给定的整数计算输出值。方法operateOn
将一个整数传递给f
并打印其输出值。有两个策略子类,AddOne
和AddTwo
,它们提供了f
的不同实现。类TestClient
演示了这些类的用法。它创建每个子类的一个实例,并调用每个实例的operateOn
方法。
public abstract class IntProcessor {
public void operateOn(int x) {
int y = f(x);
System.out.println(x + " becomes " + y);
}
protected abstract int f(int x);
}
public class AddOne extends IntProcessor {
protected int f(int x) {
return x+1;
}
}
public class AddTwo extends IntProcessor {
protected int f(int x) {
return x+2;
}
}
public class TestClient {
public static void main(String[] args) {
IntProcessor p1 = new AddOne();
IntProcessor p2 = new AddTwo();
p1.operateOn(6); // prints "6 becomes 7" p2.operateOn(6); // prints "6 becomes 8"
}
}
Listing 4-1Example Template Pattern Classes
设计这个程序的另一种方法是不使用子类化。不要把策略类实现为IntProcessor
的子类,你可以给它们自己的层次,称为策略层次。这个层次的接口被命名为Operation
,并且有方法f
。不再有任何子类或抽象方法的IntProcessor
类持有对Operation
对象的引用,并在需要调用f
时使用该引用。修改后的代码出现在清单 4-2 中。TestClient
类创建所需的Operation
对象,并通过依赖注入将每个对象传递给IntProcessor
。
public class IntProcessor {
private Operation op;
public IntProcessor(Operation op) {
this.op = op;
}
public void operateOn(int x) {
int y = f(x);
System.out.println(x + " becomes " + y);
}
private int f(int x) {
return op.f(x);
}
}
interface Operation {
public int f(int x);
}
class AddOne implements Operation {
public int f(int x) {
return x+1;
}
}
class AddTwo implements Operation {
public int f(int x) {
return x+2;
}
}
public class TestClient {
public static void main(String[] args) {
Operation op1 = new AddOne();
Operation op2 = new AddTwo();
IntProcessor p1 = new IntProcessor(op1);
IntProcessor p2 = new IntProcessor(op2);
p1.operateOn(6); p2.operateOn(6);
}
}
Listing 4-2Refactoring Listing 4-1 to Use a Strategy Hierarchy
如果您比较这两个清单,您会发现它们是彼此的重构,具有几乎相同的代码。主要区别在于策略类是如何附加到IntProcessor
类的。图 4-1 显示了两种不同设计对应的类图。
图 4-1
清单 4-1 和 4-2 的类图
将策略类组织成层次结构的技术被称为策略模式。策略模式由图 4-2 的类图描述。策略接口定义了一组方法。每个实现接口的类都为执行这些方法提供了不同的策略。客户端有一个变量,它保存一个策略类的对象。因为变量的类型是StrategyInterface
,客户端不知道对象属于哪个类,因此也不知道正在使用哪个策略。
图 4-2
策略模式
图 4-1 右侧的类图对应的是策略模式。IntProcessor
是客户端,Operation
是策略接口,AddOne
和AddTwo
是策略类。
Java Thread
类提供了模板和策略模式之间二元性的真实例子。调用清单 3-25 中的ThreadTest
程序。类Thread
是模板类,其公共方法start
调用抽象方法run
。它的子类ReadLine
是实现run
的策略类。图 4-3 描绘了Thread
与ReadLine
之间的关系。
图 4-3
使用模板模式将线程连接到它的策略类
在使用策略模式的相应设计中,ReadLine
将属于一个策略层级,该层级将是Thread
的依赖。策略接口被称为Runnable
,并有方法run
。一个Thread
对象持有一个对Runnable
对象的引用,它的start
方法将调用Runnable
对象的run
方法。参见图 4-4 。
图 4-4
使用策略模式将线程连接到它的策略类
将图 4-3 与图 4-4 进行对比。图 4-3 要求ReadLine
延伸Thread
,而图 4-4 要求ReadLine
执行Runnable
。从语法上来说,这种差异很小。事实上,修改ReadLine
的代码除了它的类头之外不涉及任何代码的改变。修改后的类出现在清单 4-3 中,与清单 3-24 的区别以粗体显示。
public class ReadLine implements Runnable {
private boolean done = false;
public void run() {
Scanner sc = new Scanner(System.in);
String s = sc.nextLine();
sc.close();
done = true;
}
public boolean isDone() {
return done;
}
}
Listing 4-3The Revised ReadLine Class
一个Thread
对象通过依赖注入获得它的Runnable
对象。也就是说,客户端将所需的Runnable
对象传递给Thread
构造器。清单 4-4 给出了客户端程序RunnableThreadTest
的代码,它修改了清单 3-25 的ThreadTest
类以使用策略模式。差异以粗体显示。
public class RunnableThreadTest {
public static void main(String[] args) {
ReadLine r = new ReadLine();
Thread t = new Thread(r);
t.start();
int i = 0;
while(!r.isDone()) {
System.out.println(i);
i++;
}
}
}
Listing 4-4The RunnableThreadTest Class
虽然ThreadTest
和RunnableThreadTest
实际上有相同的代码,但它们的设计在概念上是非常不同的。在ThreadTest
中,类ReadLine
是Thread
的子类,也就是说ReadLine
是-A Thread
。在RunnableThreadTest
中,ReadLine
对象与Thread
无关,只是被传递到一个新的Thread
对象的构造器中。
目前的观点认为,使用策略模式创建线程比使用模板模式创建线程能产生更好的设计。主要原因是策略模式创建了两个对象——在本例中是 runnable 和 thread——这使得它们的关注点保持分离。相比之下,模板模式将这两种关注结合到一个对象中。第二个原因是策略模式更加灵活,因为 runnable 对象能够扩展另一个类。例如,假设出于某种原因,您希望每个SavingsAccount
对象在自己的线程中运行。模板模式方法在这里是不可能的,因为 Java 不允许SavingsAccount
扩展Thread
和AbstractBankAccount
。
你可能已经注意到Thread
级在图 4-3 和 4-4 中有不同的描述。在图 4-3 中,它是一个抽象类,用run
作为它的抽象方法。在图 4-4 中,它是非抽象类,其run
方法调用其策略类的run
方法。
Thread
类被设计成可以以任何方式使用。清单 4-5 展示了基本的想法。关键问题是如何实现run
方法。有两种潜在的run
方法:在Thread
中定义的方法,以及在Thread
的子类中定义的方法。如果使用了策略模式(如清单 4-4 所示),则执行Thread
中定义的run
方法,该方法调用传递给它的Runnable
对象的run
方法。如果使用了模板模式(如清单 3-25 所示),那么子类中定义的run
方法覆盖了Thread
中定义的方法,并被执行。
public class Thread {
private Runnable target;
public Thread() {
this(null); // if no Runnable is specified, use null
}
public Thread(Runnable r) {
target = r;
}
public void start() {
... // allocate a new thread
run(); // and run it
}
// This method can be overridden by a subclass.
public void run() {
if (target != null)
target.run();
}
}
Listing 4-5A Simplified Implementation of Thread
您可能会对为什么 null 被用作变量target
的可能值感到困惑,特别是因为它会使代码变得复杂。原因源于需要处理以下语句:
Thread t1 = new Thread();
t1.start();
Runnable r = null;
Thread t2 = new Thread(r);
t2.start();
这些语句执行两个线程,这两个线程都没有run
方法。虽然代码是无意义的,但它是合法的,Thread
类必须处理它。在这些情况下,清单 4-5 中采用的解决方案是存储一个空值作为目标Runnable
对象。然后,run
方法可以检查目标是否为空;如果是这样,它什么也不做。
比较仪
回想一下第二章中的Comparable
接口如何使比较对象成为可能。这个接口有一个名为compareTo
的方法,它指定了对象的顺序。如果一个类实现了Comparable
,那么它的对象可以通过调用compareTo
进行比较。由CompareTo
定义的顺序被称为物体的自然顺序。
问题是Comparable
硬编码了一个特定的顺序,这使得用其他方式比较对象变得不可能。例如,AbstractBankAccount
类实现了Comparable<BankAccount>
,它的compareTo
方法(在清单 3-4 中给出)按照余额从低到高比较银行账户。它不允许您通过帐号或余额从高到低比较帐户。
如何指定不同的比较顺序?使用策略模式!策略接口声明了比较方法,策略类提供了该方法的具体实现。
因为对象比较非常常见,所以 Java 库为您提供了这个策略接口。这个接口叫做Comparator
,它声明的方法叫做compare
。除了接受两个对象作为参数之外,compare
方法与compareTo
相似。如果x>y
调用compare(x,y)
返回大于0
的值,如果x<y
返回小于0
的值,如果x=y
返回0
。
示例比较器类AcctByMinBal
的代码出现在清单 4-6 中。它的compare
方法比较两个BankAccount
参数,使用与清单 3-4 的compareTo
方法基本相同的代码。主要的区别是语法上的:compare
方法有两个参数,而compareTo
只有一个参数。另一个区别是清单 4-6 以与清单 3-4 相反的顺序减去账户余额,这意味着它从高到低比较余额。也就是说,具有最小余额的账户将是“最大的”
class AcctByMinBal implements Comparator<BankAccount> {
public int compare(BankAccount ba1, BankAccount ba2) {
int bal1 = ba1.getBalance();
int bal2 = ba2.getBalance();
if (bal1 == bal2)
return ba1.getAcctNum() – ba2.getAcctNum();
else
return bal2 – bal1;
}
}
Listing 4-6The AcctByMinBal Class
清单 4-7 给出了程序ComparatorBankAccounts
的代码,它修改了清单 2-9 的CompareBankAccounts
类。与使用自然排序找到最大银行账户的CompareBankAccounts
不同,ComparatorBankAccounts
根据四个指定的排序找到最大元素。每个排序由不同的比较器对象表示。其中两个比较器被传递给本地方法findMax
。另外两个被传递给 Java 库方法Collections
。max
。
public class ComparatorBankAccounts {
public static void main(String[] args) {
List<BankAccount> accts = initAccts();
Comparator<BankAccount> minbal = new AcctByMinBal();
Comparator<BankAccount> maxbal = innerClassComp();
Comparator<BankAccount> minnum = lambdaExpComp1();
Comparator<BankAccount> maxnum = lambdaExpComp2();
BankAccount a1 = findMax(accts, minbal);
BankAccount a2 = findMax(accts, maxbal);
BankAccount a3 = Collections.max(accts, minnum);
BankAccount a4 = Collections.max(accts, maxnum);
System.out.println("Acct with smallest bal is " + a1);
System.out.println("Acct with largest bal is " + a2);
System.out.println("Acct with smallest num is " + a3);
System.out.println("Acct with largest num is " + a4);
}
private static BankAccount findMax(List<BankAccount> a,
Comparator<BankAccount> cmp) {
BankAccount max = a.get(0);
for (int i=1; i<a.size(); i++) {
if (cmp.compare(a.get(i),max) > 0)
max = a.get(i);
}
return max;
}
... // code for the three comparator methods goes here
}
Listing 4-7The ComparatorBankAccounts Class
列出 4-7 的findMax
方法修改了列出 2-9 的相应方法。现在它需要两个参数:一个银行账户列表和一个比较器。它返回最大的帐户,其中“最大”由比较器决定。
Collections
。与其他涉及比较的库方法一样,max
方法能够处理Comparable
和Comparator
接口。如果你叫Collections
。max
如果有一个参数(如清单 2-9 所示),那么它将根据元素的自然顺序对它们进行比较。另一方面,如果调用Collections
。max
使用一个比较器作为它的第二个参数(如清单 4-7 所示),然后元素将按照比较器指定的顺序进行比较。
清单 4-7 的main
方法创建了四个类型为Comparable<BankAccount>
的对象。第一个对象是清单 4-6 的AcctByMinBal
类的一个实例。其他三个对象由方法innerClassComp
、lambdaExpComp1
和lambdaExpComp2
创建;这些方法的代码将出现在清单 4-8 到 4-10 中。这些方法中的每一个都从一个匿名内部类创建一个对象;匿名内部类将在下一节讨论。
ComparatorBankAccounts
程序的类图如图 4-5 所示。注意它是如何遵循策略模式的。
图 4-5
ComparatorBankAccounts 的类图
匿名内部类
抽象的规则(来自第二章)断言变量的类型在可能的情况下应该是一个接口。在这种情况下,实现接口的类名相对来说并不重要,因为只有在调用类构造器时才会用到它。本节研究如何创建未命名的类,称为匿名内部类,以及它们提供的便利。
显式匿名类
匿名内部类定义了一个没有名字的类。假设T
是一个接口。一般语法是:
T v = new T() { ... };
这条语句使编译器做三件事:
-
它创建了一个实现
T
的新类,代码出现在大括号中。 -
它通过调用该类的默认构造器来创建该类的新对象。
-
它将对该对象的引用保存在变量
v
中。
注意,客户机永远不需要知道新对象的类,因为它只通过类型为T
的变量与对象交互。
ComparatorBankAccounts
中方法innerClassComp
的代码出现在清单 4-8 中。粗体代码突出了匿名内部类语法。大括号内的代码实现了compare
方法,在这种情况下,它恰好与清单 3-4 中的compareTo
方法相同。这个类在图 4-5 的类图中被命名为AnonymousA
,但是我们当然不知道(或者说不关心)它的真实名字是什么。
private static Comparator<BankAccount> innerClassComp() {
Comparator<BankAccount> result =
new Comparator<BankAccount>() {
public int compare(BankAccount ba1,
BankAccount ba2) {
int bal1 = ba1.getBalance();
int bal2 = ba2.getBalance();
if (bal1 == bal2)
return ba1.getAcctNum() - ba2.getAcctNum();
else
return bal1 - bal2;
}
};
return result;
}
Listing 4-8The innerClassComp Method
λ表达式
匿名内部类提供了一种定义类和创建类的单个实例的便捷方式,因为类及其实例都可以内联创建。本节展示了如何缩短匿名内部类的定义,使它们更加方便。
如果一个接口只有一个方法,不包括任何默认的或者静态的方法,那么这个接口就被称为是功能性的。接口Comparator<T>
是功能接口的一个例子。函数接口的匿名内部类可以写得非常简洁。由于只有一个方法要定义,所以它的名字和返回类型都是由接口决定的,不需要写;您只需要为该方法编写代码。这个符号被称为λ表达式。它的语法是:
(T1 t1, ..., Tn tn) -> {...}
方法的参数列表在“箭头”的左边,它的代码在右边,在大括号内。ComparatorBankAccounts
中的方法lambdaExpComp1
使用此语法;参见清单 4-9 的粗体部分。它的compare
方法按照账号从高到低比较账户。
private static Comparator<BankAccount> lambdaExpComp1() {
Comparator<BankAccount> result =
(BankAccount ba1, BankAccount ba2) -> {
return ba2.getAcctNum() - ba1.getAcctNum();
};
return result;
}
Listing 4-9The lambdaExpComp1 Method
尽管 lambda 表达式可以写得相当简洁,但 Java 允许您进一步简化它们。
-
您不必指定参数的类型。
-
如果只有一个参数,那么可以省略括号。
-
如果方法体由一条语句组成,那么可以省略大括号;如果一个单语句方法也返回一些东西,那么你也可以省略“return”关键字。
ComparatorBankAccounts
中的方法lambdaExpComp2
使用此语法;参见清单 4-10 的粗体部分。compare
方法通过账号从低到高比较账户。
private static Comparator<BankAccount> lambdaExpComp2() {
Comparator<BankAccount> result =
(ba1, ba2) -> ba1.getAcctNum() - ba2.getAcctNum();
return result;
}
Listing 4-10The lambdaExpComp2 Method
对于 lambda 表达式的另一个例子,再次考虑清单 4-5 中Thread
类的实现。它的变量target
保存了指定的 runnable 对象,空值表示不存在的 runnable。run
方法必须使用 if 语句来确保它只执行非空的可运行程序。
使用空值来表示“什么都不做”是糟糕的设计,因为它迫使run
方法在每次执行时做出“做什么”或“什么都不做”的决定。更好的办法是让类在它的构造器中做一次决定。解决方案是使用 lambda 表达式。清单 4-11 的代码修改清单 4-5 。
public class Thread {
private static Runnable DO_NOTHING = () -> {};
private Runnable target;
public Thread() {
this(DO_NOTHING); // use the default runnable
}
public Thread(Runnable r) {
target = (r == null) ? DO_NOTHING : r;
}
public void start() {
... // allocate a new thread
run();
}
// This method can be overridden by a subclass.
public void run() {
target.run(); // no need to check for null!
}
}
Listing 4-11A revised Implementation of Thread
该类通过 lambda 表达式()->{}
创建一个Runnable
对象。这个 lambda 表达式定义了一个run
方法,它不接受任何参数,也不做任何事情。这个Runnable
对象保存在常量DO_NOTHING
中。如果没有Runnable
对象被传入Thread
构造器,那么变量target
将接收到对DO_NOTHING
的引用,而不是空值。因为这个对象是可运行的,所以run
方法可以执行它而不需要 if 语句。
作为设计工具的策略模式
让我们回到银行演示的设计上来。第三章介绍了演示的第 9 版,该版本支持组织成图 4-6 的类层次结构的三种银行账户。
图 4-6
第 9 版银行帐户层次结构
假设银行想在设计中增加另一个功能。它已经区分了国内账户和国外账户;它现在想对外资账户收取每年 5 美元的维护费。BankAccount
接口将获得一个名为fee
的新方法,该方法返回该帐户的费用。
实现fee
方法的一个简单方法是从类AbstractBankAccount
内部开始,如清单 4-12 所示。虽然这段代码很简单,但是它使用 if 语句是糟糕的设计。每次银行改变费用类别时,都需要修改方法,这是对开放/封闭规则的公然违反。
public int fee() {
if (isforeign)
return 500; // $5 is 500 cents
else
return 0;
}
Listing 4-12A Naïve Implementation of the Fee method in AbstractBankAccount
更好的方法是使用策略模式。所有权信息将被转移到它自己的策略层次结构中,它的接口被称为OwnerStrategy
,它的两个策略类对应于两个不同的费用类别。AbstractBankAccount
类将依赖于OwnerStrategy
,并将从中获取所有与所有者相关的信息。这个设计是银行演示的第 10 版。其类图的相关部分如图 4-7 所示,对图 3-5 的改动以粗体显示。
图 4-7
版本 10 银行帐户分类
这个图显示了添加到BankAccount
接口的fee
方法。类AbstractBankAccount
通过调用其OwnerStrategy
对象的fee
方法来实现该方法。OwnerStrategy
类也实现了附加方法isForeign
。
清单 4-13 中显示了OwnerStrategy
接口的代码。清单 4-14 给出了Foreign
类的代码;类似的还有Domestic
级。
public class Foreign implements OwnerStrategy {
public boolean isForeign() {
return true;
}
public int fee() {
return 500; // $5 is 500 cents
}
public String toString() {
return "foreign";
}
}
Listing 4-14The Foreign Class
public interface OwnerStrategy {
boolean isForeign();
int fee();
}
Listing 4-13The OwnerStrategy Interface
版本 10 AbstractBankAccount
类的代码出现在清单 4-15 中,用粗体显示了更改。它的布尔变量isforeign
已经被策略变量owner
取代。它的isForeign
和fee
方法调用owner
的isForeign
和fee
策略方法。它的toString
方法调用 strategy 对象的toString
方法来获取表示帐户是“国内”还是“国外”的字符串。最初,owner
变量被绑定到一个Domestic
策略对象。setForeign
方法将变量重新绑定到由参数值决定的OwnerStrategy
对象。
public abstract class AbstractBankAccount
implements BankAccount {
protected int acctnum;
protected int balance = 0;
private OwnerStrategy owner = new Domestic();
protected AbstractBankAccount(int acctnum) {
this.acctnum = acctnum;
}
public boolean isForeign() {
return owner.isForeign();
}
public int fee() {
return owner.fee();
}
public void setForeign(boolean b) {
owner = b ? new Foreign() : new Domestic();
}
public String toString() {
String accttype = accountType();
return accttype + " account " + acctnum
+ ": balance=" + balance + ", is "
+ owner.toString() + ", fee=" + fee();
}
...
}
Listing 4-15The Version 10 AbstractBankAccount Class
命令模式
OwnerStrategy
策略层次源于如何实现多种方法来计算银行账户费用的问题。清单 4-12 中给出了最初的解决方案,它使用一个 if 语句来决定执行哪个计算。if 语句的这种使用是有问题的:不仅效率低,而且每次添加新的费用类型时都需要修改。用策略层次结构代替 if 语句很好地解决了这两个问题。
类似的情况也存在于BankClient
类。它为八个不同的输入命令分配一个数字,它的processCommand
方法使用一个 if 语句来确定对于给定的命令数字执行哪个代码。该方法的代码出现在清单 4-16 中。
private void processCommand(int cnum) {
if (cnum == 0) quit();
else if (cnum == 1) newAccount();
else if (cnum == 2) select();
else if (cnum == 3) deposit();
else if (cnum == 4) authorizeLoan();
else if (cnum == 5) showAll();
else if (cnum == 6) addInterest();
else if (cnum == 7) setForeign();
else
System.out.println("illegal command");
}
Listing 4-16The Version 9 processCommand Method
对于这种方法,更好的设计是为每种命令类型创建一个策略接口InputCommand
和一个实现策略类。然后,BankClient
可以保存一个类型为InputCommand
的多态数组,包含来自每个策略类的一个对象。传递给processCommand
的命令编号成为该数组的索引。修改后的processCommand
方法出现在清单 4-17 中。注意索引数组访问是如何取代 if 语句的。
private void processCommand(int cnum) {
InputCommand cmd = commands[cnum];
current = cmd.execute(scanner, bank, current);
if (current < 0)
done = true;
}
Listing 4-17The Version 10 processCommand Method
策略接口InputCommand
有八个实现类——每种类型的命令一个类。这些类被命名为QuitCmd
、NewCmd
、DepositCmd
等等。图 4-8 显示了它们的类图。
图 4-8
输入命令策略层次结构
InputCommand
声明的策略方法命名为execute
。每个策略类的execute
方法包含执行指定命令所需的代码。这些策略类的代码取自清单 4-16 中引用的方法。例如,DepositCmd
的execute
方法包含与版本 9 deposit
方法相同的代码。
一个复杂的问题是版本 9 的方法能够修改BankClient
的全局变量;特别是,版本 9 newAccount
和select
命令改变变量current
的值,而quit
命令改变done
的值。然而,策略类不能访问BankClient
变量。版本 10 中采用的解决方案是让execute
方法返回current
的新值(或者旧值,如果它没有改变的话)。值-1 表示done
应该设置为真。清单 4-17 的代码反映了这个决定:将execute
的返回值赋给current
,如果current
为负,则将done
的值设置为 true。
清单 4-18 中显示了InputCommand
接口的代码。DepositCmd
的代码出现在清单 4-19 中。其他策略类的代码是类似的,这里省略了。
public class DepositCmd implements InputCommand {
public int execute(Scanner sc, Bank bank, int current) {
System.out.print("Enter deposit amt: ");
int amt = sc.nextInt();
bank.deposit(current, amt);
return current;
}
public String toString() {
return "deposit";
}
}
Listing 4-19The Version 10 DepositCmd Class
public interface InputCommand {
int execute(Scanner sc, Bank bank, int current);
}
Listing 4-18The Version 10 InputCommand Interface
命令对象的使用还解决了 9 版BankClient
类的另一个问题,这个问题与它的run
方法有关。有问题的代码是以“输入命令...”开头的字符串。在清单 4-20 中。这个字符串明确地给命令分配数字,并且必须与processCommand
方法保持同步。如果新的命令被添加到processCommand
中,或者如果分配给现有命令的数字发生变化,那么该字符串将需要重写。
public void run() {
while (!done) {
System.out.print("Enter command (0=quit, 1=new,
2=select, 3=deposit, 4=loan,
5=show, 6=interest, 7=setforeign): ");
int cnum = scanner.nextInt();
processCommand(cnum);
}
}
Listing 4-20The Version 9 BankClient Run Method
版本 10 BankClient
级有更好的设计,出现在清单 4-21 中。它利用了这样一个事实,即commands
数组为每个命令包含一个对象。当调用run
方法时,它调用方法constructMessage
来遍历该数组并构造“输入命令……”字符串。因此,无论命令如何变化,该字符串总是准确的。
public class BankClient {
private Scanner scanner;
private boolean done = false;
private Bank bank;
private int current = 0;
private InputCommand[] commands = {
new QuitCmd(),
new NewCmd(),
new SelectCmd(),
new DepositCmd(),
new LoanCmd(),
new ShowCmd(),
new InterestCmd(),
new SetForeignCmd() };
public BankClient(Scanner scanner, Bank bank) {
this.scanner = scanner;
this.bank = bank;
}
public void run() {
String usermessage = constructMessage();
while (!done) {
System.out.print(usermessage);
int cnum = scanner.nextInt();
processCommand(cnum);
}
}
private String constructMessage() {
int last = commands.length-1;
String result = "Enter Account Type (";
for (int i=0; i<last; i++)
result += i + "=" + commands[i] + ", ";
result += last + "=" + commands[last] + "): ";
return result;
}
private void processCommand(int cnum) {
InputCommand cmd = commands[cnum];
current = cmd.execute(scanner, bank, current);
if (current < 0)
done = true;
}
}
Listing 4-21The Version 10 BankClient Class
方法constructMessage
创建用户消息。这样做时,它会将每个InputCommand
对象附加到字符串中。Java 将其解释为隐式附加对象的toString
方法的结果。也就是说,以下语句是等效的:
result += i + "=" + commands[i] + ", ";
result += i + "=" + commands[i].toString() + ", ";
图 4-8 所示的策略层级的使用被称为命令模式。命令模式的结构与策略模式的结构相同。例如,在图 4-8 中,BankClient
是客户端,依赖于以InputCommand
为首的策略层级。这两种模式之间的唯一区别是它们策略的目的。在策略模式中,策略是计算性的——它们提供了计算值的替代方法。在命令模式中,策略是程序性的——它们提供了可以执行的替代任务。
消除阶级等级
模板模式和策略模式之间的二元性意味着任何使用模板模式的设计都可以被重新设计以使用策略模式。这一节展示了如何重新设计银行演示,以便它的BankAccount
类层次结构被一个策略层次结构取代。这个重新设计是银行演示的第 11 版。
重新设计的想法是将SavingsAccount
、RegularChecking
和InterestChecking
实现为策略类,由名为TypeStrategy
的策略接口领导。该接口声明了三个方法collateralRatio
、accountType
和interestRate
。因此,AbstractBankAccount
将不再需要子类。相反,它将通过引用一个TypeStrategy
对象来实现这三个方法。
图 4-9 显示了版本 11 的类图。在这个设计中,AbstractBankAccount
有两个策略层次。OwnerStrategy
的层次结构与版本 10 中的相同。TypeStrategy
层次包含了以前抽象的AbstractBankAccount
方法的代码。
图 4-9
银行演示的第 11 版
清单 4-22 中出现了TypeStrategy
界面。
public interface TypeStrategy {
double collateralRatio();
String accountType();
double interestRate();
}
Listing 4-22The TypeStrategy Interface
类SavingsAccount
、RegularChecking
和InterestChecking
实现TypeStrategy
。这些类与版本 10 相比基本没有变化;主要的区别是他们现在实现了TypeStrategy
,而不是扩展AbstractBankAccount
。清单 4-23 给出了SavingsAccount
的代码;其他两个类的代码类似。
public class SavingsAccount implements TypeStrategy {
public double collateralRatio() {
return 1.0 / 2.0;
}
public String accountType() {
return "Savings";
}
public double interestRate() {
return 0.01;
}
}
Listing 4-23The Version 11 SavingsAccount Class
在版本 10 中,Bank
类的newAccount
方法使用用户输入的类型号来确定新帐户的子类。版本 11 中的newAccount
方法使用类型号来确定新帐户的TypeStrategy
。然后它将TypeStrategy
对象传递给AbstractBankAccount
构造器,如清单 4-24 所示。类SavedBankInfo
需要类似的变化,但这里没有显示。
public int newAccount(int type, boolean isforeign) {
int acctnum = nextacct++;
TypeStrategy ts;
if (type==1)
ts = new SavingsAccount();
else if (type==2)
ts = new RegularChecking();
else
ts = new InterestChecking();
BankAccount ba = new AbstractBankAccount(acctnum, ts);
ba.setForeign(isforeign);
accounts.put(acctnum, ba);
return acctnum;
}
Listing 4-24The Version 11 newAccount Method of Bank
AbstractBankAccount
的代码出现在清单 4-25 中,以粗体显示。与版本 10 的主要区别是这个类不再是抽象的,它实现了以前的抽象方法collateralRatio
、accountType
和interestRate
。这些方法的代码简单地调用了TypeStrategy
变量ts
的相应方法。
public class AbstractBankAccount implements BankAccount {
private int acctnum;
private int balance = 0;
private OwnerStrategy owner = new Domestic();
private TypeStrategy ts;
public AbstractBankAccount(int acctnum, TypeStrategy ts) {
this.acctnum = acctnum;
this.ts = ts;
}
...
private double collateralRatio() {
return ts.collateralRatio();
}
private String accountType() {
return ts.accountType();
}
private double interestRate() {
return ts.interestRate();
}
}
Listing 4-25The AbstractBankAccount Class
模板与策略
模板模式和策略模式使用不同的机制来实现相似的目标——模板模式使用类层次结构,而策略模式使用单独的策略层次结构。我们能洞察到什么时候一种技术比另一种更好吗?
在模板模式中,类层次结构形成了组织不同策略类的结构。类层次结构采用一个一般的概念(如“银行账户”),并将其分成越来越窄的概念(如“储蓄账户”、“定期支票”和“利息支票”)。这样的组织被称为分类法。
分类法是一个有用的组织概念。例如,这本书的目录是其信息的分类。分类法的一个特点是类别中的成员是永久的。例如,在版本 10 银行演示中,储蓄账户不能成为支票账户。将储蓄账户“转换”为支票账户的唯一方法是创建一个新的支票账户,将储蓄账户的余额转入该账户,然后删除该储蓄账户。但是这种转换是不精确的——特别是,支票账户将具有与储蓄账户不同的账号。
分类法的另一个特点是,它只能表示其成员之间的层次关系。例如,银行演示根据“储蓄”和“支票”来组织账户该组织无法处理“国外”与“国内”的额外区别。
另一方面,策略模式更加灵活。每个策略层次都对应于一种完全独立的对象组织方式。此外,策略模式允许对象改变其策略选择。例如,BankAccount
中的setForeign
方法改变了该对象在OwnerStrategy
层次中的成员资格。
银行演示的版本 11 展示了策略如何包含子类的功能。在那个版本中,每个银行账户都属于同一个类,即AbstractBankAccount
。“支票账户”或“储蓄账户”的概念不再嵌入到类的层次结构中。相反,储蓄账户仅仅是一个银行账户,它有一个特定的TypeStrategy
方法的实现(也就是说,储蓄账户支付利息,有一个低的抵押比率,并且有“储蓄”这个名字)。类似地,这两种支票账户只是拥有自己的TypeStrategy
实现的银行账户。这样的设计非常灵活。仅仅通过混合和匹配它们的策略实现,就可以创建支票储蓄账户的各种组合。
这是个好主意吗?不一定。类的层次结构提供了一种结构,这种结构有助于驯服策略所带来的不受约束的复杂性。如何混合策略层次和模板子类的决定需要仔细考虑,并且将取决于建模的情况。例如,我的感觉是版本 10 的银行演示是一个更好的设计。支票账户和储蓄账户的划分似乎是合理的,也符合银行的运作方式。版本 11,通过放弃等级制度,看起来不太现实,也不太容易理解。
在本书中,我认为版本 11 的演示很有趣,也很有教育意义,但最终是一个死胡同。第五章的修订将基于版本 10。
摘要
在模板模式中,模板的每个子类为实现模板的抽象方法定义了不同的策略,称为策略类。本章研究了将这些策略类组织到它们自己的策略层次结构中的技术。当策略类执行计算时,这种技术被称为策略模式;当它们表示任务时,它被称为命令模式。
这两种设计模式模拟了一个类可以有多种方式来执行计算或任务的情况。一个常见的例子是对象比较。Java 库有接口Comparator
就是为了这个目的。客户端可以通过编写实现Comparator
的适当类来实现定制的比较代码。
策略类通常被写成匿名的内部类。如果策略接口是功能性的,那么它的策略类可以简洁优雅地写成一个λ表达式。
策略模式比模板模式更灵活,这种灵活性可以带来更好的设计。一个例子是如何根据帐户所有权计算费用的问题。由于类别层次结构是按帐户类型组织的,因此费用计算并不完全符合现有的类别结构。相反,创建一个独立的OwnerStrategy
层次结构既简单又优雅,并且不会影响现有的类层次结构。
策略模式实际上可以用来完全消除类层次结构,但这不一定是个好主意。作为一个类设计者,你需要了解你的选择。然后由你来权衡他们在特定情况下的取舍。
五、封装对象创建
多态使代码更加抽象。当您的代码引用一个接口而不是一个类时,它就失去了与该类的耦合,并且在面对未来的修改时变得更加灵活。这种抽象的使用是前几章中许多技术的核心。
类构造器是不可能进行这种抽象的地方。如果要创建一个对象,需要调用一个构造器;并且在不知道类名的情况下,调用构造器是不可能的。本章通过研究对象缓存和工厂的技术来解决这个问题。这些技术帮助设计者将构造器的使用限制在一个相对较小的、众所周知的类集合中,以最小化它们的潜在责任。
对象缓存
假设您想编写一个程序来分析大量运动检测传感器的状态,这些传感器的值要么是“开”,要么是“关”作为该程序的一部分,您要编写一个类Sensors
,它将传感器信息存储在一个列表中,并提供获取和设置单个传感器值的方法。该类的代码出现在清单 5-1 中。
public class Sensors {
private List<Boolean> L = new ArrayList<>();
public Sensors(int size) {
for (int i=0; i<size; i++)
L.add(new Boolean(false));
}
public boolean getSensor(int n) {
Boolean val = L.get(n);
return val.booleanValue();
}
public void setSensor(int n, boolean b) {
L.set(n, new Boolean(b));
}
}
Listing 5-1Version 1 of the Sensors Class
这段代码创建了许多Boolean
对象:构造器为每个传感器创建一个对象,而setSensor
方法每次被调用时都会创建另一个对象。但是,可以创建更少的对象。Boolean
对象是不可变的(即它们的状态不能被改变),这意味着具有相同值的Boolean
对象彼此无法区分。因此,这个类只需要使用两个Boolean
对象:一个为真,一个为假。这两个对象可以在整个列表中共享。
清单 5-2 显示了对Sensors
的修改,它利用了不变性。这段代码使用变量off
和on
作为缓存。当它需要一个Boolean
对象时,它使用on
;当它需要一个Boolean
对象为假时,它使用off
。
public class Sensors {
private List<Boolean> L = new ArrayList<>();
private static final Boolean off = new Boolean(false);
private static final Boolean on = new Boolean(true);
public Sensors(int size) {
for (int i=0; i<size; i++)
L.add(off);
}
public boolean getSensor(int n) {
Boolean val = L.get(n);
return val.booleanValue();
}
public void setSensor(int n, boolean b) {
Boolean val = b ? on : off;
L.set(n, val);
}
}
Listing 5-2Version 2 of the Sensors Class
使用缓存是一个好主意,但是在这种情况下,它仅限于Sensors
类。如果您想在另一个类中使用Boolean
对象,在两个类之间共享缓存的对象可能会很尴尬。幸运的是,有一种更好的方法——Boolean
类内置了缓存。清单 5-3 给出了Boolean
源代码的简化版本。
public class Boolean {
public static final Boolean TRUE = new Boolean(true);
public static final Boolean FALSE = new Boolean(false);
private boolean value;
public Boolean(boolean b) {value = b;}
public boolean booleanValue() {
return value;
}
public static Boolean valueOf(boolean b) {
return (b ? TRUE : FALSE);
}
...
}
Listing 5-3A Simplified Boolean Class
常量TRUE
和FALSE
是静态的和公共的。它们只在类被加载时创建一次,在任何地方都是可用的。静态方法valueOf
根据提供的boolean
值返回TRUE
或FALSE
。
清单 5-4 显示了对Sensors
类的修改,它使用了valueOf
方法和Boolean
的公共常量,而不是它的构造器。这是Boolean
类的首选用法。Java 文档声明应该优先于构造器使用valueOf
,因为它的缓存节省了时间和空间。事实上,我想不出任何人的 Java 代码需要调用Boolean
构造器的理由。
public class Sensors {
private List<Boolean> L = new ArrayList<>();
public void init(int size) {
for (int i=0; i<size; i++)
L.add(Boolean.FALSE);
}
public void setSensor(int n, boolean b) {
Boolean value = Boolean.valueOf(b);
L.set(n, value);
}
}
Listing 5-4Version 3 of the Sensors Class
尽管原始类型 boolean 和类Boolean
之间有明显的区别,但是 Java 的自动装箱概念模糊了这种区别。通过自动装箱,你可以在任何需要一个Boolean
对象的地方使用一个布尔值;编译器自动使用valueOf
方法将布尔值转换为Boolean
。类似地,拆箱的概念让你可以在任何需要布尔值的地方使用Boolean
对象;编译器自动使用booleanValue
方法将Boolean
转换为布尔值。
清单 5-5 给出了Sensors
的另一个版本,这次没有明确提到Boolean
对象。它在功能上等同于清单 5-4 。这段代码很有趣,因为它在幕后进行了很多工作。虽然它没有明确提到Boolean
对象,但是它们因为自动装箱而存在。此外,因为自动装箱调用了valueOf
,所以代码不会创建新的对象,而是使用缓存的版本。
public class Sensors {
private List<Boolean> L = new ArrayList<>();
public void init(int size) {
for (int i=0; i<size; i++)
L.add(false);
}
public void setSensor(int n, boolean b) {
L.set(n, b);
}
}
Listing 5-5Version 4 of the Sensors Class
Java 库类Integer
也执行缓存。它为介于-128 和 127 之间的整数创建了一个包含 256 个对象的缓存。它的valueOf
方法返回对这些常量之一的引用,如果它的参数在该范围内;否则它创建一个新对象并返回它。
例如,考虑清单 5-6 的代码。对valueOf
的前两次调用将返回对值为 127 的缓存的Integer
对象的引用。第三和第四次调用将分别为值 128 创建一个新的Integer
对象。换句话说,代码创建了两个新的Integer
对象,它们的值都是 128。
List<Integer> L = new ArrayList<>();
L.add(Integer.valueOf(127)); // uses cached object
L.add(Integer.valueOf(127)); // uses cached object
L.add(Integer.valueOf(128)); // creates new object
L.add(Integer.valueOf(128)); // creates new object
Listing 5-6An Example of Integer Caching
Java 编译器使用自动装箱和取消装箱在 int 值和Integer
对象之间进行转换。与Boolean
一样,它使用valueOf
方法来执行装箱,使用intValue
方法来执行取消装箱。清单 5-7 的代码在功能上等同于清单 5-6 。
List<Integer> L = new ArrayList<>();
L.add(127); // uses cached object
L.add(127); // uses cached object
L.add(128); // creates new object
L.add(128); // creates new object
Listing 5-7An Equivalent Example of Integer Caching
单例类
缓存的一个重要用途是实现单例类。单例类是一个具有固定数量对象的类,它是在类被加载时创建的。它没有公共构造器,因此不能创建其他对象。它被称为“singleton ”,因为最常见的情况是一个类只有一个实例。
例如,如果 Java 设计者将Boolean
构造器设为私有(这将是一个好主意),那么Boolean
将是一个单例类。另一方面,Integer
不能是单例类,即使它的构造器是私有的,因为它的valueOf
方法会在需要时创建新的对象。
Java enum 语法简化了单例类的创建,并且是编写单例的首选方式。例如,清单 5-8 展示了如何将Boolean
的代码重写为一个枚举。与清单 5-3 的差异以粗体显示。
public enum Boolean {
TRUE(true), FALSE(false);
private boolean value;
private Boolean(boolean b) {value = b;}
public boolean booleanValue() {
return value;
}
public static Boolean valueOf(boolean b) {
return (b ? TRUE : FALSE);
}
...
}
Listing 5-8Writing Boolean as an Enum
请注意,语法差异非常小。主要区别在于常量TRUE
和FALSE
的定义,它们省略了类型声明和对Boolean
构造器的调用。括号内的值表示构造器的参数。也就是说,声明
TRUE(true), FALSE(false);
相当于两个语句
public static final Boolean TRUE = new Boolean(true);
public static final Boolean FALSE = new Boolean(false);
从概念上讲,枚举是一个没有公共构造器的类,因此除了它的公共常量之外没有其他对象。在所有其他方面,枚举的行为就像一个类。例如,如果将Boolean
实现为枚举或类,清单 5-4 的代码将是相同的。
初学者通常不知道枚举和类之间的对应关系,因为枚举通常是作为一组命名的常量引入的。例如,下面的枚举定义了三个常量Speed.SLOW
、 Speed.MEDIUM
和Speed.FAST
:
public enum Speed {SLOW, MEDIUM, FAST};
这个枚举等价于清单 5-9 中的类定义。注意,每个Speed
常量是对不具有感兴趣的功能的Speed
对象的引用。
public class Speed {
public static final Speed SLOW = new Speed();
public static final Speed MEDIUM = new Speed();
public static final Speed FAST = new Speed();
private Speed() { }
}
Listing 5-9The Meaning of the Speed Enum
和类一样,没有参数和主体的枚举构造器(比如Speed
的构造器)被称为默认构造器。默认构造器可以从枚举声明中省略,就像它们可以从类声明中省略一样。
因为枚举中的常量是对象,所以它们继承了equals
、toString
和Object
的其他方法。在Speed
枚举的简单例子中,它的对象不能做任何其他事情。Java enum 语法的精妙之处在于,enum 常量可以被赋予任意多的附加功能。
枚举的toString
方法的默认实现是返回常量的名称。例如,以下语句将字符串“SLOW”赋给变量s
。
String s = Speed.SLOW.toString();
假设您希望Speed
常量显示为音乐节拍。然后您可以覆盖清单 5-10 中所示的toString
方法。在这种情况下,前面的语句会将字符串“largo”赋给变量s
。
public enum Speed {
SLOW("largo"), MEDIUM("moderato"), FAST("presto");
private String name;
private Speed(String name) {
this.name = name;
}
public String toString() {
return name;
}
}
Listing 5-10Overriding the toString Method of Speed
单一策略类
让我们回到银行演示的第 10 版。OwnerStrategy
接口有两个实现类,Domestic
和Foreign
。这两个类都有空的构造器,并且它们的对象是不可变的。因此,所有的Domestic
对象都可以互换使用,所有的Foreign
对象也是如此。
与其按需创建新的Domestic
和Foreign
对象(这正是AbstractBankAccount
类目前所做的),不如让这些类成为单例。清单 5-11 展示了如何将Foreign
重写为一个枚举;Domestic
的代码类似。与版本 10 代码的两个不同之处以粗体显示。
public enum Foreign implements OwnerStrategy {
INSTANCE;
public boolean isForeign() {
return true;
}
public int fee() {
return 500;
}
public String toString() {
return "foreign";
}
}
Listing 5-11Rewriting Foreign as an Enum
常量INSTANCE
保存对 singleton Foreign
对象的引用,该对象是通过调用 enum 的默认构造器创建的。这个类Domestic
也有一个常量INSTANCE
。清单 5-12 展示了类AbstractBankAccount
如何使用这些常量而不是创建新的策略对象。
public class AbstractBankAccount implements BankAccount {
protected int acctnum;
protected int balance = 0;
protected OwnerStrategy owner = Domestic.INSTANCE;
...
public void setForeign(boolean b) {
owner = b ? Foreign.INSTANCE : Domestic.INSTANCE;
}
}
Listing 5-12Revising AbstractBankAccount to Use the Enums
虽然枚举的这种使用是合理的,但是 12 版的银行演示使用了一种不同的实现技术,其中两个常量都属于一个名为Owners
的枚举。它的代码出现在清单 5-13 中。这个枚举定义了常量Owners.DOMESTIC
和Owners.FOREIGN
,它们对应于之前的常量Domestic.INSTANCE
和Foreign.INSTANCE
。
public enum Owners implements OwnerStrategy {
DOMESTIC(false,0,"domestic"), FOREIGN(true,500,"foreign");
private boolean isforeign;
private int fee;
private String name;
private Owners(boolean isforeign, int fee, String name) {
this.isforeign = isforeign;
this.fee = fee;
this.name = name;
}
public boolean isForeign() {
return isforeign;
}
public int fee() {
return fee;
}
public String toString() {
return name;
}
}
Listing 5-13The Version 12 Owners Enum
版本 12 类AbstractBankAccount
的修订代码出现在清单 5-14 中。
public class AbstractBankAccount implements BankAccount {
protected int acctnum;
protected int balance = 0;
protected OwnerStrategy owner = Owners.DOMESTIC;
...
public void setForeign(boolean b) {
owner = (b ? Owners.FOREIGN : Owners.DOMESTIC);
}
}
Listing 5-14The Version 12 AbstractBankAccount Class
从设计的角度来看,使用具有两个常量的单个枚举大致相当于使用两个各具有一个常量的枚举。我选择单枚举方法是因为我碰巧更喜欢它的美学——拥有名为FOREIGN
和DOMESTIC
的常量比拥有两个名为INSTANCE
的常量更吸引我。
10 版本银行演示中的另一个策略界面是InputCommand
。它的实现类也是不可变的,可以使用枚举重写。清单 5-15 展示了如何为SelectCmd
重写代码;其他七个策略类都差不多。
public enum SelectCmd implements InputCommand {
INSTANCE;
public int execute(Scanner sc, Bank bank, int current) {
System.out.print("Enter acct#: ");
current = sc.nextInt();
int balance = bank.getBalance(current);
System.out.println("The balance of account " + current
+ " is " + balance);
return current;
}
public String toString() {
return "select";
}
}
Listing 5-15Rewriting SelectCmd as an Enum
版本 10 BankClient
代码唯一需要修改的是它创建输入命令数组的方式。数组现在由枚举常量组成,而不是新的InputCommand
对象。见清单 5-16 。
public class BankClient {
private Scanner scanner;
private boolean done = false;
private Bank bank;
private int current = 0;
private InputCommand[] commands = {
QuitCmd.INSTANCE,
NewCmd.INSTANCE,
SelectCmd.INSTANCE,
DepositCmd.INSTANCE,
LoanCmd.INSTANCE,
ShowCmd.INSTANCE,
InterestCmd.INSTANCE,
SetForeignCmd.INSTANCE };
...
}
Listing 5-16Rewriting BankClient to Reference Enums
为每个命令创建单独的枚举的另一种方法是创建包含所有命令的单个枚举。版本 12 的代码采用了这种方法。枚举被命名为InputCommands
,它的代码出现在清单 5-17 中。InputCommands
构造器有两个参数:toString
方法使用的字符串,以及定义其execute
方法的 lambda 表达式。常量SELECT
的代码以粗体显示,以便您可以将其与清单 5-15 进行比较。
public enum InputCommands implements InputCommand {
QUIT("quit", (sc, bank, current)->{
sc.close();
System.out.println("Goodbye!");
return -1;
}),
NEW("new", (sc, bank, current)->{
System.out.print("Enter account type(1=savings,
2=checking, 3=interest checking): ");
int type = sc.nextInt();
boolean isforeign = requestForeign(sc);
current = bank.newAccount(type, isforeign);
System.out.println("Your new account number is "
+ current);
return current;
}),
SELECT("select", (sc, bank, current)->{
System.out.print("Enter account#: ");
current = sc.nextInt();
int balance = bank.getBalance(current);
System.out.println("The balance of account " + current
+ " is " + balance);
return current;
}),
DEPOSIT("deposit", (sc, bank, current)->{
System.out.print("Enter deposit amount: ");
int amt = sc.nextInt();
bank.deposit(current, amt);
return current;
}),
LOAN("loan", (sc, bank, current)->{
System.out.print("Enter loan amount: ");
int amt = sc.nextInt();
boolean ok = bank.authorizeLoan(current, amt);
if (ok)
System.out.println("Your loan is approved");
else
System.out.println("Your loan is denied");
return current;
}),
SHOW("show", (sc, bank, current)->{
System.out.println(bank.toString());
return current;
}),
INTEREST("interest", (sc, bank, current)-> {
bank.addInterest();
return current;
}),
SET_FOREIGN("setforeign", (sc, bank, current)-> {
bank.setForeign(current, requestForeign(sc));
return current;
});
private String name;
private InputCommand cmd;
private InputCommands(String name, InputCommand cmd) {
this.name = name;
this.cmd = cmd;
}
public int execute(Scanner sc, Bank bank, int current) {
return cmd.execute(sc, bank, current);
}
public String toString() {
return name;
}
private static boolean requestForeign(Scanner sc) {
System.out.print("Enter 1 for foreign,
2 for domestic: ");
int val = sc.nextInt();
return (val == 1);
}
}
Listing 5-17The Version 12 InputCommands Enum
enum 有一个静态方法values
,它返回一个常量数组。BankClient
类可以利用这个方法。现在,BankClient
可以调用InputCommands.values()
,而不是构建清单 5-16 中所示的命令数组。见清单 5-18 。
public class BankClient {
private Scanner scanner;
private boolean done = false;
private Bank bank;
private int current = 0;
private InputCommand[] commands = InputCommands.values();
...
}
Listing 5-18The Version 12 BankClient Class
虽然使用InputCommands.values
当然很方便,但是您可能想知道单枚举设计是否是个好主意。一个问题是它违反了单一责任规则——InputCommands
枚举负责八个不同的命令,这导致一个比八个单独的枚举更大更复杂的枚举。拥有单个枚举也违反了开放/封闭规则——添加一个新命令需要修改InputCommands
,而不是创建另一个枚举。
修改枚举比修改任意代码安全得多,因为修改只涉及添加或删除一个常量,这一事实减轻了这些违规行为。也许使用单个枚举的最有说服力的理由是利用它的values
方法。如果没有它,添加新命令需要创建新的 enum 并修改创建命令列表的代码;由于该代码独立于 enum 存在,因此修改很有可能会被忽略。这种可能性看起来太危险了,不容忽视,并且倾向于单枚举设计。
静态工厂方法
回想一下本章开始时,Boolean
和Integer
类有一个方法valueOf
,它接受一个原始值,将其装箱,并返回装箱的对象。此方法隐藏了有关其返回对象的某些细节,特别是调用方不知道返回的对象是新对象还是以前创建的对象。valueOf
方法承担确定最佳行动过程的责任,这就是为什么使用它比使用构造器更可取。
这个valueOf
方法被称为静态工厂方法。工厂方法是一种工作是创建对象的方法。它封装了对象创建的细节,并且可以隐藏新构造的对象的类。它甚至可以隐藏这样一个事实,即它正在返回一个先前创建的对象,而不是一个新的对象。
Java 库包含许多其他静态工厂方法。一个例子是类Arrays
中的静态方法asList
。该方法的参数是对象引用的数组,其返回值是包含这些引用的列表。下面的代码演示了它的用法。
String[] names = {"joe", "sue", "max"};
List<String> L = Arrays.asList(names);
asList
方法返回一个包含所提供数组元素的列表,但是它没有给出其他细节。该方法不仅隐藏了创建列表的算法,还隐藏了列表的类。这种封装为工厂方法选择如何创建列表提供了相当大的灵活性。例如,方法的一个选项是创建一个新的ArrayList
对象,然后将数组的每个元素添加到其中。但是其他选择也是可能的。第七章将讨论一个使用适配器类的非常有效的解决方案。
库类ByteBuffer
提供了静态工厂方法的其他例子。一个ByteBuffer
对象表示内存的一个区域,并且有方法在该区域内的任意位置存储和检索原始值。从形式上来说,ByteBuffer
是一个抽象类,有两个子类。子类DirectByteBuffer
从操作系统的 I/O 缓冲区中分配它的空间。子类HeapByteBuffer
从 Java 虚拟机中分配它的空间。
这两个子类都没有公共构造器。构造一个ByteBuffer
对象的唯一方法是使用三种静态工厂方法中的一种。方法allocateDirect
创建一个新的直接缓冲区;方法allocate
创建一个新的、未初始化的堆缓冲区;方法wrap
根据其参数数组的内容创建一个新的堆缓冲区。
以下语句说明了这三种工厂方法的用法。第一条语句创建一个 200 字节的直接缓冲区。第二条语句创建一个 200 字节的堆缓冲区。最后两条语句基于数组变量字节创建一个堆缓冲区。
ByteBuffer bb = ByteBuffer.allocateDirect(200);
ByteBuffer bb2 = ByteBuffer.allocate(200);
byte[] bytes = new byte[200];
ByteBuffer bb3 = ByteBuffer.wrap(bytes);
这些静态工厂方法的好处是它们隐藏了ByteBuffer
子类的存在。请注意类ByteBuffer
如何充当其客户和子类之间的中介,确保其客户无法辨别任何关于ByteBuffer
对象是如何创建的以及它们属于哪个类的信息。
关于静态工厂方法的最后一个例子,考虑一下银行业的演示。版本 10 BankAccount
接口有静态工厂方法createSavingsWithDeposit
。在这种情况下,工厂方法的目的是为了方便。它使客户能够使用单一方法创建一个SavingsAccount
对象并执行初始存款。
让我们看看如何通过添加额外的静态工厂方法来改进银行演示。例如,考虑版本 10 Bank
类如何创建银行账户。清单 5-19 展示了它的newAccount
方法,该方法执行帐户创建。
public int newAccount(int type, boolean isforeign) {
int acctnum = nextacct++;
BankAccount ba;
if (type == 1)
ba = new SavingsAccount(acctnum);
else if (type == 2)
ba = new RegularChecking(acctnum);
else
ba = new InterestChecking(acctnum);
ba.setForeign(isforeign);
accounts.put(acctnum, ba);
return acctnum;
}
Listing 5-19The Version 10 newAccount Method
粗体的 if 语句是整个Bank
类中唯一知道BankAccount
子类的部分。在其他地方,代码使用BankAccount
类型的变量透明地操纵银行账户。这种情况类似于ByteBuffer
发生的情况,解决方案也类似:需要一个中介来处理对构造器的调用,从而将Bank
从BankAccount
子类中屏蔽掉。
演示版本 12 为此引入了接口AccountFactory
;它的代码出现在清单 5-20 中。该接口包含静态工厂方法createSavings
、createRegularChecking
、createInterestChecking
和createAccount
。
public interface AccountFactory {
static BankAccount createSavings(int acctnum) {
return new SavingsAccount(acctnum);
}
static BankAccount createRegularChecking(int acctnum) {
return new RegularChecking(acctnum);
}
static BankAccount createInterestChecking(int acctnum) {
return new InterestChecking(acctnum);
}
static BankAccount createAccount(int type, int acctnum) {
BankAccount ba;
if (type == 1)
ba = createSavings(acctnum);
else if (type == 2)
ba = createRegularChecking(acctnum);
else
ba = createInterestChecking(acctnum);
return ba;
}
}
Listing 5-20The Version 12 AccountFactory Interface
前三个方法隐藏了子类构造器。createAccount
方法封装了关于哪个账户类型具有哪个类型号的决定。这个决定之前已经由Bank
(如清单 5-19 所示)以及SavedBankInfo
(见清单 3-17 )做出。通过将决策转移到AccountFactory
,这些类现在可以调用createAccount
,而不需要知道账户类型是如何实现的。
例如,清单 5-21 显示了Bank
的版本 12 newAccount
方法,被修改为调用createAccount
方法。类似地修改了SavedBankInfo
类,但是这里没有显示。
public int newAccount(int type, boolean isforeign) {
int acctnum = nextacct++;
BankAccount ba =
AccountFactory.createAccount(type, acctnum);
ba.setForeign(isforeign);
accounts.put(acctnum, ba);
return acctnum;
}
Listing 5-21The Version 12 newAccount Method of Bank
从BankAccount
接口中调用静态方法createSavingsWithDeposit
,该方法创建具有指定初始余额的储蓄账户。这个方法现在可以修改为调用工厂方法而不是构造器。它的代码出现在清单 5-22 中。
public interface BankAccount extends Comparable<BankAccount> {
...
static BankAccount createSavingsWithDeposit(
int acctnum, int n) {
BankAccount ba = AccountFactory.createSavings(acctnum);
ba.deposit(n);
return ba;
}
}
Listing 5-22The Version 12 BankAccount Interface
工厂对象
AccountFactory
类极大地改进了银行演示程序,因为该演示程序现在有一个单独的地方来保存关于BankAccount
子类的知识。当然,AccountFactory
耦合到每个BankAccount
子类,这意味着对子类的任何更改都需要对AccountFactory
进行修改,从而违反了开放/封闭规则。但至少这种侵犯被限制在一个单一的,众所周知的地方。
改进这种设计是可能的。这个想法是,静态工厂方法本质上是一个命令来创建一个对象。如果你有几个相关的静态工厂方法(就像AccountFactory
一样),那么你可以通过使用第四章中的命令模式来创建一个更加面向对象的设计。
回想一下,在命令模式中,每个命令都是一个对象。要执行一个命令,首先要获得想要的命令对象,然后调用它的execute
方法。类似地,要执行一个工厂命令,首先要获得所需的工厂对象,然后调用它的create
方法。下面的代码演示了这两个步骤如何结合起来从工厂对象创建一个新的BankAccount
对象。
AccountFactory af = new SavingsFactory();
BankAccount ba = af.create(123);
变量af
保存一个类型为SavingsFactory
的工厂对象。假设SavingsFactory
的create
方法调用了SavingsAccount
构造器,变量ba
将保存一个新的SavingsAccount
对象。
银行演示的版本 13 采用了这种方法。它有三个工厂类:SavingsFactory
、RegularCheckingFactory
和InterestCheckingFactory
。每个工厂类都有方法create
,该方法调用适当的类构造器。清单 5-23 显示了SavingsFactory
的版本 13 代码,其create
方法调用SavingsAccount
构造器。其他两个工厂类的代码类似。
public class SavingsFactory implements AccountFactory {
public BankAccount create(int acctnum) {
return new SavingsAccount(acctnum);
}
}
Listing 5-23The SavingsFactory Class
工厂类以AccountFactory
为接口形成一个策略层次。清单 5-24 显示了AccountFactory
的版本 13 代码。除了新的非静态方法create
,接口还修改了它的静态方法createAccount
来使用策略类。
public interface AccountFactory {
BankAccount create(int acctnum);
static BankAccount createAccount(int type, int acctnum) {
AccountFactory af;
if (type == 1)
af = new SavingsFactory();
else if (type == 2)
af = new RegularCheckingFactory();
else
af = new InterestCheckingFactory();
return af.create(acctnum);
}
}
Listing 5-24The Version 13 AccountFactory Interface
静态工厂方法createSavings
的丢失意味着BankAccount
中的方法createSavingsWithDeposit
需要修改为使用工厂对象。清单 5-25 给出了修改后的代码。
public interface BankAccount extends Comparable<BankAccount> {
...
static BankAccount createSavingsWithDeposit(
int acctnum, int n) {
AccountFactory af = new SavingsFactory();
BankAccount ba = af.create(acctnum);
ba.deposit(n);
return ba;
}
}
Listing 5-25The Version 13 BankAccount Interface
图 5-1 显示了工厂层级的类图及其与BankAccount
层级的连接。注意,从每个工厂类到其对应的BankAccount
类有一个依赖箭头。
图 5-1
帐户工厂层次结构
缓存的工厂对象
清单 5-24 和 5-25 的代码应该有助于巩固你对工厂如何工作的理解——即创建一个对象需要两步:创建一个工厂对象,并调用它的create
方法。代码也可能留给你一个问题,为什么有人会想这样做。使用工厂对象有什么好处?
答案与工厂对象不需要与它们创建的对象同时创建这一事实有关。事实上,尽早创建工厂对象并缓存它们通常是有意义的。清单 5-26 修改了清单 5-24 来执行这个缓存。
public interface AccountFactory {
BankAccount create(int acctnum);
static AccountFactory[] factories = {
new SavingsFactory(),
new RegularCheckingFactory(),
new InterestCheckingFactory() };
static BankAccount createAccount(int type, int acctnum) {
AccountFactory af = factories[type-1];
return af.create(acctnum);
}
}
Listing 5-26Revising AccountFactory to Use Caching
注意createAccount
方法的实现。它不再需要使用 if 语句来选择创建哪种类型的帐户。相反,它可以简单地索引到工厂对象的预计算数组中。这是AccountFactory
设计上的一大突破。它不仅消除了令人讨厌的 if 语句,而且使界面非常接近满足打开/关闭规则。要添加一个新的帐户工厂,现在只需要创建一个新的工厂类,并将该类的一个条目添加到factories
数组中。
当然,与其手动缓存工厂对象,不如将它们实现为枚举常量。这是银行演示版本 14 中采用的方法。清单 5-27 给出了枚举AccountFactories
的代码,它为三个工厂类对象中的每一个创建一个常量。构造器有两个参数:一个字符串表示常量的显示值,一个 lambda 表达式给出了create
方法的代码。
public enum AccountFactories implements AccountFactory {
SAVINGS("Savings",
acctnum -> new SavingsAccount(acctnum)),
REGULAR_CHECKING("Regular checking",
acctnum -> new RegularChecking(acctnum)),
INTEREST_CHECKING("Interest checking",
acctnum -> new InterestChecking(acctnum));
private String name;
private AccountFactory af;
private AccountFactories(String name, AccountFactory af) {
this.name = name;
this.af = af;
}
public BankAccount create(int acctnum) {
return af.create(acctnum);
}
public String toString() {
return name;
}
}
Listing 5-27The Version 14 AccountFactories Enum
清单 5-28 给出了AccountFactory
的版本 14 代码。与InputCommands
枚举一样,对AccountFactories.values()
的调用使得AccountFactory
完全满足打开/关闭规则。现在,添加一个新的帐户工厂所需的唯一动作是在AccountFactories
中为它创建一个新的常量。
public interface AccountFactory {
BankAccount create(int acctnum);
static AccountFactory[] factories =
AccountFactories.values();
static BankAccount createAccount(int type, int acctnum) {
AccountFactory af = factories[type-1];
return af.create(acctnum);
}
}
Listing 5-28The Version 14 AccountFactory Class
createSavingsWithDeposit
方法的版本 14 代码出现在清单 5-29 中。
public interface BankAccount extends Comparable<BankAccount> {
...
static BankAccount createSavingsWithDeposit(
int acctnum, int n) {
AccountFactory af = AccountFactory.SAVINGS;
BankAccount ba = af.create(acctnum);
ba.deposit(n);
return ba;
}
}
Listing 5-29The Version 14 BankAccount Interface
最后一点:您可能还记得版本 13 InputCommands
enum 中的常量NEW
要求用户从帐户类型列表中进行选择。如何确保呈现给用户的类型号与AccountFactory
数组相关的类型号保持同步?
解决方案是修改NEW
,使其基于AccountFactories.values
数组的内容构建用户消息。清单 5-30 显示了相关代码。
public enum InputCommands implements InputCommand {
...
NEW("new", (sc, bank, current)->{
printMessage();
int type = sc.nextInt();
current = bank.newAccount(type);
System.out.println("Your new account number is "
+ current);
return current;
}),
...
private static String message;
static {
AccountFactory[] factories = AccountFactories.values();
message = "Enter Account Type (";
for (int i=0; i<factories.length-1; i++)
message += (i+1) + "=" + factories[i] + ", ";
message += factories.length + "="
+ factories[factories.length-1] +")";
}
private static void printMessage() {
System.out.print(message);
}
}
Listing 5-30The Version 14 InputCommands Enum
字符串的构造在一个静态块中,以确保它只出现一次。代码遍历AccountFactories
枚举中的常量。对于每个常量,它将该常量的索引(加 1)添加到消息中,后跟该常量的toString
值。
工厂模式
图 5-1 的类图说明了工厂类的一个典型用法,即工厂层次结构中的类创建属于第二个平行层次结构的对象,该层次结构称为结果层次结构。工厂层次结构中的每个类在结果层次结构中都有相应的类。在图 5-1 中,平行层级是AccountFactory
和BankAccount
。
这种设计非常普遍,以至于有了一个名字:工厂模式。图 5-2 显示了工厂模式的一般形式及其平行层次。
图 5-2
工厂模式
通常,当您有一个结果层次结构,并且希望客户机能够在不知道每个结果子类名称的情况下创建结果对象时,就需要工厂模式。工厂模式要求您创建一个并行的工厂层次结构,这样您的客户就可以通过调用适当工厂对象的create
方法来创建一个结果对象。
举个例子,考虑一下List
接口。Java 库有几个实现List
的类,每个类都有不同的用途。例如,Vector
是线程安全的;CopyOnWriteArrayList
支持安全并发访问;ArrayList
是随机存取;并且LinkedList
支持快速插入和删除。假设您希望您的客户能够基于这些特征创建List
对象,但是您不希望他们自己选择类。做出这个决定可能有几个原因:也许您不希望您的客户必须知道每个类的名称及其特征,或者您希望客户只从这四个类中选择,或者您希望随着时间的推移灵活地更改与给定特征相关联的类。
您的解决方案是使用工厂模式。你创建一个接口ListFactory
,它的工厂类是ThreadSafeFactory
、ConcurrentAccessFactory
、RandomAccessFactory
和FastUpdateFactory
。每个工厂从其关联的结果类创建一个对象。客户可以使用这些工厂对象创建一个具有特定特征的List
对象,但不知道它的实际类。类图如图 5-3 所示;注意其与图 5-2 的相似性。
图 5-3
ListFactory 策略层次结构
定制对象的工厂
工厂模式假设工厂层次结构中的类从不同的结果类创建对象。使用工厂层次结构的另一种方式是让工厂类从同一个结果类创建对象。在这种情况下,每个工厂的目的都是用特定的方式定制它的结果对象。本节分析了这种设计技术的三个例子。
对于第一个例子,考虑银行业演示的版本 11(即,从第四章结束时中止的版本)。在那个版本中,AbstractBankAccount
没有子类;所有银行账户都是AbstractBankAccount
的实例。不同类型的帐户由传递给AbstractBankAccount
构造器的TypeStrategy
对象来区分。在这里你如何使用工厂类?
即使没有AbstractBankAccount
层次,拥有AccountFactory
层次仍然是有意义的。每个工厂对象将选择合适的TypeStrategy
对象,并将其传递给AbstractBankAccount
构造器。清单 5-31 显示了SavingsFactory
类可能的样子,与版本 11 代码的区别用粗体显示。每个工厂类创建一个AbstractBankAccount
对象,用不同的类型策略定制。
public class SavingsFactory implements AccountFactory {
public BankAccount create(int acctnum) {
TypeStrategy ts = new SavingsAccount();
return new AbstractBankAccount(acctnum, ts);
}
}
Listing 5-31An Alternative SavingsFactory Class
对于定制的第二个例子,返回到版本 14 的银行演示。假设银行决定新客户开立的储蓄账户的初始余额为 10 美元。实现这个特性的一个合理方法是通过将NEW_CUSTOMER
添加到AccountFactories
枚举中作为第四个常量来创建一个“新客户”工厂。见清单 5-32 。请注意,“新客户”工厂不会创建“新客户”帐户。相反,它创建的储蓄账户被定制为具有非零初始余额。
public enum AccountFactories implements AccountFactory {
SAVINGS("Savings",
acctnum -> new SavingsAccount(acctnum)),
REGULAR_CHECKING("Regular checking",
acctnum -> new RegularChecking(acctnum)),
INTEREST_CHECKING("Interest checking",
acctnum -> new InterestChecking(acctnum)),
NEW_CUSTOMER("New Customer Savings",
acctnum -> {
BankAccount result = new SavingsAccount(acctnum);
result.deposit(1000); // $10 for free!
return result; });
...
}
Listing 5-32Adding Promotional Accounts to AccountFactories
Java 库接口ThreadFactory
提供了工厂如何用于对象定制的第三个例子。该接口定义如下:
interface ThreadFactory {
Thread newThread(Runnable r);
}
newThread
方法返回一个定制的Thread
对象。每个实现了ThreadFactory
的类都有自己的方式来定制newThread
返回的线程。举例来说,清单 5-33 定义了类PriorityThreadFactory
,它生成具有指定优先级的新线程。
class PriorityThreadFactory implements ThreadFactory {
private int priority;
public PriorityThreadFactory(int p) {
priority = p;
}
public Thread newThread(Runnable r) {
Thread t = new Thread(r);
t.setPriority(priority);
return t;
}
}
Listing 5-33The Class PriorityThreadFactory
清单 5-34 说明了PriorityThreadFactory
的用法。代码创建了两个ThreadFactory
对象:一个创建高优先级的Thread
对象,另一个创建低优先级的Thread
对象。然后,它从每个工厂创建两个线程并运行它们。
ThreadFactory important = new PriorityThreadFactory(9);
ThreadFactory menial = new PriorityThreadFactory(2);
Runnable r1 = ...; Runnable r2 = ...;
Runnable r3 = ...; Runnable r4 = ...;
Thread t1 = important.newThread(r1);
Thread t2 = important.newThread(r2);
Thread t3 = menial.newThread(r3);
Thread t4 = menial.newThread(r4);
t1.start(); t2.start(); t3.start(); t4.start();
Listing 5-34Using the PriorityThreadFactory Class
清单 5-34 展示了使用工厂类定制对象的另一个好处。给定一个工厂对象,客户端可以多次调用它的create
方法,得到的对象都将被相同地定制。(在清单 5-34 中,所有的对象都有相同的优先级。在清单 5-31 中,它们都有相同的账户类型。)你可以把每个工厂对象想象成一个 cookie cutter,每个工厂类生产不同形状的 cookie。此外,工厂对象可以从一个方法传递到另一个方法,因此工厂对象的用户可能不知道它创建了哪种形状的 cookie。
摘要
类构造器是有问题的。当一个类调用另一个类的构造器时,这两个类就结合在一起了。这种耦合降低了编写抽象和透明代码的能力。本章研究了解决这个问题的两种策略:缓存和工厂。
缓存重用对象,从而减少对构造器的需求。不可变对象是缓存的良好候选对象。如果一个类只需要固定数量的不可变对象,那么它可以在加载时创建并缓存这些对象。这样的类被称为单态。Java 枚举语法是定义单例类的首选方法。
工厂是一个封装了构造器用法的类。当一个类需要创建一个对象时,它从适当的工厂类中调用一个方法。工厂类可以是静态的,也可以是非静态的。
静态工厂类通常由多个静态方法组成,每个静态方法调用一个不同的构造器。静态工厂方法隐藏了它调用的构造器,以及返回值的类。静态方法ByteBuffer.allocate
就是一个例子,它隐藏了对HeapByteBuffer
构造器的调用。allocate
方法的调用者不知道返回值所属的ByteBuffer
子类,甚至不知道ByteBuffer
有子类。
非静态工厂类被组织成策略层次。层次结构中的每个类都实现了一个create
方法,该方法包含了创建结果对象的特定策略。当工厂层次结构中的所有类创建属于同一结果层次结构的对象时,该设计被称为工厂模式。当多个工厂类创建属于同一个类的对象时,那么工厂类被认为提供了它们的结果对象的定制。
6# 六、迭代和可迭代对象
本章解决以下问题:假设一个变量包含一个对象集合;您应该编写什么代码来检查它的元素?从技术上讲,这个问题是在问关于 iterables 和迭代的问题。集合是可迭代的,检查其元素的机制是迭代。这个问题可以重新表述为“我应该如何遍历一个 iterable?”
Java 支持遍历一个 iterable 的多种方式,可以分为两类:外部迭代,你写一个循环,检查 iterable 的每个元素;和内部迭代,其中您调用一个方法来为您执行循环。本章涵盖了与迭代的两种用法相关的编程问题,以及如何编写支持迭代的类的设计问题。
迭代程序
假设您有一个保存对象集合的变量L
。你应该写什么代码来打印它的元素?一种可能性是编写以下循环:
for (int i=0; i<L.size(); i++)
System.out.println(L.get(i));
这段代码不太好有两个原因。首先,它违反了抽象规则,因为它使用了方法get
,只有实现了List
的集合才支持该方法。其次,在某些List
实现中,它的执行效率会比其他实现低得多。特别是,ArrayList
的get
方法执行常量时间数组访问,而LinkedList
的get
方法搜索节点链。因此,如果L
执行ArrayList
,循环将以线性时间执行;如果L
执行LinkedList
,循环将以二次时间执行。
解决这两个问题的方法是为每个集合类提供专门用于迭代的方法。在 Java 中,这些方法被称为hasNext
和next
。方法hasNext
返回一个布尔值,表明是否还有未检查的元素。方法next
返回一个未检查的元素。这些方法解决了第一个问题,因为它们可以针对任何类型的集合实现,而不仅仅是列表;它们解决了第二个问题,因为每个类都可以有自己的方法实现,尽可能地提高效率。
这些方法应该放在哪里?回想一下图 2-3 中集合层次的根是接口Iterable
。所有集合都是可迭代的。这些方法应该和Iterable
有关联,但是怎么关联呢?显而易见的设计是将方法添加到Iterable
接口,这意味着每个集合对象都有自己的hasNext
和next
方法。在这种设计下,打印列表L
内容的代码如下所示:
// Warning: This code is not legal Java.
while (L.hasNext())
System.out.println(L.next());
这种设计是不令人满意的,因为它可能允许不正确的行为发生。清单 6-1 提供了一个例子。如果方法的参数列表中没有重复项,那么该方法将返回 true。代码调用 helper 方法isUnique
,如果一个指定的字符串在列表中出现一次,该方法将返回 true。想法是noDuplicates
遍历L
中的每个字符串,为每个字符串调用isUnique
,如果isUnique
返回 false,则返回 false。方法isUnique
也遍历L
,如果发现字符串s
恰好出现一次,则返回 true。
// Warning: This code is not legal Java.
// And even if it were, it wouldn’t work.
public boolean noDuplicates(List<String> L) {
while (L.hasNext()) {
String s = L.next();
if (!isUnique(L, s))
return false;
}
return true;
}
private boolean isUnique(List<String> L, String s) {
int count = 0;
while (L.hasNext())
if (L.next().equals(s))
count++;
return count == 1;
}
Listing 6-1A Terrible Way to Test a List for Duplicates
不正确的行为发生是因为两个方法同时迭代L
。第一次通过noDuplicates
循环时,代码检查 L 的第一个元素并调用isUnique
。该方法将遍历L
(从第二个元素开始),并在读取完所有的L
后返回到noDuplicates
。因此,第二次通过noDuplicates
循环时,对L.hasNext
的调用将立即返回 false,而noDuplicates
将提前退出。
这个算法正确工作的唯一方法是两个方法都可以独立地迭代 L。也就是说,遍历列表的功能必须独立于列表本身。Java 采用了这种方法,将方法hasNext
和next
移到一个名为Iterator
的独立接口中。
Iterable
接口(以及每个集合)有一个方法iterator
;每次调用iterator
都会返回一个新的Iterator
对象。例如,下面的代码打印了L
的元素:
Iterator<String> iter = L.iterator();
while (iter.hasNext())
System.out.println(iter.next());
每次需要迭代一个集合时创建一个Iterator
对象可能看起来很笨拙,但这就是 Java 将迭代与集合分开的方式。当一个方法为一个集合创建一个Iterator
对象时,该方法保证它的迭代将独立于集合的任何其他迭代。(如果在迭代过程中,其他方法碰巧修改了 iterable,Java 会抛出一个ConcurrentModificationException
,通知你迭代已经无效。)
清单 6-2 给出了NoDuplicates
的正确代码。注意,每个方法都有自己的迭代器。事实上,isUnique
在每次被调用时都会为L
创建一个新的迭代器。
public class NoDuplicates {
public boolean noDuplicates(List<String> L) {
Iterator<String> iter = L.iterator();
while (iter.hasNext()) {
String s = iter.next();
if (!isUnique(L, s))
return false;
}
return true;
}
private boolean isUnique(List<String> L, String s) {
int count = 0;
Iterator<String> iter = L.iterator();
while (iter.hasNext())
if (iter.next().equals(s))
count++;
return count == 1;
}
}
Listing 6-2A Correct Way to Check a List for Duplicates
编写迭代器类
本节探索一些实现Iterator
的类。迭代器类需要实现hasNext
和next
方法。它不需要与 iterable 关联。清单 6-3 给出了一个非常简单的名为RandomIterator
的“独立”迭代器类的例子。这个类的目的是生成任意多的随机数。它的next
方法返回另一个随机整数,它的hasNext
方法总是返回 true。
public class RandomIterator implements Iterator<Integer> {
private Random rand = new Random();
public boolean hasNext() {
return true;
}
public Integer next() {
return rand.nextInt();
}
}
Listing 6-3The RandomIterator Class
清单 6-4 中的代码测试RandomIterator
。它生成随机整数,将它们保存在哈希集中,并在出现重复时停止。然后,它打印生成的非重复整数的数量。
Iterator<Integer> iter = new RandomIterator();
Set<Integer> nums = new HashSet<>();
boolean dupNotFound = true;
while (dupNotFound)
dupNotFound = nums.add(iter.next());
System.out.println(nums.size());
Listing 6-4Using the RandomIterator Class
清单 6-5 给出了另一个迭代器类的例子,名为PrimeIterator
。这个类生成第一个N
质数,其中N
在构造器中指定。next
的代码计算下一个素数的值,并跟踪已经产生了多少个素数。一旦生成了指定数量的素数,hasNext
的代码就返回 false。清单 6-6 的代码通过打印前 20 个素数来测试这个类。
Iterator<Integer> iter = new PrimeIterator(20);
while (iter.hasNext()) {
int p = iter.next();
System.out.println(p);
}
Listing 6-6Printing the First 20 Primes
public class PrimeIterator implements Iterator<Integer> {
private int current = 1;
private int howmany;
private int count = 0;
public PrimeIterator(int howmany) {
this.howmany = howmany;
}
public boolean hasNext() {
return count < howmany;
}
public Integer next() {
current++;
while (!isPrime(current)) // Loop until
current++; // you find a prime.
count++;
return current;
}
private boolean isPrime(int n) {
for (int i=2; i*i<=n; i++)
if (n%i == 0)
return false;
return true;
}
}
Listing 6-5The PrimeIterator Class
现在假设你想创建一个类PrimeCollection
,它的对象表示某个N
的前N
个素数的集合。创建实现Collection
的类的最简单方法是扩展抽象类AbstractCollection
。为此,您需要实现它的抽象方法size
和iterator
。PrimeCollection
的代码出现在清单 6-7 中。它使用PrimeIterator
类来实现iterator
方法。
public class PrimeCollection
extends AbstractCollection<Integer> {
private int size;
public PrimeCollection(int size) {
this.size = size;
}
public int size() {
return size;
}
public Iterator<Integer> iterator() {
return new PrimeIterator(size);
}
}
Listing 6-7The PrimeCollection Class
一个PrimeCollection
对象有一个非常有趣的特性——它的素数集合不存储在对象中,也不存储在其迭代器中。相反,素数是由迭代器按需生成的。有些人发现很难理解一个Collection
对象不需要实际持有一个集合。相反,它只需要像一样,通过实现接口的方法。这就是封装的妙处。
创建实现List
的类的简单方法是扩展AbstractList
并实现它的两个抽象方法size
和get
。AbstractList
的代码实现了List
接口的其余方法。清单 3-11 中的类RangeList
就是这样一个例子。
iterator
方法是AbstractList
实现的方法之一。清单 6-8 给出了该方法的简化实现。它的代码创建并返回一个新的AbstractListIterator
对象。该对象保存对列表的引用及其在列表中的当前位置。它的next
方法调用列表的get
方法从列表中检索当前位置的元素,并递增当前位置。它的hasNext
方法调用列表的size
方法来确定何时不再有元素。
public abstract class AbstractList<T> {
public abstract T get(int n);
public abstract int size();
public Iterator<T> iterator() {
return new AbstractListIterator<T>(this);
}
... // code for the other List methods
}
class AbstractListIterator<T> implements Iterator<T> {
private int current = 0;
private AbstractList<T> list;
public AbstractListIterator(AbstractList<T> list) {
this.list = list;
}
public boolean hasNext() {
return current < list.size();
}
public T next() {
T val = list.get(current);
current++;
return val;
}
}
Listing 6-8Simplified Code for the AbstractList and AbstractListIterator Classes
请注意,iterator
的这个实现使用了get
方法,因此会表现出与本章开头的代码相同的低效行为。因此,每当您通过扩展AbstractList
创建一个类时,您必须决定用一个更有效的实现覆盖默认的iterator
实现是否有意义。
迭代器模式
图 6-1 描述了上一节提到的类和接口之间的关系。接口Iterable
、它的两个子接口和三个实现类构成了图左侧的阴影层次;Iterator
和它的三个实现类形成了右边的层次结构。
图 6-1
迭代器模式的例子
Iterable
和Iterator
层次之间的这种分离被称为迭代器模式。迭代器模式断言每个实现了Iterable
的类都应该耦合到一个实现了Iterator
的对应类。Java 集合类库是专门为满足迭代器模式而设计的。
迭代器模式的并行层次与工厂模式的并行层次非常相似,如图 5-2 所示。这种相似并非巧合。iterable 可以被认为是一个“迭代器工厂”给定一个Iterable
对象L
,每次调用L.iterator()
都会创建一个新的Iterator
对象,用L
的元素定制。
设计可迭代的类
可迭代类不必是集合;它只需要是一个对iterator
方法有意义的类。举个例子,考虑一下银行业的演示。假设您想编写程序来分析由Bank
类持有的账户。这些程序可能涉及诸如查找账户余额的分布、给定类型的账户数量等任务。这里有三种设计可供你选择。
您的第一个选择是修改Bank
,让它为每个分析任务提供一个新的方法。这个选项使得编写分析程序变得容易,因为Bank
类将完成所有的工作。问题是你必须修改Bank
类来处理每一个新的需求,这违反了开放/封闭规则。修改还会导致Bank
被专门化的方法所膨胀,违反了单一责任规则。
您的第二个设计选项是认识到Bank
已经有了一个返回其账户信息的方法,即toString
。您的每个分析程序都可以调用toString
,从返回的字符串中提取所需的信息,并根据需要进行处理。例如,假设银行的toString
方法返回清单 6-9 中所示的字符串。除了第一行,每一行都描述了一个账户。为了找到每个账户的余额,分析程序可以在每一行寻找“balance=”后面的数字。要查找外国帐户,它可以查找字符串“is foreign”。等等。
The bank has 3 accounts.
Savings account 0: balance=3333, is foreign, fee=500
Regular checking account 1: balance=6666, is domestic, fee=0
Interest checking account 2: balance=9999, is domestic, fee=0
Listing 6-9Output of the Bank’s toString Method
这种技术被称为屏幕抓取。这个术语指的是程序从网页的 HTML 内容中提取信息的情况。屏幕抓取是最后的选择。这很难执行,当toString
输出的格式改变时会中断。
第三个也是唯一一个好的选择是修改Bank
类,使其具有一个(或多个)通用方法,客户端可以使用这些方法提取信息。这里的iterator
方法是个不错的选择。它允许客户端遍历所需的帐户,而不会破坏帐户信息的封装实现。(例如,客户端将无法发现帐户存储在地图中。)
银行演示的版本 15 做了这样的修改。Bank
类现在实现了Iterable<BankAccount>
,这意味着它必须提供一个返回BankAccount
对象的iterator
方法。清单 6-10 给出了银行的相关变化。
public class Bank implements Iterable<BankAccount> {
...
public Iterator<BankAccount> iterator() {
return accounts.values().iterator();
}
}
Listing 6-10The Version 15 Bank Class
银行演示的第 15 版也有两个新类,IteratorAccountStats
和StreamAccountStats
,它们是Bank
的客户。这些类中的方法支持两个原型任务:打印和查找一些选定账户的最大余额。为了说明不同的编程技术,这些类包含了两个任务的多种方法。本章的剩余部分将研究这些类中的方法及其背后的技术。
外部迭代
迭代器是检查可迭代对象元素的基本方法。清单 6-11 展示了遍历 iterable 的基本习惯用法。本章前面的例子都使用了这个习语。
Iterable<E> c = ...
Iterator<E> iter = c.iterator();
while (iter.hasNext()) {
E e = iter.next();
... // process e
}
Listing 6-11The Basic Idiom for Using Iterators
清单 6-12 给出了来自IteratorAccountStats
类的方法printAccounts1
和maxBalance1
的代码。这两种方法都使用基本的习惯用法来遍历 iterable 类Bank
。方法printAccounts1
打印所有银行账户,maxBalance1
返回账户的最大余额。
public void printAccounts1() {
Iterator<BankAccount> iter = bank.iterator();
while (iter.hasNext()) {
BankAccount ba = iter.next();
System.out.println(ba);
}
}
public int maxBalance1() {
Iterator<BankAccount> iter = bank.iterator();
int max = 0;
while (iter.hasNext()) {
BankAccount ba = iter.next();
int balance = ba.getBalance();
if (balance > max)
max = balance;
}
return max;
}
Listing 6-12The printAccounts1 and maxBalance1 Methods
这种习惯用法如此普遍,以至于 Java 有一种专门为简化它而设计的语法。这个语法就是 for-each 循环的。例如,下面的循环相当于清单 6-11 的代码。
for (E e : c) {
... // process e
}
上述 for-each 循环中的变量 c 可以是任意的Iterable
;它不必是一个集合。清单 6-13 给出了IteratorAccountStats
的方法printAccounts2
和maxBalance2
的代码。这些方法修改了它们的早期版本,用 for-each 循环替换了显式迭代器方法。注意,这段代码明显更容易阅读和理解,主要是因为它不再显式地提到迭代器。
public void printAccounts2() {
for (BankAccount ba : bank)
System.out.println(ba);
}
public int maxBalance2() {
int max = 0;
for (BankAccount ba : bank) {
int balance = ba.getBalance();
if (balance > max)
max = balance;
}
return max;
}
Listing 6-13The Methods printAccounts2 and maxBalance2
尽管 for-each 循环简化了迭代器的使用,但它并不总是适用的。问题是 for-each 循环隐藏了对迭代器的next
方法的调用,因此没有办法控制何时调用该方法。这里有两种需要这种控制的情况。
第一种情况是寻找 iterable 中的最大元素。清单 6-14 给出了方法findMax
的代码,它重写了清单 2-9 中的方法以使用迭代器。
public BankAccount findMax(Iterable<BankAccount> bank) {
Iterator<BankAccount> iter = bank.iterator();
BankAccount max = iter.next();
while (iter.hasNext()) {
BankAccount ba = iter.next();
if (ba.compareTo(max) > 0)
max = ba;
}
return max;
}
Listing 6-14Using an Iterator to Find the Maximum Element
这段代码使用迭代器的第一个元素初始化变量max
,然后遍历剩余的元素。这种策略在 for-each 循环中是不可能的。清单 6-15 中所示的解决方案将max
初始化为空。不幸的是,每次通过循环它都必须检查max
是否为空,这是不令人满意的。
public BankAccount findMax(Iterable<BankAccount> bank) {
BankAccount max = null;
for (BankAccount ba : bank)
if (max == null || ba.compareTo(max) > 0)
max = ba;
return max;
}
Listing 6-15Using a for-each Loop to Find the Maximum Element
第二种情况是当您想要交叉两个集合的元素时。清单 6-16 展示了如何使用显式迭代器执行这项任务。我不知道用 for-each 循环重写这段代码的好方法。
Iterator<String> iter1 = c1.iterator();
Iterator<String> iter2 = c2.iterator();
Iterator<String> current = iter1;
while (current.hasNext()) {
String s = current.next();
// process s
current = (current == iter1) ? iter2 : iter1;
}
Listing 6-16Interleaving Access to Two Collections
内部迭代
上一节中的每个例子都使用了一个循环来遍历它的迭代器。这个循环以两种不同的方式执行:显式调用迭代器方法,或者使用 for-each 循环。这两种方式都是外部迭代的例子,因为在每种情况下,客户端都编写循环。
也可以不写循环就遍历 iterable 对象的元素。这种技术被称为内部迭代。比如Bank
中的方法addInterest
为客户端进行内部迭代,无形中循环通过银行的账户。
addInterest
方法是一个内部迭代器,专门用于执行单一任务。Java Iterable
接口有默认的方法forEach
,可以用于通用的内部迭代。forEach
的参数是一个实现Consumer
接口的对象。这个接口也是 Java 库的一部分,有一个 void 方法accept
,定义如下:
interface Consumer<T> {
void accept(T t);
}
accept
方法的目的是对它的参数对象执行一个动作。清单 6-17 给出了一个创建和使用Consumer
对象的例子。它的第一条语句创建了Consumer
对象,并将对它的引用保存在变量action
中。Consumer
对象的accept
方法打印它的BankAccount
参数。第二条语句获得对银行账户 123 的引用,并保存在变量x
中。第三条语句对指定的帐户执行操作。换句话说,该语句打印帐户 123。
Consumer<BankAccount> action = ba -> System.out.println(ba);
BankAccount x = bank.getAccount(123);
action.accept(x);
Listing 6-17Creating and Using a Consumer Object
iterable 的forEach
方法对 iterable 迭代器的每个对象执行一个操作;这个动作是由作为forEach
参数的Consumer
对象定义的。清单 6-18 给出了方法printAccounts3
的代码,该方法使用forEach
打印银行迭代器生成的每个元素。
public void printAccounts3() {
Consumer<BankAccount> action = ba->System.out.println(ba);
bank.forEach(action);
}
Listing 6-18The Method printAccounts3
清单 6-18 有趣的特点是没有显式循环。相反,循环是在forEach
方法内部执行的。清单 6-19 给出了Iterable
接口的简化版本,展示了forEach
方法如何执行它的循环。
public interface Iterable<T> {
Iterator<T> iterator();
default void forEach(Consumer<T> action) {
Iterator<T> iter = iterator();
while (iter.hasNext())
action.apply(iter.next());
}
}
Listing 6-19A Simplified Iterable Interface
访问者模式
传递给forEach
方法的Consumer
对象被称为访问者。当forEach
方法遇到 iterable 的每个元素时,Consumer
对象“访问”该元素。这种访问可能涉及打印元素(如在printAccounts3
),修改它,或者任何其他可以表示为Consumer
的动作。
forEach
方法的美妙之处在于,它将访问元素的代码(Consumer
对象)与迭代元素的代码分开。这种分离被称为访客模式。
演示类IteratorAccountStats
有一个方法visit1
,它概括了printAccounts3
,因此它的参数可以是任何访问者。它的代码出现在清单 6-20 中。清单 6-21 中的语句说明了它的用法。
// Print all accounts
visit1(ba -> System.out.println(ba));
// Add interest to all accounts
visit1(ba -> ba.addInterest());
// Print the balance of all domestic accounts
visit1(ba -> { if (!ba.isForeign())
System.out.println(ba.getBalance()); });
Listing 6-21Uses of the visit1 Method
public void visit1(Consumer<BankAccount> action) {
bank.forEach(action);
}
Listing 6-20The Method visit1
visit1
方法的问题在于它只适用于 void 操作——即不返回值的操作。例如,不能使用visit1
来计算最大账户余额。为了实现这个功能,访问者的定义还必须有一个计算返回值的方法。Java 的库中没有这样的访问者接口,但是创建一个很容易。见清单 6-22 。
public interface Visitor<T,R> extends Consumer<T> {
R result();
}
Listing 6-22The Visitor Interface
该接口使用附加方法result
将访问者定义为消费者。访问者有两种通用类型:类型T
是它访问的元素的类型,类型R
是结果值的类型。
清单 6-23 给出了访问者类MaxBalanceVisitor
的代码,其对象计算他们访问的账户的最大余额。变量max
保存目前为止遇到的最大余额。accept
方法检查当前访问账户的余额,并在适当的时候更新max
。result
方法返回max
的最终值。
public class MaxBalanceVisitor
implements Visitor<BankAccount,Integer> {
private int max = 0;
public void accept(BankAccount ba) {
int bal = ba.getBalance();
if (bal > max)
max = bal;
}
public Integer result() {
return max;
}
}
Listing 6-23The Class MaxBalanceVisitor
IteratorAccountStats
包含两个方法,使用访问者来查找银行账户的最大余额。它们的代码出现在清单 6-24 中。方法maxBalance3a
创建了一个MaxBalanceVisitor
对象。方法maxBalance3b
内联定义了等价的 visitor 对象。由于Visitor
接口有两个非默认方法,maxBalance3b
不能使用 lambda 表达式定义它的访问者;相反,它必须使用匿名内部类。注意,Visitor
对象可以合法地传递给forEach
方法,因为Visitor
是Consumer
的子类型。
public int maxBalance3a() {
Visitor<BankAccount,Integer> v = new MaxBalanceVisitor();
bank.forEach(v);
return v.result();
}
public int maxBalance3b() {
Visitor<BankAccount,Integer> v =
new Visitor<BankAccount,Integer>() {
private int max = 0;
public void accept(BankAccount ba) {
int bal = ba.getBalance();
if (bal > max)
max = bal;
}
public Integer result() {
return max;
}
};
bank.forEach(v);
return v.result();
}
Listing 6-24The maxBalance3a and maxBalance3b Methods
清单 6-25 给出了方法visit2
的代码,它一般化了visit1
,因此它的参数是一个Visitor
对象而不是一个Consumer
对象。清单 6-26 给出了方法maxBalance3c
的代码,该方法使用visit2
来查找最大账户余额。
public int maxBalance3c() {
return visit2(new MaxBalanceVisitor());
}
Listing 6-26The maxBalance3c Method
public <R> R visit2(Visitor<BankAccount, R> action) {
bank.forEach(action);
return action.result();
}
Listing 6-25The visit2 Method
述语
前一节介绍了遍历银行迭代器的几种方法。每种方法都访问所有的账户,打印它们或者找到它们的最大余额。
假设现在您想编写代码只访问一些帐户;例如,假设您要打印国内帐户。发现这些方法都没有用,这可能是一个不受欢迎的惊喜。相反,你需要编写一个新的方法,比如清单 6-27 中所示的方法。
public void printDomesticAccounts() {
for (BankAccount ba : bank)
if (!ba.isForeign())
System.out.println(ba);
}
Listing 6-27Printing the Domestic Accounts
注意,这个方法包含了与 printAccounts2 相同的大部分代码,违反了不要重复自己的规则。此外,每当您对帐户的另一个子集感兴趣时,您将需要编写一个新方法。这种情况是不可接受的。
解决方案是重写printAccounts
和maxBalance
方法,使其具有指定所需帐户子集的参数。Java 库为此提供了接口Predicate
。它有一个方法test
,其返回值表明指定的对象是否满足谓词。Predicate
界面定义如下:
interface Predicate<T> {
boolean test(T t);
}
由于接口是函数式的(也就是说,它只有一个方法),实现Predicate
的类可以由 lambda 表达式定义。例如,考虑清单 6-28 。它的第一条语句创建了一个谓词,指定余额大于$100 的帐户。它的第二条语句获得了对帐户 123 的引用。它的第三条语句使用谓词来确定该帐户的余额是否大于$100,如果大于,就打印出来。
Predicate<BankAccount> pred = ba -> ba.getBalance() > 10000;
BankAccount x = bank.getAccount(123);
if (pred.test(x))
System.out.println(x);
Listing 6-28Creating and Using a Predicate
清单 6-29 给出了InteratorAccountStats
的方法printAccounts4
和maxBalance4
的代码。这些方法将一个任意谓词作为它们的参数,并使用该谓词来限制它们访问的银行帐户。
public void printAccounts4(Predicate<BankAccount> pred) {
for (BankAccount ba : bank)
if (pred.test(ba))
System.out.println(ba);
}
public int maxBalance4(Predicate<BankAccount> pred) {
int max = 0;
for (BankAccount ba : bank) {
if (pred.test(ba)) {
int balance = ba.getBalance();
if (balance > max)
max = balance;
}
}
return max;
}
Listing 6-29The printAccounts4 and maxBalance4 Methods
谓词也可以嵌入到访问者模式中。方法printAccounts5
创建一个访问每个账户的Consumer
对象。如果帐户满足谓词,则打印帐户。见清单 6-30 。
public void printAccounts5(Predicate<BankAccount> pred) {
Consumer<BankAccount> action =
ba -> { if (pred.test(ba))
System.out.println(ba);
};
bank.forEach(action);
}
Listing 6-30The printAccounts5 Method
清单 6-31 中的语句说明了printAccounts5
的三种用法。他们打印余额大于 100 美元的帐户、国内帐户和所有帐户。
Predicate<BankAccount> p = ba -> ba.getBalance() > 10000;
printAccounts5(p);
printAccounts5(ba->!ba.isForeign());
printAccounts5(ba->true);
Listing 6-31Using the printAccounts5 Method
清单 6-32 给出了方法maxBalance5
的代码,该方法创建了一个Consumer
对象,该对象包含了它的参数谓词和它的访问者。
public int maxBalance5(Predicate<BankAccount> pred) {
Visitor<BankAccount,Integer> r = new MaxBalanceVisitor();
Consumer<BankAccount> action =
ba -> {if (pred.test(ba))
r.accept(ba);};
bank.forEach(action);
return r.result();
}
Listing 6-32The maxBalance5 Method
以下语句显示了如何使用maxBalance5
返回国内账户的最大余额。
int max = maxBalance5(ba->!ba.isForeign());
这种将谓词与消费者结合起来的能力是可以推广的。方法visit3
有两个参数:一个谓词和一个消费者。方法visit4
的参数是一个谓词和一个访问者。每个方法都访问那些满足谓词的元素。两种方法的代码都出现在清单 6-33 中。
public void visit3(Predicate<BankAccount> pred,
Consumer<BankAccount> action) {
bank.forEach(ba -> {if (pred.test(ba))
action.accept(ba);});
}
public <R> R visit4(Predicate<BankAccount> pred,
Visitor<BankAccount, R> action) {
bank.forEach(ba -> {if (pred.test(ba))
action.accept(ba);});
return action.result();
}
Listing 6-33The visit3 and visit4 Methods
清单 6-34 中的代码演示了visit3
和visit4
的用法。第一个报表打印余额超过$50 的所有国内帐户的余额。第二条语句将国内账户的最大余额分配给变量max
。
visit3(ba->(!ba.isForeign() && ba.getBalance()>5000),
ba->System.out.println(ba));
int max = visit4(ba->!ba.isForeign(),
new MaxBalanceVisitor());
Listing 6-34Using the visit3 and visit4 Methods
收集流
本节研究 Java 库中的Stream
接口。一个Stream
对象类似于一个迭代器,因为它的方法允许你遍历一组元素。不同之处在于Stream
提供了额外的方法来简化内部迭代的使用。
清单 6-35 中显示了Stream
及其六个方法的代码。iterator
方法将流转换成迭代器,这样客户端可以使用hasNext
和next
方法进行外部迭代。这种方法不常用,只在极少数不可能进行内部迭代的情况下存在。
interface Stream<T> {
Iterator<T> iterator();
void forEach(Consumer<T> cons);
Stream<T> filter(Predicate<T> p);
<R> Stream<R> map(Function<T,R> f);
T reduce(T id, BinaryOperator<T> op);
boolean anyMatch(Predicate<T> p);
...
}
Listing 6-35The Stream Interface
其他流方法支持内部迭代。forEach
方法与Iterator
的forEach
方法相同。方法filter
、map
、reduce
和anyMatch
将很快被讨论。
实现Stream
的对象被称为集合流。集合流的元素来自一个集合或者可以被看作一个集合的东西(比如数组)。收集流与第三章中讨论的字节流没有任何关系。
Collection
接口包含方法stream
,该方法创建一个Stream
对象,其元素是集合的元素。这是获取集合流的最常见方式。一个非集合类(比如Bank
)也可以实现stream
方法。通常,这样一个类的stream
方法调用它的一个集合的stream
方法。例如,15 版银行演示中的Bank
类实现了方法stream
。清单 6-36 给出了相关代码。
public class Bank implements Iterable<BankAccount> {
private Map<Integer,BankAccount> accounts;
...
public Stream<BankAccount> stream() {
return accounts.values().stream();
}
}
Listing 6-36The version 15 Bank Class
版本 15 银行演示中的类StreamAcctStats
演示了几种Stream
方法的使用。printAccounts6
的代码出现在清单 6-37 中。它使用Stream
方法filter
和forEach
打印满足给定谓词的银行账户。
public void printAccounts6(Predicate<BankAccount> pred) {
Stream<BankAccount> s1 = bank.stream();
Stream<BankAccount> s2 = s1.filter(pred);
s2.forEach(ba->System.out.println(ba));
}
Listing 6-37The printAccounts6 Method
forEach
方法的行为与Iterable
中的相应方法相同。filter
方法将一个流转换成另一个流。它将一个谓词作为参数,并返回一个包含满足该谓词的元素的新流。在清单 6-37 中,流s2
包含来自s1
的满足谓词的账户。请注意filter
方法如何使用内部迭代来完成原本需要循环和 if 语句来完成的工作。
因为 Java 语法允许组合方法调用,所以可以重写printAccounts6
以便由stream
方法的输出调用filter
方法。参见清单 6-38 。
public void printAccounts6(Predicate<BankAccount> pred) {
Stream<BankAccount> s = bank.stream().filter(pred);
s.forEach(ba->System.out.println(ba));
}
Listing 6-38A Revised Version of the Method printAccounts6
事实上,您甚至可以重写printAccounts6
,这样所有的方法调用都出现在一条语句中。在这种情况下,你不需要变量Stream``s
。参见清单 6-39 。
public void printAccounts6(Predicate<BankAccount> pred) {
bank.stream().filter(pred).forEach(
ba->System.out.println(ba));
}
Listing 6-39Another Revsion of printAccounts6
这段代码可读性不是特别好。然而,如果重写它,使每个方法调用都在不同的行上,它就变得非常易读。这就是方法printAccounts7
的作用。它的代码出现在清单 6-40 中。
public void printAccounts7(Predicate<BankAccount> pred) {
bank.stream()
.filter(pred)
.forEach(ba->System.out.println(ba));
}
Listing 6-40The Method printAccounts7
这种编程风格叫做流畅。一个流畅的表达式由几个组合的方法调用组成。您可以将每个方法调用视为一个对象到另一个对象的转换。例如,清单 6-41 给出了一个流畅的报表,打印出余额在 10 美元到 20 美元之间的银行账户。
bank.stream()
.filter(ba -> ba.getBalance() >= 1000)
.filter(ba -> ba.getBalance() <= 2000)
.forEach(ba->System.out.println(ba));
Listing 6-41A Fluent Statement
filter
方法将一个流转换成包含原始元素子集的另一个流。另一种形式的转换由方法map
产生。map 的参数是一个实现Function
接口的对象。这个接口是 Java 库的一部分,有一个方法apply
,定义如下:
interface Function<T,R> {
R apply(T t);
...
}
apply
方法将类型为T
的对象转换为类型为R
的对象。map
方法为输入流中的每个元素调用apply
,并返回由转换后的元素组成的流。您可以使用 lambda 表达式来创建Function
对象。例如,考虑下面的语句。lambda 表达式将一个BankAccount
对象转换成一个表示其余额的整数。因此,map
方法返回一个包含每个账户余额的流。
Stream<Integer> balances = bank.stream()
.map(ba -> ba.getBalance());
能够从流中构造一个“返回值”也是有用的。这个操作被称为减少流。为此,Stream
接口具有方法reduce
。例如,清单 6-42 给出了StreamAcctStats
的maxBalance4
方法的代码。
public int maxBalance4(Predicate<BankAccount> pred) {
return bank.stream()
.filter(pred)
.map(ba->ba.getBalance())
.reduce(0, (x,y)->Math.max(x,y));
}
Listing 6-42The Method maxBalance4
reduce
方法有两个参数。第一个参数是缩减的初始值。第二个参数是归约方法,将两个值归约为一个值。reduce
方法为流的每个元素重复调用它的 reduction 方法。其算法如清单 6-43 所示,其中x
为归约初始值,r
为归约方法。
1\. Set currentval = x.
2\. For each element e in the stream:
currentval = r(currentval, e).
3\. Return currentval.
Listing 6-43The Reduction Algorithm
Stream
接口也有 reduction 方法,用于搜索匹配给定谓词的元素。这些方法中的三种是allMatch
、anyMatch
和findFirst
。清单 6-44 的代码使用anyMatch
来确定是否有一个账户的余额大于 1000 美元。
boolean result =
bank.stream()
.anyMatch(ba->ba.getBalance() > 100000);
Listing 6-44Using the anyMatch Method
收集流是 map-reduce 程序的构建模块。Map-reduce 编程是处理大数据应用程序的一种有效、常用的方法。Map-reduce 程序的结构如清单 6-45 所示。
1\. Obtain an initial collection stream.
2\. Transform the stream, filtering and mapping its contents until you get a stream containing the values you care about.
3\. Reduce that stream to get the answer you want.
Listing 6-45The Structure of a Map-Reduce Program
Map-reduce 编程有两个优点。首先,它允许你把你的问题分成一系列小的转换。人们发现这种编码风格易于编写,易于调试。请注意,map-reduce 代码没有赋值语句,也没有控制结构。
第二个优点是,您可以轻松地将代码更改为并行运行。每个集合都有一个方法parallelStream
和stream
。给定一个集合,如果使用parallelStream
方法创建流,那么产生的流将并行执行。就这样!Java parallelSteam
方法在幕后完成所有的艰苦工作。
摘要
一个迭代器生成一个元素序列。它有两种方法next
和hasNext
。方法next
返回迭代器中的下一个元素,而hasNext
指示迭代器是否还有剩余的元素。一个可迭代的是一个有相关迭代器的对象。集合是可迭代的最常见的例子。
迭代器模式将 iterable 和 iterator 类组织成独立的并行层次结构,每个 iterable 类都有一个对应的 iterator 类。Java 库包含接口Iterable
和Iterator
正是为了这个目的。Iterable
声明了方法iterator
,该方法返回与该 iterable 关联的Iterator
对象。
遍历可迭代对象L
的最基本方法是使用下面的基本习惯用法:
Iterator<T> iter = L.iterator();
while (iter.hasNext()) {
T t = iter.next();
process(t); // some code that uses t
}
Java 对这种习惯用法有一种特殊的语法,叫做 for-each 循环。使用下面的 for-each 循环可以更简洁地编写上述代码:
for (T t : L) {
process(t);
}
显式循环遍历迭代器的元素称为外部迭代。一个封装了循环的方法(比如银行的addInterest
方法)据说执行内部迭代。Java Iterable
接口有一个通用的内部迭代方法,叫做forEach
。forEach
的参数是一个指定迭代循环主体的Consumer
对象。使用forEach
,前面的代码可以写成如下:
L.forEach(t->process(t));
Consumer
对象被说成访问iterable 的每个元素。forEach
方法将访问者的规范与循环的规范分开。这种分离被称为访客模式。
收集流是简化访问者模式的一种方式。您可以使用集合流方法将迭代表达为一系列小的转换,而不是通过具有复杂主体的单个访问者来表达迭代。每个转换本身就是一个访问者,执行一个有限的操作,比如一个过滤器或者一个地图。这项技术是 map-reduce 编程的基础。其思想是,编写几个小的转换比编写一个大的转换要简单得多,更紧凑,也更不容易出错。
然而,收集流的使用提出了效率问题。如果流转换是顺序执行的,每一个都通过自己的迭代循环,那么 map-reduce 程序将比传统程序花费更多的时间,传统程序通过元素进行单次迭代。收集流只有在它们的转换可以以某种方式合并时才有用。这样做的技术很有趣,将在第八章中讨论。
七、适配器
接下来的两章研究了一种叫做包装的设计技术。包装表示两个类之间的密切关系,称为包装器类和包装器类。包装类的唯一目的是修改或增强其包装类的功能。包装完全符合开放/封闭设计规则:如果你需要一个行为稍有不同的类,那么不要修改它。相反,创建一个包装它的新类。
本章涵盖了适配器的概念,适配器是一个包装器,它改变它所包装的类的功能。本章提供了几个例子来说明适配器类的广泛适用性。特别感兴趣的是改编第三章的字节流的 Java 库类。这些类使得简单地通过修改实现字节流的现有类来读写字符流、原始值或对象成为可能。
重用的继承
假设你需要写一个类。有一个现有的类,其方法类似于您需要的方法。因此,您决定将您的类定义为这个现有类的子类,这样您就可以继承这些方法,从而简化您的代码编写任务。
这听起来是个好主意,对吧?不幸的是,事实并非如此。事实上,这是一个非常糟糕的主意。正如在第三章中所讨论的,创建子类的唯一好理由是因为子类-超类关系满足 Liskov 替换原则——也就是说,如果子类对象可以用来代替超类对象。继承代码的可能性在决策中不起作用。
然而,很难抗拒仅仅为了继承代码而创建子类的诱惑。面向对象软件的历史充满了这样的类。这种设计技术甚至还有一个名字:为重用而继承。
在面向对象编程的早期,面向重用的继承被吹捧为面向对象的一大优点。现在我们明白,这种想法是完全错误的。Java 库类Stack
为出现的问题提供了一个很好的例子。
Stack
扩展了 Java 库类Vector
,实现了List
。扩展Vector
的好处是堆栈方法empty
、push
和pop
变得非常容易实现。清单 7-1 给出了Stack
源代码的简化版本。
public class Stack<E> extends Vector<E> {
public boolean empty() {
return size() == 0;
}
public void push(E item) {
add(item);
}
public E pop() {
return remove(size()-1);
}
}
Listing 7-1Simplified Code for the Stack Class
这段代码利用了从Vector
继承的size
、add
和remove
方法。毫无疑问,Stack
的设计者很高兴他们可以使用Vector
方法来“免费”,而不用为它们写任何代码。这是一个继承重用的完美例子。
然而,这种设计有明显的问题。扩展Vector
的决定违反了 Liskov 替换原则,因为堆栈的行为一点也不像列表。例如,栈只允许你查看它的顶部元素,而列表允许你查看(和修改)每个元素。
实际上,问题是客户端可以以非类似堆栈的方式使用堆栈。举个简单的例子,下面代码的最后一条语句修改了堆栈的底部:
Stack<String> s = new Stack<>();
s.push("abc");
s.push("xyz");
s.set(0,"glorp");
换句话说,Stack
类没有被充分封装。客户端可以利用它是按照Vector
实现的这一事实。
Stack
类是第一个 Java 版本的一部分。从那以后,Java 开发社区承认这种设计是一个错误。事实上,该类的当前文档建议不要使用它。
封装器
好消息是,可以编写Stack
来利用Vector
而不是它的子类。清单 7-2 展示了这种技术:Stack
持有一个Vector
类型的变量,并使用这个变量实现它的empty
、push
和pop
方法。因为这个变量是Stack
的私有变量,所以其他类不能访问它,这确保了Stack
变量不能调用Vector
方法。
public class Stack<E> {
private Vector<E> v = new Vector<>();
public boolean empty() {
return v.size() == 0;
}
public void push(E item) {
v.add(item);
}
public E pop() {
return v.remove(v.size()-1);
}
Listing 7-2Using a Wrapper to Implement Stack
这种实现技术被称为包装。包装是依赖关系的一种具体应用,其中类C
通过对类D
的依赖来实现其方法,调用D
的方法来完成大部分(或全部)工作。类D
被称为包装器C
的后备库。例如在清单 7-2 中,Stack
包装了Vector
,因为它依赖于Vector
,并通过调用Vector
方法来实现其方法。
包装是一项非常有用的技术。如果一个设计包含了重用的继承,那么它总是可以被重新设计来使用包装。经验表明,基于包装器的设计总是更好。
适配器模式
包装类通常被用作适配器。术语“适配器”类似于现实生活中的适配器。例如,假设您想将三脚真空吸尘器插入两脚电源插座。解决这个僵局的一个方法是购买一个新的带有两个插座的真空吸尘器。另一种方法是重新连接插座,使其接受三个插脚。这两种选择都是昂贵且不切实际的。
第三个也是更好的选择是购买一端有两个插脚、另一端有三个插脚的适配器。设备插入适配器的一端,适配器的另一端插入插座。适配器管理其两端之间的电力传输。
软件也存在类似的情况。假设您的代码需要一个具有特定接口的类,但是现有的最佳类实现了一个稍微不同的接口。你该怎么办?你有同样的三个选择。
您的前两个选择是修改您的代码,使其使用现有的接口,或者修改现有类的代码,使其实现所需的接口。与电气适配器的情况一样,这些选项可能既昂贵又不切实际。此外,它们甚至不可能实现。例如,不允许修改 Java 库中的类。
更简单、更好的解决方案是编写一个适配器类。适配器类包装现有的类,并使用它来实现所需的接口。这个解决方案被称为适配器模式。其类图如图 7-1 所示。
图 7-1
适配器模式
清单 7-2 中的Stack
类是适配器模式的一个例子。现有的类是Vector
。所需的接口由方法{ push
、pop
、empty
组成。适配器类是Stack
。图 7-2 显示了它们的关系。在这种情况下,没有正式的 Java 接口,所以图中使用“StackAPI”来表示所需的方法。
图 7-2
作为适配器类堆栈
适配器模式的另一个例子出现在 Java 库类Arrays
中。回想一下第五章中的内容,Arrays
有一个静态工厂方法asList
,它返回一个包含给定数组内容的列表。下面的代码说明了它的用法:
String[] a = {"a", "b", "c", "d"};
List<String> L = Arrays.asList(a);
实现这个方法的一种方式是创建一个新的ArrayList
对象,向它添加数组元素,然后返回它。这个想法不太好,因为复制大数组的元素效率很低。
一个更好的想法是使用适配器模式。由asList
方法返回的对象将属于一个适配器类,该类包装数组并实现List
方法。清单 7-3 中的代码使用这种思想实现了Arrays
类。适配器类被称为ArrayAsList
。
public class Arrays {
...
public static <T> List<T> asList(T[] vals) {
return new ArrayAsList<T>(vals);
}
}
class ArrayAsList<T> extends AbstractList<T> {
private T[] data;
public ArrayAsList(T[] data) {
this.data = data;
}
public int size() {
return data.length;
}
public T get(int i) {
return data[i];
}
public T set(int i, T val) {
T oldval = data[i];
data[i] = val;
return oldval;
}
}
Listing 7-3A Simplified Implementation of the Arrays Class
asList
方法只是从它的ArrayAsList
适配器类中创建并返回一个对象。ArrayAsList
扩展 Java 库类AbstractList
,实现方法size
、get
和set
。请注意,代码不会复制数组,而是按需访问数组元素。设计优雅而高效。图 7-3 给出了显示ArrayAsList
如何适应适配器模式的类图。
图 7-3
适配器类数组列表
适配器的另一个例子是第六章中的类RandomIterator
,它的代码出现在清单 6-3 中。该类包装了一个类型为Random
的对象,并用它来实现Iterator
接口。
文本流
第三章介绍了抽象类InputStream
,它的子类能够从文件、管道和其他输入源读取字节;它还引入了OutputStream
,它有类似的子类用于写字节。由这些类管理的字节序列被称为字节流(与第六章的收集流完全无关)。本节涉及字符流,称为文本流。
字符流层次由抽象类Reader
和Writer
领导。这些课程与InputStream
和OutputStream
非常相似。它们的方法是相似的,主要区别在于Reader
和Writer
的read
和write
方法操作字符而不是字节。它们的子类也是类似的。例如,类FileReader
和PipedReader
平行于FileInputStream
和PipedInputStream
。
例如,清单 7-4 给出了一个程序的代码,该程序读取文本文件“mobydick.txt”并将它的前 500 个字符写入文件“shortmoby.txt”
public class FilePrefix {
public static void main(String[] args) throws IOException {
try (Reader r = new FileReader("mobydick.txt");
Writer w = new FileWriter("shortmoby.txt")) {
for (int i=0; i<500; i++) {
int x = r.read();
if (x < 0)
break;
char c = (char) x;
w.write(c);
}
}
}
}
Listing 7-4Reading and Writing a Text File
从设计的角度来看,关于文本流最有趣的问题是各种Reader
和Writer
类是如何实现的。事实证明,适配器起着很大的作用,这将在下面的小节中讨论。
适配器输出 StreamWriter
假设要求你实现FileWriter
类;你会怎么做?您的代码需要解决两个问题。首先,它需要管理文件——打开它,向它写入值,然后关闭它。其次,您的代码需要将每个输出字符转换成一个或多个字节,因为文件理解字节而不是字符。您可以通过查看FileOutputStream
的代码来处理第一个问题。它已经处理了那个问题,所以你可以复制相关的代码。您可以通过使用 Java 库中的类CharEncoder
来处理第二个问题,我们很快就会讨论这种方法。所以一切似乎都在控制之中。但是在你继续下去之前,你应该停下来重读这一段。提议的设计好吗?
答案是否定的。从FileOutputStream
复制代码违反了不要重复你自己的设计规则,并且是一个糟糕的主意。更好的设计是使用适配器类来利用现有的实现。换句话说,FileWriter
应该是一个适配FileOutputStream
并实现Writer
的类。
事实上,你可以做得更好。注意,FileWriter
唯一真正的职责是将每个字符编码成字节,并将这些字节写入其包装的输出流。这段代码适用于任何输出流,而不仅仅是FileOutputStream
。换句话说,更通用的解决方案是为OutputStream
编写一个适配器类,而不是为FileOutputStream
编写一个适配器类。
Java 库正好提供了这样一个适配器类,叫做OutputStreamWriter
。这个类包装了一个具有现有功能的对象(即OutputStream
,它具有写字节的能力),并使用它来实现所需的接口(即Writer
,它赋予您写字符的能力)。
OutputStreamWriter
适配器的用处在于它可以将任何输出流转换成一个写入器。特别是可以用来写FileWriter
。以下两个语句是等效的:
Writer fw = new FileWriter(f);
Writer fw = new OutputStreamWriter(new FileOutputStream(f));
换句话说,FileWriter
是一个便利类。它可以被实现为OutputStreamWriter
的子类,其构造器创建被包装的FileOutputStream
对象。它的代码看起来有点像清单 7-5 。
public class FileWriter extends OutputStreamWriter {
public FileWriter(String f) throws IOException {
super(new FileOutputStream(f));
}
}
Listing 7-5The Code for the FileWriter Class
图 7-4 中的类图显示了Writer
、OutputStream
、FileWriter
类之间的关系,以及适配器类OutputStreamWriter
所起的关键作用。
图 7-4
将 OutputStreamWriter 作为适配器
既然OutputStreamWriter
的目的已经明确,是时候考虑它的实现了。清单 7-6 中显示了代码的简化版本。该类扩展了抽象类Writer
,因此需要实现三个抽象方法:close
、flush
和 3-arg write
方法。OutputStreamWriter
使用其包装的OutputStream
对象实现这三种方法。close
和flush
的实现只需调用相应的OutputStream
方法。write
方法编码指定的字符,将编码的字节放入字节缓冲区,并将每个字节写入包装的输出流。
public class OutputStreamWriter extends Writer {
private CharsetEncoder encoder;
private OutputStream os;
public OutputStreamWriter(OutputStream os,
String charsetname) throws IOException {
this.os = os;
encoder = Charset.forName(charsetname).newEncoder();
}
public OutputStreamWriter(OutputStream os)
throws IOException {
this(os, Charset.defaultCharset().name());
}
public void write(char cbuf[], int offset, int len)
throws IOException {
CharBuffer cb = CharBuffer.wrap(cbuf, offset, len);
ByteBuffer bb = encoder.encode(cb);
for (int i=0; i<bb.limit(); i++)
os.write(bb.get(i));
}
public void close() throws IOException {
os.close();
}
public void flush() throws IOException {
os.flush();
}
}
Listing 7-6A Simplified OutputStreamWriter Class
这个类的复杂性源于这样一个事实,即有许多编码字符的方法。例如,Java 使用 16 位 Unicode 进行内存中字符编码,这需要两个字节来存储大多数字符。(有些字符需要四个字节,这对于 Java 来说相当复杂,但幸运的是这与本讨论无关。)但是,16 位 Unicode 不一定是编码文件的最佳方式。许多文本编辑器使用像 ASCII 这样的编码,它假定一个较小的字符集,每个字符只需要一个字节。读写文件的 Java 程序需要能够与多种字符编码进行交互。
Java 库有一个类Charset
,它的对象表示字符编码。该类支持几种标准编码,每种编码都有一个名称。例如,ASCII、8 位 Unicode 和 16 位 Unicode 的编码被命名为“US-ASCII”、“UTF-8”和“UTF-16”它的forName
方法是一个静态工厂方法,返回与指定名称对应的Charset
对象。OutputStreamWriter
类有两个构造器。第一个构造器有一个指定所需字符集名称的参数。第二个构造器使用预先确定的默认字符集。
The Charset
方法newEncoder
返回一个CharsetEncoder
对象。CharsetEncoder
有方法encode
,它执行编码。encode
的参数是一个CharBuffer
对象。一个CharBuffer
类似于一个ByteBuffer
,除了它使用一个底层的字符数组而不是字节。encode
方法对这些字符进行编码,并将它们的编码字节放入一个ByteBuffer
对象中,然后可以将该对象的字节写入输出流。
适配器输入流阅读器
Reader
和InputStream
类之间的对应类似于Writer
和OutputStream
类之间的对应。特别是,Java 库包含了包装InputStream
和扩展Reader
的适配器类InputStreamReader
。FileReader
是一个扩展了InputStreamReader
的便利类。FileReader
代码类似于FileWriter
,出现在清单 7-7 中。
public class FileReader extends InputStreamReader {
public FileReader(String f) throws IOException {
super(new FileInputStream(f));
}
}
Listing 7-7The FileReader Class
清单 7-8 中显示了InputStreamReader
适配器类的代码。它比OutputStreamWriter
更复杂,因为解码字节比编码字符更棘手。问题是,某些编码中的字符不需要编码成相同数量的字节,这意味着您无法知道要读取多少字节来解码下一个字符。InputStreamReader
类通过缓冲输入流解决了这个问题。它提前读取一些字节,并将这些字节存储在一个ByteBuffer
对象中。read
方法从那个缓冲区获取输入。
read
方法通过调用类CharDecoder
的方法decode
来执行从字节到字符的转换。它提供给decode
的两个参数是输入字节缓冲区和输出字符缓冲区。decode
方法从字节缓冲区读取字节,并将解码后的字符放入字符缓冲区。当字符缓冲区已满或字节缓冲区为空时,它就会停止。char 缓冲区已满的情况称为溢出。在这种情况下,read
方法可以停止,为下一次调用read
保留剩余的输入字节。字节缓冲器为空的情况称为下溢。在这种情况下,read
方法必须重新填充字节缓冲区并再次调用decode
,这样它就可以填充 char 缓冲区的剩余部分。
public class InputStreamReader extends Reader {
public static int BUFF_SIZE = 10;
private ByteBuffer bb = ByteBuffer.allocate(BUFF_SIZE);
private InputStream is;
private CharsetDecoder decoder;
private boolean noMoreInput;
public InputStreamReader(InputStream is,
String charsetname) throws IOException {
this.is = is;
decoder = Charset.forName(charsetname).newDecoder();
bb.position(bb.limit()); //indicates an empty buffer
noMoreInput = fillByteBuffer();
}
public InputStreamReader(InputStream is)
throws IOException {
this(is, Charset.defaultCharset().name());
}
public int read(char cbuf[], int offset, int len)
throws IOException {
int howmany = len;
while (true) {
CharBuffer cb = CharBuffer.wrap(cbuf, offset, len);
CoderResult result = decoder.decode(bb, cb,
noMoreInput);
if (result == CoderResult.OVERFLOW)
return howmany;
else if (result == CoderResult.UNDERFLOW
&& noMoreInput)
return (cb.position() > 0) ? cb.position() : -1;
else if (result == CoderResult.UNDERFLOW) {
// Get more bytes and repeat the loop
// to fill the remainder of the char buffer.
noMoreInput = fillByteBuffer();
offset = cb.position();
len = howmany - cb.position();
}
else
result.throwException();
}
}
public void close() throws IOException {
is.close();
}
private boolean fillByteBuffer() throws IOException {
bb.compact(); //move leftover bytes to the front
int pos = bb.position();
int amtToRead = bb.capacity() - pos;
int result = is.read(bb.array(), pos, amtToRead);
int amtActuallyRead = (result < 0) ? 0 : result;
int newlimit = pos + amtActuallyRead;
bb.limit(newlimit);
bb.position(0); //indicates a full buffer
return (amtActuallyRead < amtToRead);
}
}
Listing 7-8A Simplified InputStreamReader Class
适配器字符串读取器
类StringReader
是 Java 库中文本流适配器的另一个例子。这个类的任务是从一个字符串创建一个阅读器。每次调用它的read
方法都会返回字符串中的下一个字符。清单 7-9 中显示了其代码的简化版本。
public class StringReader extends Reader {
private String s;
private int pos = 0;
public StringReader(String s) throws IOException {
this.s = s;
}
public int read(char[] cbuf, int off, int len)
throws IOException {
if (pos >= s.length())
return -1; // end of stream
int count=0;
while (count<len && pos<s.length()) {
cbuf[off+count] = s.charAt(pos);
pos++; count++;
}
return count;
}
public void close() {
// strings don't need to be closed
}
}
Listing 7-9A Simplified StringReader Class
与InputStreamReader
不同,StringReader
的代码简短明了。指定的字符串充当输入缓冲区。read
方法将字符串中的一个字符放入cbuf
的下一个槽中,当len
字符被写入(“上溢”)或字符串用尽(“下溢”)时停止。在任一情况下,该方法都返回写入的字符数。
StringReader
类符合适配器模式,如图 7-5 的类图所示。
图 7-5
StringReader 作为适配器
对象流
类InputStream
和OutputStream
让你读写字节流,而Reader
和Writer
让你读写字符流。Java 库提供了两个额外的读/写级别。接口DataInput
和DataOutput
让你读写原始值,接口ObjectInput
和ObjectOutput
让你读写对象。清单 7-10 给出了这四个接口的声明。
public interface DataInput {
int readInt() throws IOException;
double readDouble() throws IOException;
... // a method for each primitive type
}
public interface DataOutput {
void writeInt(int i) throws IOException;
void writeDouble(double d) throws IOException;
... // a method for each primitive type
}
public interface ObjectInput extends DataInput {
Object readObject() throws IOException;
}
public interface ObjectOutput extends DataInput {
void writeObject(Object obj) throws IOException;
}
Listing 7-10The DataInput, DataOutput, ObjectInput, and ObjectOutput Interfaces
Java 类库ObjectInputStream
和ObjectOutputStream
实现了ObjectInput
和ObjectOutput
,因此也实现了DataInput
和DataOutput
。因此,这两个类能够管理混合了对象和原始值的流。
清单 7-11 给出了类ObjectStreamTest
的代码,演示了这些类的用法。这个程序展示了两种将字符串列表写入对象流并读取它们的方法。
public class ObjectStreamTest {
public static void main(String[] args) throws Exception {
List<String> L = Arrays.asList("a", "b", "c");
// Write the list to a file, in two ways.
try (OutputStream os = new FileOutputStream("sav.dat");
ObjectOutput oos = new ObjectOutputStream(os)) {
oos.writeObject(L); // Write the list.
oos.writeInt(L.size()); // Write the list size,
for (String s : L) // and then its elements
.
oos.writeObject(s);
}
// Read the lists from the file.
try (InputStream is = new FileInputStream("sav.dat");
ObjectInput ois = new ObjectInputStream(is)) {
List<String> L1 = (List<String>) ois.readObject();
List<String> L2 = new ArrayList<>();
int size = ois.readInt(); // Read the list size.
for (int i=0; i<size; i++) { // Read the elements.
String s = (String) ois.readObject();
L2.add(s);
}
// L, L1, and L2 are equivalent.
System.out.println(L + ", " + L1 + ", " + L2);
}
}
}
Listing 7-11The ObjectStreamTest Class
读取对象流不同于读取字节流或文本流。一个对象流可以包含一个任意的对象序列和原始值,客户端需要知道在读取它的时候会发生什么。例如,在ObjectStreamTest
中,读取流的代码必须知道文件“sav.dat”包含以下内容:一个字符串列表、一个 int 和与前面的 int 的值一样多的单个字符串。
因此,客户端应该永远不需要读取对象流的末尾。这与字节流非常不同,在字节流中,客户端通常读取字节,直到返回流尾标记值-1。
由于由readObject
返回的对象属于类型Object
,客户端必须将该对象转换为适当的类型。
演示演示了两种向流中写入列表的方法:你可以将整个列表作为单个对象写入,或者你可以单独写入元素。编写整个列表显然更可取,因为它避免了遍历列表元素的需要。代码变得更容易编写和阅读。
类ObjectInputStream
和ObjectOutputStream
是适配器类。图 7-6 显示了说明ObjectInputStream
如何符合适配器模式的类图。ObjectOutputStream
的类图是类似的。
图 7-6
ObjectInputStream 作为适配器
类ObjectInputStream
的实现类似于适配器类InputStreamReader
。一个ObjectInputStream
对象持有对它所包装的InputStream
对象的引用,并使用该对象的方法来实现它的方法。ObjectOutputStream
的实现也类似。
DataInput
和DataOutput
方法的实现非常简单。例如,writeInt
方法从给定的 int 值中提取四个字节,并将它们写入字节流。readInt
方法从字节流中读取四个字节,并将它们转换成一个 int。
readObject
和writeObject
的实现难度要大得多。writeObject
方法需要对物体的足够信息进行编码,以使其能够被重建。这些信息包括关于对象的类及其状态变量的值的元数据。如果对象引用另一个对象z
,那么关于z
的信息也必须写入字节流。如果对象多次引用z
(直接或间接),那么该方法必须确保z
只被写入流一次。将对象编码为字节序列的过程称为对象序列化。
通常,序列化对象的算法可能需要将几个对象写入字节流。writeObject
方法首先创建一个从给定对象可达的所有对象的图;然后,它系统地遍历图形,将每个对象的字节表示写入流中。readObject
方法执行相反的操作。关于writeObject
和readObject
算法的更多细节已经超出了本书的范围。
在银行演示中保存状态
对象流是保存程序状态的一种特别好的方式。回想一下,银行演示目前将银行的账户信息保存到一个文件中。管理文件读写的类是SavedBankInfo
,它的代码出现在清单 3-14 中。那个类一个字节一个字节的写账户信息;编码既困难又乏味。
对象流的使用可以极大地简化SavedBankInfo
的代码。现在只需调用一次writeObject
,就可以写入整个帐户映射,而不是将每个帐户的每个值的每个字节都写入(和读取)到文件中。清单 7-12 给出了新的代码,它在银行演示的版本 16 中。
public class SavedBankInfo {
private String fname;
private Map<Integer,BankAccount> accounts;
private int nextaccount;
public SavedBankInfo(String fname) {
this.fname = fname;
File f = new File(fname);
if (!f.exists()) {
accounts = new HashMap<Integer,BankAccount>();
nextaccount = 0;
}
else {
try (InputStream is = new FileInputStream(fname);
ObjectInput ois = new ObjectInputStream(is)) {
accounts =
(Map<Integer,BankAccount>) ois.readObject();
nextaccount = ois.readInt();
}
catch (Exception ex) {
throw new RuntimeException("file read exception");
}
}
}
public Map<Integer,BankAccount> getAccounts() {
return accounts;
}
public int nextAcctNum() {
return nextaccount;
}
public void saveMap(Map<Integer,BankAccount> map,
int nextaccount) {
try (OutputStream os = new FileOutputStream(fname);
ObjectOutput oos = new ObjectOutputStream(os)) {
oos.writeObject(map);
oos.writeInt(nextaccount);
}
catch (IOException ex) {
throw new RuntimeException("file write exception");
}
}
}
Listing 7-12The Version 16 SavedBankInfo Class
保存的文件将包含两个对象:帐户映射和下一个帐号。构造器从保存的文件中读取映射和帐号(如果它们存在的话);否则,它会创建一个空映射,并将帐号设置为 0。方法saveMap
将指定的地图和数字写入保存的文件,覆盖任何以前的文件内容。
writeObject
方法有一个额外的要求:它写的对象和该对象引用的所有对象必须是可序列化的。也就是说,他们必须实现接口Serializable
。Java 库中的大多数类都是可序列化的。如果你希望你的类是可序列化的,你必须声明它们来实现Serializable
。
接口的不寻常之处在于它没有方法。这个接口的作用是作为一个“可以写”的标志。问题是一个类可能在其私有字段中包含敏感信息(如密码、工资或银行余额)。序列化该对象将使任何有权访问该文件的人都可以看到该私有信息,这可能会产生意想不到的后果。因此,程序员需要“签署”序列化。
回到版本 16 的银行演示,注意,writeObject
方法的参数是账户映射。HashMap
和Integer
类已经是可序列化的。映射中唯一的其他组件是银行帐户类。您可以通过让BankAccount
实现Serializable
来使它们可序列化,如清单 7-13 所示。
public interface BankAccount
extends Comparable<BankAccount>, Serializable {
...
}
Listing 7-13The Version 16 BankAccount Interface
此外,银行帐户引用的任何对象也必须是可序列化的。AbstractBankAccount
类有一个对OwnerStrategy
对象的引用,所以你也应该声明OwnerStrategy
接口是可序列化的。目前唯一实现OwnerStrategy
的是枚举Owners
。默认情况下,枚举在 Java 中是可序列化的,所以从技术上讲OwnerStrategy
不需要显式地可序列化。但是无论如何声明它是一个好的做法,以防将来修改OwnerStrategy
的实现。
银行演示的适配器
适配器的另一个用途是将不同类的信息合并到一个列表中,即使这些类可能没有公共接口。这个想法是为每个类创建一个适配器,这样这些适配器就实现了相同的接口。然后,可以将得到的调整后的对象放入列表中。
例如,考虑下面与银行演示相关的场景。假设 FBI 正在调查洗钱,并希望查看余额超过 10,000 美元的外国帐户的信息。此外,联邦调查局对贷款和银行账户感兴趣,贷款的“余额”被认为是其剩余本金。FBI 希望将这些信息存储为一个FBIAcctInfo
对象的列表,其中FBIAcctInfo
是清单 7-14 中所示的接口。
interface FBIAcctInfo {
int balance(); // in dollars
boolean isForeign();
String acctType(); // "deposit" or "loan"
}
Listing 7-14The FBIAcctInfo Interface
出于这个例子的目的,版本 16 的银行演示需要有一个类Loan
,它包含一些关于银行贷款的基本信息。清单 7-15 给出了它的代码。该类具有返回贷款当前状态的方法——其余额、剩余还款和每月还款金额——以及进行下一次还款的方法。
public class Loan {
private double balance, monthlyrate, monthlypmt;
private int pmtsleft;
private boolean isdomestic;
public Loan(double amt, double yearlyrate,
int numyears, boolean isdomestic) {
this.balance = amt;
pmtsleft = numyears * 12;
this.isdomestic = isdomestic;
monthlyrate = yearlyrate / 12.0;
monthlypmt = (amt*monthlyrate) /
(1-Math.pow(1+monthlyrate, -pmtsleft));
}
public double remainingPrincipal() {
return balance;
}
public int paymentsLeft() {
return pmtsleft;
}
public boolean isDomestic() {
return isdomestic;
}
public double monthlyPayment() {
return monthlypmt;
}
public void makePayment() {
balance = balance + (balance*monthlyrate) - monthlypmt;
pmtsleft--;
}
}
Listing 7-15The Loan Class
为了处理 FBI 请求,银行需要在FBIAcctInfo
接口下结合银行账户和贷款数据。当然,问题是BankAccount
和Loan
对象都没有实现FBIAcctInfo
。BankAccount
有一个isForeign
方法,但是对应的Loan
方法是isDomestic
。另外,FBIAcctInfo
希望它的balance
方法返回一个以美元为单位的值,但是BankAccount
和Loan
对相应的方法使用不同的名称,并以便士为单位存储值。两个类都没有对应于acctType
方法的方法。
解决这个问题的方法是为实现FBIAcctInfo
的BankAccount
和Loan
创建适配器类。然后,您可以用这些适配器包装BankAccount
和Loan
对象,并将结果FBIAcctInfo
对象放在一个列表中,供 FBI 分析。
用于BankAccount
的适配器称为BankAccountAdapter
,用于Loan
的适配器称为LoanAdapter
。它们的代码出现在清单 7-16 和 7-17 中。
public class LoanAdapter implements FBIAcctInfo {
private Loan loan;
public LoanAdapter(Loan loan) {
this.loan = loan;
}
public int balance() {
return (int) (loan.principalRemaining() / 100);
}
public boolean isForeign() {
return !loan.isDomestic();
}
public String acctType() {
return "loan";
}
}
Listing 7-17The LoanAdapter Class
public class BankAccountAdapter implements FBIAcctInfo {
private BankAccount ba;
public BankAccountAdapter(BankAccount ba) {
this.ba = ba;
}
public int balance() {
return ba.getBalance() / 100;
}
public boolean isForeign() {
return ba.isForeign();
}
public String acctType() {
return "deposit";
}
}
Listing 7-16The BankAccountAdapter Class
图 7-7 显示了这些适配器对应的类图。该图还显示了一个类FBIClient
,它为每个账户和贷款创建一个改编的FBIAcctInfo
对象,并将它们存储在一个列表中。然后它可以根据需要处理列表;为了简单起见,这段代码只计算受影响的帐户。代码出现在清单 7-18 中。
图 7-7
FBI 场景的类 diagam
public class FBIClient {
public static void main(String[] args) {
Bank b = getBank();
// put account info into a single list
List<FBIAcctInfo> L = new ArrayList<>();
for (BankAccount ba : b)
L.add(new BankAccountAdapter(ba));
for (Loan ln : b.loans())
L.add(new LoanAdapter(ln));
// then process the list
int count = 0;
for (FBIAcctInfo a : L)
if (a.isForeign() && a.balance() > 1000.0)
count++;
System.out.println("The count is " + count);
}
private static Bank getBank() {
SavedBankInfo info = new SavedBankInfo("bank16.info");
Map<Integer,BankAccount> accounts = info.getAccounts();
int nextacct = info.nextAcctNum();
return new Bank(accounts, nextacct);
}
}
Listing 7-18The FBIClient Class
摘要
如果一个类依赖于一个类D
,并且通过调用D
的方法来完成大部分(或全部)工作,那么这个类就被称为包装器类。包装类的一个常见用途是作为一个适配器。适配器类使用其包装的类来帮助它实现类似的接口。例如,图 7-3 中的ArrayAsList
类通过包装一个数组来实现一个列表。适配器类、它包装的类和它实现的接口之间的关系被称为适配器模式。
Java 库中的字节流类说明了适配器的价值。类InputStream
、OutputStream
及其子类提供了在各种输入和输出之间读写字节的方法。但是字节级操作往往太低级而不实用,所以 Java 有支持更高级操作的类。类Reader
、Writer
及其子类读写字符,接口ObjectInput
和ObjectOutput
读写对象和原始值。实现这些高级操作的最佳方式是使用适配器。
特别是,类InputStreamReader
包装任何InputStream
对象,并使其能够作为一个字符序列被读取。类似地,适配器类ObjectInputStream
支持将任何InputStream
对象作为对象和值的序列读取。这些适配器只需要知道如何将一个字符(在InputStreamReader
的情况下)或一个对象(在ObjectInputStream
的情况下)编码为一个字节序列。然后,每个适配器类可以让其包装的InputStream
对象执行剩余的工作。适配器类OutputStreamWriter
和ObjectOutputStream
为其包装的ObjectStream
对象类似地工作。
八、装饰器
一个装饰器是一个包装器,它实现了与它包装的对象相同的接口。装饰器方法增强了被包装对象的方法,与适配器方法相反,替换了被包装对象的方法。
本章考察了装饰器的几种有用的应用。它们可以用来创建对象的不可变版本,协调复杂任务的执行,以及实现收集流。Java 库类InputStream
显著地使用了装饰器。本章还探讨了装饰类的作者必须面对的设计问题。
装饰类
让我们从银行演示开始。回忆一下第六章中的内容,即Bank
类有方法iterator
,该方法使客户端能够检查其BankAccount
对象。例如,清单 8-1 中的代码打印余额小于 1 美元的账户。
Iterator<BankAccount> iter = bank.iterator();
while (iter.hasNext()) {
BankAccount ba = iter.next();
if (ba.getBalance() < 100)
System.out.println(ba);
}
Listing 8-1A Reasonable Use of the Bank’s Iterator Method
这个iterator
方法的问题是,客户端也可以用它来修改BankAccount
对象,甚至删除它们。例如,清单 8-2 中的代码删除了所有余额小于 1 美元的账户,并将所有其他账户的余额翻倍。
Iterator<BankAccount> iter = bank.iterator();
while (iter.hasNext()) {
BankAccount ba = iter.next();
int balance = ba.getBalance();
if (balance < 100)
iter.remove();
else
ba.deposit(balance);
}
Listing 8-2An Unreasonable Use of the Bank’s Iterator Method
假设这不是您的意图,并且您希望iterator
方法提供对银行账户的只读访问。有两个问题需要解决。首先,iterator
方法给予客户端对每个BankAccount
对象的完全访问权;您希望它拒绝访问BankAccount
的修改方法。第二,迭代器有方法remove
,删除当前被检查的对象;您希望确保客户端无法调用此方法。这两个问题的解决方案是使用包装。
确保BankAccount
对象不可修改的一种方法是创建一个包装BankAccount
的类。包装器的非修改方法将调用被包装对象的相应方法,其修改方法将抛出异常。清单 8-3 给出了这样一个名为UnmodifiableAccount
的类的代码。为了节省空间,清单中省略了方法getBalance
、isForeign
、compareTo
、hasEnoughCollateral
、toString
、equals
和fee
的代码,因为它们与getAcctNum
的代码相似。
public class UnmodifiableAccount implements BankAccount {
private BankAccount ba;
public UnmodifiableAccount(BankAccount ba) {
this.ba = ba;
}
public int getAcctNum() {
return ba.getAcctNum();
}
... // code for the other read-only methods goes here
public void deposit(int amt) {
throw new UnsupportedOperationException();
}
public void addInterest() {
throw new UnsupportedOperationException();
}
public void setForeign(boolean isforeign) {
throw new UnsupportedOperationException();
}
}
Listing 8-3A Proposed UnmodifiableAccount Class
同样的技术可以用来确保迭代器是只读的。您创建了一个包装迭代器的类,并在调用remove
方法时抛出一个异常。清单 8-4 给出了这样一个名为UnmodifiableBankIterator
的类的代码。注意,next
方法从包装的迭代器中获取BankAccount
对象,将其包装为UnmodifiableAccount
对象,并返回包装器。
public class UnmodifiableBankIterator
implements Iterator<BankAccount> {
private Iterator<BankAccount> iter;
public UnmodifiableBankIterator(
Iterator<BankAccount> iter) {
this.iter = iter;
}
public boolean hasNext() {
return iter.hasNext();
}
public BankAccount next() {
BankAccount ba = iter.next();
return new UnmodifiableAccount(ba);
}
public void remove() {
throw new UnsupportedOperationException();
}
}
Listing 8-4The Version 17 UnmodifiableBankIterator Class
清单 8-5 给出了银行iterator
方法的修订代码。代码将迭代器包装在一个UnmodifiableBankIterator
对象中,然后将其返回给客户端。
public Iterator<BankAccount> iterator() {
Iterator<BankAccount> iter = accounts.values().iterator();
return new UnmodifiableBankIterator(iter);
}
Listing 8-5The Iterator Method of Bank
让我们暂停一下,看看这些变化是如何影响银行业演示的。银行的iterator
方法返回的迭代器是不可修改的BankAccount
对象的不可修改迭代器。然而,从客户的角度来看,什么都没有改变。客户端仍然看到一个BankAccount
对象的迭代器。因此,像IteratorAccountStats
这样的类不需要修改。
使这一壮举成为可能的是包装器类UnmodifiableAccount
和UnmodifiableBankIterator
实现了与它们包装的对象相同的接口。此功能允许使用包裹的对象来代替未包裹的对象。这种包装器被称为装饰器。
装饰器的目的是改变一个类的一个或多个方法的行为,而不改变它的接口。行为变化是班级的“装饰品”。
一个类可以有多个装饰子类。举个例子,假设银行希望能够将个人账户标记为可疑。可疑帐户以两种方式改变其行为:它在每次调用deposit
方法时向控制台写入一条消息,它的toString
方法在返回的字符串前面加上“##”。类SuspiciousAccount
实现了这个行为。它的代码出现在清单 8-6 中。
public class SuspiciousAccount implements BankAccount {
private BankAccount ba;
public SuspiciousAccount(BankAccount ba) {
this.ba = ba;
}
public int getAcctNum() {
return ba.getAcctNum();
}
... // other methods go here
public void deposit(int amt) {
Date d = new Date();
String msg = "On " + d + " account #" +
ba.getAcctNum() + " deposited " + amt;
System.out.println(msg);
ba.deposit(amt);
}
public String toString() {
return "## " + ba.toString();
}
}
Listing 8-6A Proposed SuspiciousAccount Class
清单 8-6 从BankAccount
接口中省略了几个方法。如清单 8-3 所示,这些被省略的方法的代码与getAcctNum
相似——它们只是调用被包装对象的相应方法。
使用SuspiciousAccount
类的一种方法是修改Bank
来拥有方法makeSuspicious
。该方法的代码出现在清单 8-7 中。它检索指定的银行帐户,将其包装为一个SuspiciousAccount
,然后将新的BankAccount
对象写入映射,替换旧的对象。请注意,accounts
地图将包含可疑和非可疑账户的混合,尽管客户不会意识到这一事实。
public void makeSuspicious(int acctnum) {
BankAccount ba = accounts.get(acctnum);
ba = new SuspiciousAccount(ba);
accounts.put(acctnum, ba);
}
Listing 8-7The Bank’s makeSuspicious Method
装饰图案
清单 8-3 中的类UnmodifiableAccount
和清单 8-6 中的类SuspiciousAccount
有很多相同的代码。它们都包装了BankAccount
,并在局部变量中保存了对包装对象的引用。此外,它们的几个方法除了调用被包装对象的相应方法之外什么也不做。您可以通过创建一个抽象类来保存公共代码,从而消除这种重复。这个类叫做BankAccountWrapper
,是 17 版银行演示的一部分。它的代码出现在清单 8-8 中。
public abstract class BankAccountWrapper
implements BankAccount {
protected BankAccount ba;
protected BankAccountWrapper(BankAccount ba) {
this.ba = ba;
}
public int getAcctNum() {
return ba.getAcctNum();
}
... //similar code for all the other methods of BankAccount
}
Listing 8-8The Version 17 BankAccountWrapper Class
BankAccountWrapper
通过调用其包装对象的相应方法来实现每个BankAccount
方法。这个类本身什么也不做。它的价值在于简化了其他BankAccount
包装类的编写。这解释了为什么BankAccountWrapper
是一个抽象类,尽管它没有抽象方法。它依赖子类用有趣的行为覆盖它的方法。
银行业演示的版本 17 包含了UnmodifiableAccount
和SuspiciousAccount
类,重写后扩展了BankAccountWrapper
。它们的代码出现在清单 8-9 和清单 8-10 中。注意,代码比最初出现在清单 8-3 和 8-6 中的要简单得多。
public class SuspiciousAccount
extends BankAccountWrapper {
public SuspiciousAccount(BankAccount ba) {
super(ba);
}
public void deposit(int amt) {
Date d = new Date();
String msg = "On " + d + " account #" +
ba.getAcctNum() + " deposited " + amt;
System.out.println(msg);
ba.deposit(amt);
}
public String toString() {
return "## " + ba.toString();
}
}
Listing 8-10The Version 17 SuspiciousAccount Class
public class UnmodifiableAccount
extends BankAccountWrapper {
public UnmodifiableAccount(BankAccount ba) {
super(ba);
}
public void deposit(int amt) {
throw new UnsupportedOperationException();
}
public void addInterest() {
throw new UnsupportedOperationException();
}
public void setForeign(boolean isforeign) {
throw new UnsupportedOperationException();
}
}
Listing 8-9The Version 17 UnmodifiableAccount Class
图 8-1 中显示了BankAccount
类的类图。装饰类是有阴影的。他们对BankAccount
的依赖由BankAccountWrapper
掌握。
图 8-1
修饰的银行帐户层次结构
这个类图中的装饰类是根据装饰模式组织的。装饰模式断言接口的装饰形成了层次结构。层次结构的根是一个抽象包装类,它保存对其包装类的引用,并提供接口方法的默认实现。装饰类是包装类的子类。接口的非装饰类被称为它的基类。图 8-2 描绘了装饰模式的类图。
图 8-2
装饰图案
装饰类,像适配器一样,可以被认为是将输入对象转换成输出对象。区别在于装饰器的输出和输入具有相同的类型。这个特性意味着装饰器可以被组合。
例如,考虑银行演示。Bank
类保存了一张BankAccount
对象的地图。有些会不装饰,有些会装饰上SuspiciousAccount
。如果一个客户调用银行的iterator
方法,那么迭代器中的所有对象都将用UnmodifiableAccount
来修饰。这意味着可疑账户现在将有两个装饰。
理解装饰对象的组成的一个好方法是检查它们在内存中的表示。考虑清单 8-11 的代码。前三条语句创建三个不同的BankAccount
对象,并将它们绑定到变量x
、y
和z
。
BankAccount x = new SavingsAccount(5);
BankAccount y = new SuspiciousAccount(x);
BankAccount z = new UnmodifiableAccount(y);
int a = x.getAcctNum();
int b = y.getAcctNum();
int c = z.getAcctNum();
x.deposit(4); y.deposit(4);
Listing 8-11Using Composed Decorators
虽然x
、y
、z
是不同的BankAccount
对象,但都是指账户 5。对象 y 和 z 只是那个账户的不同装饰。
清单 8-11 中的第四条语句将变量a
设置为 5。下一条语句也将变量b
设置为 5,因为y.getAcctNum
调用x.getAcctNum
。类似地,变量c
也被设置为 5,因为z.getAcctNum
调用y.getAcctNum
,而y.getAcctNum
调用x.getAcctNum
。
对x.deposit
的调用使x
的余额增加 4。对y.deposit
的调用使x
的余额又增加了 4,因为y.deposit
调用了x.deposit
。它还会向控制台写入一条消息,因为y
是可疑的。清单 8-11 故意不调用z.deposit
,因为由于z
不可修改的事实,该调用会抛出异常。
图 8-3 描述了代码执行后这些变量的内存内容。每个变量的矩形显示其状态变量的值。一个AbstractBankAccount
对象有三个状态变量:acctnum
、balance
和owner
。为简单起见,该图没有显示owner
所指的物体。一个BankAccountWrapper
对象有一个状态变量ba
,它引用被包装的对象。
图 8-3
三个 BankAccount 对象的关系
图 8-3 展示了如何将一个装饰对象看作一个对象链。链头是最近的装饰。链的尾部是对象的未修饰版本。当调用一个方法时,该方法从链的头部开始执行,并沿着链向下执行。
这种情况是递归的一种形式。装饰类是递归类。方法调用可以被认为是递归遍历装饰链。例如,z.getAcctNum
递归调用y.getAcctNum
,?? 递归调用x.getAcctNum
,后者返回其值。
指挥链模式
命令模式的链是装饰器模式的一个特例,其中装饰器执行任务而不是计算值。每个装饰器都了解任务的某个部分。执行任务的请求被发送到链中的第一个装饰器,并沿着链向下传递,直到遇到可以执行该任务的装饰器。如果没有装饰器能够执行任务,那么基类执行一个默认的动作。
例如,银行演示的版本 17 使用命令链模式来实现贷款授权。回想一下,在早期版本中,只有当指定的帐户有足够高的余额时,银行才批准贷款。在版本 17 中,银行根据客户的金融历史和银行以前与客户打交道的经验,使用两个附加标准。这些标准在清单 8-12 中给出。
-
如果银行以前与客户没有问题,并且贷款少于 2000 美元,则批准。
-
否则,如果客户的信用评级低于 500,那么拒绝。如果信用等级超过 700,贷款金额低于 10,000 美元,那么批准。
-
否则,如果指定的账户余额足够高,则批准,否则拒绝。
Criteria
Listing 8-12Revised Loan Authorization
这些标准显然是真正的银行会使用的标准的简化版本。但问题是,贷款审批需要协调非常不同种类的数据,如客户历史、财务信誉和资产,这些数据通常由不同部门负责。您可以将这些标准合并到一个方法中,但是如果任何标准发生变化,整个方法都需要修改。单一责任规则表明,更好的设计是为每种标准创建一个单独的类。然后,这些独立的类可以作为装饰器来实现,并根据命令链模式来组织。图 8-4 说明了这种组织。接口是LoanAuthorizer
,它有方法authorizeLoan
。类别GoodCustomerAuthorizer
、CreditScoreAuthorizer
和CollateralAuthorizer
实现了三个标准中的每一个。
图 8-4
版本 17 LoanAuthorizer 层次结构
类CollateralAuthorizer
是基类。如果指定银行账户的余额足够高,它就批准贷款,否则就拒绝贷款。它的代码出现在清单 8-13 中。这个代码与以前版本中的贷款授权代码非常相似。
public class CollateralAuthorizer implements LoanAuthorizer {
private BankAccount ba;
public CollateralAuthorizer(BankAccount ba) {
this.ba = ba;
}
public boolean authorizeLoan(int amt) {
return ba.hasEnoughCollateral(amt);
}
}
Listing 8-13The Version 17 CollateralAuthorizer Class
AuthorizerWrapper
类是与装饰模式相关联的标准默认包装器。它的代码出现在清单 8-14 中。
public abstract class AuthorizerWrapper
implements LoanAuthorizer {
protected LoanAuthorizer auth;
protected AuthorizerWrapper(LoanAuthorizer auth) {
this.auth = auth;
}
public boolean authorizeLoan(int amt) {
return auth.authorizeLoan(amt);
}
}
Listing 8-14The Version 17 AuthorizerWrapper Class
CreditScoreAuthorizer
和GoodCustomerAuthorizer
类是装饰器。他们的代码出现在清单 8-15 和 8-16 中。为了使这些类更加真实,银行演示必须扩展到包括客户信息。为了简单起见,代码使用随机数来模拟信用评级和客户状态。
public class GoodCustomerAuthorizer
extends AuthorizerWrapper {
private boolean isgood;
public GoodCustomerAuthorizer(LoanAuthorizer auth) {
super(auth);
// For simplicity, mock up the customer status
// associated with the owner of the bank account.
Random rnd = new Random();
isgood = rnd.nextBoolean();
}
public boolean authorizeLoan(int amt) {
if (isgood && amt < 200000)
return true;
else
return auth.authorizeLoan(amt);
}
}
Listing 8-16The Version 17 GoodCustomerAuthorizer Class
public class CreditScoreAuthorizer extends AuthorizerWrapper {
private int score;
public CreditScoreAuthorizer(LoanAuthorizer auth) {
super(auth);
// For simplicity, mock up the credit score
// associated with the owner of the bank account.
Random rnd = new Random();
this.score = 300 + rnd.nextInt(500);
}
public boolean authorizeLoan(int amt) {
if (score > 700 && amt < 100000)
return true;
else if (score < 500)
return false;
else
return auth.authorizeLoan(amt);
}
}
Listing 8-15The Version 17 CreditScoreAuthorizer Class
清单 8-17 给出了类Bank
的authorizeLoan
方法的代码。它从在LoanAuthorizer
接口中定义的静态工厂方法getAuthorizer
中获取一个LoanAuthorizer
对象。清单 8-18 中显示了LoanAuthorizer
的代码。它的getAuthorizer
方法创建了一个批准者链。最外面是GoodCustomerAuthorizer
装饰器,接着是CreditScoreAuthorizer
,然后是CollateralAuthorizer
。这个顺序意味着贷款授权将如清单 8-12 所示进行。
public interface LoanAuthorizer {
boolean authorizeLoan(int amt);
static LoanAuthorizer getAuthorizer(BankAccount ba) {
LoanAuthorizer auth = new CollateralAuthorizer(ba);
auth = new CreditScoreAuthorizer(auth);
return new GoodCustomerAuthorizer(auth);
}
}
Listing 8-18The Version 17 LoanAuthorizer Interface
public boolean authorizeLoan(int acctnum, int loanamt) {
BankAccount ba = accounts.get(acctnum);
LoanAuthorizer auth = LoanAuthorizer.getAuthorizer(ba);
return auth.authorizeLoan(loanamt);
}
Listing 8-17The Bank’s Version 17 AuthorizeLoan Method
修饰迭代器
第六章的结尾讨论了集合流以及它们的过滤器和映射方法如何将一个流转换成另一个流。你可以用 decorators 做一些类似迭代器的事情。特别是,您可以创建装饰类MapIterator
和FilterIterator
,将一个迭代器转换成另一个迭代器。MapIterator
转换组件迭代器中每个元素的值,返回这些转换值的迭代器。FilterIterator
过滤其组件迭代器,返回包含满足给定谓词的元素的迭代器。
在查看这些类的代码之前,检查一下它们将如何被使用会很有帮助。清单 8-19 的IteratorTest
类包含两个例子。第一个示例将长度在 2 和 3 之间的字符串转换为大写并打印出来。第二个示例打印长度在 2 和 3 之间的字符串的最大长度。
public class IteratorTest {
public static void main(String[] args) {
Collection<String> c = Arrays.asList("a", "bb",
"ccc", "dddd");
// Print the strings whose length is between 2 and 3
// in uppercase.
Iterator<String> i1, i2, i3, i4;
i1 = c.iterator();
i2 = new FilterIterator<String>(i1, s->s.length() > 1);
i3 = new FilterIterator<String>(i2, s->s.length() < 4);
i4 = new MapIterator<String,String>(i3,
s->s.toUpperCase());
while (i4.hasNext()) {
String s = i4.next();
System.out.println(s);
}
// Print the maximum length of those strings.
Iterator<String> j1, j2, j3;
Iterator<Integer> j4;
j1 = c.iterator();
j2 = new FilterIterator<String>(j1, s->s.length() > 1);
j3 = new FilterIterator<String>(j2, s->s.length() < 4);
j4 = new MapIterator<String,Integer>(j3, s->s.length());
int max = -1;
while (j4.hasNext()) {
Integer n = j4.next();
if (n > max)
max = n;
}
System.out.println("The max length is " + max);
}
}
Listing 8-19The IteratorTest Class
在第一个例子中,由变量i1
表示的迭代器包含四个字符串{"a "、" bb "、" ccc "、" dddd"}。迭代器i2
将i1
限制为长度超过一个字符的字符串,即{"bb "," ccc "," dddd"}。迭代器i3
将i2
限制为长度小于四个字符的字符串,即{"bb "," ccc"}。迭代器i4
将这些值转换成大写,即{"BB "," CCC"}。然后,代码使用标准的习惯用法来遍历i4
并打印其元素。
第二个例子类似。迭代器j4
包含那些长度在 2 到 3 之间的字符串的长度。代码遍历j4
找到最大长度并打印出来。
现在是时候看看这两个迭代器类的代码了。MapIterator
的代码出现在清单 8-20 中。注意这个类如何利用它的组件迭代器。hasNext
方法调用组件的hasNext
方法并返回它返回的值。next
方法调用组件的next
方法,使用给定的函数转换该值,并返回转换后的值。
public class MapIterator<T,R> implements Iterator<R> {
private Iterator<T> iter;
private Function<T,R> f;
public MapIterator(Iterator<T> iter, Function<T,R> f) {
this.iter = iter;
this.f = f;
}
public boolean hasNext() {
return iter.hasNext();
}
public R next() {
T t = iter.next();
return f.apply(t);
}
}
Listing 8-20The MapIterator Class
FilterIterator
的代码出现在清单 8-21 中。这个类使用它的组件迭代器有点复杂。问题是它的hasNext
方法必须提前读取组件迭代器,以确定是否有另一个值满足过滤器。如果找到一个满意的值,那么hasNext
将它存储在变量nextvalue
中,当调用next
方法时将返回这个变量。
public class FilterIterator<T> implements Iterator<T> {
private Iterator<T> iter;
private Predicate<T> pred;
private T nextvalue;
private boolean found = false;
public FilterIterator(Iterator<T> iter,
Predicate<T> pred) {
this.iter = iter;
this.pred = pred;
}
public boolean hasNext() {
while (!found && iter.hasNext()) {
T t = iter.next();
if (pred.test(t)) {
nextvalue = t;
found = true;
}
}
return found;
}
public T next() {
hasNext(); // just to be safe
if (!found)
throw new NoSuchElementException();
found = false;
return nextvalue;
}
}
Listing 8-21The FilterIterator Class
这些修饰迭代器非常高效。这种效率源于这样一个事实,即FilterIterator
和MapIterator
对象不预先计算它们的值。相反,它们通过查询组件迭代器按需获取值。注意,每个 decorator 类遍历它的组件迭代器一次。因此,遍历任何修饰迭代器都会遍历其基本迭代器一次,不管它有多少修饰!
例如,考虑清单 8-19 中的迭代器i4
。打印其元素的循环只需要一次遍历基础字符串集合。图 8-5 中的序列图可以帮助澄清这个事实。这个图显示了清单 8-19 中的四个通信迭代器,每列一个。这些行表示这些迭代器之间的方法调用的时间线。
图 8-5
列表顺序图 8-19
该图显示了从迭代器i4
中检索第一个值所需的调用序列。main 方法通过在步骤 1 调用hasNext
开始,并在步骤 16 接收返回值 true。然后在步骤 17 调用next
,并在步骤 20 接收“BB”。
每个迭代器的行为可以通过观察附在其列上的箭头序列来观察。i2
的行为特别有意思。为了响应hasNext
方法,它需要反复调用i1.next()
,直到i1
返回一个满足i2
谓词的值。该图显示,返回的第一个值“a”不满足谓词,但第二个值“bb”满足谓词。
顺便说一句,你可能已经注意到FilterIterator
和MapIterator
没有一个公共的抽象包装类,因此不严格符合装饰模式。MapIterator
是罪魁祸首,因为它包装了一个类型为Iterator<T>
的对象,但实现了Iterator<R>
。由于映射迭代器的元素与其组件迭代器的类型不同,因此无法为通用包装类选择类型。
实现收集流
FilterIterator
类将一个迭代器转换成另一个迭代器,后者生成其元素的子集。这个转换让人想起清单 6-35 中Stream
接口的filter
方法。同样的,MapIterator
级让人想起了Stream
的map
方法。这种相似并非巧合。本节展示了修饰迭代器是如何构成Stream
实现的基础的。
Java 库的设计者在隐藏集合流的实现方面做得非常出色。例如,考虑清单 8-22 中的流代码。集合的stream
方法返回一个实现了Stream
但其类未知的对象。类似地,filter
和map
方法的结果也是实现Stream
的未知类的对象。
Collection<String> c = Arrays.asList("a", "bb", "ccc");
Stream<String> s = c.stream()
.filter(s->s.length() == 2)
.map(s->s.toUpperCase());
s.forEach(v -> System.out.println(v)); // prints "BB"
Listing 8-22Example Stream Code
Stream
类的 Java 库实现因其封装性而受到称赞。然而,这种封装使得研究用于实现流的技术变得困难。因此,我写了一个精简版的Stream
,名为SimpleStream
。类SimpleStream
包含第六章中描述的五种方法:iterator
、forEach
、filter
、map
和reduce
。SimpleStream
和Stream
的区别在于它是一个类,而不是一个接口。它只有一个构造器,它的参数是一个迭代器。
类SimpleStreamTest
说明了SimpleStream
类的用法。它的代码出现在清单 8-23 中。它执行与清单 8-19 中相同的两个任务,使用流代替迭代器。第一个流选择长度在 2 到 3 之间的字符串,将它们转换成大写,并打印出来。第二个流选择长度在 2 到 3 之间的字符串,将每个字符串转换为它的长度,找到最大值,并打印出来。
public class SimpleStreamTest {
public static void main(String[] args) {
Collection<String> c = Arrays.asList("a", "bb",
"ccc", "dddd");
new SimpleStream<String>(c.iterator())
.filter(s->s.length() > 1)
.filter(s->s.length() < 4)
.map(s->s.toUpperCase())
.forEach(s->System.out.println(s));
Integer max =
new SimpleStream<String>(c.iterator())
.filter(s->s.length() > 1)
.filter(s->s.length() < 4)
.map(s->s.length())
.reduce(0, (i1, i2)->Math.max(i1, i2));
System.out.println("The max length is " + max);
}
}
Listing 8-23The SimpleStreamTest Class
清单 8-24 中显示了SimpleStream
的代码。每个SimpleStream
对象包装一个迭代器。(换句话说,SimpleStream
是一个将迭代器转换成集合流的适配器类。)方法filter
和map
修饰迭代器并返回一个新的SimpleStream
对象,该对象包装了被修饰的迭代器。forEach
和reduce
方法通过遍历迭代器来执行它们的动作。reduce
方法使用清单 6-43 中的归约算法。
public class SimpleStream<T> {
Iterator<T> iter;
public SimpleStream(Iterator<T> iter) {
this.iter = iter;
}
public SimpleStream<T> filter(Predicate<T> pred) {
Iterator<T> newiter =
new FilterIterator<T>(iter, pred);
return new SimpleStream<T>(newiter);
}
public <R> SimpleStream<R> map(Function<T,R> f) {
Iterator<R> newiter = new MapIterator<T,R>(iter, f);
return new SimpleStream<R>(newiter);
}
public void forEach(Consumer<T> cons) {
while (iter.hasNext()) {
T t = iter.next();
cons.accept(t);
}
}
public T reduce(T identity, BinaryOperator<T> f) {
T result = identity;
while (iter.hasNext()) {
T t = iter.next();
result = f.apply(result, t);
}
return result;
}
}
Listing 8-24The SimpleStream Class
修饰迭代器的效率延续到了SimpleStream
方法。filter
和map
方法构造一个新的修饰迭代器,并且不执行任何遍历。forEach
和reduce
方法遍历包装好的迭代器,这将总是只需要对底层集合进行一次迭代。
修饰的输入流
Decorators 在 Java 字节流类中也扮演着重要的角色(您应该记得,它与上一节的集合流完全无关)。再次考虑抽象类InputStream
。这个类在第三章中讨论过,还有它的子类FileInputStream
、PipedInputStream
和ByteArrayInputStream
。本章考察了InputStream
的一些装饰子类。
类FilterInputStream
是抽象的InputStream
包装器。它的三个装饰子类是BufferedInputStream
、ProgressMonitorInputStream
和CipherInputStream
。图 8-6 给出了相应的类图。注意它是如何符合装饰模式的。以下小节讨论这些FilterInputStream
子类。
图 8-6
InputStream 装饰器类
缓冲输入流
清单 8-25 给出了一个类InputStreamEfficiency
的代码,展示了读取文件的三种方式。每种方法都由一个方法实现,该方法返回读取文件所用的毫秒数。
public class InputStreamEfficiency {
public static void main(String[] args) throws IOException {
String src = "mobydick.txt";
long t1 = readFileUnbuffered(src);
long t2 = readFileArrayBuffer(src);
long t3 = readFileDecoratorBuffer(src);
System.out.println("Unbuffered time: " + t1);
System.out.println("Array Buffer time: " + t2);
System.out.println("Decorator Buffer time: " + t3);
}
... // code for the three methods goes here
}
Listing 8-25The InputStreamEfficiency Class
方法readFileUnbuffered
的代码出现在清单 8-26 中。该方法根据标准习惯用法读取输入流,使用无参数read
方法单独读取每个字节。不幸的是,这种从文件中读取字节的方式非常低效。问题是每次调用read
都会导致对操作系统的调用,而操作系统调用非常耗时。
public static long readFileUnbuffered(String src)
throws IOException {
long begintime = System.currentTimeMillis();
try (InputStream is = new FileInputStream(src)) {
int x = is.read();
while (x >= 0) {
byte b = (byte) x;
// process b ...
x = is.read();
}
}
return System.currentTimeMillis() - begintime;
}
Listing 8-26The readFileUnbuffered Method
readFileArrayBuffer
方法通过一次一个数组读取其底层流的字节来解决这个问题。这种技术被称为缓冲,阵列被称为缓冲。该方法的代码出现在清单 8-27 中。它有两个嵌套循环。外层循环调用 1-arg read
方法用字节填充数组,重复这个过程,直到底层流被完全读取。内部循环处理数组中的每个字节。这种缓冲的使用导致了效率的显著提高。在我的电脑上,使用一个 100 字节的数组,这个方法比readFileUnbuffered
快 100 倍。
public static long readFileArrayBuffer(String src)
throws IOException {
long begintime = System.currentTimeMillis();
try (InputStream is = new FileInputStream(src)) {
byte[] a = new byte[100];
int howmany = is.read(a);
while (howmany > 0) {
for (int pos=0; pos<howmany; pos++) {
byte b = a[pos];
// process b ...
}
howmany = is.read(a);
}
}
return System.currentTimeMillis() - begintime;
}
Listing 8-27The readFileArrayBuffer Method
虽然缓冲可以显著提高执行时间,但它也增加了代码的复杂性。特别是,readFileArrayBuffer
方法需要两个嵌套的循环来从输入流中读取字节,它的代码需要精心制作,以确保缓冲区得到正确管理。
清单 8-28 中的readFileDecoratorBuffer
方法使用 Java 库中的装饰类BufferedInputStream
来自动执行缓冲。一个BufferedInputStream
对象在内部存储一个字节数组。它最初通过使用对read
的一次调用,用来自其组件流的字节填充数组。当客户端调用read
方法时,BufferedInputStream
对象从数组中提取下一个字节。如果数组用完了字节,那么对象会自动重新填充它。
清单 8-28 有趣的特性是它使用标准的习惯用法来读取它的输入流。代码很简单,但也很高效。装饰器在客户不知情的情况下执行缓冲。这个方法在我电脑上的运行时间和readFileArrayBuffer
不相上下。
public static long readFileDecoratorBuffer(String src)
throws IOException {
long begintime = System.currentTimeMillis();
try (InputStream is = new FileInputStream(src);
InputStream bis = new BufferedInputStream(is)) {
int x = bis.read();
while (x >= 0) {
byte b = (byte) x;
// process b ...
x = bis.read();
}
}
return System.currentTimeMillis() - begintime;
}
Listing 8-28The readFileDecoratorBuffer Method
进度监控
另一个InputStream
decorator 子类是ProgressMonitorInputStream
。这个装饰器不影响字节的读取方式。相反,它的“装饰”是显示一个包含进度条的窗口。清单 8-29 给出了类ProgressMonitorFileRead
的代码,它使用一个ProgressMonitorInputStream
来装饰一个FileInputStream
。
public class ProgressMonitorFileRead {
public static void main(String[] args) throws IOException {
String src = "mobydick.txt";
String msg = "reading " + src;
try (InputStream is = new FileInputStream(src);
InputStream pis = new ProgressMonitorInputStream(
null, msg, is)) {
int x = pis.read();
while (x >= 0) {
byte b = (byte) x;
// process b ...
x = pis.read();
}
}
}
}
Listing 8-29The ProgressMonitorFileRead Class
ProgressMonitorInputStream
构造器有三个参数。第一个是指向进度监视器窗口的父窗口的指针。清单 8-29 中的值为空,因为程序没有运行在 GUI 环境中。第二个参数是与进度条一起显示的标签。在这个例子中,标签是“reading mobydick.txt”。第三个是对被修饰的输入流的引用。图 8-7 显示了我的电脑上进度监视器窗口的截图。
图 8-7
进度监视器窗口
ProgressMonitorInputStream
decorator 负责监控读取其包装的输入流的进度,并在必要时显示一个进度条。它的构造器调用包装流的available
方法,该方法返回关于流中剩余字节总数的最佳猜测。对read
的每次调用都重复对available
的调用,并将其当前值与初始值进行比较。如果该比率足够高,它会重新显示进度窗口。
密码输入流
考虑清单 3-12 中的程序EncryptDecrypt
。它的encrypt
方法实现了一个简单的 Caesar 密码:它读取其输入流的每个字节,添加一个固定的偏移量,并将结果字节写入输出流。
这个节目不令人满意有两个原因。第一,凯撒密码很容易破解;任何实际情况都需要更复杂的算法。第二,程序员必须显式地编写加密代码,复杂的密码算法的代码可能很难编写。Java 库类CipherInputStream
解决了这两个问题。
CipherInputStream
是一个装饰类。它的构造器有一个指定所需密码算法的参数。如果你用一个CipherInputStream
包装一个输入流,那么它的字节将在被读取时被加密(或解密)。也就是说,加密是装饰的一部分。清单 8-30 给出了类DecoratedEncryptDecrypt
的代码,展示了一个密码输入流的使用。与EncryptDecrypt
的差异以粗体显示。
public class DecoratedEncryptDecrypt {
public static void main(String[] args) throws Exception {
KeyGenerator kg = KeyGenerator.getInstance("DES");
kg.init(56); // DES uses 56-bit keys
SecretKey key = kg.generateKey();
encrypt("mobydick.txt", "encrypted.txt", key,
Cipher.ENCRYPT_MODE);
encrypt("encrypted.txt", "decrypted.txt", key,
Cipher.DECRYPT_MODE);
}
private static void encrypt(String source, String output,
SecretKey key, int mode) throws Exception {
Cipher c = Cipher.getInstance("DES");
c.init(mode, key);
try (InputStream is = new FileInputStream(source);
InputStream cis = new CipherInputStream(is, c);
OutputStream os = new FileOutputStream(output)) {
int x = cis.read();
while (x >= 0) {
byte b = (byte) x;
os.write(b);
x = cis.read();
}
}
}
}
Listing 8-30The DecoratedEncryptDecrypt Class
CipherInputStream
构造器需要一个Cipher
对象,它体现了加密算法。不同的密码算法需要不同的规范。类SecretKey
创建 DES 算法所需的 56 字节密钥。
注意,encrypt
方法再次使用标准的习惯用法来读取文件。根据mode
参数的值,CiphierInputStream
装饰器通过加密或解密输入字节来自动转换它们。
装饰透明度
装饰类增强其组件对象功能的另一种方式是实现新方法。例如,装饰器类PushbackInputStream
和PushbackReader
实现了方法unread
。这个方法将一个指定的字节(或字符)放到输入流中,这样下一次对read
的调用将在继续流的其余部分之前返回它。
作为一个例子,清单 8-31 给出了方法openAndSkip
的代码,该方法打开一个文本文件并跳过任何前导空格。编写这样一个方法的问题是,知道已经读完空白的唯一方法是读取一个非空白字符。您需要 pushback 阅读器将非空白字符放回到流中。
public Reader openAndSkip(String f) throws IOException {
Reader r = new FileReader(f);
PushbackReader pr = new PushbackReader(r);
skipWhitespace(pr);
return pr;
}
private void skipWhitespace(PushbackReader pr) {
int x = pr.read();
while (x >= 0) {
char c = (char) x;
if (!Character.isWhitespace(c)) {
pr.unread(c); // put c back on the input stream
return;
}
x = pr.read();
}
}
Listing 8-31A Method to Open a File, Skipping Initial Whitespace
Reader
中没有定义unread
方法。因此,清单 8-31 中的变量pr
必须具有类型PushbackReader.
(如果它具有类型Reader
,那么它对unread
的调用将不会编译。)助手方法skipWhitespace
因此是不透明的,因为它需要知道传递给它的读取器是推回读取器。
对于实现新方法的装饰类的另一个例子,再次考虑BufferedInputStream
和BufferedReader
。这些类实现了两种方法来重新读取流的一部分:方法mark
,它标记了流中的一个位置;以及将流重新定位在标记位置的方法reset
。
作为使用这些方法的一个例子,考虑下面的任务:给定一个文本文件和一个整数N
,写一个程序DoubledChars
,寻找一个在文件中出现两次的由N
字符分隔的字符。例如,如果N=0
那么程序在文本中寻找双字符,如“...aa……”;如果N=1
那么程序会寻找中间有一个字符的双字符,如"...阿坝……”。
DoubledChars
的代码出现在清单 8-32 中。main 方法从文件中读取字符,并将每个字符传递给check
方法。check
方法读取下一个N+1
字符。如果读取的最后一个字符与给定的字符匹配,则该方法打印文件的该部分。注意check
方法在被调用时如何标记流的位置,并在返回时将流重置到那个位置。
public class DoubledChars {
public static final int N = 1;
public static void main(String[] args) throws IOException {
try (Reader r = new FileReader("mobydick.txt");
Reader br = new BufferedReader(r)) {
int x = br.read(); // For each char,
while (x >= 0) {
char c = (char) x;
check(br, c); // check the N+1st char after it.
x = br.read();
}
}
}
private static void check(Reader r, char c)
throws IOException {
char[] a = new char[N+1];
r.mark(N+1);
int howmany = r.read(a);
if (howmany == N+1 && a[N] == c) {
System.out.print(c); System.out.println(a);
}
r.reset();
}
}
Listing 8-32The DoubledChars Class
许多Reader
类(如FileReader
)只能读一次它们的字符,因此它们不能实现mark
和reset
。然而,装饰类BufferedReader
可以使用它的缓冲区来绕过这个限制。对其mark
方法的调用将“标记位置”设置为缓冲区中的当前位置,对reset
的调用将缓冲区位置设置回保存的标记位置。因此,当字符在重置后被重新读取时,它们将从缓冲区而不是底层读取器中读取。mark
方法的参数指定了缓冲区数组的最大大小。如果没有这个限制,缓冲区数组可能会变得太大,并产生意外的内存异常。
注意清单 8-32 中的check
方法的参数具有类型Reader
而不是BufferedReader
。也就是说,check
方法是透明的。这种透明是可能的,因为mark
和reset
是由Reader
定义的。
但是考虑到一些阅读器(比如文件阅读器)不支持mark
和reset
,这是怎么回事呢?答案是所有读者都要有mark
和reset
方法;只是其中许多方法在被调用时会抛出异常。抛出异常的可能性是 Java 设计者为实现透明性所付出的代价。
Java 库有方法markSupported
来帮助客户避免这些异常。如果一个类可以实现mark
和reset
方法,那么对markSupported
的调用返回 true。如果一个类不能实现它们,那么markSupported
返回 false,而mark
和reset
方法抛出异常。如果客户对阅读器是否支持这些方法有任何疑问,可以调用markSupported
。
例如,清单 8-33 给出了清单 8-32 的一个变体,其中 main 方法被一个以Reader
作为参数的方法printDoubleChars
所替代。因为printDoubleChars
不知道它有什么样的阅读器,所以它调用markSupported
。如果markSupported
返回 false,那么该方法在继续之前将读取器包装在一个BufferedReader
对象中。
public class DoubledChars {
public static final int N = 1;
public static void printDoubledChars(Reader r)
throws IOException {
if (!r.markSupported())
r = new BufferedReader(r);
int x = r.read(); // For each char,
while (x >= 0) {
char c = (char) x;
check(r, c); // check the N+1st char after it.
x = r.read();
}
}
// ... the check method is unchanged
}
Listing 8-33A Variant of the DoubledChars Class
让我们停下来回顾一下PushbackReader
和BufferedReader
的设计含义。尽管两个 decorator 类都实现了新方法,但是 Java 库对它们的处理非常不同。PushbackReader
方法unread
不被Reader
识别,使用该方法的客户端必须以不透明的方式进行操作。另一方面,BufferedReader
方法mark
和reset
是Reader
的一部分,客户端可以透明地调用这些方法。这种透明性的缺点是客户端必须小心避免产生异常。
这两个设计决策可以总结为在透明度和安全性之间的选择。没有一个通用的规则,设计师可以用来做这个选择;你必须单独考虑每种情况。
Java 库通常选择安全性。该库为mark
和reset
选择透明的一个重要原因是这些方法也受一些非装饰基类的支持,比如ByteArrayInputStream
、CharArrayReader
和StringReader
。这些类中的每一个都将流存储在基础数组中。实际上,它们的值已经被缓冲,所以mark
和reset
很容易实现。由于标记和重置被多个类支持,设计者可能认为它们应该被包含在InputStream
API 中。
透明性的最后一个方面涉及到一个对象的装饰器的组成顺序。在完全透明的设计中,每个装饰类都是独立于其他装饰类的,因此它们的组合顺序不应该影响代码的正确性。然而在实践中,装饰类可能有导致某些排序失败的需求。例如,假设您想要创建一个支持mark
、reset
和unread
方法的输入流。考虑以下两种说法:
InputStream bis = new BufferedInputStream(is);
PushbackInputStream pis = new PushbackInputStream(bis);
类PushbackInputStream
不支持mark
和reset
,即使它的底层输入流支持。因此变量pis
将支持unread
,但不支持mark
和reset
。另一方面,如果交换bis
和pis
的声明,那么bis
将支持mark
和reset
,但不支持unread
。事实上,输入流(或阅读器)没有办法支持mark
、reset
和unread
。
再举一个例子,假设您想要向缓冲的输入流添加一个进度监视器。您编写以下语句:
InputStream bis = new BufferedInputStream(is);
InputStream pmis = new ProgressMonitorInputStream(bis);
与PushbackInputStream
不同,类ProgressMonitorInputStream
支持mark
和reset
,当它的底层输入流支持时。因此变量pmis
支持mark
和reset
。交换bis
和pmis
的声明不会改变修饰输入流的功能。
另一方面,假设您想要向密码输入流添加一个进度监视器。在这种情况下,以下排序有效。
InputStream pmis = new ProgressMonitorInputStream(is);
InputStream cis = new CipherInputStream(pmis, cipher);
然而,下面的顺序将不能工作,它交换了pmis
和cis
的声明。
InputStream cis = new CipherInputStream(is, cipher);
InputStream pmis = new ProgressMonitorInputStream(cis);
问题是ProgressMonitorInputStream
调用其底层流的available
方法,该方法告诉它还剩多少字节。但是CipherInputStream
无法准确知道它的编码会产生多少个字符,所以它的available
方法总是返回 0。因此,pmis
认为读数已经完成,不会显示监视器窗口。
这里的教训是装饰透明是一个难以捉摸的目标。乍一看,输入流装饰器提供的增强似乎是相互独立的,但是它们以微妙的方式相互作用。如果程序员没有意识到这些交互,那么很可能会出现“神秘的”错误。设计者越接近装饰器透明性,这些类的用户就越容易使用。
摘要
一个装饰类是一个包装器,它实现了与它包装的类相同的接口。这种包装的目的是通过增强现有方法的行为或提供新方法来改变包装对象的功能。装饰器信奉开放/封闭规则的精神——通过装饰一个类,您可以改变该类的工作方式,而不必对它进行任何修改。
decorator 模式指出了如何组织给定接口的装饰器。所有 decorators 都应该有一个公共的抽象超类来管理包装,并提供接口方法的默认实现。每个 decorator 类都将扩展这个公共超类,并提供它希望增强的方法的实现。命令链模式是装饰器模式的一个特殊实例,其中装饰器执行任务而不是计算值。
当设计装饰类时,你必须考虑透明性的问题。理想情况下,客户端应该能够在不知道类的情况下使用一个InputStream
或Reader
对象,并且应该能够在不考虑它们可能的交互的情况下组成装饰器。设计师必须认识到不同装饰器之间冲突的可能性,以便更好地分析所涉及的权衡。
九、组合
第八章研究了 decoratorss,decorator 是实现与它们包装的对象相同的接口的包装器。本章考察了组合物体。组合对象类似于装饰器,只是它包装了多个对象,每个对象都实现了与自身相同的接口。这个看似很小的区别对组合的结构和使用方式有很大的影响。组合对象对应于树,组合方法倾向于涉及树遍历。
这一章展示了三个组合的例子:谓词、图形用户界面(GUI)和菜谱。这些例子共享一个被称为组合模式的公共类设计。它们也有一些功能上的差异,说明了设计师面临的不同选择。
组合谓词
一个谓词是一个计算结果为真或假的数学表达式。给定两个谓词,您可以通过对它们应用操作符and
或or
来创建另一个更大的谓词。这个较大的谓词被称为一个组合,两个较小的谓词是它的组件。您可以继续这个过程,构建越来越大的组合谓词。非组合谓词被称为基本谓词。
例如,清单 9-1 显示了一个由三个基本谓词组成的组合谓词。如果n
小于 20 并且能被 2 或 3 整除,则返回 true。
n<20 and (n%2=0 or n%3=0)
Listing 9-1A Composite Predicate
组合谓词可以表示为一棵树,其内部节点是操作符{ and
,or
},其叶子是基本谓词。图 9-1 描绘了清单 9-1 的谓词树。
图 9-1
列表 9-1 的谓词树
Java 谓词是实现接口Predicate
的对象,如第六章中所讨论的。基本谓词通常是通过 lambda 表达式创建的。例如,清单 9-2 给出了 Java 语句来实现清单 9-1 中的三个基本谓词。
Predicate<Integer> pred1 = n -> n < 20;
Predicate<Integer> pred2 = n -> n%2 == 0;
Predicate<Integer> pred3 = n -> n%3 == 0;
Listing 9-2Basic Predicates in Java
在 Java 中支持组合谓词的一种方法是为每个操作符创建一个类。将这些类称为AndPredicate
和OrPredicate
。每个类包装两个组件谓词并实现Predicate
。如果两个组件都返回 true,则AndPredicate
的test
方法返回 true,如果至少一个组件返回 true,则OrPredicate
的test
方法返回 true。为了方便编码,我还将创建类CompositePredicate
作为AndPredicate
和OrPredicate
的公共超类,管理它们的包装对象。清单 9-3 给出了CompositePredicate
的代码,清单 9-4 给出了AndPredicate
的代码。OrPredicate
的代码类似,在此省略。
public class AndPredicate<T> extends CompositePredicate<T> {
public AndPredicate(Predicate<T> p1, Predicate<T> p2) {
super(p1, p2);
}
public boolean test(T t) {
return p1.test(t) && p2.test(t);
}
}
Listing 9-4The AndPredicate Class
public abstract class CompositePredicate<T>
implements Predicate<T> {
protected Predicate<T> p1, p2;
protected CompositePredicate(Predicate<T> p1,
Predicate<T> p2) {
this.p1 = p1;
this.p2 = p2;
}
public abstract boolean test(T t);
}
Listing 9-3The CompositePredicate Class
图 9-2 包含了一个类图,显示了这些Predicate
类之间的关系。三个“BasicPredicate”类对应于为清单 9-2 中的pred1
、pred2
和pred3
创建的匿名类。
图 9-2
谓词的类图
类图看起来非常像装饰模式。不同之处在于包装类CompositePredicate
包装了两个对象,而不是一个。为了突出这一区别,依赖箭头被标注了可选的基数标签“2”
清单 9-5 中的类CompositePredicateTest
说明了组合谓词在 Java 中的使用。这段代码首先创建基本谓词pred1
、pred2
和pred3
,如清单 9-2 所示。然后,它以三种不同的方式实现清单 9-1 的组合谓词。
public class CompositePredicateTest {
public static void main(String[] args) {
Predicate<Integer> pred1 = n -> n < 20;
Predicate<Integer> pred2 = n -> n%2 == 0;
Predicate<Integer> pred3 = n -> n%3 == 0;
// First: use AndPredicate and OrPredicate objects
Predicate<Integer> pred4 =
new OrPredicate<Integer>(pred2, pred3);
Predicate<Integer> pred5 =
new AndPredicate<Integer>(pred1, pred4);
printUsing(pred5);
// Second: use the 'or' and 'and' methods separately
Predicate<Integer> pred6 = pred2.or(pred3);
Predicate<Integer> pred7 = pred1.and(pred6);
printUsing(pred7);
// Third: compose the 'or' and 'and' methods
Predicate<Integer> pred8 = pred1.and(pred2.or(pred3));
printUsing(pred8);
}
private static void printUsing(Predicate<Integer> p) {
for (int i=1; i<100; i++)
if (p.test(i))
System.out.print(i + " ");
System.out.println();
}
}
Listing 9-5The CompositePredicateTest Class
第一种方式使用了AndPredicate
和OrPredicate
类。谓语pred4
是一个OrPredicate
宾语,谓语pred5
是一个AndPredicate
宾语。图 9-3 描绘了内存中的这五个Predicate
对象。该图类似于图 8-3 的内存图,每个对象由一个矩形表示,其全局变量的值显示在其矩形内。注意对象引用如何形成一棵与图 9-1 的谓词树完全对应的树。
图 9-3
组合谓词的记忆表征
在创建了谓词pred5
之后,清单 9-5 的代码将pred5
传递给它的printUsing
方法,后者调用谓词的test
方法来处理从 1 到 100 的整数。图 9-4 描绘了跟踪表达式pred5.test(9)
执行的序列图。步骤 2 调用pred5
的第一个组件pred1
的test
方法,该方法返回 true。Step 4 然后在它的第二个组件pred4
上调用test
。为了确定它的响应,pred4
在它的两个组件上调用test
。组件pred2
返回假,而pred3
返回真;因此pred4
可以返回 true。由于pred5
的两个组件现在都返回 true,因此pred5
返回 true。
图 9-4
表达式 pred5.test(9)的序列图
注意对test(9)
的调用是如何从谓词树的根向下传递到它的叶子的。事实上,这个方法调用序列对应于树的后序遍历。
类AndPredicate
和OrPredicate
不是 Java 库的一部分。相反,Predicate
接口有默认的方法and
和or
,这使得创建组合谓词而不必自己创建组合对象成为可能。
清单 9-5 的第二和第三部分说明了这些方法的使用。如果pred2
或pred3
为真,变量pred6
返回真,并且在功能上等同于pred4
。同样,变量pred7
在功能上等同于pred5
。对and
和or
方法的调用也可以被组合,如变量pred8
所示。
清单 9-6 展示了如何实现and
和or
方法。and
方法创建并返回一个AndPredicate
对象,该对象包装了两个对象:当前对象和传递给该方法的对象。or
方法的实现也类似。
public interface Predicate<T> {
boolean test(T t);
default Predicate<T> and(Predicate<T> other) {
return new AndPredicate(this, other);
}
default Predicate<T> or(Predicate<T> other) {
return new OrPredicate(this, other);
}
}
Listing 9-6A Reasonable Implementation of Predicate
这些方法的实际 Java 库实现与清单 9-6 略有不同,出现在清单 9-7 中。lambda 表达式定义了等价于AndPredicate
和OrPredicate
的匿名内部类。这段代码非常优雅,因为它不再需要显式的AndPredicate
和OrPredicate
类。
public interface Predicate<T> {
boolean test(T t);
default Predicate<T> and(Predicate<T> other) {
return t -> test(t) && other.test(t);
}
default Predicate<T> or(Predicate<T> other) {
return t -> test(t) || other.test(t);
}
}
Listing 9-7The Actual Implementation of Predicate
JavaFX 中的组合对象
对于组合对象的第二个例子,考虑一个用于构建 GUI 应用程序的库。当您创建一个应用程序窗口时,您通常将其内容组织为一个组合对象。例如,图 9-5 描述了我使用 JavaFX 库创建的一个窗口。
图 9-5
JavaFX 窗口
在 JavaFX 中,窗口的内容由节点构成。JavaFX 库有实现几种类型节点的类。这个示例窗口使用两种类型的节点:控件和窗格。
一个控件是一个可以被用户操作的节点。JavaFX 中的所有控件都扩展了抽象类Control
。示例窗口中的控件属于类别Label
、ChoiceBox
、CheckBox
和Button
。
一个窗格是一个可以包含其他节点的节点,称为它的子节点。每个窗格负责确定其子节点在屏幕上的位置。这被称为窗格的布局策略。
JavaFX 库有几个 pane 类,每个类都有自己的布局策略。它们都扩展了类Pane
。示例窗口使用了其中的两个:HBox
和VBox
。一个HBox
窗格水平布局其子窗格。一个VBox
窗格垂直排列其子窗格。
图 9-5 的窗口有九个节点:五个控件和四个窗格。图 9-6 描绘了它们的布局。
图 9-6
图 9-5 的节点
描述窗口结构的另一种方法是使用树,其内部节点是窗格,其叶节点是控件。这棵树被称为窗口的节点层次。图 9-7 描绘了图 9-6 对应的节点层次。这些节点上的标签对应于 JavaFX 类AccountCreationWindow
中的变量名,Java FX 类是实现窗口的代码。
图 9-7
图 9-6 的节点层次结构
清单 9-8 给出了AccountCreationWindow
的代码。由于这段代码是您对 JavaFX 程序的第一次介绍,因此值得详细研究。JavaFX 程序扩展了库类Application
并遵循模板模式。模板类是Application
,有公共方法launch
和抽象策略方法start
。实现start
的策略类是AccountCreationWindow
。
public class AccountCreationWindow extends Application {
public void start(Stage stage) {
Pane root = createNodeHierarchy();
stage.setScene(new Scene(root));
stage.setTitle("Bank Account Demo");
stage.show();
}
public static void main(String[] args) {
Application.launch(args);
}
private Pane createNodeHierarchy() {
// see Listing 9-9
}
}
Listing 9-8The AccountCreationWindow Class
这种技术非常类似于Thread
使用模板模式的方式(你可能还记得第三章的结尾)。不同之处在于,与Thread
不同,客户端不能通过简单地调用Application
构造器来创建Application
对象。相反,静态工厂方法launch
负责创建Application
对象并在一个新线程中运行它。使用工厂方法的优点是,它对客户端隐藏了应用程序线程,从而防止线程被不正当地使用。
launch
方法还创建了一个Stage
对象,它管理窗口的框架。例如,Stage
方法setTitle
指定了要在窗口标题栏中显示的字符串。然后,launch
方法调用应用程序的start
方法,将Stage
对象作为参数传递。
列出 9-8 的start
方法调用createNodeHierarchy
来创建节点层次结构。它将该层次结构的根传递给一个新的Scene
对象,然后通过setScene
方法将该对象发送到舞台。
AccountCreationWindow
中的大部分代码致力于创建节点层次结构。清单 9-9 中显示了createNodeHierarchy
方法的代码。
private Pane createNodeHierarchy() {
VBox p3 = new VBox(8);
p3.setAlignment(Pos.CENTER);
p3.setPadding(new Insets(10));
p3.setBackground(
new Background(
new BackgroundFill(Color.SKYBLUE,
new CornerRadii(20), new Insets(0))));
Label type = new Label("Select Account Type:");
ChoiceBox<String> chbx = new ChoiceBox<>();
chbx.getItems().addAll("Savings", "Checking",
"Interest Checking");
p3.getChildren().addAll(type, chbx);
VBox p4 = new VBox(8);
p4.setAlignment(Pos.CENTER);
p4.setPadding(new Insets(10));
CheckBox ckbx = new CheckBox("foreign owned?");
Button btn = new Button("CREATE ACCT");
p4.getChildren().addAll(ckbx, btn);
HBox p2 = new HBox(8);
p2.setAlignment(Pos.CENTER);
p2.setPadding(new Insets(10));
p2.getChildren().addAll(p3, p4);
VBox p1 = new VBox(8);
p1.setAlignment(Pos.CENTER);
p1.setPadding(new Insets(10));
Label title = new Label("Create a New Bank Account");
double size = title.getFont().getSize();
title.setFont(new Font(size*2));
title.setTextFill(Color.GREEN);
p1.getChildren().addAll(title, p2);
btn.setOnAction(event -> {
String foreign = ckbx.isSelected() ? "Foreign " : "";
String acct = chbx.getValue();
title.setText(foreign + acct + " Account Created");
});
return p1;
}
Listing 9-9The createNodeHierarchy Method
这些控件的行为如下。一个Label
对象显示一个字符串。字符串的初始值在构造器中指定,但是它的值可以通过调用setText
方法随时更改。一个CheckBox
对象显示一个复选框和一个描述性字符串。如果该框当前被选中,它的isSelected
方法返回 true,否则返回 false。一个ChoiceBox
对象允许用户从对象列表中选择。getItems
方法返回列表,而getValue
方法返回选择的对象。
一个Button
对象有一个标签,并在被触发时执行一个动作。它的构造器指定标签。方法setOnAction
指定了它的动作。setOnAction
的参数是一个EventHandler
对象。第十章将会更详细地讨论事件处理程序。现在,只要知道这个事件处理程序是由 lambda 表达式指定的就足够了,该表达式的主体包含单击按钮时要执行的代码。
清单 9-9 中的 lambda 表达式调用复选框的isSelected
和选择框的getValue
方法来获取新账户的类型及其外国所有权状态。然后,它构造一条描述这些选择的消息,并将标题标签的文本设置为该消息。特别是,如果用户选择类型“Checking”,检查“is foreign”,并单击按钮,标题标签将显示“Foreign Checking Account Created”
您可能会感到失望,点击按钮并没有真正创建一个帐户。问题是,如果没有对Bank
对象的引用,窗口就不能创建帐户。第十一章将讨论将银行信息连接到窗口的正确方式,所以你需要等到那时。
窗格的行为如下。每个Pane
对象都有方法getChildren
,该方法返回其子节点的列表。客户可以随时修改列表的内容。它的setPadding
方法指定窗格周围边距中的像素数。
窗格的setBackground
方法指定其背景。AccountCreationWindow
的窗格p3
演示了它的用法。BackgroundFill
对象指定纯色背景。(另一种可能是使用一个BackgroundImage
对象,它指定一个图像作为背景。(BackgroundFill
)的三个参数指定颜色、角的圆度和背景周围的边距大小。
为VBox
和HBox
显示的构造器接受一个参数,即它们的子元素之间的像素数。他们的setAlignment
方法指定了子元素应该如何对齐。因为不是所有的Pane
子类都支持这个方法,所以它必须在VBox
和HBox
中被不透明地定义。
图 9-8 显示了本节描述的 JavaFX Node
类的类图。这个图故意省略了许多 JavaFX 类,这使得它看起来比实际简单得多。这种简单性使得理解 JavaFX 的设计原则更加容易。对 JavaFX 节点类的全面讨论超出了本书的范围。
图 9-8
节点类层次结构
注意这个类图与图 9-2 的Predicate
类图是多么的相似。基类是Control
的子类。包装类是Pane
,它的子类是递归类。从Pane
到Node
的依赖箭头带有标签*
,表示一个窗格可以包装任意数量的节点。
Node
接口声明了很多方法;图 9-8 的类图只显示了其中的三个。每个节点都有一个可以用作其 id 的字符串。默认情况下,id 是空字符串。方法setId
设置 id,方法getId
返回 id。
每个节点还需要知道它的大小和位置。方法getLayoutBounds
返回类型Bounds
的值。一个Bounds
对象包含节点的高度和宽度,以及它左上角的坐标。
控件和窗格计算大小的方式不同。控件的大小由其属性决定。例如,标签的大小取决于要显示的文本及其字体的大小和类型。窗格的大小基于其子窗格的大小加上由布局算法确定的任何额外空间(如子窗格之间的空间)。
getLayoutBounds
方法可以作为节点层次结构的后序遍历来实现。根窗格的大小取决于其子窗格的大小,而子窗格的大小又取决于其子窗格的大小,依此类推,直到到达Control
对象。
为了说明getLayoutBounds
方法,考虑一下类PrintNodeInformation
。它的代码出现在清单 9-10 中。
public class PrintNodeInformation extends Application {
private Label label;
private ChoiceBox<String> chbx;
private Button btn;
private Pane p1, p2;
public void start(Stage stage) {
createNodeHierarchy();
stage.setScene(new Scene(p1));
stage.setTitle("Bank Account Demo");
stage.show();
System.out.println("NODE\tWID HT");
printNodeSize(label);
printNodeSize(chbx);
printNodeSize(p2);
printNodeSize(btn);
printNodeSize(p1);
}
public static void main(String[] args) {
Application.launch(args);
}
private void printNodeSize(Node n) {
Bounds b = n.getLayoutBounds();
int w = (int) b.getWidth();
int h = (int) b.getHeight();
System.out.println(n.getId() + "\t" + w + " " + h );
}
private void createNodeHierarchy() {
p2 = new VBox(10);
p2.setId("p2");
label = new Label("Select Account Type:");
label.setId("label");
chbx = new ChoiceBox<>();
chbx.setId("chbox");
chbx.getItems().addAll("Savings", "Checking",
"Interest Checking");
p2.getChildren().addAll(label, chbx);
p1 = new HBox(10);
p1.setId("p1");
btn = new Button("CREATE ACCT");
btn.setId("button");
p1.setPadding(new Insets(10));
p1.getChildren().addAll(p2, btn);
}
}
Listing 9-10The Class PrintNodeInformation
这段代码是AccountCreationWindow
的精简版,只包含两个窗格和三个控件。这将创建如图 9-9 所示的窗口。
图 9-9
由 PrintNodeInformation 创建的窗口
start
方法为窗口中的每个节点调用方法printNodeSize
。printNodeSize
方法根据getLayoutBounds
返回的值打印给定节点的 id、高度和宽度。程序的输出如清单 9-11 所示。
NODE WID HT
label 132 17
chbox 149 27
p2 149 54
button 108 27
p1 287 74
Listing 9-11The Output of PrintNodeInformation
让我们来理解这个输出。首先考虑窗格p2
及其子节点label
和chbox
。这些控件计算自己的大小。程序输出断言chbox
比label
高一点,宽一点,截图证明了这一点。窗格p2
是一个VBox
,这意味着它的宽度应该与其最宽的子窗格相同,在本例中是chbox
。p2
的高度是其子元素的高度加上 10 个像素的总和,以说明它们之间的间距。这些值由程序输出验证。
现在考虑窗格p1
及其子节点p2
和btn
。窗格p1
的四边都有 10 像素的边距。因此,它的高度和宽度将比为其子级计算的值多 20 个像素。窗格p1
是一个HBox
,所以它的高度将是其子级的最大高度(在本例中是p2
的高度)加上 20 个像素的边距。p1
的宽度是其子元素的宽度加上 10 个像素的间距加上 20 个像素的边距。程序输出也验证了这些值。
组合模式
到目前为止,您已经看到了组合对象的两个例子:Java 谓词和 JavaFX 节点。尽管这些对象来自完全不同的领域,但它们的类图——如图 9-2 和图 9-8 所示——非常相似。这种相似性被称为组合模式。
组合模式表达了创建树形结构对象的首选方式。其类图如图 9-10 所示。树由类型为Component
的对象组成。这些组件或者是类型CompositeComponent
或者是类型BaseComponent
。一个BaseComponent
对象没有子对象,它将是树的一片叶子。一个CompositeComponent
对象可以有任意数量的子对象(因此它的依赖箭头上有一个“*
”标签),并且位于树的内部。
图 9-10
组合模式
Component
接口指定了所有组件将拥有的方法;图中没有显示这些方法。CompositeComponent
是一个抽象类,包含修改组合对象的子对象列表的方法。
组合模式的类图类似于装饰模式的类图。它们唯一的区别是装饰类只包装一个对象,而组合类可以包装多个对象。因此,装饰器形成了一条链,组合者形成了一棵树。
这种差异对装饰器和组合的使用方式有着深远的影响。装饰链有一个端点,它被视为链的主要对象。链上剩余的对象是“装饰器”,它们增强了这个主要对象的行为。另一方面,组合树有多个叶子,没有一个是主要的。相反,它的主要对象是树根。根把它的孩子当作“助手”,依靠他们来帮助计算它的方法的答案。这就是为什么组合方法经常被实现为树遍历。
图 9-10 中的类CompositeComponent
包含两种管理组合对象子对象的方法。这个设计是许多可能的设计之一。例如,JavaFX Pane
类只有一个方法getChildren
来管理其子类。
此外,Predicate
层次没有子管理方法。当一个组合的Predicate
对象被创建时,它的子对象由构造器分配,并且没有办法在以后改变这些子对象。这样的设计叫做静。具有添加和移除子元素方法的组合设计被称为动态。
动态组合的设计者可以决定将子管理方法放在组合接口或抽象包装类中。图 9-8 和 9-10 中显示的选择是将方法放在包装类中。这个决定导致方法不透明。例如,考虑 JavaFX 中的getChildren
方法。这个方法在Pane
中定义,这意味着它不能被Node
类型的变量调用。注意清单 9-9 中的变量p1
、p2
、p3
和p4
属于类VBox
和HBox
,而不是节点。
替代设计是将修改方法移到Component
界面。这种设计实现了透明性,但是以安全为代价。有了这样的设计,客户端可以向基本对象添加子对象,即使这样做没有合法的意义。
这种设计偶尔会被采用,但通常是作为最后的手段。Java Swing 库就是这样一个例子,它是 JavaFX 的前身。为了支持遗留软件,Swing 中的控件类被指定为Container
的子类,它是定义add
方法的类。因此,下面的代码是合法的 Java:
JButton b1 = new JButton("push me");
JButton b2 = new JButton("where am I?");
b1.add(b2);
add
方法将按钮b2
放在b1
的孩子列表中。但是由于b1
(无可非议地)忽略了这个列表,b2
将永远不会被显示。这种错误可能很难发现。
烹饪书的例子
对于组合模式的第三个例子,考虑编写一个程序来管理食谱中的食谱。食谱由一份配料清单和一些说明组成。配料可以是“基本食物”,如胡萝卜、苹果或牛奶;或者,它可能是另一个配方的结果。图 9-11 显示了一个示例配方。
图 9-11
一个示例配方
首要任务是设计食谱类。因为一个食谱可以包括其他食谱以及基本食物,所以指出了组合模式。图 9-12 给出了合理的类图。它包含一个基本食物类和一个食谱类。这两个类都实现了FoodItem
接口。Recipe
类还依赖于FoodItem
,后者表示它的成分列表。
图 9-12
食谱类图
清单 9-12 中出现了FoodItem
界面。它声明了BasicFood
和Recipe
必须实现的三个抽象方法。前两个方法表示食品项目的属性:方法name
返回项目的名称,如果食品不含动物产品,方法isVegan
返回 true。每一种基本食物都有一个明确的标志,表明它是否是素食主义者;如果一个食谱的所有成分都是纯素的,那么它就是纯素的。
public interface FoodItem extends Iterable<FoodItem> {
String name();
boolean isVegan();
Iterator<FoodItem> childIterator();
default Iterator<FoodItem> iterator() {
return new FoodIterator(this);
}
}
Listing 9-12The FoodItem Interface
FoodItem
的最后两种方法使客户能够检查食品的成分。childIterator
方法返回一个迭代器,其中包含给定食物项目的子项目。如果项目是一个食谱,那么迭代器包含它的成分;如果项目是一个基本的食物,那么迭代器将是空的。iterator
方法返回一个迭代器,该迭代器执行以给定对象为根的树的完整遍历。根据类FoodIterator
,方法iterator
被实现为接口的默认方法。该类将在下一节中讨论。
清单 9-13 给出了BasicFood
的代码。食物的名称和它的素食标志被传递给它的构造器,而name
和isVegan
方法返回这些值。childIterator
方法返回一个空迭代器,因为基本食物没有子代。
public class BasicFood implements FoodItem {
private String name;
private boolean isvegan;
public BasicFood(String name, boolean isvegan) {
this.name = name;
this.isvegan = isvegan;
}
public String name() {
return name;
}
public boolean isVegan() {
return isvegan;
}
public Iterator<FoodItem> childIterator() {
return Collections.emptyIterator();
}
public String toString() {
String veg = isvegan ? " (vegan)" : "";
return name + veg;
}
}
Listing 9-13The BasicFood Class
清单 9-14 给出了Recipe
的代码。一个Recipe
对象是一个组合对象,它的子对象是菜谱中使用的配料。成分保存在地图中。贴图的关键字是FoodItem
对象,它的值是相关的数量。方法addIngredient
将指定的配料添加到地图中。我选择将这种方法放在Recipe
(而不是FoodItem
)中,因为比起透明,我更喜欢安全。isVegan
方法通过检查食谱的成分来计算它的值。如果它发现一种非素食的成分,那么它返回 false 否则返回 true。注意递归是如何使这个方法在食谱的成分层次结构中执行树遍历的。最后,childIterator
方法返回与映射键相关联的迭代器。
public class Recipe implements FoodItem {
private String name;
private Map<FoodItem,Integer> ingredients = new HashMap<>();
private String directions;
public Recipe(String name, String directions) {
this.name = name;
this.directions = directions;
}
public void addIngredient(FoodItem item, int qty) {
ingredients.put(item, qty);
}
public String name() {
return name;
}
public boolean isVegan() {
Iterator<FoodItem> iter = childIterator();
while (iter.hasNext())
if (!iter.next().isVegan())
return false;
return true;
}
public Iterator<FoodItem> childIterator() {
return ingredients.keySet().iterator();
}
public int getQuantity(FoodItem item) {
return ingredients.get(item);
}
public String toString() {
String veg = isVegan() ? " (vegan)" : "";
String result = "Recipe for " + name + veg + "\n";
result += "Ingredients:";
for (FoodItem item : ingredients.keySet()) {
int qty = ingredients.get(item);
result += "\t" + qty + " " + item.name() + "\n";
}
return result + "Directions: " + directions + "\n";
}
}
Listing 9-14The Recipe Class
清单 9-15 显示了一个方法addRecipes
的代码,该方法说明了配方的创建。要创建一个食谱,首先调用Recipe
构造器,传入食谱的名称和方向。然后你称每种成分为addIngredient
法。注意,这个成分可以是一个BasicFood
对象,也可以是一个Recipe
对象。代码假设了一个全局变量cbook
,它将一个String
对象映射到其关联的Recipe
对象。
private static void addRecipes() {
Recipe dressing = new Recipe("dressing", "Mix well.");
dressing.addIngredient(new BasicFood("oil", true), 4);
dressing.addIngredient(new BasicFood("vinegar", true), 2);
cbook.put("dressing", dressing);
Recipe salad = new Recipe("salad",
"Chop lettuce, add bacon. Pour dressing over it.");
salad.addIngredient(new BasicFood("lettuce", true), 1);
salad.addIngredient(new BasicFood("bacon", false), 6);
salad.addIngredient(dressing, 1);
cbook.put("salad", salad);
}
Listing 9-15The addRecipes Method
遍历组合对象
组合对象通常具有遍历对象组件的方法。例如Predicate
中的test
方法、Node
中的getLayoutBounds
方法、FoodItem
中的isVegan
方法。这些方法被称为内部树遍历,因为遍历发生在方法内部,客户端不知道也不控制。这个概念类似于第六章中讨论的内部迭代的概念。像内部迭代器一样,这些内部树遍历是特定于任务的。
这一节关注的问题是,一个组合的客户机是否应该能够执行定制的树遍历,如果能够,应该如何执行。例如,Predicate
接口的设计使得定制的遍历是不可能的。设计者省略了任何使客户端能够检查谓词结构的方法,这意味着没有办法确定给定的Predicate
对象的基本谓词,甚至无法判断它是否是组合的。遍历Predicate
对象的唯一方法是调用它的test
方法。
另一方面,JavaFX 客户端可以通过使用其getchildren
方法来执行对Pane
对象的定制遍历。清单 9-16 中的NodeTraversal
类提供了一个例子。该类首先构建与图 9-9 中相同的 JavaFX 窗口。然后调用两个遍历窗口层次结构的方法:printAllNodes
,打印每个节点的高度和宽度;和getWidestControl
,返回最宽控件对应的节点。
public class NodeTraversal extends Application {
...
public void start(Stage stage) {
createNodeHierarchy(); // as in Listing 9-9 with root p1
stage.setScene(new Scene(p1));
stage.setTitle("Bank Account Demo");
stage.show();
System.out.println("NODE\tWID HT");
printAllNodes(p1);
Node n = getWidestControl(p1);
System.out.println("The widest control is "+ n.getId());
}
...
private void printAllNodes(Node n) {
// see listing 9-17
}
private Node getWidestControl(Node n) {
// see listing 9-18
}
}
Listing 9-16The NodeTraversal Class
清单 9-17 给出了printAllNodes
的代码。它的参数是一个节点n
,它打印以n
为根的组合层次结构中的每个节点。它通过执行n
的前序遍历来实现。也就是它先打印出n
的大小;然后,如果n
是一个窗格,它在n
的每个子节点上递归调用printAllNodes
。
private void printAllNodes(Node n) {
// first print the node
printNodeSize(n); // same as in Listing 9-10
// then print its children, if any
if (n instanceof Pane) {
Pane p = (Pane) n;
for (Node child : p.getChildren())
printAllNodes(child);
}
}
Listing 9-17Printing the Components of a Node
清单 9-18 给出了getWidestControl
的代码。这种方法的结构类似于printAllNodes
。如果参数n
是一个控件,那么它显然是树中唯一的控件,因此也是最宽的。如果n
是一个窗格,那么代码递归调用其子节点上的getWidestControl
,并选择返回对象中最宽的一个。
private Node getWidestControl(Node n) {
if (n instanceof Control)
return n;
Node widest = null;
double maxwidth = -1;
Pane p = (Pane) n;
for (Node child : p.getChildren()) {
Node max = getWidestControl(child);
double w = max.getLayoutBounds().getWidth();
if (w > maxwidth) {
widest = max;
maxwidth = w;
}
}
return widest;
}
Listing 9-18Calculating a Node’s Widest Control
虽然getChildren
方法可以以这种方式用于定制Node
对象的遍历,但它不太适合这个目的。该方法是在Pane
中定义的,这意味着它不能被透明地使用。结果是清单 9-17 和 9-18 中的代码需要 if 语句和笨拙的类型转换。
cookbook 示例中的遍历方法childIterator
和iterator
是在FoodItem
接口中定义的,因此更适合编写定制的树遍历。清单 9-19 中的Cookbook
代码演示了这些方法的使用。它的 main 方法创建一些Recipe
对象,并将它们保存在一个以名称为关键字的地图中。然后,它调用执行配方遍历的方法。
public class Cookbook {
private static Map<String,Recipe> cbook = new HashMap<>();
public static void main(String[] args) {
addRecipes(); // from Listing 9-15
System.out.println("\n---VEGAN RECIPES---");
printRecipes(r->r.isVegan());
System.out.println("\n---RECIPES USING 4+ ITEMS---");
printRecipes(r -> foodsUsed1(r)>=4);
printRecipes(r -> foodsUsed2(r)>=4);
printRecipes(r -> foodsUsed3(r)>=4);
System.out.println("\n---RECIPES COMPRISING SALAD---");
printRecipesUsedIn1(cbook.get("salad"));
printRecipesUsedIn2(cbook.get("salad"));
System.out.println("\n---SHOPPING LIST FOR SALAD---");
printShoppingList(cbook.get("salad"));
}
... // the remaining methods are in listings 9-20 to 9-26
}
Listing 9-19The Cookbook Class
清单 9-20 显示了printRecipes
方法。对于 cookbook 中的每个食谱,如果它满足给定的谓词,它将打印该食谱。Cookbook
类调用了printRecipes
四次,每次都有不同的谓词。第一个谓词调用配方的isVegan
方法,该方法执行内部树遍历。剩下的三个谓词调用方法foodsUsed
的变体,使用外部树遍历来计算食谱中使用的基本食物。这些方法的代码出现在清单 9-21 到 9-23 中。
private static void printRecipes(Predicate<Recipe> pred) {
for (Recipe r : cbook.values())
if (pred.test(r))
System.out.println(r);
}
Listing 9-20The printRecipes Method
方法foodsUsed1
的代码出现在清单 9-21 中。它调用childIterator
方法来显式检查指定食品的成分。如果配料是基本食品,那么它会增加计数。如果一个配料是一个食谱,那么它递归地调用这个食谱上的foodsUsed1
。请注意,与 JavaFX 示例中的方法相比,childIterator
方法的透明性简化了代码。
private static int foodsUsed1(FoodItem r) {
int count = 0;
if (r instanceof BasicFood)
count = 1;
else {
Iterator<FoodItem> iter = r.childIterator();
while (iter.hasNext())
count += foodsUsed1(iter.next());
}
return count;
}
Listing 9-21The foodsUsed1 Method
方法foodsUsed2
使用iterator
方法检查以指定配方为根的整个组合树。这段代码比foodsUsed1
简单,因为这段代码可以通过迭代器执行一次循环,而不需要递归。它的代码出现在清单 9-22 中。
private static int foodsUsed2(FoodItem r) {
int count = 0;
Iterator<FoodItem> iter = r.iterator();
while (iter.hasNext())
if (iter.next() instanceof BasicFood)
count++;
return count;
}
Listing 9-22The foodsUsed2 Method
方法foodsUsed3
与foodsUsed2
基本相同。不同之处在于,iterator
方法是通过 for-each 循环隐式调用的。
private static int foodsUsed3(FoodItem r) {
int count = 0;
for (FoodItem item : r)
if (item instanceof BasicFood)
count++;
return count;
}
Listing 9-23The foodsUsed3 Method
Cookbook
类中的两个printRecipesUsedIn
方法打印出制作给定食谱所需的所有食谱的名称。例如,制作沙拉所需的食谱是“沙拉”和“调料”这两种方法的代码都利用了FoodItem
是可迭代的这一事实。方法printRecipesUsedIn1
使用iterator
方法遍历菜谱的组合树,打印菜谱中任何食品的名称。它的代码出现在清单 9-24 中。
private static void printRecipesUsedIn1(Recipe r) {
for (FoodItem item : r) {
if (item instanceof Recipe)
System.out.println(item.name());
}
}
Listing 9-24The printRecipesUsedIn1 Method
printRecipesUsedIn2
的代码出现在清单 9-25 中。它使用了方法forEach
和访问者模式。
private static void printRecipesUsedIn2(Recipe r) {
r.forEach(item -> {
if (item instanceof Recipe) {
System.out.println(item.name());
}});
}
Listing 9-25The printRecipesUsedIn2 Method
清单 9-26 给出了printShoppingList
的代码。该方法打印配方中使用的每个基本项目所需的名称和数量。该方法的第二个参数是将要制作的食谱的份数。代码的一个复杂性是配方中每一项的数量必须乘以配方中正在制作的部分的数量。
这个方法与其他方法不同,因为它的代码需要知道组合树的结构。特别是,代码需要知道一种配料属于哪种配方,以及该配方将被制成多少份。因此,代码必须使用childIterator
来手动遍历配方的成分,并为子配方执行递归。iterator
方法在这里没有用。
private static void printShoppingList(Recipe r, int howmany) {
Iterator<FoodItem> iter = r.childIterator();
while (iter.hasNext()) {
FoodItem item = iter.next();
int amt = r.getQuantity(item) * howmany;
if (item instanceof BasicFood)
System.out.println(item.name() + " " + amt);
else
printShoppingList((Recipe) item, amt);
}
}
Listing 9-26The printShoppingList Method
本节的最后一个主题是如何实现iterator
方法。回想一下清单 9-12 ,这个方法的代码在FoodItem
接口中声明如下:
default Iterator<FoodItem> iterator() {
return new FoodIterator(this);
}
清单 9-27 给出了FoodIterator
类的代码。它实现了Iterator<FoodItem>
。它的构造器的参数是一个食物项目f
。对next
的连续调用将返回以f
为根的组合层次结构中的每一项,从f
本身开始。
public class FoodIterator implements Iterator<FoodItem> {
private Stack<Iterator<FoodItem>> s = new Stack<>();
public FoodIterator(FoodItem f) {
Collection<FoodItem> c = Collections.singleton(f);
s.push(c.iterator());
}
public boolean hasNext() {
return !s.isEmpty();
}
public FoodItem next() {
FoodItem food = s.peek().next(); // return this value
if (!s.peek().hasNext())
s.pop(); // pop the iterator when it is empty
Iterator<FoodItem> iter = food.childIterator();
if (iter.hasNext())
s.push(iter); // push the child iterator if non-empty
return food;
}
}
Listing 9-27The FoodIterator Class
next
方法本质上是使用一堆迭代器来执行非递归的树遍历。每次调用next
都会从最顶层的迭代器中移除一个项目。如果迭代器没有更多的元素,那么它将从堆栈中弹出。如果被检索的条目有子条目,那么它的子迭代器被推到堆栈上。如果堆栈不为空,hasNext
方法返回 true。构造器通过添加一个包含组合层次根的迭代器来初始化堆栈。
摘要
组合对象具有层次结构。层次结构中的每个对象实现相同的接口,称为组合接口。组合模式描述了组织组合对象的类的首选方式。这些类形成了两个类别:基类,其对象是组合层次结构的叶子,以及递归类,其对象形成了层次结构的内部。每个递归对象包装一个或多个实现组合接口的对象。这些包装的对象被称为它的子对象。
从语法上来说,组合对象非常类似于装饰对象;唯一的区别是一个组合可以有多个子元素,而一个装饰器只能有一个。然而,这种不同完全改变了他们的目的。装饰器是一个链,其中递归对象用来增强链末端的基本对象的方法。组合是一棵树,它的非根对象组合起来执行根的方法。组合方法通常作为树遍历来实现。
当设计符合组合模式的类时,您需要考虑它们的方法的透明性。修改组合的子列表的方法不应该在组合接口中定义,因为它将允许客户端在基本对象上执行无意义的(和潜在危险的)操作。最好在包装类中定义这样的方法,并不透明地使用它们。另一方面,可以为组合接口设计方法,使客户端能够透明地遍历组合层次结构。本章介绍了两个这样的方法:childIterator
,它返回一个包含对象子对象的迭代器,以及iterator
,它返回一个包含整个组合层次结构的迭代器。实现iterator
方法还可以让组合接口扩展Iterable
,这意味着客户端可以使用forEach
方法和 for-each 循环来检查组合对象。
十、观察者
随着新对象的创建和现有对象的修改,程序的状态会随着时间而改变。该程序可以响应这些变化事件中的一些。例如,向一个外资银行账户存入一大笔存款可能会启动一个检查非法活动的流程。该程序还可以响应某些输入事件,例如鼠标动作和键盘输入。例如,鼠标点击按钮通常会得到响应,但鼠标点击标签通常会被忽略。
使用观察器是一种通用技术,用于管理程序对事件的响应。一个对象可以维护一个观察者列表,并在一个值得注意的事件发生时通知他们。本章介绍了观察者模式,这是将观察者合并到代码中的首选方式。本章给出了使用它的实际例子,并研究了各种设计问题和权衡。
观察者和可观察物
以银行业的演示为例。假设银行希望在创建新账户时执行一些操作。例如,营销部门希望向账户所有人发送“欢迎来到银行”信息包,审计部门希望对新的外资账户进行背景调查。
为了实现这个功能,Bank
类将需要一个对每个想要被通知新帐户的对象的引用,这样它的newAccount
方法就可以通知这些对象。清单 10-1 中的代码是这一思想的直接实现,其中Bank
类保存了对MarketingRep
和Auditor
对象的引用。
public class Bank implements Iterable<BankAccount> {
private Map<Integer,BankAccount> accounts;
private int nextacct;
private MarketingRep rep;
private Auditor aud;
public Bank(Map<Integer,BankAccount> accounts, int n,
MarketingRep r, Auditor a) {
this.accounts = accounts;
nextacct = n;
rep = r; aud = a;
}
public int newAccount(int type, boolean isforeign) {
int acctnum = nextacct++;
BankAccount ba =
AccountFactory.createAccount(type, acctnum);
ba.setForeign(isforeign);
accounts.put(acctnum, ba);
rep.update(acctnum, isforeign);
aud.update(acctnum, isforeign);
return acctnum;
}
...
}
Listing 10-1Adding Observers to the Bank Class
MarketingRep
和Auditor
类被称为观察者类,它们的对象被称为观察者。Bank
类被称为可观测类。当创建新帐户时,它会通知其观察者。按照惯例,通知方法被命名为“update ”,以表示可观察对象正在告诉它的观察者更新已经发生。
可观察对象-观察者的关系类似于发布者和他们的订阅者之间的关系。当出版商有新材料要分发时,它会通知其订户。因此,在程序中使用观察者也被称为发布-订阅技术。
Twitter 应用程序是一个众所周知的发布-订阅示例。一个 Twitter 用户有一个关注者列表。当有人在推特上发布消息时,该消息将被发送给列表中的每个关注者(订阅者)。发布-订阅技术也被留言板和 listservs 使用。如果有人向 listserv 发送消息,那么 listserv 的所有订户都会收到该消息。
清单 10-1 中的Bank
代码的问题在于,银行确切地知道哪些对象正在观察它。换句话说,可观察类与其观察者类紧密耦合。这种紧密耦合使得每次观察器改变时都必须修改Bank
。
例如,假设银行决定使用多个营销代理,比如一个用于国外账户,另一个用于国内账户。然后,银行将有两个MarketingRep
对象观察它。或者假设银行决定添加一个观察者,将每个新帐户的信息记录到一个文件中。在这种情况下,Bank
需要持有一个额外的观察者对象,这次是类型AccountLogger
。
解决这个问题的正确方法是注意,银行并不真正关心它有多少个 observer 对象,也不关心它们的类是什么。银行只需持有一份观察员名单就足够了。当一个新帐户被创建时,它可以通知列表中的每个对象。
为了实现这个想法,observer 类必须实现一个公共接口。调用这个接口BankObserver
。它将有一个名为update
的方法,如清单 10-2 所示。
public interface BankObserver {
void update(int acctnum, boolean isforeign);
}
Listing 10-2The BankObserver Interface
然后,Bank
代码将看起来像清单 10-3 。请注意这种设计如何极大地减少了可观察对象和观察者之间的耦合。
public class Bank implements Iterable<BankAccount> {
private Map<Integer,BankAccount> accounts;
private int nextacct;
private List<BankObserver> observers;
public Bank(Map<Integer,BankAccount> accounts,
int n, List<BankObserver> L) {
this.accounts = accounts;
nextacct = n;
observers = L;
}
public int newAccount(int type, boolean isforeign) {
int acctnum = nextacct++;
BankAccount ba =
AccountFactory.createAccount(type, acctnum);
ba.setForeign(isforeign);
accounts.put(acctnum, ba);
observers.forEach(obs->obs.update(acctnum, isforeign));
return acctnum;
}
...
}
Listing 10-3An Improved Bank Class
提供给Bank
构造器的列表可以包含任意数量的观察者,这些观察者可以属于任何实现BankObserver
的类。对于一个具体的例子,考虑一个简单版本的Auditor
类,它将每个新的外国拥有的账户的账号写入控制台。它的代码可能看起来像清单 10-4 。
public class Auditor implements BankObserver {
public void update(int acctnum, boolean isforeign) {
if (isforeign)
System.out.println("New foreign acct" + acctnum);
}
}
Listing 10-4The Auditor Class
图 10-1 的类图描述了Bank
类和它的观察者之间的关系。
图 10-1
银行阶级及其观察者
观察者模式
Bank
和它的观察者之间的这种关系是观察者模式的一个例子。基本思想是一个可观察的物体拥有一系列的观察者。当被观察对象决定公布其状态的变化时,它会通知它的观察者。这个想法在图 10-2 的类图中有所表达。
图 10-2
观察者模式
这个类图很像图 10-1 的图。Bank
类是可观察对象,BankObserver
是观察者接口,Auditor
、MarketingRep
、AccountLogger
是观察者类。
尽管图 10-2 描述了观察者模式的整体架构,但它在实际细节上有些欠缺。update
方法的参数应该是什么?可观察对象是如何得到它的观察者列表的?事实证明,有几种方法可以回答这些问题,这导致了观察者模式的多种变化。以下小节研究了一些设计可能性。
推与拉
第一个问题是考虑对update
方法的争论。在清单 10-2 的BankObserver
接口中,update
有两个参数,它们是新创建的银行账户的值,观察者对这些值感兴趣。在更现实的程序中,该方法可能需要更多的参数。例如,一个现实的Auditor
类想要知道所有者的账号、外国身份和税收 id 号;而MarketingRep
类想要所有者的账号、姓名和地址。
这种设计技术被称为推,因为可观察对象将值“推”给它的观察者。推送技术的困难在于,update
方法必须发送任何观察者可能需要的所有值。如果观察者需要许多不同的值,那么update
方法就变得不实用了。此外,可观察对象必须猜测任何未来的观察者可能需要什么值,这可能导致可观察对象“以防万一”地推出许多不必要的值。
另一种叫做拉的设计技术缓解了这些问题。在拉技术中,update
方法包含对可观察对象的引用。然后,每个观察者可以使用该参考从可观察对象中“提取”它想要的值。
清单 10-5 显示了BankObserver
的代码,修改后使用了拉技术。它的update
方法传递一个对Bank
对象的引用。它还传递新帐户的帐号,以便观察者可以从正确的帐户中提取信息。
public interface BankObserver {
void update(Bank b, int acctnum);
}
Listing 10-5Revising the BankObserver Interface to Use Pull
清单 10-6 显示了Auditor
观察者的修改代码。注意它的update
方法是如何从提供的Bank
引用中提取外来状态标志的。
public class Auditor implements BankObserver {
public void update(Bank b, int acctnum) {
boolean isforeign = b.isForeign(acctnum);
if (isforeign)
System.out.println("New foreign acct" + acctnum);
}
}
Listing 10-6The Revised Auditor Class
拉技术有某种优雅之处,因为可观察对象为每个观察者提供了工具,使其能够提取所需的信息。拉技术的一个问题是,观测者必须返回到可观测值来检索所需的值,这种时间滞后可能会影响正确性。
例如,假设一个用户创建了一个新的国内帐户,但是不久之后调用setForeign
方法将其更改为国外所有者。如果观察者在执行setForeign
之后从银行提取账户信息,那么它将错误地认为该账户是作为外国账户创建的。
另一个问题是,拉技术只能在被观测者保留观测者想要的信息时使用。例如,假设一个银行观察者希望每次执行deposit
方法时都得到通知,这样它就可以调查异常大的存款。如果银行不保存每笔存款的金额,那么拉是不可行的。相反,银行将需要通过其update
方法推送存款金额。
混合推挽式设计可用于平衡推挽式设计。例如,update
方法可以推送一些值以及对可观察对象的引用。或者,update
方法可以推送一个相关对象的引用,观察者可以从中提取。清单 10-7 给出了后一种接口的例子。在这种情况下,可观察对象推送一个对新的BankAccount
对象的引用,观察者可以从中获取他们需要的信息。
public interface BankObserver {
void update(BankAccount ba);
}
Listing 10-7A Hybrid Push-Pull BankObserver Interface
管理观察者列表
需要研究的第二个问题是,一个可观察对象如何获得它的观察列表。在清单 10-3 中,列表通过其构造器传递给可观察对象,并在整个程序生命周期中保持不变。然而,这样的设计不能处理观察者动态地来来去去的情况。
例如,假设您希望观察者记录正常银行营业时间之外发生的所有银行交易。一种选择是让观察者持续活跃。收到每个事件通知后,观察器会检查当前时间。如果银行关门了,它就会记录事件。
问题在于,银行活动通常在营业时间最繁忙,这意味着观察者将花费大量时间忽略它收到的大多数通知。更好的办法是在银行晚上关门时将观察者添加到观察者列表中,并在银行早上重新开门时将其删除。
为了适应这种需求,observables 必须提供方法来显式地在观察者列表中添加和删除观察者。这些方法通常被称为addObserver
和removeObserver
。有了这些变化,Bank
代码看起来将如清单 10-8 所示。
public class Bank implements Iterable<BankAccount> {
private Map<Integer,BankAccount> accounts;
private int nextacct;
private List<BankObserver> observers = new ArrayList<>();
public Bank(Map<Integer,BankAccount> accounts, int n) {
this.accounts = accounts;
nextacct = n;
}
public void addObserver(BankObserver obs) {
observers.add(obs);
}
public void removeObserver(BankObserver obs) {
observers.remove(obs);
}
...
}
Listing 10-8Another Revision to the Bank Class
这种将观察者动态添加到可观察列表的技术是依赖注入的一种形式。可观察对象对每个观察者都有依赖关系,这种依赖关系通过它的addObserver
方法注入到可观察对象中。这种形式的依赖注入被称为方法注入(相对于清单 10-3 中的构造器注入)。
有两种方法来执行方法注入。第一种方法是让另一个类(如BankProgram
)将观察者添加到列表中;另一种方法是每个观察者添加自己。清单 10-9 的BankProgram
代码说明了方法注入的第一种形式。
public class BankProgram {
public static void main(String[] args) {
...
Bank bank = new Bank(accounts, nextacct);
BankObserver auditor = new Auditor();
bank.addObserver(auditor);
...
}
}
Listing 10-9One Way to perform Method Injection
这种形式的方法注入的一个优点是观察者对象可以用 lambda 表达式来表示,因此不需要显式的观察者类。这个思路如清单 10-10 所示,假设清单 10-7 的BankObserver
接口。
public class BankProgram {
public static void main(String[] args) {
...
Bank bank = new Bank(accounts, nextacct);
bank.addObserver(ba -> {
if (ba.isForeign())
System.out.println("New foreign acct: "
+ ba.getAcctNum());
});
...
}
}
Listing 10-10Revising BankProgram to Use a Lambda Expression
清单 10-11 展示了方法注入的第二种形式。Auditor
观察者通过其构造器接收对可观察的Bank
对象的引用,并将自己添加到银行的观察者列表中。
public class BankProgram {
public static void main(String[] args) {
...
Bank bank = new Bank(accounts, nextacct);
BankObserver auditor = new Auditor(bank);
...
}
}
public class Auditor implements BankObserver {
public Auditor(Bank b) {
b.addObserver(this);
}
...
}
Listing 10-11A Second Way to Perform Method Injection
这种技术导致了可观察对象和它的观察者之间非常有趣的关系。可观察对象调用其观察者的update
方法,但对它们一无所知。另一方面,观察者知道哪个对象在调用他们。这种情况与典型的方法调用完全相反,在典型的方法调用中,方法的调用方知道它在调用谁,而被调用方不知道谁在调用它。
Java 中的通用观察者模式
Java 库包含接口Observer
和类Observable
,旨在简化观察者模式的实现。Observer
是一个通用的观察者接口,其代码出现在清单 10-12 中。它的update
方法有两个参数,支持混合推拉设计。
interface Observer {
public void update(Observable obs, Object obj);
}
Listing 10-12The Observer Interface
update
的第一个参数是对发出调用的可观察对象的引用,供拉技术使用。第二个参数是一个包含 push 技术发送的值的对象。如果可观察对象想要推送多个值,那么它会将它们嵌入到单个对象中。如果可观察对象不想推送任何值,那么它会将 null 作为第二个参数发送。
Observable
是一个抽象类,实现了观察者列表及其相关方法。observable 扩展了这个抽象类,以便继承这个功能。它的代码出现在清单 10-13 中。
public abstract class Observable {
private List<Observer> observers = new ArrayList<>();
private boolean changed = false;
public void addObserver(Observer obs) {
observers.add(obs);
}
public void removeObserver(Observer obs) {
observers.remove(obs);
}
public void notifyObservers(Object obj) {
if (changed)
for (Observer obs : observers)
obs.update(this, obj);
changed = false;
}
public void notifyObservers() {
notifyObservers(null);
}
public void setChanged() {
changed = true;
}
...
}
Listing 10-13The Observable Class
注意两种不同的notifyObservers
方法。单参数版本将参数作为第二个参数传递给观察者update
。零参数版本将 null 作为第二个参数发送给update
。
还要注意,在客户端第一次调用setChanged
之前,notifyObservers
方法什么也不做。setChanged
的目的是支持定期执行通知的程序,而不是在每次更改后立即执行通知。在这样的程序中,做定期通知的代码可以在任何时候调用notifyObservers
,确信除非setChanged
在上次通知后被调用,否则它不会有任何效果。
清单 10-14 展示了如何使用Observer
和Observable
类以及推送技术重写银行演示。这个列表包含了Bank
(可观察对象)和Auditor
(观察者)的相关代码。注意Bank
不再需要代码来管理它的观察者列表和相关方法,因为它的超类Observable
处理它们。
public class Bank extends Observable
implements Iterable<BankAccount> {
...
public int newAccount(int type, boolean isforeign) {
int acctnum = nextacct++;
BankAccount ba =
AccountFactory.createAccount(type, acctnum);
ba.setForeign(isforeign);
accounts.put(acctnum, ba);
setChanged();
ObserverInfo info =
new ObserverInfo(acctnum, isforeign);
notifyObservers(info);
return acctnum;
}
...
}
public class Auditor implements Observer {
public Auditor(Bank bank) {
bank.addObserver(this);
}
public void update(Observable obs, Object obj) {
ObserverInfo info = (ObserverInfo) obj;
if (info.isForeign())
System.out.println("New foreign account: "
+ info.getAcctNum());
}
}
Listing 10-14Rewriting Bank and Auditor Using Observable and Observer
update
方法的第二个参数是一个类型为ObserverInfo
的对象。这个类在一个对象中嵌入了帐号和外国身份标志。它的代码出现在清单 10-15 中。
public class ObserverInfo {
private int acctnum;
private boolean isforeign;
public ObserverInfo(int a, boolean f) {
acctnum = a;
isforeign = f;
}
public int getAcctNum() {
return acctnum;
}
public boolean isForeign() {
return isforeign;
}
}
Listing 10-15The ObserverInfo Class
虽然Observable
和Observer
实现了观察者模式的基础,但是它们的通用性质有一些缺点。Observable
是一个抽象类,不是一个接口,这意味着 observable 不能扩展任何其他类。update
方法是“一刀切”,因为应用程序必须将其推送的数据挤入和挤出一个对象,如ObserverInfo
。由于这些缺点,以及编写它们提供的代码相当简单的事实,通常跳过使用Observable
和Observer
会更好。
事件
前面几节集中讨论了当一个新的银行账户被创建时,Bank
类如何通知它的观察者。新账户的创建是事件的一个例子。一般来说,可观察对象可能希望向其观察者通知多种类型的事件。例如,版本 18 Bank
类有四种事件类型。这些类型对应影响其银行账户的四种方式,即newAccount
、deposit
、setForeign
、addInterest
。版本 18 bank demo 将这四种事件类型定义为 enum BankEvent
的常量。参见清单 10-16 。
public enum BankEvent {
NEW, DEPOSIT, SETFOREIGN, INTEREST;
}
Listing 10-16The Version 18 BankEvent Enum
问题是像Bank
这样的可观察对象如何管理四个不同事件的通知。有两个问题:观察对象应该保留多少个观察列表,以及观察对象接口应该有多少个更新方法。也可以为每个事件创建一个单独的观察者接口。
考虑一下观察者列表。保持一个单一的列表更简单,但是这将意味着每个观察者将被通知每个事件。如果被观察对象能够为每个事件保留一个观察列表,那么通常会更好,这样它的观察对象就可以只注册他们关心的事件。
现在考虑更新方法。一种选择是观察者接口为每个事件提供一个更新方法。这样做的好处是,您可以设计每个方法,以便为其事件定制参数。缺点是观察者必须为每个方法提供一个实现,即使它只对其中一个感兴趣。
另一种方法是让接口有一个更新方法。该方法的第一个参数可以标识事件,其余的参数将传递足够的信息来满足所有观察者。缺点是可能很难将所有这些信息打包到一组参数值中。
对于 18 版本的银行演示,我选择使用单一的update
方法。清单 10-17 给出了版本 18 BankObserver
的接口。update
方法有三个参数:事件、受影响的银行账户和一个表示存款金额的整数。并非所有的论点都适用于每个事件。例如,DEPOSIT
观察者将使用所有的自变量;NEW
和SETFOREIGN
观察员将只使用赛事和银行账户;而INTEREST
观察者将只使用事件。
public interface BankObserver {
void update(BankEvent e, BankAccount ba, int depositamt);
}
Listing 10-17The Version 18 BankObserver Interface
版本 18 Bank
类为四种事件类型中的每一种都有一个观察者列表。为了方便起见,它将这些列表捆绑到一个基于事件类型的映射中。它的addObserver
方法向指定的列表中添加一个观察者。removeObserver
方法类似,但是为了方便起见,省略了它的代码。Bank
还有一个notifyObservers
方法,通知指定列表上的观察者。
Bank
有四种生成事件的方法:newAccount
、deposit
、setForeign
和addInterest
。版本 18 修改了这些方法来调用notifyObservers
方法。清单 10-18 给出了代码的相关部分。请注意,notifyObservers
的第三个参数对于除了deposit
之外的所有方法都是 0,因为DEPOSIT
是唯一与该值相关的事件。其他事件忽略该值。
public class Bank implements Iterable<BankAccount> {
private Map<Integer,BankAccount> accounts;
private int nextacct;
private Map<BankEvent,List<BankObserver>> observers
= new HashMap<>();
public Bank(Map<Integer,BankAccount> accounts, int n) {
this.accounts = accounts;
nextacct = n;
for (BankEvent e : BankEvent.values())
observers.put(e, new ArrayList<BankObserver>());
}
public void addObserver(BankEvent e, BankObserver obs) {
observers.get(e).add(obs);
}
public void notifyObservers(BankEvent e, BankAccount ba,
int depositamt) {
for (BankObserver obs : observers.get(e))
obs.update(e, ba, depositamt);
}
public int newAccount(int type, boolean isforeign) {
int acctnum = nextacct++;
BankAccount ba =
AccountFactory.createAccount(type, acctnum);
ba.setForeign(isforeign);
accounts.put(acctnum, ba);
notifyObservers(BankEvent.NEW, ba, 0);
return acctnum;
}
public void setForeign(int acctnum, boolean isforeign) {
BankAccount ba = accounts.get(acctnum);
ba.setForeign(isforeign);
notifyObservers(BankEvent.SETFOREIGN, ba, 0);
}
public void deposit(int acctnum, int amt) {
BankAccount ba = accounts.get(acctnum);
ba.deposit(amt);
notifyObservers(BankEvent.DEPOSIT, ba, amt);
}
public void addInterest() {
forEach(ba->ba.addInterest());
notifyObservers(BankEvent.INTEREST, null, 0);
}
...
}
Listing 10-18The Version 18 Bank Class
类别Auditor
的版本 18 代码出现在清单 10-19 中。该类是两个事件的观察者:NEW 和 SETFOREIGN。因为它观察两个事件,所以它检查其update
方法的第一个参数来确定哪个事件发生了。
public class Auditor implements BankObserver {
public Auditor(Bank bank) {
bank.addObserver(BankEvent.NEW, this);
bank.addObserver(BankEvent.SETFOREIGN, this);
}
public void update(BankEvent e, BankAccount ba,
depositamt amt) {
if (ba.isForeign()) {
if (e == BankEvent.NEW)
System.out.println("New foreign account: "
+ ba.getAcctNum());
else
System.out.println("Modified foreign account: "
+ ba.getAcctNum());
}
}
}
Listing 10-19The Version 18 Auditor Class
版本 18 BankProgram
代码出现在清单 10-20 中。该类创建了两个观察器:一个Auditor
实例和一个观察DEPOSIT
事件的 lambda 表达式。如果检测到超过 100,000 美元的存款,这个观察者调用银行的makeSuspicious
方法。
public class BankProgram {
public static void main(String[] args) {
SavedBankInfo info = new SavedBankInfo("bank18.info");
Map<Integer,BankAccount> accounts = info.getAccounts();
int nextacct = info.nextAcctNum();
Bank bank = new Bank(accounts, nextacct);
Auditor aud = new Auditor(bank);
bank.addObserver(BankEvent.DEPOSIT,
(event,ba,amt) -> {
if (amt > 10000000)
bank.makeSuspicious(ba.getAcctNum());
});
...
}
}
Listing 10-20The Version 18 BankProgram Class
JavaFX 中的观察员
事件和事件观察器在 GUI 应用程序中起着重要的作用。在 JavaFX 中,用户与屏幕的交互导致一系列的输入事件发生。JavaFX 库指定了几种类型的输入事件。每个事件类型都是扩展类Event
的类中的一个对象。三个这样的等级是MouseEvent
、KeyEvent
和ActionEvent
。清单 10-21 显示了这些类的一些常见事件类型。
MouseEvent.MOUSE_CLICKED
MouseEvent.MOUSE_ENTERED
KeyEvent.KEY_TYPED
ActionEvent.ACTION
Listing 10-21Four Common JavaFX Event Types
事件类型表示生成的事件的种类。事件的目标是负责处理它的节点。例如,如果用户鼠标点击屏幕上的某个特定位置,那么该位置最顶端的节点将成为一个MOUSE_CLICKED
事件的目标。
每个 JavaFX Node
对象都是可观察的。节点为每种事件类型保留一个单独的观察器列表。也就是说,一个节点将有一个鼠标点击观察器、鼠标输入观察器、键盘输入观察器等的列表。
在 JavaFX 中,事件观察者被称为事件处理者。每个节点都有方法addEventHandler
,该方法为给定的事件类型向节点的观察者列表添加一个观察者。这个方法有两个参数:感兴趣的事件类型和对事件处理程序的引用。
事件处理程序属于实现接口EventHandler
的类。该接口只有一个方法,名为handle
。它的代码出现在清单 10-22 中。
public interface EventHandler {
void handle(Event e);
}
Listing 10-22The EventHandler Interface
清单 10-23 给出了事件处理程序类ColorLabelHandler
的代码,其handle
方法将指定标签的文本更改为指定的颜色。
public class ColorLabelHandler
implements EventHandler<Event> {
private Label lbl;
private Color color;
public ColorLabelHandler(Label lbl, Color color) {
this.lbl = lbl;
this.color = color;
}
public void handle(Event e) {
lbl.setTextFill(color);
}
}
Listing 10-23The ColorLabelHandler Class
作为事件处理程序的使用示例,再次考虑清单 9-8 和 9-9 中的AccountCreationWindow
程序。图 10-3 显示其初始屏幕。
图 10-3
初始帐户创建窗口屏幕
清单 10-24 修改了程序,增加了四个事件处理程序:
-
标题标签上的一个
MOUSE_ENTERED
处理程序,当鼠标进入标签区域时,它的文本变成红色。 -
标题标签上的一个
MOUSE_EXITED
处理程序,当鼠标退出标签区域时,它将文本变回绿色。这两个处理程序的组合产生了一种“翻转”效果,当鼠标滑过标签时,标签会暂时变成红色。 -
最外层窗格上的一个
MOUSE_CLICKED
处理程序,通过取消选中复选框,将选择框的值设置为 null,并将标题标签的文本改回“Create a new bank account”来重置屏幕 -
按钮上的一个
MOUSE_CLICKED
处理程序,它使用复选框和选择框的值来改变标题标签的文本。
public class AccountCreationWindow extends Application {
public void start(Stage stage) {
...
Label title = ... // the label across the top
title.addEventHandler(MouseEvent.MOUSE_ENTERED,
new ColorLabelHandler(title, Color.RED));
title.addEventHandler(MouseEvent.MOUSE_EXITED,
e -> title.setTextFill(Color.GREEN));
Pane p1 = ... // the outermost pane
p1.addEventHandler(MouseEvent.MOUSE_CLICKED,
e -> {
ckbx.setSelected(false);
chbx.setValue(null);
title.setText("Create a New Bank Account");
});
Button btn = ... // the CREATE ACCT button
btn.addEventHandler(MouseEvent.MOUSE_CLICKED,
e -> {
String foreign = ckbx.isSelected() ?
"Foreign " : "";
String acct = chbx.getValue();
title.setText(foreign + pref + acct
+ " Account Created");
stage.sizeToScreen();
});
...
}
}
Listing 10-24A Revised AccountCreationWindow Class
第一个处理程序使用清单 10-23 中的ColorLabelHandler
类。它的handle
方法将在鼠标进入标题标签的区域时执行。第二个处理程序使用 lambda 表达式来定义handle
方法。lambda 表达式(或内部类)的一个特性是它可以从其周围的上下文中引用变量(如title
)。这避免了像在ColorLabelHandler
中那样将这些值传递给构造器的需要。
第三个处理程序观察窗格p1
上的鼠标点击,第四个处理程序观察按钮上的鼠标点击。这两个处理程序都通过 lambda 表达式定义了它们的handle
方法。
指定按钮处理程序的一种常见方式是用ActionEvent.ACTION
替换事件类型MouseEvent.MOUSE_CLICKED
。一个ACTION
事件表示来自用户的“提交”请求。按钮支持几种提交请求,比如鼠标点击按钮,通过触摸屏触摸按钮,当按钮获得焦点时按空格键。为按钮处理程序使用ACTION
事件通常比使用MOUSE_CLICKED
事件更好,因为单个ACTION
事件处理程序将支持所有这些请求。
Button
类也有一个方法setOnAction
,它进一步简化了按钮处理程序的规范。例如,清单 9-9 中的按钮处理程序使用了setOnAction
而不是addEventHandler
。以下两种说法效果相同。
btn.addEventHandler(ActionEvent.ACTION, h);
btn.setOnAction(h);
JavaFX 属性
JavaFX 节点的状态由各种属性表示。例如,ChoiceBox
类的两个属性是items
,它表示选择框应该显示的项目列表,以及value
,它表示当前选中的项目。对于节点的每个属性,该节点都有一个方法返回对该属性的引用。方法的名称是属性名,后跟“property”例如,ChoiceBox
有方法itemsProperty
和valueProperty
。
形式上,属性是实现接口Property
的对象。它的三种方法如清单 10-25 所示。基于这些方法,您可以正确地推断出一个Property
对象既是包装器又是可观察对象。方法getValue
和setValue
获取并设置包装的值,方法addListener
将一个监听器添加到它的观察列表中。在下面的小节中,我们将研究属性的这两个方面。
public interface Property<T> {
T getValue();
void setValue(T t);
void addListener(ChangeListener<T> listener);
...
}
Listing 10-25Methods of the Property Interface
作为包装的属性
属性的getValue
和setValue
方法很少使用,因为每个节点都有替代的便利方法。特别是,如果一个节点有一个名为p
的属性,那么它有便利的方法getP
和setP
。例如,清单 10-26 显示了清单 9-9 中createNodeHierarchy
方法的开始。对getP
和setP
方法的调用以粗体显示。
private Pane createNodeHierarchy() {
VBox p3 = new VBox(8);
p3.setAlignment(Pos.CENTER);
p3.setPadding(new Insets(10));
p3.setBackground(...);
Label type = new Label("Select Account Type:");
ChoiceBox<String> chbx = new ChoiceBox<>();
chbx.getItems().addAll("Savings", "Checking",
"Interest Checking");
...
}
Listing 10-26The Beginning of the AccountCreationWindow Class
这些方法都是方便的方法,因为类VBox
有属性alignment
、padding
和background
,而ChoiceBox
有属性items
。为了证明这一点,清单 10-27 给出了不使用这些便利方法的代码的替代版本。
private Pane createNodeHierarchy() {
VBox p3 = new VBox(8);
Property<Pos> alignprop = p3.alignmentProperty();
alignprop.setValue(Pos.CENTER);
Property<Insets> padprop = p3.paddingProperty();
padprop.setValue(new Insets(10));
Property<Background> bgprop = p3.backgroundProperty();
bgprop.setValue(...);
Label type = new Label("Select Account Type:");
ChoiceBox<String> chbx = new ChoiceBox<>();
Property<String> itemsprop = chbx.itemsProperty();
itemsprop.getValue().addAll("Savings", "Checking",
"Interest Checking");
...
}
Listing 10-27Revising Listing 10-26 to Use Explicit Property Objects
可观察的属性
一个属性是一个可观察的对象,它维护着一个观察者列表。当其包装的对象改变状态时,该属性通知其观察者。一个属性观察者被称为变更监听器,并实现清单 10-28 中所示的接口ChangeListener
。
public interface ChangeListener<T> {
void changed(Property<T> obs, T oldval, T newval);
}
Listing 10-28The ChangeListener Interface
该接口由一个名为changed
的方法组成。注意changed
是一种混合推挽观测器方法。第二个和第三个参数将新旧值推送给观察者。第一个论据是可观测性本身,观测者可以从中获得额外的信息。(从技术上讲,第一个参数属于类型ObservableValue
,这是一个比Property
更通用的接口。但是为了简单起见,我忽略了这个问题。)
创建变更监听器最简单的方法是使用 lambda 表达式。例如,清单 10-29 给出了可以添加到AccountCreationWindow
类中的监听器代码。这个监听器观察复选框ckbx
。如果框被选中,执行它的代码会使标签的文本变成绿色,如果框被取消选中,则变成红色。
ChangeListener<Boolean> checkboxcolor =
(obs, oldval, newval) -> {
Color c = newval ? Color.GREEN : Color.RED;
ckbx.setTextFill(c);
};
Listing 10-29A Check Box Change Listener
要让变更监听器执行,您必须通过调用属性的addListener
方法将其添加到属性的观察者列表中,如清单 10-30 所示。结果是,当复选框被选中和取消选中时,它的颜色会从红色变为绿色,然后再变回绿色。
ChangeListener<Boolean> checkboxcolor = ... // Listing 10-29
Property<Boolean> p = ckbx.selectedProperty();
p.addListener(checkboxcolor);
Listing 10-30Attaching a Change Listener to a Property
清单 10-29 和 10-30 需要三条语句来创建一个监听器并将其添加到所需属性的观察者列表中。我这样写是为了一步一步地向你展示需要发生什么。实际上,大多数 JavaFX 程序员会将整个代码写成一条语句,如清单 10-31 所示。
ckbx.selectedProperty().addListener(
(obs, oldval, newval) -> {
Color c = newval ? Color.GREEN : Color.RED;
ckbx.setTextFill(c);
});
Listing 10-31Revising the Check Box Change Listener
更改侦听器也可用于同步 JavaFX 控件的行为。再次考虑图 10-3 中显示的AccountCreationWindow
的初始屏幕。请注意,选择框是未选中的。如果用户此时点击了CREATE ACCT
按钮,如果代码试图创建一个帐户,就会出现运行时错误。
为了消除出错的可能性,您可以设计屏幕,使按钮最初被禁用,只有在选择了帐户类型时才被启用。这种设计要求在选择框中添加一个更改监听器。其代码如清单 10-32 所示。
public class AccountCreationWindow extends Application {
public void start(Stage stage) {
...
chbx.valueProperty().addListener(
(obj, oldval, newval) ->
btn.setDisable(newval==null));
...
}
}
Listing 10-32Adding Change Listener for the Choice Box
变量chbx
引用选择框。如果选择框的新值为空,更改监听器禁用按钮,否则启用按钮。结果是按钮的启用/禁用状态与选择框的选中/未选中状态同步。
事件侦听器和更改侦听器可以交互。回想一下清单 10-24 中,AccountCreationWindow
的最外层窗格p1
有一个事件监听器,当窗格被点击时,它将选择框的值设置为空。此更改将导致选择框的更改侦听器触发,然后禁用按钮。也就是说,从选择框中选择一个项目会启用该按钮,单击外部窗格会禁用该按钮。用户可以通过从选择框中选择一个帐户类型,然后单击外部窗格来反复启用和禁用该按钮。试试看。
JavaFX 绑定
JavaFX 支持计算属性的概念,它被称为绑定。绑定实现了接口Binding
,清单 10-33 中显示了其中的两个方法。注意,绑定和属性之间的主要区别在于绑定没有setValue
方法。绑定没有setValue
,因为它们的值是计算出来的,不能手动设置。
public interface Binding<T> {
public T getValue();
public void addListener(ChangeListener<T> listener);
...
}
Listing 10-33The Binding Interface
可以用几种方法创建绑定,但最简单的方法是使用与您拥有的属性类型相关联的方法。例如,包装对象的属性扩展了类ObjectProperty
并继承了方法isNull
。清单 10-34 展示了如何为选择框的value
属性创建一个绑定。
ChoiceBox chbx = ...
ObjectProperty<String> valprop = chbx.valueProperty();
Binding<Boolean> nullvalbinding = valprop.isNull();
Listing 10-34An example Binding
变量nullvalbinding
引用了一个包装了布尔值的Binding
对象。这个布尔值是从选择框的value
属性计算出来的——特别是,如果value
包含一个空值,那么这个布尔值将为真,否则为假。
当一个Binding
对象被创建时,它将自己添加到其属性的观察者列表中。因此,对属性值的更改将通知绑定,然后绑定可以相应地更改其值。为了帮助你形象化这种情况,请看图 10-4 的图表,它描绘了清单 10-34 的三个变量的记忆图。
图 10-4
绑定与其属性之间的关系
chbk
对象代表选择框。它有对其每个属性的引用。该图仅显示了对value
的引用,并暗示了对items
的引用。valprop
对象代表value
属性。它有一个对其包装对象(字符串“savings”)和观察者列表的引用。图表显示列表至少有一个观察者,这就是绑定nullvalbinding
。请注意,绑定的结构类似于属性。它的包装对象是一个布尔值false
。
当chbx
节点改变其被包装的对象时,比如说通过执行代码valueProperty().setValue(null)
,value
属性将发送一个改变通知给它的观察者。当绑定收到通知时,它会注意到属性的新值为 null,并将其包装对象的值设置为true
。
清单 10-32 的代码为选择框创建了一个变更监听器。清单 10-35 重写代码以使用绑定。注意变更监听器如何将按钮的disable
属性的值设置为绑定的值。不需要像清单 10-32 中那样显式地检查 null,因为检查是由绑定执行的。
public class AccountCreationWindow extends Application {
public void start(Stage stage) {
...
ObjectProperty<String> valprop = chbx.valueProperty();
Binding<Boolean> nullvalbinding = valprop.isNull();
nullvalbinding.addListener(
(obj, oldval, newval) -> btn.setDisable(
nullvalbinding.getValue()));
...
}
}
Listing 10-35Rewriting the Choice Box Change Listener
清单 10-35 的代码有些难读(也有些难写!).为了简化起见,Property
对象有方法bind
,它为您执行绑定。清单 10-36 相当于清单 10-35 的代码。
public class AccountCreationWindow extends Application {
public void start(Stage stage) {
...
btn.disableProperty()
.bind(chbx.valueProperty().isNull());
...
}
}
Listing 10-36Using the Bind Method to Create an Implicit Change Listener
bind
方法有一个参数,它是一个绑定(或属性)。这里,方法的参数是由isNull
方法创建的绑定。bind
方法向该绑定添加了一个更改侦听器,这样当它的包装值更改时,按钮的disable
属性的值也会随之更改。该行为与清单 10-35 中的行为完全相同。
清单 10-36 的代码格外漂亮。bind
方法和isNull
方法都创建变更监听器,这些监听器通过观察者模式(两次!)使这两个控件能够同步它们的值。而这一切都发生在幕后,客户毫不知情。这是观察者模式的有用性和适用性的极好例子。
摘要
一个观察者是一个对象,它的工作是响应一个或多个事件。一个可观察的是一个物体,它能识别特定事件何时发生,并保持一个对这些事件感兴趣的观察者列表。当一个事件发生时,它通知它的观察者。
观察者模式规定了观察者和可观察物之间的一般关系。但是这种模式没有解决多个设计问题。一个问题涉及到更新方法:一个可观察对象应该向它的观察者推送什么值,以及观察者应该从可观察对象获取什么值?第二个问题是关于可观察对象如何处理多种类型的事件:它应该用单独的更新方法和观察列表独立地处理每个事件,还是可以以某种方式组合事件处理?这些问题没有最佳解决方案。设计师必须考虑给定情况下的各种可能性,并权衡利弊。
观察者模式对于 GUI 应用程序的设计尤其有用。事实上,JavaFX 中充满了 observer 模式,如果不大量使用 observer 和 observables,设计 JavaFX 应用程序几乎是不可能的。即使应用程序没有显式地使用观察器,应用程序使用的类库几乎肯定会使用。
JavaFX 节点支持两种观察器:事件处理程序和变化监听器。事件处理程序响应输入事件,如鼠标点击和按键。每个事件处理程序都属于某个节点的观察者列表。变化监听器响应节点状态的变化。每个变更监听器属于一个节点的某个属性的观察者列表。通过适当地设计事件处理程序和更改侦听器,JavaFX 屏幕可以被赋予非常复杂的行为。
十一、模型、视图和控制器
这本书的最后一章讨论了如何将一个程序的计算相关的职责和它的表示相关的职责分开的问题。您可能还记得第一章在创建银行演示的第二版时首次解决了这个问题。版本 2 包含了新的类BankClient
,它包含了表示责任,以及Bank
,它包含了计算责任。
原来第一章走的还不够远。本章认为程序也应该将计算类和表现类隔离开来,为此你需要类在它们之间进行调解。计算、表示和中介类被称为模型、视图和控制器。本章介绍了 MVC 模式,这是在程序中组织这些类的首选方式。本章还讨论了使用 MVC 模式的优点,并给出了几个例子。
MVC 设计规则
一个程序通常有两个感兴趣的领域。首先是它如何与用户交互,请求输入和呈现输出。第二个是它如何从输入计算输出。经验表明,设计良好的程序会将这两个领域的代码分开。输入/输出部分被称为视图。计算部分被称为模型。
这一思想通过设计规则“将模型与视图分离”来表达在面向对象的环境中,这条规则意味着应该有专门用于计算结果的类,以及专门用于呈现结果和请求输入的类。此外,不应该有一个两者都做的类。
视图和模型有不同的关注点。该视图需要是一个视觉上有吸引力的界面,易于学习和使用。该模型应该是实用和高效的。由于这些问题没有共同点,因此视图和模型应该相互独立地设计。因此,模型不应该知道视图如何显示结果,视图也不应该知道它所显示的值的含义。
为了保持这种隔离,程序必须有连接模型和视图的代码。这个代码叫做控制器。控制器理解程序的整体功能,并在视图和模型之间进行协调。它知道模型的哪些方法对应于每个视图请求,以及在视图中显示模型的哪些值。
这些想法被编入下面的模型-视图-控制器设计规则,或者被称为 MVC 规则。这个规则是单一责任规则的一个特例,它断言一个类不应该组合模型、视图或控制器责任。
模型-视图-控制器规则
一个程序应该被设计成它的模型、视图和控制器代码属于不同的类。
例如,考虑版本 18 银行演示。Bank
类是模型的一部分,它依赖的类和接口也是模型的一部分。因为这些类不包含视图或控制器代码,所以它们满足 MVC 规则。另一方面,BankClient
和InputCommands
类不满足 MVC 规则,因为它们都结合了视图和控制器代码。清单 11-1 和 11-2 中说明了这种情况。
清单 11-1 显示了InputCommands
中定义常量DEPOSIT
的部分。lambda 表达式包含视图代码(对扫描器和System.out.print
的调用)以及控制器代码(对bank.deposit
的调用)。
public enum InputCommands implements InputCommand {
...
DEPOSIT("deposit", (sc, bank, current)->{
System.out.print("Enter deposit amt: ");
int amt = sc.nextInt();
bank.deposit(current, amt);
return current;
}),
...
}
Listing 11-1A Fragment of the Version 18 InputCommands Enum
清单 11-2 显示了BankClient
类的开头和它的两个方法。值得称赞的是,该类主要包含视图代码。唯一的问题是它的两个变量,bank
和current
,引用了这个模型。尽管该类没有以任何有意义的方式使用这些变量,但它们不属于视图。
public class BankClient {
private Scanner scanner;
private boolean done = false;
private Bank bank;
private int current = 0;
...
private void processCommand(int cnum) {
InputCommand cmd = commands[cnum];
current = cmd.execute(scanner, bank, current);
if (current < 0)
done = true;
}
}
Listing 11-2A Fragment of the Version 18 BankClient Class
版本 19 的银行演示通过将这些类的控制器代码移动到新的类InputController
中来纠正这些类的问题。清单 11-3 给出了它的一些代码。
public class InputController {
private Bank bank;
private int current = 0;
public InputController(Bank bank) {
this.bank = bank;
}
public String newCmd(int type, boolean isforeign) {
int acctnum = bank.newAccount(type, isforeign);
current = acctnum;
return "Your new account number is " + acctnum;
}
public String selectCmd(int acctnum) {
current = acctnum;
int balance = bank.getBalance(current);
return "Your balance is " + balance;
}
public String depositCmd(int amt) {
bank.deposit(current, amt);
return "Amount deposited";
}
...
}
Listing 11-3The Version 19 InputController Class
控制器对每个输入命令都有一个方法。视图将调用这些方法,提供适当的参数值。控制器负责对模型执行必要的操作。它还负责构造一个描述结果的字符串,并将其返回给视图。控制器还管理保存当前帐户的变量current
。
清单 11-4 和 11-5 给出了BankClient
和InputCommands
的版本 19 代码。这些类构成了视图。他们用扫描仪输入,用System.out
输出。BankClient
将控制器传递给InputCommands
,而InputCommands
将所有与模型相关的活动委托给控制器。
public enum InputCommands implements InputCommand {
QUIT("quit", (sc, controller)->{
sc.close();
return "Goodbye!";
}),
NEW("new", (sc, controller)->{
printMessage();
int type = sc.nextInt();
boolean isforeign = requestForeign(sc);
return controller.newCmd(type, isforeign);
}),
SELECT("select", (sc, controller)->{
System.out.print("Enter acct#: ");
int num = sc.nextInt();
return controller.selectCmd(num);
}),
DEPOSIT("deposit", (sc, controller)->{
System.out.print("Enter deposit amt: ");
int amt = sc.nextInt();
return controller.depositCmd(amt);
}),
...
}
Listing 11-5The Version 19 InputCommands Enum
public class BankClient {
private Scanner scanner;
private InputController controller;
private InputCommand[] commands = InputCommands.values();
public BankClient(Scanner scanner, InputController cont) {
this.scanner = scanner;
this.controller = cont;
}
public void run() {
String usermessage = constructMessage();
String response = "";
while (!response.equals("Goodbye!")) {
System.out.print(usermessage);
int cnum = scanner.nextInt();
InputCommand cmd = commands[cnum];
response = cmd.execute(scanner, controller);
System.out.println(response);
}
}
...
}
Listing 11-4The Version 19 BankClient Class
主类BankProgram
必须修改以适应视图和控制器类。它的代码出现在清单 11-6 中。这个类最好理解为既不属于模型、控制器,也不属于视图。相反,它的工作是创建和配置模型、控制器和视图类。清单 11-6 中的粗体代码突出显示了这些类的创建顺序。BankProgram
首先创建模型对象(类型为Bank
)。然后,它创建控制器,向其传递对模型的引用。最后,它创建视图,将一个引用传递给控制器。
public class BankProgram {
public static void main(String[] args) {
SavedBankInfo info = new SavedBankInfo("bank19.info");
Map<Integer,BankAccount> accounts = info.getAccounts();
int nextacct = info.nextAcctNum();
Bank bank = new Bank(accounts, nextacct);
...
InputController controller = new InputController(bank);
Scanner scanner = new Scanner(System.in);
BankClient client = new BankClient(scanner, controller);
client.run();
info.saveMap(accounts, bank.nextAcctNum());
}
}
Listing 11-6The Version 19 BankProgram Class
图 11-1 显示了描述这些模型、视图和控制器类之间关系的类图。注意,尽管视图和模型由多个类组成,但是有一个类作为配置的“主要”类。这种情况通常适用于所有的 MVC 设计。
图 11-1
基于 MVC 的银行演示的类图
从理论上讲,模型和视图之间的区别是很明显的:如果某样东西的功能与它的呈现方式无关,那么它就属于模型,如果它与模型无关,那么它就属于视图。然而,在实践中,做出这些区分可能需要仔细分析。银行演示提供了一些例子。
经常账户的概念就是一个例子。我之前说过它不应该是视图的一部分。但是它应该是控制器的一部分还是模型的一部分呢?答案取决于当前帐户是仅与特定视图相关,还是模型固有的。对我来说,关键是要意识到银行客户机的每个会话可能有不同的当前帐户,我不希望模型负责管理特定于会话的数据。这向我表明,当前帐户属于控制器,而不是模型。
再举一个例子,考虑一下BankClient
分配给输入选项的数字。输入命令被分配一个从 0 到 7 的数字,帐户类型被分配一个从 1 到 3 的数字,所有权规范是 1 代表“国内”,2 代表“国外”视图负责为命令和国内/国外选择分配号码,但是模型决定帐户类型号码。为什么呢?
标准是输入值的含义是否与模型相关。如果模型不关心,那么视图应该负责确定值的含义。这是命令和所有权号的情况,因为模型永远看不到它们。另一方面,帐户类型由模型操纵,因此必须由模型决定。
一个模型的多个视图
将模型与视图分离的一个优点是,您可以创建使用相同模型的不同程序。例如,银行模型可以由一个面向客户的程序(例如,在线银行)、另一个面向银行员工的程序以及另一个面向银行高管的程序使用。要编写每个程序,只需编写视图和一个将视图与现有模型挂钩的控制器。
模型和视图之间的分离也使得修改视图变得更加容易,这样它就有了不同的用户界面。例如,BankClient
可以修改为使用命令名而不是数字,或者支持语音命令,或者拥有基于 GUI 的界面。这最后一种选择将在本章后面讨论。
18 版银行演示有四个使用银行模型的程序:BankProgram
、FBIClient
、IteratorStatProgram
和StreamStatProgram
。后三个程序不满足 MVC 规则。它们都非常简单——它们没有输入,它们的输出只是打印一些测试查询的结果。用 MVC 重写他们的代码有意义吗?最简单的程序是StreamStatProgram
,它有相关的类StreamAccountStats
。这些类最初在第六章中讨论过。让我们重写它们,看看会发生什么。
清单 11-7 给出了StreamAccountStats
的前两种方法。它主要是模型代码;唯一的问题是这些方法调用了System.out.println
。
public class StreamAccountStats {
private Bank bank;
public StreamAccountStats(Bank b) {
bank = b;
}
public void printAccounts6(Predicate<BankAccount> pred) {
Stream<BankAccount> s = bank.stream();
s = s.filter(pred);
s.forEach(ba->System.out.println(ba));
}
public void printAccounts7(Predicate<BankAccount> pred) {
bank.stream()
.filter(pred)
.forEach(ba->System.out.println(ba));
}
...
}
Listing 11-7The Original StreamAccountStats Class
清单 11-8 显示了版本 19 的修订版,称为StreamStatModel
。两个printAccounts
方法变了。他们名字中的前缀“print”已经被重命名为“get”,以反映他们的返回类型现在是String
而不是void
。此外,他们对forEach
方法的使用必须修改为使用reduce
,这样它可以从对ba.toString
的单独调用中创建一个单独的字符串。
public class StreamStatModel {
private Bank bank;
public StreamStatModel(Bank b) {
bank = b;
}
public String getAccounts6(Predicate<BankAccount> pred) {
Stream<BankAccount> s = bank.stream();
s = s.filter(pred);
Stream<String> t = s.map(ba->ba.toString());
return t.reduce("", (s1,s2)->s1 + s2 + "\n");
}
public String getAccounts7(Predicate<BankAccount> pred) {
return bank.stream()
.filter(pred)
.map(ba->ba.toString())
.reduce("", (s1,s2)->s1 + s2 + "\n");
}
...
}
Listing 11-8The Revised StreamStatModel Class
StreamStatProgram
的原始代码出现在清单 11-9 中。它包含视图和控制器代码。控制器代码由调用模型方法组成。视图代码包括打印它们的结果。
public class StreamStatProgram {
public static void main(String[] args) {
...
StreamAccountStats stats = ...
Predicate<BankAccount> pred = ba -> ba.fee() == 0;
...
System.out.println("Here are the domestic accounts.");
stats.printAccounts6(pred);
System.out.println("Here are the domestic accounts
again.");
stats.printAccounts7(pred);
}
}
Listing 11-9The Original StreamStatProgram Class
银行演示的版本 19 包含视图类StreamStatView
。它的代码,如清单 11-10 所示,调用控制器方法而不是模型方法。注意,视图不知道谓词,因为谓词引用了模型。
public class StreamStatView {
StreamStatController c;
public StreamStatView(StreamStatController c) {
this.c = c;
}
public void run() {
...
System.out.println("Here are the domestic accounts.");
System.out.println(c.getAccounts6());
System.out.println("Here are the domestic accounts
again.");
System.out.println(c.getAccounts7());
}
}
Listing 11-10The Version 19 StreamStatView Class
StreamStatController
类出现在清单 11-11 中。它根据模型实现了三种视图方法中的每一种。它还创建了谓词。
public class StreamStatController {
private StreamStatModel model;
Predicate<BankAccount> pred = ba -> ba.fee() == 0;
public StreamStatController (StreamStatModel model) {
this.model = model;
}
public String getAccounts6() {
return model.getAccounts6(pred);
}
public String getAccounts7() {
return model.getAccounts7(pred);
}
...
}
Listing 11-11The Version 19 StreamStatController Class
最后,清单 11-12 给出了版本 19 StreamStatProgram
类的代码。该类配置模型、视图和控制器,然后调用视图的run
方法。
public class StreamStatProgram {
public static void main(String[] args) {
SavedBankInfo info = new SavedBankInfo("bank19.info");
Map<Integer,BankAccount> accounts = info.getAccounts();
int nextacct = info.nextAcctNum();
Bank bank = new Bank(accounts, nextacct);
StreamStatModel m = new StreamStatModel(bank);
StreamStatController c = new StreamStatController(m);
StreamStatView v = new StreamStatView(c);
v.run();
}
}
Listing 11-12The Version 19 StreamStatProgram Class
比较 MVC 版本的StreamStatProgram
类和它们的原始代码。你可能会惊讶于 MVC 版本是多么的干净和有组织。虽然它比原始版本包含更多的代码,但每个单独的类都很短,很容易修改。寓意是,即使对于小程序,MVC 设计也是值得考虑的。
Excel 中的 MVC
Excel 是遵循 MVC 设计规则的商业程序的一个例子。该模型由电子表格的单元格组成。电子表格的每个图表都是模型的视图。图 11-2 显示了一个描述图表及其底层单元格的屏幕截图。
图 11-2
电子表格的模型和视图
Excel 在模型和视图之间保持严格的分离。细胞不知道图表的事。每个图表都是位于单元格顶部的“对象”。
创建 Excel 图表有两个方面。第一个方面是图表是什么样子的。Excel 具有指定不同图表类型、颜色、标签等的工具。这些工具对应于视图方法;它们让你让图表看起来更有吸引力,不管它代表什么数据。
第二个方面是图表显示什么数据。Excel 有一个名为“选择数据”的工具,用于指定图表的基本单元格。该工具对应于控制器。图 11-3 给出了图 11-2 的控制器窗口截图。“名称”文本字段指定包含图表标题的单元格;“Y 值”指定包含人口值的单元格;而“水平(类别)轴标签”指定包含年份的单元格。
图 11-3
图表的控制器
分离模型和视图提供了很大的灵活性。一个单元格区域可以是许多不同图表的模型,一个图表可以是许多不同单元格区域的视图。控制器将它们连接在一起。
JavaFX 视图和控制器
再次考虑出现在图 10-3 中的AccountCreationWindow
类。这个类显示 JavaFX 控件,允许用户选择要创建的银行帐户的类型。但是,该类没有连接到银行模型。点击CREATE
ACCT
按钮除了改变标题标签的文字外没有任何作用。换句话说,这个程序纯粹是视图代码,这是完全合适的,因为 JavaFX 是用来创建视图的。
如何创建将视图连接到银行模型的控制器?在回答这个问题之前,让我们从一个简单的 JavaFX 例子开始,来说明这个问题。程序Count1
显示一个包含两个按钮和一个标签的窗口,如图 11-4 所示。标签显示变量count
的值。这两个按钮增加和减少计数。
图 11-4
Count1 程序的初始屏幕
清单 11-13 给出了Count1
的代码。这段代码不符合 MVC 设计规则。该模型由变量count
组成。updateBy
方法更新计数(对模型的操作),但也改变标签的文本(对视图的操作)。
public class Count1 extends Application {
private static int count = 0;
private static Label lbl = new Label("Count is 0");
public void start(Stage stage) {
Button inc = new Button("Increment");
Button dec = new Button("Decrement");
VBox p = new VBox(8);
p.setAlignment(Pos.CENTER);
p.setPadding(new Insets(10));
p.getChildren().addAll(lbl, inc, dec);
inc.setOnAction(e -> updateBy(1));
dec.setOnAction(e -> updateBy(-1));
stage.setScene(new Scene(p));
stage.show();
}
private static void updateBy(int n) {
count += n; // model code
lbl.setText("Count is " + count); // view code
}
public static void main(String[] args) {
Application.launch(args);
}
}
Listing 11-13The Count1 Class
计数演示的版本 2 将代码分为模型、视图和控制器类。主类Count2
负责创建这些类并将它们相互连接。它的代码出现在清单 11-14 中。
public class Count2 extends Application {
public void start(Stage stage) {
CountModel model = new CountModel();
CountController controller = new CountController(model);
CountView view = new CountView(controller);
Scene scene = new Scene(view.getRoot());
stage.setScene(scene);
stage.show();
}
public static void main(String[] args) {
Application.launch(args);
}
}
Listing 11-14The Count2 Class
这个类的结构类似于版本 19 的BankProgram
和StreamStatProgram
类的结构。首先,创建模型。然后创建控制器,并传入模型。然后创建视图,并传入控制器。
该类调用视图方法getRoot
,该方法返回其节点层次结构的根。这个根被传递到Scene
构造器中,然后传递到 stage(通过它的setScene
方法)。
该模型由一个名为CountModel
的类组成,其代码出现在清单 11-15 中。该类有一个保存当前计数的变量count
,以及获取和更新计数的方法getCount
和updateBy
。
public class CountModel {
private int count = 0;
public void updateBy(int n) {
count += n;
}
public int getCount() {
return count;
}
}
Listing 11-15The CountModel Class
视图由一个名为CountView
的类组成。它的代码出现在清单 11-16 中。
class CountView {
private Pane root;
public CountView(CountController cont) {
root = createNodeHierarchy(cont);
}
public Pane getRoot() {
return root;
}
private Pane createNodeHierarchy(CountController cont) {
Button inc = new Button("Increment");
Button dec = new Button("Decrement");
Label lbl = new Label("Count is 0");
VBox p = new VBox(8);
p.setAlignment(Pos.CENTER);
p.setPadding(new Insets(10));
p.getChildren().addAll(lbl, inc, dec);
inc.setOnAction(e -> {
String s = cont.incrementButtonPressed();
lbl.setText(s);
});
dec.setOnAction(e ->
lbl.setText(cont.decrementButtonPressed()));
return p;
}
}
Listing 11-16The CountView Class
大多数视图代码都致力于创建节点层次结构的普通任务。更有趣的是视图如何使用它的两个按钮处理程序与控制器交互。increment
按钮处理程序调用控制器的incrementButtonPressed
方法。这个方法做它需要做的事情(在本例中是告诉模型增加计数),然后返回一个字符串,供视图在其标签中显示。类似地,decrement
按钮处理程序调用控制器的decrementButtonPressed
方法,并显示其返回值。
请注意,这两个处理程序具有相同的结构。我编写了彼此不同的代码,只是为了说明不同的编码风格。
控制器类被命名为 CountController 。它的代码出现在清单 11-17 中。控制器负责将视图上的事件转换成模型上的动作,并将模型的返回值转换成视图上可显示的字符串。
class CountController {
private CountModel model;
public CountController(CountModel model) {
this.model = model;
}
public String incrementButtonPressed() {
model.updateBy(1);
return "Count is " + model.getCount();
}
public String decrementButtonPressed() {
model.updateBy(-1);
return "Count is " + model.getCount();
}
}
Listing 11-17The CountController Class
注意控制器如何在模型和视图之间进行协调,这使得视图不知道模型。视图知道“嘿,我的按钮被按了”,但是它不知道该怎么办。因此视图将这项工作委托给控制器。此外,视图同意显示控制器返回的任何值。
2003 年,一位苹果工程师在苹果开发者大会上唱了一首关于 MVC 的精彩歌曲,他的表演被录制下来留给后人。你可以在 YouTube 上搜索“MVC 歌曲”来找到这个视频当你观看表演时,你可能会被朗朗上口的旋律吸引。但要特别注意歌词,它简洁地传达了 MVC 的真正之美。
扩展 MVC 架构
本章到目前为止已经展示了三个 MVC 程序的例子:BankProgram
(列表 11-6 )、StreamStatProgram
(列表 11-12 )和Count2
(列表 11-14 )。这些程序都有相似的架构,视图与控制器对话,控制器与模型对话。这种架构简单明了。
尽管这种体系结构可以很好地处理单视图程序,但对于具有多视图的程序却很糟糕。例如,考虑计数演示的版本 3,它向版本 2 的演示添加了第二个视图。图 11-5 显示了点击几下按钮后的程序截图。
图 11-5
Count3 程序的屏幕截图
这两个视图在窗口中都有一个窗格。第二个视图是“观察者”视图。它跟踪计数改变的次数,并显示计数是偶数还是奇数。但是观察者视图如何知道模型何时发生了变化呢?答案是使用观察者模式!模型需要广播计数视图所做的更改,以便观察者视图可以观察到它们。因此,模型需要修改为可观测的。
定义一个观察者接口,名为CountObserver
。这个接口将有一个观察器方法update
,它将新的计数推送给它的观察器。它的代码出现在清单 11-18 中。
public interface CountObserver {
public void update(int count);
}
Listing 11-18The CountObserver Interface
类CountModel
需要管理一个观察者列表。它的updateBy
方法将向列表上的观察者广播新的计数。清单 11-19 显示了这个类的最终变化。
public class CountModel {
private int count = 0;
private Collection<CountObserver> observers
= new ArrayList<>();
public void addObserver(CountObserver obs) {
observers.add(obs);
}
private void notifyObservers(int count) {
for (CountObserver obs : observers)
obs.update(count);
}
public void updateBy(int n) {
count += n;
notifyObservers(count);
}
public int getCount() {
return count;
}
}
Listing 11-19The CountModel Class
观察者的控制器将是模型观察者。当控制器收到来自模型的通知时,它将确定需要对其视图进行的更改,并将这些更改传递给视图。不幸的是,这种行为目前是不可能的,因为观察者控制器不知道它的视图是谁!为了解决这个问题,需要修改观察器视图及其控制器:控制器需要一个对视图的引用,视图需要有一个控制器可以调用的方法。
通过给控制器一个对它的视图的引用,观察器视图和它的控制器将相互引用。视图通过构造器注入获得它的引用。但是控制器不能,因为在创建控制器时视图还没有被创建。解决方案是让控制器通过方法注入获得对视图的引用。它定义了一个方法setView
。当创建视图时,它可以调用控制器的setView
方法,向控制器传递对自身的引用。
观察者视图定义了控制器要调用的方法updateDisplay
。该方法有三个参数,对应于控制器想要传递给视图的三个值:标签的新消息,以及两个复选框的所需值。
清单 11-20 给出了控制器的代码。注意,控制器负责跟踪模型变化的次数,因为我认为这个值与模型无关。如果您不这么认为,您应该更改模型,以便它保留这些信息。
public class WatcherController
implements CountObserver {
private WatcherView view;
private int howmany = 0;
public WatcherController(CountModel model) {
model.addObserver(this);
}
// called by the view
public void setView(WatcherView view) {
this.view = view;
}
// called by the model
public void update(int count) {
howmany++;
boolean isEven = (count%2 == 0);
boolean isOdd = !isEven;
String msg = "The count has changed "
+ howmany + " times";
view.updateDisplay(msg, isEven, isOdd);
}
}
Listing 11-20The WatcherController Class
清单 11-21 给出了观察者视图的代码。它的构造器调用控制器的setView
方法,从而建立视图和控制器之间的双向连接。updateDisplay
方法设置视图的三个控件的值。请注意,视图不知道这些值的含义。
class WatcherView {
private Label lbl
= new Label("The count has not yet changed");
private CheckBox iseven
= new CheckBox("Value is now even");
private CheckBox isodd = new CheckBox("Value is now odd");
private Pane root;
public WatcherView(WatcherController controller) {
root = createNodeHierarchy();
controller.setView(this);
}
public Pane root() {
return root;
}
public void updateDisplay(String s, boolean even,
boolean odd) {
lbl.setText(s);
iseven.setSelected(even);
isodd.setSelected(odd);
}
private Pane createNodeHierarchy() {
iseven.setSelected(true);
isodd.setSelected(false);
VBox p = new VBox(8);
p.setAlignment(Pos.CENTER);
p.setPadding(new Insets(10));
p.getChildren().addAll(lbl, iseven, isodd);
return p;
}
}
Listing 11-21The WatcherView Class
主程序Count3
将两个视图配置到一个窗口中。为了处理多个视图,代码将两个视图的节点层次结构放在一个HBox
窗格中。清单 11-22 给出了代码。合并这些观点的陈述用粗体表示。
public class Count3 extends Application {
public void start(Stage stage) {
CountModel model = new CountModel();
// the first view
CountController ccontroller
= new CountController(model);
CountView cview = new CountView(ccontroller);
// the second view
WatcherController wcontroller
= new WatcherController(model);
WatcherView wview = new WatcherView(wcontroller);
// Display the views in a single two-pane window.
HBox p = new HBox();
BorderStroke bs = new BorderStroke(Color.BLACK,
BorderStrokeStyle.SOLID,
null, null, new Insets(10));
Border b = new Border(bs);
Pane root1 = cview.root(); Pane root2 = wview.root();
root1.setBorder(b); root2.setBorder(b);
p.getChildren().addAll(root1, root2);
stage.setScene(new Scene(p));
stage.show();
}
public static void main(String[] args) {
Application.launch(args);
}
}
Listing 11-22The Count3 Class
MVC 模式
虽然WatcherController
需要做模型观察者,但是CountController
不需要。因为它是唯一可以改变模型的视图,所以它确切地知道模型何时以及如何改变。至少目前是这样。但是如果程序碰巧添加了另一个可以改变模型的视图呢?那么CountView
显示的值可能会不正确。这种错误很难被发现。如果我们希望CountView
总是显示当前的计数,而不管其他视图存在什么,那么CountController
也需要成为一个模型观察者。
要成为模型观察者,CountController
必须实现update
方法。它的代码update
将构造一个描述新计数的消息,并将其发送给视图。因此,按钮处理程序方法incrementButtonPressed
和decrementButtonPressed
现在应该是无效的,因为它们不再负责构造消息。此外,控制器需要一个对视图的引用。因此,它使用与WatcherController
相同的技术实现方法setView
。清单 11-23 给出了修改后的代码。
class CountController implements CountObserver {
private CountModel model;
private CountView view;
public CountController(CountModel model) {
this.model = model;
model.addObserver(this);
}
// Methods called by the view
public void setView(CountView view) {
this.view = view;
}
public void incrementButtonPressed() {
model.updateBy(1);
}
public void decrementButtonPressed() {
model.updateBy(-1);
}
// Method called by the model
public void update(int count) {
view.setLabel("Count is " + count);
}
}
Listing 11-23The Revised CountController Class
类CountView
需要被修改以适应其控制器的变化。视图构造器调用控制器的方法setView
,视图实现方法setLabel
供控制器调用。代码出现在清单 11-24 中。
class CountView {
private Label lbl = new Label("Count is 0");
private Pane root;
public CountView(CountController controller) {
root = createNodeHierarchy(controller);
controller.setView(this);
}
public Pane root() {
return root;
}
public void setLabel(String s) {
lbl.setText(s);
}
private Pane createNodeHierarchy(CountController cont) {
Button inc = new Button("Increment");
Button dec = new Button("Decrement");
... // create the node hierarchy, having root p
inc.setOnAction(e -> cont.incrementButtonPressed());
dec.setOnAction(e -> cont.decrementButtonPressed());
return p;
}
}
Listing 11-24The revised CountView Class
为了理解这些变化的影响,考虑当点击Increment
按钮时计数视图和计数控制器现在会发生什么。
-
视图调用控制器的
incrementButtonPressed
方法。 -
该方法调用模型的
updateBy
方法。 -
该方法更新计数并调用
notifyObservers
,后者调用控制器的update
方法。 -
该方法格式化视图显示的字符串,并调用视图的
setLabel
方法。 -
该方法将其标签的文本修改为当前计数。
这个方法调用序列与在Count2
控制器中的效果相同。不同之处在于,控制器调用一对 void 方法,而不是返回值的单个方法。这种增加的复杂性对于保证在所有视图中更新计数是必要的,无论哪个视图进行更新。
控制器应该是观察者的观点是 MVC 设计模式的基础。这种模式断言应用程序的结构应该类似于Count3
。特别是:模型应该是可观测的,所有控制器都应该是模型观测器;控制器直接与模型对话;每个视图/控制器对可以直接相互对话。该模式由图 11-6 的类图表示。
图 11-6
MVC 模式
使用 MVC 模式的通信工作如下:
-
视图上的一个动作(比如一个按钮点击)被传递给它的控制器。
-
控制器将该动作转换为模型上的方法调用。
-
如果该方法调用是对数据的请求,那么模型将请求的数据直接返回给控制器,控制器将数据转发给它的视图。
-
如果这个方法调用导致了模型的改变,模型会通知它的观察者。
-
作为模型观察者,每个控制器决定更新是否与其视图相关。如果是这样,它调用适当的视图方法。
许多 GUI 应用程序依赖 MVC 模式来同步它们的视图。为了说明这一点,我将使用我电脑上 MacOS 界面中的两个例子,但是在 Windows 或 Linux 中也可以找到类似的例子。
对于第一个例子,考虑文件管理器。打开两个文件管理器窗口,让它们显示同一文件夹的内容。转到其中一个窗口,重命名一个文件。您将在另一个窗口中看到该文件被自动重命名。现在打开一个应用程序,创建一个文件,并将其保存到该文件夹中。您将看到该文件自动出现在两个文件管理器窗口中。
对于第二个例子,考虑文本文档和它的 pdf 版本之间的对应关系。在文本编辑器中打开文档。将文档保存为 pdf 文件,并在 pdf 查看器中打开 pdf 版本。更改文本文档,并将其重新保存为 pdf。pdf 查看器中显示的版本将自动更改。
在这两个例子中,计算机的文件系统充当模型。文件管理器窗口和 pdf 查看器是文件系统的视图。每个视图都有一个观察文件系统的控制器。当文件系统发生变化时,它会通知它的观察者。当文件管理器控制器收到通知时,它会确定这些更改是否会影响正在显示的文件,如果会,则更新其视图。当 pdf controller 收到通知时,它会确定这些更改是否会影响它正在显示的文件,如果会,它会告诉视图重新加载文件的新内容。
MVC 和银行演示
为银行演示开发基于 JavaFX 的接口的时机终于到来了。该界面将有一个包含三个视图的窗口:用于创建新帐户的视图;用于管理所选帐户的视图;以及显示所有帐户信息的视图。图 11-7 显示了三视图的截图。
图 11-7
银行演示的 JavaFX 接口
您已经遇到了标题为“创建新银行帐户”的视图用户选择所需的帐户类型,指定帐户是国内所有还是国外所有,然后单击按钮。创建了一个新帐户。
在标题为“访问现有帐户”的视图中,用户通过在顶部窗格的文本字段中输入帐号并单击“选择帐户”按钮来指定当前帐户。然后,帐户余额会出现在其下方的文本字段中,底部窗格中的国内/国外选择框会被设置为帐户的相应值。选择帐户后,用户可以向其存款、申请贷款授权或更改其所有权状态。自始至终,帐户余额始终保持最新。当发生存款或应计利息时,余额会更新。
标题为“管理所有帐户”的视图使用其toString
方法的输出在文本区域中显示所有帐户。该视图还有一个用于执行银行的addInterest
方法的按钮。帐户显示会自动保持最新。每当银行帐户的状态发生变化时,列表就会刷新。
该程序是使用 MVC 模式构建的。它的主类叫做FxBankProgram
,有三个视图类和三个控制器类。Bank
类就是模型。回想一下Bank
在第十章中被修改为一个可观测值(见清单 10-18 ),不需要进一步修改。以下小节将研究FxBankProgram
和每个视图/控制器对。
fxbank 程序类
这个类将视图配置到一个 JavaFX 窗口中。清单 11-25 给出了代码。JavaFX Application
类除了start
之外还有两个方法init
和stop
。launch
方法首先调用init
,然后是start
,然后是stop
。init
的目的是初始化应用所需的值。这里,init
为三个视图中的每一个创建节点层次结构,并将它们的根保存在变量root1
、root2
和root3
中。它还创建了模型和三个控制器。stop
方法保存银行的状态。
public class FxBankProgram extends Application {
private SavedBankInfo info =
new SavedBankInfo("bank19.info");
private Map<Integer,BankAccount> accounts =
info.getAccounts();
Bank bank = new Bank(accounts, info.nextAcctNum());
private Pane root1, root2, root3;
public void start(Stage stage) {
VBox left = new VBox();
left.getChildren().addAll(root1, root2);
HBox all = new HBox(left, root3);
stage.setScene(new Scene(all));
stage.show();
}
public void init() {
Auditor aud = new Auditor(bank);
bank.addObserver(BankEvent.DEPOSIT,
(event,ba,amt) -> {
if (amt > 10000000)
bank.makeSuspicious(ba.getAcctNum());
});
CreationController c1 = new CreationController(bank);
AllController c2 = new AllController(bank);
InfoController c3 = new InfoController(bank);
CreationView v1 = new CreationView(c1);
AllView v2 = new AllView(c2);
InfoView v3 = new InfoView(c3);
BorderStroke bs = new BorderStroke(Color.BLACK,
BorderStrokeStyle.SOLID,
null, null, new Insets(10));
Border b = new Border(bs);
root1 = v1.root(); root2 = v2.root(); root3 = v3.root();
root1.setBorder(b); root2.setBorder(b);
root3.setBorder(b);
}
public void stop() {
info.saveMap(accounts, bank.nextAcctNum());
}
public static void main(String[] args) {
Application.launch(args);
}
}
Listing 11-25The FxBankProgram Class
创建帐户视图
“创建帐户”视图类称为CreationView
。它的代码出现在清单 11-26 中。代码类似于第九章和第十章中的AccountCreationWindow
类,除了它现在有一个控制器与之对话。
public class CreationView {
private Pane root;
private Label title = new Label("Create a New Bank Acct");
public CreationView(CreationController controller) {
controller.setView(this);
root = createNodeHierarchy(controller);
}
public Pane root() {
return root;
}
public void setTitle(String msg) {
title.setText(msg);
}
private Pane createNodeHierarchy(CreationController cont) {
... // Create the hierarchy as in Listing 9-9\. Root is p1.
btn.addEventHandler(ActionEvent.ACTION, e -> {
cont.buttonPressed(chbx.getSelectionModel()
.getSelectedIndex(),
ckbx.isSelected());
String foreign = ckbx.isSelected() ? "Foreign " : "";
String acct = chbx.getValue();
title.setText(foreign + acct + " Account Created");
});
return p1;
}
}
Listing 11-26The CreationView Class
视图通过按钮处理程序与控制器对话。处理程序调用控制器的buttonPressed
方法,将选择框和复选框的值传递给它。创建帐户后,控制器将调用视图的setTitle
方法,向其传递要显示的消息。
控制器叫做CreationController
。它的代码出现在清单 11-27 中。它的buttonPressed
方法调用银行的newAccount
方法来创建账户。
public class CreationController implements BankObserver {
private Bank bank;
private CreationView view;
public CreationController(Bank bank) {
this.bank = bank;
bank.addObserver(BankEvent.NEW, this);
}
// methods called by the view
void setView(CreationView view) {
this.view = view;
}
public void buttonPressed(int type, boolean isforeign) {
bank.newAccount(type+1, isforeign);
}
// method called by the model
public void update(BankEvent e, BankAccount ba, int amt) {
view.setTitle("Account " + ba.getAcctNum()
+ " created");
}
}
Listing 11-27The CreationController Class
像所有遵循 MVC 模式的控制器一样,控制器是一个模型观察者。回想一下第十章Bank
支持四个事件。控制器向银行注册自己为新事件的观察者。当控制器接收到一个更新通知时,它为视图构建一个要显示的消息,并调用视图的setTitle
方法。
帐户信息视图
“账户信息”视图类称为InfoView
。它的代码出现在清单 11-28 中。
public class InfoView {
private Pane root;
private TextField balfld = createTextField(true);
private ChoiceBox<String> forbx = new ChoiceBox<>();
public InfoView(InfoController controller) {
controller.setView(this);
root = createNodeHierarchy(controller);
}
public Pane root() {
return root;
}
public void setBalance(String s) {
balfld.setText(s);
}
public void setForeign(boolean b) {
String s = b ? "Foreign" : "Domestic";
forbx.setValue(s);
}
private Pane createNodeHierarchy(InfoController cont) {
... // Create the hierarchy, with p1 as the root.
depbtn.setOnAction(e ->
controller.depositButton(depfld.getText()));
loanbtn.setOnAction(e ->
respfld.setText(controller.loanButton(
loanfld.getText())));
forbtn.setOnAction(e ->
controller.foreignButton(forbx.getValue()));
selectbtn.setOnAction(e ->
controller.selectButton(selectfld.getText()));
return p1;
}
}
Listing 11-28The InfoView Class
该视图有四个按钮,每个按钮的处理程序调用不同的控制器方法。请注意,发送到控制器方法的值是字符串,即使这些值表示数字。控制器负责将一个值转换成适当的类型,因为它知道如何使用该值。
loan authorization 按钮与其他按钮不同,它从模型中请求一个值。因此它的控制器方法不是无效的。该视图在其“贷款响应”文本字段中显示返回值。
视图的控制器叫做InfoController
。它的代码出现在清单 11-29 中。它对视图的每个按钮都有一个方法;每个方法都为其按钮在模型上执行必要的操作。例如,depositButton
方法调用银行的deposit
方法。selectButton
方法检索当前帐户的BankAccount
对象,并告诉视图设置余额文本字段和所有权选择框的显示值。
public class InfoController implements BankObserver {
private Bank bank;
private int current = 0;
private InfoView view;
public InfoController(Bank bank) {
this.bank = bank;
bank.addObserver(BankEvent.DEPOSIT, this);
bank.addObserver(BankEvent.INTEREST, this);
bank.addObserver(BankEvent.SETFOREIGN, this);
}
// methods called by the view
public void setView(InfoView view) {
this.view = view;
}
public void depositButton(String s) {
int amt = Integer.parseInt(s);
bank.deposit(current, amt);
}
public String loanButton(String s) {
int loanamt = Integer.parseInt(s);
boolean result = bank.authorizeLoan(current, loanamt);
return result ? "APPROVED" : "DENIED";
}
public void foreignButton(String s) {
boolean b = s.equals("Foreign") ? true : false;
bank.setForeign(current, b);
}
public void selectButton(String s) {
current = Integer.parseInt(s);
view.setBalance(
Integer.toString(bank.getBalance(current)));
String owner = bank.getAccount(current).isForeign() ?
"Foreign" : "Domestic";
view.setForeign(bank.isForeign(current));
}
// method called by the model
public void update(BankEvent e, BankAccount ba, int amt) {
if (e == BankEvent.SETFOREIGN &&
ba.getAcctNum() == current)
view.setForeign(ba.isForeign());
else if (e == BankEvent.INTEREST ||
ba.getAcctNum() == current)
view.setBalance(
Integer.toString(bank.getBalance(current)));
}
}
Listing 11-29The InfoController Class
控制器将自己注册为三个银行事件的观察者:DEPOSIT
、INTEREST
和SETFOREIGN
。它的update
方法检查它的第一个参数,以确定哪个事件导致了更新。对于利息事件,控制器获取当前账户的余额,并将其发送给视图的setBalance
方法。对于DEPOSIT
或SETFOREIGN
事件,控制器检查受影响的账户是否是当前账户。如果是,它将获取当前帐户的余额(或所有权)并将其发送给视图。
所有帐户视图
“所有帐户”视图类称为AllView
。清单 11-30 给出了它的代码。Add
Interest
按钮的处理程序简单地调用控制器的interestButton
方法。当控制器决定刷新账户显示时,它调用视图的setAccounts
方法。
public class AllView {
private Pane root;
TextArea accts = new TextArea();
public AllView(AllController controller) {
controller.setView(this);
root = createNodeHierarchy(controller);
}
public Pane root() {
return root;
}
public void setAccounts(String s) {
accts.setText(s);
}
private Pane createNodeHierarchy(AllController cont) {
accts.setPrefColumnCount(22);
accts.setPrefRowCount(9);
Button intbtn = new Button("Add Interest");
intbtn.setOnAction(e -> cont.interestButton());
VBox p1 = new VBox(8);
p1.setAlignment(Pos.TOP_CENTER);
p1.setPadding(new Insets(10));
Label title = new Label("Manage All Accounts");
double size = title.getFont().getSize();
title.setFont(new Font(size*2));
title.setTextFill(Color.GREEN);
p1.getChildren().addAll(title, accts, intbtn);
return p1;
}
}
Listing 11-30The AllView Class
该视图在文本框中显示帐户列表。这种设计决策的问题是不能单独更新单个帐户值。因此,setAccounts
方法必须用一个新的列表替换整个列表。接下来的两个部分将研究能够产生更好的实现的其他控件。
控制器叫做AllController
。它的代码出现在清单 11-31 中。控制器是所有四个Bank
事件的观察者。每当任何类型的事件发生时,控制器通过调用方法refreshAccounts
来刷新显示的账户。这个方法遍历银行账户,并创建一个字符串来追加它们的toString
值。然后,它将这个字符串发送到视图。
public class AllController implements BankObserver {
private Bank bank;
private AllView view;
public AllController(Bank bank) {
this.bank = bank;
bank.addObserver(BankEvent.NEW, this);
bank.addObserver(BankEvent.DEPOSIT, this);
bank.addObserver(BankEvent.SETFOREIGN, this);
bank.addObserver(BankEvent.INTEREST, this);
}
// methods called by the view
public void setView(AllView view) {
this.view = view;
refreshAccounts(); // initially populate the text area
}
public void interestButton() {
bank.addInterest();
}
// method called by the model
public void update(BankEvent e, BankAccount ba, int amt) {
refreshAccounts();
}
private void refreshAccounts() {
StringBuffer result = new StringBuffer();
for (BankAccount ba : bank)
result.append(ba + "\n");
view.setAccounts(result.toString());
}
}
Listing 11-31The AllController Class
可观察列表视图
使用文本区域来实现所有帐户的列表是不令人满意的:它看起来很糟糕,即使单个帐户发生变化,也需要完全刷新。JavaFX 有一个控件ListView
,比较令人满意。图 11-8 显示了它在“所有账户”视图中的截图。
图 11-8
管理所有帐户屏幕
列表视图和文本区域的区别在于列表视图显示 Java List
对象的内容。列表视图的每一行都对应列表中的一个元素,并显示调用该元素的toString
方法的结果。
类AllView2
和AllController2
重写了AllView
和AllController
以使用ListView
控件。清单 11-32 给出了 AllView2 的代码,新代码以粗体显示。
public class AllView2 {
private Pane root;
ListView<BankAccount> accts = new ListView<>();
public AllView2(AllController2 controller) {
root = createNodeHierarchy(controller);
accts.setItems(controller.getAccountList());
}
...
}
Listing 11-32The AllView2 Class
只有两行新代码。第一行创建一个新的ListView
对象。第二行指定它应该显示的列表,在本例中是由控制器的getAccountList
方法返回的列表。
AllView2
不再需要方法来更新它的ListView
控件。相反,控件及其列表通过观察者模式连接。这份名单是可观察的。ListView
控件是其列表的观察者。当控制器更改列表时,列表会通知控件,控件会自动更新自身。
这个特性简化了视图与控制器的交互。控制器不再需要明确地管理视图更新。当模型通知控制器账户已经改变时,控制器只需要修改它的列表。视图计算出其余部分。
控制器的代码命名为AllController2
,出现在清单 11-33 中。变量accounts
保存着BankAccount
对象的可观察列表。JavaFX 类FXCollections
包含几个用于创建可观察对象的静态工厂方法;方法observableArrayList
创建了一个包装了ArrayList
对象的可观察列表。
public class AllController2 implements BankObserver {
private Bank bank;
private ObservableList<BankAccount> accounts
= FXCollections.observableArrayList();
public AllController2(Bank bank) {
this.bank = bank;
bank.addObserver(BankEvent.NEW, this);
bank.addObserver(BankEvent.DEPOSIT, this);
bank.addObserver(BankEvent.SETFOREIGN, this);
bank.addObserver(BankEvent.INTEREST, this);
for (BankAccount ba : bank)
accounts.add(ba); // initially populate the list
}
public ObservableList<BankAccount> getAccountList() {
return accounts;
}
public void interestButton() {
bank.addInterest();
}
public void update(BankEvent e, BankAccount ba, int amt) {
if (e == BankEvent.INTEREST)
refreshAllAccounts();
else if (e == BankEvent.NEW)
accounts.add(ba);
else {
int i = accounts.indexOf(ba);
refreshAccount(i);
}
}
private void refreshAccount(int i) {
// a no-op, to force the list to notify its observer
accounts.set(i, accounts.get(i));
}
private void refreshAllAccounts() {
for (int i=0; i<accounts.size(); i++)
refreshAccount(i);
}
}
Listing 11-33The AllController2 Class
控制器观察四种事件,它的update
方法根据事件执行不同的动作。对于一个INTEREST
事件,控制器调用refreshAllAccounts
,这样视图将重新显示列表中的每个元素。对于NEW
事件,控制器将新的银行账户添加到列表中。对于DEPOSIT
和SETFOREIGN
事件,控制器刷新具有指定账号的列表元素。
注意,DEPOSIT
或SETFOREIGN
事件改变了列表元素的状态,但实际上并没有改变列表。这是一个问题,因为列表不会通知视图,除非它改变了。refreshAccount
方法通过设置列表元素的新值与旧值相同来解决这个问题。尽管该操作对列表元素没有影响,但列表会将其识别为对列表的更改,并通知视图重新显示该元素。
可观察的表格视图
ListView
控件在单个单元格中显示每个BankAccount
对象的信息。如果帐户信息可以显示为一个表格,每个值都在自己的单元格中,这在视觉上会更令人愉快。这就是TableView
控制的目的。图 11-9 显示了修改后使用TableView
控件的视图截图。
图 11-9
以表格视图的形式管理所有帐户
这个视图被命名为 AllView3 ,它的代码出现在清单 11-34 中。变量accts
现在的类型为TableView
。一个TableView
控件观察一个列表,与ListView
相同。它的方法setItems
将控件与列表连接起来。因为机制与ListView
控制器完全相同,AllView3
可以像AllView2
一样使用控制器AllController2
。
public class AllView3 {
private Pane root;
TableView<BankAccount> accts = new TableView<>();
public AllView3(AllController2 controller) {
root = createNodeHierarchy(controller);
TableColumn<BankAccount,Integer> acctnumCol
= new TableColumn<>("Account Number");
acctnumCol.setCellValueFactory(p -> {
BankAccount ba = p.getValue();
int acctnum = ba.getAcctNum();
Property<Integer> result
= new SimpleObjectProperty<>(acctnum);
return result;
});
TableColumn<BankAccount,Integer> balanceCol
= new TableColumn<>("Balance");
balanceCol.setCellValueFactory(p ->
new SimpleObjectProperty<>
(p.getValue().getBalance()));
TableColumn<BankAccount,String> foreignCol
= new TableColumn<>("Owner Status");
foreignCol.setCellValueFactory(p -> {
boolean isforeign = p.getValue().isForeign();
String owner = isforeign ? "Foreign" : "Domestic";
return new SimpleObjectProperty<>(owner);
});
accts.getColumns().addAll(acctnumCol, balanceCol,
foreignCol);
accts.setItems(controller.getAccountList());
accts.setPrefSize(300, 200);
}
...
}
Listing 11-34The AllView3 Class
TableView
和ListView
的区别在于一个TableView
控件有一个TableColumn
对象的集合。方法getColumns
返回这个集合。
一个TableColumn
对象有一个头字符串,它被传递给它的构造器。一个TableColumn
对象也有一个“单元格值工厂”该对象的参数是一个方法,它计算给定列表元素的单元格的显示值。该方法的参数p
表示可观察列表的一个元素,这里是一个包装了BankAccount
的对象。它的方法getValue
返回被包装的BankAccount
对象。SimpleObjectProperty
类从它的参数对象中创建一个属性。
例如,考虑清单 11-34 中的第一个 lambda 表达式,它计算列acctnumCol
的值。
p -> {
BankAccount ba = p.getValue();
int acctnum = ba.getAcctNum();
Property<Integer> result =
new SimpleObjectProperty<>(acctnum);
return result;
}
这个 lambda 表达式解开 p,从解开的银行帐户中提取帐号,将值包装为属性,并返回它。λ表达式可以更简洁地表达如下:
p -> new SimpleObjectProperty<>(p.getValue().getAcctNum())
摘要
MVC 设计规则声明程序中的每个类都应该有模型、视图或者控制器的职责。以这种方式设计程序可能需要纪律。您可能需要编写三个类来执行任务,而不是编写一个单独的类,以便将任务的模型、视图和控制器方面分开。尽管创建这些类无疑需要更多的努力,但它们带来了显著的好处。分离的关注点使程序更加模块化,更容易修改,因此更符合基本的设计原则。
MVC 模式描述了一种组织模型、视图和控制器类的有效方法。根据该模式,一个程序将有一个模型,可能还有几个视图。每个视图都有自己的控制器,并使用控制器作为中介来帮助它与模型通信。每个控制器都有一个对其视图和模型的引用,因此它可以向模型发送视图请求,并向视图发送模型更新。然而,这个模型对控制器和视图一无所知。相反,它通过观察者模式与他们交流。
MVC 模式在模型、控制器和视图之间编排了一段复杂的舞蹈。这个舞蹈的目的是支持程序的灵活性和可修改性。特别地,视图是相互独立的;您可以在 MVC 程序中添加和删除一个视图,而不会影响其他视图。
这种灵活性具有巨大的价值。本章给出了几个基于 MVC 的商业软件的例子——比如 Excel、pdf viewers 和文件管理器——并描述了由于它们的 MVC 架构而成为可能的特性。
尽管本章对 MVC 模式给予了热情的支持,但现实是该模式并没有一个统一的定义。本章给出的定义只是用来组织模型、视图和控制器的几种方法之一。然而,不管它们有什么不同,所有 MVC 定义的中心特征都是它们对观察者模式的使用:控制器向模型发出更新请求,模型通知它的观察者由此产生的状态变化。我更喜欢使用控制器作为模型观察者,但是也可以使用视图,甚至是视图和控制器的组合。对于如何将视图连接到它的控制器,也有不同的方法。
正如本书中所有的设计模式一样,总有一些折衷要做。一个好的设计师会调整 MVC 组件之间的连接,以适应给定程序的需要。一般来说,你对 MVC 模式的工作方式和原因理解得越深,你就有越多的自由来做出调整,这将使你得到最好的设计。