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

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

原文:Learn Object-Oriented Java the Hard Way

译者:飞龙

协议:CC BY-NC-SA 4.0

致谢

感谢上帝,他赐予我创造的能力。

感谢 Deanna,在我都不确定是否能完成时,她相信我能做到。

感谢数百位购买我第一本书的人;没有你们,这第二本书将不存在!

感谢耶稣,他每天教我恩典与谦卑。愿我每一天都更加依靠他。

前言:在做中学习

二十多年来,我一直在教初学者如何编程。超过 2000 名学生上过我的课程,离开时已经学会了如何编写运行良好的简单程序。有些人只学会了一点点,而另一些人在短短几年内获得了惊人的技能。

我有许多出色的学生,但我的大多数学生都是没有经验、对编程没有特殊天赋的普通孩子。这本书是像他们这样的普通人写的。

大多数编程书籍和在线教程是由那些具有极高天赋但对真实初学者缺乏经验的人编写的。他们的书往往涵盖了太多内容且速度过快,并且高估了真正初学者的理解能力。

如果你有丰富的经验或极高的天赋,你可以从几乎任何来源学习编程。我有时会看到这样的评论:“我教我 9 岁的女儿编程,她在六周后就做出了她的第一个 Android 应用!”如果你是天才儿童,那么这本书不是为你写的。

我也开始相信,编写大量的小程序是不可替代的。所以这就是你在这本书中要做的。你将输入小型程序并运行它们。

“最好的学习方式就是实践。” – P.R. Halmos

引言:面向对象的 Java

Java 是一种面向对象的编程语言。我的第一本书涵盖了 Java 语言的所有基础语法,但避开了所有面向对象的部分。这本书涵盖了其余部分。

如果你以前从未用任何语言编写过程序,那么这本书可能不适合你。在读完这本书之前,你需要对类似的语言具有一定的经验。如果你已经了解 Java 或其他语言(如 C、C++、C# 或 Javascript)的基础知识,那么你就没问题。如果你只熟悉 Python 或 Ruby 这样完全不同的语言,那么你需要追赶一下。

如果你在尝试跟随练习 1 中的代码时迷路了,那么你应该回去先读一本更简单的书,然后再尝试这一本。

你将学到

  • 如何安装 Java 编译器和文本编辑器

  • 如何与 Java 对象一起工作并创建你自己的类

  • 字段和实例变量

  • 方法和参数

  • 构造函数

  • 引用变量 vs 基本类型

  • 泛型和强制转换

  • 继承

  • 接口

  • 抽象类和方法

  • 包

  • 如何创建 JAR 文件

  • JavaFX 中的图形用户界面

  • GUI 中的鼠标和键盘输入

  • 测试与效率

  • 算法复杂度和大 O 符号

  • ArrayList

  • 排序

以及更多!

在最后一章中,你将编写一个流行的跳棋游戏的图形版本,并能够将其打包发送给他人。

书中的所有示例都可以在 Java 1.8 或更高版本中运行。如果你省略最后几个关于 JavaFX 的章节,大部分代码可以在 Java 1.6 或更高版本中运行。

你将不会学到

  • 如何在终端中编译和运行 Java 程序

  • Java 的基础知识

  • 如何制作 Android 应用

  • 不同“版本”的 Java 细节

  • Javascript

创建、编译与运行

如果你以前写过一些 Java 但一直使用 IDE,那么你应该学习如何在简单的文本编辑器中编写代码,以及如何从终端编译你的代码。我的第一本书中有一专门关于此主题的整个章节,可以免费在线阅读,如果你需要,请先完成该部分。

无基础

如果你还不知道如何在 Java 中创建变量以及编写 if 语句、循环和函数,那么你应该在尝试阅读这本书之前先学习这些内容。

无 Android

Android 应用非常复杂,如果你是初学者,一个应用远超你的能力范围。

无特定版本

除了最后几章,我将不会涵盖任何最近添加到 Java 中的新特性。如果你在意这些差异,那么这本书不适合你。

无 Javascript

“Javascript”是一门编程语言的名字,而“Java”也是一种编程语言的名字。这两种语言之间毫无关系。

许可证

本书的某些章节可免费在线阅读,但未经许可不得为他人制作。

提供的下载材料未经复制、扫描、复制或发布到公共网站,无论是全部还是部分。

购买了本书和或教程视频的教育工作者被许可将其用于自学或对单一学生进行面对面的辅导。教学此课程需要站点授权。

除非另有说明,所有内容版权 2015-2016 Graham Mitchell。

练习 0:设置

这个练习没有代码但不要跳过。它将帮助你安装一个良好的文本编辑器并安装 Java 开发工具包 (JDK)。如果你没有完成这两项任务,你无法完成书中任何其他练习。你应该尽可能准确遵循这些指令。

无论你使用什么类型的系统,你都要做三件事:

  1. 安装一个用于编写代码良好的文本编辑器。

  2. 弄清楚如何打开终端窗口以便输入命令。

  3. 安装 JDK。

而且在 Windows 上,你还需要做第四件事:

  1. 将 JDK 添加到系统 PATH。

(JDK 命令在 Apple 电脑和 Linux 计算机会自动添加到 PATH。)

我在下方提供了针对 Windows 的说明,然后是 Mac OS,最后是 Linux。请直接跳转到你偏好的操作系统部分。

Windows

安装一个好用的文本编辑器 (Notepad++)

  1. 使用网络浏览器访问 notepad-plus-plus.org,下载最新版本的 Notepad++ 文本编辑器并安装。执行此操作不需要管理员权限。

  2. 一旦安装了 Notepad++,我总是会运行它并关闭“自动完成(Auto-Completion)”,因为它对初学者没有好处。(对我个人来说也很烦人)。打开“设置(Settings)”菜单并选择“首选项(Preferences)”。然后点击左侧列表中间部分的“自动完成(Auto-Completion)”。最后取消勾选“每次输入时启用自动完成(Enable auto-completion on each input)”旁边的复选框,然后点击“关闭(Close)”按钮。

  3. 最后在 Notepad++ 运行时,右键点击 Windows 任务栏上的 Notepad++ 按钮,点击“将此程序固定到任务栏(Pin this program to taskbar)”。这将方便以后在编程时启动 Notepad++。

打开终端窗口 (PowerShell)

  1. 点击开始按钮打开开始菜单。(在 Windows 8 及更高版本中,你可以通过按 Windows 键 + S 直接打开搜索框)。在搜索框中输入“powershell”。

  2. 从结果列表中选择“Windows PowerShell”。

  3. 右键点击任务栏上的 PowerShell 按钮并选择“将此程序固定到任务栏”。

  4. 在 Powershell/Terminal 窗口中,输入

javac

你可能会看到一行红色的错误信息,内容类似于“The term ‘javac’ is not recognized as the name of a cmdlet....”

这意味着 JDK 没有安装并添加到 PATH 中,这正是我们目前预期的结果。

安装 Java 开发工具包 (JDK)

  1. 使用网络浏览器访问 Oracle Java SE 下载页面。

  2. 点击左侧顶部附近巨大的“Java”按钮来下载 Java 平台 (JDK) 8u102。点击此将带你进入标题为“Java SE Development Kit 8 Downloads”的页面。

  3. 在此页面上,你需要接受许可协议,然后选择列表底部附近的“Windows x86”版本。下载 8u102 版本或任何更高版本的文件。

    如果你确定运行的是 64 位的 Windows,那么下载 JDK 的“Windows x64”版本也是可以的。如果你不确定,应该下载“x86”(即 32 位)版本,因为该版本可以同时运行在 32 位和 64 位的 Windows 上。

    你不需要下载“Demos and Samples(演示与示例)”。

  4. 下载完成后,运行 jdk-8u102-windows-i586.exe 进行安装。在你第一次点击“Next >”后,你会看到一个显示 Install to: C:\Program Files (x86)\Java\jdk1.8.0_102\ 或类似内容的屏幕。请记下这个位置;你很快就会需要它。

  5. 只需一直点击“Next(下一步)”直到全部完成。除非你真的知道自己在做什么,否则最好的办法可能就是让安装程序按它的意图运行。

将 JDK 添加到 PATH

  1. 现在既然已经安装了 JDK,你需要找到安装所在文件夹的确切名称。查看 C: 盘下的 Program Files 文件夹,如果有的话查看 C:\Program Files (x86) 文件夹。你正在寻找一个名为 Java 的文件夹。里面有一个名为 jdk1.8.0_102 的文件夹,它里面还有一个名为 bin 的文件夹。文件夹名称必须包含 jdk1.8;jre8 是不一样的。确保有一个 bin 文件夹。

  2. 当你进入 bin 文件夹后,可以左键点击上方文件夹的位置栏,它会变成类似 C:\Program Files (x86)\Java\jdk1.8.0_102\bin 的内容。你可以把它记下来,或者选中并右键复制到剪贴板。

  3. 一旦 JDK 安装完成且你知道了该位置,打开你的终端窗口(PowerShell)。在 PowerShell 中,输入以下内容:

<<(code/set-environ-var-8u102.txt)

不过请将其写在同一行。即:

输入或粘贴 [Environment]::SetEnvironmentVariable("Path", "$env:Path;

先不要按回车。你可以通过右键粘贴到 PowerShell。

然后输入或粘贴上面的文件夹位置。如果你安装的是 x86 (32 位) 版本 JDK 8u102,它应该是:

C:\Program Files (x86)\Java\jdk1.8.0_102\bin

(仍然不要按回车。)

然后在末尾添加 ", "User")。最后,按 ENTER。

如果你遇到错误,说明你输入的内容不对。你可以按向上方向键找回命令,并使用左右方向键查找并修复错误,然后再次按 ENTER。

如果 SetEnvironmentVariable 命令完成且没有报错,输入 exit 关闭 PowerShell 窗口。如果不关闭 PowerShell 窗口,你做的更改将不会生效。

确保 JDK 安装正确

  1. 再次启动 PowerShell。

  2. 在提示符后输入 javac -version。

你应该能看到类似 javac 1.8.0_102 的响应。

  1. 在提示符后输入 java -version。

你应该能看到类似 java version "1.8.0_102" 的响应。

确保它们报告的版本号相同!如果没有,你可能安装了两个不同的(不兼容的)Java 版本,这将导致你无法完成书中的练习。

如果版本号不匹配,进入控制面板并添加/程序。移除所有与 Java 相关的程序、JDK、JRE。如果安装了 Eclipse 也将其移除。移除所有 Java 相关内容并重新开始。

但是,如果两个命令都正常工作、没有报错且版本号一致,那么恭喜你!

在命令行 (PowerShell) 中导航

你应该创建一个新文件夹来存放你的代码。完成这本书后,你将会有至少 100 个新文件。

如果你想操作得高级一点,就不需要使用鼠标切换回终端;你只需要在 Windows 或 Linux 上按下 Alt + Tab,或者在 Mac 上按下 Command + Tab 即可切换应用程序。

按住 Alt 键不要松开。然后按下并释放一次 Tab 键。在继续按住 Alt 键的同时,多次按下 Tab 直到选中你的终端窗口,然后松开 Alt 键完成切换。

如果你只是快速按下 Alt+Tab 并立即松开两个键,它通常会带你回到上一个应用程序。我经常这样做。当我在文本编辑器中时,按下 Alt+Tab 返回终端,当我在终端完成工作后,我再次按下 Alt+Tab 回到文本编辑器。一旦习惯了,这非常快。

你应该直接跳到本章的底部阅读“给初学者的警告”,否则,你已经完成了设置,并准备好在 Windows 上开始练习 01 了!干得好。

Mac OS X

安装一个好用的文本编辑器 (TextWrangler)

  1. 使用浏览器访问 barebones.com。下载 TextWrangler 版本 5.0 或更高版本的磁盘映像。

  2. 运行磁盘映像,然后打开“应用程序”(Applications)文件夹,并按照指示将图标拖到其中。你可能需要管理员用户名和密码进行验证。

  3. 安装完成后,启动 TextWrangler,如果它没有自动出现在 Dock 栏中,请手动将它添加。

打开终端窗口 (Terminal)

  1. 最小化 TextWrangler 并切换到 Finder。使用搜索(Spotlight)开始搜索“terminal”。这会打开一个小的 bash 终端。

  2. 同样将你的 Terminal 放入 Dock 栏中。

  3. 在 Terminal 窗口中,输入

javac

你可能会收到一个错误提示,告诉javac是一个未知命令。(欢迎通过邮件给我发送错误信息的截图,我可以更新这一段内容。)

这仅仅意味着尚未安装 JDK,这正是我们预期的结果。

安装 Java 开发工具包 (JDK)

  1. 使用浏览器访问 Oracle 的 Java SE 下载页面。

  2. 点击页面左侧上方巨大的“Java”按钮来下载 Java Platform (JDK) 8u102。点击这将带你进入标题为“Java SE Development Kit 8 Downloads”的另一个页面。

  3. 在该页面,你必须接受许可协议,然后选择列表中间的“Mac OS X x64”版本。下载版本8u102或更高版本的文件。

    你不需要下载“Demos and Samples”(演示与示例)。

  4. 下载完成后,运行jdk-8u102-macosx-x64.dmg进行安装。

  5. 一直点击“Next”(下一步)直到完成。除非你真的知道自己在做什么,否则最好就让安装程序按照它的意愿运行。

将 JDK 添加到 PATH

你可以跳过这部分,因为 JDK 安装程序在 Apple 电脑上会为你完成这一操作。不过,为了让更改生效,你可能需要关闭并重新打开终端。

确保 JDK 安装正确

  1. 再次启动Terminal。

  2. 在提示符处输入javac -version。

你应该看到类似javac 1.8.0_102的响应。

  1. 在提示符处输入java -version。

你应该看到类似java version "1.8.0_102"的响应。

确保它们报告的是相同的版本号!如果不是,你可能安装了两个不同的(不兼容的)Java 版本,在完成本书书中的练习时会遇到困难。

如果版本号不匹配,请卸载与 Java、JDK、JRE 相关的所有程序。如果安装了 Eclipse,也将其删除。删除所有 Java 相关内容并重新开始。

但是,如果两个命令都正常运行且没有任何错误,并且版本号匹配,那么恭喜你!搞定这一切是非常困难的,你可能已经具备了完成书中剩余内容的资质!

不过,先不要进入下一个练习!我们应该习惯使用命令行进行导航,因为这正是你运行在本书中编写的程序的方式。

命令行导航 (Terminal)

你应该创建一个新文件夹来存放所有的代码。读完这本书后,你将会有至少 100 个新文件,把它们放在一个地方会更好。

输入ls然后按回车。(那个“L”是“list”的第一个字母。)此命令将列出当前文件夹/目录的内容。

cd命令意味着“切换目录”,它将带你进入“Documents”(文档)文件夹,以便未来的命令在该处生效。

注意,你的提示符会改变,显示你现在位于一个新文件夹内部。例如,当我第一次打开Terminal时,我的提示符是

localhost:~ mitchell$

然后当我进入“Documents”目录后,提示符会变为

localhost:Documents mitchell$

  1. 在“视图”(View)选项卡中,勾选“显示行号”(Display line numbers)。

  2. 确保“启用自动换行”(Enable text wrapping)旁边没有勾选。

  3. 切换到“编辑器”(Editor)选项卡,并将Tab width:更改为 4。

  4. 勾选“启用自动缩进”(Enable automatic indentation)。

打开终端窗口 (Terminal)

  1. 最小化你的文本编辑器,在启动器 Dash 栏中搜索“Terminal”。其他 Linux 发行版可能将其称为“GNOME Terminal”、“Konsole”或“xterm”。其中任何一个都应该可以使用。

  2. 同样将终端锁定到启动器栏上。

  3. 在终端窗口中,输入

javac

你应该会收到一条错误信息,显示“The program javac can be found in the following packages”,后面跟着一个软件包列表。

这仅意味着没有安装 JDK,这正是我们预期的。

安装 Java 开发工具包 (JDK)

  1. Linux 的优点之一是包管理器。如果你愿意,可以手动安装 Oracle 的“标准”版本 Java,但我总是直接使用 OpenJDK 版本:
sudo apt-get openjdk

基本上就这样了。书中的所有内容使用 OpenJDK 都能正常运行。(事实上,我每天大部分工作都使用 Linux,而书中的练习实际上是使用 OpenJDK 编写和测试的!)

然而,如果你坚持要像 Windows 和 Mac 用户那样安装某些特定版本,你可以从 Oracle 的 Java SE 下载页面 下载。

不过,安装过程需要你自行负责。说真的,请直接使用你包管理器提供的版本即可。

将 JDK 添加到 PATH

你可以跳过这部分,因为在 Linux 计算机上这已经为你完成了。不过,为了让更改生效,你可能需要关闭并重新打开终端。

然而,在我的电脑上,运行任何 Java 工具都会在终端窗口打印出一条烦人的信息:

Picked up JAVA_TOOL_OPTIONS: -javaagent:/usr/share/java/jayatanaag.jar

这是因为 Eclipse 在没有这个 JAR 文件的情况下无法正常工作。但我们不会使用 Eclipse,而且这条信息很困扰我,所以你需要在名为.profile的隐藏文件末尾添加一行。(文件名以点/句号开头,所以它是隐藏的。)

  1. 启动你的文本编辑器。点击“打开”(Open)。

  2. 确保你处于“主目录”(Home)下。

  3. 在“打开”窗口的任何地方点击右键,勾选“显示隐藏文件”(Show Hidden Files)。

  4. 打开名为.profile的文件。

  5. 在文件底部添加以下行:

unset JAVA_TOOL_OPTIONS

保存文件并关闭。你可能想再次点击“打开”并取消“显示隐藏文件”的勾选。

确保 JDK 安装正确

  1. 再次启动终端。

  2. 在提示符处输入javac -version。

你应该看到类似javac 1.8.0_91的响应。

  1. 在提示符处输入java -version。

你应该能看到类似openjdk version "1.8.0_91"的响应。

确保它们报告的是相同的版本号!如果不是,你可能安装了两个不同的(不兼容的)Java 版本,这将导致你在完成书中练习时遇到困难。

如果版本号不匹配,请卸载所有与 Java、JDK、JRE 相关的程序。如果安装了 Eclipse,也将其删除。删除所有与 Java 相关的内容并重新开始。

然而,如果两个命令都成功运行且没有任何错误,版本号也匹配,那么恭喜你!让这一切运行起来相当困难,你可能已经具备完成书中剩余内容的要素!

不过先不要进入下一个练习!我们应该习惯使用命令行进行导航,因为这将是你运行在书中编写的程序的方式。

命令行 (终端) 导航

你应该创建一个新文件夹来存放所有的代码。读完这本书后,你将会有至少 100 个新文件,如果它们都在同一个地方会更好。

输入ls然后按回车键。(那是“list”的字母“L”)。此命令将列出当前文件夹/目录的内容。

cd命令意味着“更改目录”,它将带你进入“Documents”(文档)文件夹,以便未来的命令在该处生效。

注意,你的提示符会变化,显示你现在位于新文件夹内部。例如,当我第一次打开终端时,我的提示符是

mitchell@localhost:~$

一旦我进入“Documents”目录,提示符就会变为

mitchell@localhost:~/Documents$

进入后,你应该再次输入ls来查看你的 Documents 目录内容。现在我们准备好创建文件夹了。

mkdir意味着“创建目录”,它将在当前位置创建一个新文件夹。此后输入ls应该会看到新目录已经在那里了。你可以根据喜给文件夹起一个除“javahard2”以外的名字。每台电脑只需要创建一次这个文件夹。

进入javahard2文件夹。之后,输入ls。

cd javahard2
ls

其他的只会只会让你困惑,所以请坚持计划。

练习 1:操作对象

无法避免的是,Java 是一种面向对象的语言。在原版的《Java 径之路》(Learn Java the Hard Way)中,我尝试尽可能避免 Java 中面向对象的部分,但某些部分还是溜了!

在本章中,我们将观察 Java 在创建和操作对象时使用的一些常用模式,我还会简要回顾如何从命令提示符或终端窗口编译并执行 Java 程序。

将以下代码输入到名为WorkingWithObjects.java的单个文件中,并将其放入你可以从终端窗口访问的文件夹中。

如果其中某些内容让你感到陌生,不要担心。我们只是在观察模式,细节在这个作业中并不那么重要。特别是,你可能从未用过ArrayList或Random,这完全没关系。

如果你觉得这段代码极其难以理解,那么你可能遇到了问题。也许你不知道什么是if语句,或者System.out.println,或者你从未用过for循环。在这种情况下,这本书对你来说可能太难了。你应该回去先读一本更简单的书,等当你对 Java 基础非常熟悉后再回到这里。

无论如何,输入下面的代码,然后我会提醒你如何从终端编译它。记住,对于这些练习你不应该使用任何 IDE。还要记住不要输入每行前面的行号;那些数字是为了方便以后讨论代码。

import java.io.File;
import java.util.ArrayList;
import java.util.Random;
import java.util.Scanner;

public class WorkingWithObjects {
    public static void main( String[] args ) throws Exception {
        File f = new File("datafiles/phonetic-alphabet.txt");

        if ( f.exists() == false ) {
            System.out.println( f.getName() + " not found in this folder. :(");
            System.exit(1);
        }

        ArrayList<String> words = new ArrayList<String>();
        Scanner alpha = new Scanner(f);

        System.out.print("Reading words from \"" + f.getPath() + "\"... ");
        while ( alpha.hasNext() ) {
            String w = alpha.next();
            words.add(w);
        }
        alpha.close();
        System.out.print("done.\n\t");

        Random rng = new Random();
        rng.setSeed(12345);
        // rng.setSeed(23213);

        for ( int n=0; n<3; n++ ) {
            int i = rng.nextInt( words.size() );
            String s = words.get(i);
            System.out.print( s.toLowerCase() + " " );
        }
        System.out.println();
    }
}

WorkingWithObjects.java

一旦你输入了代码,我假设你已经将WorkingWithObjects.java文件保存到了名为javahard2的文件夹中。如果你将其保存到了不同的文件夹,请替换下面的名称。

打开终端窗口并进入到javahard2文件夹:

(如果你在使用没有 PowerShell 的非常老旧的 Windows 版本,那么你必须输入dir。不过其他人都可以输入ls。)

希望你会看到一个包含内容的输出列表:

这意味着你处于正确的路径。然后,你将使用 Java 编译器(称为javac)来编译该文件:

如果此命令给出关于javac本身的错误,那么你跳过了练习 0!回去确保 JDK 已安装并在PATH路径中,然后回来!

假设你注意细节并按照我告诉你的那样操作,此命令将运行几秒钟,然后终端将再次显示提示符,而不显示其他任何内容。

然而,如果你犯了错误,你将看到一些错误。如果你有任何错误消息,修复它们,然后save你的代码,回到终端再次编译。

最终你应该能够成功,它将会在没有任何错误也没有任何消息的情况下完成编译。执行目录列表操作,你应该看到字节码文件出现在代码旁边的文件夹中:

现在我们已经成功创建了字节码文件,可以通过运行名为java的 Java 虚拟机(JVM)程序来运行(或“执行”)它:

你应该看到的内容

好了,既然那个已经运行了,让我们谈谈在这个练习中看到的一些模式。

第 1 行导入了一个“库”。java.io.File包含了名为File的对象/类的定义。一旦导入了它,我们就可以在代码中创建File对象并调用这些对象内部定义的方法。

接三行导入了另外三个库,每个库都定义了一个对象。某些编程语言将这些导入的内容称为“模块”而不是库。

在上一章中,我们从 Java 的“标准库”中导入了一些对象:这些由语言创建者预构建的类和方法的集合。

在本章中,我们将创建自己的对象,每个对象将包含一个方法。

请输入以下代码并使其通过编译。将其保存在你的javahard2文件夹中,命名为OldMacDonald.java。

 1 class Cow {
 2     public void moo() {
 3         System.out.println("Cow says moo.");
 4     }
 5 }
 6 
 7 class Pig {
 8     public void oink() {
 9         System.out.println("Pig says oink.");
10     }
11 }
12 
13 class Duck {
14     public void quack() {
15         System.out.println("Duck says quack.");
16     }
17 }
18 
19 public class OldMacDonald {
20     public static void main( String[] args ) {
21 
22         Cow maudine = new Cow();
23         Cow pauline = new Cow();
24         maudine.moo();
25         pauline.moo();
26 
27         Pig snowball = new Pig();
28         snowball.oink();
29         snowball.oink();
30 
31         Duck ferdinand = new Duck();
32         ferdinand.quack();
33     }
34 }

OldMacDonald.java

你应该看到什么

第 1-5 行定义了一个名为Cow的对象。Cow类的定义包含了名为moo()的方法定义。注意第 2 行写的是public void moo(),而不是public static void moo()。除了main()之外,你将不再频繁使用static关键字了。

第 7 到第 11 行定义了一个名为Pig的类,包含一个oink()方法。第 13 到 17 行定义了一个名为Duck的类,它包含一个quack()方法。

第 19 到 34 行定义了与 Java 文件名匹配的类。注意在该文件中,OldMacDonald类前面带有public关键字,但其他任何类都没有。在 Java 中,每个文件只能有一个公共类,且该公有类的名称必须与文件名匹配。

该类包含main()方法,这是 Java 虚拟机执行文件时开始运行的地方。OldMacDonald类列在文件中其他类之后,但如果类的顺序不同,运行效果也是那样的。

当我们运行这个程序时,执行从main()方法的第一行开始。文件中的任何其他代码只有在从main()内部调用时才会执行。

第 22 和 23 行实例化两个Cow对象。分别调用moo()方法。这会导致执行跳转到第 3 行,运行方法内部的println()语句,然后返回下方。

在第 27 行,我们创建了一个Pig对象的实例,然后调用了两次它的oink()方法。在第 31 行,我们实例化了一个Duck对象并在下一行调用了它的唯一方法。

然后在第 33 行,我们遇到了 main() 方法的右大括号,这通常意味着程序的结束。

你看到了吗?定义你自己的对象并不难,一旦你实例化了一个对象,调用它们的方法也非常简单。

练习 3:在单独文件中定义对象

在上一个练习中,我们定义了三个对象(如果算上包含 main() 的那个就是四个),但它们都实现在同一个文件中。这通常不是标准做法。通常 Java 将每个类的实现放在它自己的文件中,然后会有另一个文件只持有 main() 方法,用于实例化对象并让它们运行。这个类通常被称为“驱动”(driver)类,因此通常我会在文件名中加入“Driver”一词。

输入以下代码,并将每个类放在单独的文件中,命名所示。将它们全部保存在同一个文件夹中。

1 public class OldMacCow {
2     public void moo() {
3         System.out.println("Cow still says moo.");
4     }
5 }

OldMacCow.java

在你输入并保存 OldMacCow.java 之后,你应该尝试编译它,以确保在继续之前没有犯任何错误。

1 public class OldMacDuck {
2     public void quack() {
3         System.out.println("Duck still says quack.");
4     }
5 }

OldMacDuck.java

你是否不小心尝试运行了这个文件或第一个文件?它们一个都不包含 main() 方法,因此单独运行它们是行不起的。

 1 public class OldMacDriver {
 2     public static void main( String[] args ) {
 3         OldMacCow maudine = new OldMacCow();
 4         OldMacCow pauline = new OldMacCow();
 5         maudine.moo();
 6         pauline.moo();
 7 
 8         OldMacDuck ferdinand = new OldMacDuck();
 9         ferdinand.quack();
10     }
11 }

OldMacDriver.java

到目前为止,我们只查看了对象内部的方法。但大多数对象内部也包含变量,这些变量被称为“字段”(有时称为“实例变量”)。

此程序将演示如何访问对象中的字段。

输入此代码并将其保存为独立文件,文件名按指示所示。

1 public class TVActor {
2     String name;
3     String role;
4 }

TVActor.java

然后输入这段代码并将其保存到与第一个文件相同的文件夹中。

 1 public class TVActorDriver {
 2     public static void main( String[] args ) {
 3         TVActor a = new TVActor();
 4         a.name = "Thomas Middleditch";
 5         a.role = "Richard Hendricks";
 6 
 7         TVActor b = new TVActor();
 8         b.name = "Martin Starr";
 9         b.role = "Bertram Gilfoyle";
10 
11         TVActor c = new TVActor();
12         c.name = "Kumail Nanjiani";
13         c.role = "Dinesh Chugtai";
14 
15         System.out.println( a.name + " played " + a.role );
16         System.out.println( b.name + " played " + b.role );
17         System.out.println( c.name + " played " + c.role );
18     }
19 }

TVActorDriver.java

记住,你只需要 编译包含 main() 方法的那个文件,尽管在完成每个文件后都测试编译每个文件是一个好主意,以确保在继续之前它是正确的。

你应该看到的内容

因此,类 TVActor 包含两个实例变量,它们都是 Strings 类型。第一个变量名为 name,第二个变量名为 role。

它们被称为“实例”变量,是因为对象的每个实例(副本)都会获得变量的自己的副本。

也就是说,在第 11 行结束之后,创建了三个 TVActor 类的实例。public class TVActor 相当于一种模式、配方或蓝图,而第 3 行在实例化对象时实际上缝合了衣服、烹饪了配方或构建了结构。

因此,名为 a 的实例拥有 name 变量的副本和 role 变量的副本。我们可以如第 4 行和第 5 行所示为 a 的这些变量副本赋值,尽管我们在书中后面会看到这被认为是不好的风格。

第 7 行创建了类的第二个实例,它拥有自己的实例变量副本。

第 11 行创建了类的第三个实例,它也拥有自己的变量副本。因此,到第 14 行,内存中至少浮着九个对象:三个 TVActor 对象和六个 String 对象(每个 TVActor 两个)。

练习 5:编程范式

在我深入研究面向对象编程 (OOP) 细节之前,查看 OOP 风格代码与其它编程范式中实现相同程序之间的区别可能会很有用。

我创建了一个执行四件事的小程序:

  1. 允许人类输入消息。

  2. 反转消息中字符的顺序。

  3. 对每个单词进行“驼峰命名”(Camel-case)。也就是说,将“Hello how are you” 转换为“HelloHowAreYou”。

  4. 显示结果。

首先,这是一个使用 Java 尽可能允许的简单单体风格的程序。除非你真的想写,否则不需要输入这个程序。

 1 import java.util.Scanner;
 2 
3 public class StringFunMonolith {
 4     public static void main( String[] args ) {
 5         Scanner keyboard = new Scanner(System.in);
 6 
 7         // input it
 8         System.out.print("Enter a message: ");
 9         String msg = keyboard.nextLine();
10 
 11         // reverse it
 12         String rev = "";
 13         for ( int i=msg.length()-1; i>=0; i-- )
 14             rev += msg.substring(i,i+1);
15 
 16         // camel-case it
 17         String lower = rev.toLowerCase();
 18         String[] words = lower.split(" ");
 19         String result = "";
 20         for ( String w : words )
 21             result += w.substring(0,1).toUpperCase() + w.substring(1);
22 
 23         // display it
 24         System.out.println(result);
 25     }
 26 }

StringFunMonolith.java

你应该看到的内容

因此,第 8-9 行输入消息,第 12-14 行反转它,第 17-21 行驼峰命名,第 24 行显示它。如果你不懂驼峰名的细节,不用太担心。

接下来,我用“函数式”风格编写了相同的程序。函数式风格只使用具有少量输入和一个输出的函数。函数之间通过输入和输出共享信息,否则不共享信息。再次,除非你想写,否则没有意义写这个版本。

 1 import java.util.Scanner;
 2 
3 public class StringFunFunctional {
 4     public static void main( String[] args ) {
 5         Scanner keyboard = new Scanner(System.in);
 6 
 7         // input it
 8         System.out.print("Enter a message: ");
 9         String msg = keyboard.nextLine();
10 
 11         // reverse it
 12         String rev = "";
 13         for ( int i=msg.length()-1; i>=0; i-- )
 14             rev += msg.substring(i,i+1);
15 
 16         // camel-case it
 17         String lower = rev.toLowerCase();
 18         String[] words = lower.split(" ");
 19         String result = "";
 20         for ( String w : words )
 21             result += w.substring(0,1).toUpperCase() + w.substring(1);
22 
 23         // display it
 24         System.out.println(result);
 25     }
 26 }

StringFunFunctional.java

第 1-9 行与之前相同,因为没有其他方式从人类那里获取输入。

但你可以看到在第 12 行,消息(msg)被传递给名为 reverse 的函数,结果被放回 msg,覆盖了原来的值。这更容易理解一点。

第 21-27 行是 reverse 函数本身。这需要额外的设置来命名函数和参数,以及额外的一行来“返回”最终结果。不过,我们可以把输入命名为 s 而不必关心它是 msg。

第 15 行是 camelCase 函数调用,第 29-36 行是函数定义。注意在第 29 行我们可以自由将参数命名为 s 而不必担心程序的其他部分。

main() 中的变量真的叫 s 吗?*不重要。

其他函数使用变量 s 吗? 不重要。

返回值给了哪个变量? 不重要。我们可以命名为 result 或 rev 或任何适合我们的。

函数式编程的另一个优点是,由于每个函数都接收输入并返回输出,函数可以非常紧凑地链起来。

这是相同的函数式版本,但函数全部嵌套在一起。

 1 import java.util.Scanner;
 2 
3 public class StringFunFunctionalShort {
 4     public static void main( String[] args ) {
 5         Scanner keyboard = new Scanner(System.in);
 6 
 7         System.out.print("Enter a message: ");
 8         System.out.println(camelCase(reverse(keyboard.nextLine())));
 9     }
 10 
 11     public static String reverse( String s ) {
 12         String rev = "";
 13         for ( int i=s.length()-1; i>=0; i-- )
 14             rev += s.substring(i,i+1);
15 
 16         return rev;
 17     }
 18 
 19     public static String camelCase( String s ) {
 20         String[] words = s.toLowerCase().split(" ");
 21         String result = "";
 22         for ( String w : words )
 23             result += w.substring(0,1).toUpperCase() + w.substring(1);
24 
 25         return result;
 26     }
 27 }

StringFunFunctionalShort.java

你从外向内读取第 8 行。最内层的内容首先发生:调用 keyboard.nextLine()。一旦执行完毕,它会返回一个 String,我们立即将其传递给 reverse(),然后是 camelCase(),最后是 println()。

有些程序用函数式风格编写会比较困难,但当它有效时,看起来会非常漂亮且整洁。

顺便说一下,电子表格程序(如微软的 Excel、苹果的 Numbers 或 LibreOffice 的 Calc)中的公式是用函数式风格编写的。这是一种棘手的编码方式,如果你曾经为了写对一个公式而苦苦,你就知道了!

图形处理着色器(如 OpenGL 或 Direct3D 中的)通常也是用函数式风格写的,这使得它们非常适合并行处理。

好了,最后让我们用面向对象的风格来写这个相同的小程序。我们将像通常一样使用两个文件:一个包含我们的类/对象,另一个包含驱动程序。这些这些是你需要输入的代码。

 1 public class StringFunObject {
 2 
 3     String message;
 4 
 5     public void setMessage( String s ) {
 6         message = s;
 7     }
 8 
 9     public String getMessage() {
10         return message;
11     }
12 
13     public void reverse() {
14         String rev = "";
15         for ( int i=message.length()-1; i>=0; i-- )
16             rev += message.substring(i,i+1);
17 
18         message = rev;
19     }
20 
21     public void camelCase() {
22         String[] words = message.toLowerCase().split(" ");
23         String result = "";
24         for ( String w : words )
25             result += w.substring(0,1).toUpperCase() + w.substring(1);
26 
27         message = result;
28     }
29 }

StringFunObject.java

这个文件里有一些新东西。在第 3 行有一个实例变量 / 字段,就像你在上一个练习中学的那样。

注意在第 6 行,有一个方法将参数 s 的拷贝存储到一个名为 message 的变量中。这个 message 在哪里声明的?它是字段。我们将在后面的章节中详细讨论它,所以现在不用太担心。

只需记住,对于这段代码,任何引用“message” 的地方,它都是实例变量。所有其他变量都是“局部”的,这意味着它们只存在于定义它们的方法内部。

所以,这里是驱动程序的代码。这个也输入。

import java.util.Scanner;

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

        // input it
        System.out.print("Enter a message: ");
        String msg = keyboard.nextLine();

        StringFunObject sfo = new StringFunObject();
        sfo.setMessage(msg);
        sfo.reverse();
        sfo.camelCase();

        // display it
        System.out.println( sfo.getMessage() );
    }
}

StringFunOODriver.java

你应该看到的(提醒)

(这里再次提供了输出,这样你就不需要向上滚动了。)

这是非常典型的面向对象代码。第 11 行声明并实例化了一个对象。第 12 行调用该对象的 setMessage() 方法,并将消息作为参数传递进去。然后接下来的几次变化发生在对象内部:消息被反转,然后消息被转换为驼峰命名法。

最后在驱动程序的第 17 行,我们调用该对象的 getMessage() 方法,它向我们返回修改后的 String 用于打印。

也许你不喜欢面向对象的风格。也许你认为单体版本更好,或者也许认为函数式版本更好。

你知道吗?我同意你的观点。面向对象编程并不非常适合这种微型程序。当程序规模很大且复杂时(例如 10,000 行代码或更多),OOP 的效果最好。

每当我写一个程序来帮助自己自动化某些烦人的事情时,如果代码只有 500 行或更少,我几乎不会用面向对象的风格编写。

如果代码只有 10-50 行,我会用单体风格编写。如果在 50-500 行,我会使用函数;如果比这大得多,我将开始使用面向对象风格。

遗憾的是,这本书的其余部分将会有点奇怪。即使是只有 20 行的微型程序,我也将使用面向对象的风格。这会很糟糕。你可能不喜欢它。你可能会想:“如果他只是……,这个程序就会简单得多。”

但我不能只通过编写巨大的、完美的 1,000 行代码来教你面向对象编程。

练习 6:访问字段

在这个练习中,我们将更深入地观察拥有字段的对象。这里是对象的源码:

public class Repeater {

    String phrase;
    int repeats;

    public void setValues( String p, int r ) {
        phrase = p;
        repeats = r;
    }

    public String getRepeatedPhrase() {
        String result = "";
        for ( int i=0; i<repeats; i++ )
            result += phrase;
        return result;
    }
}

Repeater.java

这个类有两个字段,一个名为 phrase 的 String 和一个名为 repeats 的 int。记住,如果我们在驱动中实例化多个版本,每个实例将拥有自己的。

“实例变量”是定义在类中但在方法之外的变量。“字段”只是类成员的通用名称。它们的意思几乎相同,所以我交替使用。

以防你没从上一个练习中记得,这些属于整个类。

第 6-9 行实现了一个名为 setValues() 的方法。它从外部世界接收两个值:一个称为 p 的 String 和一个称为 r 的整数。

在第 7 行,我们将 p 的值拷贝存储到实例变量 phrase 中,在下一行我们将 r 的值存储到 repeats 中。在第 9 行之后,参数变量 p 和 r 就消失了;这些名字在方法之外没有任何意义。这个方法是 void,所以它不向外部世界返回任何值。

第 11 到 16 行包含了名为 getRepeatedPhrase() 方法的实现,它构建并返回一个 String 值的拷贝。

在第 12 行,我们从一个新 String result 开始,它被初始化为“空字符串”(我们称之为一个没有字符的 String 值)。

第 13 行设置了一个 for 循环,它将执行循环体 repeats 次。也就是说,如果 repeats 的值是 4,循环将运行四次。循环中没有大括号,所以循环的主体只有单行:result += phrase;,这会将 phrase 值的拷贝添加到 result 中现有内容的末尾。

当循环结束,result 现在包含了多个 phrase 的拷贝。

最后,在第 15 行,本地变量 result 中的 String 副本被返回到外部世界。该方法返回一个 String,这就是为什么方法的第一行写的是 public String getRepeatedPhrase() 而不是 public void getRepeatedPhrase()。

(注意,该方法并没有*返回变量 result 本身;它仅仅返回了该变量中 value 的副本。)

好了,这里是驱动代码:

import java.util.Scanner;

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

        System.out.print("Enter a message: ");
        String msg = keyboard.nextLine();
        System.out.print("Number of times: ");
        int n = keyboard.nextInt();

        PhraseRepeater pr = new PhraseRepeater();
        pr.setValues(msg, n);
        System.out.println( pr.getRepeatedPhrase() );
    }
}

PhraseRepeaterDriver.java

你应该看到的内容

如果你已经学了一段时间 Java,那么驱动程序的前十行非常直观:它们允许人类输入一些值,这些值被存储在本地变量 msg 和 n 中。

然后在第 12 行,我们实例化 PhraseRepeater 对象的副本,并将其引用存储在变量 pr 中。

接着在第 13 行,我们调用该对象的 setValues() 方法,传入我们本地变量的副本。驱动程序并不知道对象内部的实例变量命名为 phrase 和 repeats。驱动程序不在乎它们叫什么。

驱动程序不知道 setValues() 方法的参数会被命名为 p 和 r。这对驱动程序来说没有区别。只有对象本身关心这些变量被称为什么。

这种“不在乎”的态度是面向对象编程使得编写和调试非常大型复杂程序变得更容易的原因之一。

无论如何,驱动程序最后一行有用的代码是第 14 行,它调用了 getRepeatedPhrase() 方法。该方法返回一个 String,而返回的 String 被交给 println() 用于……打印。

注意 setValues() 方法改变了对象内部的某些内容。在调用方法后,对象中的字段发生了变化。因此,以某种方式改变对象内部状态的方法通常被称为“修改方法”(modifier methods)或“变器方法”(mutator methods)。

另一方面,getRepeatedPhrase() 方法没有改变对象内部的任何内容;两个实例变量都被使用了,但没有被修改。不过该方法确实返回了一个值,让你了解对象的内部状态。这样的方法通常被称为“访问方法”(accessor methods),因为它们允许驱动程序以某种方式访问字段。

练习 7:封装与自动化测试

这个练习的 OOP 部分并不比上一个练习更难。有两个字段被变器方法改变,并被访问器方法访问(但不改变)。

但在驱动程序中……哦,你将会看到的。

public class SquareRootFinder {

    double n;
    int iterations;

    public void setNumber( double number ) {
        n = number;
        iterations = 7;
        if ( n < 10 )
            iterations++;
    }

    public double getRoot() {
        double x = n/4;
        for ( int i=0; i<iterations; i++ ) {
            x = (x+(n/x))/2.0;
        }
        return x;
    }
}

SquareRootFinder.java

所以,这里没什么新奇。SquareRootFinder 类有两个实例变量 n 和 iterations。setNumber() 方法允许类的用户传入一个值,该值会被复制到字段 n 中。

然后 getRoot() 方法进行了一些复杂的计算,以计算该数字平方根的估计值。它有效吗?有效。是如何工作的?这就是编程的奇妙之处。有时你并不知道。

继续输入驱动代码,然后我们继续这个思路。

import java.util.Scanner;

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

        SquareRootFinder sqrt = new SquareRootFinder();

        do {
            System.out.print("Enter a number (or <=0 to quit): ");
            n = keyboard.nextDouble();

            if ( n > 0 ) {
                sqrt.setNumber(n);
                System.out.println( sqrt.getRoot() );
            }
        } while ( n > 0 );
    }
}

SquareRootDriver.java

你应该看到的内容

SquareRootFinder 类有两个实例变量 n 和 iterations。setNumber() 方法允许类的用户传入一个值,该值会被复制到字段 n 中。

然后 getRoot() 方法进行了一些复杂的计算来计算该数字平方根的估计值。它有效吗?有效。是如何工作的?这就是编程的奇妙之处。有时你并不知道。

那么,创建类的人是如何知道 iterations 是正确的?我测试过。测试了很多。不是只了几个数字,而是:

public class SquareRootTester {
    public static void main( String[] args ) {
        SquareRootFinder sqrt = new SquareRootFinder();

        double max = 0, maxN = 0;
        double fakeroot, realroot, diff;

        System.out.print("Testing algorithm... ");
        for ( double n = 0; n<=2000; n += 0.01 ) {
            sqrt.setNumber(n);
            fakeroot = sqrt.getRoot();
            realroot = Math.sqrt(n);
            diff = Math.abs( fakeroot - realroot );
            if ( diff > max ) {
                max = diff;
                maxN = n;
            }
        }

        if ( max > 0.000001 ) {
            System.out.println("FAIL");
            System.out.println("Worst difference was " + max + " for " + maxN );
        }
        else
            System.out.println("PASS");
    }
}

SquareRootTester.java

如果运行测试程序你会看到什么

在我的测试程序中,我将 getRoot() 方法的输出与 Java 内置的 Math.sqrt() 计算的真实平方根进行比较。我测试了从 0 到 2000 的每一个数字,步长为 0.01。对于每个数字,我找到“我的”平方根与真实平方根之间的绝对差,如果最大差异大于 0.000001,那么我会抛出一个错误。

反复运行此程序允许我测试不同的内容。变量 x 是我对平方根的初始估计;我最初将 x 设置为 n,但随着 n 变大,这开始变得不准确。将 x 起于 n/2 更好,但这样对于非常小的 n 值就会不准确。

我发现的最佳折中方案是从 n/4 开始猜测,这使得我在范围内的每个数字在七次迭代内都能得到足够接近的结果。除了 0 到 1 之间的 n 值(此时 \sqrt{n} > n)。我最终放弃了,并决定给小数字多增加一次迭代,以补偿我在这些情况下糟糕的初始估计。

通常情况下(至少对于设计良好/管理良好的软件而言),创建该类的人或团队中的其他成员会为该类设计一个“测试套件”。例如,SQLite(可以嵌入到其他软件中的数据库)以测试非常充分而闻名。引用自《SQLite 如何进行测试》:

SQLite 的可靠性和健壮性部分通过详尽且仔细的测试得以。

截至 3.8.10 版本,SQLite 库包含约 94.2 KSLOC 的 C 代码。(KSLOC 意味着数行“源代码”,换句话说,就是排除空行和注释的代码行。)相比之下,项目的测试代码和测试脚本是其 971 倍——91515.5 KSLOC。

每当 SQLite 的维护者修复漏洞或进行改进时,他们都会运行完整的测试套件,以确保他们没有意外破坏其他内容!这是一个好主意,而且 OOP 强制你使用的模块化设计使得这种测试成为可能。

练习 8:未能进行封装

在上一个练习中,我们查看了极端测试以及封装如何使这一切成为可能。在这一次,我们将看到在字段和方法上可以进行的一些权衡。

public class SphereCalc {
    double radius;

    public void setRadius( double r ) {
        radius = r;
    }

    public double getRadius() {
        return radius;
    }

    public double getSurfaceArea() {
        return 4*Math.PI*radius*radius;
    }

    public double getVolume() {
        return 4*Math.PI*Math.pow(radius,3) / 3.0;
    }
}

SphereCalc.java

这个对象与最近几个练习中的非常相似。这次有一个实例变量、一个变器方法(setRadius())和三个访问器方法。(球的表面积为  4 \pi r² ,球的体积为  \frac{4}{3} \pi r³ 。)

 1 public class SphereCalcTester {
 2     public static void main( String[] args ) {
 3 
 4         SphereCalc c = new SphereCalc();
 5 
 6         c.setRadius(5);
 7         if ( isNear(c.getSurfaceArea(), 314.159265359) )
 8             System.out.println("PASS: surfaceArea for " + c.getRadius());
 9         else
10             System.out.println("FAIL: surfaceArea not what was expected!");
11         if ( isNear(c.getVolume(), 523.598775598) )
12             System.out.println("PASS: volume for " + c.getRadius());
13         else
14             System.out.println("FAIL: volume not what was expected!");
 15 
 16         c.setRadius(0.1);
 17         if ( isNear(c.getSurfaceArea(), 0.125663706) )
 18             System.out.println("PASS: surfaceArea for " + c.getRadius());
 19         else
20             System.out.println("FAIL: surfaceArea not what was expected!");
 21         if ( isNear(c.getVolume(), 4.18879E-3) )
 22             System.out.println("PASS: volume for " + c.getRadius());
 23         else
24             System.out.println("FAIL: volume not what was expected!");
 25 
 26 
 27     }
 28 
29     public static boolean isNear( double a, double b ) {
30         return Math.abs(a-b) < 1E-9;
31     }
32 }

SphereCalcTester.java

你应该看到什么

这显然是一个测试器,而不仅仅是一个简单的驱动程序;我有通过或失败的测试。编写这些内容相当烦人,但我想向你们展示这个思路,而不让它变得太疯狂,所以我不得不用我的计算器配合几个测试用例来查看结果应该是怎样的。在未来的练习中,我们将看到一种处理很多测试更好的方法。

现在,它看起来可能没那么坏了。它可能就像测试器中的第 16 行一样。与其写:

c.setRadius(0.1);  // <-- 为什么要写这个...  
c.radius = 0.1;    // <-- 明明写这个要**容易得多**?

这样效率更高,对吧?既然你可以直接给变量赋值,谁又想去调用一个方法呢?!我这哥们才不想!

不管如何,希望这说明了那个“问题”。至于解决方案,你需要在下一个练习中再回来。

练习 9:私有字段和构造函数

假设你已经完成了上一个练习,你已经看到,如果直接修改类的字段而不是通过它们的方法,某些类将无法正常工作。在本次练习中,你将学习如何停止这种行为。

你还将学习一些能让别人更容易use 你的类并且让它更安全的东西。

 1 public class SphereCalc3 {
 2     private double radius, area, volume;
 3 
 4     public void setRadius( double r ) {
 5         radius = r;
 6         area = 4*Math.PI*r*r;
 7         volume = 4*Math.PI*Math.pow(r,3) / 3.0;
 8     }
 9 
10     public double getRadius()      { return radius; }
11     public double getSurfaceArea() { return area;   }
12     public double getVolume()      { return volume; }
13 }

SphereCalc3.java

这基本上是 SphereCalc2 的缩减版。当你在方法中所做的只是返回单个变量的值时,Java 程序员通常会将所有的“getter”方法写在同一行,就像我在第 10-12 行写的那样。

所以,唯一有趣的变化是在第 2 行的开头:关键字 private。

从你写第一个 Java 程序开始,多亏于 public static void main,一直在把东西设为 public,所以也许你已经怀疑了。

实例变量通常被设为 private。事实上,几乎总是如此。(你也可以将方法指定为私有而不是公共,但我们一段时间内不会看到那例子。)

私有意味着“别碰!”。任何私有变量都无法在定义它的类之外的任何地方被访问。

在Inside类内部,私有变量的工作方式与我们一直使用的字段完全一样;类内部任何方法都可以自由地更改或访问私有变量。

“public”和“private”在 Java 中被称为“访问级别修饰符”。当你漏掉它们时(就像我们从第 4 练习以来对字段所做的那样),默认访问权限被称为“包级私有”(package-private),这意味着它们对于同一个“包”内的任何内容都是可访问的。我们到为止编写的所有类都在同一个包中,但在接近书末之前,我们不会对此做任何处理。

所以对于我们的目的,“public”变量和没有no包修饰符的变量是等价的:它们可以从类的outside被访问或更改。这是一件坏事。

Java 程序员对将字段设为私有通常非常严格。事实上,在大学高级计算机科学(Advanced Placement Computer Science)考试中,未能将实例变量标记为私有是一个非常严重的错误,以至于即使你的解决方案其他所有部分都是完美的,它也会让你丢失该题目超过 10% 的分值!

总之,回到主点。现在字段是私有的,使用 SphereCalc3 类的代码没有选择;它们only能通过 setRadius() 方法更改半径。尝试直接这样做甚至无法编译。

SphereCalc3 sph = new SphereCalc3();  
sph.radius = 7;   // <-- 这甚至无法编译。  
sph.setRadius(7); // 这当然工作得很好。  
System.out.println( sph.radius ); // 仍然无法编译

如你所见,即使你没有尝试change 实例变量,这种情况也适用。private 不仅仅防止修改字段,还防止访问它。这就是为什么你必须为每个你想要可访问的变量编写公共的“getter”方法。

某些编程语言(如 C#)处理这个问题的方法略有不同;你可以将变量标记为只读,这样它们就无法从类外部被changed(只能通过方法),但仍然可以被读取。其他语言有一种方法可以让look 像你正在直接访问变量,但它们实际上在秘密运行一个方法来设置或读取变量。

在 Java 中,带有 setter 和 getter 的私有字段是唯一的良好解决方案。

现在,在我们继续之前,还有一个潜在的问题。必须总是记住在做任何其他事情之前调用 setter 方法有一点烦人。看一看过去几次练习中的例子:

SphereCalc sph = new SphereCalc();  
sph.setRadius(5);  
// 现在可以安全地使用 SphereCalc 中的其他方法了  
//
SquareRootFinder srf = new SquareRootFinder();  
srf.setNumber(n);  
// 现在可以安全使用 SquareRootFinder 中的其他方法了  
//
PhraseRepeater pr = new PhraseRepeater();  
pr.setValues(msg, n);  
// 现在可以安全使用 PhraseRepeater 中的其他方法了  
//
StringFunObject sfo = new StringFunObject();  
sfo.setMessage(msg);

这不仅很烦人,而且不safe,但忘记调用 setNumber() 或 setValues() 将无法工作。幸运的是,有一个解决方案!一种特殊的“构造函数”。这是一个例子:

 1 public class SphereCalc4 {
 2     private double radius, area, volume;
 3 
 4     public SphereCalc4( double r ) {
 5         radius = r;
 6         area = 4*Math.PI*r*r;
 7         volume = 4*Math.PI*Math.pow(r,3) / 3.0;
 8     }
 9 
10     public void setRadius( double r ) {
 11         radius = r;
 12         area = 4*Math.PI*r*r;
 13         volume = 4*Math.PI*Math.pow(r,3) / 3.0;
 14     }
 15 
16     public double getRadius()      { return radius; }
17     public double getSurfaceArea() { return area;   }
18     public double getVolume()      { return volume; }
19 }

SphereCalc4.java

第 4-8 行是构造函数的实现。注意第 4 行与 setRadius() setter 变器方法不同,构造函数不是void。

此外注意构造函数的名称与类本身相同。这是要求的。如果你做到这两点,那么与其需要记住调用某个特殊方法为实例变量传递初始值,你可以在实例化对象时传递它们。你本来也必须这样做!例如:

SphereCalc4 sc = new SphereCalc4( 5 );  
// 立即安全地使用 SphereCalc 中的其他方法了  
SquareRootFinder srf = new SquareRootFinder(n);  
// 同上  
PhraseRepeater pr = new PhraseRepeater(msg, n);  
StringFunObject sfo = new StringFunObject(msg);

这在实现类时增加了一些工作量,因为你通常需要写一个构造函数并also编写你的 setter 方法。但它让操作对象变得更容易且更安全。

好了,现在就到此为止。在接下来的章节中我们将看到更多的构造函数。

练习 10:使用数组进行自动化测试

两个练习前,SphereCalcTester 对我们的对象测试得还不错,但每个测试都需要花费大量的代码,并且有很多重复代码。让测试代码尽可能易于编写和自动运行是非常重要的。否则,你可能会被诱不去测试你的代码,而这不会导致任何好的结果。

所以这里有一个针对 SphereCalc4 的测试器示例,它使得在不增加任何额外代码的情况下添加额外的测试变得容易得多!

这段代码使用双精度浮数数组来存储预期的输入和输出,且每个数组中的顺序相同,因此 areas[0] 存储半径 inputs[0] 的预期面积输出,而 volumes[0] 存储对应的预期体积。这样使用的数组被称为“并行”数组。

这不是实现此功能的绝对最佳方法,但目前已经足够了。我们将在书的后面部分看到更好的测试技术。

 1 public class BetterTesting {
 2     public static void main( String[] args ) {
 3 
 4         double[] inputs = {
 5             5,
 6             0.1,
 7             3.3,
 8             20000,
 9             8
10         };
 11         double[] areas  = {
 12             314.159265359,
 13             0.125663706,
 14             136.84777599,
 15             5026548245.743669104,
 16             804.247719319
 17         };
 18         double[] volumes = {
 19             523.598775598,
 20             4.18879E-3,
 21             150.532553589,
 22             3.3510321638291125E13,
 23             2144.660584851
 24         };
 25         int passed = 0;
 26         double r, a, v, A, V;
 27 
 28         SphereCalc4 c = new SphereCalc4(0);
 29         for ( int i=0; i<inputs.length; i++ ) {
 30             r = inputs[i];
 31             a = areas[i];
 32             v = volumes[i];
 33 
 34             c.setRadius(r);
 35             A = c.getSurfaceArea();
 36             V = c.getVolume();
 37             if ( isNear(A, a) )
 38                 passed++;
 39             else {
 40                 System.out.print("FAIL: surfaceArea for radius " + r );
 41                 System.out.println("-- Expected " + a + ", got " + A);
 42             }
 43             if ( isNear(V, v) )
 44                 passed++;
 45             else {
 46                 System.out.print("FAIL: volume for radius " + r );
 47                 System.out.println("-- Expected " + v + ", got " + V);
 48             }
 49         }
 51 
 51         if ( passed == 2*inputs.length )
 52             System.out.println("PASS: All tests passed.");
 53     }
 54 
 55     public static boolean isNear( double a, double b ) {
 56         return Math.abs(a-b) < 1E-9;
 57     }
 58 }

BetterTesting.java

你应该看到的内容

第 4 到 24 行仅包含输入和输出的值。我喜欢这样每行列出它们,但 Java 并不在乎你把它们都放在同一行。如果你 确实 把它们都放在同一行,为了你的心理健康,最好添加额外的空格使得对应的条目对齐,如下所示:

double[] inputs  = {   5,           0.1,           3.3,         // 等
double[] areas   = { 314.159265359, 0.125663706, 136.84777599,  // 等
double[] volumes = { 523.598775598, 4.18879E-3,  150.532553589, // 等

在第 28 行,我们只创建了一个单一的 SphereCalc4 对象,每次都会被重用。然后遍历数组中的每个值进行循环。注意:如果你不小心让数组的长度不同,那么这段代码可能会崩溃。这就是并行数组的问题之一。

在第 30 到 36 行中,我们提取出预期的半径、面积和体积,并将它们放入命名良好但易于输入的变量中,然后告诉对象使用那个半径并从对象中获取计算出的面积和体积。因此,a是预期的面积,而A是根据我们的对象得到的实际面积。

然后我们可以直接使用之前的isNear()函数配合if语句,检查我们得到的结果是否符合预期。每通过一次“PASS”,我们就增加变量的值,但懒得打印任何内容。如果发生失败,我们会打印一条错误消息。

循环结束后,“通过”的次数应该是输入数量的两倍,所以我们检查这一点,如果一切正常,就打印一条总结消息。

还不错,对吧?增加更多的测试用就像添加更多用例一样简单。

这个(无聊的)Rectangle类有两个私有字段。(在将来,我可能不会费心去写“私有字段”这些字;称它们为“字段”基本上就意味着它们是私有的。这是 Java 程序员通常的办事方式。我本应该为它们编写 getter 和 setter,但这个练习侧重于构造函数,所以我把它们省略了。)

有两个构造函数。关于构造函数,有三点你需要记住:

  1. 它们的名称与类相同(在本例中为Rectangle)。

  2. 它们没有返回类型——甚至连“void”都没有。

  3. 它们的“工作”是确保所有必要的设置都已完成。这意味着初始化所有的实例变量,但有时也会发生其他事情。

我本想加上“构造函数必须是公共的”,但事实并非如此。它们通常是公共的,但有时你想要一个构造函数却不想让别人调用它,所以你会把其中一个构造函数设为私有之类的。

 1 public class RectangleDriver {
 2     public static void main( String[] args ) {
 3         // Rectangle r = new Rectangle();
 4         // r.length = 10;
 5         // r.width = 5;
 6 
 7         Rectangle r = new Rectangle(10, 5);
 8         System.out.println("The area is " + r.getArea());
 9     }
10 }

RectangleDriver.java

驱动程序中的第 3-5 行被注释掉了,但它们展示了几个练习前你为了实例化对象所会的操作。你必须构造对象本身(第 3 行),然后将值放入所有字段中。

现在我们的字段是私有的,而且我们现在有了构造函数,我们可以在驱动程序中仅用一行代码就完成这一切。这在第 7 行所示。

第 7 行发生的第一件事是等号的左侧。编译器创建一个Rectangle对象并将其命名为r。起初,它里面没有任何对象。

然后执行等号的右侧:它调用了参数构造函数,并将10和5作为参数传入。由于10在前,一个副本会被放入第一个参数(int类型l)中。然后,5的副本被传递给第二个参数(名为w的int)。

在第 9 行之前的后台秘密时刻,我们的Rectangle对象实际上在内存中被实例化了。然后通过第 9 行和第 10 行,构造函数将参数中的值复制到实例变量中。

一旦构造函数在第 11 行结束,后台还会发生另一件事,这在普通方法中不会发生:一个Rectangle对象的引用被返回给驱动程序。(我们将在稍后的练习中学习更多内容。)

这让我们回到了驱动程序的第 7 行,此时右侧刚刚完成。所以最后执行了该行的“等号”部分;从构造函数返回的((对象的)引用被存储在左侧的变量(r)中。

所以到此时,第 7 行已完全完成,对象已经实例化,字段也有了值。

这一行代码就做了这么多工作,对吧?这就是为什么构造函数很棒的原因。虽然它比手动做所有事情稍微复杂一点点,但对于使用我们类的人来说更好,而且更安全,因为它确保了在对象被使用之前所有设置都已完成。

你应该看到的内容

酷吧?

练习 13:字段的默认值

在使用变量之前确保它们有值是一个好主意。事实上,在驱动程序代码中,如果你尝试在初始化变量之前使用它,Java 甚至无法编译该文件。

但在类内部,Java 在后台执行了一些隐藏魔法来让这变得稍微容易。字段有默认值,即使你从未给它们设置过任何值,这些值也会被使用。

 1 public class Dog
 2 {
 3     private String name, breed;
 4     private int age;
 5     private double weight;
 6 
 7     public Dog() {}
 8 
 9     public Dog( String n, String b, int a, double w ) {
10         name = n;
11         breed = b;
12         age = a;
13         weight = w;
14     }
15 
16     public String getName()   { return name;   }
17     public String getBreed()  { return breed;  }
18     public int getAge()       { return age;    }
19     public double getWeight() { return weight; }
20 
21     public String getEverything() {
22         return name + " is a " + breed + ", " + age + " years old, "
23             + weight + " kg.";
24     }
25 }

Dog.java

这个对象希望非常直观。几个字段,两个构造函数(一个默认构造函数和一个带参数的构造函数),大量的 getter,以及一个返回适合打印的漂亮String的“get everything”方法。

唯一“奇怪”的地方在第 7 行:默认构造函数存在但是空的。在那些大括号之间没有代码!

不过它是可以编译的。现在我们看驱动程序,它应该也相当无聊。尝试预测输出将是什么。

 1 public class DogDriver {
 2     public static void main( String[] args ) {
 3         Dog a = new Dog();
 4         Dog pal = new Dog("Lassie", "Rough Collie", 3, 26.0);
 5         Dog spike = new Dog("Yeller", "Mastador", 5, 43.0);
 6 
 7         System.out.println(a.getEverything());
 8         System.out.println(pal.getName() + " is a " + pal.getBreed() + ".");
 9         System.out.println(spike.getName() + " is a " + spike.getBreed() + ".");
10 
11         System.out.println(pal.getEverything());
12         System.out.println(spike.getEverything());
13     }
14 }

DogDriver.java

驱动程序在 5 和 6 行创建了两个Dog对象并在 8 到 11 行调用了各种方法。

我认为,有趣的行是 3 和 12。

第 3 行使用默认构造函数实例化了一个Dog对象。然后第 12 行调用了一个返回所有字段值的方法。这可以编译,但这些字段是什么时候初始化的呢?!

那么使用了什么值呢?

答案是 Java 字段有默认值。(而且只有字段如此。Java 中其他类型的变量没有默认值,这就是为什么如果你在初始化变量之前使用它,编译器会报错。)

int类型的字段默认值为0。double默认值为0.0。boolean的默认值为false。任何对象都认为null,这个引用意味着“此对象尚未指向任何内容”。String是对象,所以在这种情况下,name和breed字段都被设置为null。

始终显式地为所有字段赋值是一个好主意,但知道如果你忘记了,Java 会填充合理的默认值是很好的。

练习 14:toString 和 this

今天的练习希望有点趣。没有多少新概念,希望它们不要太复杂。

我们将查看对象拥有的名为this的内置引用变量,以及所有对象继承的名为toString()的内置方法。在编写这些代码之前,你可能想回顾一下上练习(Dog.java)中的getEverything()方法,并查看练习 4 中TVActorDriver.java中的字段。

public class Tweet {
    private String created_at;
    private int favorite_count;
    private boolean favorited;
    private long id;
    private int retweet_count;
    private boolean retweeted;
    private String text;

    public Tweet( String created_at, int favorite_count, boolean favorited,
        long id, int retweet_count, boolean retweeted, String text )
    {
        this.created_at = created_at;
        this.favorite_count = favorite_count;
        this.favorited = favorited;
        this.id = id;
        this.retweet_count = retweet_count;
        this.retweeted = retweeted;
        this.text = text;
    }

    public String toString() {
        String out = "";
        out += text + "\n";
        if ( retweet_count > 0 ) {
            out += "Retweets: " + retweet_count + "  ";
        }
        if ( this.favorite_count > 0 ) {
            out += "Favorites: " + this.favorite_count + "\n";
        }
        else {
            out += "\n";
        }
        out += created_at + "\n";
        if ( this.retweeted ) {
            out += "[^v]";
        }
        else {
            out += "[  ]";
        }
        if ( favorited ) {
            out += "[*]";
        }
        else {
            out += "[ ]";
        }
        return out;
    }
}

Tweet.java

Twitter 是一家允许人们在网上发布非常短的消息以供他人阅读的公司。这些短消息被称为“推文”(tweets),并限制在 140 个字符以内。然而,让 Twitter 运行的代码复杂得多!在上面的代码中,我定义了一个 Java 版本的Tweet对象,它包含了 Twitter 定义的实际“推文”中存在的部分字段。(如果你想查看其余字段,需要浏览 Twitter API 概览。请注意 Twitter 是用 Ruby 和 Scala 编写的,而不是 Java,因此某些变量类型并不完全相同。)

这个对象中无聊的新点是,返回一个适合打印的直观字符串的方法被称为toString()而不是getEverything()。这样做是有原因的,我们很快就会看到。

不过,你会在第 13 行发现一些奇怪的地方。

从第 10 行开始的参数构造函数接收了七个传入参数。在之前的对象中,我只是给参数取了短名称——通常只是一个字母。但在这个对象中,有两个以‘f’开头的参数,另外两个以‘r’开头的参数。我担心尝试缩短它们会引起困惑。

因为参数的名称与字段的名称相同,它们“遮蔽”(shadow)了这些字段。这意味着参数的名称隐藏了字段的名称,因为它们定义在更靠近第 13 行的位置。

但 Java 给了我们一种访问被遮蔽字段的方法,即使用this关键字。在 Java 中,this始终包含指向你所处对象的引用。(如果这让你有点困惑也没关系。我们稍会讨论更多关于引用的内容。)

所以,this.created_at指属于整个当前对象的名为created_at的变量。而在该范围内——第 12 行到第 19 行——变量created_at指的是参数而不是字段。

this.created_at = created_at;

…意味着将参数中的值存储到实例变量中。

第 14 行到第 19 行做了相同的事情。它们将参数值的拷贝存储到同名的字段中。

注意,在toString()方法中没有任何参数。因此,在此范围内唯一名为retweet_count的变量是字段。所以在第 25 行,我可以像在之前的练习中做的那样,直接通过名称访问它。

然而,使用this.访问字段(如第 28 行所示)是总是合法的。有些程序员总是这样做。我通常只在需要时才这样做。

toString()方法从第 23 行的一个名为out的空字符串开始,然后使用if语句逐部分构建它。到方法的结束时,我们得到了一个四行长的字符串,并它被返回给main()进行打印。

public class TweetDriver {
    public static void main( String[] args ) {
        Tweet t = new Tweet("Thu Feb 19 20:19:00 UTC 2009", 8, true,
            568507566182344L, 2, false,
            "You can now buy 'Java the Hard Way' using bitcoin!");

        System.out.println(t.toString());
        System.out.println("\n--------");
        System.out.println(t);
    }
}

TweetDriver.java

你应该看到的内容

最后让我们谈谈为什么这个方法被称为toString()而不是其他名字。正如你在驱动程序的第 9 行所看到的,当你打印toString()方法返回的字符串时,一切运行得非常完美。

然而,在第 11 行,我们正尝试打印整个对象本身!通常这会显示一串杂乱的字母,但由于我们的对象定义了一个名为toString()的公共方法,Java 编译器将打印toString()返回的内容,而不是对象本身。

这非常有用!

好了,这个练习就到此为止!

练习 15:井井井战(Noughts and Crosses)/ 极端测试

这个练习的代码相当长!这是因为你现在已经掌握了足够编写有趣事情的知识。我们要编写的第一个是一个用于玩儿童游戏“井井井”(Noughts and Crosses)的对象,它也被称为“井字棋”(Tic Tac Toe)。

public class NoughtsCrossesObject
{
    // 实例变量
    private String[][] board;
    private int turns;

    // 构造函数
    public NoughtsCrossesObject()
    {
        board = new String[3][3];
        turns = 0;

        for ( int r=0; r<3; r++ )
            for ( int c=0; c<3; c++ )
                board[r][c] = " ";
    }

    // 访问方法
    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) {
        return board[a][b].equals(board[c][d])
            && board[a][b].equals(board[e][f]);
    }

    public boolean isFull() {
        if ( turns == 9 )
            return true;
        else
            return false;
        // return turns == 9;
    }

    public boolean isCat() {
        return isFull() && !isWinner("X") && !isWinner("O");
    }

    public boolean isValid( int r, int c ) {
        if ( 0 <= r && r <= 2 && 0 <= c && c <= 2 )
            return true;
        else
            return false;
    }

    public int numTurns() {
        return turns;
    }

    public String playerAt( int r, int c ) {
        if ( isValid(r,c) )
            return board[r][c];
        else
            return "@";
    }

    public boolean isTaken(int r, int c) {
        String p = playerAt(r,c);
        if ( p.equals(" ") )
            return false;
        else
            return true;
    }

    public String toString() {
        String out = "";
        out += "  0  " + board[0][0] +"|"+ board[0][1] +"|"+ board[0][2 +"\"\n";
        out += "    --+-+--" + "\n";
        out += "  1  " + board[1][0] +"|"+ board[1][1] +"|"+ board[1][2] +""\n";
        out += "    --+-+--" + "\n";
        out += "  2  " + board[2][0] +"|"+ board[2][1] +"|"+ board[2][2] +""\n";
        out += "     0 1 2 " + "\n";
        return out;
    }

    // 修改/变器方法
    public void playMove( String p, int r, int c ) {
        board[r][c] = p;
        turns++;
    }
}

NoughtsCrossesObject.java

第一个实例变量(称为board的那个)是一个String类型的二维数组。这意味着数组中包含许多按行和列排列的字符串。

有一个构造函数,用于实例化一个三行三列的二维数组,将turns设置为零,然后使用嵌套循环在每个位置放入一个空白格。

isWinner()方法在p获胜时返回true,在玩家未获胜时返回false。它通过一个名为winCheck()的“辅助方法”来完成任务。注意winCheck()是一个private方法,这意味着我们可以在对象内部调用它,但不能在驱动程序的其他地方调用它。

winCheck()检查行a和列b是否包含与行c和列d相同的字母。然后它确认[a,b]中的字母也与[e,f]中的字母相同。如果所有条件都成立,则该方法返回一个true值。

isFull()在棋盘上所有格子都被使用时返回true。你可以像所示那样使用if语句和else语句来实现,也可以使用被注释掉的那单行代码来实现。

isCat()在棋盘填满但任何玩家都没有获胜时返回true。

isValid()在行值和列值都在范围内时返回true。

numTurns()返回回合数。

playerAt()返回指定行r和列c处的玩家,否则返回@。

isTaken()如果指定位置已被占用则返回false,否则返回true。

toString()返回棋盘的格式化输出字符串。

// 修改/变器方法

playMove()将玩家p放在[r][c]处,并将回合数加 1。

NoughtsCrossesGame.java

你可以看到在第 8 行实例化了我们的对象,然后可以使用提供的所有方法来游戏。

第 12 行打印ttt对象,这会调用toString()方法。该方法会返回棋盘的状态。

第 14 行和第 15 行允许人类输入行和列。你可以通过在提示处输入两个数字来实现。

第 17 行到第 26 行进行错误检查,确保玩家在合法位置上操作。

第 27 行实际执行玩家的操作,第 29 行到第 32 行将玩家从“X”切换到“O”或反之。而第 37 行到第 42 行宣布获胜者。

现在让我们输入驱动程序:

import java.util.Scanner;

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

        String p = "X";
        NoughtsCrossesObject ttt = new NoughtsCrossesObject();
        int r, c;

        while ( !ttt.isWinner("X") && !ttt.isWinner("O") && !ttt.isFull() ) {
            System.out.println(ttt);
            System.out.print("'" + p + "', choose your location (row column): ");
            r = keyboard.nextInt();
            c = keyboard.nextInt();

            while ( !ttt.isValid(r,c) || ttt.isTaken(r,c) ) {
                if ( ttt.isValid(r,c) == false )
                    System.out.println("Not a valid location. Try again.");
                else if ( ttt.isTaken(r,c) )
                    System.out.println("Location already full. Try again.");

                System.out.print("'" + p + "', choose your location (row column): ");
                r = keyboard.nextInt();
                c = keyboard.nextInt();
            }
            ttt.playMove( p, r, c );

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

        System.out.println(ttt);

        if ( ttt.isWinner("X") )
            System.out.println("X is the winner!");
        else if ( ttt.isWinner("O") )
            System.out.println("O is the winner!");
        else if ( ttt.isCat() )
            System.out.println("The game is a tie.");
    }
}

你应该看到的

噢,额,什么?

所以,我犯了一个错误。在对象的某个地方。如果所有代码都在main()函数中,这将会很难追踪。幸运的是,由于它是一个可以逐个调用的对象,我们可以对其进行自动化测试!

以下是我几年前写的一个测试器,用来让我的学生找到他们井井棋程序中的 Bug。我并不打算解释它,甚至不建议你手动输入一遍。直接下载或复制我提供的文件即可。

我可以告诉你,这个测试器会运行所有可能存在的井井棋游戏,并且每次都检查棋盘的状态。如果我们测试的对象报告的结果与测试器预期的结果不符,它就会报错。

如果这个测试器通过了你的代码,那么你的代码极可能没有 Bug。这是一个很不错的事实。

 1 // 最初编写于 2007-09-19
  2 
  3 import java.util.Arrays;
  4 
  5 public class NoughtsCrossesTester
  6
  7     private static NoughtsCrossesObject ttt;
  8 
  9     public static void main( String[] args ) {
 10         // 构造函数
 11         System.out.print("Checking constructor................");
 12         System.out.print("....................................");
 13         ttt = new NoughtsCrossesObject();
 14         check( "numTurns()",    ttt.numTurns(),    0 );
 15         check( "isWinner('X')", ttt.isWinner("X"), false );
 16         check( "isWinner('O')", ttt.isWinner("O"), false );
 17         check( "isCat()",       ttt.isCat(),       false );
 18         check( "isFull()",      ttt.isFull(),      false );
 19         System.out.println("  [ok]");
 20 
 21         // 是否有效
 22         int trials = 0;
 23         System.out.print("Checking isValid()....");
 24         for ( int r = -100; r<=100; r++ )
 25             for ( int c = -100; c<=100; c++ ) {
 26                 check( "isValid("+r+","+c+")", ttt.isValid(r,c),
 27                     ( 0 <= r && r < 3 && 0 <= c && c < 3 ) );
 28                 if ( ++trials % 777 == 0 ) System.out.print(".");
 29             }
 30 
 31         System.out.println("  [ok]");
 32 
 33         // 运行所有可能的游戏以确保评分正确
 34         byte[] game = { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
 35         int[] cells = { 0, 1, 2, 4, 8, 16, 32, 64, 128, 256 };
 36         int n = 0, i;
 37         boolean gameOver = false;
 38         String p = "X";
 39         int r, c, winner = 3;
 40         int xTotal = 0, oTotal = 0;
 41         boolean xWin, oWin, gato;
 42         trials = 0;
 43 
 44         System.out.print("Checking isWinner()....");
 45         while ( game[0] < 10 ) {
 46             // 玩单局游戏
 47             ttt = new NoughtsCrossesObject();
 48             n = 0;
 49             xTotal = oTotal = 0;
 50             for ( byte b : game ) {
 51                 r = (b-1) / 3;
 52                 c = (b-1) % 3;
 53                 ttt.playMove(p,r,c);
 54 
 55                 if ( p.equals("X") )
 57                     xTotal += cells[b];
 58                 else
 59                     oTotal += cells[b];
 60 
 61                 n++;
 62                 xWin = ( Arrays.binarySearch(winPatterns,xTotal) >= 0 );
 63                 oWin = ( Arrays.binarySearch(winPatterns,oTotal) >= 0 );
 64                 gato = ( ! xWin && ! oWin && n == 9 );
 65 
 66                 check( "numTurns()",    ttt.numTurns(),    n );
 67                 check( "isWinner('X')", ttt.isWinner("X"), xWin );
 68                 check( "isWinner('O')", ttt.isWinner("O"), oWin );
 69                 check( "isCat()",       ttt.isCat(),       gato );
 70                 check( "isFull()",      ttt.isFull(),      n == 9 );
 71 
 72                 p = ( p.equals("X") ? "O" : "X" );
 74                 if ( xWin || oWin )
 75                     break;
 76             }
 77             trials++;
 78             if ( trials % 720 == 0 )
 79                 System.out.print(".");
 80 
 81             next_permutation( game );
 82         }
 83         System.out.println("  [ok]");
 84         System.out.println("\nAll tests passed!");
 85     private static void check(String property, boolean is, boolean shouldBe) {
 86             if ( is != shouldBe ) {
 87                 System.out.println("\nFATAL ERROR: " + property + " returns " +
 88                     is + ", but should be " + shouldBe);
 89                 System.exit(1);
 90             }
 91     private static void check(String property, int is, int shouldBe) {
 92             if ( is != shouldBe ) {
 93                 System.out.println("\nFATAL ERROR: " + property + " returns " +
 94                     is + ", but should be " + shouldBe);
 95                 System.exit(1);
 96             } } 97     private static boolean unique(byte[] a) {
 98         boolean[] used = new boolean[10];
 99         for ( int i=0; i<a.length; i++) {
 100            if ( used[a[i]] )
 101                return false;
 102            else
 103                used[a[i]] = true;
 104        }
 105        return true; }
 106    private static void increment(byte[] a) {
 107         a[a.length-1]++;
 108         for ( int i=a.length-1; i>0; i--) {
 109             if ( a[i] >= 10 ) {
 110                 a[i] = 0;
 111             } } }
 112    private static void next_permutation(byte[] a) {
 113         do {
 114             increment( a );
 115         } while ( unique(a) );
 116     }
 117     private static int[] winPatterns = {
 118           7, 15, 23, 39, 56, 57, 58, 60, 71, 73, 75, 79,
 119           84, 85, 86, 87, 89, 92, 93, 94, 103, 105, 107, 116,
 120           117, 118, 120, 121, 122, 124, 135, 143, 146, 147, 150, 151,
 121           154, 158, 167, 178, 179, 184, 185, 186, 188, 201, 205, 210,
 122           212, 213, 214, 220, 233, 242, 244, 263, 271, 273, 275,
 123           277, 279, 281, 283, 285, 292, 293, 294, 295, 300, 302, 305,
 124           307, 308, 309, 312, 313, 314, 316, 329, 331, 337, 339, 340,
 125           341, 342, 345, 348, 356, 358, 369, 372, 401, 402, 403, 405,
 126           409, 410, 420, 421, 428, 433, 448, 449, 450, 452, 456,
 127           457, 458;
 128     };
 }
 1 import java.util.ArrayList;
 2 
 3 public class ArrayListIntro {
 4     public static void main(String[] args) {
 5         ArrayList<String> arr = new ArrayList<String>();
 6 
 7         System.out.println( "ArrayList has size(): " + arr.size() );
 8 
 9         arr.add("Ant-Man");
10         arr.add("Hulk");
11         arr.add("Iron Man");
12         arr.add("Thor");
13         arr.add("Wasp");
14 
15         System.out.println( "ArrayList has size(): " + arr.size() );
16         System.out.println( "Current contents: " + arr );
17         showList(arr);
18 
19         arr.add(3, "Jarvis"); // inserts into slot 3; others slide right
20         showList(arr);
21 
22         int i = arr.indexOf("Hulk");
23         System.out.println( arr.get(i) + " is located in slot " + i );
24         arr.remove(i); // removes value in that slot; others slide left
25         showList(arr);
26 
27         arr.add(1, "Captain America"); // original slot 1 and other slide right
28         showList(arr);
29 
30         arr.set(1, "Cap"); // replaces the value in slot 1
31         showList(arr);
32 
33         arr.remove(5); // removing backward lets us avoid sliding 
34         arr.remove(4);
35         arr.remove(2);
36         arr.remove(0);
37         showList(arr);
38 
39         arr.add("Hawkeye");
40         arr.add("Quicksilver");
41         arr.add("Scarlet Witch");
42         showList(arr);
43 
44         i = arr.indexOf("Hawkeye");
46         String removed = arr.remove(i);
47         arr.add(i-1, removed);
48         showList(arr);
49 
50         System.out.println( "Current contents: " + arr );
51     }
52 
53     public static void showList( ArrayList<String> a ) {
54         System.out.println("size() is " + a.size() );
55         for ( int i=0; i<a.size(); i++ ) {
56             int len = a.get(i).length() + 4; // two quotes, two spaces
57             System.out.print("|" + centerPad(""+i, len));
58         }
59         System.out.println("|");
60         for ( String s : a )
61             System.out.print("| \"" + s + "\" ");
62         System.out.println("|\n");
63     }
64 
65     public static String centerPad(String s, int width) {
66         int totalSpaces = width - s.length();
67         int leftSide = totalSpaces / 2;
68         int rightSide = totalSpaces - leftSide;
69         String out = "";
70         for ( int i=0; i<leftSide; i++ )
71             out += " ";
72         out += s;
73         for ( int i=0; i<rightSide; i++ )
74             out += " ";
75         return out;
76 }

ArrayListIntro.java

这里只有一个文件!你可以直接编译并运行它!

首先(第 5 行),我们实例化了一个 ArrayList 对象。这是一个 String 类型的 ArrayList,这正是尖括号的作用所在。你可以为任何类型的对象创建 ArrayList。ArrayList 初始是空的。

ArrayList 拥有一个 size() 方法,可以告诉你其中当前存储了多少项。请注意,这与它的容量(capacity)无关:它们会随着添加和删除项目而增长或收缩。

接下来,我们使用 add() 方法向列表添加五个项目。你添加的第一个项目进入第 0 个槽位。下一个进入第 1 个槽位,依此类推。在这些调用之后,列表的 .size() 将 5。

在第 16 行,你可以(可能)看到 ArrayList 自身的打印格式非常整洁。打印良好的事实意味着 ArrayList 类内部有一个 toString() 方法,该方法负责构建并返回一个美观的 String 用于打印!

但我对默认显示并不满意,因为我希望在其中包含槽位编号,并且希望它们是居中的,所以这就是 showList() 函数的作用。

centerPad() 函数返回一个指定宽度的 String。该 String 将把参数 s 位于中间,并在左右留空。我知道这多此一举,但我想让它看起来好看一点。无论如何,回到 ArrayList 类的内置方法。

如果你只向 add() 方法提供一个值,它会将新值放在末尾,并在需要时调整大小。但如果你还向 add() 方法传递一个槽位编号,它将把新值放在你指定的槽位中,并将所有值向右(向更大的槽位)移动以腾出空间。我们在第 19 行执行了此操作,查看输出应该能清楚列表中其他值发生了什么。

你应该看到的内容

ArrayList 还拥有一个 indexOf() 方法,它会查找你传递的值并返回首次找到它的槽位编号。如果没有找到,它将返回 -1 而不是槽位编号。

remove() 方法会从指定的槽位删除值,然后将所有其他值向左(向较小的槽位)移动以填补空隙。再次,我希望输出让这一点变得清晰。

还有一个 set() 方法(第 30 行),用于替换现有的值。其余值不会移动。

删除一组值总是很棘手的,因为所有值都会向左(向较小的索引)滑动。因此,一个巧妙的技巧是从大到小地删除值,这样在处理过程中槽位编号就不会发生变化。

在第 44 行到 47 行,我的意图是从大到小地删除值,这样在处理过程中槽位编号就不会发生变化。

这本书是 H.G. 威尔斯的《莫尔陶博士岛》。这本书于 1896 年首次出版,版权现已过期;它现在属于公有领域,并由优秀的古特登堡计划(Project Gutenberg)在线托管。

你应该看到什么

关于这个练习没什么好说的。它是 ArrayList 的一个相当标准的用法。这只是对 ArrayList 的额外练习,以及关于对象的使用。

try-catch 块用于可能会抛出异常的代码。在这种情况下,实例化 URL 对象可能会因为“格式化的 URL 异常”而崩溃。如果 URL 类的 openStream() 方法无法打开到该 URL 的流,它也可能抛出异常。因此,我们将这两个语句都放在 try 块中。

假设两个都执行成功,catch 块中的代码将被跳过。如果任何一个语句抛出异常,catch 块中的代码就会运行。在这里,我们只是抱怨并退出程序。有时可以克服错误并继续运行,但我们在这里并不尝试。

练习 18:内存中的原始类型变量

到目前为止,我对变量的描述还比较笼统。变量是你储存值的地方。它们有一个名称(标识符)、一个类型(int 或 double)以及——最终有一个值。

我认为你已经写了足够久的代码,准备好面对更多细节了。输入代码,然后我们再讨论。

 1 public class PrimitivesInMemory {
 2     public static void main(String[] args) {
 3 
 4         int age = 41;
 5         double btc = 0.11307678;
 6         boolean veracity = true;
 7 
 8         short month = 11;
 9         byte day = 31;
10         long debt = 18153890112907L;
11         float mass = 78.971F;
12 
13         System.out.println( btc );
14         System.out.println( debt );
15         System.out.println();
16 
17         String name = "Gosling";
18     }
19 }

PrimitivesInMemory.java

你应该看到什么

第 4 到第 6 行定义了你已经过的过的变量类型。整数只存储整数,但每个数字都被精确存储;整数永远不会有舍入问题。

Doubles 可以包含小数(分数部分),但在内部以科学计数法存储,并且只有特定数量的有效数字。

Boolean 变量只能存储 true 值或 false 值。

稍后我会详细讨论这三种变量类型。但首先,文件中接下来的四行代码定义了我之前没有提到过的四种变量类型!

原始数据类型

byte、short 和 long 都是不同类型的整数。它们都只存储正或负整数,但它们的大小不同。float 就像 double 但大小只有它的一半。

让我解释一下我的意思。Java 中的 int 存储为 32 位二进制数。因此,二进制中的数字 5 可以 被简单写为 101,但一个包含 5 的 Java int 实际上存储方式如下:

00000000000000000000000000000101

而存储在 byte 类型变量中的相同 5 存储方式如下:

00000101

short 占用 16 位,而 long 有比 int 更大的空间;它使用 64 位。

Java 中所有这些数值类型类型都是“带符号”的,这意味着第一位(“最高位”)决定符号。0 表示正,1 表示负。然后剩余的位决定数字的绝对值或“幅度”。

(算是有吧。整数存储在一种名为“补码”的特殊格式中,这意味着负数的表示方式和我描述的略有不同。如果你想了解更多细节,可以查一下。)

这意味着由于剩余 31 位用于数值,Java 中的整数可以存储从 00000000000000000000000000000000 (0) 到 01111111111111111111111111111111 (2147483647) 之间的任何正数,以及从 11111111111111111111111111111111 (-1) 到 10000000000000000000000000000000 (-2147483648) 之间的任何负数。

以下是 Java 中其他整数类型的细节。

| type | bits | 最小值 | 最大值 |

| --- | --- | --- | --- |

| byte | 8 bits | -128 | 127 |

| short | 16 bits | -32768 | 32767 |

| int | 32 bits | -2147483648 | 2147483647 |

| long | 64 bits | -9223372036854775808 | 9223372036854775807 |

引用类型

在第 17 行,我们创建了一个名为 name 的 String 变量并将值 "Gosling" 放入其中。其实我们并没有。

Java 中的字符串不是原始数据类型,所以值 "Gosling" 并没有直接存储在变量中。字符串值实际上存储在内存的另一个地方(称为“堆”),然后将其内存地址存储在变量中。

练习 19:内存中的引用变量

原始变量类型直接将变量的值存储在变量的内存位置中。引用变量将值存储在其他地方,而引用变量的内存位置则持有那个“其他地方”的位置。

 1 import java.util.Scanner;
 2 import java.util.ArrayList;
 3 
 4 class Example {
 5 }
 6 
 7 public class ReferencesInMemory {
 8     public static void main(String[] args) {
 9 
10         int age = 41;
11         String name = "Gosling";
12 
13         int[] digits = { 3, 1, 4, 1, 5, 9, 2 };
14         Scanner keyboard = new Scanner(System.in);
15         ArrayList arr = new ArrayList();
16         Integer year = new Integer(1995);
17         Object obj = new Object();
18         Example ex = new Example();
19 
20         System.out.println( digits );
21         System.out.println( obj );
22         System.out.println( ex );
23         System.out.println();
24 
25         String[] names = { "John", "Paul", "George", "Ringo", "Brian" };
26         System.out.println( names );
27     }
 28 }

ReferencesInMemory.java

你应该看到的内容

(输出中的数字在你的系统上可能可能相同。)

在 Java 中,整数是一个基本数据类型。标识符 age 指向一个内存位置,而二进制值

00000000000000000000000000101001

……被直接存储在那个位置。我喜欢说 age 的“浅层”值是 41。由于 age 是一个基本数据类型,它只有一个浅层值,而没有“深层”值。(稍后我会详细解释我的意思。)

然而,String 在 Java 中并不是基本数据类型。String 是对象,且 String 变量持有的是值的引用,而不仅仅是值本身。

因此,标识符 name 也指向一个内存位置,但该内存位置并没有存储值 "Gosling"。相反,一个包含字符串字面量“Gosling”的对象被存储在其他地方(堆上),而该位置被存储在变量中。

也就是说,name 的“浅层”值是一个内存地址/引用,而“深层”值是字符串值。

在 Java 中,所有对象变量都有一个深层值和一个浅层值。对象的浅层值始终是引用,而深层值始终是实际对象。

如果你对这些都完全理解,你可能可以略过这个练习的剩余部分。如果没有,我将在下面尝试解释得清楚一点。

符号表

大多数编程语言都会在一个名为“符号表”的大列表里跟踪程序中的所有变量。让我们来看程序的一个一小片段。

int x;
int y = 5;
double z = 1;
String s;

在这段代码执行完毕后,符号表可能如下所示:

| 标识符 | 地址 | 类型 | 是否初始化? |

| --- | --- | --- | --- |

| x | @38000100 | int |否 |

| y | @38000096 | int | 是 |

| z | @38000088 | double | 是 |

| s | @38000080 | 指向 String 的引用 |否 |

符号表包含标识符及其位置,但通常不持有变量的值。这些值存储在内存的另一个区域,被称为“栈(stack)”。

栈

每个计算机程序(技术上,多线程程序中的每个线程)都会获得一个栈,栈是存储程序所有基本变量以及其他控制流簿记信息的地方。通常来说,栈中的地址倾向于从较大的数字开始并向下增长。不过,栈的细节有点复杂,所以不要太担心它们。

无论如何,该程序的的一个栈片段可能像这样:

| @38000100 | …096 | …092 | …088 | …084 | …080 |

| --- | --- | --- | --- | --- | --- |

| ?????? | 5 | | 1 | | ?????? |

你会注意到所有四个变量(x、y、z 和 s)现在都声明了;它们的名称和类型已经设置。但到目前只有其中两个被初始化了;x 和 s 还没有值。

让我们给这两个变量赋值。

x = 12;
s = "hello";

符号表只发生了一点变化,因为我们没有声明任何新变量。

| 标识符 | 地址 | 类型 | 是否初始化? |

| --- | --- | --- | --- |

| x | @38000100 | int | 是 |

| y | @38000096 | int | 是 |

| z | @38000088 | double | 是 |

| s | @38000080 | 指向 String 的引用 |否 |

| @38000100 | …096 | …092 | …088 | …084 | …080 |

| --- | --- | --- | --- | --- | --- |

| ?????? | 5 | | 1 | | ?????? |

输出的最后一行是你看到 String 对象数组时显示的内容。

嗯,我想对于这个练习来说这足够了!我们将在未来的练习中更多地讨论对象、引用和原始值之间的这种区别。

注意:在其他一些编程语言中(特别是 C 和 C++),可以将一个对象的值直接存放在栈(stack)上。在这些语言中,你可以在栈上创建对象,也可以在堆(heap)中动态分配它,并像 Java 那样在栈上存储指向它的 reference(引用)。

在 Java 中,你没有这个选择。直接在栈上分配对象是 impossible(不可能的)。在不将对象存储在堆中的情况下创建对象也是 impossible(不可能的)。

练习 20:原始值列表

在之前的两个练习中,你finally 终于学会了 Java 中原始数据类型(如 int 和 double)与引用/对象类型之间的区别。

现在我可以告诉你们一个秘密:ArrayLists only 可以持有引用。在代码中输入,然后我们来讨论细节。

import java.util.ArrayList;

public class ListsOfPrimitives {
    public static void main(String[] args) {
        ArrayList<String> hats = new ArrayList<String>();

        hats.add("fez");
        hats.add("bowler");
        hats.add("beanie");
        hats.add("western");
        hats.add("fedora");

        System.out.println( hats );

        String jumble = "";
        for ( String s : hats ) {
            jumble += s;
        }
        System.out.println("All together now: " + jumble);

        // ArrayList<int> bins = new ArrayList<int>();
        ArrayList<Integer> bins = new ArrayList<Integer>();

        bins.add(new Integer(1));
        bins.add(new Integer(3));
        bins.add(new Integer(3));
        bins.add(new Integer(1));

        bins.add(1);
        bins.add(4);
        bins.add(6);
        bins.add(4);
        bins.add(1);

        System.out.println( bins );

        int total = 0;
        for ( Integer N : bins ) {
            int n = N.intValue();
            total += n;
        }
        System.out.println("The total is " + total);

        total = 0;
        for ( int n : bins ) {
            total += n;
        }
        System.out.println("The total is still " + total);

        ArrayList<Character> letters = new ArrayList<Character>();
        letters.add('z'); // auto-boxes char

        ArrayList<Double> weights = new ArrayList<Double>();
        weights.add(0.14); // auto-boxes double

        ArrayList<Boolean> dealt = new ArrayList<Boolean>();
        while ( dealt.size() < 52 )
            dealt.add(false); // auto-boxes boolean
    }
}

ListsOfPrimitives.java

你应该看到的

这个练习从创建一个 String 对象的 ArrayList 开始。列表初始是空的,但然后我们迅速添加了五个值。

很容易假装在那些代码行之后,ArrayList 包含了如下值:

| 0 | 1 | 2 | 3 | 4 |

| --- | --- | --- | --- | --- |

| fez | bowler | beanie | western | fedora |

事实上,ArrayList 类的 toString() 方法也让它看起来像那样。

但实际上,ArrayLists 只持有 references(引用)。所以真实的情况更像这样:

| 0 | 1 | 2 | 3 | 4 |

| --- | --- | --- | --- | --- |

| @d70 | @a60 | @868 | @db0 | @338 |

ArrayList 对象中的每个槽位都包含一个 reference(引用),而不是字符串字面量。字符串 values 实际上存储在引用的内存位置上。

Java 花了很大努力来隐藏 Strings 有时是引用的事实,这样你就可以在了解它们的情况下编写许多运行正常的代码。

当你想要创建一个持有原始整数的 ArrayList 时,问题就出现了。第 21 行被注释掉的代码显示了我们 want(想要)写的内容。但我们必须写的是第 22 行……因为 ArrayLists 只能持有引用。

int 是一个原始类型。Integer 是一个包含单个 int 的对象。Java 将此称为“包装”(wrapper)类,因为它是一个包裹在原始类型上的非常薄的层。

我们想向 ArrayList 中添加四个 int。但我们必须 wrap(包装)它们。ArrayLists 只能持有引用。

int 是一个原始类型。Integer 是一个包含单个 int 的对象。Java 将此称为“包装”(wrapper)类,因为它是一个包裹在原始类型上的非常薄的层。

我们想向 ArrayList 中添加四个 int。但我们必须 wrap(包装)它们。ArrayLists 只能持有引用。

int 是一个原始类型。Integer 是一个包含单个 int 的对象。Java 将此称为“包装”(wrapper)类,因为它是一个包裹在原始类型上的非常薄的层。

我们想向 ArrayList 中添加四个 int。但我们必须 wrap(包装)它们。ArrayLists 只能持有引用。

int 是一个原始类型。Integer 是一个包含单个 int 的对象。Java 将此称为“包装”(wrapper)类,因为它是一个包裹在原始类型上的非常薄的层。

练习 21:泛型与强制转换

在之前的两个练习中,你finally 终于学会了 Java 中原始数据类型(如 int 和 double)与引用/对象类型之间的区别。

现在我可以告诉你们一个秘密:ArrayLists only 可以持有引用。在代码中输入,然后我们来讨论细节。

import java.util.ArrayList;

public class GenericsVsCasts {

    @SuppressWarnings("unchecked")
    public static void main(String[] args) {
        ArrayList objarr = new ArrayList();

        objarr.add("one");
        objarr.add("two");

        Object aa = objarr.get(0);
        Object bb = objarr.get(1);

        // String qq = objarr.get(0); // won't work
        // String rr = objarr.get(1); // won't work

        String ss = (String)objarr.get(0);
        String tt = (String)objarr.get(1);

        System.out.println(objarr);

        // ---------------------------------------
        Integer i = new Integer(123);
        objarr.add(i);

        System.out.println(objarr); // ["one", "two", 123] !!!

        // String uu = (String)objarr.get(2); // runtime ClassCastException

        // strarr.add(i); // won't compile
    }
}

GenericsVsCasts.java

第 5 行是一个被称为“annotation”(注解)。在这种情况下,它是告诉编译器不要显示关于该类 main() 方法中“未检查或不安全操作”的警告。如果没有这个注解,代码也可以编译,但这个警告让我很困扰。

它警告我,在泛型存在的情况下用旧方法处理 ArrayLists 是不安全的。

当你发现持有原始整数的 ArrayList 运行太慢时,Java 增加了“自动装箱”(autoboxing),这意味着 Java 现在会自动将 int 转换为 Integer 对象。

同样,Java 现在也有“自动拆箱”(unboxing),这样像第 39 行所示的从 Integer 对象获取 int 值的方法是可行的。

在第 45 和 46 行中,我们可以直接将 int 添加到 ArrayList。这很方便,但最好记住:从 ArrayList 中获取原始值会产生额外的工作。

泛型允许你创建一个对象类型是可变的结构。

我应该先解释一下 Object 是什么。在 Java 中,每一个对象都继承自一个名为 Object 的特殊类。

我们将在稍后的练习中详细讨论继承,但基本思想是:Java 中所有不是基本类型的类型都是一个 Object(对象)。

  • String 是 Object。

  • Scanner 是 Object。

  • Integer 是 Object(但 int 不是;那些是基本类型)。

  • 你创建的任何类都是 Object。

因此,类型为 Object 的变量(例如第 12 行的 aa)持有一个引用,就像 Java 中其他非基本类型变量一样。但 aa 可以持有指向任何类型对象的引用。

无泛型型

main() 中的第一行代码创建了一个名为 objarr 的 ArrayList,但它没有告诉程序应该持有什么种类型的引用。因此,这个 ArrayList 可以持有指向对象的引用。可以是任何 Object。

在第 9 行和第 10 行,我们可以向列表添加两个 String 的引用,因为 objarr 可以持有指向对象的引用,而 String 也是 Object。

在第 12 行和第 13 行,我们获取了这两个引用,并将它们的副本存储到名为 aa 和 bb 的两个 Object 变量中。

让我们停一下来看目前的符号表和 ArrayList:

| identifier | address | type | initialized? |

| --- | --- | --- | --- |

| objarr | @12a020b0 | 指向对象 ArrayList 的引用 | yes |

| aa | @12a020a8 | 指向 Object 的引用 | yes |

| bb | @12a020a0 | 指向 Object 的引用 | yes |

ArrayList objarr:

| 0 | 1 |

| --- | --- |

| @a58 | @980 |

在这个虚构的示例中,String 字面量 "one" 存储在堆上的引用 @a58,String 字面量 "two" 位于引用 @980。

好了,现在看栈:

| …20b0 | …20a8 | …20a0 |

| --- | --- | --- |

| @f98 | @a58 | @980 |

我们可以看到,变量 aa(类型为 Object)持有引用 @a58。Object 变量 bb 持有 @980。

@a58 是指向 String “one”的引用。@980 是指向 String “two”的引用。但第 15 行和第 16 行的代码无法编译。尽管 objarr.get(0) 存储的引用确实是指向 String 的引用,但该信息已经丢失了。编译器只能确定 objarr 包含指向 Object 的引用,而不是所有的 Object 都是 String。

因此,在 Java 有泛型之前的时代,我们必须使用“强制转换(cast)”来解决编译器信息的缺失。这在第 18 行和第 19 行中得到了体现。

从列表中出来的是一个 Object 引用,但我们知道它实际上不是任意 Object,而是一个指向 String 的引用。我们知道这一点是因为我们最初把 String 放在那里的!

所以 (String) 是一个“强制转换”(括号是有意义且是必须的)。它告诉编译器:“我知道你认为那个引用只是一个 Object,但我向你保证它实际上是一个指向 String 的引用。让它通过吧。”

即使它们不是指向 String 的引用,这段代码也可以通过编译。但如果我们错误地尝试将引用强制转换为错误的类型,强制转换将在程序运行时“奔溃”。(具体来说,它会抛出 ClassCastException。你将在学习练习中尝试这一点。)

有泛型

在第 24 行,我们创建了第二个 ArrayList。但多亏泛型,我们可以指定它是指向 String 引用的 ArrayList。

(如果你愿意,可以在第二组角括号内放入 String,但这是可选的。尽管我经常把它放在那里,因为我会忘记可以不写它。)

在第 25 行和第 26 行之后,符号表看起来没有太大区别:

| identifier | address | type | initialized? |

| --- | --- | --- | --- |

| objarr | @12a020b0 | 指向对象 ArrayList 的引用 | yes |

| aa | @12a020a8 | 指向 Object 的引用 | yes |

| bb | @12a020a0 | 指向 Object 的引用 | yes |

| strarr | @12a02098 | 指向 String ArrayList 的引用 | yes |

以及 ArrayList strarr:

| 0 | 1 |

| --- | --- |

| @a58 | @980 |

和以前一样,列表持有两个 String 字面量的引用。但这次,编译器知道它们是指向 String 的引用,而不仅仅是 Object。

无论如何,既然编译器可以确定从 strarr 中出来的是 String ,第 28 行和第 29 行运行正常,不需要强制转换。

此外!由于 String 是 Object,第 33 行和第 34 行也运行正常。变量 a 可以持有指向任何 Object 的引用,包括从 ArrayList 中获取的 String 引用。

…合并成一个大文件。我选择这些小说是因为它们很长,得益于优秀的 古腾登堡计划 很容易获得,而且——最重要的是——它们现在都属于公有领域。

使用不同的源材料,你可能会得到略有不同的结果。

我已经回到了创建对象和驱动程序,所以你仍然输入以下文件以使程序运行。

import java.io.FileInputStream;

public class IBeforeEDriver {
    public static void main( String[] args ) throws Exception {
        FileInputStream file = new FileInputStream("datafiles/en-sample.txt");
        IBeforeEChecker check = new IBeforeEChecker( file );
        System.out.println( check.getWordCount() + " ie/ei words found." );
        System.out.print( "\t" + check.getMatches() + " match the rule, " );
        System.out.print( check.getExceptions() + " do not: " );
        System.out.println(100.0*check.getMatches()/check.getWordCount() + "%");
    }
}

IBeforeEDriver.java

你应该看到什么

对于这个示例,该规则的生效率为 78%。

现在让我们来谈谈代码是如何工作的,以及为什么它要这样安排。

我想驱动程序部分应该很清楚了。我决定驱动程序必须通过构造函数传入一个InputStream对象,因为你可以从本地文本文件(使用FileInputStream)或从 URL(使用java.net.URL的openStream()方法)获取InputStream对象。

这样一来,IBeforeEChecker对象就不需要知道或关心数据从哪里来了。这使得该对象更具通用性,也更容易让他人使用。在设计对象时,你需要考虑这些事情。

驱动程序在运行器的第 6 行实例化了一个IBeforeEChecker对象。我希望这个过程快速完成,这样对象在调用真正需要这些信息的方法之前,就不会花费时间去计算统计数据。

另外注意,这个驱动程序调用了两次getWordCount()。它调用了两次getMatches(),并调用了一次getExceptions()。当我写这个对象时,我不确定所有方法是否被调用,还是只有少数几个,而且不知道方法会以什么顺序被调用。

这就是我创建这个对象的目标:

  • IBeforeEChecker对象在被需要之前不应该做大量工作。

  • 它不应该重复工作。任何计算都应该只发生一次。

  • 驱动程序不需要知道调用某个神奇的“执行工作”函数;这应该在对象内部处理。

  • 无论以什么顺序调用方法,方法都应该返回正确的值。

  • 即使只调用了其中一个方法,方法也应该返回正确的值。

  • 多次调用方法不应该导致工作重复。

考虑到这些点,让我们回到IBeforeEChecker对象的代码。

我们用四个私有变量开始对象;这些是实例变量/字段,在类中的每个方法中都可以访问。

source字段持有从对象用户处传入的InputStream对象的引用。稍后,我们将一个Scanner对象连接到这个InputStream以读取所有单词。

matchCount字段将存储符合“I before E”规则的单词数。它初始为零,因为我们还没有进行任何计算。

computed字段是一个布尔值,在我们实际读取所有单词并进行计算之前,它将一直为false。这个变量让我们不会重复执行工作。

最后,wordList是一个ArrayList的引用。我们所有的实例变量都在构造函数内部(12 到 15 行)赋予了合适的初始值,这始终是一个好主意。

私有辅助方法

从第 18 行开始,我们定义了一个private方法!fitsRule()将是一个“辅助方法”,我们用它来让代码更容易易读和理解,但由于它是私有的,驱动程序无法直接调用它。只有我们可以在编写其他方法时才能调用它。

fitsRule()接收一个String参数,如果该单词包含“ie”但不包含“cie”,或者它包含“cei”,则返回true。

从第 28 行开始,我们定义了第二个名为doCalculations()的私有辅助方法。这个名字很笨蛋,但很合适。

这个辅助方法将Scanner附加到用户给我们的InputStream上,然后一个单词接一个单词地扫描。对于每个单词,我们检查是否包含字母“ie”或“ei”。如果是,它就会添加到ArrayList中。(但只添加一次!添加重复单词会偏斜统计数据。)

读取完所有单词后,我们从InputStream关闭/断开Scanner。

然后,对于wordList中的每个 ie/ei 单词,我们使用另一个辅助方法查看该单词是否符合拼写规则。如果是,我们就增加计数器。

最后,我们将computed字段更改为true。我们很快就会发现这很重要。

公共方法

我们的第一个公共方法是getMatches(),它返回符合拼写规则的单词数量。返回matchCount,但只有在计算完成后才有效。

所以,我们检查computed变量。如果不是 true,说明还没有运行计算方法。

getExceptions()非常类似。我们不知道这些方法会以什么顺序被调用,所以这个方法也需要检查computed字段。一旦确定工作已完成,我们就执行一个小技巧。

调用getWordCount()方法获取 ie/ei 单词的总数,然后减去匹配数以得到异常数。并返回结果。

还有一个名为getList()的公共方法,它返回指向ArrayList的引用。驱动程序代码目前没有使用这个方法,但在我写对象时,我觉得可能有人想要获取这个列表。

我认为再次强调:所有的实例变量都是private,所以用户无法访问它们。如果我为对象编写 javadoc 文档,私有变量甚至不会被提到。另外,私有方法只是为了方便我。使用这个对象的人不知道它们,也不需要处理它们。构造函数和公共方法就是他们需要的。

练习 23:使用泛型编写趣味类

几个练习前,我们查看了 Java 泛型如何允许我们使用ArrayList,而不需要每次从中取出值时进行强制转换。

所以这次我们将看到如何在创建的类中使用泛型。这个练习有四个文件。前两个你可能应该跳过输入,你需要复制我的,因为驱动程序预期它们存在。

第三个(PointSwapper)是魔法发生的地方,你应该自己输入。

 1 public class PointSwapperInt {
 2     private int a, b;
 3 
 4     public PointSwapperInt( int a, int b ) {
 5         this.a = a;
 6         this.b = b;
 7     }
 8 
 9     public void swap() {
10         int temp = a;
11         a = b;
12         b = temp;
13     }
 14 
 15     public int getFirst() { return a; }
 16     public int getLast()  { return b; }
 17 
 18     public String toString() {
19         return "(" + a + ", " + b + ")";
20     }
 21 }

PointSwapperInt.java

我不确定为什么会有人想要这样的东西,但让我们假设由于某种原因,你想要一个表示点(比如x和y)的类,并且你想要能够轻松交换x和y的位置。

我也不清楚;就按我的思路吧。

所以首先,我们决定这两个值应该是整数。因此,我们的私有实例变量/字段是整数。构造函数接收两个整数作为参数。

我们有一个swap()方法,方法内部的临时变量也是整数。

我们的getFirst()方法返回一个整数。getLast()也返回一个整数。

这些之所以是整数并没有什么特别的理由;它们之所以是整数是因为我们的点是由整数组成的。

好,,现在让我们假设我们还想要用浮点值创建一个可以交换的点。这里是对应的代码!

 1 public class PointSwapperDouble {
 2     private double a, b;
 3 
 4     public PointSwapperDouble( double a, double b ) {
 5         this.a = a;
 6         this.b = b;
 7     }
 8 
 9     public void swap() {
10         double temp = a;
11         a = b;
12         b = temp;
13     }
 14 
 15     public double getFirst() { return a; }
 16     public double getLast()  { return b; }
 17 
 18     public String toString() {
19         return "(" + a + ", " + b + ")";
20     }
 21 }

PointSwapperDouble.java

注意代码有多相似!除了所有的整数都变成了double。当唯一的区别是点的坐标类型时,必须复制这么多代码,真是遗憾。

所以这就是泛型大显身场的地方。这里相同代码的示例,但将类型变成了泛型。

 1 public class PointSwapper<Type> {
 2     private Type a, b;
 3 
 4     public PointSwapper( Type a, Type b ) {
 5         this.a = a;
 6         this.b = b;
 7     }
 8 
 9     public void swap() {
10         Type temp = a;
11         a = b;
12         b = temp;
13     }
 14 
 15     public Type getFirst() { return a; }
 16     public Type getLast()  { return b; }
 17 
 18     public String toString() {
19         return "(" + a + ", " + b + ")";
20     }
 21 }

PointSwapper.java

注意在public class之后之后有一个包含单词的角括号。我选择在里面放入“Type”,但它只是一个标识符。你可以在那里放入任何内容,只要保持一致,它就能工作。(不过大多数 Java 程序员更倾向于用大写字母开头。)使用<T>非常常见,<E>(代表“element”)也是如此。

你会发现,每当我该说int或double的地方,我只需要放入类型参数(也称为“类型变量”)的名字:Type。简单!

好,这里是运行所有三个对象的驱动程序。

 1 public class PointSwapperDriver {
 2     public static void main( String[] args ) {
 3         PointSwapperInt pi = new PointSwapperInt(3, 5);
 4 
 5         System.out.println( "before: " + pi );
 6         pi.swap();
 7         System.out.println( "after: " + pi );
 8         pi.swap();
 9         System.out.println( "after after: " + pi );
 10 
 11         PointSwapperDouble pd = new PointSwapperDouble(1.1, 4.4);
 12 
13         System.out.println( "\nbefore: " + pd );
14         pd.swap();
15         System.out.println( "after: " + pd );
16         pd.swap();
17         System.out.println( "after after: " + pd );
18 
19         PointSwapper<Integer> pg1 = new PointSwapper<>(2, 6);
20         PointSwapper<Double>  pg2 = new PointSwapper<>(1.3, 5.7);
 22 
23         System.out.println( "\nbefore: " + pg1 );
24         pg1.swap();
25         System.out.println( "after: " + pg1 );
26         pg1.swap();
27         System.out.println( "after after: " + pg1 );
 28 
29         System.out.println( "\nbefore: " + pg2 );
30         pg2.swap();
31         System.out.println( "after: " + pg2 );
32         pg2.swap();
33         System.out.println( "after after: " + pg2 );
34     }

PointSwapper.java

驱动程序的前几行创建了一个PointSwapperInt对象并测试它。然后实例化了一个PointSwapperDouble对象。乐趣从第 19 行创建PointSwapper<Integer>和PointSwapper<Double>

你应该看到的

遗憾的是,泛型在 Java 中的用法不像在其他语言中那样简单。在这个练习中,我们将编写一个像 Java 内置ArrayList一样工作的类。

练习 24:编写一个实用的类

class HardArrayList<E> {
    private E[] arr;
    private int used;

    @SuppressWarnings("unchecked")
    public HardArrayList() {
        used = 0;
        arr = (E[])new Object[10];
    }

    public int size() {
        return used;
    }

    public boolean add(E obj) {
        if ( used == arr.length )
            grow();
        arr[used] = obj;
        used++;
        return true;
    }

    public void add(int index, E obj) {
        if ( used == arr.length )
            grow();
        // move the values to make space
        for ( int i=used; i>index; i-- )
            arr[i] = arr[i-1];
        arr[index] = obj;
        used++;
    }

    public E get(int index) {
        if ( index < used ) {
            return arr[index];
        }
        return null;
    }

    public E set(int index, E obj) {
        if ( index < used ) {
            E old = arr[index]; // set() returns old value
            arr[index] = obj;
            return old;
        }
        return null;
    }

    public E remove(int index) {
        if ( index < used ) {
            E temp = arr[index];
            // slide everything back one slot
            for ( int i=index; i<used; i++ )
                arr[i] = arr[i+1];
            used--;
            return temp;
        }
        return null;
    }

    public boolean contains(E obj) {
        for ( int i=0; i<used; i++ )
            if ( arr[i].equals(obj) )
                return true;
        return false;
    }

    public String toString() {
        String out = "";
        if ( used > 0 )
            out += arr[0];
        for ( int i=1; i<used; i++ )
            out += ", " + arr[i];
        return "[" + out + "]";
    }

    private void grow() {
        int newCapacity = 1 + 2*arr.length;
        @SuppressWarnings("unchecked")
        // E[] arr2 = new E[newCapacity]; // "error: generic array creation" :(
        E[] arr2 = (E[])new Object[newCapacity];
        for ( int i=0; i<arr.length; i++ )
            arr2[i] = arr[i];
        // move the reference so that arr now refers to the new larger array
        arr = arr2;
    }
}

HardArrayList.java

你会注意到,我几乎可以直接在类名后面加上 <E>,然后在所有我本该放置 Integer 或 Double 的地方都改为 E。

唯一的例外是第 8/9 行和 81/82 行。我本想直接创建一个 E 对象的数组,但这会导致“泛型数组创建”的错误而无法编译。

因此,我不得不实例化一个 Object 数组,然后将引用强制转换为 E 对象的数组。这很诡异,但显然在 Java 中必须这样做。此外,我还必须在那里加上 SuppressWarnings 注解,以避免编译时出现“未检查或不安全的操作”的警告。

在我开始详细解释这些方法之前,这里是驱动程序,它对 ListsOfPrimitives.java 进行了修改,改为使用这个 ArrayList 类,而不是 Java 标准库中的那个。

public class JavaHardArrayListDriver {
    public static void main(String[] args) {
        JavaHardArrayList<String> hats = new JavaHardArrayList<>();

        hats.add("fez");
        hats.add("bowler");
        hats.add("beanie");
        hats.add("western");
        hats.add("fedora");

        System.out.println( hats );

        String jumble = "";
        for ( int i=0; i<hats.size(); i++ ) {
            jumble += hats.get(i);
        }
        System.out.println("All together now: " + jumble);

        JavaHardArrayList<Integer> bins = new JavaHardArrayList<>();

        bins.add(new Integer(1));
        bins.add(new Integer(3));
        bins.add(new Integer(3));
        bins.add(new Integer(1));

        bins.add(1);
        bins.add(4);
        bins.add(6);
        bins.add(4);
        bins.add(1);

        System.out.println( bins );

        int total = 0;
        for ( int i=0; i<bins.size(); i++ ) {
            total += bins.get(i);
        }
        System.out.println("The total is " + total);

        JavaHardArrayList<Character> letters = new JavaHardArrayList<>();
        letters.add('z'); // auto-boxes char

        JavaHardArrayList<Double> weights = new JavaHardArrayList<>();
        weights.add(0.14); // auto-boxes double

        JavaHardArrayList<Boolean> dealt = new JavaHardArrayList<>();
        while ( dealt.size() < 52 )
            dealt.add(false); // auto-boxes boolean
    }
}

JavaHardArrayListDriver.java

(我猜我也删减了几行代码,只是为了让它短一点点。)

你应该看到的

这个练习的目标是看到泛型以实用的方式使用的真实例子。你已经做到了。所以,如果你愿意,可以跳过所有这些文字,直接进入下一个练习。

然而,从理论上来说,你已经拥有理解此练习中所有代码所需的背景知识。你们中的一些可能对细节感兴趣,所以我将解释所有内容。

但如果你想跳过,也不要觉得抱歉。仅仅因为我花时间写这些,并不意味着我认为你必须阅读它们。真的!

设计思路

数组很酷。它们速度快且易于访问。但它们不会增长!一旦分配了数组,其大小就固定了,无法更改。不过我们稍后会处理这个问题。

我们将从一个包含十元素的固定大小数组开始。目前 ArrayList 中什么都没有,但有一个有十个槽位的数组准备就绪。我们还将使用一个单独的变量来跟踪数组当前存储了多少个值。

起初,情况看起来是这样:

arr

| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |

| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |

| ? | ? | ? | ? | ? | ? | ? | ? | ? | ? |

| used | | | | | | | | | |

由于 used 从值零开始,我们可以想象 used 正在“指向”数组中的下一个可用槽。也就是说,我使用这个变量来计算数组中已经有多少个值,同时也用来引用下一个可用索引。

这是行得通的,因为数组索引(技术上称为“索引”)是从零而不是一开始的。

其实我撒谎了。在创建数组时,arr 填充的是 null。所以 used 是零。这里有一个更准确的图片:

arr

| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |

| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |

| null | null | null | null | null | null | null | null | null | null |

| used | | | | | | | | |

方法细节

我们从两个实例变量开始arr 和 used。arr 是一个 E 对象的数组,无论类型是什么。used 是一个整数,用于跟踪数组当前存储了多少值。它也“指向”下一个空位。

接下来是构造函数,它只是将 used 设置为 0 并分配一个十元素的数组。

size() 方法应该返回当前存储的项数,这存储在 used 字段中。

ArrayList(实际上所有的 List)都有两个 add() 方法。第一个在列表的“末尾”添加新项。此方法也应该在添加“成功”时返回 true。(Java 中的某些 List 可能会无法添加项,但在本例中,我们将始终返回 true)。

如果数组“满”了,我们就必须神奇地“增长”数组让其变大。(稍后详说!)然后,要么我们一开始就没有满,要么我们现在有了足够的空间,我们将项放入下一个可用槽位,然后增加计数器。

第二个 add() 方法接收一个要添加新项的特定索引。和之前一样,我们首先检查数组是否已满,如果需要则神奇地增长。

然后,为新项腾出一个“空隙”。从最后一个填充槽开始,将每个值复制到其右侧的位置(arr[i-1] 是 arr[i] 左侧的槽,所以每个值向右移动)。

我们从结尾一直循环到 index,这是他们想要添加新值的地方。所以当循环结束,所有的值都移动完毕,槽 index 已经准备好接收新值。我们将其放在那里,增加 used 并返回 true。

(我可能应该检查一下新的 index 是否超过了列表当前的大小。哎!我会在学习练习中让你做这一点。)

get() 方法要简单得多;它只需要返回指定 index 处的值。我们检查请求的索引是否“越界”,然后返回该值。

如果请求的索引确实越界了,那么目前什么都不会返回,而是执行 return null。如果我能更酷炫一点,我本该想个办法来像真正的 ArrayList 类那样抛出一个“IndexOutOfBounds”异常。但这段代码已经足够复杂了。

set() 方法替换给定索引处的值,并返回该位置之前先存储的任何值。同样,我们要检查被替换的值有没有越界。然后,我们对该位置的现有值进行复制,覆盖它,最后返回旧值。

remove() 方法就像是 add() 方法的反向操作;它将所有值向左移动一个位置,以确保数组中没有空隙。由于删除了一个值,used 就会减 1。该方法还会返回被删除的值的拷贝,因为 Java 的 ArrayList 类就是这样工作的。

contains() 方法非常简单;我们只需要逐个检查每个槽位。请注意,由于我们处理的是对象,必须使用 equals() 进行比较。如果我们找到了指定的对象,就返回 true;如果循环结束,说明我们没有找到它,可以返回 false。

另外注意,循环只计数到 used,而不是 arr.length。数组可能并没有满,所以查看那些没有任何内容的槽位是浪费的。

真正的 ArrayList 类有一个很棒的 toString() 方法,让打印变得容易。它创建一个包含所有值按顺序排列的字符串,中间用逗号分隔,然后将整个字符串包裹在方括号中。

魔法的 grow() 方法

最后,我们来到了神奇的 grow() 方法,它让数组变大。尽管 Java 中的数组是无法调整大小的。让我们来看一个“满”的数组,此时 used == arr.length。

arr: @2b8

| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | |

| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |

| @e50 | @660 | @540 | @b70 | @c08 | @fe0 | @4a8 | @3b0 | @580 | @960 | |

| | | | | | | | | | | used |

arr.length 是 10,used 是 10,所以数组全满了。

在代码中,我们创建了一个容量为 21 的第二个更大的数组。(在图中我只把新容量设为 15,以便适应页面宽度。)

然后,我们将原始较小数组中的所有值复制到新的大数组中。看起来应该是这样:

arr2: @4c0

| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |

| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |

| @e50 | @660 | @540 | @b70 | @c08 | @fe0 | @4a8 | @3b0 | @580 | @960 | null | null | null | null | null |

现在见证魔法。记住,在符号表中,数组变量仅仅持有一个引用。假设在这一刻,我们的符号表如下:

symbol table

| identifier | address | type |

| --- | --- | --- |

| arr | @17400100 | reference to E[] |

| used | @17400096 | int |

| newCapacity | @17400092 | int |

| arr2 | @17400084 | reference to E[] |

并且——更重要的一点是——如果栈目前如下:

stack

| 17400100 | …096 | …092 | …088 | …084 |

| --- | --- | --- | --- | --- |

| @2b8 | 10 | 14 | | @4c0 |

这是那行看起来非常无聊的关键代码:

arr = arr2;

你看到了吗?!我们刚刚把 arr2 的浅拷贝值——它的引用——复制到了 arr 中,覆盖了它之前的值!所以现在栈看起来变成了这样:

stack

| 17400100 | …096 | …092 | …088 | …084 |

| --- | --- | --- | --- | --- |

| @4c0 | 10 | 14 | | @4c0 |

此时,arr 和 arr2 都持有了同一个引用:指向那个大数组的引用!在 grow() 方法运行后,它的局部变量(newCapacity 和 arr2)离开了作用范围,它们的条目也会被从栈中移除。

public class ThingySorter<E extends Comparable<E>> {
    
    private E[] arr;

    public ThingySorter( E[] arr ) {
        this.arr = arr;
    }

    private void swap( int i, int j ) {
        E temp = arr[i];
        arr[i] = arr[j];
        arr[j] = temp;
    }

    public void exchangeSort() {
        for ( int i=0; i<arr.length-1; i++ )
            for ( int j=i+1; j<arr.length; j++ )
                if ( arr[i].compareTo(arr[j]) > 0 )
                    swap(i,j);
    }

    public void quickSort() {
        quickSortHelper(0, arr.length-1);
    }

    private void quickSortHelper( int lo, int hi ) {
        if ( lo < hi ) {
            int pivotloc = partition(lo, hi);
            quickSortHelper(lo, pivotloc-1);
            quickSortHelper(pivotloc+1, hi);
        }
    }

    private int partition( int lo, int hi ) {
        int pi = (lo+hi)/2;
        E pivot = arr[pi];
        swap(pi,hi);
        int cur = lo;
        for ( int i=lo; i<hi; i++ ) {
            if ( arr[i].compareTo(pivot) < 0 ) {
                swap(i,cur);
                cur++;
            }
        }
        swap(cur,hi);
        return cur;
    }

    public boolean isSorted() {
        for ( int i=0; i<arr.length-1; i++ ) {
            if ( arr[i].compareTo(arr[i+1]) > 0 )
                return false;
        }
        return true;
    }
}

ThingySorter.java

嗯。看第一行。

当我最初创建这个作业时,我希望在类的第一行写下的是:

public class ThingySorter<E> {

但是这里有一个问题。在第 18 行(以及第 40 行),我获取了一个类型为 E 的对象并对其调用了 compareTo() 方法。而 Java 中并不是所有的对象都有 compareTo() 方法!

所以我想写:

public class ThingySorter<E implements Comparable> {

……这似乎意味着“让 E 任意实现了 Comparable 接口的类型”。但那也不行。(关于接口,我们将在未来的练习中讨论。)事实证明,即使 Comparable 是一个接口而不是超类,你仍然需要写成:

public class ThingySorter<E extends Comparable> {

但这仍然不起作用,因为 Comparable 本身需要一个泛型类型(参数化类型)。所以必须写成:

public class ThingySorter<E extends Comparable<E>> {

这基本上意味着:ThingySorter 类需要一个类型参数。我们将参数命名为 E。但提供的类型必须是实现了 Comparable 接口的类型,并且它必须与其他类型为 E 的对象可比较。明白吗?

足够。

(如果你真的想写得高级一点,你可以写成:

public class ThingySorter<E extends Comparable<? super E>> {

……这意味着该类型必须与其他类型为 E 的对象或 E 的任何子类的对象可比较。再次强调,不要太担心这个,因为我们将在未来的练习中更多更多子类、超类、继承、接口和实现。)

import java.util.Arrays;
import java.util.Random;

public class ThingySorterDriver {
    public static void main( String[] args ) {

        long start, stop;
        Integer[] inums = {68, 65, 5, 56, 83, 17, 4, 9, 90, 78, 42, 10, 91, 34};
        Double[] dnums = {12.1, 45.7, 43.9, 7.0, 1.5, 13.7, 19.6, 49.1, 29.7};

        ThingySorter<Integer> sort1 = new ThingySorter<>(inums);
        System.out.println( Arrays.toString(inums) );
        System.out.println( "Sorted? " + sort1.isSorted() );
        sort1.exchangeSort();
        System.out.println( Arrays.toString(inums) );
        ThingySorter<Double> sort2 = new ThingySorter<>(dnums);
        System.out.println( Arrays.toString(dnums) );
        System.out.println( "Sorted? " + sort2.isSorted() );
        sort2.exchangeSort();
        System.out.println( Arrays.toString(dnums) );
    }
}

exchangeSort() 方法非常简单直接。我们让 i 从数组开头移动到末尾,然后 j 从 i 之后的位置开始也移动到末尾。如果位置 i 处的值大于位置 j 处的值,说明顺序乱了,应该交换位置。完成。

你应该注意到,这些是 嵌套 循环,而且每个循环都会计数到末尾。这意味着如果 N 是数组中元素的数量,那么两个循环内部的 if 语句会被检查大约 NxN 次,或者说 N² 次。

quickSort() 方法是递归的,因此它需要传入参数,以便使用这些参数来跟踪它当前正在处理数组的哪部分。但驱动类并不知道这一点(也不应该 need 知道这一点),所以这个公共方法只是负责填充空白,并调用该方法的递归版本。

如果你现在还不能完全理解递归快速排序的细节,请不要担心。这是一段相当复杂的代码,搞定所有细节花了我 me 一段时间。

不过,基本思想很容易。假设你就是那个递归快速排序方法。你被要求确保数组中 lo 和 hi 之间的所有值都是正确的顺序。

你选取一个“基准”(pivot)值,然后调整数值的位置,使得所有小于基准的值移动到数组的前部分,所有大于基准的值移动到数组的后部分。然后,你让递归快速排序方法排序前半部分。最后,你让递归快速排序方法为你排序后半部分。这里有一个简单的例子,排序九个值:

arr

| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 2 | 1 | 8 | 9 | 4 | 7 | 5 | 6 | 3 |
| lo |   |   |   |   |   |   |   | hi |

起初数组还没有排序,lo “指向”数组的第一个位置,hi 指向数组的最后一个位置。在我们的代码中,我们选择 lo 和 hi 之间的点作为基准。以更好的方式选择基准值确实会影响快速排序的速度,但我只是想做一些简单且运行良好的操作。

所以在此种情况下,它会选择基准值 4,它恰好位于位置 4。在 partition() 方法执行之前,这里是原始数组。我标出了它将选择的基准值。

arr

| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 2 | 1 | 8 | 9 | 4 | 7 | 5 | 6 | 3 |
| lo |   |   |   |   |   |   |   | hi |

partition(0,8) 调用会将所有小于 4 的值移动到数组开头,所有大于 4 的值移动到末尾。完成后,数组看起来像这样:

arr

| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 2 | 1 | 3 | 4 | 8 | 7 | 5 | 6 | 9 |
| lo |   |   | p |   |   |   |   | hi |

然后快速排序辅助方法将再次运行:执行两次。它会划分位置 0-2,然后划分位置 4-8。

快速排序的代码确实更复杂,但它总的比较次数要少得多。数组中的所有内容都只与基准值进行了比较,但现在位于前面的值(2, 1, 3)并没有与后面的值(8, 7, 5, 6, 9)进行比较……而且 它们永远不会进行比较。

事实上,每次 partition() 方法运行时,剩余的比较次数都会减半。所以它不是执行 N x N 次比较,而是执行了 N 乘以 N 的以 2 为底对数的比较。

N 的对数(底为 2)基本上是你为了得到 N 而必须对 2 升的幂。例如,如果 N 是 8,那么 log2(8) 是 3,因为 2³ = 8。如果 N 是 100,那么 log2(100) 在于 6 和 7 之间,因为 2⁶ = 64 且 2⁷ = 128。(如果有计算器,你可以算出精确值,因为 log2(N) 等于 log10(N) / log10(2)。)

我们稍后会详细讨论它的影响,但首先……

二次时间意味着,如果你将数组中的数值数量翻倍,你必须进行的比较次数就会增加四倍(2x2)。如果你将数组的长度增加三倍,工作量就会增加 9 倍(3x3)。依此类推。

快速排序在每次遍历中都会将元素分为两个“半部分”,因此比较次数次数实际上更接近 N x log2(N)。由于几乎所有的算法复杂度分析都使用以 2 为底的对数,我们将其称为 O(n log n)(“N log N 数量级”)。这通常被称为“对数线性时间”,这是你在排序数值时能达到的极限。

想一想。如果交换排序的复杂度是 O(n²) 且 N 是 50,000,那么它将执行大约 50000 x 50000 次比较,那是 25 亿次啊!

快速排序执行的是 50000 x log2(50000),而 50,000 的 2 基对数大约只有 15.6,所以它只进行了大约 780,000 次比较!

我不想深入探讨大 O 表示法,但在那之前,这里有一张你经常看到的复杂度图表,从最好到最差排序。

Big O

| Big O | 名称 | 示例 |

| --- | --- | --- |

| O(1) | 常数 | 查找数组的第一个元素 |

| O(log n) | 对数 | 对排序后的数组进行二分查找 |

| O(n) | 线性 | 累加数组中的所有值 |

| O(n log n) | 对数线性 | 快速排序 |

| O(n²) | 二次 | 交换排序、冒排序 |

| O(2^n) | 指数 | 递归斐波那契数列 |

| O(n!) | 阶乘 | 暴力破解旅行商人问题 |

说明一下:常数时间意味着“无论输入有多大,花费的时间都是相同的”。

练习 26:排序速度 - 原生类型 vs 对象

在本次练习中,我们将观察处理大量原生类型与大量对象 之间的相对速度。

而且在好几个练习中的第一次,你只需要输入一个文件!

 1 import java.util.Arrays;
 2 import java.util.Random;
 3 import java.util.Scanner;
 4 
 5 public class SortingSpeeds {
 6     public static void main( String[] args ) {
 7         long start, stop, elapsed, slowest = 0, fastest = Long.MAX_VALUE;
 8 
 9         int iterations = 40;
10         int SIZE = 20000;
11         int[]     intArr = new int    [SIZE];
12         Integer[] objArr = new Integer[SIZE];
13 
14         Random gen = new Random(555555585);
15         Scanner kb = new Scanner(System.in);
16 
17         System.out.println( "1) Primitive ints using selection sort" );
18         System.out.println( "2) Integer objects using selection sort" );
19         System.out.println( "3) Integer objects using Arrays.sort()" );
20         System.out.print( "Which one? " );
21         int choice = kb.nextInt();
22 
23         String testDescription = "error";
24 
25         for ( int runs=0; runs<iterations; runs++ ) {
26 
27             for ( int i=0; i<SIZE; i++ ) {
28                 intArr[i] = gen.nextInt(1000);
29                 objArr[i] = new Integer(intArr[i]);
30             }
31 
32             if ( choice == 1 ) {
33                 testDescription = "primitive ints using selection sort";
34                 start = System.nanoTime();
35                 selectionSortPrimitives(intArr);
36                 stop = System.nanoTime();
37             }
38             else if ( choice == 2 ) {
39                 testDescription = "Integer objects using selection sort";
40                 start = System.nanoTime();
41                 selectionSortObjects(objArr);
42                 stop = System.nanoTime();
43             }
44             else { // choice == 3, probably
45                 testDescription = "Integer objects using Arrays.sort()";
46                 start = System.nanoTime();
47                 Arrays.sort(objArr);
48                 stop = System.nanoTime();
49             }
50             elapsed = (stop-start)/1000000;
51 
52             if ( elapsed < fastest ) {
53                 fastest = elapsed;
54             }
55             if ( elapsed > slowest ) {
56                 slowest = elapsed;
57             }
58         }
59         System.out.println( "\n" + iterations + " runs sorting " );
60         System.out.println( testDescription );
61         System.out.println( "\tFastest time: " + fastest + " milliseconds." );
62         System.out.println( "\tSlowest time: " + slowest + " milliseconds." );
63     }
64     static void selectionSortPrimitives(int[] arr) {
65         for ( int i=0; i<arr.length-1; i++ ) {
66             int min = i;
67             for ( int j=i+1; j<arr.length; j++ ) {
68                 if ( arr[j] < arr[min]) {
69                     min = j;
70                 }
71             }
72             int temp = arr[i];
73             arr[i] = arr[min];
74             arr[min] = temp;
75         }
76     }
77     static void selectionSortObjects(Integer[] arr) {
78         for ( int i=0; i<arr.length-1; i++ ) {
79             int min = i;
80             for ( int j=i+1; j<arr.length; j++ ) {
81                 if ( arr[j].compareTo(arr[min]) < 0) {
82                     min = j;
83                 }
84             }
85             Integer temp = arr[i];
86             arr[i] = arr[min];
87             arr[min] = temp;
88         }
89     }
90 }

SortingSpeeds.java

在本次练习中,我们将观察处理大量原生类型与大量对象之间的相对速度。

而且在好几个练习中的第一次,你只需要输入一个文件!

内部第一个块是另一个循环,它从 0 到 999 随机选取 20,000 个数字,并将副本存储到每个数组中。

然后,根据所选择的选项,我们为测试设置适当的描述,启动计时器,运行指定的排序,然后停止计时器。

单次测试运行后(假设我们已经不再处于预热阶段),我们会检查这次运行是否比我们之前的获胜者快,或者是否比我们之前最慢的运行慢。

这结束了我们的主外层循环,因此在所有测试完成后,我们只需打印出详情。

下面是两个函数(也称为“静态方法”)。它们几乎完全相同,区别在于一个必须使用 .compareTo(),而临时交换变量是一个 Integer。

关于效率

你可以看到,在同等的情况下,Java 处理原生 int 的速度比处理 Integer 对象快一点。在我的电脑上,基本类型的速度大约是后者的“两倍”。在编写处理大量值的代码时,请记住这一点。

但是!不要被欺骗了!真正的启示是,即使使用对象和我我自己写的一个糟糕的平方时间排序算法,我的机器也能在不到六分之一秒内排完 20,000 个值!

而且!没有理由自己写排序算法,因为 Java 有一个非常完美的库排序方法,可以在不到百分之一秒内完成相同值的排序!

真正更高效的是使用更好的算法,并且不需要你自己去写代码!

Java 标准库提供的内置排序方法是 O(n log n)(也称为对数线性)排序。对于基本类型 int,文档说它使用了一种“稳定的、自适应的、迭代归并排序,当输入数组部分有序时,它需要的比较次数远少于 n lg(n),当输入数组随机随机排序时,它提供传统归并排序的性能……[它] 改自 Tim Peters 的 Python 列表排序(TimSort)。”

你知道我最喜欢使用标准库排序的哪一点吗?它只需要一行代码,而且我不需要调试!而且它使用的算法比我写的要高效得多。

所以,这里是我对编写高效代码的建议:

米切尔先生编写高效代码的技巧

  1. 不要担心效率。反正,你对什么是高效的判断可能是错误的。

  2. 写出代码。不要担心它是否高效。

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