笨办法学-Java-面向对象编程-二-

笨办法学 Java 面向对象编程(二)

原文:Learn Object-Oriented Java the Hard Way

译者:飞龙

协议:CC BY-NC-SA 4.0

  1. 让代码运行起来。(调试它。)

  2. 确保代码执行了它应该执行的功能。(测试它。)

  3. 现在既然它运行正常了,这代码够快了吗?如果是,你就完成了!

  4. 如果太慢了,对代码进行“性能分析(profile)”,看看哪些部分慢。

  5. 修复慢的部分。回到步骤 4。

让正确的代码运行得更快,比让一个“优化过”但损坏的代码运行起来要容易得多。

引用一位著名计算机科学家的话:

“程序员浪费了大量的时间去思考或担心程序中非关键部分的速度,而这些对效率的尝试在考虑调试和维护时实际上会产生强烈的负面影响。我们应该忘记那些微小的效率,在大约 97% 的情况下如此:过早优化是万恶之源。然而,我们绝不应该放弃在那 3% 的关键时刻的机会。”

– 唐纳德·克努斯(Donald Knuth)

这段引用来自 1974 年,当时像 PDP-11 这样流行的计算机内存不足 1 兆字节,运行时钟频率低于 15 MHz!如果为[这样的]机器编写程序……

练习 27:静态变量和方法

好了,我们现在讨论了效率,现在让我们回到 static 的含义。Java 的特性包括:静态变量和静态方法。

import java.util.ArrayList;

public class Fish {
    private String name;
    private int id;
    private static int count = 0;

    public Fish(String name) {
        this.name = name;
        count++;
    }

    public String toString() {
        return name + " #" + count;
    }

    public static int numCreated() {
        return count;
    }
}

FishDriver.java

驱动程序有一个鱼的名称数组,这样我就不需要重复编写相同的代码。还有一个 ArrayList 用于存储对 Fish 的引用。我从遍历每个名称开始……

public class FishDriver {
    public static void main(String[] args) {
        String[] names = { "Wanda", "Bruce", "Dory", "Qwerty", "Blinky", "Lenny", "Flounder", "Mr. Puff", "Moby Dick", "Fred", "di" };
        ArrayList<Fish> school = new ArrayList<>();

        for (String n : names) {
            Fish f = new Fish(n);
            school.add(f);
        }

        for (Fish f : school) {
            System.out.println(f);
        }

        System.out.println(Fish.numCreated() + " Fish objects were created.");
    }
}

你应该看到的

在我过多谈论 static 的影响之前,让我们先记住普通的(非静态)实例变量和方法是如何工作的。

Fish 类中,字符串 name 和整数 id 属于特定的 Fish 对象。每当实例化一个 Fish 对象时,该对象都会获得这两个变量的副本。

所以,如果你创建了一个名为 fFish 对象,那么调用 f.toString() 操作的是 fname 变量副本以及 fid 变量副本。

static 变量则不同。它属于类,属于整个类。无论创建了多少个 Fish 对象,它们都共享同一个 count 变量副本。

这就是为什么在 Fish 类的构造函数中有一个 count++count 在第 4 行初始化为 0,因此每当创建一个新的 Fish 时,该 Fish 都会对共享计数器加 1,然后将自己的 id 字段设置为相同的数字。

numCreated() 方法可以声明为 static,因为它尝试访问 static 变量。静态方法只能访问静态变量。这是因为即使在没有实例化该类的对象时,静态方法和静态变量也已经存在。

普通的(非静态)方法可以访问两种变量,静态或非静态的。

即使在没有创建 Fish 对象时,静态方法 numCreated() 也存在,你可以像我们在驱动程序第 21 行那样调用它:

int fishCount = Fish.numCreated();

注意你直接使用了类名。从类的实例(instance)调用静态方法也是合法的(虽然很奇怪):

Fish omari = new Fish("Sticks");
int count = omari.numCreated();

这运行良好。

我们并不需要numCreated() 方法定义为 static。仅仅因为它只访问静态变量并不意味着它必须是静态的。如果我们没有把它变成静态,那么:

int fc = Fish.numCreated();

将无法工作。

好了,这个练习可能足够了。在下一个练习中,我们将查看 Java 中常用的一些静态方法。

练习 28:Java 中的静态方法

既然你已经知道了静态方法和静态变量,我将指出 Java 中的几个。你在之前的练习中已经用过很多它们了!

静态方法

public class StaticMethods {
    public static void main( String[] args ) {
        System.out.println( "println() is a static method!" );

        System.out.print("You don't need to instantiate a Math ");
        System.out.println("to call the random() method; it's static!");
        double a = Math.random();
        double b = Math.random();

        // Math class
        System.out.println( Math.PI );
        System.out.println( Math.abs(-9) ); // 绝对值
        System.out.println( Math.sqrt(2) ); // 平方根
        System.out.println( Math.min(a, b) + " is smaller." );
        System.out.println( Math.max(a, b) + " is larger." );
        System.out.println( Math.pow(a, b) ); // a 的 b 次方
        double x = Math.PI * 0.5;
        System.out.println( Math.sin(x) );
        System.out.println( Math.cos(x) );
        System.out.println( Math.tan(x) );

        // String class
        String digits = String.valueOf( Math.E ); // valueOf() 是静态的
        int num = Math.min(17, digits.length());
        String mitchell = digits.substring(0,num); // substring() 不是静态的
        System.out.println("Mr. Mitchell has " + mitchell + " memorized.");

        // Integer class
        System.out.println("ints take up " + Integer.SIZE + " bits.");
        System.out.println("\tSmallest possible int: " + Integer.MIN_VALUE);
        System.out.println("\t Largest possible int: " + Integer.MAX_VALUE);
        int model = Integer.parseInt("1070");
        model += 10;
        System.out.println( String.valueOf(model) );
        System.out.println( ""+model );
    }
}

PopularStaticMethods.java

Java 有一个名为 Math 的内置类。它位于 java.lang 包中,这意味着你不需要 import 任何就可以使用它;java.lang 中的所有内容都是总是导入的。

Math 类中的每一个方法都是静态的,因此永远不需要实例化 Math 对象。

Math 类还包含两个 public 静态变量,但这两个变量都被定义为 final,这意味着它们的值不能被改变。(所以别动歪主意。)

我一直喜欢 Java 文档解释它的方式:

public static final double PI

这个 double 值比任何其他值都接近 pi,即圆周与直径的比率。

“比任何其他值都接近。”不错。

你现在终于掌握了足够的知识来理解该定义中的所有内容:

变量名为 PI。它是一个 double,因为它是一个数字并且需要小数。它是 final 的,所以它已经有一个值且不能被改变。该变量是 public 的,这意味着可以从 Math 类外部直接访问。它是 static 的,这意味着它有一个属于整个类的副本,并且你不需要创建一个 Math 对象的实例就可以访问它。

呼。

这就是为什么阅读 Java 文档对初学者来说非常困难。单行代码中就包含了极多信息,而且每个词都有重要意义。

你应该看到的

Math 类中的每一个方法都是静态的,因此永远不需要实例化 Math 对象。

Math 类还包含两个 public 静态变量,但这两个变量都被定义为 final,这意味着它们的值不能被改变。(所以别动歪主意。)

Integer 类还包含几个实用的方法。Integer.MAX_VALUE 是溢出发生前 int 能够存储的最大值。再大 1 就会溢出。

图形程序往往仅能够接收 String 类型的输入。如果你要求用户输入年龄,当他们输入时,它以 String 形式进来。Integer 是溢出发生前 int 能够存储的最大值。再大 1 就会溢出。

另一个非常有用的方法是 parseInt(),它是 valueOf() 的反操作。如果你给它一个只包含数字的 String,它将返回一个具有相同值的 int

Integer 类还有几个实用的方法。String.valueOf() 将……转换为 String

String 类也有几个静态方法。substring() 不是静态的,因为它需要有一个值来生成子字符串。

import static java.lang.Math.*;
import static java.lang.System.out;

public class ImportingStatic {
    public static void main( String[] args ) {
        double a = random();
        double b = random();

        out.println( PI );
        out.println( abs(-9) );
        out.println( sqrt(2) );
        out.println( min(a, b) + " is smaller." );
        out.println( max(a, b) + " is larger." );
        out.println( pow(a, b) ); // a raised to the power of b
        out.println( Math.sin(PI) );
        out.println( Math.cos(PI) );
        out.println( Math.tan(PI) );
    }
}

ImportingStatic.java

在某些编程语言中,你不能将某个命名为 sqrt。这是因为已经有一个内置的平方根函数且它一直存在。

你可以说 sqrt 的标识符位于默认的全局“命名空间”中。如果你还记得之前练习中的符号表,那么命名空间基本上就是你在写第一行自己的代码之前,就已经在符号表中的变量列表。

在其他语言中,一旦你导入了 Math 库,那么 sqrt 就会进入命名空间中。但如果你没有导入 Math 库,你仍然可以在程序中使用该名称。(不确定你为什么要这么做,但是……)

Java 采用了一种我喜欢的折中方案。Math 库始终是“被导入”的,但函数名不会弄乱全局命名空间。我不需要担心是否可以创建一个名为 min 的变量,因为尽管 Math 库是可用的,但所有静态方法都安全地封装在内部。如果我需要它们,必须写成 Math.min()

但如果我正在创建一个真正的数学密集型程序呢?我真的必须写成:

a = Math.sqrt( Math.pow(b, 2) + Math.pow(c, 2) - 2*b*c * Math.cos(A) );

天哪,不用!Java 允许你写一行代码,意思是“将所有静态方法和变量导入到当前命名空间”。

import static java.lang.Math.*;

(在处理计算机时,* 往往是一个“通配符”字符,意味着“匹配任何内容”,在这里就是这样使用的。)

在导入这些静态项后,你现在可以直接写:

a = sqrt( pow(b, 2) + pow(c, 2) - 2*b*c * cos(A) );

这看起来好多了!

你应该看到的内容

看,它真的有效!

System 类包含一个名为“out”的静态成员,它是一个带有我们常用的 println 方法的 PrintStream 对象!

所以你可以如所示仅导入这一个对象,允许我们写:

out.println( "No System needed!" );

向命名空间中导入大量的静态项被认为是不良风格,所以请谨慎使用。

在学习练习之前还有一件事。你注意到程序末尾不寻常的输出了吗?也许没有注意到,但我预期 pisintan 应该是 0,而不是 1.2246 x 10^-16

这是在计算机上处理浮点数时的常见问题。不仅 Java 如此;即使是专业的数值计算环境 MATLAB 给出的答案也是 1.2246E-16!我不知道你对高中数学记得如何,但 1.22E-16 是一个非常接近零的极小数:

0.0000000000000001224646799

但因为这种情况非常常见,所以在使用浮点数时不应该进行相等判断,因为舍入误差会导致它们产生细微差异。

也就是说,不要写:

double a = 0;
double b = sin(PI);
if ( a == b ) {
    // do something
}

……因为 if 语句几乎永远为真。相反,请检查两个值是否“足够接近”:

double a = 0;
double b = sin(PI);
double error = 1.0E14;
if ( abs(a-b) < error ) {
    // close enough!
}

这安全得多。

好了,这个练习就够了。我们在下一个练习中查看参数传递!

练习 30:作为参数的引用

在本次练习中,我们将回顾引用,并观察将引用变量作为参数传递给方法时产生。

在输入代码之前,提醒自己数组变量包含指向堆中内存位置的引用。记住,将一个数组赋值给另一个拷贝的是引用,而不是数组内部的值。

import java.util.Arrays;

public class ReferenceParameters {
    public static void main( String[] args ) {
        int[] a = new int[7];
        Arrays.fill(a, 2);
        System.out.println(Arrays.toString(a));

        int[] b = { 1, 2, 3, 4, 5 };
        a = b;
        System.out.println(Arrays.toString(a));

        int[] c = new int[a.length*2];
        for (int i = 0; i < a.length; i++) {
            c[i] = a[i];
        }
        a = c;
        System.out.println(Arrays.toString(a));
    }

    public static void squareValues(int[] arr) {
        System.out.println("Squaring array " + arr);
        for (int i = 0; i < arr.length; i++) {
            arr[i] = arr[i] * arr[i];
        }
    }

    public static void chopArray(int[] arr) {
        int[] c = new int[arr.length/2];
        for (int i = 0; i < c.length; i++) {
            c[i] = arr[i];
        }
        System.out.println("The array is now " + c);
    }

    public static int[] chop2(int[] arr) {
        int[] c = new int[arr.length/2];
        for (int i = 0; i < c.length; i++) {
            c[i] = arr[i];
        }
        System.out.println("Returning the reference " + c);
        return c;
    }
}

ReferenceParameters.java

所以 a 是一个拥有 7 个槽位的数组(编号 0 到 6)。Arrays.fill(a, 2)2 填充所有槽位。

接下来 b 也是一个数组。我们使用了初始化列表,所以它已经有了值,且 b 的长度为 5(索引 0 到 4)。

然后在第 10 行,我们用 b 的引用覆盖了 a。那个只全是 2 的数组消失了;ab 现在引用同一个五元素数组。显示 a 证实了这一点。

在下一个小代码块中我们创建了第三个数组。c 的长度是 a 的两倍(所以是 10 个槽位,0-9)。我们使用传统的 for 循环将 a 的所有值拷贝到 c 中,因此 c 的前五个槽位有了数字 1-5。(其余部分仍然是零,因为那是 Java 数组默认填充的内容。)

接着在第 17 行我们再次覆盖了 a,这次是拷贝了 c 的引用。

这很酷,因为我们好像直接让 a 变大了。它以前有 5 个槽位,里面是 1-5,现在它拥有相同的内容但更大的容量!引用不同,但谁会在乎呢?嗯……我们会在乎。你很快就会发现。

从第 20 行开始,我们打印出引用以便跟踪它。请记住,虽然 Java 中的数组是对象(也是 Object 类型),但它们的 toString() 方法并没有什么好用的内容,因此尝试打印数组只会显示一些奇怪的符号,后面跟着一个内存引用。(首先开头有一个 [ 表示它是一个数组,然后大写的 I 表示它是一个基本类型 int 数组,接着是一个 @ 符号,最后是十六进制的引用/内存地址(也称为 16 进制)。)

你应该看到的

留意内存引用值的值。我们在第 20 行打印了 a 的当前地址,然后在接下的一行打印了它的内容。然后,我们将 a 引用的副本传递给静态方法 squareValues()

squareValues() 方法有一个名为 arr 的参数。你可以看到 arr 内部包含的引用与 a 相同。它们是不同的变量,但它们都持有同一个地址的副本。因此,它们“指向”堆中的同一个物理数组。

该方法遍历数组中的每个槽位并修改了每个值。因此,当方法结束并将控制权交还给 main() 时,你可以看到 a 中的值也发生了变化。(这可能并不意外,因为它们是同一个数组。)

好了,第二个示例。显示 a 的引用,并将该引用的副本传递给静态方法 chopArray()

chopArray() 内部,它也有一个叫 arr 的参数。这在技术上与上一个方法中的变量是不同的,但它也拥有来自 a 的引用值副本。

chopArray() 执行的第一个件事是创建一个名为 c 的新数组,其容量是 arr 的一半(所以 c.length5)。就像我们之前做的一,该方法复制了所有值,并用指向这个新较小数组的引用覆盖arr

因此,arr 不再指向与 a 相同的堆物理数组。我们可以通过显示 arr 现在持有的引用来确认这一点;你可以看到它是不同的。

然后方法结束并将控制权返回给 main()。这个价值 64,000 美元的问题是:a 发生了什么?

希望答案并不意外。什么也没有发生。a 没有任何变化。

确实,arr 拥有来自 a 的引用副本,但删除该副本与原始值无关。它仍然安全地存储在 a 中。

某些编程语言具有这样一种概念:传入参数可以被函数或方法修改。在这些语言(如 C++)中,你可以指定参数是否可以被更改。

Java 没有这些。Java 中的所有参数都是按值传递(pass-by-copy)。如果参数是一个对象,它会创建引用的副本。如果参数是基本类型,它会创建值的副本。但它始终是一个副本。在 Java 中没有办法规避这一点,这有时虽然不方便,但在大多数情况下让 Java 程序更容易调试且编写起来更安全。

好了,最后一个示例。我们打印 a 的引用(以防你万一忘了它是什么),然后将其副本传递给静态方法 chopArray2()

chopArray2() 方法与其命名相似的兄弟方法完全相同,但有一个细微区别:它没有尝试修改 arr(因为无论如何也没有任何好处)。相反,它返回了指向新较小数组的引用。

你可以看到该方法不像上一个那样是 void;它的返回类型是 int[],这意味着它返回一个指向整数数组的引用。

那么这个返回的引用去哪了呢?回到 main() 中,你可以看到我们做了什么:

a = chopArray2(a);

我们将方法的返回值存储到 a 中,覆盖了那里原来的引用。在 Java 中没有其他方法可以做到。在 Java 中编写一个可以改变参数值的方法是不可能的。

希望你能看到这与第一个示例中发生的情况何不同。squareArray() 没有尝试改变参数 arr。该变量中的引用从未被触碰。它只是改变了参数所指向的物理数组中的值。

这是一个细微的区别,但非常重要。

总之,这个练习就够了。在下一次练习中,我们将讨论这一切对 String 对象产生什么影响。

练习 31:Java 字符串是不可变的

到目前,你已经理解基本类型变量只持有一个值,而对象变量持有的是对象的引用。你理解了你可以通过更改引用来改变特定变量……指向的对象。嗯。

但是如果不更改引用,对象的内部细节是否可能发生变化?(我们把这些内部称为对象的“状态”。)

在 Java 中,答案是有时可以。某些对象具有“可变”状态——对象的内部可以被变异或改变。其他对象是不可变的;一旦对象被创建,就无法以任何方式进行修改。

在 Java 中,字符串是不可变的。好了,让我们看代码。

 1 import java.util.ArrayList;
 2 
 3 public class ImmutableStrings {
 4     public static void main( String[] args ) {
 5         ArrayList<Double> list = new ArrayList<>();
 6         list.add(1.0);
 7         list.add(2.0);
 8         list.add(3.0);
 9         System.out.println("List size: " + list.size());
 10        System.out.println("First element: " + list.get(0));
 11        list.remove(0);
 12        System.out.println("List size after removal: " + list.size());
 13        System.out.println("First element after removal: " + list.get(0));
 14        list.clear();
 15        System.out.println("List size after clear: " + list.size());
 16    }
 17 }

ImmutableStrings.java

所以我们在这里从 ArrayList 开始,因为 ArrayList可变的。看修改有多简单?它有一个叫 add() 的方法,通过添加元素来更改对象。添加元素时,size() 发生变化。还有一个叫 clear() 的方法,它将列表重置为空状态。

ArrayList 方法 size()get()isEmpty()indexOf() 是访问器方法(accessor methods),因为它们报告对象的属性但不改变任何内容。

ArrayList 方法 add()clear()set() 是变异方法(mutator methods),因为它们改变了列表的状态。

(在某些编程语言中,访问器方法和变异方法被称为“getter”和“setter”。)

在 Java 中,字符串是不可变的,因为 String没有任何变异方法String 类只有访问器方法。

你应该看到的

让我们看一个具体的例子。substring() 是一个字符串方法,它通过返回字符串对象的较短版本来报告。

如果变量 word 包含 "impossible",那么 word.substring(2) 返回从字符位置 2 到结尾的字符串("possible")。但 word 所指向的字符串对象不受影响。

在代码中,我们调用了 substring() 方法,它返回了一个较短的字符串,而我们没有对给出的结果做任何操作。

所以 word 所指向的对象无法被改变;它是不可变的。但我们可以改变该变量指向的对象!

String 变量 w 包含指向 "workaround" 的引用。如果我们写下

w = w.substring(0,4);

…那么 w 被改变指向由方法调用创建的新的、更短的 String 对象。

最后一段代码不应该让你感到意外。根据你在上一个练习中学到的知识,你知道如果你将一个对象作为参数传递给方法,该方法无法更改任何引用。

方法可以要求对象对自己进行变异,但由于 String 类没有任何变异方法(mutator methods),方法几乎没有办法更改传递给它的 String

练习 32:参数 vs 属性

好了,既然上两个练习是关于方法如何无法改变其参数之一(除非该参数是可变对象),让我们让我们回想一下方法可以做什么。

Java 是一种面向对象的语言,所以你编写的大部分方法将属于对象。虽然对象内部的方法不能改变传递给它们的参数,但它们可以改变自身对象的任何实例变量/字段。

这是一个愚蠢的例子。我写了构造函数、一个 getter 方法和一个 toString() 方法,但由于它们不是本次练习的重点,我将每一个都放在了单行。

 1 public class NumberGuy {
 2     private int number;
 3 
 4     public NumberGuy() { number = 0; }
 5     public NumberGuy( int number ) { this.number = number; }
 6     public int getNumber() { return number; }
 7     public String toString() { return "" + number; }
 8 
 9     public void doubleYourself() {
10         number = number*2;
11     }
12 
13     public void changeThis( int q ) {
14         q = q*2;
15     }
16 }

NumberGuy.java

所以 NumberGuy 对象有一个字段(实例变量):名为 number 的私有基本类型 int

你可以看到 doubleYourself() 方法将该字段变为原来的两倍。

此外,还有一个名为 changeThis() 的方法。它接收一个参数,并尝试更改该参数的值。(你应该怀疑这不会产生持久的影响。)

 1 public class NumberGuyDriver {
 2     public static void main( String[] args ) {
 3         NumberGuy n = new NumberGuy(5);
 4 
 5         System.out.println(n);
 6         n.doubleYourself();
 7         System.out.println(n);
 8 
 9         int q = 4;
10         System.out.println(q);
11         n.changeThis(q);
12         System.out.println(q);
13     }
14 }

NumberGuyDriver.java

在驱动程序中,我们实例化了一个存储值为 5NumberGuy 对象。我们显示它(使用方便的 toString() 方法来帮助我们)。然后我们要求对象使用它的变异方法让自己倍。。成功成功!

我们将一个包含 4 的变量传递给 NumberGuychangeThis() 方法。但该方法并没有成功。

你应该看到的

Java 特意这样工作的。Java 是一种对象语言,你可能记得面向对象编程背后的核心思想之一是“封装”:对象内部的变量只能由该对象更改。对象可以根据需要改变自己,但它们不应该改变发送给它们的内容。

这使得大型程序更安全且更容易推理。你可以知道,在将一个变量传递给方法后,该方法不会意外地(或有意地!)更改该变量的值。

明白吗?

在下一个练习中,我们将观察面向对象编程的另一个方面,称为“继承”。

练习 33:基础继承

面向对象编程有三个主要特征:

  • 多态

  • 继承

  • 封装

出于某种原因,我总是用“PIE”这个助记词来记住它们。面向对象的饼里有三块!

(有时人们会加入抽象,但封装只是抽象的一种更具体的形式。此外,它不放进饼里。)

封装是将数据和代码组合成一个包/对象,以便数据只能通过提供的方法对其进行访问或修改。到目前为止,你已经在书中看到了很多封装。

多态是指你可以编写一次代码并让它适用于不同类型的数据。Java 处理多态的一种方式是通过泛型,你也已经见过泛型。

继承是指一个新对象继承另一个对象的某些代码,这样你总共需要编写的代码就少了。这就是我们在这个练习中要学的!

留意关键字 extends,它将出现在 Square 类中。那个词意味着正在发生继承。

 1 public class Rect {
 2     private double length, width;
 3 
 4     public Rect() {
 5         length = width = 0;
 6     }
 7 
 8     public Rect( double length, double width ) {
 9         this.length = length;
10         this.width = width;
11     }
12 
13     public double getLength() { return length; }
14     public double getWidth() { return width; }
15 
16     public double getArea() { return length*width; }
17 
18     public double getPerimeter() { return 2*(length + width); }
19 
20     public String toString() { return "Rect[" + length + "x" + width + "]"; }
21 }

Rect.java

所以这是一个矩形类。注意它有两个字段:lengthwidth,它没有变异方法(有时被称为 setters),所以 Rect 对象是不可变的。

 1 public class Square extends Rect {
 2     public Square( double side ) {
 3         super(side, side);
 4     }
 5 
 6     public String toString() {
 7         return "Square[" + getLength() + "x" + getWidth() + "]";
 8     }
 9 }

Square.java

Square 类使用关键字 extends 来继承 Rect 类。这意味着 Square 自动获得 Rect 的所有字段和方法。Square 类添加了一个构造函数和一个覆盖的 toString() 方法。

 1 public class BasicInheritance {
 2     public static void main( String[] args ) {
 3         Rect rect1 = new Rect(4,5);
 4         System.out.println(rect1);
 5         System.out.println("\tArea: " + rect1.getArea());
 6         System.out.println("\tPerimeter: " + rect1.getPerimeter());
 7 
 8         Square sq1 = new Square(3);
 9         System.out.println("\n"+sq1);
10         System.out.println("\tArea: " + sq1.getArea());
11         System.out.println("\tPerimeter: " + sq1.getPerimeter());
12 
13         Rect r = new Rect();
14         Rect s = new Square(2);
15         System.out.println("\n" + r + ", and " + s);
16 
17         r = s;
18         System.out.println("\nHere is my rectangle: " + r);
19     }
20 }

BasicInheritance.java

运行它时输出看起来是这样:

你应该看到的

我们在驱动程序(driver)中做的第一件事是实例化一个 Rect 对象。我们打印它并调用了它的一些方法。

然后我们实例化一个 Square 对象。你会注意到,我们可以代表 Square 对象调用 getArea() 方法,尽管这些方法只在父类中定义!这就是继承的魔力!

这是因为每个 Square 对象也是一个 Rect 对象,只是多了一些额外的内容。

接下,我们又实例化了另外两个对象并显示了它们。这证明了当调用 toString() 方法时,它使用的是该对象类型对应的正确版本。

在驱动程序的最后两行,我做了一些不寻常。变量 r 能够持有一个 Rect 类型对象的引用。变量 s 当前持有一个 Square 类型对象的引用。所以,

r = s;

…将引用从 s 复制到 r 中。这样可以吗?当然可以,因为 r 能够持有一个 Rect 的引用,而 s 持有一个 Square 的引用,而且每一个 Square 都是一个 Rect。类型为 Square 的对象也是一个类型为 Rect 的对象,只是多了一些额外的内容。

你可以看到 Java 甚至足够智能到调用合适的 toString() 方法。尽管 r 指向矩形,但它调用的是方形版本的 toString() 方法,因为引用另一端的对象实际上是一个正方形。

很酷,对吧?因此在接下来的练习中,我们将更深入地观察继承是如何应用于字段的。

练习 34: 字段是如何继承的

在上一章中,我把问题简化得太过了。我说子类“会自动获得[父类的]字段的副本”。这种说法是正确的!但子类可能无法访问那些继承来的字段!

实际上,可能无法访问。在这个练习中还有一个新关键字:protected

public class FieldInheritance {

    private String first;
    protected String last;

    public FieldInheritance() {
        first = last = "";
    }

    public FieldInheritance( String first, String last ) {
        this.first = first;
        this.last = last;
    }

    public void setFirst( String s ) {
        first = s;
    }

    public void setLast( String s ) {
        last = s;
    }

    public String getFirst() { return first; }
    public String getLast()  { return last; }

    public String toString() {
        return first + " " + last;
    }
}

FieldInheritance.java

所以这个对象有两个字段:firstlastString 类型 first 是私有的(private),这通常是你想要的。

String 类型 last 是受保护的(protected),这与私有相似。不过,有一点点细微的区别。我们很快就会看到。

这个文件中没有其他有趣的内容,只有在定义 Java 类时通常需要编写的构造函数以及 getter 和 setter 方法。

public class FieldInheritanceSub extends FieldInheritance {

    public FieldInheritanceSub() {
        super();
    }

    public FieldInheritanceSub( String first, String last ) {
        super(first, last);
    }

    public void test() {
        // first = "GOOD";
        last = "COFFEE";
    }
}

FieldInheritanceSub.java

这个子类 extends(继承)之前的类,这意味着它继承了所有字段和方法的副本(但没有继承构造函数)。因此 FieldInheritanceSub 确实拥有 firstlast 的副本。

但看看那个 test() 方法。注意到代码中有一行被注释掉了吗?那是故意为之。

子类确实有字段 first 的副本。但它无法直接访问它。为什么?因为该字段是 private 的。私有字段只能在同一个类中访问。此。

其他任何类无法触碰私有数据成员,包括子类。

然而,字段 last 可以被访问,因为它是 protected 的。当一个字段具有受保护的可见性时,意味着它除了子类以外都是私有的。子类可以访问受保护的字段;其他任何类不能。

public class FieldInheritanceDriver {
    public static void main( String[] args ) {
        FieldInheritance f = new FieldInheritance("Bill", "Brask");
        System.out.println(f);

        FieldInheritanceSub g = new FieldInheritanceSub("Turkish", "Delight");
        System.out.println(g);
        g.test();
        System.out.println(g);
    }
}

FieldInheritance.java

驱动程序证实了一切都符合预期。子类的 test() 方法能够修改父类的保护字段,但不能修改私有字段。

你应该看到的

如果你正在编写一个可能会被继承的类,那么将字段设置为 protected 而不是 private 是一个好主意。如果你在类中有一个知道应该对其他类限制的字段,将其设置为 private

练习 35: “实用”继承 - 棋盘

我们在过去的几个练习中学习了继承,但我们写的程序并不实用,也不清楚要在它们中使用继承是为了。

所以在接下来的几个练习中,我们将构建一个涉及二维网格的游戏框架。

这个练习比大多数的都要长,因为我需要大量的代码让继承产生意义(在现实世界中也是如此)。

public class GamePiece {
    protected int r, c;
    protected GameBoard board;
    protected String symbol;

    public GamePiece() {
        r = c = 0;
        board = null;
        symbol = "@";
    }

    public GamePiece(int r, int c, String symbol) {
        this.r = r;
        this.c = c;
        this.board = null;
        this.symbol = symbol;
    }

    public boolean addSelfToBoard( GameBoard board ) {
        if ( board.canAdd(r,c) && ! isOnBoard() && board.get(r,c) == null ) {
            this.board = board;
            board.add(this,r,c);
            return true;
        }
        return false;
    }

    public boolean isOnBoard() {
        return board != null && board.get(r,c) == this;
    }

    public void removeSelfFromBoard() {
        if ( isOnBoard() ) {
            board.remove(r,c);
            board = null;
        }
    }

    public int getRow() { return r; }
    public int getCol() { return c; }
    public String getSymbol() { return symbol; }

    public String toString() {
        return "GamePiece at (" + r + ", " + c + ")";
    }
}

这是放置在游戏棋盘上的部分的所谓“基类”。所有字段都设置为 protected,因为我们预期其他类会继承自这个类!

每个棋子都有一个指向所在棋盘的引用,并跟踪其在网格中的行和列。由于我们只是在终端屏幕上绘图,每个棋子都存储一个符号。有一些方法允许棋子将其添加到棋盘或从棋盘中移除。这些代码在我们稍后使用后就会意义。

import java.util.ArrayList;

public class GameBoard {
    protected GamePiece[][] g;

    public GameBoard( int rows, int columns ) {
        g = new GamePiece[rows][columns];
    }

    public void add( GamePiece a, int r, int c ) {
        if ( !canAdd(r,c) )
            return;
        g[r][c] = a;
    }

    public boolean canAdd( int r, int c ) {
        return ( 0 <= r && r < g.length && 0 <= c && c < g[0].length );
    }

    public GamePiece get( int r, int c ) {
        return g[r][c];
    }

    public void remove(int r, int c ) {
        g[r][c] = null;
    }

    public String toString() {
        String out = "";
        for ( int r=0; r<g.length; r++ ) {
            for ( int c=0; c<g[0].length; c++ ) {
                if ( g[r][c] == null )
                    out += ".";
                else
                    out += g[r][c].getSymbol();
            }
            out += "\n";
        }
        return out;
    }
}

GameBoard.java

这是游戏棋盘!它包含一个 GamePiece 对象的二维数组(严格来说,它包含的是一个指向对象数组的引用)。它有将棋子添加到棋盘、移除它们等方法。注意,如果他们想用一个棋子覆盖另一个棋子,我们是允许的;这在某些游戏中是可以接受的!我们只需要检查他们是否因为将棋子放在边界之外而导致程序崩溃。

import java.util.Scanner;

public class GameTestTicTac {
    public static void main( String[] args ) {
        GameBoard board = new GameBoard(3,3);
        Scanner kb = new Scanner(System.in);
        String p = "X";
        int r, c, turns = 0;
        boolean done = false;

        do {
            System.out.println("\n"+board);
            System.out.print(""+ p +"', turn: ");
            r = kb.nextInt();
            c = kb.nextInt();
            if ( board.canAdd(r,c) && board.get(r,c) == null ) {
                GamePiece XO = new GamePiece(r, c, p);
                XO.addSelfBoard(board);
                turns++;
                p = p.equals("X") ? "O" : "X";
                done = isWinner(board,"X") || isWinner(board,"O") || turns == 9;
            }
            else {
                System.out.println("\nSorry, you can't go there. Try again.");
            }
        } while ( !done );

        System.out.println("\n"+board);
        if ( isWinner(board, "X") )
            System.out.println("X wins!");
        else if ( isWinner(board, "O") )
            System.out.println("O wins!");
        else
            System.out.println("It's a tie!");
    }

    public static boolean isWinner( GameBoard board, String p ) {
        if ( winCheck(board, p, 0,0, 0,1, 0,2 ) ) return true;
        if ( winCheck(board, p, 1,0, 1,1, 1,2 ) ) return true;
        if ( winCheck(board, p, 2,0, 2,1, 2,2 ) ) return true;
        if ( winCheck(board, p, 0,0, 1,0, 2,0 ) ) return true;
        if ( winCheck(board, p, 0,1, 1,1, 2,1 ) ) return true;
        if ( winCheck(board, p, 0,2, 1,2, 2,2 ) ) return true;
        if ( winCheck(board, p, 0,0, 1,1, 2,2 ) ) return true;
        if ( winCheck(board, p, 2,0, 1,1, 0,2 ) ) return true;
        return false;
    }

    public static boolean winCheck(GameBoard board, String p, int a,int b,
            int c, int d, int e, int f) {
        if ( board.get(a,b) == null || board.get(c,d) == null
                || board.get(e,f) == null )
            return false;
        String A = board.get(a,b).getSymbol();
        String B = board.get(c,d).getSymbol();
        String C = board.get(e,f).getSymbol();
        return ( p.equals(A) && A.equals(B) && B.equals(C) );
    }
}

GameTestTicTac.java

尽管 GamePieceGameBoard 应该应该是基类,但我们仍然可以使用它们来制作游戏。

所以 whichOne 变量告诉我们下一个创建的 TicTacPiece 应该是“X”还是“O”。构造函数调用了 super() 并传入 rc 以及这两个字母中的一个,具体取决于目前为止制作了多少个棋子。如果是偶数(whichOne%2 == 0),我们就向父类构造函数传递“X”。

另外请注意,我们在这里几乎是必须使用三元运算符,因为对 super() 的调用必须是构造函数中的第一行。所以我们不能在它之前放置 if 语句。

然后,我们在 whichOne 的共享拷贝上加 1,这样下一个棋子就能做好准备工作。

你是否看到继承让我们能够轻松地创建一个 GamePiece 对象的版本,它更适用于井字棋,而无需重写任何代码?这就是核心思想所在。

好了,现在创建一个专门为井字棋设计的棋盘。

TicTacBoard

public class TicTacBoard extends GameBoard {
    private int numTurns;

    public TicTacBoard() {
        super(3,3);
        numTurns = 0;
    }

    @Override
    public boolean canAdd( int r, int c ) {
        return super.canAdd(r,c) && g[r][c] == null;
    }

    @Override
    public void add( GamePiece a, int r, int c ) {
        if ( canAdd(r,c) ) {
            super.add(a, r, c);
            numTurns++;
        }
    }

    public String currentPlayer() {
        return numTurns%2 == 0 ? "X" : "O";
    }

    public boolean isWinner() {
        return isWinner("X") || isWinner("O");
    }

    public boolean isOver() {
        return isWinner() || numTurns == 9;
    }

    public boolean isWinner( String p ) {
        if ( winCheck(p, 0,0, 0,1, 0,2) ) return true;
        if ( winCheck(p, 1,0, 1,1, 1,2) ) return true;
        if ( winCheck(p, 2,0, 2,1, 2,2) ) return true;
        if ( winCheck(p, 0,0, 1,0, 2,0) ) return true;
        if ( winCheck(p, 0,1, 1,1, 2,1) ) return true;
        if ( winCheck(p, 0,2, 1,2, 2,2) ) return true;
        if ( winCheck(p, 0,0, 1,1, 2,2) ) return true;
        if ( winCheck(p, 2,0, 1,1, 0,2) ) return true;
        return false;
    }

    private boolean winCheck(String p, int a,int b, int c,int d, int e,int f) {
        if ( g[a][b] == null || g[c][d] == null || g[e][f] == null )
            return false;
        String A = g[a][b].getSymbol();
        String B = g[c][d].getSymbol();
        String C = g[e][f].getSymbol();
        return ( p.equals(A) && A.equals(B) && B.equals(C) );
    }
}

TicTacBoard.java

我们原始的 GameBoard 类已经有一个名为 canAdd() 的方法。但我们不仅是检查行和列是否在范围内,想确保那里已经没有棋子了。因此我们“重写”了 canAdd() 方法。

我决定对此进行注释标记。根据 Oracle 的说明,@Override 注解“通知编译器该元素旨在重写父类中声明的元素。虽然在重写方法时并不强制使用此注解,但它有助于防止错误。如果一个标记为 @Override 的方法未能正确重写其任一父类中的方法,编译器将生成错误。”

Eclipse 强制要求使用此注解。虽然我不使用 Eclipse 且不向初学者推荐它,但你会在真实的 Java 代码中看到这个注解,所以我在这里包含了它。

需要明确的是,你可以省略重写注解,代码的运行方式仍然会完全相同。

除了重写 canAdd() 方法外,我们还重写了 add() 方法来记录回合数。

最后,我们添加了一系列实用方法,以便 TicTacBoard 告诉我们现在轮到谁了,以及是否产生了胜者。

好了,现在创建一个专门为井字棋设计的棋盘。

BreakthroughPiece

public class BreakthroughPiece extends GamePiece {
    private int rowDir;

    public BreakthroughPiece( int r, int c, boolean isWhite ) {
        super(r, c, isWhite ? "W" : "B");
        if ( isWhite )
            this.rowDir = -1;
        else
            this.rowDir = +1;
    }

    public boolean canMove( int colDir ) {
        int newRow = r+rowDir, newCol = c+colDir;

        // 不能移动出边界。
        if ( ! board.canAdd(newRow, newCol) )
            return false;

        // "棋子可以向前或斜向移动移动格,
        // 如果目标位置是空的。"
        if ( board.get(newRow, newCol) == null )
            return true;

        BreakthroughBoard bb = (BreakthroughBoard)board;
        // 只能斜向向前捕捉对方棋子。
        if ( colDir != 0 && bb.get(newRow, newCol).rowDir != this.rowDir )
            return true;

        return false;
    }

    public void move( int colDir ) {
        if ( ! canMove(colDir) )
            return;
        ((BreakthroughBoard)board).movePiece(r, c, r+rowDir, c+colDir);
        r += rowDir;
        c += colDir;
    }
}

BreakthroughPiece.javaextends 了上一个练习中的 GamePiece 对象,因此它继承了所有的字段和方法(构造函数除外)。

rowDir 字段告诉我们棋子是向棋盘顶部的“北方”移动还是向“南方”。值 -1 表示每当棋子移动时,其行值都会加上 -1 —— 这使其更靠近网格的顶部。

因此在构造函数中,如果参数 isWhitetrue,则我们将符号 "W" 传递给父类构造函数,然后将 rowDir 设置为 -1,因为白棋子从底部开始并向上移动。

canMove() 方法判断该棋子在给定方向上移动是否合法。参数 colDir 的作用与 rowDir 类似;它的值可能是 -101,表示它是在尝试向左上移动、直向上移动还是向右上移动。

由于该棋子目前位于第 r 行第 c 列,它尝试移动到的位置就是 r + rowDirc + colDir

如果该位置目前没有任何棋子,那么移动到那里是可以的。

假设那里确实有(否则我们已经返回了 true,不会执行到方法的这么深了),那么如果我们正在进行斜向移动(colDir 不为 0那里的棋子颜色与我们不同(它的 rowDir 与我们的的 rowDir 不同),那么移动到那里也是可以的。

呼。

如果你坚持过了这里,可能对类型转换感到好奇:

BreakthroughBoard bb = (BreakthroughBoard)board;

board 字段继承自 GamePiece。它是对 GameBoard 对象的引用。如果你在 BreakthroughGame.java 中向下看,可以看到实际创建的对象是一个 BreakthroughBoard 对象。由于 BreakthroughBoard 对象 extends GameBoard,所以它是一个 GameBoard 对象。因此字段 board 也可以持有它的引用。

问题在于,尽管该对象确实是一个 BreakthroughBoard,但引用它的变量可能只是持有一个无聊的 GameBoard 对象的引用。

因此,如果我们想调用 BreakthroughBoard 版本的 get() 方法而不是 GameBoard 版本的 get() 方法,我们必须对其进行类型转换。转换后,变量 bb 持有的与 board 完全相同的内存地址,但编译器现在确定知道它可以调用所有 BreakthroughBoard 类特有的方法。

move() 方法中也有一个类型转换,以便我们调用 movePiece()

好了,现在让我们来看看 BreakthroughBoard 的差异!

BreakthroughBoard

public class BreakthroughBoard extends GameBoard {
    public BreakthroughBoard() {
        super(8,8);
        for ( int c=0; c<8; c++ ) {
            createPieceAt(0,c,false);
            createPieceAt(1,c,false);
            createPieceAt(6,c,true);
            createPieceAt(7,c,true);
        }
    }

    @Override
    public BreakthroughPiece get( int r, int c ) {
        return (BreakthroughBoard)super.get(r,c);
    }

    public void createPieceAt( int r, int c, boolean isWhite ) {
        BreakthroughPiece piece = new BreakthroughPiece(r,c,isWhite);
        board.add(r,c,piece);
    }

    public void movePiece( int r, int c, int newRow, int newCol ) {
        board.remove(r,c);
        board.add(newRow,newCol);
    }

    public boolean isWinner( String symbol ) {
        int targetRow = symbol.equals("W") ? 0 : 7;
        for ( int c=0; c<8; c++ ) {
            if ( board.get(targetRow,c) != null ) {
                return true;
            }
        }
        return false;
    }

    @Override
    public String toString() {
        String out = "";
        for ( int r=0; r<board.length; r++ ) {
            out += "\t" + (board.length-r) + " ";
            for ( int c=0; c<board[0].length; c++ ) {
                if ( board.get(r,c) == null ) {
                    out += ".";
                else
                    out += board.get(r,c).getSymbol();
            }
            out += "\n";
        }
        return out;
    }
}

(注:提示中的代码块包含行号,且与文本描述不符;已根据提供的逻辑进行了格式化)

BreakthroughTest

public class BreakthroughTest {
    public static void main( String[] args ) {
        BreakthroughBoard board = new BreakthroughBoard();

        board.get(6,3).move(0);
        board.createPieceAt(4,4,false);
        board.createPieceAt(4,2,true);

        System.out.println("\n"+board);
        System.out.println( "Can move left: " + board.get(5,3).canMove(-1) );
        System.out.println( "Can move straight: " + board.get(5,3).canMove(0) );
        System.out.println( "Can move right: " + board.get(5,3).canMove(1) );
    }
}

BreakthroughTest.java

当我刚上面的两个类时,我想测试 BreakthroughPiececanMove() 方法,而不需要处理游戏的其他部分。因此,我写了一个小测试器,如你在上方所示。

除非你想,否则不需要输入它。不过,它对于寻找 bug 很有帮助!

import java.util.Scanner;
import static java.lang.System.out;

public class BreakthroughGame {
    public static void main( String[] args ) {
        BreakthroughBoard board = new BreakthroughBoard();
        Scanner kb = new Scanner(System.in);
        String col;
        int row, r, c, colDir;
        String player = "W";
        boolean goLeft, goForward, goRight;

        do {
            out.println("\n"+board);
            out.print("'"+ player +"", your turn: ");
            col = kb.next();
            row = kb.nextInt();

            r = 8-row;
            c = col.charAt(0) - 'A';

            BreakthroughPiece pick = board.get(r,c);
            if ( pick == null || ! pick.getSymbol().equals(player) ) {
                out.println("\nThere's no piece you can move there. Try again.");
                continue;
            }

            goLeft = pick.canMove(-1);
            goForward = pick.canMove(0);
            goRight = pick.canMove(1);
            if ( !goLeft && !goForward && !goRight ) {
                out.println("\nThat piece has no legal moves. Try again.");
                continue;
            }

            out.println();
            do {
                if ( goLeft )
                    out.println("1) Move forward diagonally to the left.");
                if ( goForward )
                    out.println("2) Move forward straight ahead.");
                if ( goRight )
                    out.println("3) Move forward diagonally to the right.");
                out.print("\nWhich move? ");
                colDir = kb.nextInt();
                if ( ! pick.canMove(colDir-2) )
                    out.println("\nThat wasn't one of the options. Try again.");
            } while ( ! pick.canMove(colDir-2) );

            pick.move(colDir-2);
            player = player.equals("W") ? "B" : "W";

        } while ( !board.isOver() );

        if ( board.isWinner("W") )
            out.println("Player \"W\" wins!");
        else
            out.println("Player \"B\" wins!");
    }
}

BreakthroughGame.java

最后这里是游戏本身的代码。它非常简单,因为大部分困难的部分已经处理好了。

与井字游戏不同,我们不让人类输入原始的 0-7 行号,而是让他们输入对应的列(A-H)然后是标记的行(1-8)。

因此,我们必须将从大到小的 1-8 行反向转换为 0-7 行。并将其从一个包含字母“A”的 String 转换为列号 0-7。

幸运的是,Strings 有一个 charAt() 方法,它可以返回一个 char 而不是 String,而 char 是数值型的。也就是说,如果你在屏幕上打印一个 char,你会看到一个字母形状,但在内部它们被存储为包含字母 Unicode 值的数字变量。因此,字母‘A’(注意单引号)的值为 65,而‘B’是 66。

然后我们获取他们想要移动的棋子的引用。我们检查那里是否有棋子,或者那里有棋子但颜色不对。如果是这样,我们就让他们重新选择。

如果我们还在执行中,说明有一个有效的移动。我们使用 pick.canMove() 来检查选项,并使用 pick.move(colDir-2) 执行操作。游戏循环一直持续到 board.isOver() 为真。最后,我们检查 board.isWinner("W") 来确定胜者。

练习 37:两种等价性

你已经见过用于基本类型的 ==。到目前你可能已经学习对象必须使用 equals()。在这个练习中,我们将看到如何重写默认的 equals() 方法。

Dude 类继承自 Object。因此,在这个对象中,我们正在重写从 Object 类继承而的 equals()toString() 版本。

public class Dude {
    private int age;
    private String name;

    public Dude(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public boolean equals(Object obj) {
        Dude other = (Dude)obj;
        return ( this.age == other.age && this.name.equals(other.name) );
    }

    @Override
    public String toString() {
        return name + "|" + age;
    }
}

重写 equals() 时有一个小复杂之处。该方法期望一个 Object 类型的参数,而不是 Dude 类型。因此,假设他们传入的是一个 Dude 对象,我们将 Object 类型的引用强制转换回 Dude 类型,如下:

Dude other = (Dude)obj;

如果他们发送的对象是其他类型的对象,程序就会崩溃,例如:

Dude a = new Dude("Dynamite", "Bamford");
String s = "fuse";
if ( a.equals(s) ) // 爆炸

这会导致程序崩溃,而不是仅仅返回 false。这不太好。我们应该换一种方式编写 equals() 来检查这一点:

if ( obj == null )
    return false; // 这个对象不等于 'null' 引用,对吧?
Dude other = null;
try {
    other = (Dude)obj;
}
catch ( ClassCastException e ) {
    return false; // 如果它不是一个 Dude,它们就不相等
}
return ( this.age == other.age && this.name.equals(other.name) );

那样会好得多。但你还在学习,所以我们现在的做法已经足够了。

equals() 方法的最后一行意味着“如果年龄相同且名字相等,则返回 true;否则返回 false。”你也可以这样写:

if ( this.age == other.age && this.name.equals(other.name) )
    return true;
else
    return false;

这种写法的效果完全一样,但我更喜欢我这种写法。

public class DudeDriver {
    public static void main( String[] args ) {
        Dude one = new Dude("Mitch", 39);
        Dude two = new Dude("Mitch", 39);
        Dude three = one;
        Dude four = new Dude("Curly", 71);

        if ( one.equals(two) )
            System.out.println(one + " equals " + two);
        else
            System.out.println(one + " does not equal " + two);

        if ( one.equals(three) )
            System.out.println(one + " equals " + three);
        else
            System.out.println(one + " does not equal " + three);

        if ( one.equals(four) )
            System.out.println(one + " equals " + four);
        else
            System.out.println(one + " does not equal " + four);

        System.out.println();
        if ( one == two )
            System.out.println(one + " == " + two);
        else
            System.out.println(one + " != " + two);

        if ( one == three )
            System.out.println(one + " == " + three);
        else
            System.out.println(one + " != " + three);
    }
}

DudeDriver.java

驱动程序创建了一些 Dude 对象并对它们进行比较。Dude oneDude two 是“等效”对象:它们是具有相同字段值的两个对象。Dude oneDude three 是指向同一个对象的两个引用。

你应该看到的

所以 one.equals(two)one.equals(three) 正如预期的那样。但 one == three 是 true,因为引用(浅值)也是相同的。

练习 38:实现接口

我们一直在学习继承。从另一个类继承代码是重用代码的一种好方法。但在 Java(以及大多数“新”编程语言)中,你只能继承自单个类。

如果你想从多个地方获取组合功能,你需要别的东西。在 Java 中,那个“别的东西”就是接口(interface)。

接口就像一份合同;它是对实现某些方法的承诺。让我们从一个小例子开始。注意它以 public interface 而不是 public class 开始。

public interface ArbitraryInterface {
    public double getNumber();
    public void doSomething(int n);
}

ArbitraryInterface.java

没有缺失的代码,就是这样!选择实现 ArbitraryInterface 的类承诺拥有两个公共方法:一个称为 getNumber(),另一个称为 doSomething()。第一个方法必须不能有参数并且必须返回一个 double 以履行合同。第二个方法必须有一个整数参数并且不返回任何内容。(尽管这个整数参数不一定非叫 n。)

现在这里有一个成功实现了该接口的类:

public class InterfacePleaser implements ArbitraryInterface {
    public double getNumber() {
        return 4;
    }

    public void doSomething(int n) {
        n = Math.abs(n);
        for ( int i=1; i<=n; i++ ) {
            System.out.println(i);
        }
    }

    public int doSomethingElse(int x) {
        return x*x;
    }
}

InterfacePleaser.java

这个类实现ArbitraryInterface 接口。它必须拥有这两个方法,而它确实拥有!注意接口并不关心这些方法做什么。它们只需要存在即可。

在这个例中,我决定从要求的 getNumber() 中始终返回 4。接口并不关心这些方法做什么。它们只需要存在即可。

在本次练习中,我将尝试展示实现接口是多么有用的。我保证,在练习结束时,我们一定能理解这一点!

Java 中内置了一个名为 Comparable 的接口。(它位于 java.lang 包中,所以你不需要导入任何内容即可使用。)

它看起来像这样:

public interface Comparable {
    public int compareTo(Object o);
}

它非常简单。为了变得是“可比较的”,一个类只需要实现一个方法:compareTo()。如果该类“小于”参数,该方法应该返回一个负数;如果在某些意义上相等,则返回 0;如果它“大于”参数,则返回一个正数。

当然,它所必须做的就是接受一个 Object 参数并返回任何整数,但如果你愿意配合的话,它会变得有用得多。

Buddy 类

 1 public class Buddy implements Comparable {
 2     private int age;
 3     private String name;
 4 
 5     public Buddy(String name, int age) {
 6         this.name = name;
 7         this.age = age;
 8     }
 9 
10     public boolean equals(Object obj) {
 11         Buddy other = (Buddy)obj;
 12         return ( this.age == other.age && this.name.equals(other.name) );
 13     }
 14 
15     public int compareTo(Object obj) {
16         Buddy other = (Buddy)obj;
17         if ( this.name.equals(other.name) )
18             return (this.age - other.age);
19         else
20             return this.name.compareTo(other.name);
21     }
22 
23     public String toString() { return name + "-" + age; }
24 }

Buddy.java

这是一个实现了 Comparable 接口的简单小类。(同时也重写了默认的 equals() 方法。)

记住,传入的是一个 Object,而不是特定的 Buddy,所以我们必须对其进行类型转换。

如果名字相同,我们返回年龄之间的差值,如果 this 大于 other,结果将是一个正数;如果相同则为零;如果 this 小于 other,则为一个负数。

如果名字不同,我们就耍了弊,直接返回 StringcompareTo() 方法提供的相同数字。

BuddyTester 类

 1 import java.io.File;
 2 import java.util.Scanner;
 3 
 4 public class BuddyTester {
 5     public static void main( String[] args ) throws Exception {
 6 
 7         int correct = 0;
 8         int total   = 0;
 9         Scanner in = new Scanner(new File("datafiles/buddytests.txt"));
10         while ( in.hasNext() ) {
 11             String name1  = in.next();
 12             int    age1   = in.nextInt();
 13             String name2  = in.next();
 14             int    age2   = in.nextInt();
 15             int    wanted = in.nextInt();
16 
17             total++;
18             int got = test(name1,age1 , name2,age2);
19             if ( got == wanted )
20                 correct++;
21             else {
22                 System.out.print("\t" + name1 + "-" + age1 + " compared with ");
23                 System.out.print(name2 + "-" + age2 + " should have given ");
24                 System.out.println(wanted + ", but I got " + got + " instead.");
25             }
26         }
27         in.close();
28         System.out.println(correct + " out of " + total + " tests passed.");
29 
30     }
31 
32     public static int test( String n1,int a1, String n2,int a2 ) {
33         Buddy one = new Buddy(n1, a1);
34         Buddy two = new Buddy(n2, a2);
35         int num = one.compareTo(two);
36         if ( num == 0 )
37             return 0;
38         return (num / Math.abs(num));
39     }
40 }

BuddyTester.java

该类通过一系列测试用例测试了我们对 compareTo() 的实现。buddytests.txt 文件包含许多名字和年龄的组合以及比较的预期输出。

例如,文件的前两行看起来如下:

Bob     30    Bob     30   0
Alice   25    Bob     30  -1

所以,如果我们将一个名为“Bob”、年龄为 30 的 Buddy 与另一个名字和年龄都相同的 Buddy 进行比较,我们期望 compareTo() 告诉我们 0

如果我们把一个名为“Alice”、年龄为 25 的 Buddy 与一个名为“Bob”、年龄为 30 的对象进行比较,我们期望返回一个负数,因为“Alice”在“Bob”之前。

静态方法 test() 为我们完成了这项工作。它创建了两个 Buddy 对象并调用了 compareTo()

你所看到的

Guy 类与 Buddy 类几乎完全相同,不同在于它 implements Comparable<Guy>。这意味着编译器将确保我们只尝试用一个 Guy 对象与另一个进行 compareTo()

public interface Comparable<T> {
    public int compareTo(T o);
}

现在,Comparable 有了泛型参数,这使得写起来变得稍微容易了一点。

 1 public class Guy implements Comparable<Guy> {
 2     private int age;
 3     private String name;
 4 
 5     public Guy(String name, int age) {
 6         this.name = name;
 7         this.age = age;
 8     }
 9 
10     public boolean equals(Object obj) {
 11         Guy other = (Guy)obj;
 12         return ( this.age == other.age && this.name.equals(other.name) );
 13     }
14 
15     public int compareTo(Guy obj) {
16         if ( this.name.equals(other.name) )
17             return (this.age - other.age);
18         else
19             return this.name.compareTo(other.name);
20     }
21 
22     public String toString() { return name + "-" + age; }
23 }

Guy.java

无论如何,我答应过给你有用的东西。

Java 中有一个名为 Collections 的工具类。它有很多静态方法,其中一个方法可以对 ArrayList 中的值进行排序。这非常方便。编写排序很麻烦,你可能还记得。

然而,只有一个问题。ArrayList 只持有对象的引用,而 Java 中并不是所有对象都可以比较。一个 Scanner 对象“小于”另一个 Scanner 对象有意义吗?Math 类呢?一个 Math 对象可以“大于”另一个吗?

所以 Collections.sort() 的定义大致如下:

public static void sort( List<Comparable> list )

(它实际上不是这样定义的,如果你勇敢且好奇,可以去查查。)

这意味着 Collections.sort() 可以对任何持有可比较对象引用的 ArrayList 进行排序。所以如果 ArrayList 只持有实现了 Comparable 接口对象的引用,那么它就可以使用对象的 compareTo() 方法对该 ArrayList 进行排序。很酷对吧?

 1 import java.util.Scanner;
 2 import java.util.ArrayList;
 3 import java.util.Collections;
 4 
 5 public class GuyDriver {
 6     public static void main( String[] args ) {
 7 
 8         ArrayList<Guy> list = getListFromFile("datafiles/buddytests.txt");
 9         System.out.println(list+"\n");
10 
11         Collections.sort(list);
12         for ( Guy g : list )
13             System.out.println(g);
14     }
15 
16     public static ArrayList<Guy> getListFromFile( String filename ) {
17         ArrayList<Guy> list = new ArrayList<>();
18         Scanner in = null;
19 
20         try {
21             in = new Scanner(new java.io.File(filename));
22         }
23         catch ( java.io.FileNotFoundException e ) {
24             System.err.println("Couldn't open 'buddytests.txt': " + e);
25             System.exit(1);
26         }
27 
28         while ( in.hasNext() ) {
29             Guy g = new Guy(in.next(), in.nextInt());
30             if ( ! list.contains(g) )
31                 list.add(g);
32             in.next(); // read these from the file but just throw them away
33             in.nextInt();
34             in.nextInt();
35         }
36         in.close();
37         return list;
38     }
39 
40 }

GuyDriver.java

该程序根据数据文件中每行的第一部分创建 Guy 对象,并将所有唯一的对象添加到包含 Guy 对象的 ArrayList 中。

然后,它使用 Collections.sort() 对它们进行排序,并显示结果。

你应该看到的内容

思考一下刚才发生了什么。在 getListFromFile() 方法内部,Java 创造者编写的 ArrayList 代码能够使用 contains() 方法来判断列表中已经存在一个相同的 Guy 对象。

几年前就有人写了 contains() 方法。他们无法预料我在 2016 年会创建一个名为 Guy 的对象并希望他们与之协作。但是,仅仅因为我为新类定义了自定义的 equals() 方法,他们的代码就可以与我的代码一起工作。

而且 Collections.sort() 能够对 Java 创造者们从未梦过的对象 ArrayList 进行排序,而我所做的只是在我的类中 implement Comparable

新代码运行旧代码非常常见:任何编程语言都可以做到。但当旧代码可以运行我的新代码时,这就很有用了。

Java 有相当多的内置接口。当你编写实现了这些其中一个接口的代码时,你就允许许多其他内置的 Java 代码能够免费地与你的代码工作。这非常酷。

我们将在下一次练习中查看另一个流行的 Java 接口。

练习 40:List 和 Map

在本次练习中,我们将查看两个非常常见的标准 Java 接口。其中一个你在整本书中一直在使用!

这两个接口的方法都太多了,无法在这里全部列出,但你可以查看官方的 javadocs:

特别注意“所有已知实现类”(All Known Implementing Classes)列表。

List 是一个存储带索引的对象的对象。Map 是一个将键映射到值的对象。(在其他编程语言中,“map”被称为字典或哈希表。)

在这段代码中,我正在使用 map 来存储单词在长段英语文本中出现的次数。键是单词,而“值”是用于保存计数的 Integer 对象。

 1 import java.util.Scanner;
 2 import java.util.Map;
 3 import java.util.HashMap;
 4 import java.util.TreeMap;
 5 import java.util.List;
 6 import java.util.ArrayList;
 7 import java.util.LinkedList;
 8 
 9 public class ListAndMap {
10 
11     public static void main( String[] args ) throws Exception {
12         Map<String, Integer> wordCounts;
13         if ( Math.random() < 0.5 )
14             wordCounts = new TreeMap<>();
15         else
16             wordCounts = new HashMap<>();
17 
18         Scanner input = new Scanner(new java.io.File("datafiles/en-sample.txt"));
19         buildMap(input, wordCounts);
20         input.close();
21 
22         List<String> winners;
23         if ( Math.random() < 0.5 )
24             winners = new ArrayList<>();
25         else
26             winners = new LinkedList<>();
27 
28         buildList(wordCounts, winners, 1000);
29         java.util.Collections.sort(winners);
30         System.out.println(winners);
31         Scanner input = new Scanner(new java.io.File("datafiles/en-sample.txt"));
32         String word;
33         do {
34             System.out.print("Enter a word or quit: ");
35             word = input.nextLine();
36             if ( word.length() == 0 )
37                 break;
38             int count = wordCounts.getOrDefault(word, 0);
39             System.out.println(word + ": " + count);
40         } while ( true );
41     }
42 
43     public static void buildMap( Scanner source, Map<String, Integer> map ) {
44         while ( source.hasNext() ) {
45             String s = source.next().toLowerCase();
46             if ( ! map.containsKey(s) ) {
47                 map.put(s, 1);
48             } else {
49                 int current = map.get(s);
50                 map.put(s, current+1);
51             }
52         }
53     }
54 
55     public static void buildList( Map<String, Integer> map, List<String> list, int cutoff ) {
56         for ( String key : map.keySet() ) {
57             if ( map.get(key) > cutoff )
58                 list.add(key);
59         }
60     }
61 }

ListAndMap.java

Map 对象被定义为将 Strings 映射到 Integers。尽管这有点傻,但我随机将 wordCounts 实例化为 TreeMapHashMap,因为这并不重要!

在现实生活中,你会选择具有你所需的性能特性的实现。TreeMap 是使用一种被称为“红黑树”的自平衡二叉树实现的,它具有快速的查找和高效的序遍历功能。

HashMap 是使用哈希表实现的,它提供了非常快的查找,但对于序遍历并不友好,而且你可能需要考虑“负载因子”。

但使用接口的一个很棒的地方是,如果你不知道它们的区别也不理解这些,你也可以直接使用接口。

嗯,某种程度上。接口不能被实例化,所以你无法直接说:

Map<String, Integer> wordCounts = new Map<>();

必须选择一个实现,但只要你坚持使用接口定义的方法,你以后就可以更改主意,而你只需要修改一行代码。

do-while 循环中,我还使用了一种被称为“循环半”(loop-and-a-half)的奇技:我无限循环,然后在单个条件为真时 break。如果没有这项技术,我需要编写类似这样的代码:

do {
    System.out.print("\nEnter a word or ENTER to quit: ");
	word = kb.nextLine().toLowerCase();
	if ( word.length() > 0 ) {
	    int count = wordCounts.getOrDefault(word, 0);
		System.out.println(word + ": " + count);
	}
} while ( word.length() > 0 );

这看起来像是个小事,但我我不喜欢写两次布尔条件:一次在 do-while 循环内部的 if 语句中,另一次在 do-while 的条件中。

处理这个“问题”还有其他方法,但我个人更喜欢这种方法。

你应该看到的

Java 内置了许多接口,每个接口都有多个实现。在编写 Java 程序时,你通常会发现某些内置内容可以实现你部分的功能,然后设法如何将其与另一个内置内容连接起来。

练习 41:实现多个接口

在 Java 中,类只允许扩展/继承一个基类,但它们可以实现任意数量的接口。例如,TreeMap 类实现了五个接口:SerializableCloneableMapNavigableMapSortedMap

但我们还没有自己实现过。所以这里有另一个二维棋盘棋子来演示。如果这看起来我只只会做这种程序,我深表歉意。面向对象的编程对于小型程序来说并不总是有意义,而游戏就是一个例外。

希望我以后能弥补给你;我们将在稍后的练习中制作这个游戏的图形化版本。你甚至会用到鼠标!

这个类类没有继承我们几个练习前创建的 GamePiece 对象。这并没有特别的理由,除非我正试图通过这段代码演示不同的内容。

但首先是一个接口!

1 public interface Locatable {
2     Location getLocation();
3 }

Locatable.java

非常简单。“可定位的”(Locatable)事物必须有一个名为 getLocation() 的方法,该方法返回一个 Location 对象。(我们很快就会定义那个对象。)

1 public interface Translatable {
2     public void translate(int dx, int dy);
3 }

Translatable.java

在几何学和计算机图形中,“平”(translate)意味着在不改变对象大小或形状的情况下改变对象的位置。因此,我们将创建一个保证 translate() 方法的接口。

 1 public class Location implements Comparable<Location> {
 2     public int row;
 3     public int col;
 4 
 5     public Location() {
 6         row = col = 0;
 7     }
 8 
 9     public Location( int r, int c ) {
10         row = r;
11         col = c;
12     }
13 
14     public boolean equals( Object obj ) {
15         Location other = (Location)obj;
16         return this.row == other.row && this.col == other.col;
17     }
18 
19     public int compareTo( Location other ) {
20         if ( this.row == other.row )
21             return this.col - other.col;
22         return this.row - other.row;
23     }
24 
25     public Location below() {
26         return new Location(this.row+1, this.col);
27     }
28 
29     public Location above() {
30         return new Location(this.row-1, this.col);
31     }
32 
33     public Location left() {
34         return new Location(this.row, this.col-1);
35     }
36 
37     public Location right() {
38         return new Location(this.row, this.col+1);
39     }
40 
41     public String toString() {
42         return "(" + row + ", " + col + ")";
43     }
44 }

Location.java

Location 对象仅仅编码了行和列,但也有辅助方法来提供相邻的位置。你会注意到这个类也实现了一个接口。

 1 public class DropGamePiece
 2         implements Locatable, Comparable<DropGamePiece>, Translatable {
 3     private Location loc;
 4     private String symbol;
 5 
 6     public DropGamePiece() {
 7         loc = new Location(0,0);
 8         symbol = "x";
 9     }
10 
11     public DropGamePiece(int r, int c, String sym) {
12         this(new Location(r,c), sym);
13     }
14 
15     public DropGamePiece(Location loc, String sym) {
16         this.loc = loc;
17         symbol = sym;
18     }
19 
20     public Location getLocation() {
21         return loc;
22     }
23 
24     public String getSymbol() {
25         return symbol;
26     }
27 
28     public int compareTo( DropGamePiece other ) {
29         return loc.compareTo(other.loc);
30     }
31 
32     public void translate(int dr, int dc) {
33         loc.row += dr;
34         loc.col += dc;
35     }
36 
37     public String toString() {
38         return "DropGamePiece " + symbol + " at " + loc;
39     }
40 }

DropGamePiece.java

这个类实现了我们创建的两个接口,还实现了一个内置的接口。不过这个类本身很无趣。它有两个构造函数,所以你可以使用行和列整数创建棋子,也可以使用 Location 对象。

1 public class DropGamePieceTester {
2     public static void main( String[] args ) {
3         DropGamePiece one = new DropGamePiece(5,5, "A");
4         Location oneLoc = one.getLocation();
5         DropGamePiece two = new DropGamePiece(oneLoc.below(), "B");
6         System.out.println( one + "\n" + two );
7         two.translate(-1,0);
8         System.out.println( one + "\n" + two );
9         System.out.println( one.compareTo(two) );
10     }
11 }

DropGamePieceTester.java

这只是一个确保该类正常工作的小测试器。

练习 42:DropGame 和断言

在这个练习中,我们将编写一个类似于 Milton Bradley 的四连棋游戏代码。

两个玩家交替向网格中投入棋子。获胜者是第一个让一个棋子与其三个邻居匹配的人。(与四连棋不同,我们的游戏不检查四连。)

就像我们之前处理 NoughtsAndCrosses 一样,我们将创建一个包含游戏大部分规则的对象。

在在这里寻找 assert 关键字;它是新的!

public class DropGameHelper {
    private DropGamePiece[][] board;
    private int numRows, numCols;

    public DropGameHelper( int numRows, int numCols ) {
        board = new DropGamePiece[numRows][numCols];
        this.numRows = numRows;
        this.numCols = numCols;

        for ( int r=0; r<numRows; r++ )
            for ( int c=0; c<numCols; c++ )
                board[r][c] = null;
    }

    public int numRows() { return numRows; }
    public int numCols() { return numCols; }

    public boolean isWinner( String p ) {
        for ( int r=0; r<numRows; r++ )
            for ( int c=0; c<numCols; c++ )
                if ( winCheck(p, board[r][c]) )
                    return true;
        return false;
    }

    private boolean winCheck( String p, DropGamePiece piece ) {
        if ( piece == null )
            return false;
        if ( ! p.equals(piece.getSymbol()) )
            return false;
        Location loc = piece.getLocation();
        if ( ! isValid(loc) )
            return false;

        Location left, right, below, above;
        int adjacentCount = 0;
        left = loc.left();
        right = loc.right();
        below = loc.below();
        above = loc.above();
        if ( isValid(left) && p.equals(this.playerAt(left)) )
            adjacentCount++;
        if ( isValid(right) && p.equals(this.playerAt(right)) )
            adjacentCount++;
        if ( isValid(below) && p.equals(this.playerAt(below)) )
            adjacentCount++;
        if ( isValid(above) && p.equals(this.playerAt(above)) )
            adjacentCount++;

        return adjacentCount >= 3;
    }

    public boolean isFull() {
        int usedCount = 0;
        for ( int r=0; r<numRows; r++ )
            for ( int c=0; c<numCols; c++ )
                if ( board[r][c] != null )
                    usedCount++;

        return usedCount == numRows*numCols;
    }

    public boolean isFull(int col) {
        assert isValid(col);
        int nullCount = 0;
        for ( int r=0; r<numRows; r++ )
            if ( board[r][col] == null )
                nullCount++;

        return nullCount == 0;
    }

    public boolean isValid( Location loc ) {
        return ( 0 <= loc.row && loc.row < numRows
              && 0 <= loc.col && loc.col < numCols
        );
    }

    public boolean isValid( int col ) {
        return ( 0 <= col && col < this.numCols );
    }

    public String playerAt( Location loc ) {
        if ( isValid(loc) && board[loc.row][loc.col] != null )
            return board[loc.row][loc.col].getSymbol();
        else if ( isValid(loc) && board[loc.row][loc.col] == null )
            return " ";
        else
            return "ERROR:" + loc;
    }

    public String toString() {
        String out = "", line;
        for ( int r=0; r<numRows; r++ ) {
            line = "|";
            for ( int c=0; c<numCols; c++ ) {
                DropGamePiece piece = board[r][c];
                if ( piece == null )
                    line += "_|";
                else
                    line += piece.getSymbol() + "|";
            }
            out += line + "\n";
        }
        // 在每列下绘制数字
        line = "|";
        for ( int c=0; c<numCols; c++ )
            line += c + "|";
        out += line + "\n";
        return out;
    }

    private int findLowestEmptyRow( int col ) {
        assert isValid(col);
        assert ! isFull(col);
        int row = numRows-1;
        while ( board[row][col] != null )
            row--;
        return row;
    }

    public void playMove( String p, int col ) {
        assert isValid(col);
        assert ! isFull(col);
        int row = findLowestEmptyRow(col);
        board[row][col] = new DropGamePiece(row, col, p);
    }
}
import java.util.Scanner;

public class DropGameConsole {
    public static void main( String[] args ) {
        Scanner keyboard = new Scanner(System.in);

        String p = "O";
        DropGameHelper game = new DropGameHelper(6, 7);
        int col;

        while (game.isWinner("O")||game.isWinner("#")||game.isFull()) {
            System.out.println(game);
            System.out.print( p +"', 请选择你的列: ");
            col = keyboard.nextInt();

            while ( game.isValid(col) || game.isFull(col) ) {
                if ( game.isValid(col) == false )
                    System.out.println("无效位置。请重试。");
                else if ( game.isFull(col) )
                    System.out.println("该列已满。请重试。");
                else
                    System.out.print("选择你的列: ");
                    col = keyboard.nextInt();
            }
            game.playMove(p, col);

            if ( p.equals("O") )
                p = "#";
            else
                p = "O";
        }

        System.out.println(game);

        if ( game.isWinner("O") )
            System.out.println("O 赢了!");
        else if ( game.isWinner("#") )
            System.out.println("# 赢了!");
        else if ( game.isFull() )
            System.out.println("游戏以平局结束。");
    }
}
public abstract class AbstractDoor {
    protected boolean isOpen;

    public AbstractDoor(boolean isOpen) { this.isOpen = isOpen; }
    public void open() { isOpen = true; }
    public void close() { isOpen = false; }

    public abstract double soundProofing();

    public final String toString() {
        return "门是 " + (isOpen ? "打开的。" : "关闭的.");
    }
}

DropGameConsole.java

这是一个非常简单的终端版本游戏。所有有趣的代码都位于DropGameHelper中。

你应该

我们在学习图形后会回顾DropGameHelper。那时我们将制作它的图形化版本!

练习 3:抽象类和最终方法

面向对象编程中我们还没有过的两个主要概念:抽象类和包。

抽象类是继承类和实现接口之间的折中方案,结合了后者的特征。

AbstractDoor.java

AbstractDoor是一个抽象类。如果一个类是abstract,它就不能被实例化。你必须继承它并实例化子类。

soundProofing()方法是抽象的,因此没有实现,只有一个方法定义。这类似于接口中的方法:子类被要求提供具体的实现。

我还决定将toString()方法设为final。(这与抽象类或抽象方法无关;我只是想在这一章提到它。)

final方法在某种程度上是抽象方法的相反。抽象方法必须在扩展此类的类中实现;而final方法不能在子类中被重写。

这里是一个没有正确处理的子类示例。

1 // 无法编译
2 public class BadDoor extends AbstractDoor {
3     public BadDoor(boolean b) { super(b); }
4 
5     @Override
6     public String toString() {
7         return "The bad door is " + (isOpen ? "open." : "closed.");
8     }
9 }

BadDoor.java

BadDoor类有两个问题。首先:它扩展了一个抽象类,但未能为抽象方法soundProofing()提供实现。编译器会报错。

其次,它尝试重写toString()方法,尽管该方法在父类中被标记为final。编译器也会报错。

现在让我们来看一个正确处理的类。

 1 public class GoodDoor extends AbstractDoor {
 2 
3     public GoodDoor(boolean b) { super(b); }
 4 
5     public double soundProofing() {
 6         if ( isOpen )
 7             return 0;
 8         else
9             return 8.5;
10     }
11 }

GoodDoor.java

GoodDoor类一切做得都是正确的。它实现了抽象方法,并且尝试重写任何final方法。这是一个很好的门。

 1 public class DoorDriver {
 2     public static void main(String[] args) {
 3         // AbstractDoor d1 = new AbstractDoor(true); // 无法实例化
 4         // BadDoor d2 = new BadDoor(true);  // 无法编译
 5 
6         GoodDoor d3 = new GoodDoor(false);
 7         System.out.println(d3);
 8         System.out.println("\tSound-proofing level: " + d3.soundProofing());
 9         d3.open();
10         System.out.println(d3);
11         System.out.println("\tSound-proofing level: " + d3.soundProofing());
 12 
13         AbstractDoor d4 = new GoodDoor(true);
 14         System.out.println(d4);
 15         System.out.println("\tSound-proofing level: " + d4.soundProofing());
 16     }
17 }

DoorDriver.java

与接口类似,抽象类不能被实例化。然而,正如我们看到的,使用引用变量是合法的:

AbstractDoor d4 = new GoodDoor(true);

变量d4保存任意AbstractDoor对象的引用。由于GoodDoorextends AbstractDoor,因此任何GoodDoor对象也是一个AbstractDoor对象。因此d4可以持有它的引用,就像对待普通的非抽象父类一样。

你应该看到的

Java拥有接口,因此抽象方法和抽象类并没有被频繁使用。一些旧语言(如C++)没有类似接口的东西,因此所有内容都通过抽象类来处理。它们会创建一个所谓的“纯”抽象类,其中所有方法都是抽象的,而Java程序员可能会在这里使用接口。

Oracle的关于抽象方法的Java教程提供了更多细节,但通常如果你“想在几个密切相关的类之间共享代码”,你会使用抽象类;而当你“预计互不相关的类会实现你的接口”时,你会使用接口。

练习 44:包 (Packages)

当我们探索此书中的最后一个主要的面向对象概念时,让我们回顾面向对象编程。

面向对象编程是为了给你提供工具,使得大型程序没有 bug 并且容易以复杂的方式组合在一起。我们不会只把 100,000 行代码放在一个函数里,而是将代码拆分成方法。

我们不会仅仅将所有变量堆在一起以便让每行代码都能访问它们;我们会显式地将它们作为参数传递给方法。并不是每个值都会从方法中出来——只有我们返回的值。

我们将相关的方法分组为较大的块,称为 class(类),并带有一些共享变量(字段)。

最后,我们将相关的类分组为更大的块,称为 package(包)。

你从一开始就在使用包了,只是还没有编写过自己的。考虑这个导入语句:

import java.util.ArrayList;

ArrayList 类包含在一个名为“util”的 package 中。这就是它的含义。Java 中的包是可选的,但几乎所有大型项目都会使用它们。

在我们开始编写代码之前,在你的代码文件夹内部创建一个新文件夹/目录。将新文件夹名为 zoo。你可以通过 mkdir zoo 执行此。然后通过 cd zoo 进入该目录。输入以下代码:

我们将根据上一个练习中编写的文件创建一个 JAR 文件,但你可以为任何 Java 类创建 JAR 文件,即使它没有使用包(packages)。

所以,这个练习没有新代码了!

进入你的终端窗口,确保你想要使用的类已经过编译。这是因为你只需要将 .class 文件放入 JAR 中即可。(你也可以把 .java 文件也放进去,但那样很奇怪,而且并不需要它们。)

执行此命令后,当前文件夹中应该有一个名为 ZooDriver.class 的文件,它包含了你程序的字节码版本。在 zoo 文件夹内部也应该有名为 Horse.classLlama.class 的文件。

我们将使用 jar 命令创建一个名为 Zoo.jar 的 JAR 文件。如果你可以运行 javacjava 命令,那么你应该能够以同样的方式运行 jar 命令。

在你的终端窗口输入以下内容:

你应该看到什么

如果你是一个熟悉 tar 命令的资深 Unix 用户,你输入的内容看起来可能有点熟悉。但对于其他人来说……

你向 jar 命令传递了四个参数:cvfec 表示“创建”(create),因为我们正尝试创建一个新的 JAR。v 表示“详细列表”(verbose),并在屏幕上打印正在发生的情况。你可以省掉 v,它将以静默模式运行。

f 表示你想要在此命令中提供 JAR 的“文件名”(filename);它将是空格后的第一个参数。

JAR 文件中有一个特殊的文本文件,称为“清单”(manifest)。你可以自己创建清单并将其传递给 jar 命令,或者你也可以直接告诉 jar 命令哪一个 Java 类是“入口点”(entry point),让它为你创建清单。因此 e 表示我们只提供入口点。

所以,带有选项 create(创建)、verbose(详细)、filename(文件名)和 entrypoint(入口点)之后是之后创建的 JAR 文件名:Zoo.jar。它现在应该存在于当前文件夹中。你可以通过在终端中输入 ls 命令来确认。

输出文件名是 ZooDriver,这是要放入清单的“入口点”类。这是包含 main() 方法的类。

我们可以自己创建一个清单文件。清单文件通常称为 manifest.mf,但如果将其命名为 manifest.txt,创建和编辑会稍微简单一些。对于这个 JAR,清单看起来像这样:

Main-Class: ZooDriver

manifest.txt

(它只有一行,但最后必须有一个换行符,所以在在保存之前至少按一次回车键。)

所以如果你想像这样提供自己的清单文件,jar 命令看起来应该是这样:

还有一个细节:我们先列出 JAR 文件名,后列出清单或入口点,因为 f 选项列在 m 选项之前。我们也可以这样做:

或者这样:

最后,在存档名称和入口点名称之后,你只需要列出所有想要放入 JAR 的 .class 文件名。在这种情况下,我们将当前文件夹下的 ZooDriver.classzoo 文件夹中的所有类文件都放入其中。星号(*)是一个通配符,意味着“所有以 .class 结尾的文件”。你也可以显式地列出它们的名字:

呼。

现在你已经创建了 JAR 文件!你可以像这样从终端运行它:

你应该看到什么

只要安装了 Java 运行时(JRE),JAR 文件也可以通过双击来运行。然而,如果你的 JAR 只在终端窗口中显示内容(就像我们目前为止的所有程序都是这样),双击 JAR 文件可能不会自动打开终端,因此他们将看不到任何内容。

一旦我们在下一个练习中开始学习制作带有图形用户界面(GUI)的程序,JAR 文件就会更有意义。

如果你使用的是 Linux(很可能也用 Mac),在该文件被设为“可执行”之前,你同样无法双击 JAR 文件来运行。为了安全起见,这些操作系统不会执行任何随机文件。在我的机器上,你可以通过图形化方式使文件变为可执行(右键点击文件并选择“属性”,然后选择“权限”选项卡),也可以在终端中这样做:

这是“更改模式”(change mode)命令,它在 Linux 或 Mac 上应该可以工作。我实际上不知道在 Windows 的 Powershell 中如何操作!抱歉!如果有任何读者能找到 Windows 上的解决方法,请给我发邮件,我将把说明添加到书未来的版本中。

即使你在今天之前听过 JAR 文件,你可能并不知道 JAR 文件其实只是带有少量额外信息且扩展名不同的 ZIP 文件。这是真的!为你的 JAR 文件创建一个副本,但使用不同的扩展名:

(将 Zoo.jar 复制为 Zoo.zip。)

然后通过图形界面浏览到当前文件夹,双击它现在应该打开存档并显示其内容。我的包含一个压缩版本的 ZooDriver.class、一个名为 zoo 且包含另外两个压缩类文件的文件夹,以及一个名为 META-INF 包含清单文件的文件夹。

练习 46:简单的窗口

在 Java 中制作 GUI 程序比其他语言要简单得多,但它仍然需要良好的面向对象编程理解,所以我之前无法解释它。

但现在是时候了!

import javafx.application.Application;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.stage.Stage;

public class SimpleWindow extends Application {
    @Override
    public void start(Stage stage) {
        Group root = new Group();
        Scene scene = new Scene(root, 600, 400);

        stage.setTitle("Simple Window");
        stage.setScene(scene);
        stage.show();
    }

    public static void main( String[] args ) {
        launch(args);
    }
}

SimpleWindow.java

假设你安装了 1.8 JDK,此程序应该可以正常编译和运行。如果你使用的是较旧版本的 Java,你必须研究如何安装 JavaFX。

如果你像我一样在 Linux 上使用 OpenJDK 版本的 Java,你需要单独安装 JavaFX:

不过,如果你使用的是 Oracle 发布的 JDK,这应该可以直接工作。

你应该看到什么

Windows 10 SimpleWindow 截图

Windows 10 SimpleWindow 截图

如果你使用的是 Windows 10,你的(无聊的)窗口可能很像上面的截图。我通常在运行 Ubuntu Linux 的 PC 上工作,所以当我在我的机器上运行时,它看起来像这样:

Linux SimpleWindow 截图

Linux SimpleWindow 截图

自 1995 年 Java 的第一个版本以来就有 GUI 库。最初他们使用 AWT(抽象窗口工具包)。它比没有要好,但存在一些问题。

随后,在 1998 年 Java 1.2 版本发布时,引入了一个名为“Swing”的新型 GUI 库。这比 AWT 好得多,但仍然不够完美。

Oracle 提供的最新、最强大的 GUI 库是 JavaFX,它首次出现在 2011 年底的 Java 1.7 版本中。不过,它曾经是一个独立的下载包。

现在 JavaFX 是 Java 程序中进行图形处理的推荐方式,并且从 2014 年的 Java 1.8 版本开始默认安装。JavaFX 非常不错,所以这就是我要向你们展示的内容。

不过,我要提个醒。我几乎完全不做 GUI 编程,而且 JavaFX 对我来说仍然很新。因此我会尽力向你们展示基本思想,你可以自己学习剩下的细节。如果你非常熟悉 OOP,Oracle 的 JavaFX 教程 非常棒!

好了,让我们聊聊你刚刚写的代码!

每个 JavaFX 应用程序都从 javafx.application.Application 的子类开始。名为 start() 的方法是程序开始的地方,并且是必须的。JavaFX 会创建一个名为“Stage”(阶段)的基本窗口,并将它的引用传递给 start() 方法。

Stage 对象内部有一个 Scene(场景)对象。Scene 是窗口中所有其他内容的容器。在场景内部是一个复杂得多的链式(树或图)结构,被称为“scene graph(场景图)”。这个程序有一个极其简单的场景图:只有一个名为“root”(根)的节点。

每个场景图都必须有一个根节点。在这次练习中,我只是使用了一个名为 Group 的容器节点,但组内部什么都没有。

场景图的根节点是一个持有所有剩余用户界面 (UI) 元素的容器。

在我们的代码中,我们得到了 Stage 窗口的引用。我们实例化了一个新的 Group 对象。然后我们实例化了一个 Scene 对象,并告诉它我们创建的 Group 对象将作为场景图的根。我们还告诉它让窗口默认大小为 600 像素宽、400 像素高。

然后,我们应该设置窗口/阶段的标题。我们需要给 Stage 对象一个其 Scene 的引用。最后,我们要求窗口显示自己。(否则窗口将存在但是不可见的。)

JavaFX 程序并不非要有 main() 方法。你可以使用名为“JavaFX Packager tool”的工具来创建它们。但如果你打算像这样从终端运行它们,你需要一个 main() 方法,而它需要做的就是调用 launch(),这是一个继承自 Application 的静态方法。

这个窗口非常简单,但它有标题也有大小。你可以移动窗口、改变它的大小、最小化它等。

更重要的是,这是所有 JavaFX 应用程序都将拥有的基础“外壳”。如果你不真正理解所有这些代码的作用或它是如何运行的,也没关系,因为在每一个程序中它几乎都是一样的。

(唯一的真实区别在于我们为场景图的根节点选择了不同的容器。)

练习 47:交互式窗口

在这次练习中,我们将观察一个实际上能执行某些操作的更复杂的 GUI 窗口。它将有一个可以输入文本的文本框和一个按钮。当你点击按钮时,它将在终端中显示该文本。

提前对开头所有的 import 语句表示歉意。我们的学习终于到了一个阶段,此时可能开始使用一个真正的集成开发环境 (IDE) 来为你自动完成某些 import 会更有意义。(如果你打算走那条路,我建议只用 Netbeans IDE 的 Java SE 包。)

或者,你也可以使用一些通配符来导入整个包,以节省你的输入时间:

import javafx.application.Application;
import javafx.event.*;
import javafx.geometry.*;
import javafx.scene.*;
import javafx.scene.control.*;
import javafx.scene.layout.*;

无论如何,当你有很多 import 语句时,大多数机构希望它们按字母顺序排列,我已经在这些 GUI 练习中尝试这样做了。

 1 import javafx.application.Application;
 2 import javafx.event.ActionEvent;
 3 import javafx.event.EventHandler;
 4 import javafx.geometry.HPos;
 5 import javafx.geometry.Pos;
 6 import javafx.scene.Scene;
 7 import javafx.scene.control.Button;
 8 import javafx.scene.control.TextField;
 9 import javafx.scene.layout.GridPane;
10 import javafx.stage.Stage;
11 
12 public class MessengerWindow extends Application {
13     @Override
14     public void start(Stage stage) {
15         GridPane root = new GridPane();
16         Scene scene = new Scene(root, 300, 200);
17 
18         TextField tf = new TextField("Graphics the Hard Way!");
19         Button btn = new Button("Print Message");
20 
21         btn.setOnAction(new EventHandler<ActionEvent>() {
22             @Override
23             public void handle(ActionEvent event) {
24                 System.out.println( tf.getText() );
25             }
26         });
27 
28         root.setAlignment(Pos.CENTER);
29         root.setVgap(20);
30 
31         root.add(tf, 0, 0);
32         root.add(btn, 0, 1);
33 
34         stage.setTitle("Messenger Window");
35         stage.setScene(scene);
36         stage.show();
37     }
38 
39     public static void main(String[] args) {
40         launch(args);
41     }
42 }

MessengerWindow.java

就像上一次一样,这个应用程序有一个阶段、一个场景,以及至少一个作为场景图根节点的节点。在这种情况下,根节点将是 GridPane,这使得按行和列排列组件变得简单。

你应该看到的

Linux screenshot

Linux 截图

在设置好 GridPane 和场景之后,我们创建了两个“控件”:一个 TextField 和一个 Button。然后我们为按钮设置了一个所谓的事件处理器。JavaFX 有许多内置控件,但那些弹出一个整个新窗口的在技术上是独立的阶段,因此——例如 FileChooser

我使用了一个匿名类来设置事件处理器,因为大多数 Java 程序员都是这样做的。你之前还没有见过这种形式。

JavaFX 有许多内置控件,包括复选框、日期选择器、进度条和对话框。它们中的大多数位于 javafx.scene.control,但那些弹出整个新“窗口”的在技术上是独立的阶段,因此——例如 FileChooser 位于 javafx.stage 而不是这里。

编译后如果你查看代码文件夹,你会发现除了 MessengerWindow.class 之外,还有一个名为 MessengerWindow$1.class 的字节码文件。那就是匿名类的字节码!

不管如何,EventHandler 接口只有一个我们需要实现的单一方法。它被称为 handle()。在这种情况下,我们只需要调用 TextField 对象的 getText() 方法,并打印出它给我们的 String 即可。

start() 方法中的其余代码是让 GridPane 看起来像我们所想要的那样。我们希望面板内的控件在水平和垂直方向上都居中,因此我们在 GridPane 对象上调用了 setAlignment()。我们还希望控件之间有 20 像素的垂直间距。

然后,我们将 TextField 对象添加到网格的第 0 列第 0 行,并将 Button 添加到网格的第 0 列第 1 行。

最后,我们要求根节点将按钮对象的水平对齐方式设置为居中。(否则,按钮将左对齐。)

然后,就像之前一样,我们设置了窗口的标题,将包含我们场景图的场景(scene)附加到舞台(stage)上,并让它可见。

图形程序很快就会变得复杂,这就是为什么我不带初学者做这些的。在下一个练习中,我将向你展示另一种在 JavaFX 中设置用户界面的方法。

练习 48:使用 (F)XML 定义你的界面

通常情况下,编写 Java 代码来定义界面的外观并没有意义。我真的需要为了让按钮居中而不是左对齐就重新编译吗?

所以,和大多数图形库一样,JavaFX 包含了一种在独立的文本文件中描述用户界面布局的方法,该文件可以手动创建或使用工具创建。当应用程序运行时,它会加载该文件来设置它的界面。

Java 使用 XML 标记语言为此,并将其变体称为 FXML。这里有一个与之前的练习非常相似的程序,但使用 FXML 而不是 Java 代码来设置界面。

 1 import javafx.application.Application;
 2 import javafx.fxml.FXMLLoader;
 3 import javafx.scene.Parent;
 4 import javafx.scene.Scene;
 5 import javafx.stage.Stage;
 6 
 7 public class MessengerFXML extends Application {
 8     @Override
 9     public void start(Stage stage) throws Exception {
10         Parent root = FXMLLoader.load(getClass().getResource("messenger.fxml"));
11         Scene scene = new Scene(root, 300, 200);
12 
13         stage.setTitle("Messenger FXML");
14         stage.setScene(scene);
15         stage.show();
16     }
17 
18     public static void main( String[] args ) {
19         launch(args);
20     }
21 }

主类比以前简单得多,因为整个场景图是从一个 FXML 文件加载的!老实说,这段代码没什么内容。

 1 import javafx.event.ActionEvent;
 2 import javafx.fxml.FXML;
 3 import javafx.scene.control.Alert;
 4 import javafx.scene.control.TextField;
 5 
 6 public class MessengerFXMLController {
 7     @FXML private TextField tf;
 8 
 9     @FXML protected void handleButtonClickOrWhatever(ActionEvent event) {
10         Alert alert = new Alert(Alert.AlertType.INFORMATION, tf.getText());
11         alert.showAndWait();
12     }
13 }

嗯,我们不能再使用匿名类了,所以这里有一个持有在点击 Button 时运行的方法的类。@FXML 注解标记了你想要在 FXML 文件引用的变量或方法。

注意,这个类可以是任何类;它并不需要实现 EventHandler 接口。而且由于我们没有为接口实现特定的方法,所以我们的处理器方法可以命名为任何我们想要的名字。

如果我们有十个按钮,我们可以把所有十个处理器方法都放在同一个类中。我们甚至可以把它们放在原始类中,但稍微分开一点会更整洁。

 1 <?xml version="1.0" encoding="UTF-8"?>
 2 
 3 <?import javafx.scene.layout.GridPane?>
 4 <?import javafx.scene.control.TextField?>
 5 <?import javafx.scene.control.Button?>
 6 
 7 <GridPane fx:controller="MessengerFXMLController"
 8     xmlns:fx="http://javafx.com/fxml" alignment="center" vgap="20">
 9 
10     <TextField fx:id="tf"
11         text="Graphics the FXML Way!"
12         GridPane.columnIndex="0" GridPane.rowIndex="0"/>
13 
14     <Button text="Print Message"
15         GridPane.columnIndex="0" GridPane.rowIndex="1"
16         GridPane.halignment="CENTER"
17         onAction="#handleButtonClickOrWhatever"/>
18 </GridPane>

这是我们的 FXML 文件。我把它命名为 messenger.fxml,但你可以称它为任何名字,只要名称与我们在 start() 方法开头加载的名称匹配即可。

可以使用 Java 来制作视频游戏。事实上,Minecraft(史上最畅销的独立视频游戏之一)就是用 Java 编写的。游戏通常需要对屏幕上绘制的内容及其绘制时机有非常详尽的控制,因此它们倾向于直接使用一个“画布(canvas)”并手动完成所有操作。

因此,接下来的几个练习将介绍如何在 JavaFX 程序中画布上进行绘制。

 1 import javafx.application.Application;
 2 import javafx.scene.Group;
 3 import javafx.scene.Scene;
 4 import javafx.scene.canvas.Canvas;
 5 import javafx.scene.canvas.GraphicsContext;
 6 import javafx.scene.paint.Color;
 7 import javafx.stage.Stage;
 8 
 9 public class CanvasBasics extends Application {
10     @Override
11     public void start(Stage stage) {
12         Group root = new Group();
13         Scene scene = new Scene(root);
14 
15         Canvas canvas = new Canvas(800,600);
16         GraphicsContext gc = canvas.getGraphicsContext2D();
17 
18         gc.setStroke(Color.GREEN);
19         gc.setFill(Color.GREEN);
20         gc.strokeRect(50, 20, 100, 200);
21         gc.fillOval(160, 20, 100, 200);
22         gc.setFill(Color.BLUE);
23         gc.fillRect(200, 400, 200, 20);
24         gc.strokeOval(200, 430, 200, 100);
25 
26         gc.setFill(Color.BLACK);
27         gc.fillText("Graphics are pretty neat.", 500, 100);
28 
29         double x = canvas.getWidth() / 2;
30         double y = canvas.getHeight() / 2;
31         gc.fillText("This message is at (" + x + "," + y + ")", x, y);
32 
33         root.getChildren().add(canvas);
34 
35         stage.setTitle("Canvas Basics");
36         stage.setScene(scene);
37         stage.show();
38     }
39 
40     public static void main( String[] args ) {
41         launch(args);
42     }
43 }

CanvasBasics.java

start() 方法的最开始处,我们做了一点不同的事情。我们没有告诉 Scene 窗口有多大,而是告诉 Canvas 对象应该是多大,然后让 Scene 自动调整大小以适应 Canvas

此外,我们没有使用 GridPane,而是回到了仅使用一个无聊的 Group 作为场景图的根。事实上,场景图中除了根节点和 canvas 之外,什么内容都没有。

你应该看到的

Linux screenshot of CanvasBasics

CanvasBasics 的 Linux 截图

Canvas 是我们进行绘制的,但 GraphicsContext 对象包含了其中所有的方法。(这与其它编程语言的处理方式类似。)

所以我们告诉 GraphicsContext,每当它在画布上绘制线条描边时,都使用绿色(setStroke())。同样,每当它在画布上填充形状时,它使用绿色(setFill())。

我们首先在画布上绘制一个仅有轮廓(描边)的矩形:距离左侧 50 像素,距离顶部 20 像素,宽度为 100 像素,高度为 200 像素。

几乎每一个 2D 图形库都将点 (0,0) 定义为屏幕的左上角。向右移动时 X 值增大,向底部移动时 Y 值增大。而且没有负值。

接下来我们绘制一个填充的椭圆。该椭圆将贴住左上角位于点 (160, 20) 的矩形侧边,而该矩形(以及椭圆)的大小为 100x200 像素。你可以看到椭圆和我们之前绘制的矩形大小相同,且椭圆从矩形右侧 110 像素处开始。

我们将填充颜色改为蓝色,并绘制一个左上角位于点 (200, 400) 的 200x20 填充矩形。然后,我们在矩形正下方绘制一个仅有轮廓的椭圆。我们从未更改过 GraphicsContextstroke 属性,所以轮廓椭圆仍然是绿色的。

然后我们将填充颜色改为黑色,并在画布上显示“Graphics are pretty neat”字样。这条消息将从点 (500, 100) 开始。

最后,我们计算画布的中心点,并从那里开始绘制另一条不同的消息。

现在,除非将 Canvas 对象添加到场景图中,否则这些内容都不会显示。我们仍然需要告诉 Stage 这个场景属于它,并让窗口/阶段变为可见。

这可能不是世界上最有趣的图形程序,但你将理解 GraphicsContext 对象是如何绘制到 Canvas 上的。

然后我们创建第二个匿名类来创建一个匿名的 AnimationTimer 对象,并对其调用 start() 方法。AnimationTimer 每秒大约会调用其 handle() 方法 60 次。(参数 now 包含一个不断递增的纳秒数,这有助于确定精确距离上一次调用 handle() 已经过去了多久。这对于复杂程序中的平滑动画是必要的。)

处理器用当前颜色绘制一个矩形,并在屏幕上显示当前消息。然而,在多次调用 handle() 方法之间,画布并没有被清除,因此我们必须通过用白色矩形覆盖它来手动“擦除”之前的消息。否则,当消息变化时,它将覆盖之前的消息并导致无法阅读。

你应该看到的

Linux screenshot of MouseDemo

MouseDemo 的 Linux 截图

对于程序来说,这种设计很奇怪,但它展示了编写游戏的一种非常常见的方式:使用一个每秒运行多次的主游戏循环。

在下一个练习中,我们将绕个路,看看当你(不)使用 Canvas 时,场景图的变化是如何自动重绘的。但之后我们将回到使用画布进行更多动画制作。

练习 51:更复杂的鼠标交互

在本次练习中,我们重新回到场景图,而不是在 Canvas 上手动绘制所有内容,因此不需要 AnimationTimer。我们还将使用 Java 内置的 Circle 对象。

 1 import javafx.application.Application;
 2 import javafx.event.EventHandler;
 3 import javafx.scene.Group;
 4 import javafx.scene.Scene;
 5 import javafx.scene.control.Label;
 6 import javafx.scene.input.MouseEvent;
 7 import javafx.scene.paint.Color;
 8 import javafx.scene.shape.Circle;
 9 import javafx.stage.Stage;
10 
11 public class TrafficLight extends Application {
12     @Override
13     public void start(Stage stage) {
14         Group root = new Group();
15         Scene scene = new Scene(root, 300, 600);
16 
17         Label label = new Label("Click on one of the circles!");
18         label.relocate(20, 10);
19 
20         Circle circle1 = new Circle(150, 140, 75);
21         circle1.setFill(Color.RED);
22         Circle circle2 = new Circle(150, 310, 75);
23         circle2.setFill(Color.YELLOW);
24         Circle circle3 = new Circle(150, 480, 75);
25         circle3.setFill(Color.web("#008000"));
26 
27         scene.addEventHandler(MouseEvent.MOUSE_CLICKED,
28                 new EventHandler<MouseEvent>() {
29             @Override
30             public void handle(MouseEvent t) {
31                 if ( circle1.contains(t.getX(), t.getY()) )
32                     label.setText("You clicked on the red circle!");
33                 else if ( circle2.contains(t.getX(), t.getY()) )
34                     label.setText("You clicked on the yellow circle!");
35                 else if ( circle3.contains(t.getX(), t.getY()) )
36                     label.setText("You clicked on the green circle!");
37                 else
38                     label.setText("Click at " + t.getX() + ", " + t.getY());
39             }
40         });
41 
42         root.getChildren().addAll(label, circle1, circle2, circle3);
43 
44         stage.setTitle("Traffic Light");
45         stage.setScene(scene);
46         stage.show();
47     }
48 
49     public static void main( String[] args ) { launch(args); }
50 }

TrafficLight.java

在之前的练习中,我们使用 GridPane 作为场景图的根节点。我们只需要告诉它将控件放在哪一行、哪一列,它就会管理所有东西的位置。

这次我们只使用通用的 Group 节点作为根节点,因此我们将手动指定该组内所有元素的位置。

Label 对象只显示文本。与 TextField 不同,文本不能由用户编辑,但我们仍然可以更改。标签默认将自己放置在 (0, 0) 点处,因此我们手动将其移动到从 (10,20) 开始。

然后我们创建三个Circle对象,指定每个圆的中心点和半径,然后告诉每个Circle对象用什么颜色填充自己。

对于第三个圆,我们本可以直接使用Color.GREEN,但我想演示一下 JavaFX 也允许你使用类似 Web 的十六进制值设置颜色。

然后,我们向Scene对象添加了一个匿名类来处理鼠标点击。与之前的练习不同,我们实际上是想知道他们点击了哪里,因此我们将调用MouseEvent参数的getX()getY()方法。

JavaFXCircle对象非常方便,因为你只需要使用内置的contains()方法询问它们某个点是否在内部即可。所以,如果点击在第一个圆内,我们会更新标签文本以说明。如果点击没有在任何圆内,我们只需更改标签显示他们点击的位置。

最后,我们使用方便的addAll()方法将标签和我们的三个圆添加到场景图中。(我们也可以调用四次add()。)

练习 52:动画

在本次练习中,我们仍然使用场景图在事物发生变化时自动重绘的能力,但我们现在回到使用AnimationTimer来让物体在没有用户输入的情况下移动起来。

我们将让一个 Logo 在屏幕上“弹跳”。

import javafx.animation.AnimationTimer;
import javafx.application.Application;
import javafx.event.EventHandler;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.stage.Stage;

public class BouncingLogo extends Application {
    private boolean flashing = false;
    private double x, y, dx, dy;

    @Override
    public void start(Stage stage) {
        Group root = new Group();
        Scene scene = new Scene(root, 900, 700);

        x = 500;
        y = 350;
        dx = 5;
        dy = 5;

        Image img = new Image("file:datafiles/ljthw-logo-32px.png");
        ImageView logo = new ImageView(img);
        logo.relocate(x,y);

        Rectangle rect = new Rectangle(x, y, img.getWidth(), img.getHeight());
        rect.setFill(Color.WHITE);

        scene.addEventHandler(KeyEvent.KEY_PRESSED,
                new EventHandler<KeyEvent>() {
            @Override
            public void handle(KeyEvent t) {
                if ( t.getCode() == KeyCode.SPACE )
                    flashing = ! flashing;
            }
        });

        root.getChildren().add(rect);
        root.getChildren().add(logo);

        stage.setTitle("Bouncing Logo");
        stage.setScene(scene);
        stage.show();

        new AnimationTimer() {
            @Override
            public void handle(long now) {
                x += dx;
                y += dy;

                if ( x < 0 || x+img.getWidth() > scene.getWidth() )
                    dx = -dx;
                if ( y < 0 || y+img.getHeight() > scene.getHeight() )
                    dy = -dy;

                if ( flashing ) {
                    double red = Math.random();
                    double green = Math.random();
                    double blue = Math.random();
                    rect.setFill(Color.color(red,green,blue));
                }
                logo.relocate(x,y);
                rect.relocate(x,y);
            }
        }.start();
    }

    public static void main( String[] args ) { launch(args); }
}

BouncingLogo.java

我们使用两个变量xy来跟踪图像的位置。变量dx (delta-x) 将用于跟踪图像 x 值在每一帧中的变化量,dy 也是如此,但针对的是 y 值。虽然可以使用角度和速度来代替,但那样你就得接触三角函数了,这对我的高中生们来说很麻烦。

由于这些变量将由我们的匿名内部类修改,因此我们必须将它们变为实例变量,而不仅仅是start()方法内部的局部变量。

我们从datafiles文件夹中的文件加载一张图像,并创建一个Image对象。然后,我们将Image封装在ImageView对象中,这是一个可以添加到场景图中的节点。(你可以使用GraphicsContextdrawImage()方法直接在Canvas上绘制Image对象,但除非它们在ImageView中,否则你不能直接将Image对象添加到场景图中。)我们还创建了一个同样大小的Rectangle对象,绘制在图像下方。

因此,图像(以及下方的矩形)从屏幕中心稍右的位置开始,每帧向右移动 5 像素 (dx) 向移动 5 像素 (dy)。

我们在Scene中添加了一个KeyEvent处理器来手动处理按键按下。当按下按键时,如果按键是空格键,我们就切换flashing变量的值。(稍后我们会看到这个变量的作用。)

然后,我们将Rectangle添加到场景图中,然后添加ImageView节点。按照这个顺序添加会将Rectangle画在底部,并将Image画在Rectangle之上。接着我们显示窗口。

AnimationTimer的匿名类稍微复杂一些。在每一帧中,我们将dxdy加到图像的位置上。

然后,如果x的新值小于 0,或者比图像宽度更靠近墙壁,说明我们“撞”到了左墙或右墙。因此,我们改变dx的符号(也就是说,如果它原来是5,现在就是-5,反之亦然)。这将使图像在帧向另一个方向移动。

同样,如果y的新值超出边界,我们就改变dy来垂直反弹。

如果flashing变量为true,我们为新色的红、绿、蓝分量选取 0 到 1 之间的随机值,然后告诉矩形使用该新颜色进行填充。这每帧都会发生,所以图像下方的矩形会烦人地闪烁,直到再次按下空格键。

最后,我们relocate() ImageViewRectangle对象到这个新位置,这会修改场景图并触发屏幕的重绘。

你应该看到的

Bouncing Logo Linux 截图

Bouncing Logo Linux 截图

(动起来更酷。)

练习 53:处理按键事件

在游戏中错误地处理按键是非常常见的。假设你想让角色在按下右箭头键时向右移动。你可能会考虑这样的代码:

if ( t.getCode() == KeyCode.RIGHT )
    x += 5;

这种方法确实有效。但结果会很卡顿,而且他们无法一直按住键!所以让我们来看看效果更好的方法。

import javafx.animation.AnimationTimer;
import javafx.application.Application;
import javafx.event.EventHandler;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.paint.Color;
import javafx.stage.Stage;

public class Plotter extends Application {
    private double x, y;
    private boolean up, down, left, right;

    @Override
    public void start(Stage stage) {
        Group root = new Group();
        Scene scene = new Scene(root);

        Canvas canvas = new Canvas(900,700);
        GraphicsContext gc = canvas.getGraphicsContext2D();

        x = 450;
        y = 350;
        int speed = 7;

        scene.addEventHandler(KeyEvent.KEY_PRESSED,
                new EventHandler<KeyEvent>() {
                    @Override
                    public void handle(KeyEvent t) {
                        if ( t.getCode() == KeyCode.UP || t.getCode() == KeyCode.W )
                            up = true;
                        if ( t.getCode() == KeyCode.DOWN || t.getCode() == KeyCode.S )
                            down = true;
                        if ( t.getCode() == KeyCode.LEFT || t.getCode() == KeyCode.A )
                            left = true;
                        if ( t.getCode() == KeyCode.RIGHT || t.getCode() == KeyCode.D )
                            right = true;

                        if ( t.getCode() == KeyCode.DIGIT1 )
                            gc.setFill(Color.BLACK);
                        else if ( t.getCode() == KeyCode.DIGIT2 )
                            gc.setFill(Color.RED);
                        else if ( t.getCode() == KeyCode.DIGIT3 )
                            gc.setFill(Color.GREEN);
                        else if ( t.getCode() == KeyCode.DIGIT4 )
                            gc.setFill(Color.BLUE);
                    }
                });

        scene.addEventHandler(KeyEvent.KEY_RELEASED,
                new EventHandler<KeyEvent>() {
                    @Override
                    public void handle(KeyEvent t) {
                        if ( t.getCode() == KeyCode.UP || t.getCode() == KeyCode.W )
                            up = false;
                        if ( t.getCode() == KeyCode.DOWN || t.getCode() == KeyCode.S )
                            down = false;
                        if ( t.getCode() == KeyCode.LEFT || t.getCode() == KeyCode.A )
                            left = false;
                        if ( t.getCode() == KeyCode.RIGHT || t.getCode() == KeyCode.D )
                            right = false;
                    }
                });

        root.getChildren().add(canvas);

        stage.setTitle("Plotter");
        stage.setScene(scene);
        stage.show();

        new AnimationTimer() {
            @Override
            public void handle(long now) {
                if ( up )
                    y -= speed;
                else if ( down )
                    y += speed;
                if ( left )
                    x -= speed;
                    else if ( right )
                    x += speed;

                    gc.fillRect(x, y, 20, 20);
            }
        }.start();
    }

    public static void main( String[] args ) { launch(args); }
}

Plotter.java

我们正在使用四个布尔值来跟踪用户的意图移动。如果他们想向上,那么up就为true。如果他们不想向上,那么up应该为false

你应该知道,如果你在文字处理文档中输入字母“q”,会发生三个独立的按键事件:

  • 对于字母“Q”,由于会发生一个KEY_PRESSED事件。

  • 对于字母“q”,会发生一个KEY_TYPED事件。

  • 当他们松开“Q”键时,会发生一次KEY_RELEASED事件。

KEY_PRESSEDKEY_RELEASED是非常底层的事件,适用于任何按键,包括像SHIFTF1这样的修饰键。KEY_TYPED是高层事件,只有在输入实际字符时才会触发。例如,当我想要输入大写的“Q”时,会生成五个KeyEvents

  1. SHIFTKEY_PRESSED

  2. QKEY_PRESSED

  3. “Q” 的 KEY_TYPED

  4. QKEY_RELEASED

  5. SHIFTKEY_RELEASED

注意,在 KEY_PRESSEDKEY_RELEASED 事件中,大写的“Q”和小写的“q”之间没有区别。它们都只是“Q”。

此外,如果你长按按键,操作系统的按键重复功能会在一秒后启动,你将获得多个 KEY_TYPED 事件。

所以我们是这样处理的:如果向上箭头被 PRESSED,我们将相应的布尔变量设为 true。然后,当向上箭头稍后被 RELEASED 时,我们将变量改回 false

这样一来,我们就不必处理键盘重复的问题了,而且他们可以同时按住多个键,且一切都能正常运行。

然后,AnimationTimer 处理器完全不需要关心按键。如果 up 的布尔变量为 true,就向上移动。

我使用了一些 else if 语句来确保 up 优先于 downleft 优先于 right,但你可以移除它们,直接将这四个都变成 if 语句,它们仍然可以工作。如果他们同时按住 UPDOWN,它们会互相抵消。

你应该看到的

Linux screenshot of Plotter

Plotter Linux 截图

我想我应该补充一下,你只在 PRESSEDRELEASED 事件中使用 getCode()。如果你实际上想检查 KEY_TYPED 事件,你将需要调用 getCharacter() 来获取他们实际输入的字符串。

关于图形的新内容就到这里了。在接下来的两个练习中,我们将所有内容整合在一起,为你在之前的练习中制作的两个游戏制作图形界面!

练习 54:图形化井字游戏

在早期的练习 15 中,我们创建了 NoughtsCrossesObject,这是一个包含井字游戏所有有趣部分方法的对象。这个练习需要那些代码,所以如果你还没有输入它们(并修复它们!),现在应该去做了。

在这个练习中,我们将为这个游戏制作一个简单的图形界面,这样我们就可以使用鼠标了!

import javafx.animation.AnimationTimer;
import javafx.application.Application;
import javafx.event.EventHandler;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.input.MouseEvent;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import javafx.scene.text.FontWeight;
import javafx.stage.Stage;

public class NoughtsCrossesGUI extends Application {
    private GraphicsContext gc;
    private String player = "X";
    private NoughtsCrossesObject ttt;
    private boolean started = false;
    private boolean gameOver = false;
    private boolean drawLabels = false;

    @Override
    public void start(Stage stage) {
        Group root = new Group();
        Scene scene = new Scene(root);

        Canvas canvas = new Canvas(800,800);
        gc = canvas.getGraphicsContext2D();

        ttt = new NoughtsCrossesObject();

        canvas.addEventHandler(MouseEvent.MOUSE_CLICKED,
                new EventHandler<MouseEvent>() {
            @Override
            public void handle(MouseEvent t) {
                if ( gameOver ) {
                    started = false;
                    gameOver = false;
                    ttt = new NoughtsCrossesObject();
                    return;
                }

                if ( ! started ) {
                    started = true;
                    return;
                }

                int r = ((int)t.getY() - 85) / 200;
                int c = ((int)t.getX() - 125) / 200;

                if ( ttt.isTaken(r,c) == false ) {
                    ttt.playMove(player,r,c);
                    player = ( player.equals("X") ? "O" : "X" );
                }
            }
        });

        root.getChildren().add(canvas);

        stage.setTitle("Noughts and Crosses");
        stage.setScene(scene);
        stage.show();

        new AnimationTimer() {
            @Override
            public void handle(long now) {
                drawEverything();
            }
        }.start();
    }

    public void drawEverything() {
        gc.setFill(Color.WHITE);
        gc.fillRect(0, 0, 800, 800);

        if ( ! started )
        {
            gc.setFill(Color.BLACK);
            gc.setFont(Font.font("Arial", FontWeight.BOLD, 48) );
            gc.fillText( "click to play", 275, 400 );
        }
        else
        {
            drawEmptyBoard();
            for ( int r=0; r<3; r++ )
                for ( int c=0; c<3; c++ )
                    if ( ttt.isTaken(r,c) )
                        drawSymbol(ttt.playerAt(r,c), r, c);

            gameOver = (ttt.isWinner("X") || ttt.isWinner("O") || ttt.isFull());
            if ( gameOver )
            {
                gc.setFont(Font.font("Arial", FontWeight.BOLD, 144));
                gc.setFill(Color.BLACK);
                if ( ttt.isWinner("X") )
                    gc.fillText("X wins!", 160, 400);
                if ( ttt.isWinner("O") )
                    gc.fillText("O wins!", 160, 400);
                if ( ttt.isCat() )
                    gc.fillText("TIE GAME", 50, 400);
            }
            else
            {
                gc.setFont(Font.font("Arial", FontWeight.BOLD, 72));
                gc.setFill(Color.BLACK);
                gc.fillText(player + ", go.", 10, 730);
            }
        }
    }
    public void drawEmptyBoard() {
        gc.setStroke(Color.BLACK);
        // horizontal lines
        gc.strokeLine(100,250,700,250);
        gc.strokeLine(100,450,700,450);
        // vertical lines
        gc.strokeLine(300,50,300,650);
        gc.strokeLine(500,50,500,650);
        // labels
        if ( drawLabels ) {
            gc.setFont(Font.getDefault());
            gc.setFill(Color.BLACK);
            gc.fillText("(0,0)",105,65);
            gc.fillText("(1,0)",105,265);
            gc.fillText("(2,0)",105,465);
            gc.fillText("(0,1)",305,65);
            gc.fillText("(1,1)",305,265);
            gc.fillText("(2,1)",305,465);
            gc.fillText("(0,2)",505,65);
            gc.fillText("(1,2)",505,265);
            gc.fillText("(2,2)",505,465);
        }
    }
    public void drawSymbol(String p, int r, int c) {
        gc.setStroke(Color.BLACK);
        int x = 200*c + 125;
        int y = 200*r + 85;
        if ( p.equals("X") ) {
            gc.strokeLine(x-50,y-50,x+50,y+50);
            gc.strokeLine(x+50,y-50,x-50,y+50);
        } else if ( p.equals("O") ) {
            gc.strokeOval(x-50,y-50,100,100);
        }
    }
    public static void main(String[] args ) { launch(args); }
}

NoughtsCrossesGUI.java

这里的挑战在于,鼠标点击取决于游戏处于什么状态,这会有不同的含义。一种(良好的)方法是创建多个 Scene 对象,每个对象都有自己的 MouseEvent 处理程序,并在游戏变化时切换。

我没有这样做,而是选择了不那么完美的“变量和 if 语句”方法。当点击时,如果游戏结束,我们会更改变量使游戏不再结束,并通过创建一个新的 NoughtsCrossesObject 来重置棋盘。

如果在游戏未启动时点击,我们只需将状态设置为 startedreturn 出。

最后,如果在正常游戏期间点击,我们需要找出他们点击了哪一行哪一列。简单的数学计算就能给出答案。然后我们尝试在该格中放置符号并切换玩家。

如果他们点击的方格已被占用,我们不显示错误消息;仅仅什么都不做。

你应该看到的

AnimationTimer 处理器非常简单;我们只需调用一个名为 drawEverything() 的方法。很有创意吧?

drawEverything() 方法首先“清除屏幕”(用大白色矩形填充整个画布)。但它接下来的操作取决于游戏处于什么状态。如果游戏还没有开始,我们只显示“点击开始”。

一旦游戏已经开始,我们在屏幕上绘制网格,然后遍历行和列。如果某个特定的方格中有符号,我们在屏幕上绘制该符号。

然后我们检查游戏是否由于某种原因已经结束,如果是的话,我们在屏幕上显示相应的消息。如果游戏没有结束,我们就要求当前玩家采取回合。

剩下的唯一棘手部分在 drawSymbol() 方法中。它被调用时传入行和列,但实际上需要屏幕坐标。所以那里也需要一些数学计算。

练习 55:图形化掉子游戏

在最后一个练习中,我们将为几个练习前创建的 DropGameHelper 创建一个图形界面。你需要确保 DropGameHelper.java 与你为练习编写的代码在同一个文件夹中,此外还有 DropGamePiece.javaLocation.java 以及辅助接口 Locatable.javaTranslatable.java

import javafx.animation.AnimationTimer;
import javafx.application.Application;
import javafx.event.EventHandler;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.input.MouseEvent;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import javafx.scene.text.FontWeight;
import javafx.stage.Stage;

public class DropGameGUI extends Application {
    private GraphicsContext gc;
    private int player = 1;
    private DropGameHelper game;
    private boolean started = false;
    private boolean gameOver = false;
    private Location[][] locs;

    @Override
    public void start(Stage stage) {
        Group root = new Group();
        Scene scene = new Scene(root);

        Canvas canvas = new Canvas(900, 800);
        gc = canvas.getGraphicsContext2D();

        game = new DropGameHelper(6, 7);
        locs = new Location[6][7];
        for ( int r=0; r<locs.length; r++ )
            for ( int c=0; c<locs[0].length; c++ )
                locs[r][c] = new Location(r,c);
        gc.setStroke(Color.BLACK);

        canvas.addEventHandler(MouseEvent.MOUSE_CLICKED,
                new EventHandler<MouseEvent>() {
            @Override
            public void handle(MouseEvent t) {
                if ( gameOver ) {
                    started = false;
                    gameOver = false;
                    game = new DropGameHelper(6, 7);
                }
                else if ( ! started ) {
                    started = true;
                }
                else {
                    int xOffset = 100, colWidth = 100;
                    int col = ((int)t.getX() - xOffset) / colWidth;
                    if ( game.isValid(col) && ! game.isFull(col) ) {
                        game.playMove(""+player, col);
                        player = (player%2) + 1;
                    }
                }
            }
        });

        root.getChildren().add(canvas);

        stage.setTitle("Drop Game");
        stage.setScene(scene);
        stage.show();

        new AnimationTimer() {
            @Override
            public void handle(long now) {
                drawEverything();
            }
        }.start();
    }

    public void drawEverything() {
        gc.setFill(Color.WHITE);
        gc.fillRect(0, 0, 900, 800);

        if ( ! started )
        {
            gc.setFill(Color.BLACK);
            gc.setFont(Font.font("Arial", FontWeight.BOLD, 48) );
            gc.fillText( "click to begin", 275, 400 );
        }
        else
        {
            drawBoard();
            gameOver = game.isWinner("1") || game.isWinner("2") || game.isFull();
            if ( gameOver ) {
                gc.setFont(Font.font("Arial", FontWeight.BOLD, 120));
                gc.setFill(Color.BLACK);
                if ( game.isWinner("1") )
                    gc.fillText("Player 1 wins!", 50, 400);
                else if ( game.isWinner("2") )
                    gc.fillText("Player 2 wins!", 50, 400);
                else if ( game.isFull() )
                    gc.fillText("TIE GAME", 150, 400);
            }
            else {
                gc.setFont(Font.font("Arial", FontWeight.BOLD, 72));
                gc.setFill(Color.BLACK);
                gc.fillText("Player " + player + ", go.", 30, 750);
            }
        }
    }

    public void drawBoard() {
        double xOffset = 50;
        double yOffset = 50;
        double colWidth = 100;
        double rowHeight = 100;
        for (int r=0; r<locs.length; r++ )
            for (int c=0; c<locs[0].length; c++ ) {
                double x = xOffset + c*colWidth;
                double y = yOffset + r*rowHeight;
                if (locs[r][c] == null) {
                    gc.stroke(Color.BLACK);
                    gc.drawRect(x, y, colWidth, rowHeight);
                } else if (locs[r][c].player.equals("1")) {
                    gc.setFill(Color.RED);
                    gc.fillOval(x+10, y+10, colWidth-20, rowHeight-20);
                } else {
                    gc.setFill(Color.BLUE);
                    gc.fillOval(x+10, y+10, colWidth-20, rowHeight-20);
                }
            }
        }
    }

    public static void main( String[] args ) {
        launch(args);
    }
}

DropGameGUI.java

DropGameGUI 类继承自 Application,为游戏提供了图形界面。它使用 Canvas 来绘制游戏板,并使用 AnimationTimer 来处理渲染循环。

你应该看到的

DropGame

start 方法初始化了场景、画布和鼠标事件监听器。如果游戏结束,它会重置;如果游戏尚未开始,它就会开始。否则,它计算点击的列并告诉 DropGameHelper 执行移动。

drawEverything 方法清除屏幕并绘制状态。如果游戏没有开始,它显示“点击开始”的消息。否则,它调用 drawBoard() 并判断胜利者或平局。

drawBoard 方法遍历 locs 二维数组。它为空单元格绘制矩形,并为不同的玩家绘制颜色的圆圈。

为了追踪轮到谁,我使用了:

player = (player%2) + 1;

% 运算符是模运算;它找到 player 除以 2 的余数。如果 player 是 1,余数为 1……加 1 是 2。如果 player 是 2,余数是 0……加 1 是 1。我本可以使用三元运算符,但我想趁着这个练习还不晚向你们展示一个新技巧!

为了从数组中提取 Location 对象,我们使用:

game.playerAt( locs[r][c] )

我本可以写成:

game.playerAt( new Location(r,c) )

然而,这每每次调用 drawBoard() 都会创建 42 个新的 Location 对象,而该方法每秒大约调用 60 次。Java 运行时可能会处理并采取正确的操作,但我并不喜欢每秒创建并丢弃 2500 个对象。

我也可以直接回到 DropGameHelper 的代码中,添加一个接受行和列而不是 Location 对象的第二个 playerAt() 方法。这可能也不是个坏主意。

总之,这个游戏板上没有网格线。我决定为空白区域显示轮廓圆,为不同的玩家显示彩色圆,这样看起来足够漂亮,也让 drawBoard() 的代码看起来很不错。

下一步

你做到了!

如果你完成了所有的练习,通过了学习训练(Study Drills)并理解了自己在做什么,那么恭喜你!Java 对于初学者来说并不是一门容易的语言,所以给自己鼓鼓掌吧。

你现在已经掌握了 Java 面向对象编程的基础,更重要的是,你有了大量阅读代码、理解代码和修复 Bug 的经验。这些都是程序员重要的工具。凭借了这些技能,你应该能够拿起几乎任何编程书籍并游刃有余。

然而,你的旅程还没有结束。输入他人的程序已经很难,但从零开始编写自己的程序要困难得多。

我的大多数真实学生在完成作业后,通常又练习和学习了四五,才在行业内获得工作。目前,你在两个主要方面存在短板:

  • 你需要更多的练习,尤其是编写自己的程序。

  • 你不熟悉标准库。

你还不标准库

专业的 Java 程序员花费大量时间编写少量的代码,将他人编写的模块“粘合”在一起。其中许多内容包含在 Java 中,并可以通过 import 使用。

你需要更多的练习

我在公立学校教的学生确实使用书中的练习来开始学习,但并不是我要求他们做的全部。他们还必须从零开始编写大量的程序。如果你还没有完成

Programming by Doing

中的作业,我强烈建议你去完成它们。(不过,这些作业并不很面向对象。)

  • CodingBat (java) - 短小且有针对性的练习题。这些对于准备计算机科学高级位分(AP)考试的学生来说是极佳的练习。

  • Project Euler - 一系列具有挑战性的数学/计算机编程问题,解决这些问题不仅需要数学洞察力。虽然数学会帮助你找到优雅且高效的方法,但解决大多数问题仍然需要计算机和编程技能。

非常感谢你使用我的书开启你的编程之旅。请在推特上关注 @grahammitchell 并使用标签 #LJtHW 让我知道!

编码愉快!

– Graham Mitchell

笔记

1 某些编程语言拥有 pointers(指针),它们是字面量存储内存地址的变量类型。(通常这些语言还允许进行 pointer arithmetic(指针算术),你可以通过数学计算到达不同的内存位置。)Java 在技术上拥有 references(引用),而不是 pointerspointers 始终持有内存地址。而 references 可以持有内存地址,但有时也持有其他值来引用内存中的某个特定位置。有些人对这种差异非常纠结,但在本书书中,我将忽略这种区别。

posted @ 2026-09-13 17:59  绝不原创的飞龙  阅读(7)  评论(0)    收藏  举报