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类为例。抽象类实现了所有的接口方法,在需要的时候调用抽象的“助手”方法。每个子类都实现这些助手方法。这种技术被称为模板模式。抽象类提供了每个接口方法应该如何工作的“模板”,每个子类提供了特定于子类的细节。