原文:
zh.annas-archive.org/md5/213b34f3b33252c99b2c0bd8aff6356f译者:飞龙
第六章:与数组一起工作
数组是一种基本的数据结构,你可以用它在一个变量中存储多个值。掌握数组不仅会使你的代码更加有序和高效,而且会打开通往更高级编程技术的大门。一旦你加入了数组,你就可以提升你应用程序的数据结构。
在本章中,我们将探讨数组,并为你提供有效使用这种基本数据结构所需的技术。你将学习如何创建、操作和遍历数组,以解决各种编程挑战。
下面是本章我们将涵盖的内容概述:
-
数组是什么以及如何使用它们
-
声明和初始化数组
-
访问数组元素
-
获取数组的长度并理解边界
-
遍历数组及其元素的不同方式
-
与多维数组一起工作
-
使用
Arrays类执行数组的常见操作
到本章结束时,你将拥有处理数组的坚实基础,这将使你能够自信地应对更复杂的编程任务。所以,让我们深入探讨吧!
技术要求
本章的代码可以在 GitHub 上找到:github.com/PacktPublishing/Learn-Java-with-Projects/tree/main/ch6。
数组——是什么,何时,为什么?
到目前为止,我们只看到了单个值,比如int、double和String。想象一下,我们想要计算一个平均值。那看起来可能像这样:
double result1 = 7.0;double result2 = 8.6;
double result3 = 9.0;
double total = result1 + result2 + result3;
double average = total / 3;
System.out.println(average);
这段代码的可扩展性不高。如果我们想添加第四个结果,我们需要做三件事才能让它工作:
-
声明并初始化第四个变量
-
将这个第四个变量加到总数中
-
用
4除而不是用3
这很麻烦,而且容易出错。如果我们了解数组,我们只需更改代码中的一个元素就能改变这一点。让我们看看数组是什么。然后,当我们能够遍历数组时,我们将重新编写这个示例。
Java 不能做基本的数学运算吗?!
如果你运行前面的代码片段,你会看到一些有趣的东西。如果我问你计算平均值,你会说 8.2,而且你会是对的。如果我们让 Java 来做,它会说 8.200000000000001。
你可能会想,如果 Java 不能进行基本的计算,那么学习 Java 还有什么用呢?这不仅仅是一个 Java 问题;这是一个普遍的计算机问题。它必须将十进制数字转换为二进制数字——就像你不能精确地用十进制数表示 1/3(0.33333)一样。
数组解释
好吧,所以数组可以在某些特定情况下更好地结构化我们的代码。但它们是什么?数组是一种数据结构,可以存储固定大小的、有序的相同数据类型的元素集合。数组中的元素存储在连续的内存位置中,这使得计算机更容易访问和操作数据。
到目前为止,我们还没有看到很多需要它们的情况。现在,我们将真正提高我们逻辑的复杂性,因为我们学习如何处理数组。
何时使用数组
因此,让我们谈谈何时使用数组。在我们之前的示例中,当我们计算平均值时,使用数组意味着我们不需要三个单独的变量来存储我们的三个结果。我们可以将它们存储在一个双精度数组类型的单个变量中。这使得处理数据更加容易。
数组(以及我们稍后将看到的存储多个值的其他类型方式)用于各种原因:
-
组织数据:数组可以帮助以结构化的方式组织和管理工作量大的数据
-
简化代码:使用数组可以通过减少存储和操作数据所需的变量数量来简化代码
-
提高性能:访问和修改数组中的元素比使用其他数据结构更快,因为元素存储在连续的内存位置
能够处理数组将成为你的 Java 工具箱中的强大工具!让我们看看我们如何声明和初始化它们。
声明和初始化数组
在 Java 中声明和初始化数组有不同的方法。你需要什么将很大程度上取决于具体情况。所以,让我们先从声明数组的基础知识开始。
声明数组
在 Java 中声明数组时,你需要指定元素的数据类型,然后是方括号([])和数组的名称。以下是一个示例:
int[] ages;
在这里,int[]是数组的数据类型,而ages是数组的名称。目前,我们无法向数组添加任何值,因为它尚未初始化。这与我们迄今为止所看到的初始化变量不同。让我们看看如何初始化数组。
初始化数组
在声明数组之后,需要对其进行初始化。我们通过指定其大小并为元素分配内存来完成此操作。我们可以使用new关键字来完成此操作,然后指定数据类型,并在方括号内指定数组的大小。以下是一个示例:
ages = new int[5];
此代码将ages变量初始化为包含大小为5的整数数组的数组。
我们也可以在一行代码中声明和初始化数组:
int[] ages = new int[5];
在这里,我们首先在左侧声明数组,然后在右侧初始化它。我们还可以使用特殊的简短语法直接分配其值,我们将在下面探讨。
数组初始化的简短语法
我们可以使用 Java 的快捷语法来声明和初始化具有特定值的数组。我们不需要分别声明和初始化数组,而是可以使用花括号({})直接指定元素。请看以下示例:
int[] ages = {31, 7, 5, 1, 0};
此代码创建了一个整数数组,并使用指定的值对其进行初始化。数组的大小由花括号内的元素数量确定。
实际上,我们之前的数组也有值,因为当你使用new关键字创建数组时,Java 会自动根据其数据类型使用默认值初始化元素。默认值如下:
-
数值类型(
byte、short、int、long、float、double):0或0.0 -
char: ‘\u0000’(Unicode 中的null字符) -
boolean:false -
引用类型(对象和数组):
null
例如,假设你创建了一个大小为3的整数数组:
int[] results = new int[3];
Java 使用默认值0初始化元素,因为int是数值类型。到目前为止,我们已经看到了如何声明和初始化数组。现在是时候学习如何访问数组中的元素并更新值了。
访问数组中的元素
为了访问数组中的元素,我们需要使用它们的索引。索引表示数组中的位置。这使我们能够检索特定位置的值并将其赋予新的值。让我们首先谈谈索引。
理解索引
在 Java 中,数组使用基于 0 的索引,这意味着第一个元素的索引是0,第二个元素的索引是1,依此类推。看看我们的ages数组示例:
int[] ages = {31, 7, 5, 1, 0};
这意味着第一个元素(31)的索引是0,最后一个元素的索引是4。
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_06_1.jpg
图 6.1 – 使用年龄数组解释索引
我们像通常那样计算数组的长度,从1开始。因此,这个数组的长度将是5。数组中的最后一个元素的索引等于数组的长度减去1。对于一个长度为 N 的数组,有效的索引范围是 0 到 N-1。
了解如何使用索引很重要,因为这样我们可以访问数组中的元素。
访问数组元素
要访问数组中的元素,你可以使用数组名称,后跟方括号内所需元素的索引。例如,要访问名为ages的数组的第一个元素,你可以使用以下代码:
int maaikesAge = ages[0];
这将在age变量中存储值31。为了访问第二个元素,你必须这样做:
int gaiasAge = ages[1];
我们还可以使用索引访问元素,并在该元素中存储另一个值。
打印数组
如果我们打印包含数组的变量,我们可以得到类似以下的内容:[I@28a418fc
这不会很有帮助。所以请注意,你正在打印toString()方法返回的内容。这不是针对数组定制的,也不是很有用。我们最可能想看到的是数组中的元素。有一种打印数组内容的方法。我们将在介绍处理数组的内置方法时看到这一点。
修改数组元素
修改元素也是通过索引完成的。这看起来很像我们之前所做的变量赋值。例如,要更改我们数组中名为ages的最后一个元素的值,我们可以使用以下代码:
ages[4] = 37;
我们只能访问存在的元素。如果我们尝试获取不存在的元素,我们会得到一个异常(错误)消息。
处理长度和边界
为了避免得到异常,我们需要保持在数组的边界内。索引始终从0开始,结束于数组长度减 1。如果你尝试访问这个范围之外的元素,你会得到ArrayIndexOutOfBoundsException。避免这种情况的关键是处理数组的长度。
确定数组长度
我们可以使用length属性来确定数组的长度。length属性返回数组中的元素数量。例如,要获取ages数组的长度,我们可以使用以下代码:
int arrLength = ages.length;
数组的长度从1开始计数。因此,我们的ages数组长度为5。最大索引是4。
处理数组的边界
如果你尝试使用无效索引(小于 0 或大于或等于数组长度的索引)访问或修改数组元素,Java 将抛出ArrayIndexOutOfBoundsException异常。这个异常是一个运行时错误,这意味着它在程序运行时发生,而不是在编译时。我们将在第十一章中了解更多关于异常的内容。
为了防止ArrayIndexOutOfBoundsExceptions,我们应该在使用数组索引访问或修改数组元素之前始终验证索引。我们可以通过检查索引是否在有效范围内(0到数组长度减 1)来完成此操作。以下是一个演示如何验证数组索引的示例:
String[] names = {"Maria", "Fatiha", "Pradeepa", "Sarah"};int index = 5;
if (index >= 0 && index < names.length) {
System.out.println("Element at index " + index + ": " +
names[index]);
} else {
System.out.println("Invalid index: " + index);
}
输出将如下所示:
Invalid index: 5
这段代码片段在访问数组元素之前检查索引是否在有效范围内。如果索引无效,程序将打印错误消息而不是抛出异常。
我们还可以使用我们在上一章中学到的循环来遍历数组中的元素,访问或修改它们的值。
遍历数组
遍历数组有不同的方法。我们将查看传统for循环和增强型for循环(也称为for-each循环)的使用。
使用 for 循环
我们可以使用传统的for循环通过索引变量遍历数组。循环从索引0开始,直到索引达到数组的长度。以下是一个演示如何使用for循环遍历数组并打印其元素的示例:
int[] results = {10, 20, 30, 40, 50};for (int i = 0; i < results.length; i++) {
System.out.println("Element at " + i + ": " +
results[i]);
}
输出将如下所示:
Element at 0: 10Element at 1: 20
Element at 2: 30
Element at 3: 40
Element at 4: 50
到目前为止,我们已经足够了解如何回顾本章开头看到的示例,计算几个结果的平均值。现在,我们不再有单独的原始数据类型,而是将有一个数组。它看起来是这样的:
double[] results = {7.0, 8.6, 9.0};double total = 0;
for(int i = 0; i < results.length; i++) {
total += results[i];
}
double average = total / results.length;
System.out.println(average);
如果我们现在想要添加一个结果,我们只需要在一个地方修改它。我们只需将结果添加到 results 数组中。由于我们遍历了所有元素,我们不需要添加一个额外的变量来计算总结果。此外,由于我们使用了长度,我们不需要将 3 改为 4。
我们也可以使用循环来修改数组的值。以下是一个示例,演示了如何使用 for 循环将数组中每个元素的值加倍:
int[] results = {10, 20, 30, 40, 50};// Double the value of each element
for (int i = 0; i < results.length; i++) {
results[i] = results[i] * 2;
}
// Print the updated array elements
for (int i = 0; i < results.length; i++) {
System.out.println("Element at " + i + ": " +
results[i]);
}
输出将如下所示:
Element at 0: 20Element at 1: 40
Element at 2: 60
Element at 3: 80
Element at 4: 100
如你所见,在第一个 for 循环中,数组元素被加倍。在第二个 for 循环中,它们被打印出来。正如输出所示,值确实加倍了!
让我们看看增强型 for 循环以及我们如何使用它来遍历数组。
使用 for-each 循环
我们还可以使用 for-each 循环,也称为增强型循环,来遍历数组。这个特殊的 for 循环简化了遍历数组(以及其他可迭代对象)的过程。for-each 循环会自动遍历数组中的元素,并且不需要索引变量。以下是一个示例,演示了如何使用 for-each 循环遍历数组并打印其元素:
int[] results = {10, 20, 30, 40, 50};for (int result : results) {
System.out.println("Element: " + result);
}
输出将如下所示:
Element: 10Element: 20
Element: 30
Element: 40
Element: 50
for-each 循环需要一个临时变量,用于在每次迭代中存储当前元素。在我们的例子中,这是 int result。将其称为 result 是有逻辑的,因为它是在 results 数组中的一个元素。但这不是功能所必需的;我也可以将其称为 x,如下所示:
int[] results = {10, 20, 30, 40, 50};for (int x : results) {
System.out.println("Element: " + x);
}
输出将完全相同。我喜欢在心中这样阅读代码行 for (int x : results):对于 results 中的每个元素 x,执行代码块中的任何操作。
因此,有两种遍历数组的方法,让我们谈谈何时选择哪一种。
在常规循环和增强型循环之间进行选择
我们可以使用常规 for 循环和(增强型)for-each 循环来遍历数组。这两种方法有一些区别,选择其中一种的原因。
当你需要索引可用时,你应该使用传统的 for 循环,因为这个循环使用索引变量来访问数组中的元素,而 for-each 循环直接访问元素而不使用索引变量。
for-each 循环不允许你在迭代过程中修改数组元素,因为它不提供访问索引变量的权限。如果你需要在迭代过程中修改数组元素,你应该使用传统的 for 循环。
如果你只想读取变量而不需要索引,通常你想要选择 for-each 循环,因为其语法更简单。
好的,现在我们知道了如何遍历数组。让我们使数据结构稍微复杂一些,并学习关于多维数组的内容。
处理多维数组
多维数组是数组的数组。在 Java 中,你可以创建具有两个或更多维度的数组。最常见类型的多维数组是二维数组,也称为矩阵或表格,其中元素按行和列排列。
让我们看看如何创建多维数组。
声明和初始化多维数组
要声明二维数组,您需要指定元素的数据类型,然后是两对方括号([][])和数组名。以下是一个示例:
int[][] matrix;
就像一维数组一样,我们使用new关键字初始化二维数组,后面跟着方括号内每个维度的数据类型和大小,如下所示:
matrix = new int[3][4];
此代码初始化了一个 3 行 4 列的矩阵。类型是int,因此我们知道矩阵的值是整数。
我们也可以在一行中声明和初始化多维数组:
int[][] matrix = new int[3][4];
我们也可以使用简短语法。要使用特定值初始化多维数组,我们使用嵌套花括号({}):
int[][] matrix = { {1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12}
};
就像一维数组一样,Java 通过提供的值来确定长度。这个矩阵有三个内部数组(三行),每个数组包含四个元素(四列)。访问和修改多维数组中的元素与之前类似,但现在我们需要提供两个索引。
访问和修改多维数组的元素
要访问或修改多维数组的元素,您需要在方括号内指定每个维度的索引。例如,要访问名为matrix的二维数组的第一行第二列的元素,可以使用以下代码:
int element = matrix[0][1];
要修改相同的元素,可以使用以下代码:
matrix[0][1] = 42;
图 6*.2*展示了我们的二维数组matrix的索引工作方式。
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_06_2.jpg
图 6.2 – 数组矩阵的行和列索引
因此,如果我们想获取值为12的元素并将其存储在last变量中,我们的代码如下所示:
int last = matrix[2][3];
我们也可以遍历多维数组中的所有变量。让我们看看这是如何实现的。
遍历多维数组
由于多维数组只是数组中的数组,我们可以使用嵌套循环来遍历多维数组。以下是一个示例,展示了我们如何使用嵌套for循环遍历二维数组:
int[][] matrix = { {1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12}
};
for (int i = 0; i < matrix.length; i++) {
for (int j = 0; j < matrix[i].length; j++) {
System.out.print(matrix[i][j] + " ");
}
System.out.println();
}
输出将如下所示:
1 2 3 45 6 7 8
9 10 11 12
目前我们做的只是打印这个元素。这也是我们可以使用增强的for循环遍历多维数组时可以做到的。以下是一个示例,展示了如何做到这一点:
int[][] matrix = { {1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12}
};
for (int[] row : matrix) {
for (int element : row) {
System.out.print(element + " ");
}
System.out.println();
}
输出将与上一个示例相同:
1 2 3 45 6 7 8
9 10 11 12
如你所见,外部的 for-each 循环遍历二维数组的行。行本身也是一个数组,这就是为什么类型是 int[]。内部的 for-each 循环遍历每一行中的元素。这些是整数。
传统的嵌套 for 循环和嵌套 for-each 循环都可以用来遍历多维数组。这取决于个人喜好以及你是否需要访问元素的索引。
数组可以有很多层级,但这并不真正改变基本原理。例如,对于四维数组,类型后面会有 [][][][],你需要四个级别的嵌套循环来遍历所有元素。
Java 以不同的方式帮助我们处理数组。让我们看看我们可以使用的数组的一些内置方法。
使用 Java 的内置数组方法
处理数组非常常见。通常,对于非常常见的事情,Java 有内置的功能。我们可以使用内置 Arrays 类的方法来做许多我们想对数组做的事情。
用于处理数组的内置 Arrays 类
内置的 Arrays 类是 java.util 包中的一个辅助类。它提供了许多实用方法,帮助我们高效地处理数组。我们将使用 Arrays 类来探索一些常见的数组操作任务。
toString() 方法
你可能想在数组上执行的一个非常有用的操作是将它转换成一个 String,这对于调试和日志记录非常有价值。为了实现这一点,Arrays 类提供了一个专门的方法,称为 toString()。需要注意的是,这个方法是静态的,允许我们直接在 Arrays 类上调用它。
import java.util.Arrays;public class ArrayHelperMethods {
public static void main(String[] args) {
int[] results = {30, 10, 50, 20, 40};
// Convert the array to a string representation
String arrayAsString = Arrays.toString(results);
System.out.println("Array: " + arrayAsString);
}
}
输出将如下所示:
Array: [30, 10, 50, 20, 40]
如你所见,results 数组被转换成一个表示数组元素的字符串,这些元素被方括号包围并由逗号分隔。Arrays 类上有许多这样的方法!接下来,让我们探索 sort 方法。
sort() 方法
你想在数组上执行的一个常见操作是对数组进行排序。以下是一个示例,展示了如何使用 Arrays 类的 sort 方法对数组的值进行排序:
import java.util.Arrays;public class ArrayHelperMethods {
public static void main(String[] args) {
int[] results = {30, 10, 50, 20, 40};
// Sort the array
Arrays.sort(results);
System.out.println(Arrays.toString(results));
}
}
输出将如下所示:
[10, 20, 30, 40, 50]
如你所见,results 数组最初是无序的。我们可以直接在 Arrays 类上调用 Arrays 类的方法,因为它们是静态的。对于整数值,默认情况下按从低到高的顺序排序。我们可以改变这种行为,但我们还没有掌握所需的知识。
我们使用另一个内置方法 toString 来打印数组。这会将数组转换成我们可以理解的形式。
当数组排序后,我们可以使用 binarySearch 方法来查找一个值。
binarySearch() 方法
我们还可以在数组中搜索一个值。我们将使用内置的 binarySearch 方法来完成此操作。非常重要的一点是,由于搜索算法的工作方式,这只能在排序数组中完成。以下是如何做到这一点的示例:
import java.util.Arrays;public class ArrayHelperMethods {
public static void main(String[] args) {
int[] results = {10, 20, 30, 40, 50};
int target = 30;
int index = Arrays.binarySearch(results, target);
System.out.println("Index of " + target + ": " +
index);
}
}
输出将如下所示:
Index of 30: 2
binarySearch 方法要求输入数组在之前已经排序。binarySearch 算法旨在在排序数组中查找目标值。它不是逐个搜索数组元素,而是将数组分成两半,直到找到目标或剩余要搜索的部分变为空。当中间的值更大时,它知道需要向数组的左侧移动,当它更小时,它知道需要向右侧移动。这就是为什么数组必须排序。如果找到了目标值,binarySearch 方法返回目标值的索引。如果目标值未找到,它返回一个负值,这表示插入点。所以,假设我们更新了我们的代码如下:
int[] results = {10, 20, 30, 40, 50}; int target = 31;
int index = Arrays.binarySearch(results, target);
System.out.println("Index of " + target + ": " +
index);
这将导致以下结果:
Index of 31: -4
这是因为它原本将在数组的第四个位置(而不是第四个索引!)。
让我们看看如何使用 fill 方法给数组中的所有元素赋予一个特定的值。
fill() 方法
有时,您可能需要以编程方式创建具有相同值的数组。以下是如何做到这一点的示例。我们使用 Arrays 类中的 fill 方法。以下是操作方法:
import java.util.Arrays;public class ArrayHelperMethods {
public static void main(String[] args) {
int[] results = new int[5];
Arrays.fill(results, 42);
System.out.println(Arrays.toString(results));
}
}
输出将如下所示:
[42, 42, 42, 42, 42]
fill 方法将数组中的所有元素设置为指定的值。有时我们需要创建数组的副本或调整它的大小。在这种情况下,我们可以使用 copyOf 方法。
copyOf() 方法
有时您需要创建数组的副本,例如,当您想在应用程序的另一个位置结束它时,但不想影响您的原始数组。
这是一个示例,说明我们如何创建数组的副本:
import java.util.Arrays;public class ArrayHelperMethods {
public static void main(String[] args) {
int[] results = {10, 20, 30, 40, 50};
int[] copiedResults = Arrays.copyOf(results,
results.length);
System.out.println(Arrays.toString(copiedResults));
}
}
输出将如下所示:
[10, 20, 30, 40, 50]
我们可以用以下代码证明我们已经复制了数组:
copiedResults[0] = 1000;System.out.println(Arrays.toString(copiedResults));
System.out.println(Arrays.toString(results));
如果我们没有创建副本,而是将其存储在另一个变量中,它将同时改变两个数组。前面的代码片段将给出以下输出:
[1000, 20, 30, 40, 50][10, 20, 30, 40, 50]
但假设我们有以下代码:
int[] copiedResults = results;copiedResults[0] = 1000;
System.out.println(Arrays.toString(copiedResults));
System.out.println(Arrays.toString(results));
它将给出以下输出:
[1000, 20, 30, 40, 50][1000, 20, 30, 40, 50]
如您所见,这改变了持有数组的两个变量。这是因为这两个变量,copiedResults 和 results,都指向相同的数组对象。所以,如果您在一个地方更改它,它就会对两个都进行更改。这就是为什么有时您需要创建数组的副本。
因此,copyOf 方法创建了一个与原始数组具有相同元素的新数组,而第二种方法只是创建了一个指向相同数组对象的新变量。我们还可以通过传递第二个参数来调整数组的大小。
使用 copyOf() 方法调整数组大小
数组的大小是固定的,但有时你仍然需要更改大小。我们刚刚看到的 Arrays.copyOf() 方法也适用于调整数组大小。要调整数组大小,你可以创建一个具有所需大小的新数组,并将原始数组中的元素复制到新数组中。你只需要提供一个第二个参数。
这里有一个示例,演示了如何调整大小:
import java.util.Arrays;int[] originalArray = {10, 20, 30, 40, 50};
int newLength = 7;
int[] resizedArray = Arrays.copyOf(originalArray, newLength);
System.out.println("Original array: " + Arrays.toString(originalArray));
System.out.println("Resized array: " + Arrays.toString(resizedArray));
输出将如下所示:
Original array: [10, 20, 30, 40, 50]Resized array: [10, 20, 30, 40, 50, 0, 0]
在这个例子中,我们将长度为 5 的 originalArray 调整大小到新的长度 7。新数组包含原始数组中的元素,后面跟着默认值(对于 int 是 0)来填充剩余的位置。
这不是你应该经常做的事情。在性能方面可能效率低下。如果您需要经常调整数组大小,那么查看 第十三章,在那里我们学习了集合,是值得的。
equals() 方法
我们将要讨论的最后一个内置方法是 equals() 方法。此方法可以确定两个数组是否具有相同的值。使用此内置方法,您可以比较两个数组是否相等。以下是操作方法:
import java.util.Arrays;public class ArrayHelperMethods {
public static void main(String[] args) {
int[] results1 = {10, 20, 30, 40, 50};
int[] results2 = {10, 20, 30, 40, 50};
boolean arraysEqual = Arrays.equals(results1,
results2);
System.out.println("Are the arrays equal? " +
arraysEqual);
}
}
输出将如下所示:
Are the arrays equal? true
equals() 方法逐个元素比较两个数组,以检查它们是否具有相同顺序的相同值。如果数组相等,则返回 true;否则返回 false。
干得好!
你在数组学习方面做得很好!在这个阶段,你准备好理解这个编程笑话了:
为什么 Java 开发者辞职了?
因为他们无法得到“数组!”
练习
数组对于存储和管理类似类型的数据非常有用,例如恐龙名称列表、恐龙重量和游客最喜欢的零食。数组很有用,并且使我们能够管理 Mesozoic Eden 中的更复杂数据。尝试以下操作:
-
我们公园的独特吸引力在于我们恐龙种类的多样性。(而且我们确实有恐龙。)创建一个数组来存储公园中所有恐龙种类的名称。这份清单将帮助我们进行库存管理。
-
每位游客都有自己的最爱恐龙,对许多人来说,那就是最重的那个。编写一个程序来找出这个明星在恐龙重量数组中的重量。然后,我们可以将此信息突出显示在我们的公园游览和教育项目中。
-
恐龙有各种大小,最小的那些在孩子们的心中占有特殊的位置。编写一个程序来找出这个最小的恐龙在恐龙重量数组中的位置。
-
经营一个恐龙公园不是一个人的表演,需要一支专门的员工团队。创建一个公园员工名称数组,并使用增强型
for循环打印出这些名称。这将帮助我们更有效地欣赏和管理我们的员工。 -
为了确保我们恐龙居民的福祉,监测它们的平均年龄是至关重要的。这些数据可以帮助我们更好地调整我们的护理和喂养计划,以适应我们恐龙的年龄特征。编写一个程序,使用恐龙年龄数组来计算这个值。
-
我们的公园被细致地划分为各个区域,以方便游客导航和恐龙的饲养。创建一个表示公园地图的二维数组,每个单元格包含一个字符串数组,表示某个区域的围栏或设施。
-
公园之旅的乐趣在很大程度上取决于舒适的座位安排。使用嵌套循环从二维数组中打印出公园游览巴士的座位图。这将帮助我们确保每位游客在整个公园的旅程中都有愉快的体验。
项目 – 恐龙追踪器
安全总是第一位的。这就是为什么跟踪我们所有的恐龙居民至关重要。公园管理者需要有一个易于使用的系统来管理他们这些略带异国情调的宠物的信息。
对于这个项目,你将创建一个恐龙追踪器。这是一个简单的追踪系统,用于记录每只恐龙的名称、年龄、物种和围栏编号。这将通过固定数组来完成——总共四个数组,每个属性一个。
假设你公园里现在有 10 个恐龙的空间,所以每个数组应该有 10 个长度。每个恐龙将对应数组中的一个索引。例如,如果恐龙“Rex”在名称数组的第一个位置,它的年龄、物种和围栏编号也将分别位于它们各自数组的第一个位置。
你将打印有关所有恐龙的信息,并在之后打印它们的平均年龄和体重。
我意识到这可能会很多。如果你需要一些额外的指导,以下是一些指导你通过这个过程的步骤:
-
dinoNames、dinoAges、dinoSpecies和dinoEnclosures。每个数组的大小应为 10。 -
名称使用
Dinosaur1、Dinosaur2等。 -
显示详情:编写一个循环遍历数组,并以可读的格式打印出每只恐龙的详细信息。
-
将
dinoAges数组除以恐龙的数量。当然,这个过程对于体重也是类似的,但使用体重数组。
概述
在本章中,我们探讨了 Java 中的数组。数组是数据结构,允许我们在连续的内存块中存储相同数据类型的多个值。它们提供了一种有效的方式来组织数据列表。
我们首先讨论了数组的声明和初始化。我们学习了不同的声明和初始化数组的方法,包括使用数组初始化的快捷语法。我们还介绍了如何使用默认值初始化数组。
之后,我们讨论了如何使用索引访问和修改数组元素。我们了解了数组长度的重要性,以及我们可以通过使用length属性来找出长度。我们还讨论了通过验证数组索引来避免ArrayIndexOutOfBoundsExceptions。
然后,我们探讨了使用传统的for循环和增强的for循环(即for-each循环)遍历数组的方法。
在此之后,我们探讨了多维数组,即数组的数组,并学习了如何声明、初始化和访问它们的元素。我们还讨论了如何遍历多维数组。
最后,我们使用Arrays类及其内置方法介绍了常见的数组操作。我们看到了如何排序数组,在排序数组中搜索元素,用特定值填充数组,复制和调整数组大小,以及比较数组。
通过掌握这些概念,你现在有了在 Java 中使用数组的坚实基础。这种理解将帮助你更有效地在 Java 程序中存储和处理数据。我们最后查看了一些内置方法。在下一章中,你将学习如何编写自己的方法。
第七章:方法
在第六章中,我们学习了 Java 中的数组。我们了解到数组是固定大小的数据结构。它们存储在连续的内存位置中,每个位置的数据类型相同。我们还看到了如何声明、初始化和处理数组。传统和增强的for循环都非常适合处理数组。
此外,我们还讨论了多维数组,包括它们的组织方式和处理方法。最后,由于数组非常常见,我们讨论了Arrays类,它有几个用于处理数组的实用方法。
在本章中,我们将介绍方法。方法使我们能够创建一个可以执行于代码其他部分的命名代码块。首先,我们将解释为什么方法如此普遍。您将学习方法定义和方法调用的区别。我们将探讨方法签名是什么,以及方法重载如何使方法具有相同的名称,而不产生冲突。我们还将解释变量参数(varargs),它允许方法以 0 个或多个参数执行。最后,我们将概述 Java 的按值传递参数(和返回值)的原则。到本章结束时,您将能够编写和执行方法。此外,您将理解方法重载、varargs和 Java 的按值调用机制。
本章涵盖了以下主要主题:
-
解释为什么方法很重要
-
理解方法定义和方法执行之间的区别
-
探索方法重载
-
解释
varargs -
掌握按值调用
技术要求
本章的代码可以在 GitHub 上找到:github.com/PacktPublishing/Learn-Java-with-Projects/tree/main/ch7。
解释为什么方法很重要
方法是为便于引用而命名的代码块。它们可以接受输入并返回输出。输入和输出都是可选的。方法应该完成一个任务并且做得很好。将方法保持简短(少于 20 行)被认为是良好的实践。方法越长,它做得多的事情就越多。这里的“保持简单”的原则适用。
控制流程
简而言之,当方法被调用(执行)时,执行的控制流程会发生变化。让我们讨论一个简单的例子,这将有助于展示这一点。这是一个重要的观点,特别是对于经验不足的开发者来说。图 7.1展示了代码:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_07_1.jpg
图 7.1 – 一个非常简单的函数
在这个例子中,我们有两个方法:main()方法(第 4 至 8 行)和simpleExample()方法(第 9 至 11 行)。它们都存在于Methods类中(第 3 至 12 行)。
在 Java 中,每个程序都以main()方法开始。JVM 代表我们调用它;我们不必自己调用(或执行)它。因此,在这个例子中,main()中的第一行,第 5 行,是第一条要执行的行。
第 6 行很重要——这就是我们所说的方法调用。第 9 行的simpleExample()方法定义与第 6 行的方法调用之间存在直接关联。我们将在稍后讨论这种关系。目前,只需理解方法调用改变了程序的执行顺序。通常,Java 从上到下执行代码行,这是真的。然而,方法调用改变了这一点。在本例中,当第 6 行执行时,接下来要执行的行是第 10 行(在simpleExample()方法内部)。
因此,main()方法现在已经将控制权交给了simpleExample()方法,并且只有在simpleExample()方法退出后,控制才会返回到main()。这可以在执行到simpleExample()方法末尾的闭合}时发生(第 11 行)。这正是本例中的情况。或者,一个方法可以通过使用return关键字来退出。
因此,第 6 行调用了simpleExample()方法,导致其代码执行。第 10 行将一些文本输出到屏幕上。第 11 行的闭合}导致simpleExample()退出,现在控制权返回到main(),执行从第 7 行恢复。
总结来说,这个程序的执行顺序可以通过输出来说明:
main: before call to simpleExample() Executing simpleExample() method...
main: after call to simpleExample()
在这里,你可以看到println(),在simpleExample()方法内部,被main()方法中的两个println()语句所包围。这表明控制流在第六行的方法调用中被改变。
栈
那么,调用方法,例如main(),如何在simpleExample()方法返回后简单地恢复到它离开的地方?main()的局部变量又是如何的呢?
方法能够在调用它的方法返回后精确地恢复到它离开的地方,这需要使用一种称为栈的内存结构。我们将在本章后面讨论栈。
回到我们关于“为什么方法很重要”的讨论,方法的主要优势有两个:它们提供了抽象并避免了代码重复。我们将依次探讨这些优势。
抽象
抽象是软件工程中的一个原则,其中服务的客户端被从服务实现中抽象出来。这解耦了使用服务的客户端,使他们不必知道服务是如何实现的。因此,如果服务实现发生变化,客户端不会受到影响。
以麦当劳的免下车服务为例,你开车到那里并下订单。在这种情况下,你是麦当劳服务的客户。你不在乎麦当劳如何处理你的订单;你只是想下订单并收到食物/饮料。如果麦当劳更改其内部实现,你会受到保护(抽象化)免受这些更改的影响。这被称为抽象。
对于我们的目的而言,方法本身就像是麦当劳的服务。方法调用就像是麦当劳的客户。方法调用抽象了方法代码的内部变化。
代码重复
方法可以帮助我们避免代码重复。这还有一个额外的优点,就是简化调试。让我们来看一个简单的例子。图 7.2 展示了重复的代码:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_07_2.jpg
图 7.2 – 重复的代码
在前面的图中,第 8 行到第 12 行在第 14 行到第 18 行重复,第 20 行到第 24 行也重复。这些部分中的每一个都提示用户输入一个数字,将用户输入存储在名为 number 的变量中,并检查该数字是否在范围内。如果数字超出范围,则标记错误。虽然循环会是一个明显的改进,但请记住,这些代码行可能位于程序的单独部分。此外,对于这个简单的例子,我们只对突出代码重复感兴趣。我们只是提示输入一个数字,接受用户的输入,并验证它。结果是五行代码重复了三次。
现在,假设我们想要将有效范围从 10 调整到 100。我们必须修改第 8、14 和 20 行的提示。此外,第 10、16 和 22 行的 if 语句也需要修改。因此,简单的范围调整导致了相当多的代码更改,我们很容易忘记进行一个或多个必要的更改。让我们将代码重构为一个方法。图 7.3 展示了重构后的代码:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_07_3.jpg
图 7.3 – 将图 7.2 中的代码重构为使用方法
在前面的图中,方法本身是从第 9 行到第 17 行编写的,将在下一节中详细解释。图 7.2 中的五行重复代码只在第 11 行到第 15 行编写了一次。方法的执行调用在第 5、6 和 7 行;每行一个执行调用。如果我们想将有效范围从 10 调整到 100,我们只需修改方法即可——即第 11 和第 13 行。这两个更改会自动反映在整个代码中。实际上,第 5、6 和 7 行的三个方法调用会自动反映方法中的更改。
如您所想象,这种情况非常易于扩展。例如,如果在图 7.2 中我们重复了代码 10 次,我们就需要在代码的 10 个区域进行更改。然而,有了方法实现,仍然只有一个地方需要更改,那就是方法本身。
既然我们已经解释了为什么需要方法,让我们来检查方法本身和方法调用之间的区别。
理解方法定义和方法执行之间的区别
对于编程新手来说,可能会惊讶地知道,要让方法做某事有两个部分。首先,我们必须编写方法(方法定义)。这类似于街上的自动取款机 – 它只是静静地坐着,什么也不做,等待被使用。其次,我们必须执行方法(方法执行)。这类似于客户“使用”自动取款机。记住,main方法是唯一由 JVM 自动执行的方法。任何其他方法调用都必须明确编码。
现在,让我们依次检查方法定义和方法执行。
方法定义
方法定义(声明)本身就是方法代码 - 这是在调用方法时执行的代码块。图 7.4 展示了语法:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_07_4.jpg
图 7.4 – 方法定义的语法
在前面的图中,与其他图一样,方括号表示可选元素。access-modifier 和 static 元素将在第八章中讨论。throws someException 元素将在第十一章中介绍。在本章中,我们将重点关注加粗的元素;即,return-type(必需)、methodName(必需)和parameters(可选)。
方法的返回类型可以是原始类型、引用类型或void。void关键字表示该方法不返回任何内容。如果是这种情况,你不能简单地省略返回类型;你必须指定void。此外,当你从方法中不返回任何内容时,你可以指定return;或完全省略return关键字(这是我们为所有main()方法所做的那样)。
让我们检查一个接受输入并返回结果的方法。图 7.5 展示了这样一个例子:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_07_5.jpg
图 7.5 – 示例方法定义
在前面的图中,我们有一个接受两个整数和一个要使用这两个整数作为操作数执行数学运算的方法。例如,如果传入"+",则两个数字相加并返回结果。让我们回顾一下方法是如何做到这一点的。
第 15 行非常重要。目前,如前所述,第八章将解释public(访问修饰符)和static。return-type是int – 这意味着这个方法返回整数。方法的名字是performCalc。方法名通常以动词开头,并遵循驼峰命名法。
注意,圆括号跟在方法名后面。圆括号是方法可选输入参数的分隔符。对于每个参数,你必须指定参数的数据类型(因为 Java 是一种强类型语言)和参数的标识符名称。如果你有两个或更多参数,用逗号分隔它们。这些参数是方法接受输入的方式。在 图 7.5 中,我们有两个整数 x 和 y,后面跟着一个名为 operation 的 String。在这种情况下,任何方法参数(例如 x、y 和 operation)的作用域是整个方法。
第 16-26 行封装了一个 switch 表达式。实际上,根据传入的数学 operation,该操作会在两个输入 x 和 y 上执行。局部 int 变量 result 根据相应地初始化。第 27 行返回 result 变量。由于第 15 行声明的返回类型是 int,返回 result(它也是一个 int),这是可以的。
方法定义本身并不做任何事情。它只是定义了一块代码。如前所述,这类似于街上的自动取款机 – 它只是坐在那里,什么也不做,等待被使用。为了让自动取款机有用,你必须“使用”它。同样,我们必须“使用”方法 – 这就是我们所说的执行方法。
方法执行
执行方法也称为调用或调用方法。调用方法的方法称为“调用”方法(或调用者方法)。所以,你有调用方法和被调用方法。当你调用一个方法时,如果你有,你会传递所需的参数。被调用方法将在这一点上执行。当被调用方法完成后,控制权返回到调用方法。如果被调用方法有结果,该结果也会返回。这使得被调用方法能够将数据返回给调用方法,在那里它可以输出到屏幕、存储在变量中,或者简单地被忽略。
方法参数与方法参数
方法定义定义了参数,而方法调用传递参数。这些术语通常被交替使用。
图 7.6 展示了一个代码示例,以帮助进一步解释这一点:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_07_6.jpg
图 7.6 – 展示方法调用的示例代码
IntelliJ IDEA 内联提示
注意,当你编码时,IntelliJ 编辑器会插入内联提示。在上一幅图中,performCalc 方法签名(第 13 行)指定参数为 x、y 和 operation。这就是为什么在每个方法调用中,内联提示都使用这些参数名称。例如,在第 5 行,我们输入了 10 作为第一个参数;然而,IntelliJ 在检查方法签名后意识到 10 映射到 x,这就是为什么你会看到“performCalc(10, 2, "+")”以及 IntelliJ 将其转换为 performCalc(x: 10, y: 2, operation: "+") 的原因。
在图 7.6 中,performCalc方法(第 13-26 行)与图 7.5 中的没有变化。然而,我们现在可以看到各种方法调用(第 5 行和第 7-11 行)。
让我们从第 5 行开始。在赋值的右侧,我们有performCalc(10, 2, "+")方法的调用。这个方法调用比赋值有更高的优先级,所以它首先被执行。IntelliJ IDE 非常出色地突出显示了10将被作为x传递给方法,2将被作为y传递给方法,而"+"将作为operation传递。非常重要的一点是,一旦我们到达第 5 行的方法调用,接下来执行的代码是第 14 行——所以,从第 5 行开始,我们跳入performCalc方法,并开始执行第 14 行的switch表达式。
由于这次方法调用中的operation是"+",第 15 行将 10 + 2(12)赋值给result。第 25 行将result的值返回到调用方法(第 5 行),其中值 12 被赋值给result。第 6 行输出了第 5 行performCalc调用的返回值,即 12。
不同的作用域
注意,两个result变量(第 5 行和第 14 行)完全不同,因为它们在不同的作用域中——一个在main()方法中,另一个在performCalc方法中。因此,没有任何冲突或歧义。
第 7 行在println的()内执行了System.out.println()方法调用。在这种情况下,Java 将执行println的()内的方法调用,然后方法返回的内容将被输出到屏幕。所以,对于第 7 行,传递给performCalc的参数是10、2和"-"。因此,在performCalc中,x是 10,y是 2,operation是"-"。switch表达式现在执行第 16 行,导致result变为 8(10 - 2)。这个result被返回(第 25 行)到调用方法(第 7 行),其中8被输出到屏幕。
第 8 行和第 9 行的操作与第 7 行类似,除了switch表达式中执行的代码行不同。第 8 行的方法调用执行了switch表达式中的第 17 行,导致result被初始化为 20。这个值被返回到调用方法(第 8 行),其中20被输出到屏幕。第 9 行的方法调用执行了switch表达式中的第 18 行,导致result被初始化为 5,因此5被输出到屏幕。
第 10 行导致switch表达式中的第 19 行被执行,初始化result为0(10 % 2)。这个result被返回到调用方法,由于它没有被存储在变量中,所以它只是被丢失/忽略。
第 11 行的performCalc调用传递了"&",这执行了switch表达式的default分支。这导致屏幕上显示错误消息“未识别的操作:&”,并返回-1。然后-1 被输出到屏幕。
现在我们已经知道了如何定义和执行方法,我们将继续讨论方法重载,其中不同的方法可以具有相同的标识符名称。
探索方法重载
考虑一个场景,你有一个算法,通过一个方法实现,它在各种输入类型上操作相似——例如String和int。如果为每种输入类型分别构造两个不同的方法名,比如doStuffForString(String)和doStuffForInt(int),那就太遗憾了。如果两个方法都有相同的名称——即doStuff——并通过它们的输入类型区分,即doStuff(String)和doStuff(int),那就好多了。这样就不会有构造的方法名。这正是方法重载提供的。为了正确地讨论方法重载,我们首先必须定义方法签名。
方法签名
方法签名由方法名和可选参数组成。它不包括返回类型。让我们通过一个例子来进一步解释这一点:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_07_7.jpg
图 7.7 – 方法签名
在前面的图中,方法签名用虚线矩形突出显示。它由方法名、参数的类型和顺序组成。这意味着图7.7中的方法签名是performCalc,它接受两个整数和一个String,按此顺序。请注意,参数名并不重要。因此,实际上,从编译器的角度来看,方法签名是performCalc(int, int, String)。
方法重载
当两个或多个方法具有相同的名称但参数类型和/或顺序不同时,方法就是重载的。如果你从编译器的角度来看,这很有意义。如果你调用一个有两个或多个定义的方法,编译器将如何知道你指的是哪一个?为了定位正确的方法定义,编译器会将方法调用与重载的方法签名进行比较和匹配。图 7.8展示了一个具有各种签名的重载方法:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_07_08.jpg
图 7.8 – 方法签名对重载的影响
在这个图中,someMethod方法被重载了多次。第 6 到第 10 行的方法签名分别是someMethod()、someMethod(int)、someMethod(double)、someMethod(String)和someMethod(double, int)。
有趣的案例是第 11-13 行的编译器错误。第 11 行的错误是编译器的一个误导性错误。换句话说,如果我们注释掉第 12 和第 13 行,第 11 行的编译器错误就会消失。第 11 行没有问题,因为这是编译器第一次看到这个特定的方法签名——即someMethod(int, double)。问题是第 12 到第 13 行有相同的方法签名,编译器正在标记所有具有该签名的行。
第 12 行强调了参数名称并不重要,因为它们不是方法签名的一部分。因此,它们在 11 行被命名为x和y,在 12 行被命名为a和b,这根本无关紧要。
同样,第 13 行表明返回类型不是方法签名的一部分。第 13 行是一个编译器错误,因为它的签名someMethod(int, double)与第 11 行和第 12 行的签名相同,尽管这两个方法有不同的返回类型(分别是int和void)。
总结来说,返回类型和参数名称都不是方法签名的一部分。现在我们已经了解了方法签名中包含什么(以及不包含什么),让我们看看一个简单的方法重载示例。图 7.9展示了代码:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_07_9.jpg
图 7.9 – 方法重载示例
在这个图中,我们有一个重载的add方法。第一个版本(第 10 到 13 行)接受两个int参数;第二个版本(第 14-17 行)接受两个double参数。它们各自的签名分别在第 10 行和第 14 行捕获。因此,当我们第 5 行调用add并传递两个整数时,编译器将调用与第 10 行的add版本匹配,因为该版本的add接受两个整数。同样,第 7 行的add调用与第 14 行的add匹配,因为调用和方法签名都匹配(两者都是两个double类型)。
现在我们已经了解了方法参数类型及其顺序如何影响方法重载,让我们来看看 Java 如何使我们能够执行参数数量可变的函数。
解释可变参数
考虑以下情况:你想调用一个方法m1,但参数的数量可能不同。你是否需要为每个版本的方法重载,每个版本的方法都多接受一个参数?例如,假设参数类型是String类型,你是否在每次新版本中多接受一个String参数时重载m1?在这种情况下,你将不得不编写m1(String)、m1(String, String)、m1(String, String, String)等等。这并不具有可扩展性。
这就是varargs发挥作用的地方。varargs是 Java 中一个非常灵活的语言特性,专门为此用例提供。语法是类型名称后跟省略号(三个点)。图 7.10显示了varargs的实际应用:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_07_10.jpg
图 7.10 – 可变参数示例
在这个图中,在第 10 行,m1(int… )为m1方法定义了一个方法签名,定义了 0 个或多个int参数。这与第 4 行定义的main的String[]非常不同。实际上,你根本不需要向m1传递任何参数;或者你可以传递 1 个、2 个、3 个或更多的整数。这通过方法调用(第 5-8 行)显示出来。在m1方法内部,varargs被视为一个数组。第 12-14 行的for循环展示了这一点。
图 7.10的输出如下:
01
3
6
第 5 行没有任何输出。第 6 行生成1;第 7 行生成3;第 8 行生成6。
让我们通过图 7.11来检查一些varargs的边缘情况:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_07_11.jpg
图 7.11 – varargs 编译器错误
在前面的图中,我们可以看到varargs必须是方法定义中的最后一个参数。第 10 行是正确的,因为它将varargs参数定义为最后一个参数。然而,第 11 行是编译器错误,因为它试图在varargs参数之后定义一个参数。这是有道理的,因为所有其他参数都是强制性的;所以,如果varargs可以定义 0 个或多个参数,它必须是最后一个参数。
由于varargs被当作数组处理,这引发了一个问题:我们能否用数组代替varargs?答案是:不可以。编译器错误(第 5-8 行)都与这样一个事实有关,即尽管在第 12 行有m1(int[])的定义,但编译器无法找到与这些方法调用匹配的方法定义。
方法中的最后一个重要主题是按值调用。我们现在将讨论这个主题。
掌握按值调用
Java 在向方法传递参数和从方法返回结果时使用按值调用。简而言之,这意味着 Java会复制某个东西。实际上,当你向方法传递一个参数时,会复制该参数;当你从方法返回一个结果时,会复制该结果。我们为什么要关心这个?好吧,根据你复制的内容——原始类型或引用类型——int是一个引用类型的例子。
在方法中,当参数是原始类型和引用类型时,改变的效果有明显的区别。我们将通过一个代码示例来演示这一点,但首先,为了欣赏这些差异,我们需要理解内存中发生了什么。
内存中的原始类型与引用类型
数组是一个对象,而原始类型不是。我们将在第八章中详细讨论对象,但现在,让我们检查图 7.12中的代码:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_07_12.jpg
图 7.12 – 包含原始类型和数组的示例代码
要理解前图中代码在内存中的样子,我们需要讨论栈、堆和引用。
栈
栈是方法使用的一个特殊内存区域。每次调用一个新的方法 A 时,就会在栈上 push(创建)一个新的帧。该帧包含诸如 A 的局部变量及其值等内容。每个帧像盘子一样一个压一个地堆叠起来。如果 A 调用了另一个方法 B,则 A 的现有帧会被保存,并在栈上为 B 推入一个新的帧,从而创建一个新的上下文。当 B 执行完毕后,其栈帧会被从栈中 pop(移除),然后恢复 A 的帧(包括所有局部变量及其值,就像在调用 B 之前一样)。这就是为什么栈被称为 后进先出(LIFO)结构。有关栈和 Java 内存管理的更多详细信息,请参阅我们之前的书籍:www.amazon.com/Java-Memory-Management-comprehensive-collection/dp/1801812853/ref=sr_1_1?crid=3QUEBKJP46CN7&keywords=java+memory+management+maaike&qid=1699112145&sprefix=java+memory+management+maaike%2Caps%2C148&sr=8-1
)
对于我们这里的讨论,我们需要意识到局部变量(原始数据类型和/或引用)存储在栈上。对象 不 存储在栈上;对象存储在堆上。
堆
堆是为对象和数组预留的内存区域,而数组也是对象。这意味着数组存储在堆上。要访问一个对象,我们使用引用。
参考文献
用于访问对象的命名标识符称为引用。引用类似于指针。考虑一台没有按钮来更换频道但有一个遥控器的电视。引用是遥控器,而电视是对象。
在理解了这些定义之后,让我们回顾一下 图 7.12 中的代码。第 5 行声明了一个原始数据类型 int 叫做 x 并将其初始化为 19。第 6 行声明了一个 int 数组,即 arr,并将 arr[0] 初始化为 1,arr[1] 初始化为 2。数组引用是 arr。图 7.13 显示了当我们到达第 7 行时 图 7.12 的内存表示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_07_13.jpg
图 7.13 – 图 7.12 代码的内存表示
在前面的图中,我们可以看到有一个 main 方法的栈帧,其中包含局部变量 x 和 arr。请注意,为了简化,省略了 main 中的 String[] args 参数。立即,你可以看到原始数据类型(即 x)和引用(即 arr)的存储方式之间的区别 – x 和其值存储在栈上;而 arr 的值则指向堆上的对象。
考虑到这一点,我们现在可以检查一个合适的代码示例,以展示在传递原始数据类型和引用时按值传递的实际影响。图 7.14代表了我们将要使用的代码示例:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_07_14.jpg
图 7.14 – 按值传递原始数据类型和引用
在这个图中,callByValue方法定义在第 13-17 行:该方法按顺序接受一个int类型和一个int数组,并返回一个int。第 14 行将int参数的值改为-1,第 15 行将数组的索引 0 改为-1。最后,该方法在第 16 行返回x的值。
让我们检查对callByValue的第一次调用,传递x和arr参数。重要的是要注意,在main中声明的x和arr变量与在callByValue方法中声明的x和arr参数是完全不同的变量。这是因为它们在两个不同的作用域(方法)中。由于 Java 使用按值传递,原始数据x和引用arr的副本被创建,并且传递到callByValue方法的是这些副本。
复制一个原始数据类型就像复印一张空白纸——如果你把复印的纸给某人,他们可以在上面写字,你的原始空白纸仍然是空的。复制一个引用就像复制一个遥控器——如果你把第二个遥控器(复制的那个)给另一个人,他们可以改变电视的频道。关键在于,这里只有一个电视——复制的是遥控器,而不是电视。
注意
这样做可以节省内存,因为复制一个引用的内存占用远小于复制一个可能很大的对象。
图 7.15代表了在即将从callByValue的第一次调用return时的内存表示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_07_15.jpg
图 7.15 - 基于 7.14 行 16 的内存视图(基于第一次调用 callByValue 行 7)
如前一个图中的堆栈所示,当我们从main中调用callByValue(x, arr)时,现有的main帧被保存,并在main帧的上面压入一个callByValue帧。然后执行callByValue的代码:
x = -1:arr[0] = -1;
return x;
首先,在callByValue帧中改变了x的值。这是从main中复制来的x的副本。注意,main中的x的值保持不变(仍然是 19)。因此,main中的x输出为 19(第 8 行)。因此,被调用的方法不能(直接)改变调用方法的基本值。我们很快会回到这个点上。
然而,callByValue 中的 arr[0] = 1; 行确实对 main 有实质性影响。当 callByValue 使用其 arr 引用,这是从 main 复制的 arr 引用时,它会改变两个方法共享的一个对象。实际上,main 正在查看的数组对象被改变了。这可以在 callByValue 方法返回后的 main 中看到:
System.out.println(arr[0] + ", " + arr[1]); // -1, 2
关键的是,arr[0] 的值输出为 -1。因此,请注意,在传递方法引用时,方法可以更改你正在查看的对象。
让我们回顾一下原始情况。如果我们想让被调用的方法改变传递下来的原始值,这是为什么 callByValue 会返回 x。第一次对 callByValue 的调用完全忽略了返回值:
callByValue(x, arr);
然而,第二次调用并不:
x = callByValue(x, arr);
从 callByValue 返回的 -1 用于覆盖 main 中 x 的值。因此,main 中的 x 输出为 -1(第 11 行)。
这就完成了我们对方法的讨论。现在,让我们将这些知识付诸实践,以巩固这些概念。
练习
维护恐龙公园需要的不仅仅是热情。它包括对我们恐龙的定期健康检查,确保我们的客人感到舒适,以及公园有足够的员工。所有这些任务都涉及系统化的过程。幸运的是,我们现在知道了方法!
您可以将这些方法添加到同一个类中:
-
恐龙所处的生命阶段可以显著影响其行为和需求。编写一个接受恐龙年龄并返回其是孵化幼体、幼崽还是成年恐龙的方法。
-
重要的是要记住,我们的恐龙实际上并不是宠物——它们是体型庞大、通常很重的生物,有着很大的胃口。编写一个接受恐龙体重并计算其每日所需食物量的方法。
-
了解我们恐龙的平均年龄有助于我们规划未来。设计一个接受恐龙年龄数组的数组并计算平均年龄的方法。
-
公园不是全天 24 小时对日间游客开放。我们需要一些时间来清理爆米花和修复围栏的任何轻微损坏。编写一个根据当前时间检查公园是否开放或关闭的方法。(提示:此方法不需要任何输入。)
-
个性化是让我们的客人感到特别的关键。创建一个使用恐龙的名字和游客的名字来制作个性化问候信息的方法。
-
正如你所意识到的,安全是我们的首要任务。我们需要一种方法来确定我们是否可以让另一组客人(一定数量的人)进入公园,这基于当前游客数量和允许的最大游客数量。
项目 - 中生代伊甸园助手
这将是我们的最大项目。所以,系好安全带!
让我们从高级描述开始。中生代伊甸园助手是一个交互式控制台应用程序,用于管理恐龙公园。助手应该具备以下功能:
-
添加或删除恐龙
-
检查公园的营业时间
-
欢迎客人并提供公园信息
-
跟踪游客数量以确保公园不会过于拥挤
-
管理公园员工详细信息
由于我们不会让你淹死,如果你需要,这里有一个逐步指南。起始项目将遵循以下步骤:
-
Dinosaur、Guest和Employee。包括适当的属性和方法。 -
Scanner类。 -
Scanner类。 -
创建菜单:创建一个菜单,允许用户与公园管理系统交互。
-
处理操作:每个菜单项应触发特定的操作,例如添加恐龙、检查公园营业时间或欢迎客人。
-
退出程序:提供一个选项让用户退出程序。
这里有一个代码片段来帮助你开始:
import java.util.Scanner;public class Main {
// Use Scanner for reading input from the user
Scanner scanner = new Scanner(System.in);
public static void main(String[] args) {
Main main = new Main();
main.start();
}
public void start() {
// This is the main loop of the application. It
will keep running until the user decides to exit.
while (true) {
displayMenu();
int choice = scanner.nextInt();
handleMenuChoice(choice);
}
}
public void displayMenu() {
System.out.println("Welcome to Mesozoic Eden
Assistant!");
System.out.println("1\. Add Dinosaur");
System.out.println("2\. Check Park Hours");
System.out.println("3\. Greet Guest");
System.out.println("4\. Check Visitors Count");
System.out.println("5\. Manage Staff");
System.out.println("6\. Exit");
System.out.print("Enter your choice: ");
}
public void handleMenuChoice(int choice) {
switch (choice) {
case 1:
// addDinosaur();
break;
case 2:
// checkParkHours();
break;
case 3:
// greetGuest();
break;
case 4:
// checkVisitorsCount();
break;
case 5:
// manageStaff();
break;
case 6:
System.out.println("Exiting...");
System.exit(0);
}
}
}
因此,这是一个很好的起点!但还没有完成。在前面的代码片段中,addDinosaur()、checkParkHours()、greetGuest()、checkVisitorsCount()和manageStaff()是方法占位符,你需要根据你的数据结构和功能实现这些方法。Scanner类用于从控制台读取用户的菜单选择。
你可以通过添加额外的功能和增强来使项目变得尽可能复杂。
摘要
在本章中,我们通过说明方法只是被赋予名称以便于引用的代码块来开始对方法的讨论。方法之所以重要,是因为它们使我们能够抽象出实现,同时帮助我们避免不必要的代码重复。
一个方法有两个部分:方法定义(或声明)和方法调用(或调用)。方法定义声明(包括其他内容)了方法名称、输入参数和返回类型。方法名称和参数类型(包括它们的顺序)构成了方法签名。方法调用将(如果有的话)传递参数作为方法的输入。如果方法有返回值,可以通过将方法调用赋值给变量来捕获返回值。
方法重载是在几个不同的方法中使用相同的方法名称。区分各种方法的是它们有不同的签名——参数类型和/或它们的顺序将不同。参数名称和返回类型并不重要。
在方法声明中使用省略号(三个点)指定了一个varargs(可变参数)参数。这意味着在调用此方法时,对应该参数的参数是可变的——你可以传递 0 个或更多参数。在方法内部,varargs参数被视为一个数组。
当向方法传递参数时,Java 使用按值传递。这意味着会创建参数的副本。根据你传递的是原始数据类型还是引用类型,对被调用方法对调用方法产生的影响有重大影响。如果是原始数据类型,被调用方法不能改变调用方法中的原始数据类型(除非调用方法故意用返回值覆盖变量)。如果是引用类型,被调用方法可以改变调用方法正在查看的对象。
现在我们已经完成了对方法的探讨,让我们继续到我们的第一个严格意义上的面向对象编程(OOP)章节,我们将探讨类、对象和枚举。
第二部分:面向对象编程
在这部分,我们将深入探讨抽象类和极其重要的接口结构。随后,我们将研究 Java 的异常框架。最后,我们将探索 Java 核心 API 中的选定类,例如String和StringBuilder。
本节包含以下章节:
-
第八章, 类、对象和枚举
-
第九章, 继承和多态
-
第十章, 接口和抽象类
-
第十一章, 处理异常
-
第十二章, Java 核心 API
第八章:类、对象和枚举
在第七章中,我们学习了 Java 中的方法。在理解了方法为什么有用之后,我们了解到方法有两个部分——方法定义和方法调用。我们看到,方法定义是在通过方法调用调用方法时执行的代码。我们讨论了方法签名如何实现方法重载。我们还学习了varargs如何帮助我们用零个或多个参数调用方法。最后,我们讨论了 Java 的按值调用机制,其中传递给方法的参数在内存中被复制。根据传递的参数类型,原始类型或引用类型,将影响调用方法对调用方法传递的参数所做的更改的效果。
第七章总结了本书的 Java 基础知识部分。该部分的内容在许多编程语言中都是通用的,包括非面向对象编程(OOP)语言,如 C。第八章开始介绍本书的 OOP 部分。
在本章中,我们将涵盖类、对象、记录和枚举。类和对象是面向对象编程语言(如 Java)独有的;换句话说,非 OOP 语言(如 C)不支持它们。尽管它们密切相关,理解类和对象之间的区别很重要。我们将讨论类与类对象之间的关系。要访问对象,我们必须使用引用。将引用与对象分离将在以后证明非常有用。将实例成员与类成员进行讨论,以及何时使用任一或两者。本章还将解释'this'引用及其与当前执行实例方法的对象之间的关系。
我们还将解释 Java 中的访问修饰符。这些访问修饰符是实现面向对象编程(OOP)的关键基石之一,即封装。尽管基本的封装可以轻易实现,但正确封装你的类需要额外的注意。这将在高级封装部分进行说明。
理解对象生命周期,考虑到程序执行时内存中的情况,对于避免许多细微的错误至关重要。这个主题将通过图表进行解释。
在本章的末尾,鉴于我们对引用(以及它们所引用的对象的分离)的理解,我们将讨论instanceof关键字。最后,我们将介绍类的一种变体,即枚举,其中对象实例的数量受到限制。
本章涵盖了以下主要主题:
-
理解类和对象之间的区别
-
对比实例与类成员
-
探索
'this'引用 -
应用访问修饰符
-
实现封装
-
掌握高级封装
-
深入探讨对象生命周期
-
解释
instanceof关键字 -
理解枚举
-
欣赏记录
技术要求
本章的代码可以在 GitHub 上找到,链接为github.com/PacktPublishing/Learn-Java-with-Projects/tree/main/ch8。
理解类和对象之间的区别
由于类和对象对于面向对象编程至关重要,理解它们之间的区别是至关重要的。在本节中,我们将讨论类与其对象之间的关系。由于创建对象需要使用new关键字,这也会被涵盖。我们还将探讨构造函数及其作用。所有这些主题都是相互关联的:对象是类的内存表示(模板);要创建对象,使用构造函数,要调用构造函数,我们使用new关键字。让我们逐一检查这些内容。
类
在 Java 中,类是如此重要,以至于你无法编写任何不定义类的程序!类是你对象的蓝图或模板。它类似于房屋的设计图——使用房屋设计图,你可以讨论房屋的所有内容;然而,你不能走进厨房泡上一杯茶/咖啡。在这一点上,房屋设计图是抽象的,类也是如此。类定义了字段(属性)和操作这些字段的方法。字段是你的数据,方法使你可以操作这些数据。
对象
对象是你对类的内存表示。如果类是你的房屋设计图,那么对象就是你的建成房屋。现在,你可以走进厨房,泡上一杯茶/咖啡。就像房屋和房屋设计图一样,你可以基于类创建许多对象。这些对象被称为对象实例,强调每个对象都是其独特的实例。
总结来说,类是模板,对象是类的内存表示。如果你想执行其(实例)方法,你需要一个对象(实例)。那么,我们如何创建对象呢?我们使用new关键字。
熟悉new关键字
Java 中的new关键字使我们能够创建对象。对象在堆上创建,堆是内存中为对象保留的特殊区域。返回对象的引用(类似于指针)。这个引用使我们能够操作对象;例如,执行实例方法。让我们来检查图 8**.1中显示的代码示例:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_1.jpg
图 8.1 – 创建对象
在前面的图中,第 3 行定义了一个Person类。目前它没有任何内容;随着我们的进展,我们将对其进行扩展。第 6 行很重要——我们正在使用new关键字创建一个Person对象。除了new关键字外,第 6 行与任何方法调用非常相似。p引用(在栈上)被初始化为指向堆上的Person类型对象。名为p的Person引用正指向一个Person对象;然而,在前进的过程中,这种情况很少见。当使用new关键字“构造”对象时,被调用的方法是一个特殊的方法,称为构造函数。
构造函数
构造函数是一个特殊的方法,它由new关键字调用。它有两个独特的属性,使其与其他方法区分开来:它具有与类相同的名称,并且不定义任何返回类型,甚至不是void。(Java 在后台返回对象的引用)。
每个类都包含一个构造函数,即使你没有自己编写。如果你没有为你的类编写构造函数,Java 会为你自动生成(或定义)一个“默认构造函数”。默认构造函数将具有与常规构造函数相同的属性;即,与类相同的名称且没有返回类型。然而,默认构造函数不会定义任何参数;它将具有与类相同的访问修饰符,并且只包含一行代码,即super();。我们将在本章后面讨论访问修饰符,并在第九章中讨论super()。
注意,如果你插入了一个构造函数,默认构造函数就不会被自动生成。这就像编译器说:“好吧,你已经有了构造函数(们),你知道你在做什么,所以我不介入。”
既然我们知道默认构造函数何时由编译器生成,我们就可以看到在图 8**.1中Person和PersonExample都需要默认构造函数。图 8*.2*表示编译器插入默认构造函数后的代码:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_2.jpg
图 8.2 – 插入的默认构造函数
前面图中的红色矩形表示编译器插入的默认构造函数。这发生在两个类上,因为两个类都没有定义任何构造函数,而每个类都需要一个构造函数。除了具有与类相同的名称且不返回任何内容(甚至不是void)之外,默认构造函数不定义任何参数(第 4 行和第 9 行),并且简单地调用super();。正如前一个提示中所述,super()将在我们讨论第九章中的继承时进行讨论。
我们将在后面详细讨论访问修饰符,但请注意,默认构造函数的访问权限与它们各自类的访问权限相匹配。例如,PersonExample 是一个 public 类,因此它的构造函数也是 public(分别在第 8 和第 9 行)。Person 类没有提及任何 显式 访问修饰符,其构造函数也是如此(分别在第 3 和第 4 行)。
现在,你可以看到为什么第 13 行的 new Person(); 不会生成编译器错误。为了清楚起见,第 13 行没有编译器错误,因为编译器插入了 Person 类的默认构造函数(第 4 至 6 行),因此 new Person() 能够找到构造函数并因此编译。
PersonExample 的默认构造函数(第 9 至 11 行)在这个程序中没有实质性的影响。JVM 在每个程序的 main 方法中启动程序。
我们现在将转向讨论实例成员与类成员的区别。请注意,局部变量(在方法中)也不是。
对比实例成员与类成员
一个对象更准确地可以称为对象 实例。这就是 实例 成员(方法/数据)获得名称的地方:每个对象都会获得一个实例成员的副本。然而,类成员是不同的,因为每个类只有一个副本,无论创建了多少对象实例。我们现在将讨论这两个主题。
实例成员(方法/数据)
这一点通过先展示一个代码示例更容易解释。图 8.3 展示了一个具有实例成员的类:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_3.jpg
图 8.3 – 具有实例成员的类
当你使用 new 创建一个对象时,你正在创建一个对象 实例。每个实例都获得实例成员(变量和方法)的副本。关于实例变量,我们需要定义实例变量在哪里声明以及它们的范围。实例变量是在类内部定义的,但不在类中编写的每个方法之外。因此,实例变量的范围是类本身;这意味着,类中的每个实例方法都可以访问实例变量。
现在我们来讨论代码示例。在前面的图中,Person 类定义了实例变量和实例方法。由于实例变量是在每个方法外部声明的,因此它们的范围是类级别的。实例变量被标记为 private 以及实例方法被标记为 public 的原因将在本章后面解释。构造函数如下:
Person(String aName) { // constructor name = aName;
count++;
}
这个构造函数使我们能够传入一个 String 并根据该 String 初始化实例变量。例如,当我们按照以下方式实例化一个对象时:
Person p1 = new Person("Maaike");
我们将"Maaike"传递给构造函数,因此p1引用的对象中的name实例变量引用了"Maaike"。构造函数还通过每次调用构造函数时增加count来记录创建的对象数量。请注意,在这个例子中,编译器没有插入默认的Person构造函数,因为类中已经编写了构造函数。
我们还使用p1和p2引用调用了getName()实例方法,如下所示:
System.out.println(p1.getName()); // MaaikeSystem.out.println(p2.getName()); // Sean
这种refName.instanceMethod()的语法称为点表示法。根据代码中的注释,"Maaike"和"Sean"将按顺序输出到屏幕上。(图 8*.4*)显示了在创建了两个对象,分别由p1和p2引用后,代码在内存中的表示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_4.jpg
图 8.4 – 图 8.3 的内存表示(行 27 开始)
如前图所示,我们有堆栈上的两个引用,即p1和p2。p1引用堆上的第一个Person对象——即第 23 行创建的对象。p1的实例变量值(其“状态”)是name的"Maaike"和count的1。由于字符串是对象,name是一个引用另一个对象的引用,即一个值为"Maaike"的String对象。同样,p2引用指向第 24 行创建的对象。从图中可以看出,p2的实例变量值是name的"Sean"和count的1。
注意,堆上的每个Person对象实例都有一个实例变量的副本。这就是为什么它们被称为实例变量。
第 27 和 28 行将name实例变量的值分别更改为"Maaike van Putten"和"Sean Kennedy",对应于p1和p2。(图 8*.5*)显示了这些更改:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_5.jpg
图 8.5 – 图 8.3 的内存表示(行 29 开始)
此图显示,两个String对象已被更改:p1的实例变量name引用了"Maaike van Putten",而p2的实例变量name引用了"Sean Kennedy"。因此,第 29 到 30 行分别输出了"Maaike van Putten"和"Sean Kennedy"。
字符串不可变性
字符串是不可变对象。这意味着一旦创建了String对象,就不能更改。永远不能。虽然看起来它们已经改变了,因为产生了变化的效果,但实际上已经创建了一个全新的对象,而原始对象保持未变。我们将在第十二章中更详细地回顾String不可变性。
因此,原始的String对象,"Sean"和"Maaike",仍然在堆上占用空间。它们没有用处,因为我们没有对这些对象的引用,所以我们无法访问它们。记住,p1和p2的name实例变量分别引用了新创建的包含"Maaike van Putten"和"Sean Kennedy"的String对象。
那么,这些不再使用的对象会怎样呢?它们会被“垃圾回收”。我们很快就会讨论这个问题,但首先,只需知道 JVM 在后台运行一个名为垃圾回收器的进程来整理(回收)所有无法访问的对象。我们无法控制这个进程何时运行,但有一个垃圾回收器可以让我们免于自己清理(而与其他 OOP 语言如 C++不同,你必须这样做!)。
图 8.3 中的代码存在问题 – count 的值为 1,而它应该是 2。实例变量默认初始化为0。在每次构造函数调用中,我们将count从0增加到1。我们希望第一次构造函数调用将count从0增加到1,而第二次构造函数调用将count从1增加到2。这就是类成员发挥作用的地方。
类成员(方法/数据)
要将字段和/或方法标记为类成员,而不是实例成员,可以在成员的声明中插入static关键字。类成员由类的所有实例共享。这意味着你不需要创建对象实例来访问类的static成员。
访问static成员的语法与访问实例成员的语法不同。而不是使用引用,使用类名,如className.staticMember。这强调了被访问成员的类性质。例如,JVM 使用PersonExample.main()在图 8.3中启动程序。这就是 JVM 以节省构建对象及其内存占用为代价启动每个程序的方式。
让我们回到我们的count问题(它的值应该是 2 而不是 1)。图 8.6表示必须进行的更改以解决这个问题:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_6.jpg
图 8.6 – 使“count”静态化
将图 8.6中的代码与图 8.3中的代码进行对比,我们可以看到count被声明为static(第 5 行)。因此,只有一个count的副本,它在所有Person实例之间共享。因此,p1和p2正在查看相同的count。
在构造函数(第 9 行)中,虽然不是必需的,但我们使用正确的语法来强调 count 的 static 特性。同样,由于 getCount(第 17 行)只是返回一个 static 成员,我们将其标记为 static。此外,我们使用了 Person.count 静态语法(第 18 行)。最后,第 25 行使用正确的语法 Person.getCount 访问了 private 类变量 count。我们可以看到它输出了 2,这是正确的。比较代码中的其他差异,main(图 8*.3*)中的一些额外代码已被删除,以帮助我们关注这里讨论的内容。
实例到静态,但反之则不行
如果您在一个实例方法中,您可以访问一个 static 成员,但反之则不行。当我们解释 this 引用时,我们将讨论为什么。这意味着,在 图 8*.6* 中,您可以使用 p1 引用来访问 getCount 方法(第 25 行)。因此,p1.getCount() 是有效的,但这是一种 较差 的编程实践,因为它给人一种 getCount 是实例方法的印象,而实际上它是一个 static 方法 - 按照代码使用 Person.getCount()。
图 8*.7* 显示了 图 8*.6* 代码的内存表示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_7.jpg
图 8.7 – 图 8.6 代码的内存表示
如前一个图例的右下角所示,Person 类的 static/class 成员与实例本身存储在分开的地方。现在只有一个 count 的副本,它被 p1 和 p2 共享。因此,count 的值为 2 是正确的。
类和实例变量的默认值
每次创建一个新类时,实例变量都会初始化为默认值。
类变量在类首次加载时初始化为默认值。这可能在使用 new 或引用类成员(使用类语法)时发生。
类和实例变量的默认值如下:
| 类型 | 默认值 |
|---|---|
byte, short, 和 int | 0 |
long | 0L |
float | 0.0f |
double | 0.0d |
char | ‘\u0000’ (Unicode 零) |
String (或任何对象的引用) | null |
boolean | false |
表 8.1 – 类和实例变量的默认值
在之前的提示中,我们强调了您可以从实例方法中访问类成员,但反之则不行。现在让我们深入探讨这一点。
探索“this”引用
当您调用一个实例方法时,编译器会秘密地将调用该方法的对象引用的副本传递到方法中。这个引用作为 this 引用对实例方法可用。
类方法不会获得 this 引用。这就是为什么如果你在一个 static 方法(上下文)中尝试直接访问实例成员(没有对象引用),你会得到编译器错误。实际上,每个实例成员在访问时都需要一个对象引用。这很有意义,因为实例成员是实例特定的,因此你需要一个实例(引用)来说明,“我想访问这个特定的实例/对象,而不是那个特定的对象。”
让我们重构 图 8.3 中的代码,以便 Person 类显式地使用 this 引用。此外,所有对不正确工作的 count 实例变量的引用都已删除,以便我们可以专注于 this 引用。图 8.8 包含重构后的 Person 类(PersonExample 类保持不变):
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_8.jpg
图 8.8 – 使用“this”引用
在前面的图中,第 7、11 和 15 行被注释掉,分别替换为第 8、12 和 16 行。让我们更仔细地对比一下被注释掉的 7 行和新的 8 行:
// name = aName; // line 7this.name = aName; // line 8
首先,假设第 7 行被取消注释。第 7 行是如何协调其变量的?最初,编译器检查当前作用域(代码的构造块),并将 aName 协调为构造函数的参数。然而,编译器还没有协调 name,因此它检查下一个外部作用域,即类作用域,其中定义了实例/类变量。在这里,它找到一个名为 name 的实例变量,因此第 7 行可以编译。
第 8 行的操作有些不同。是的,它以类似的方式协调 aName,但现在,它遇到了 this.name(而不是 name)。看到 this 后,编译器立即检查已声明的实例变量。它找到一个名为 name 的实例变量,因此第 8 行可以编译。第 7 行和第 8 行实际上是相同的。
第 16 行与第 8 行相同,因为我们使用了相同的参数标识符,aName。第 12 行只是简单地返回 name 实例变量。
因此,这涵盖了如何在类中使用 this,但我们是怎样将实例与 this 关联起来的呢?
将实例与“this”引用关联
幸运的是,编译器会自动完成这项工作。正如之前所述,this 引用仅(秘密地)传递给实例方法,并且它指向当时调用该方法的实例。例如,当执行 p1.getName() 时,getName 中的 this 引用指向 p1,而当执行 p2.getName() 时,getName 中的 this 引用指向 p2。因此,this 引用会根据调用方法的实例而变化。图 8.9 表示了 this 引用动态特性的实际应用:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_9.jpg
图 8.9 – “this”引用的动态特性
此图表示我们在执行第 24 行方法调用时,从 图 8*.8* 中的代码执行第 12 行时的内存表示。当第 24 行的 getName 在 p2 上被调用——换句话说,p2.getName();——getName 中的 this 引用指向与 p2 指向相同的对象。这由从 this 引用指向与 p2 指向相同对象的实线表示。
虚线表示第 12 行方法调用中 this 引用所指向的内容,即 p1。因此,this 引用是动态地指向由 p1 或 p2 引用的实例。
正如我们在 图 8*.8* 中的代码所看到的,this 引用是不必要的。让我们考察一个需要 this 引用的情况。
阴影或隐藏实例变量
当一个变量与实例变量具有相同的标识符时,就会发生实例变量的阴影。图 8*.10* 展示了这种情况的代码,以便我们可以观察它造成的问题:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_10.jpg
图 8.10 – 实例变量的阴影
在前面的图中,构造函数存在逻辑问题;换句话说,代码可以编译,但代码没有按预期工作。第 7 行是问题所在。记住,如果一个变量没有用 this 限定,则检查当前作用域以查看是否在该作用域中声明了该变量。在第 6 行,我们声明了一个使用 name 标识符的构造函数参数,它与第 4 行的实例变量具有相同的标识符。因此,第 7 行实际上是将局部变量赋值给自己,而实例变量保持未更改。由于实例变量是 String 类型,其默认值是 null。因此,第 19 行输出的是 null 而不是 "Maaike"。
为了解决这个问题,我们有两种选择。第一种选择是为构造函数参数使用不同的标识符,并使用这个新标识符。这就是 setName 所做的(第 12-13 行):使用一个不与 name 实例标识符阴影的方法参数 aName。第二种选择是使用 this 引用来指定正在初始化的变量是实例变量。图 8*.11* 展示了这一点:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_11.jpg
图 8.11 – 使用 “this” 解决阴影问题
在此图中,第 7 行很重要:this.name 指向 name 实例变量,而 name 单独使用时,则指向方法参数。因此,阴影已被消除,第 19 行现在输出预期的 "Maaike"。
我们知道只有非静态(实例)方法接收 this 引用。让我们考察这个问题如何影响我们以及如何解决它。图 8*.12* 展示了在 static 上下文(方法)中,我们尝试直接访问实例变量的代码:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_12.jpg
图 8.12 – 从“静态”上下文访问实例变量
在前面的图中,我们有一个名为x的实例变量(第 4 行),一个名为m的实例方法(第 5 行),以及一个名为main的static方法(第 6-19 行)。正如我们所知,static方法如main不会自动获得this引用(因为它们是类方法而不是实例方法)。
第 9、10、11 和 12 行存在编译错误。当你直接访问实例成员时,如第 9 和 11 行,编译器会在成员前插入this。换句话说,当编译器完成第 9 和 11 行时,从内部来看,它们看起来与第 10 和 12 行相同。因此,由于main没有this引用,编译器会对第 9、10、11 和 12 行提出抱怨。
第 15-18 行封装了解决这个问题的方法。当你处于static上下文并且想要访问实例成员(变量或方法)时,你需要创建一个对象实例来引用实例成员。因此,在第 15 行,我们创建了一个包含实例成员的类(对象)的实例,即PersonExample,并将引用存储在一个标识符pe中。现在我们有了实例,我们可以访问实例成员,我们在第 16、17 和 18 行这样做。第 16 行成功将x从(其默认值)0 更改为 999。这是第 18 行输出的内容。第 17 行显示访问m也没有问题。请注意,在代码编译和运行之前,你必须注释掉第 9-12 行。
在这些示例中,我们使用了private和public访问修饰符。让我们更详细地讨论这些内容。
应用访问修饰符
面向对象编程(OOP)的一个基石是封装(数据抽象)原则。封装可以通过访问修饰符来实现。在我们讨论封装之前,我们必须理解访问修饰符本身。
访问修饰符决定了类、字段或方法的可见性和可用性。你正在注解的级别决定了可用的访问修饰符:
-
public或包私有(无关键字) -
private、包私有、protected和public
让我们依次讨论这些内容。
private
被标记为private的成员只能在它自己的类中访问。换句话说,类的块作用域定义了边界。当在一个类(作用域)中时,你不能访问另一个类的private成员,即使你有指向包含private成员的类的对象引用。
包私有
对于package-private没有特殊的关键字。如果一个类型(类、接口、记录或枚举)没有访问修饰符,则应用package-private。package-private的类型仅在同一包内可见。回想一下,包只是一个相关类型的命名组。
图 8*.11 中的Person类(第 3 行)是一个包私有类,这意味着Person不能被导入到另一个包中。此外,Person构造函数(第 6 行)是包私有的,这意味着你无法从不同的包中创建Person类型的对象。
在成员级别,有一些例外情况需要你在省略访问修饰符时注意:
-
类/记录成员默认是
包私有。 -
接口成员默认是
public。 -
枚举常量(成员)默认是
publicstatic和final。枚举构造函数默认是private。我们将在本章后面讨论枚举。
默认包
默认包也被称为无名称包或未命名的包。在文件顶部没有显式包声明的类型会被放入这个包中。这就是放置了*图 8**.11 中的Person和PersonExample类的包。
这个问题的含义是,由于包没有名称,如果我们处于不同的(命名)包中,我们就无法导入Person和PersonExample。PersonExample是public(第 16 行)的事实并没有区别。因此,只有同一(默认)包中的其他类型可以访问它们。
protected
被标记为protected的成员意味着它在自己的包内可见(与包私有类似),但也可以被包外部的子类访问。当我们讨论继承时,我们将在第九章中更详细地讨论子类和protected。
public
被标记为public的类型或成员在所有地方都是可见的。因此,没有边界限制。
表 8.2总结了访问修饰符及其可见性:
表 8.2 – 访问修饰符及其可见性
让我们水平地检查表 8.2。只有类可以访问标记为private的成员。如果一个类或成员没有访问修饰符(包私有),那么这个类或成员只能在类和包内部可见。如果成员被标记为protected,那么这个成员对类、包以及该类的子类都是可见的,无论包如何。最后,如果一个类或成员被标记为public,那么这个类或成员在所有地方都是可见的。
为了进一步解释表 8.2,让我们绘制一个示例类集及其相关包的图,并为其制作另一个专门的可见性表。图 8*.13 展示了这一点:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_13.jpg
图 8.13 – 示例访问修饰符图
在这个图中,Dog 类被加粗并下划线,因为下面的表格,表 8.3,代表了其成员的可见性。例如,当阅读 private 行时,假设我们在 Dog 中标记了一个成员为 private,并正在确定它在其他类中的可见性。让我们来查看 表 8.3:
表 8.3 – 当应用于 Dog 成员时修饰符的可见性
因此,如果一个 Dog 成员是 private,只有 Dog 可以看到它。如果 Dog 成员是包私有,只有 Dog 和 Cat 可以看到它。如果 Dog 成员是 protected,Dog 和 Cat 可以看到它。最后,如果一个 Dog 成员是 public,每个类都可以看到它。
(*) 我们将在继承章节回顾 protected 时完成这个表格。
访问级别如何影响你?
访问级别将以两种方式影响你。首先,你可能会使用一个外部类(例如 Java API 中的类)并想知道你是否可以在你的代码中使用该类及其成员。其次,当编写一个类时,你将想要决定每个类和成员的访问级别。一个很好的经验法则是尽可能地将成员保持为 private 以避免误用。此外,除非它们是常量,否则请避免使用 public 字段。我们将在讨论封装时进一步讨论这一点。
让我们来看看这些访问修饰符在代码中的应用。特别是,我们将关注包,并学习如何创建一个包以及边界如何影响访问。
包
回想一下,完全限定类型名称包括包名。包定义了一个命名空间。例如,在 图 8*.13* 中,ch8.animals 包中的 Dog 类完全限定为 ch8.animals.Dog。因此,一个名为 kennel 的包中的 Dog 类将有完全限定的名称 kennel.Dog;这与 ch8.animals.Dog 完全不同。因此,Java 可以区分这两种 Dog 类型,并且不会发生名称冲突。正如我们将看到的,包结构也被用作你的 java 文件的目录结构。Oracle 提供了非常好的指南(见 docs.oracle.com/javase/tutorial/java/package/namingpkgs.html),说明如何命名你的包,以便你的类型不会与其他人的冲突。包名全部使用小写字母书写,以区分类型名称。随后,公司应使用反向互联网域名来开始它们的包名。例如,如果你在名为 somecompany.com 的公司工作,并且你正在创建一个名为 somepackage 的包,那么完整的包名应该是 com.somecompany.somepackage。在公司内部,命名可以遵循公司惯例,例如包括地区:com.somecompany.region.somepackage。
让我们检查 图 8*.13* 中的包。我们将从 ch8.animals 开始:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_14.jpg
图 8.14 – 图 8.13 中的“ch8.animals”包
在这个图中,为了简单起见,我们将包中的两个类组合到一个 Java 文件中。这个文件叫做Dog.java(因为public类是Dog)。第一行很重要:package ch8.animals表示在这里定义的类型(类等)将放入这个包中。此外,Dog.java 文件将被放入硬盘上名为ch8\animals的文件夹中。
在这个图中,第 4 行定义了一个名为dogName的private实例变量。这个变量只能在类内部访问(如第 6 行和第 9 行所示),但不能在类外部访问(如第 18 行所示)。
第 5 行定义了一个名为age的protected实例变量,我们可以在包内的另一个类中访问它(第 19 行)。第 12 行定义了一个名为pkgPrivate()的包私有方法,第 20 行显示我们可以从同一包内的另一个类中访问它。注意,Cat类及其构造函数都是包私有(分别在第 14 行和第 15 行)。
图 8*.15*显示了另一个包,ch8.farm:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_15.jpg
图 8.15 – 图 8.13 中的“ch8.farm”包
再次注意,第 1 行声明了包名——这是ch8.farm包。文件名为Pig.java(因为public类是Pig),文件将被放入硬盘上名为ch\farm的文件夹中。
注意导入时使用完全限定名(第 3 行和第 4 行)。因为我们想访问位于单独包中的Dog类,我们必须导入它。导入Dog没有问题,因为它是public的。然而,我们无法导入Cat,因为Cat是包私有(而且我们处于不同的包中)。
第 8 行演示了Pig可以创建一个Dog对象。注意这里有两个访问点:Dog类是public的(因此我们可以import它);Dog构造函数也是public的(因此我们可以从不同包中的代码创建Dog的实例)。这就是为什么类和构造函数的访问修饰符应该匹配。第 9 行显示,当我们处于不同的包中时,我们没有权限访问来自另一个包的包私有成员。
现在我们已经了解了访问修饰符,我们可以讨论封装。
封装
如前所述,封装是面向对象编程中的一个关键概念。这里的原理是保护你类中的数据,并确保数据只能通过你的代码进行操作(检索和/或更改)。换句话说,你控制外部类如何与你的内部状态(数据)交互。那么,我们如何做到这一点呢?
实现封装
基本封装很容易实现。你只需将你的数据标记为 private,并通过 public 方法来操作数据。因此,外部类不能直接访问数据(因为它被标记为 private);这些外部类必须通过你的 public 方法来检索或更改数据。
这些 public 方法构成了类的“接口”;换句话说,你如何与类交互。这个“接口”(一组 public 方法)与语言构造 interface(第十章)非常不同,不应混淆。图 8.16 展示了一个代码示例,以帮助我们进一步探讨这个主题:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_16.jpg
图 8.16 – 基本封装的实际应用
在前面的图中,Adult 类有两个 private 实例变量,分别是 name 和 age(分别在第 4 行和第 5 行)。因此,这些实例变量只能在 Adult 代码块内部访问。请注意,即使有一个 Adult 对象引用也无法绕过这个访问规则——第 29 行(注释掉的)上的编译错误证明了这一点。
公共类名与文件名的关系
在 Java 中,public 类的名称必须与文件名匹配。在 图 8.16 中,public 类是 BasicEncapsulation。这意味着文件名必须是 BasicEncapsulation.java,它确实是这样的。这个规则意味着你不能在同一个文件中有两个 public 类——这就是为什么 Adult 类不是 public 的。
如果我们处于不同的包中,并且想要创建一个如图 8.16 所示的 Adult 对象,会发生什么?这是一个问题,因为 Adult 是包私有的(第 3 行)。为了解决这个问题,我们需要将 Adult 类设为 public,这样我们就可以在不同的包中 import 它。这意味着我们需要将 Adult 类移动到一个单独的文件中,命名为 Adult.java。此外,Adult 及其构造函数都需要是 public 的。为什么?因为我们处于不同的包中时,类是 public 的使我们能够 import 类,构造函数是 public 的使我们能够创建 Adult 类型的对象。
Adult 构造函数(第 7 行)没有访问修饰符,因此是包私有的。因此,只有同一包内的类可以调用这个构造函数。换句话说,只有 ch8 包(第 1 行)中的类可以创建 Adult 对象。由于 BasicEncapsulation 也在 ch8 包中,所以第 26 行的对象创建是正确的。
Adult 类的其余部分(第 11-20 行)提供了用于操作对象状态(实例变量)的 getter/setter 方法对。这些 getter/setter 方法也分别被称为访问器/修改器方法。通常每个实例变量都有一个对应的方法对,它们遵循以下格式(注意这只是一个示例):
public int getAge(){ return age;
}
public void setAge(int age){
this.age = age;
}
在图 8.16的第 26 行创建Adult对象后,我们使用public访问器方法getName和getAge(第 27-28 行)输出对象状态。由于这些访问器方法是public的,这些方法对任何包中的任何类都是可用的。鉴于输出了'John'和20,我们知道我们的对象被正确创建。
假设我们是Adult类的开发者,并要求成年人的年龄必须是 18 岁或以上。此外,我们还将假设我们不知道BasicEncapsulation类的开发者。第 29 行展示了,由于我们的Adult数据是private的,它被保护免受直接外部破坏。这正是封装提供的东西;这正是它的存在理由!
第 30 行展示了对象的状态仍然可能被破坏。然而,第 30 行通过 set/mutator 方法进行的破坏与第 29 行的直接破坏非常不同。作为Adult类的作者,我们可以控制和因此修复set(mutator)方法中的破坏错误。我们set(mutator)方法的问题在构造函数中也被复制。图 8.17在构造函数和 mutator 方法中解决了这个(内部)破坏问题:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_17.jpg
图 8.17 – 确保年龄至少为 18 岁
由于BasicEncapsulation保持不变,它不包括在先前的图中。请注意,已经引入了一个新的isAgeOk方法(第 27-29 行)。该方法接受一个int参数age并检查它是否>= 18。如果是这样,该方法返回true;否则,它返回false。
isAgeOk方法是从setAgemutator 方法(第 21 行)调用的。由于构造函数调用setAge(第 8 行),它也利用了年龄检查逻辑。如果将无效的年龄传递给构造函数或setAge,则设置错误值为-1。请注意,有更好的方法来做这件事,但现在这样是可以的。当我们现在运行程序时,由于传递给setAge的age值是-99(john.setAge(-99)),实例变量age被设置为错误值-1。
这涵盖了基本的封装。我们现在将讨论基本封装的一个特定问题以及如何通过高级封装来解决它。
掌握高级封装
“私有数据,公共方法”的简单原则(其中public方法操作数据)在确保数据适当封装方面走得很远。然而,你仍然不完全安全。在本节中,我们将回顾 Java 的传值原则,该原则用于向方法传递参数和从方法返回值。我们将检查这可能会带来一个微妙的问题。最后,我们将检查如何保护你的代码,防止遇到这个问题。
传值调用再探讨
在第七章,我们讨论了当向方法传递参数时,Java 的按值调用机制会创建那些参数的副本。我们看到了意识到当参数是一个引用时,例如数组,被调用方法现在可以操作调用方法正在查看的数组对象的必要性。
类似地,当一个方法正在返回某些内容时,按值调用再次适用。换句话说,你返回的内容会创建一个副本。根据副本的内容,这可能会导致封装被破坏或不被破坏。如果你返回的是private原始数据,那么就没有问题——原始数据会返回一个副本,客户端可以对副本做任何它想做的事情;你的private原始数据是安全的。你可能还记得,从第七章,复制原始数据就像复印一张纸。复印的纸张可以被写上内容,而不会影响原始副本。
问题
如果你的private数据是一个引用(到一个对象),问题就会出现。如果客户端收到这个引用的副本,那么客户端就可以操作你的private对象!从第七章,你可能还记得,复制一个引用就像复制电视的遥控器一样。新的遥控器可以改变同一台电视的频道。图 8*.18*展示了破坏封装的代码:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_18.jpg
图 8.18 – 破坏封装的代码
在前面的图中,Seniors类有两个private实例变量(第 6-7 行),即ages和num。构造函数(第 9-13 行)初始化实例变量。我们有一个公共的getNum访问器方法,它返回private实例变量num(第 14 行)。请注意,我们为了节省空间,把这个方法放在了一行。
我们还有一个名为getAges的访问器方法(第 15-17 行),它返回一个名为ages的private数组。第 16 行是问题所在,因为它破坏了封装。当我们讨论main中的代码时,我们将解释原因。
在main中,我们首先做的事情是创建一个Seniors的实例(第 21 行)。这样我们就可以访问Seniors中定义的实例方法。main的其余部分分为两部分:一部分(第 23-27 行)演示了返回private原始数据是可行的;另一部分(第 30-37 行)演示了仅仅返回private引用会破坏封装。
让我们检查第一部分。第 23 行根据seniors.getNum()的返回值初始化局部变量num。由于private的Seniors实例变量num在Seniors构造函数(第 10 行)中被初始化为2,因此(完全独立的)局部变量num也被初始化为2。我们在第 24 行输出了这一事实。然后我们将局部num变量的值更改为-100(第 25 行)。现在的问题是,当我们更改局部变量num时,private的Seniors实例变量num是否也被更改?为了找出答案,我们可以简单地使用公共访问器方法getNum(第 26 行)再次检索num。第 27 行输出2,证明private原始数据num在main中做出的更改是安全的。
第二部分是事情变得有趣的地方。第 30 行根据public访问器方法seniors.getAges()的返回值初始化局部变量copyAges。由于getAges只是返回(一个副本的)private的ages引用,我们现在有两个引用指向同一个数组对象。这些引用是private实例变量ages和局部变量copyAges。第 31 行输出了copyAges的值,对于索引0和1分别是30和40。这些是private的ages数组在Seniors构造函数(第 11-12 行)中初始化时的相同值。
现在,在第 34-35 行,我们更改了copyAges数组中的值:索引0被设置为-9,索引1被设置为-19。与第一部分一样,我们现在想知道,更改局部数组是否对Seniors中的private实例数组有任何影响?答案是肯定的!为了证明这一点,我们可以再次使用getAges(第 36 行)检索private数组并输出其值(第 37 行)。输出值-9和-19表明客户端AdvancedEncapsulation能够操作(更改)所谓的Seniors的private数据。因此,Seniors最终并没有实现封装。
图 8*.19*展示了内存中的情况,揭示了为什么Seniors没有实现封装:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_19.jpg
图 8.19 – 图 8.18(第 37 行)的内存表示
在前面的图中,局部变量num位于栈上。它是private的Seniors实例变量num的一个副本,并且随着我们通过main的进展,其不同的值以删除线字体反映出来。第 25 行(图 8*.18*)将局部变量更改为-100。如所见,这种更改不会影响Seniors中的private实例变量num,它仍然为2。
问题出在 private 数组对象 ages 的引用上。因为 getAges(第 15 行)只是返回引用,所以这个引用的副本被存储在局部变量 copyAges(第 30 行)。由于局部引用 copyAges 和 private 引用 ages 现在指向同一个对象,副本引用可以更改 private 数组对象。这就是为什么数组对象在索引 0 和 1 分别有 -9 和 -19 的值。copyAges2 引用只是用来证明这一点。
解决方案
既然我们已经知道了问题,修复它就相当直接了。关键是,在返回引用时,确保你简单地不要返回 private 引用(因为按值调用将返回该引用的副本)。解决方案是 复制你希望返回的对象,并返回对新对象的引用。这样,外部类(客户端)就可以操作这个新对象,而不会影响你的私有内部对象。图 8.20 是正确封装、重构后的 图 8.18 版本:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_20.jpg
图 8.20 – 正确封装的代码
在前面的图中,我们用一个新的版本(第 15-18 行)替换了访问器方法 getAges。这个新版本是正确封装的。在第 16 行,我们不是简单地返回数组实例变量(或其引用),而是将数组 ages 复制到一个新的数组,即 newArr。我们使用 Arrays.copyOf 方法实现这一点。我们返回新数组对象的(副本)引用。
现在,在第 24 行,当我们初始化 copyAges 时,它指的是在第 16 行创建的副本数组。这个引用 newArr 已经超出作用域(因为我们从 getAges 返回),但新的数组对象仍然在堆上,copyAges 指向它。这里的重要点是,在第 25 行,我们有两个不同的数组引用:ages 实例和局部 copyAges。这些引用现在指向两个 不同 的对象。
第 25 行输出了副本数组的详细信息;索引 0 为 30,索引 1 为 40。这是预期的。第 26 和 27 行将副本数组索引 0 和 1 的内容分别更改为 -9 和 -19。现在,我们需要检查一下:当我们更改 copyAges 数组的内容时,private 内部 Seniors 数组的 ages 内容是否也改变了?为了检查,在第 28 行,我们可以使用 private 数组 ages 的(副本)内容初始化一个 copyAges2 数组。当我们输出第 29 行的 copyAges2 的详细信息时,我们得到 30 和 40,从而证明当我们更改局部 copyAges 数组(第 26-27 行)时,private 内部数组 ages 并没有改变。现在,Seniors 已经被正确封装。
图 8.21 展示了在执行第 29 行时内存中的这种情况:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_21.jpg
图 8.21 – 图 8.20 的内存表示
在前面的图中,就在Seniors对象构建之后(第 22 行),堆栈上有一个seniors引用,指向堆上的Seniors对象。Seniors对象包含一个设置为 2 的num原始值(第 10 行)和一个指向数组对象的ages数组引用(第 11-12 行)。
当我们调用getAges(第 24 行)时,复制数组newArr被创建(第 16 行),尽管这里没有显示,但新数组最初包含30和40的值(分别对应索引0和1),如第 25 行所示。当newArr从getAges返回(第 17 行)时,(副本的)引用被分配给copyAges(第 24 行)。如前图所示,copyAges局部变量和ages实例变量指向两个不同的数组对象。这正是我们想要的。使用 copyAges 所做的任何更改都不会影响私有数组 ages。
这正是第 26-27 行所做的更改所展示的内容。使用copyAges引用所做的更改反映在图中。为了证明第 26-27 行的更改没有影响private数组ages,我们再次调用getAges。一个新的数组,代表private数组的副本,再次被创建(第 16 行),并且(副本的)新数组引用被返回并分配给局部引用copyAges2。当我们输出第 29 行的新数组内容时,我们得到30和40,这表明private数组没有受到局部数组(第 26-27 行)更改的影响。
现在我们已经理解了按值传递和高级封装,我们处于一个很好的位置来讨论对象生命周期。
深入了解对象生命周期
要理解 Java,了解后台在内存中发生的事情非常有帮助。本节将帮助我们巩固在调用方法、声明局部/实例变量等时堆栈和堆上发生的事情。
局部变量保存在堆栈上(以便快速访问),而实例变量和对象则存在于堆上(一大块内存区域)。正如我们所知,我们使用new关键字来创建 Java 对象。new关键字在堆上为对象分配空间,并返回对象的引用。如果对象不再可访问会发生什么?例如,引用可能已经超出作用域。我们如何回收那块内存?这就是垃圾回收发挥作用的地方。
垃圾回收
如前所述,垃圾回收会回收不再被使用的对象占用的内存;也就是说,没有引用指向这些对象。这个垃圾回收过程是 JVM 在后台运行的一个进程。JVM 可能会在空闲时决定运行垃圾回收,也可能不会。简单来说,我们无法控制垃圾回收何时运行。即使我们调用了System.gc(),这也只是向 JVM 提出运行垃圾回收的建议——JVM 可以自由地忽略这个建议。垃圾回收的主要优势是我们不需要自己进行清理;而在像 C++这样的语言中,我们必须这样做。
想要了解更多关于 Java 内存管理的细节,请参阅我们之前出版的书籍:[www.amazon.com/Java-Memory-Management-comprehensive-collection/ dp/1801812853/ref=sr_1_1?crid=3QUEBKJP46CN7&keywords=java+memory+management+maaike&qid=1699112145&sprefix=java+memory+ management+maaike%2Caps%2C148&sr=8-1](https://www.amazon.com/Java-Memory-Management-comprehensive-collection/dp/1801812853/ref=sr_1_1?crid=3QUEBKJP46CN7&keywords=java+memory+ management+maaike&qid=1699112145&sprefix=java+memory+management+maaike%2Caps%2C148&sr=8-1).
对象生命周期示例
在这个阶段,一个示例程序会有所帮助。图 8*.22* 展示了一个符合我们目的的程序:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_22.jpg
图 8.22 – 解释对象生命周期的示例程序
当这个(简单且非常人为的)程序执行时,有三个方法被推入栈中,分别是main、tagAnimal和setCountry。图 8*.23* 表示我们即将退出setCountry方法(第 19 行)时的内存表示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_23.jpg
图 8.23 – 图 8.22 中代码的内存表示
让我们更详细地看看这个例子。
主方法
如前两个图所示,第 9 行在堆上创建了Cow对象,在main帧的栈上的局部引用cow1指向它。此时,堆上的Cow对象的实例变量,即tag和country,将是null。
第 10 行将cow1中的值赋给main中的另一个局部引用,即cow2。现在,在第 11 行,我们在栈上有一个main的帧,包含两个局部引用变量,即cow1和cow2,它们都指向堆上的一个Cow对象。
第 11 行使用cow2引用来执行实例方法tagAnimal。因此,当在tagAnimal方法内部(这次调用期间),this引用将指向cow2所指向的内容(即堆上的Cow对象)。此外,cow1引用被作为参数传递给tagAnimal方法。这并不是必要的,因为tagAnimal已经有一个指向Cow对象的引用(使用this),但这个程序只是为了示例目的。
tagAnimal 方法
与任何方法调用一样,tagAnimal 的栈帧被压入栈中。根据按值调用规则,tagAnimal(第 13 行)将方法参数 cow 别名为来自第 11 行的 cow1。因此,tagAnimal 中的 cow 引用和 main 中的 cow1 引用指向同一个 Cow 对象,该对象是在第 9 行创建的。
如我们所知,this 引用指向负责方法调用的对象实例——在这种情况下,cow2(第 11 行)。因此,第 14 行的 tag 引用(实际上是 this.tag)是指向可以通过 cow2 访问的 tag 实例变量。因此,第 14 行在堆上创建了一个新的 Tag 对象,并将其引用存储在 Cow 对象的 tag 实例变量中,覆盖了其先前的默认值 null。请注意,鉴于这个例子是人为设计的,Cow 对象通过三个不同的引用被引用:main 中的 cow1 和 cow2;以及 tagAnimal 中的 cow。
第 15 行指定了一个 "France" 的 String 文字。由于 String 文字是对象,因此在堆上创建了一个 String 对象。使用 cow 引用,调用 setCountry 方法,传递 String 文字 "France"。
setCountry 方法
setCountry 的栈帧被压入栈中。setCountry 声明将方法参数 country 别名为引用 String 文字 "France",它在方法调用中传递下来(第 15 行)。第 18 行将 country 实例变量初始化为传递下来的参数,即 "France"。第 18 行明确使用了 this 引用,因为参数名和实例变量具有相同的标识符 country。this 引用指向 cow 所指向的内容,即堆上的 Cow 对象。这是因为 setCountry 方法调用(第 15 行)是在 cow 引用上执行的。
既然我们已经知道了方法是如何压入栈中的,那么让我们在从这些方法调用返回时检查内存——换句话说,当我们弹出栈时。图 8*.24* 表示在退出 setCountry 方法但退出 tagAnimal 方法之前内存中的表示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_24.jpg
图 8.24 – “setCountry”方法完成后内存中的表示
如前图所示,setCountry 帧已从栈中弹出。然而,String 对象 "France" 仍然位于堆上,因为 Cow 实例对象中的 country 实例变量仍然指向它。只有没有任何引用指向它们的对象才有资格进行垃圾回收。
图 8*.25* 表示在 tagAnimal 方法完成但 main 方法完成之前内存中的表示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_25.jpg
图 8.25 – “tagAnimal”方法完成后内存表示
与前一个图相比,这个图几乎没有变化,只是 tagAnimal 的栈帧已经被弹出。堆上的 Cow 对象不能被垃圾回收,因为 main 中的两个引用 cow1 和 cow2 都指向它。此外,由于 Cow 对象不能被移除,Tag 或 String 对象也不能被移除。这是因为 Cow 实例变量 tag 和 country 指向它们。这个图表示了内存中的情况,直到 main 退出,此时可以回收一切。
这就结束了我们对对象生命周期的讨论。现在,我们将继续讨论 instanceof 关键字。
解释 instanceof 关键字
instanceof 关键字使我们能够确定引用所引用的对象类型。这就是为什么将引用与对象分开是如此关键的原因。引用的类型和对象类型通常非常不同。事实上,在大多数情况下,它们是不同的。当我们讨论继承(第九章)以及接口(第十章)时,我们将更详细地讨论 instanceof。
因此,目前我们将保持简单 – 当引用类型和对象类型相同时。图 8.26 展示了一个这样的代码示例:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_26.jpg
图 8.26 – 基本的“instanceof”示例
在这个图中,第 7 行创建了一个名为 dog 的 Dog 引用,它引用了一个 Dog 对象。第 8 行创建了一个名为 cat 的 Cat 引用,它引用了一个 Cat 对象。第 9 行检查 dog 引用末尾的对象是否是 Dog 的“实例”。它是,所以第 10 行执行。同样,第 12 行检查 cat 引用所引用的对象是否是 Cat 类型。它是,所以第 13 行执行。
第 15 行被注释掉了,因为它会生成编译器错误。由于 Cat 和 Dog 是完全不相关的类(第 3-4 行),编译器知道没有一种方法可以让 Cat 引用,即 cat,引用 Dog 对象。相反,Dog 引用,如 dog,也不能引用 Cat 对象。
我们将在本章后面讨论 instanceof。现在,让我们继续讨论下一个主题,它与类密切相关:即枚举。
理解枚举
枚举,或简称为 enum,是一种特殊类型的类。在类中,你可以有任意多的实例(类的实例);而在枚举中,实例是预定义的,因此受到限制。枚举在适用于有限值集的情况下非常有用 – 例如,一周中的日子、一年的季节和方向。
这确保了类型安全,因为有了编译器的帮助,只允许定义的实例。在编译时发现问题总是比在运行时更好。例如,如果你有一个定义了String参数的方法,即direction,那么有人可以用"WESTT"(注意拼写错误)调用该方法。编译器不会捕获这个错误,因为它是一个有效的String,所以错误会在运行时显现。然而,如果方法参数是一个枚举,编译器就会捕获它。我们很快就会看到这一点。
枚举有两种类型:简单和复杂。我们现在来讨论它们。
简单枚举
一个简单的枚举之所以被命名为“简单”,是因为它确实很简单。从某种意义上说,当你查看枚举时,代码很少。图 8.27展示了使用简单枚举的代码:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_27.jpg
图 8.27 – 简单枚举
在前面的图中,定义了Water枚举(第 3-5 行)。枚举的值用大写字母表示(类似于常量)。这不是强制性的,但这是常见的做法。这个枚举所表达的意思是我们有一个名为Water的枚举,只允许有两个实例,即STILL和SPARKLING。实际上,STILL和SPARKLING是对唯一允许的对象实例的引用。第 4 行末尾的分号对于简单枚举是可选的。对于复杂枚举,相应的分号是强制性的。枚举值从0开始给出序号值。因此,对于Water,STILL的序号值为0,而SPARKLING的序号值为1。
如前所述,枚举是一种特殊类型的类。然而,也有一些区别。其中之一是枚举构造函数默认是private的。这包括编译器生成的默认构造函数(如图 8.27中的Water)。与类的默认构造函数相比,它具有与类相同的访问权限。因此,你不能像普通对象那样实例化枚举。这就是为什么第 8 行不会编译——编译器生成的默认枚举构造函数是private的,因此对外部类型不可访问。
因此,如果我们不能使用new创建枚举实例,我们该如何创建枚举实例呢?换句话说,构造函数调用在哪里?枚举值的声明,即 STILL 和 SPARKLING ,(第 4 行)就是构造函数调用! 因为它们在类内部,所以它们可以访问private构造函数。这些枚举值只初始化一次——也就是说,当枚举首次使用时。
因此,要创建枚举(对象),请使用相关的枚举值。这是在第 11 行完成的,我们现在有一个引用 stillWater,它引用了 STILL 实例。将第 11 行与第 9 行(无法编译)进行对比。尝试使用任何其他值,如 EXTRA_SPARKLING,将无法编译。这就是我们之前讨论的类型安全。只允许两个 Water 实例,STILL 和 SPARKLING,编译器强制执行此规则。
第 12 行和第 13 行演示了只创建了一个 Water.STILL 的实例。由于等价运算符和 equals 方法都返回 true,因此只能有一个实例。
继承的方法
虽然继承将在第九章*中详细讨论,但我们需要深入了解这个主题以理解枚举。Java 中的每个类都隐式继承自一个名为 Object 的类。这意味着你可以默认获得 Object 中的方法。这就是 Java 确保每个类都有某些重要方法的方式。你可以接受来自 Object 的版本或替换它(称为 重写 方法)。
从 Object 继承来的这些方法之一是 equals。Object 中的版本比较引用以查看它们是否相等,并根据该比较返回 true 或 false。本质上,这与使用 == 来比较引用相同。
枚举隐式继承自 Enum 类(Enum 继承自 Object,因此无法避免 Object!)。因此,枚举可以访问 valueOf、values、ordinal 和 name 等方法。
switch 语句(第 14-20 行)根据 Water 引用切换,即 stillWater(第 14 行)。case 标签是不带限定符的枚举值(STILL,第 15 行)。第 18 行显示带限定符的枚举值是不正确的。第 19 行(和第 21 行)演示了即使枚举值有序号,枚举也是类型而不是整数。
由于继承,Enum 类型中存在一些有趣的方法可供我们使用。让我们从 valueOf(String) 开始。
valueOf(String) 方法
这是一个隐式声明的方法,当给定一个枚举常量名称时,返回该枚举实例(第 22 行)。因此,此方法提供了一种快速简单的方法来创建枚举实例,一旦你知道常量名称。
让我们看看如何使用 values() 方法遍历所有枚举实例。
values() 方法
这是一种隐式方法。在第 25 行,我们使用增强型 for 循环按枚举声明的顺序遍历枚举,即第 4 行声明的 STILL 后跟 SPARKLING。一旦我们实例化了一个枚举,我们就可以使用其他方法来获取该特定枚举的详细信息。
让我们看看 ordinal() 方法是如何为枚举提供序号的。
ordinal() 方法
ordinal() 方法(第 28 行)返回此枚举的序数值。初始枚举常量被赋予序数值 0;因此,STILL 的 ordinal() 返回 0,而 SPARKLING 的 ordinal() 返回 1。
要确定枚举的名称,我们可以使用 name() 方法。
name() 方法
name() 方法(第 28 行)返回此枚举的名称,与在枚举中声明的完全一致(第 4 行)。例如,STILL 的 name() 返回 "STILL",而 SPARKLING 的 name() 返回 "SPARKLING"。请注意,与其使用 name 方法,更好的选择是重写 toString() 方法,因为你可以自定义显示(给用户)的 String,使其更友好。我们将在继承中做很多这样的操作(第九章)。
现在我们已经检查了简单的枚举,让我们继续讨论复杂枚举。
复杂枚举
如前所述,枚举是一种特殊的类类型,其中实例是有限的。由于简单枚举非常直接,因此很难看到类/枚举之间的关系。对于复杂枚举,确定枚举与类之间的关系要容易得多。
复杂枚举具有实例变量、构造函数和方法,因此它们与类非常相似。图 8*.28* 展示了一个用于讨论的复杂枚举:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_28.jpg
图 8.28 – 一个复杂枚举
在此图中,我们声明了 WorkDay 枚举(第 3-25 行)。这个 enum 封装了我们在办公室从周一到周五 9 点到 5 点工作,周六在家从 10 点到 1 点工作的情况。假设我们在周日休息!
枚举常量从第 5-13 行声明。有一个名为 hoursOfWork 的 private 实例变量(第 15 行),它由构造函数(第 16-18 行)初始化。请注意,构造函数默认是 private 的。访问器方法 getHoursOfWork(第 19-21 行)是外部类获取对 private 实例变量 hoursOfWork 访问的方式。另一个访问器方法 getWorkLocation(第 22-24 行)假设我们每天都在办公室工作(当然,这是大流行前的假设!)。SATURDAY 常量(第 10-13 行)值得讨论,我们很快就会谈到这一点。
让我们仔细检查第 5 行:这是一个对已声明(第 16-18 行)的构造函数的调用。换句话说,hoursOfWork 实例变量被设置为 "9-5" 用于 MONDAY。其他常量 – TUESDAY、WEDNESDAY、THURSDAY 和 FRIDAY(第 6-9 行) – 以类似方式初始化。
那么SATURDAY呢?由于我们还没有介绍继承,这可能会有些棘手。我们说的是,对于周六,我们只在家工作。要做到这一点,我们必须替换(覆盖)默认的getWorkLocation方法(第 22-24 行)。默认的getWorkLocation方法返回"Office",但我们的自定义getWorkLocation(第 12 行)对于SATURDAY返回"Home"。SATURDAY常量定义了一个“特定于常量的类体”,它从第 10 行的花括号开始,到第 13 行的花括号结束。
注意,第 13 行的分号是在复杂枚举常量末尾必需的,无论它们是否声明特定于常量的类体。那个特定的分号(第 13 行)告诉编译器,“我们现在已经完成了枚举常量的定义,所以你可以从现在开始期望实例变量或构造函数或方法。”
现在我们已经定义了我们的enum,让我们来使用它。第 28 行实例化MONDAY,导致枚举常量(第 5 行)执行构造函数(第 16-18 行),从而初始化MONDAY实例的hoursOfWork为"9-5"。第 29 行通过输出"9-5"来证明这一点。第 30 行调用(默认)版本的getWorkLocation(第 22-24 行),从而将"Office"输出到屏幕。
第 31 行实例化SATURDAY,并将hoursOfWork输出为"10-1",因为这是从第 10 行传递到构造函数的内容。第 32 行调用SATURDAY的特定版本getWorkLocation,将"Home"输出到屏幕。
这就完成了我们对枚举的讨论。现在让我们讨论一个非常有用的特性,即记录。
欣赏记录
记录是一种特殊的类,被认为是“数据载体”。它们帮助我们避免输入大量的模板代码。记录通过记录声明来指定,其中列出记录的组件。在后台隐式生成的是规范构造函数;toString、equals和hashCode方法以及为每个指定的组件生成public访问器方法。访问器方法采用与组件相同的名称(与更传统的get方法相反)。记录最好通过与常规类进行对比来解释。图 8*.29*展示了一个带有大量模板代码的正常类:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_29.jpg
图 8.29 - 一个有很多模板代码的类
在前一个图中的Person类进行了一些定制,以便更容易地映射到一个记录。例如,类本身是final(第 5 行),实例变量,即name和age(第 6-7 行),也是final。实例变量是blank final(声明为final但不在声明时初始化)的事实意味着实例变量必须在构造函数中初始化。这正是构造函数所做的事情(第 10-11 行)。
有两个访问器方法用于检索实例变量,即name(第 13-15 行)和age(第 16-18 行)。请注意,方法名前面没有get,换句话说,不是getName和getAge。这是因为记录使用组件标识符来命名实例变量和访问器方法。
此外,这个类还有自定义的equals、hashCode和toString版本,分别位于第 20-26 行、第 28-30 行和第 32-36 行。这些方法中的每一个都是通过提供特定的自定义版本来覆盖继承的版本。覆盖这个主题在继承部分有详细的讨论(见第九章)。toString方法的作用是返回包含实例变量值(组件值)的字符串。equals方法确保如果两个记录类型相同且包含相等的组件值,则它们被认为是相等的。hashCode方法确保相等的对象返回相同的哈希码值(关于这一点,请参阅第十三章)。
现在我们来检查图 8**.30中的等价记录:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_30.jpg
图 8.30 - 类 8.29 的等价记录
是的——只需要一行代码!正如你所见,这使我们免去了很多样板代码。实际上,图 8.29和图 8.30是等价的(编译器完成时)。这两个参数被称为组件,前面的单行代码导致在后台生成以下代码:
-
以记录命名的
final class(在这个例子中是Person)。 -
private final实例变量,每个组件一个,以组件命名。 -
用于初始化组件(实例变量)的规范构造函数。
-
访问器方法,每个组件一个,以组件命名。
-
自定义的
toString、equals和hashCode方法。
记录是可定制的。换句话说,如果我们愿意,我们可以覆盖(替换)所有默认版本。图 8*.31*展示了这种情况。
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_08_31.jpg
图 8.31 - 规范和紧凑构造函数
在这个图中,我们正在自定义规范构造函数(第 7-13 行),因为我们想验证人的age组件——如果他们小于 18 岁,那将是一个错误,我们将生成自定义错误值。再次注意,有更好的方法来处理错误值,但到目前为止,这已经足够了。否则,组件初始化为传入的值。
然而,这个规范构造函数可以以更简洁的方式编写。紧凑构造函数(第 15-19 行)正在替换规范构造函数。紧凑构造函数是规范构造函数的一种变体,并且是特定于记录的。注意,第 15 行甚至没有一对圆括号——组件可以从组件列表(第 5 行)推断出来。此外,没有必要根据第 11-12 行初始化组件;编译器可以为我们完成这项工作。
第 23-26 行演示了如何使用我们声明的记录 Person。第 23 行声明了一个名为 p1 的 Person 实例。第 24 行调用了 Record 类提供的隐式 toString 方法(每个记录都从该类继承)。第 25-26 行调用了两个访问器方法;注意它们的名称分别是 name() 和 age()。输出在每行的右侧注释中(第 24-26 行)。
由于记录与类密切相关,记录可以用 instanceof 关键字使用,这并不奇怪。这就是我们将要检查的记录模式。
记录模式
在过去的几年里,instanceof 关键字已经从简单的 instanceof-and-cast 习语发展到支持类型模式和记录模式。让我们首先讨论一下“类型模式”和“模式匹配”是什么。
类型模式和模式匹配
在 Java 16 中,instanceof 被扩展为接受类型模式并执行模式匹配。在 Java 16 之前,以下代码很常见:
if(obj instanceof String){ // 'obj' is of type Object String s = (String)obj;
System.out.println(s.toUpperCase());
}
这段代码检查 Object 引用 obj 是否指向一个 String 对象,如果是,则(安全地)将引用转换为 String,这样我们就可以访问 String 方法。记住,你可以访问的方法基于引用类型。然而,如果引用末尾的对象是一个 String 对象,那么我们可以安全地将引用转换为 String,从而使用新的 String 引用访问 String 方法。我们将在继承部分(第九章)中更详细地讨论这一点。
截至 Java 16,我们可以更简洁、更安全地编写之前的代码段:
if(obj instanceof String s){ // "String s" - type pattern// String s = (String)obj; // no longer needed
System.out.println(s.toUpperCase());
}
有两个变化需要注意。第一个变化是使用类型模式 String s 作为 instanceof 的一部分。模式匹配在运行时发生,其中 instanceof 将类型与提供的类型模式进行比较,如果匹配,则为我们执行类型转换。第二个变化是,由于 instanceof 代表我们执行类型转换,我们不再需要自己进行转换。这导致了一种更声明性的风格(你只需说明你想要什么,而不是如何得到你想要的东西)。
这很自然地引出了 Java 21 中引入的记录模式。在记录模式之前,需要以下代码(假设使用 图 8*.30* 中的 Person 记录):
if(obj instanceof Person p){ // type pattern String name = p.name(); // accessor
int age = p.age(); // accessor
System.out.println(name + "," + age);
}
使用记录模式,之前的代码可以更简洁地表达:
if(obj instanceof Person(String sName, Integer nAge)) System.out.println(sName + "," + nAge);
}
在这段代码中,Person(String sName, Integer nAge) 是一个记录模式。记录模式由一个类型、一个组件模式列表(可能为空)和一个可选的标识符组成。记录模式为我们做了两件事:首先,检查对象是否通过 instanceof 测试;其次,将记录实例分解为其组件。所以,在我们的例子中,假设 obj 指的是一个 Person 对象,那么局部变量 sName 将初始化为 name() 访问器方法的返回值,局部变量 nAge 将初始化为 age() 访问器方法的返回值。我们故意使用不同的标识符来强调它们不必与 图 8*.30* 中使用的组件标识符匹配。然而,需要注意的是,类型的顺序必须匹配;换句话说,记录模式必须指定一个 String 变量,然后是一个 Integer 变量,正如 图 8*.30* 中组件列表的顺序。
这样,我们就完成了对记录的讨论,并且确实结束了 第八章。现在,让我们将所学知识付诸实践,以巩固我们学到的概念。
练习
类、对象和枚举非常适合增强我们的中生代伊甸园软件。在这些练习中,你将创建类来表示公园中的不同实体,并使用枚举来定义一组固定的常量:
-
我们公园里有很多种类的恐龙,每种恐龙都有其独特的特征。定义一个名为
Dinosaur的类,具有诸如名称、年龄和物种等属性。 -
我们公园的灵魂和核心在于其员工。创建一个名为
Employee的类,用于封装诸如名称、职位和经验年数等属性。 -
在有了这些类之后,创建一些
Dinosaur和Employee的实例,并练习操作这些对象。我很难为这个练习提供更多细节,但例如,你可以创建一个新的类App。然后,在这个类中,你可以创建一些Dinosaur和Employee的实例。如果你想发挥创意,你可以添加一个接受Dinosaur作为参数的方法,然后打印出这个恐龙的信息(如它的名称、年龄等)。当然,你也可以为Employee做同样的事情。 -
“公园”本身可以被视为一个具有自身属性和行为的对象。设计一个
Park类,其中包含打开和关闭公园、添加或删除恐龙等方法。你也可以考虑给它一个员工数组和一个恐龙数组。 -
我们提供给恐龙的食物种类繁多。定义一个
Food类,具有诸如名称、营养价值和成本等属性。 -
如你所知,安全是我们的首要任务。出于明显的安全原因,我们的恐龙被安置在不同的围栏中。创建一个
Enclosure类,其中包含一个Dinosaur对象数组。 -
为了增加清晰度,让我们定义一个用于恐龙类型的枚举,例如草食性、肉食性和杂食性。
-
没有门票的公园参观是不完整的。创建一个具有价格、访客姓名和访问日期等属性的
Ticket类。
项目 - Mesozoic Eden 公园管理员
在这个项目中,你将创建一个名为 Mesozoic Eden 公园管理员的完全交互式控制台应用程序。这个应用程序允许公园管理员监督和管理恐龙公园的各个方面。公园管理员可以使用这个应用程序来高效地管理多个恐龙、公园员工和公园门票。这个系统的关键特性应该包括以下内容:
-
创建、编辑或删除恐龙档案、公园员工档案和公园门票的能力。
-
一个实时跟踪系统,用于监控公园内恐龙的位置和状态。
-
一个基本的名单系统,用于组织和管理工作人员的排班。
-
一个强大的票务系统,用于管理访客入场并确保公园保持最佳容量。
-
系统还应处理特殊场景,如紧急情况或 VIP 访客访问。
这可能听起来很多。所以,这里有一个逐步指南来实现这一点:
-
Dinosaur和Employee类。另外,添加一个名为Guest的类。每个类都应该包含更多的属性和方法。 -
Dinosaur、Guest和Employee对象。 -
Scanner类。这个接口应该为公园管理员提供管理公园的各种选项。 -
增强菜单创建:菜单现在应包括管理多个恐龙、员工和票证的选项。每个选项都应该对应程序中的特定功能。
-
处理操作:每个菜单项都应该触发一个函数。例如,选择管理恐龙选项可以触发一个函数来添加、删除或编辑恐龙档案。
-
退出程序:提供一个选项让用户退出程序。
这里是一个起始代码片段:
import java.util.Scanner;public class Main {
// Use Scanner for reading input from the user
Scanner scanner = new Scanner(System.in);
public static void main(String[] args) {
Main main = new Main();
main.start();
}
public void start() {
// This is the main loop of the application. It
will keep running until the user decides to exit.
while (true) {
displayMenu();
int choice = scanner.nextInt();
handleMenuChoice(choice);
}
}
public void displayMenu() {
System.out.println("Welcome to Mesozoic Eden Park
Manager!");
System.out.println("1\. Manage Dinosaurs");
System.out.println("2\. Manage Park Employees");
System.out.println("3\. Manage Tickets");
System.out.println("4\. Check Park Status");
System.out.println("5\. Handle Special Events");
System.out.println("6\. Exit");
System.out.print("Enter your choice: ");
}
public void handleMenuChoice(int choice) {
switch (choice) {
case 1:
// manageDinosaurs();
break;
case 2:
// manageEmployees();
break;
case 3:
// manageTickets();
break;
case 4:
// checkParkStatus();
break;
case 5:
// handleSpecialEvents();
break;
case 6:
System.out.println("Exiting...");
System.exit(0);
}
}
}
注释掉的方法调用是您需要根据数据结构和功能实现的方法的占位符。
概述
在本章中,我们通过区分对象和类开始了我们的讨论。类类似于房子的设计图,而对象则是(建造的)房子本身。我们使用new关键字创建对象,并使用其引用来操作对象。区分引用和对象在以后非常重要。一个有用的类比是,引用就像遥控器,而对象就像电视。
构造函数是用于构建对象时使用的特殊方法。构造函数是一个与类名相同但没有返回类型的方法。始终存在构造函数 - 如果您没有提供,编译器会介入并插入默认构造函数。构造函数通常用于初始化实例变量。
每个对象都获得实例成员(变量和方法)的副本。类成员被标记为 static,并由所有实例共享。当访问实例成员时,我们使用引用,而当访问类成员时,我们使用类名。点符号适用于这两种语法。
this 引用是我们实例方法中可用的一种特殊引用。它指向负责方法调用的对象实例。因此,它是动态的,因为它的值取决于调用方法时使用的引用。它对类(静态)方法不可用。
访问修饰符既适用于顶级(类/接口/记录)级别,也适用于成员级别。在顶级级别,public 或包私有访问适用。包私有通过完全不指定任何关键字来实现,并确保顶级构造仅在同一个包内可见。如果顶级构造是 public,则它在任何地方都可用;没有限制。
除了 public 和包私有(具有相同的语义)之外,成员(变量/方法)还可以是 private 和 protected。private 意味着成员仅在类内部可见。protected 与包私有类似,但子类(无论包如何)都可以访问成员。
封装是面向对象编程的基石之一。这意味着一个类可以隐藏其数据以防止外部滥用;这通常被称为“数据隐藏”。在 Java 中,这是通过将数据标记为 private 并提供 public 访问器/修改器(get/set)方法来操纵数据来实现的。这里的重要概念是外部代码必须通过你的 public 方法访问 private 数据。因此,通过在 public 方法中使用条件逻辑,你可以防止你的数据被破坏。
然而,“私有数据,公有方法”的原则仅到此为止。当返回对 private 对象的引用时,Java 的按值调用机制返回该引用的副本。因此,private 对象现在可以通过外部代码直接访问。高级封装通过复制 private 对象并返回副本对象的引用来对抗这种情况。因此,你的 private 对象仍然是私有的,并且免受外部干扰。
理解一个对象的生命周期非常有用。局部变量存在于栈上,而对象和实例变量存在于堆上。当一个对象不再有任何引用指向它时,它就有资格进行垃圾回收。垃圾回收是由 JVM 在 JVM 选择的时间自动运行的过程。当垃圾回收器运行时,有资格进行垃圾回收的对象将被移除,堆空间将被回收。
instanceof 关键字使我们能够确定引用所指向的对象类型。这将在以后非常有用。
枚举(enums)与类紧密相关,因为枚举实际上就是类,其实例数量是有限且已指定的。它们在确保类型安全方面非常有用,编译器会标记错误而不是在运行时发现错误。
枚举分为两种类型:简单和复杂。简单枚举仅指定常量值;编译器会自动生成默认构造函数。所有枚举构造函数默认为private。因此,外部类不能new它们——实际上定义的常量是构造函数的调用。复杂枚举看起来与类非常相似,因为它们有实例变量、(显式)构造函数和方法。
当你拥有大量样板代码的类时,记录(Records)非常有用。记录的组件在记录声明中指定。编译器在后台生成实例变量、规范构造函数、访问器方法、toString、equals和hashCode方法。记录是final的,实例变量(组件)也是如此。紧凑构造函数是规范构造函数的一种更简洁的变体。
这就完成了我们对类、对象和枚举的讨论。我们现在将转向另一个重要的面向对象编程(OOP)章节:继承。
第九章:继承和多态
在第八章中,我们学习了类、对象和枚举。最初,我们探讨了类与对象之间的关系以及将引用类型与对象类型分开的需要。我们对比了实例成员与类成员,并看到使用static关键字将类作用域应用于成员。我们讨论了this引用,并演示了在实例方法内部,this引用指向负责方法调用的对象实例。我们还涵盖了各种访问修饰符:private、包私有(无关键字)、protected和public。这些修饰符使我们能够应用面向对象编程的基石之一,即封装。虽然封装通常被称为“私有数据,公有方法”,但我们演示了这还不够,因为 Java 在将引用传递进方法和从方法返回时采用值传递机制。我们展示了如何使用称为“防御性复制”的技术来应用适当的(高级)封装。为了提高我们对后台发生的事情的理解,我们详细介绍了对象生命周期,并简要提到了垃圾回收。我们还涵盖了instanceof关键字,它用于确定引用所引用的对象类型。我们涵盖了类的变体,即枚举(枚举)。枚举使我们能够限制实例的数量,从而促进类型安全。我们涵盖了简单和复杂的枚举。最后,我们涵盖了另一种类变体,即记录,它可以节省我们编写大量样板代码。
在本章中,我们将探讨继承,这是面向对象编程的另一个核心原则。最初,我们将概述继承的好处以及要使用的 Java 关键字。这导致多态,这是面向对象编程的另一个核心支柱。我们将解释多态,并通过示例说明如何实现多态。由于多态需要“方法重写”,我们将解释如何使用instanceof,以确保向下转型时的类型安全。
我们还将对比方法重写与方法重载。我们将解释super关键字及其用法。正如在第八章中所承诺的,我们将重新探讨protected,这是 Java 访问修饰符中最容易被误解的一个。
之后,我们将讨论abstract和final关键字及其在继承中的作用。我们还将展示如何使用sealed类来限制继承的范围。此外,我们还将涵盖继承层次结构中的static和实例块。最后,我们将讨论向上转型和向下转型继承树,以及一个简单的经验法则如何帮助防止ClassCastException错误。
本章涵盖了以下主要主题:
-
理解继承
-
应用继承
-
探索多态
-
对比方法重写与方法重载
-
探索
super关键字 -
重新探讨
protected访问修饰符 -
解释
abstract和final关键字 -
应用
sealed类 -
理解实例和
static块 -
掌握向上转型和向下转型
技术要求
本章的代码可以在 GitHub 上找到:github.com/PacktPublishing/Learn-Java-with-Projects/tree/main/ch9。
理解继承
Java 有三个核心支柱:多态性、继承和封装(数据隐藏)。使用缩写“PIE”(Polymorphism, Inheritance, 和 Encapsulation)来记忆它们很容易。现在让我们来考察继承。
继承是一种代码重用机制,通过在相关类型之间形成关系来利用它们之间的共同属性。Java 中的继承关系是通过从一个类扩展或通过实现一个接口来创建的。我们将在第十章中介绍接口,所以现在我们将假设类。为了理解为什么面向对象的继承很重要,我们将检查其优点(和缺点)。由于我们尚未介绍所使用的术语,这次讨论将有些抽象。
继承的优点
继承的一个主要优点是代码重用。可以基于现有类编写新类,而不是从头开始编写新类。换句话说,新类可以继承已经编写(并测试)的代码。这被称为代码重用,减少了冗余。
继承自然地促进了多态性,我们稍后讨论。这个特性给你的代码带来了灵活性。例如,你可以有一个处理Animal引用的方法,但在运行时,执行的代码是在Dog类型(或Cat或任何其他Animal类型的层次结构中的类型)。实际上,一个方法可以与所有Animal类型一起工作。
继承将代码组织成层次结构。这可以提高生产力并简化代码的维护,因为对继承代码所做的更改会立即在整个层次结构中反映出来。
继承的缺点
尽管有优点,继承确实有其缺点。基类型(源类型)和派生类型(目标类型)之间的紧密耦合是一个缺点。对基类型所做的任何更改都会影响所有派生类型。
代码膨胀是另一个缺点。可能对基类型进行更改,而许多派生类型并不需要这些更改,这可能导致代码库不必要地庞大。
既然我们已经对继承及其用途有了认识,让我们讨论在讨论继承时使用的术语(名词)。
基类
“基”类也被称为“超”类或“父”类。这是定义继承成员的地方。由于类是一种类型,因此术语类型通常可以与类互换使用。请注意,在 Java 中,Object类是每个层次结构的顶层。
派生类
子类也被称为“子”或“派生”类。因此,子类从基类继承功能(和/或数据)。再次强调,由于类是一种类型,因此术语 子类型 常与子类互换使用。一个类可以既是基类也是子类。Java 确保每个(继承)层次结构的顶部都是 Object。因此,我们编写的每个类都是隐式子类型(即使你没有这么说)。
“是”关系
继承生成所谓的 “是” 关系。图 9.1 将帮助我们解释这一点。随着本章的进展,我们将扩展这个图:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_09_1.jpg
图 9.1 – 车辆层次结构的 UML 图
统一建模语言 (UML)
UML 是一种在软件设计中使用的建模语言,它利用了“一图胜千言”的原则。UML 使得理解诸如继承等主题变得非常直接,因此在这里我们将简要介绍 UML。更多详细信息请参阅:en.wikipedia.org/wiki/Unified_Modeling_Language。
在考虑 图 9.1 的基础上,以下是所使用的符号概述:
-
包名:包名位于左上角 (
ch9) -
类:类在有三个部分的框中,顶部框是类名;中间框用于实例/类变量;底部框用于方法
-
访问修饰符:
public(+),private(-), 包私有 (~), 和protected(#) -
静态:下划线用于表示成员是
static -
方法返回类型:UML 中方法签名中的最后一部分
-
类继承:带有实线的箭头;例如,
Car从Vehicle继承 -
接口:这些在带有虚线的框中表示 (第十章)
-
接口继承:带有虚线的箭头 (第十章)
-
关联:一条实线;例如,
TestVehicle与Vehicle相关联的简单原因是我们将在main()函数中基于Vehicle层次创建对象。
如 图 9.1 所示,我们有一个包,即 ch9。在 Vehicle 层次结构中有五个类:Vehicle、Car、Saloon、Convertible 和 Boat。在这个层次结构中,从基类角度来看,Vehicle 是 Car 和 Boat 的基类;而 Car 是 Saloon 和 Convertible 的基类。从子类角度来看,Car 和 Boat 是 Vehicle 的子类,而 Saloon 和 Convertible 是 Car 的子类。无论使用哪种视角,每个 Car 都是 Vehicle 的“是”,每个 Boat 也是 Vehicle 的“是”。此外,每个 Saloon 是 Car 的“是”,每个 Convertible 也是 Car 的“是”。
这也意味着,因为Saloon是Car的一种,而Car又是Vehicle的一种,所以Saloon也是Vehicle的一种。同样的情况也适用于Convertible;换句话说,Convertible是Car的一种,Car是Vehicle的一种;因此,Convertible也是Vehicle的一种。
然而,“是一个”的关系只在一个方向上起作用(从底部向上读取图)。例如,虽然每个Car都是Vehicle的一种,但不是每个Vehicle都是Car的一种;有些是Boat。这有一个非常好的原因,我们将在讨论向上转型和向下转型时进一步探讨。
在Vehicle类中有一个方法,即toString(),因为它被声明为public,所以被所有子类型继承;也就是说,Car、Saloon、Convertible和Boat。因此,Vehicle中的toString()版本在整个层次结构中都是可用的。最后,其他类TestVehicle包含main()方法,这样我们就可以测试这个层次结构。
现在我们已经理解了继承的概念,让我们在代码中应用它。
应用继承
如我们在上一节所学,继承创建了一个“是一个”的关系层次结构。这使得基类的功能可以被继承,因此对子类可用,而无需额外的编码。Java 在应用继承时使用两个关键字:extends和implements。现在让我们来讨论它们。
extends
这是用于类和接口的原理关键字。关于类,我们声明class Sub extends Base {}。在这种情况下,Base类中所有非private成员都将被继承到Sub类中。请注意,private成员和构造函数不会被继承——这是有道理的,因为private成员和构造函数都是类特定的。此外,Java 禁止多重类继承。这意味着你一次不能从一个以上的类中扩展。关于接口,我们声明interface ChildInt extends ParentInt {}。
implements
虽然我们将在第十章中详细讨论接口,但在这里简要概述是合适的。接口是一种结构,它使 Java 能够确保如果一个类实现了接口,那么这个类实际上是在签署一份合同。这个合同通常表示,该类将为接口中的abstract方法提供代码。abstract方法,我们将在稍后详细讨论,是一种没有实现代码的方法;换句话说,没有花括号。
关于继承,与类不同,Java 允许接口一次扩展多个接口。例如,interface C extends A, B {},其中A、B和C都是接口,这是可以的。请注意,截至 Java 8,接口中的default和static方法都有实现代码。
一个类使用 class Dog implements Walkable 语法实现接口。这样,Walkable 中的 static 和 default 方法对 Dog 可用。
现在,让我们看看继承的实际应用。图 9.2 展示了 图 9.1 中的 UML 图对应的 Java 代码:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_09_2.jpg
图 9.2 – 继承的实际应用
在这个图中,第 3 行和第 4 行是等效的。Vehicle 是这个特定层次结构的最顶层,为了确保每个类都继承自 Object,编译器在第 3 行简单地插入 extends Object。第 5-7 行是对从 Object 继承的 toString() 方法的自定义实现。这被称为 重写,我们将在稍后详细讨论这个话题。第 9-12 行代表了继承层次结构的其余部分:一个 Car 是一个 Vehicle;一个 Boat 是一个 Vehicle;一个 Saloon 是一个 Car;一个 Convertible 是一个 Car。
在第 16 行,我们创建了一个 Vehicle 对象,并使用一个名为 vehicle 的 Vehicle 引用来引用它。在第 17 行,我们调用定义在第 5-7 行的 toString() 方法,输出 Vehicle::toString()。
在第 18 行,我们创建了一个 Car 对象,并使用一个名为 car 的 Car 引用来引用它。在第 20 行,我们只需将 car 引用插入到 System.out.println() 中。当 Java 遇到这样的引用在 System.out.println() 内部时,它会查找对象类型(在这个例子中是 Car)并调用其 toString() 方法。由于每个类都继承自 Object,而 Object 定义了一个基本的(不友好的)toString(),因此将存在一个 toString() 版本。然而,在这个层次结构中,Vehicle 用自己的自定义版本(第 5-7 行)替换了从 Object 继承的 toString()。这个来自 Vehicle 的自定义版本被 Car 继承。发生的情况是,Java 检查 Car 中是否定义了自定义的 toString();由于没有定义,Java 然后检查其父类,即 Vehicle。如果 Vehicle 中没有 toString(),将使用来自 Object 的版本。由于 toString() 在 Vehicle 中定义,这是 Car 继承并用于第 20 行的版本。因此,输出再次是 Vehicle::toString()。
在第 21 行,我们创建了一个 Saloon 对象,并使用一个名为 saloon 的 Saloon 引用来引用它。同样,在第 22 行,我们只需将 saloon 引用插入到 System.out.println() 中。由于 Saloon 没有自定义的 toString(),其父类 Car 也没有自定义版本,因此使用从 Vehicle 继承的版本。这导致输出到屏幕的是 Vehicle::toString()。
第 24 行用于演示当使用Object类的toString()方法时的输出。在第 24 行,我们创建了一个TestVehicle的实例并调用其toString()方法。由于TestVehicle没有明确地从任何类继承(使用extends),它隐式地继承了Object。此外,由于TestVehicle没有用自己的自定义版本覆盖toString(),因此使用从Object继承的版本。这可以通过第 24 行的输出得到证明:ch9.TestVehicle@378bf509。Object中的toString()方法的输出格式为package_name.class name@hash code。在这个例子中,包名是ch9(第 1 行),类名是TestVehicle(第 24 行),而哈希码是一个用于散列集合的十六进制数(第十三章)。
现在我们已经看到了基本继承的作用,让我们继续探讨面向对象编程的另一个基石,即多态性。
探索多态性
多态性起源于希腊术语 poly(许多)和 morphe(形式)。任何通过一个以上的“is-a”测试的对象都可以被认为是多态的。因此,只有Object类型的对象不是多态的,因为任何类型都会通过Object和它自己的“is-a”测试。
在本节中,我们将讨论为什么将引用类型与对象类型分开是如此重要。此外,我们还将检查方法覆盖及其在启用多态性中的关键作用。
将引用类型与对象类型分开
现在我们有了继承层次结构,我们将经常区分引用类型和对象类型。引用类型可以是类、记录、枚举或接口。换句话说,我们在引用类型方面有灵活性。对象类型更为严格:对象类型仅基于非抽象类、记录和枚举。换句话说,我们不能基于抽象类或接口创建对象。
例如,给定图 9.2中的层次结构,以下说法是完全合法的:
Vehicle v = new Car();
这是因为每个Car都是Vehicle的“is-a”类型(从右到左阅读,因为赋值是从右到左关联的)。在这个例子中,引用v是Vehicle类型,它指向一个Car类型的对象。这被称为向上转型,因为我们正在向上继承树(再次从右到左阅读,从Car向上到Vehicle)。我们正在向上转型Car引用,由new Car()创建,并将其转换为Vehicle引用v。
为什么这行得通?这行得通是因为,由于继承,Vehicle可用的所有可继承方法都将存在于Car中。这是一个保证。无论Car是否用其自定义的任何/所有Vehicle方法替换了这些方法,都是无关紧要的。鉴于编译器查看引用类型(而不是对象类型),我们可以使用Vehicle引用v调用的方法是在Vehicle(和Object)中定义的,并且将在Car(对象类型)中存在。
因此,这是需要记住的第一个要点——编译器始终查看引用类型。正如我们很快就会看到的,对象类型在运行时发挥作用。所以,一个简单但有效的经验法则是一个引用可以指向其自身类型的对象或子类对象。实际上,一个引用可以指向(UML)层次结构的“上方和下方”。
如果一个引用始终指向层次结构的“上方”,那么就会出现ClassCastException错误。为什么会出现这种情况呢?好吧,子类从其父类继承。除了替换继承的功能(重写)之外,子类还可以添加额外的方法。所以,如果你有一个子类类型的引用,你可以调用这些额外添加的方法。但是,如果你的对象是父类类型,这些方法将不存在!这对 JVM 来说是一个严重的问题,它会立即抛出一个异常(第十一章)。
因此,引用类型决定了可以调用对象的方法。此外,虽然引用类型不能改变,但它所指向的对象类型可以改变。
现在,让我们讨论如何利用多态性。
应用多态性
多态性仅适用于实例(非静态)方法,因为只有实例方法可以被重写。在编译时,编译器决定绑定哪个方法签名;然而,将提供实际要执行的方法的对象是在运行时决定的!这就是多态性的含义。这也是为什么多态性也被称为“运行时绑定”或“后期绑定”的原因。
如果你正在访问一个静态成员怎么办?
一个static成员(方法或数据)与类相关联,因此不涉及多态性。以下适用:如果你正在访问任何类型的数据(static或非静态)或static方法,JVM 使用引用类型。只有当它是实例方法时,才会使用对象类型(多态性)。
因此,为了使多态性起作用,我们需要在基类和子类中实现实例方法,其中子类重写了基类版本。为了实现这一点,子类必须编写一个与父类具有相同签名的函数。
好吧,理论就到这里为止——让我们看看一个例子,以巩固我们迄今为止所学的一切。
代码中的多态性——示例 1
图 9.3显示了以下代码的 UML 图:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_09_3.jpg
图 9.3 – 多态性的 UML 示例
在这个图中,Vehicle类有一个move()方法。它是一个实例方法,返回类型为void。Car和Boat都扩展了Vehicle并重写了move()方法。Car添加了一个名为wheels()的方法,而Boat添加了一个名为floats()的方法。Saloon和Convertible都扩展了Car。Saloon重写了move()方法,而Convertible没有。
图 9*.4* 展示了此 UML 的代码,并展示了多态的实际应用:
@Override注释
注释是一种元数据形式,它提供了关于程序的信息,但这些信息本身并不包含在程序中。在 Java 中,注释由@符号开头,并且有多种用途。例如,注释被编译器用来检测错误,或者被运行时用来生成代码。
当重写基类方法时,我们可以在子类方法之前插入@Override注释。虽然这不是强制的,但它非常有用,因为如果我们应用这个注释,编译器将确保我们正确地重写了方法。
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_09_4.jpg
图 9.4 – 多态示例
在这个图中,Car和Boat都扩展了Vehicle;Saloon和Convertible都扩展了Car。请注意,Vehicle中的move()方法(第 4 行)是一个非静态/实例方法,因此是多态的。此外,由于move()不是private的,它是可继承的。Vehicle中的move()方法被Car(第 7 行)、Boat(第 11 行)和Saloon(第 15 行)重写。为了强调这一点,我们在这些行的每一行都使用了@Override注释。这意味着父类move()方法被相应的子类版本重写。
第 21 行创建了一个Car对象,并使用一个Vehicle引用,即v,来引用它。值得重复的是,这种从Car到Vehicle的上转型,仅因为通过继承,每个 Car 都是 Vehicle 的子类。因此,任何对Vehicle引用可用的方法都将存在于Car中。因此,由于上转型从不构成风险,编译器会隐式地执行它;换句话说,你不需要在代码中显式地声明(上)转型,如下所示:
Vehicle v = (Vehicle) new Car();
编译时
第 22 行调用了多态的v.move()方法。每次在代码中进行方法调用时,都需要考虑两个视角:编译时和运行时。正如我们所知,编译器关注的是引用类型。因此,在这种情况下,编译器检查引用v,并确定它是Vehicle类型。然后编译器检查在Vehicle类中是否存在具有该确切签名的move()方法,无论是定义在Vehicle中还是从Object(在这个例子中)继承到Vehicle中。由于在Vehicle中定义了move()方法,编译器对此感到满意。
多态的实际应用
在运行时,由于move()是一个非静态的多态方法,被引用的对象,即v,适用。由于v指的是一个Car对象,所以执行了Car版本的move()。这是多态的实际应用!我们有一个方法,但有很多这个方法的实现。编译器确保方法存在,并在运行时动态地触发多态,并执行被引用对象中的版本。由于v指的是一个Car对象,所以第 22 行的输出结果是Car::move()。
第 23 行重复使用了Vehicle引用,v(这是完全有效的),来指代一个Boat对象。因为Boat是Vehicle的一种(即“is-a”关系),所以这是可以的。第 24 行对v.move()进行了与第 22 行相同的多态调用。然而,这次v指的是一个Boat对象,并且由于Boat重写了move()方法,所以在运行时执行了Boat版本的move()。因此,输出结果是Boat::move()。
第 25 行展示了编译器查看引用类型。正如我们所知,v的类型是Vehicle。然而,Vehicle没有floats()方法;这是一个特定于Boat的方法。因此,编译器在第 25 行对v.floats()提出了抱怨,所以这一行被注释掉了。
第 26 行重复使用了Vehicle引用,v,来指代一个Saloon对象。因为Saloon是Vehicle的一种(即“is-a”关系),所以这是可以的。第 27 行对v.move()进行了相同的多态调用,就像第 22 行和第 24 行的情况一样。由于v现在指的是一个具有重写move()方法的Saloon对象,所以在运行时以多态方式执行了Saloon版本的move()。因此,输出结果是Saloon::move()。
第 28 行创建了一个Convertible对象,并使用v来引用它。这没问题,因为Convertible是Vehicle的一种(通过Car间接)。换句话说,因为Convertible是Vehicle的一种,而Vehicle是Car的一种,所以Convertible也是Car的一种。第 29 行进行了相同的多态调用,即v.move(),就像第 22 行、第 24 行和第 27 行的情况一样。然而,需要注意的是,Convertible没有重写move()方法。Convertible有一个空的类体。因此,Convertible中的方法是从Car继承来的move()和wheels()方法,以及从Object继承来的方法,如toString()。所以,在运行时,当调用v.move()时,JVM 执行了Car中的move()版本,结果是Car::move()。你也可以这样理解:运行时在Convertible中查找move(),但没有找到;然后 JVM 检查其父类Car,并找到了一个,然后执行它。请注意,如果Car没有提供move()方法,它的父类Vehicle将会是下一个搜索的对象。所以,这是一个“逐级向上,一次一代”的有序搜索。
为什么我们会得到ClassCastException错误?
第 31 行展示了向下转型和ClassCastException错误。异常将在第十一章中讨论,所以这里不会详细说明。向下转型将在本章后面更详细地讨论,但这个例子太好了,不容错过!让我们更详细地检查第 31 行:
Saloon s = (Saloon) new Vehicle(); // ClassCastException
首先要注意的是,转型(Saloon)是必需的。编译器不会允许以下代码:
Saloon s = new Vehicle(); // Compiler error
这是一个编译器错误,因为并非每个Vehicle都是Saloon类;有些是Boat。实际上,即使没有Boat类,这一行代码也无法编译。为什么?因为,从右向左阅读,你是在从Vehicle到Saloon的层次结构中向下移动。由于Saloon可能(实际上确实如此)有不在Vehicle类中的额外方法,这种情况必须被阻止。例如,Saloon引用s可以访问从Car继承来的wheels()方法,而Vehicle中没有这个方法。
现在,我们可以通过使用(向下)转型来覆盖编译器。这就是第 31 行使用(Saloon)转型的做法。实际上,通过插入转型并覆盖编译器错误,你是在告诉编译器:“让我继续,我知道我在做什么。”因此,代码在放置转型后可以编译。然而,在运行时,JVM 意识到它有一个指向继承树中Vehicle对象的Saloon引用。这是大忌,因为如果 JVM 允许Saloon引用s指向一个Vehicle对象,那么在随后的s.wheels()方法调用中会发生什么?记住,我们会看到一个没有这种方法的Vehicle对象!因此,JVM 生成一个ClassCastException错误。
让我们重构这段代码,从另一个角度展示多态性。
代码中的多态性 – 示例 2
图 9.5显示了从图 9.4重构的代码:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_09_5.jpg
图 9.5 – 重构的多态性示例
注意,在这个图中,继承层次结构从图 9.4保持不变。TestVehicle类(第 19-30 行)已经被重构。我们引入了一个新方法,即doAction()(第 20-22 行),它接受一个Vehicle引用。在doAction()方法中,我们简单地调用move()方法(第 21 行)。由于Vehicle有一个move()方法,这是可以的。
第 24 行与之前相同;它创建了一个Car对象,并将引用向上转换为Vehicle引用v。因此,v引用了一个Car对象。第 25 行调用doAction()方法,传入引用v。这个在第 24 行声明的引用v被复制到另一个(不同的作用域)但名称相似的引用v,它在第 20 行声明。现在,在doAction()中,我们有一个局部v引用,它引用了第 24 行创建的同一个Car对象。因此,当我们第 21 行调用v.move()时,多态就起作用了,我们得到Car版本的move(),结果是Car::move()。
第 26 行在一行代码中做了与前面两行代码(第 24-25 行)相同的事情。在第 26 行,创建了Boat对象,对doAction()的方法调用导致向上转换为Vehicle引用v(第 20 行)。之后,第 21 行以多态方式执行,我们得到Boat版本的move(),结果是Boat::move()。
第 27 行与第 26 行相同,只是我们正在创建一个Saloon对象。因此,doAction()中的Vehicle引用v执行了move()方法作为Saloon,结果是Saloon::move()。
第 28 行与第 27 行相同,只是我们正在创建一个Convertible对象。因此,doAction()中的Vehicle引用v试图在Convertible中执行move()方法。由于没有找到,检查Convertible的父类,即Car。Car确实有一个move()版本,结果是Car::move()。
为了清楚地了解何时应用多态以及何时不应用,我们将重新审视之前提出的调用框。
JVM – 引用类型与对象类型的使用
如前一个调用框中简要讨论的,如果你处理任何类型的数据(static或非静态),则应用引用类型;当处理实例方法时,应用对象类型(多态)。图 9.6 展示了代码示例:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_09_6.jpg
图 9.6 – JVM 使用引用类型与对象类型的情况
在这个图中,Vehicle类声明了一个实例变量,即cost(第 4 行),以及一个类变量,即age(第 5 行)。此外,Vehicle还声明了一个名为move()的实例方法(第 6-8 行)和一个名为sm()的类方法(第 9-11 行)。因此,在Vehicle中,我们既有实例和static数据,也有实例和static方法。
Car类从Vehicle扩展(第 13-23 行)并简单地复制Vehicle。换句话说,Car具有与父类Vehicle相同的数据和方法。
在Car中,我们分别声明了实例变量和非实例变量,即cost和age(第 14-15 行)。这些变量在Car中的类型和标识符与父类Vehicle中的对应变量相同。换句话说,Vehicle有一个名为cost的实例变量,它是一个double;Car也有一个名为cost的实例变量,它也是一个double。Vehicle中的age类变量也有相同的情况——Car子类中也有一个名为age的类变量。这被称为隐藏(或遮蔽)。
Vehicle定义了一个实例方法move()(第 6-8 行),它被Car中的版本(第 17-19 行)重写。由于这是一个实例方法,如果调用move(),则在运行时应用多态。
Vehicle还定义了一个名为sm()的类方法(第 9-11 行),它被Car中的sm()版本(第 20-22 行)隐藏(遮蔽)。
第 26 行创建了一个Car对象,并使用一个Vehicle引用v来引用它。
第 27 行输出v.cost。由于cost是数据(一个实例变量),引用类型适用。因此,我们得到100.0,这是Vehicle中的cost实例变量(而不是20_000.0,这是Car中的cost实例变量)。
在访问静态成员时使用类名
第 28 行和第 29 行展示了你应该永远不要使用的语法:使用引用来访问static成员。在访问static成员时,你应该在成员前加上类名。例如,第 28 行应该使用Vehicle.age,而第 29 行应该使用Vehicle.sm(),因为这强调了成员的static属性。在这里使用引用是令人困惑的,因为它暗示成员是非静态的。我们仅为了演示目的使用引用来访问static成员!
第 28 行输出v.age。由于age是一个static成员,编译器会检查v的类型(即Vehicle),并将v.code更改为Vehicle.code。因此,使用的是Vehicle中的age,而不是Car中的age。换句话说,输出是 1,而不是 2。
第 29 行是对v.sm()的调用。由于sm()也是static的,编译器将其转换为Vehicle.sm(),因此输出是Vehicle::sm()。
最后,第 30 行是对move()的多态调用,因此使用的是对象类型Car。这导致输出Car::move()。
现在我们已经理解了多态,让我们确保我们理解了两个经常被混淆的术语之间的区别,即方法重写和方法重载。
对比方法重写和方法重载
这两个术语经常被混淆,但在本节中,我们将比较和对比这两个术语。我们将展示,在方法重载的情况下,方法签名必须不同;而在方法重写的情况下,方法签名必须相同。回想一下,方法签名由方法名和参数类型组成,包括它们的顺序。返回类型和参数标识符不是方法签名的一部分。所以,例如,考虑图 9**.5中的方法:
public static void doAction(Vehicle v){…}
签名为doAction(Vehicle)。
在这个前提下,我们将首先讨论方法重载。
方法重载
回想一下,方法签名由方法名和参数类型组成。方法重载是你有相同的方法名,但参数不同,无论是类型和/或顺序。这意味着即使方法名相同,方法签名也不同。它们必须这样,否则编译器如何选择绑定哪个方法?因此,方法重载完全是关于编译时间的。
规则
考虑到方法签名必须不同(除了方法名之外),规则相当简单:
-
重载方法必须使用不同的参数列表;要么使用的类型必须不同,要么类型的顺序必须不同
-
由于方法签名只与方法名和参数列表相关,重载方法可以更改返回类型和访问修饰符,并使用新的或更广泛的检查异常
-
重载方法可以在同一类型或子类型中进行重载
现在,让我们看看代码中方法重载的一个例子。
方法重载示例
图 9*.7* 展示了示例代码:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_09_7.jpg
图 9.7 – 方法重载
在这个图中,我们将首先讨论第 10-17 行之间的方法重载。为了帮助理解,每行上的注释中都包含了方法签名。第 10 行定义了一个calc方法,该方法接受一个int和一个double类型的参数,顺序如下。因此,签名如下:
calc(int, double)
我们对返回类型或用于int和double参数的标识符不感兴趣。只要我们不在同一个类中编码另一个calc(int, double)方法,我们就没问题。注意,如果我们在一个子类型中编码了一个具有相同签名的函数,这将是重写!由于第 11-14 行之间的方法签名不同,它们是可行的。
让我们分析为什么第 16 和 17 行无法编译。第 16 行试图只更改参数使用的标识符。这并没有改变方法签名。因此,这个签名与方法第 10 行的签名完全匹配,因此编译器会报错。同样,第 17 行更改了返回类型(以及参数列表中的标识符)。再次,由于这个签名与第 10 行的签名重复,编译器会报错。
继承层次结构很有趣。我们有一个父类Animal(第 2-4 行)和一个子类Cow(第 5-8 行)。在第 3 行,Animal定义了一个eat()方法。在第 7 行,Cow使用eat(String)方法重载了这个方法。父类Animal版本不接受任何参数,而子类版本接受String参数。编译器很高兴。
但第 6 行呢,Cow类定义了一个不接受任何参数的eat()方法?这是在覆盖父类版本(多态),所以没有冲突。编译器将绑定到使用的引用类型,无论是Animal还是Cow,因为两者都有一个eat()方法。在运行时,根据对象类型,JVM 将执行相关代码。
让我们检查这个过程以确保它清晰。第 20 行创建了一个Animal对象,并使用一个Animal引用aa来引用它。第 21 行调用aa.eat()。在编译时,编译器检查Animal类中是否有具有该确切签名的eat()方法,因为Animal是aa的类型。由于有,编译器很高兴。在运行时,由于方法是实例方法,多态适用,JVM 将执行Animal版本(因为这是对象类型)。
注意第 22 行为什么无法编译。这是因为Animal类中没有eat(String)方法。记住,编译器只查看引用类型,而aa的类型是Animal,它检查Animal类。
第 24-26 行进一步说明了问题。第 24 行创建了一个Cow对象,并使用一个名为ac的Animal引用来引用它。第 25 行进行了多态调用eat(),这将执行运行时的Cow版本。第 26 行很有趣,它存在是为了证明编译器正在查看引用类型。尽管我们的对象类型是Cow且Cow有一个eat(String)方法,但ac.eat("Grass")类仍然无法编译(因为ac的类型是Animal)。
那么,我们如何访问eat(String)方法呢?我们需要一个Cow引用。这正是第 28-30 行所展示的。第 30 行成功使用第 28 行声明的cc引用调用了cc.eat("Grass")。
这段代码展示的是,一个Animal引用只能访问它定义的eat()方法。另一方面,一个Cow引用可以访问eat()和eat(String)。Cow类型继承(并覆盖)了eat()并自己定义了eat(String)。注意,Cow类不需要覆盖eat()就能访问继承的版本。
方法覆盖
当你在父类和子类中都有相同的方法签名时,就会发生方法重写。方法重写对于启用(运行时)多态至关重要。记住,一个方法必须首先被继承才能被重写。例如,定义为private、static或final的方法不会被继承,因为private方法仅限于类内部;static方法不是多态的,将方法标记为final是声明“这个方法不应该被重写”。
要理解规则,关键是要记住编译器是根据引用来编译代码的。因此,运行时多态方法必须与编译器验证的行为一致。例如,重写方法上的访问修饰符不能更严格。
在我们讨论规则之前,我们必须首先解释协变返回。
协变返回
当你在子类中重写父类的方法时,如果返回类型是原始类型,那么重写方法的返回类型必须匹配。然而,如果返回类型是非原始类型,那么有一个例外:协变返回。
协变返回的含义是,如果你在父方法中返回类型X,那么你可以在重写方法中返回X及其任何子类型。例如,如果父方法返回Animal,那么重写方法可以返回Animal(自然地),以及Animal的任何子类型;例如,Cow。
规则
在我们讨论规则时,记住编译器是针对引用类型进行检查是有帮助的。这些重写规则确保运行时对象不能做编译器(以及你的代码)没有期望的事情。规则如下:
-
方法签名必须在父类和子类中完全匹配;否则,你只是在重载方法。
-
返回类型也必须匹配,除了协变返回。
-
重写方法上的访问修饰符不能更严格。所以,如果父方法将方法定义为
public,子类不能使用private方法来重写它。这很有意义,因为你的代码,经过编译器的验证,期望可以访问该方法。然而,如果你被允许在重写时降低访问级别,编译器会说“可以访问这个方法”,而 JVM 则不会!这条规则有助于保持编译器和 JVM 同步。 -
再次强调,为了保持编译器和 JVM 同步,重写方法不能抛出(生成)新的或更广泛的已检查异常(第十一章)。简而言之,异常是一个错误,已检查异常必须有代码来处理它们。这是由编译器强制执行的。如果在运行时,重写方法抛出/生成了一个没有代码来处理的异常,JVM 就会遇到麻烦。因此,编译器介入并阻止这种情况发生。
现在,让我们看看代码中方法重写的一个例子。
方法重写示例
图 9.8展示了示例代码:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_09_8.jpg
图 9.8 – 方法重写
该图中的代码演示了在重写方法时可以做什么和不能做什么。在Dog类(第 5-8 行)中,我们有一个返回无内容(void)的walk()方法。还有一个返回Dog的run()方法。
Terrier类从Dog类继承(第 9 行)。因此,任何Terrier都是Dog的子类。由于Dog中的两个方法都是public,Terrier自动继承它们。
让我们依次检查Terrier中的行。
第 10 行无法编译,因为虽然方法签名匹配(两者都是walk()),但返回类型不同。父类返回类型是void,因此,重写返回类型必须匹配;它不匹配,是String,导致编译器错误。
第 11 行无法编译,因为在重写时不能弱化访问修饰符。Dog中的walk()方法是public,所以Terrier中的walk()不能是private。如果这被允许,那么当 JVM 执行Terrier中的walk()方法时(如第 24 行所示),将会有严重问题。编译器查看public的Dog版本,会说“一切正常;”但 JVM 会多态地遇到Terrier中的private版本!
第 12 行无法编译,因为被重写的方法没有抛出任何异常,但重写的方法正在尝试抛出一个新的已检查异常(IOException)。这与之前的访问问题类似——编译器将检查Dog中的walk()版本,并且因为它没有抛出异常(错误),没有代码来处理(适应)这些异常。如果重写方法被允许抛出新的已检查异常,JVM 将如何处理它们(因为没有代码来处理它们)?
第 13 行只是一个重载。Dog定义了一个walk()方法;Terrier定义了一个walk(int)方法。两个不同的方法签名意味着两个不同的方法。由于方法具有相同的名称,这属于方法重载。
第 14 行是一个正确的方法重写。我们使用了@Override注解来确保我们已经正确地重写了(例如,没有打字错误)。
第 16 行是第 7 行定义的run()方法的精确复制。我们只是为了演示目的而包含它。
第 17 行展示了协变返回,因为它定义了Terrier返回类型。这是一个有效的协变返回,因为Terrier是父类返回类型Dog(第 7 行)的子类型。重写方法的代码(第 17 行)简单地返回一个Terrier对象。
第 18 行几乎与第 17 行相同,除了返回类型现在是Dog。因此,在后台发生了向上转型。第 18 行的walk()代码是以下代码的简写:
Dog d = new Terrier():return d;
现在,让我们看看OverridingTest中的main()方法。
第 23 行创建了一个可以通过 Dog 引用 dt 访问的 Terrier 对象。第 24 行在 Terrier 中调用了多态的 walk() 方法。由于 Terrier 覆盖了从 Dog 继承的 walk() 方法,因此在运行时动态执行的是 Terrier 版本的 walk(),导致输出 Terrier::walk()。
第 25 行使用第 23 行创建的 dt 引用执行了 run() 方法。由于 run() 是一个实例方法,其中 Terrier 覆盖了从 Dog 继承的版本,因此执行的是 Terrier 中的版本,导致第 25 行的 d 引用指向一个 Terrier 对象(第 18 行)。这通过使用 instanceof 操作符(第 26 行)得到了证明。由于 Dog 引用 d 确实指向一个 Terrier 对象,因此 if 语句为 true,导致在屏幕上输出 Terrier 对象。
这就结束了我们对方法重载和方法覆盖的讨论。现在,让我们考察一个在继承中至关重要的关键字:super。
探索 super 关键字
super 关键字在子类中有两个特定的使用场景:调用父构造函数和访问父成员(通常是方法)。当对象被构造时,构造函数调用的顺序非常重要。考虑到我们现在有可能在继承层次结构中有许多类,构造函数调用的顺序是从上到下的。这意味着父构造函数总是先于子类构造函数被调用。如果你有一个 Toyota 是 Car 的子类,而 Car 又是 Vehicle 的子类,那么当你创建一个 Toyota 对象时,构造函数调用的顺序如下:首先调用 Vehicle 的构造函数,然后是 Car,最后是 Toyota。
这有一个很好的原因。首先,记住构造函数的作用是初始化类的实例成员。现在,考虑到子类构造函数在初始化自己的成员时可能会使用从其父类继承的成员,因此可以合理地认为父类必须首先有机会初始化这些成员。
让我们讨论 super 关键字经常被使用的情景。然后我们将展示代码,并辅以 UML 图形,以演示这两种上下文。
super()
当你在 super 后面使用括号,如 super() 时,你是在调用父构造函数。如果需要,你可以在括号内传递参数,因为构造函数只是(特殊的)方法。使用 super() 有两个规则:
-
调用
super()只能在构造函数内部出现,而不能在普通方法中 -
如果存在,
super()的调用必须是构造函数中的第一行(有一个例外——见注释)
我们已经编写了几个构造函数,但它们都没有包含对super()的调用。这是怎么工作的呢?好吧,如果你没有提供任何构造函数,编译器会为你生成默认构造函数,并且其第一行代码是super();。请参考图 8.1 和图 8.2 中的示例。如果你提供了构造函数,编译器也会插入super();作为第一行(除非第一行已经是super()或this()的调用)。
任何构造函数的第一行
任何构造函数的第一行必须是this()或super()。两者不能同时存在。对this()的调用是对同一类中另一个构造函数的调用。从继承层次的角度来看,这是一个横向调用。请记住,在子类构造函数之前必须调用父类构造函数。无论是否有this(),构造函数调用的顺序都是从上到下。现在,如果子类构造函数中存在this()调用,它只会延迟对super()的调用。在某个时刻,无论是显式还是隐式,对super()的调用都将执行。注意,与super()一样,对this()的调用可以包含参数。
因此,super()仅与构造函数相关,并且必须是代码的第一行(假设this()已经存在)。现在,让我们考察另一种情况。
super.
要访问父成员(不是构造函数),你可以使用super.点符号语法。与this关键字一样,super关键字与实例相关,因此不能在static上下文中使用(static方法或static块)。这在你想要利用父功能时非常有用。例如,子类方法可以先调用其父版本,然后再执行自己的版本。这正是我们将通过示例来展示的。
因此,与其从子类构造函数中调用父类构造函数(这正是super()的作用),super.为我们提供了访问其他(非构造函数)成员的能力。
使用 super 的示例
让我们通过代码来考察super()和super.。图 9.9 展示了 UML 继承图:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_09_9.jpg
图 9.9 – 展示 super()和 super.的 UML 图
在这个图中,我们有三个类代表一个类继承层次结构。Employee位于层次结构的顶部。Manager“是”Employee,Director“是”Manager。间接地,Director“是”Employee。每个类都有其各自的构造函数将初始化的private实例变量。例如,Employee构造函数接受两个参数,int后跟String;这些参数将被用来初始化Employee实例变量,即empId(int)和name(String)。
EmployeeTest只是一个驱动程序,以确保代码按预期工作。让我们检查一下代码。图 9*.10是图 9**.9*中 UML 的代码:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_09_10.jpg
图 9.10 – 展示 super 的代码
在这个图中,Employee类初始化其实例变量(第 8-9 行)。Employee的toString()方法(第 11 行)返回一个String,概述了empId和name实例变量的值。第 11 行还使用了@Override注解,因为它正在重写从Object继承来的toString()方法。
Manager类“是”Employee(第 13 行)。Manager包含(由String实例变量组成)名为deptName的变量。这被称为组合。
组合与继承
组合定义了一个*“具有”关系,而继承定义了一个“是”关系。组合是指一个对象由其他对象“组成”。例如,Car具有Engine。在图 9**.10*中,Manager“是”Employee(第 13 行),但Manager“具有”一个部门,这由String实例变量deptName(第 14 行)表示。
Manager构造函数(第 16-19 行)是事情变得有趣的地方。第 17 行,super(empId, name),是调用Employee中的父构造函数,传递了Employee构造函数所需的员工 ID(empId)和员工姓名(name)。这就是为什么Manager构造函数最初需要这些参数的原因——它需要员工 ID 和员工姓名,以便调用其父Employee构造函数。Manager构造函数还需要部门名称,以便初始化其自己的实例变量deptName。因此,当执行Manager构造函数时,首先执行Employee构造函数,然后执行Manager构造函数。
注意,如果第 17 行被注释掉,代码将无法编译。为什么?因为编译器现在将插入super();它试图调用不带参数的Employee构造函数(即no-args构造函数,即Employee())。在Employee中没有这样的构造函数。此外,由于Employee已经定义了一个构造函数,编译器将不会插入默认的(不带参数的)构造函数。
Manager类的toString()方法(第 21-24 行)覆盖了从Employee继承来的版本。然而,Manager仍然可以访问Employee的版本,它是通过在第 23 行使用super.toString()来实现的。因此,Manager中的toString()方法首先执行Employee中的toString()方法,该方法返回员工 ID 和员工姓名。然后,Manager类的toString()方法将它的自己的实例变量deptName追加到要返回的整体String中。
Director 类的行为与 Manager 类相似。构造函数“向上传递”(第 30 行)了 Manager 构造函数所需的数据;反过来,Manager 构造函数向上传递了 Employee 构造函数所需的数据。因此,在创建 Director 对象时,构造函数调用的顺序如下:首先 Employee,然后 Manager,最后 Director。在第 31 行,Director 初始化其自己的实例数据。
在第 33 行,Director 版本的 toString() 首先使用 super.toString() 调用 Manager 版本的 toString()。Manager 版本(第 23 行)然后调用 Employee 类的 toString() 方法,该方法在第 11 行。因此,员工的 ID 和姓名是字符串中的第一个员工详细信息。接下来,将经理数据(deptName)附加到字符串中(在调用 Employee 类的 toString() 方法返回之后)。最后,将 Director 数据(budget)附加到字符串中(在调用 Manager 类的 toString() 方法返回之后)。请注意,您不能绕过层次结构中的任何一级;这意味着不允许使用 super.super.。
EmployeeTest 是驱动类。在 main() 函数的第 39 行,我们创建了一个 Director 对象,该对象可以通过 emplDir 的 Employee 引用访问(隐式向上转型)。按照概述使用 super(),这将导致首先执行 Employee 构造函数,然后是 Manager 构造函数,最后执行 Director 构造函数。
第 40 行将 emplDir 引用传递给 System.out.println(),导致对 Director 类的 toString() 方法的多态调用。使用 super.toString(),Director 调用 Manager 类的 toString() 方法,该方法也有一个 super.toString() 方法,导致首先执行 Employee 的 toString() 方法。然后,Manager 类的 toString() 方法完成,最后,Director 类的 toString() 方法完成。输出显示如下:
ID: 754, Name: Joe Bloggs, Department: Marketing, Budget:10000.0
关于输出,ID: 754, Name: Joe Bloggs 是从 Employee toString() 输出的,Department: Marketing 是从 Department toString() 输出的,而 Budget: 10000.0 是从 Director toString() 输出的。
这就结束了我们对 super 的讨论。现在,正如在 第八章 中所承诺的,我们已经理解了继承,让我们回到 protected 访问修饰符。
回顾 protected 访问修饰符
回想一下,一个protected成员可以从其自身包内以及包外的任何子类中访问:protected = package + children。表面上,这似乎非常简单。然而,一些细微差别会导致混淆。访问protected成员的子类(通过继承),只能以非常具体的方式进行。来自包外部的子类不能使用超类引用来访问protected成员!此外,来自包外部的无关类也不能使用对包外子类的引用来访问protected成员。实际上,一旦包外部的子类继承了protected成员,该成员对该子类(以及子类的子类)来说就变成了private。这相当棘手,确实需要举例说明。
UML 图
图 9.11显示了此例的 UML 图:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_09_11.jpg
图 9.11 – “protected”代码的 UML
在这个图中,我们有两个包,即ch9.pkgA和ch9.pkgB。在ch9.pkgA中,我们有一个Book类及其子类NonFictionBook。Book中的read()方法用#符号标记,这意味着它是protected的。Magnifier类与Book无关,只是同一包中的另一个类。
在ch9.pkgB中,FictionBook从ch9.pkgA的Book类中继承,并提供了doThings()方法,我们将用它来演示允许和不允许的情况。此外,SpaceFictionBook从FictionBook继承,并覆盖了从FictionBook继承的doThings()方法。最后,Reader是一个完全独立的类,与Book层次结构无关;它的doThings()方法也是一个用于演示目的的示例方法。
回想一下上一章,我们没有完全完成访问修饰符表(因为我们当时还没有涉及继承)。表 9.1表示完成的访问修饰符表。请注意,该表表示在Book类中注释一个成员。
表 9.1 - 填充了“protected”行的访问修饰符表
检查protected行,我们现在可以看到,无论子类属于哪个包,都可以访问继承的受保护的成员。
现在,让我们依次检查每个包的代码。首先,我们将检查定义protected成员的包。
包含受保护成员的包
图 9.12显示了来自图 9.11的第一个包ch9.pkgA的代码:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_09_12.jpg
图 9.12 – 来自 UML 的“ch9.pkgA”代码
在这个图中,有一个名为Book的类(第 3-5 行),它定义了一个protected的read()方法(第 4 行)。NonFictionBook是Book的子类,并有自己的doThings()方法(第 7-9 行)。此外,还有一个与Book完全无关的类,即Magnifier。
首先要注意的是,由于read()方法是protected的,同一包中的其他代码可以访问它,即使代码不是子类。这由第 14 行演示,其中从完全无关的类Magnifier访问了Book中的read()方法。
当然,无论包是什么,子类都可以访问protected成员。这在第 8 行显示,其中NonFictionBook子类调用了read()。记住,第 8 行实际上是this.read()。所以,无论使用哪个NonFictionBook对象在第 7 行调用doThings(),都会用来在第 4 行调用继承的(并且是protected的)read()方法。
有趣的代码在另一个包中,即ch9.pkgB。现在让我们检查一下。
另一个包
图 9*.13* 展示了代码:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_09_13.jpg
图 9.13 – 来自 UML 的“ch9.pkgB”代码
在这个图中,我们可以看到FictionBook“是”Book(第 5 行),而SpaceFictionBook“是”FictionBook(第 19 行)。为了使这个层次结构成为可能,Book类需要从另一个包中导入(第 3 行)。我们之所以能够从另一个包中导入Book,是因为Book是一个public类。此外,还有一个完全无关的类叫做Reader(第 27-36 行)。
现在,让我们来点有趣的!让我们检查一下FictionBook中的dothings()方法(第 6-17 行)。第 7 行和第 8 行实际上是等价的,显示了当子类在包外部使用直接继承时,可以访问protected成员。
第 9-10 行也显示,当在包外部的子类内部时,如果你创建该特定子类的实例(在这个例子中是FictionBook),那么一切正常。这很有道理,因为用于调用read()而不出问题的两个引用,即this和fb,都是FictionBook类型,代码就在那里。
注意第 15 行,我们实例化了一个Book对象,它可以编译,因为Book类(图 9.12,第 3 行)是public的。Book类没有定义构造函数,因此为我们创建了一个默认构造函数。这个默认构造函数具有与类相同的访问权限,即public,因此我们可以从不同的包中调用构造函数。
第 16 行无法编译,非常有趣。当在包外部的子类内部时,你不能使用超类引用来访问protected成员——即使protected成员位于那个超类中!记住,一旦超出包,protected成员对子类(及其子类)来说就变成了private。换句话说,你必须非常具体地使用继承。
SpaceFictionBook (第 19-26 行) 显示,包外子类的子类可以访问权限。第 21 行与第 7 行相同,只是它们位于两个不同的类中。当这一行编译时,它证明了包外子类的子类可以访问基类中的protected成员。
第 22 行和第 23 行都无法编译。第 22 行试图通过Book引用访问protected成员,而第 23 行试图通过FictionBook引用访问它。两者都失败了。与第 24 行形成对比,它使用当前类的实例,即SpaceFictionBook,这是可行的。注意,第 24 行与第 21 行相似,因为在两种情况下都使用了SpaceFictionBook引用(因为第 21 行等价于this.read())。此外,第 24 行与第 9-10 行非常相似。因此,当在包外的子类中,直接访问protected成员,就像第 7 行、第 21 行那样;或者使用当前子类的引用,就像第 10 行、第 24 行那样。
Reader类(第 27-36 行)与Book层次结构完全独立。第 30 行试图使用定义protected成员的类的引用来访问protected成员,即Book,并失败了。第 34 行试图使用继承protected成员的包外子类的引用来访问它,即FictionBook,也失败了。
所以protected有些棘手。当我们回顾先前的主题时,这是一个回顾switch的理想机会。更具体地说,讨论switch的模式匹配。
switch的表达式模式匹配
正如从第四章中承诺的,现在我们理解了继承和多态,我们将重新审视switch。考虑到以下代码:
public static void patternMatchingSwitch(Vehicle v) { System.out.println(
switch(v){
case Boat b -> "It's a Boat";
case Train t -> "It's a Train";
case Car c when c.getNumDoors() == 4 ->
"Saloon "+ c.onRoad(); // custom Car method
case Car c when c.getNumDoors() == 2 ->
"Convertible: " + c.onRoad();
case null, default -> "Invalid type";
}
);
}
假设Car、Boat和Train都扩展自Vehicle,并且Car有一个自定义方法onRoad()。正如你所见,在这个switch表达式中,选择器表达式v可以是任何引用类型(Boat、Train、Car等等)。case标签展示了类型模式和模式匹配;例如,Boat b。
此外,Car的两个case标签都被称为保护模式。保护模式是位于when子句右侧的“保护器”上的case标签。保护器是一个条件表达式,其结果为真或假。注意使用自定义的Car方法onRoad(),以及不需要进行类型转换的事实,因为类型转换已经在后台为我们完成了(前提是我们处理的是Car)。
最后一个包含default的case标签确保了穷举性,从而让编译器满意。换句话说,所有可能的Vehicle都被考虑到了。注意,null也被用作一个有效的标签,以及null和default可以用逗号分隔。
现在,让我们考察两个特定关键字对继承的影响,即abstract和final。
解释抽象和final关键字
如我们所知,在编写方法时,我们可以应用访问修饰符关键字,即private、protected、public和包私有(没有关键字)。还有两个关键字在继承方面具有特殊意义:abstract和final。两者互为对立,这就是为什么它们不能同时应用于一个方法。现在让我们来讨论它们,从abstract开始。
抽象关键字
抽象关键字应用于类和方法。虽然抽象类将在第十章中更详细地讨论,但我们也会在这里讨论它们(原因很快就会变得明显)。一个抽象方法没有实现(代码)。换句话说,方法签名,而不是跟随大括号{},它代表实现,一个抽象方法签名只是简单地跟随一个分号。将方法标记为抽象意味着以下内容:
-
类必须也是
抽象的 -
第一个具体(非
抽象)子类必须为抽象方法提供实现
让我们更详细地讨论这个问题。当你将一个方法(或多个方法)标记为抽象时,你是在说这个方法没有实现代码。由于有“缺失”的部分,类本身也必须被标记为抽象。这告诉编译器该类是不完整的,因此,你不能基于一个抽象类实例化(创建)对象。换句话说,你不能在抽象类上执行new操作(尽管引用是完全可行的)。抽象方法(以及因此抽象类)的全部理由是让子类覆盖它们,在子类中提供“缺失”的实现代码。现在,如果直接子类没有为继承的抽象方法提供实现代码,那么该子类也必须是抽象的。因此,一个抽象类的第一个非抽象(具体)子类必须为抽象方法提供实现代码。图 9*.14*展示了这些原则:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_09_14.jpg
图 9.14 – “抽象”关键字的作用
在这个图中,我们在第 4 行有一个抽象方法,即write()。注意方法中没有花括号;我们只是在括号后立即有一个分号。由于Pencil类(第 3-5 行)包含一个抽象方法,因此该类本身也必须是抽象的;它确实是(第 3 行)。
在第 6 行,CharcoalPencil试图继承Pencil。但是因为(a)它没有为从Pencil继承的抽象方法write()提供实现,并且(b)CharcoalPencil本身不是抽象的,所以CharcoalPencil无法编译。
将第 6 行与第 7 行进行对比。正如我们所见,第 6 行无法编译。然而,第 7 行WaterColorPencil可以编译。为什么?因为WaterColorPencil是abstract的;它没有为abstract方法write()提供实现,这并不成问题。
抽象类不必有抽象方法
正如我们所知,如果你有 1(或更多)abstract方法,那么这个类必须是abstract的。然而,情况并非总是如此。换句话说,一个abstract类根本不必有任何abstract方法!注意,WaterColorPencil(图 9.14 中的第 7 行)是这样的一个类的例子。它是abstract的,但没有任何方法。这是可以的。这可能是设计决策,即使类只包含具体方法,你仍然希望这个类被用作引用类型而不是对象类型(因为你不能new它)。
GraphitePencil类(第 8-13 行)是一个具体、非抽象类。因为它extends了abstract类Pencil,它必须为abstract方法write()提供实现。这是在第 10 到 12 行完成的,我们使用@Override注解来强调这一点。
第 17 行演示了你不能实例化一个abstract类的对象。Pencil pp语句的引用部分是好的。问题是new Pencil()部分。
第 18 行显示了允许的情况。同样,我们再次使用Pencil引用,但这次我们引用的是一个GraphitePencil对象。GraphitePencil是一个具体类(第 8 行)。第 19 行多态地调用了GraphitePencil(第 10-12 行)提供的write()方法。假设第 6 行和第 17 行被注释掉(这样代码就可以编译),第 19 行输出GraphitePencil::write()。
现在我们已经理解了abstract方法和类,让我们来检查final关键字。
final关键字
final关键字可以应用于各种上下文。继承是这里的主要焦点,但我们也将检查其他情况。我们将逐一检查它们,然后查看演示它们的代码。我们将从final方法开始。
final方法
一个final方法不能在子类中被重写。这防止了子类的不当更改。我们可以通过final类进一步探讨这一点。
final类
被标记为final的类不能用作基类。这意味着你不能从一个final类扩展。类中的所有方法都是隐式final的。Java 在其 API 中使用这一点来保证行为。例如,String类是final的,这样就没有人可以扩展它并提供自定义实现。因此,Java 总是知道字符串的行为。现在,我们将检查final方法参数。
final方法参数
final方法参数是一个不能更改的参数。然而,请注意,根据参数类型,其语义可能会有细微差别。如果参数类型是原始类型,例如int,那么你不能更改int参数的值。
然而,如果相关的参数是一个引用(而不是原始类型),final应用于引用,因此,不能更改的是引用本身。换句话说,引用指向的对象是可修改的,但引用本身不是。这意味着,例如,如果方法接受一个Dog引用,即dog,那么使用dog引用,你可以改变对象的属性,例如dog.setAge(10)。然而,你不能将dog改为指向不同的对象,例如dog = new Dog()。
final(常量)
常量是一个不能改变的值。使用大写字母作为常量的标识符是一种习惯,并且是良好的实践,每个单词之间用下划线分隔。这使得它们更加突出,开发者知道它们不能更改。Java API 中的一个例子是Math类(在自动导入的java.lang包中)的*PI*常量。它是final的,因此不能更改。为了提供方便的访问,PI也是public和static的。
现在,让我们通过一个代码示例来加强final的使用。图 9*.15*展示了以下代码:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_09_15.jpg
图 9.15 – “final”关键字的作用
在这个图中,我们有一个名为Earth的final类(第 3 行)。第 5 行通过编译器错误演示了你不能从一个final类扩展。
第 8 行在Pen类中定义了一个名为write()的final方法。因此,当尝试覆盖write()时,FountainPen类会遇到编译器错误(第 15 行)。
第 11 行显示你不能将一个方法同时标注为abstract和final – abstract意味着这个方法将在子类中被覆盖;final意味着这个方法不能被覆盖。
第 18 行声明了一个名为ONE_YEAR的常量,并将其设置为1。第 27 行尝试更改常量值 – 由于不允许这样做,编译器会报错。
print()方法(第 19-28 行)概述了final对方法参数的含义。方法参数(第 19 行)分别是final String name和final int age。String是一个非原始类型,因此name是一个引用。换句话说,name内部的价值是对象在堆上的内存位置(引用)。另一方面,age只是一个原始的int类型,其值是一个简单的整数,例如1。当你将value视为final时,很容易理解你可以和不能做什么。因此,如果1在age中,它不能被更改,name中的引用(地址)也不能更改。然而,name引用的对象可以被修改。
第 21 行是一个编译器错误,展示了final原始数据类型不能被更改。
第 23 行显示我们能够访问(如果需要的话可以修改)引用所指向的对象。请注意,在这个特定的例子中,因为String是不可变对象,所以toUpperCase()方法返回新的大写String,而不是改变原始的。我们将在第十二章中更多地讨论String。重要的是要注意,编译器对第 23 行没有问题。
第 25 行尝试将String引用name改为指向不同的String。由于引用是final的,编译器会报错。再一次,引用和对象的分离使得事情更容易理解。
到目前为止,我们知道如何创建(无限的)继承层次结构(使用extends)。我们还知道final禁用了继承。如果我们想要一个“中间地带”,在这个地带中我们可以根据某些类型定制我们的层次结构,那会是什么样子?这就是密封类所允许的。现在让我们来讨论它们。
应用密封类
密封类是在 Java 17 中引入的。我们在这里要讨论的内容与类相关,但同样的逻辑也适用于接口(第十章)。使用继承,你可以使用extends关键字从任何类(或接口)扩展,当然,前提是这个类不是final。
注意
接口不能是final的,因为它们的整个理由就是要被实现。
考虑以下场景:如果你希望你的类可以被继承,但仅限于某些类?换句话说,你想要限定允许的子类范围。到目前为止,使用extends关键字进行继承使得每个类都可以成为子类,而final关键字则阻止一个类有子类。
这就是密封类有用的地方——它们允许你指定允许哪些子类。只是为了重申,这也适用于接口,我们可以指定允许实现接口的类。
在我们看例子之前,有一些新的关键字我们需要理解。
密封和允许
这些关键字共同工作。为了声明一个类是密封的,你可以简单地指定它就是密封的,即sealed。然而,一旦你这样做了,你必须指定哪些类可以从这个类扩展。为此,你使用permits关键字,后面跟着用逗号分隔的类列表。
非密封
当你开始限定/限制一个层次结构时,在指定子类时必须使用某些关键字。参与密封层次结构的子类必须声明以下之一:
-
它也是密封的。这意味着我们还有进一步的限定要执行,因此我们必须在这个子类上使用
sealed/permits配对来指定允许的子类。 -
这是层次结构中的
final类(不允许有更多的子类)。 -
这结束了限定。实际上,你希望再次打开层次结构以进行扩展。为此,我们使用
non-sealed关键字,因为non-sealed类可以被继承。
现在,让我们来看一个例子。
使用密封、允许和非密封的示例
图 9.16展示了我们将使用的代码示例的 UML 图:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_09_16.jpg
图 9.16 – “密封”类的 UML 图
在这个图中,我们有一个Vehicle层次结构。我们将要限制(密封)的部分是Vehicle、Car和Saloon类。因此,唯一可以成为Vehicle子类的类是Car;唯一可以成为Car子类的类是Saloon。请注意,尽管图表暗示Truck是Vehicle的子类,而Convertible是Car的子类,但在这个示例中,我们将通过代码防止这种情况发生。
代码的目标是确保我们感兴趣的Vehicle只有Car,我们感兴趣的Car只有Saloon。此外,所有Saloon(Ford和Volvo)都是感兴趣的。图 9.17展示了代码。
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_09_17.jpg
图 9.17 – “密封”代码
在前面的图中,第 3 行声明我们有一个名为Vehicle的密封类,并且允许的唯一子类(允许的)是Car。在此阶段,Car类必须存在;否则,编译器会报错。
第 4 行定义了一个名为Car的密封类,它是Vehicle的子类(由于第 3 行的原因,它必须这样做),并且允许的唯一子类是Saloon。请注意,当我们定义Car时,我们必须指定Car是sealed、non-sealed还是final。
第 5 行是尝试将Truck类作为Vehicle子类的Truck类。然而,由于我们已经将Vehicle密封,只允许Car作为子类,这会生成编译器错误。
第 6 行将Saloon定义为Car的子类(正如第 4 行所预期的)。在这种情况下,我们选择通过声明Saloon是non-sealed来打开层次结构以供进一步扩展(由任何类)。第 7 行和第 8 行通过允许Volvo和Ford分别从它扩展来展示Saloon是一个非密封类。
最后,在第 9 行,Convertible尝试将Car类作为子类。这不被允许,因为第 4 行声明,允许的Car类的唯一子类是Saloon。
现在让我们继续讨论实例和static块。
理解实例和静态块
如我们所知,在 Java 中,代码块由大括号{}界定,这些代码块没有不同。实例和static代码块的不同之处在于它们出现的位置,换句话说,它们的范围。这两个代码块都出现在每个方法之外,但出现在类内部。
我们将逐一讨论它们,然后通过代码示例来展示它们的工作原理。我们将从实例块开始。
实例块
实例块是一组大括号,它出现在任何方法之外但出现在类内部。假设一个类中存在实例块,每次创建对象(使用new)时,实例块都会被执行。请注意,实例块在构造函数之前执行。为了技术上的准确性,super()首先执行,这样父构造函数就有机会执行;然后是实例块,之后是构造函数的其余部分执行。使用“sic”(super, instance block, constructor)这个缩写可以帮助记住顺序。你可以认为编译器在调用super()之后将实例块插入到构造函数代码中。如果一个类中存在多个实例块,它们将按照出现的顺序执行,从上到下。
作为一个例子,实例块作为每个构造函数的一部分执行,因此是插入你希望每个构造函数都拥有的代码的理想位置。换句话说,所有构造函数中通用的代码应该放入实例块中。这样可以避免在构造函数之间重复代码。
正如我们所知,父构造函数必须在子构造函数之前执行。实例块也是如此。换句话说,父实例块必须在子实例块之前执行。我们将在代码示例中看到这一点。
静态块
static块是一组大括号,它由static关键字开头,出现在任何方法之外但出现在类内部。static块只执行一次,即在类第一次被加载时执行。这可能会发生在创建类的第一个对象或第一次访问static成员时。static块在实例块之前执行(因为我们必须在执行构造函数之前加载类文件/字节码)。一旦执行,由于类文件现在已加载到内存中,static块将不再执行。
与实例块类似,如果一个类中存在多个static块,它们将按照出现的顺序执行,从上到下。同样,如果涉及到继承,那么父static块将在子static块之前执行。
这一切都会在代码示例中变得更加清晰,我们将能够比较和对比继承层次结构中这两种类型的代码块。
图 9.18 展示了以下代码:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_09_18.jpg
图 9.18 – 实例和“static”代码块示例
在这个图中,我们有一个名为Parent的父类和一个名为Child的子类(想出这些名字花了一些时间!)。这两个类都有两个实例初始化块、两个static初始化块和一个构造函数。请注意,static初始化块(第 7、12、16 和 21 行)都是简单地用static关键字前缀的代码块。同时,注意它们的位置/作用域——在方法之外但在类内部。实例初始化块(第 5、11、15 和 20 行)也是如此,只是实例块前面没有关键字。
主要驱动类InitializationBlocks也有一个static初始化块和一个实例初始化块(分别在第 24 和 25 行)。
这些块简单地输出跟踪消息,以便我们知道当前正在执行哪个代码块。跟踪消息用递增的数字标注,以便我们更容易地跟踪执行顺序。图 9*.19展示了图 9**.18*中的代码输出:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_09_19.jpg
图 9.19 – 图 9.18 中的代码输出
注意
为了避免在图 9**.19中代表输出的数字与图 9**.18中的行号混淆,这里提到的所有数字都指的是图 9**.19中的输出数字。任何与图 9**.18相关的行号将明确标注为“行…”
所有 Java 程序都以main()方法开始。因此,JVM 必须使用CLASSPATH环境变量查找包含main()的.class文件,即InitializationBlocks.class。当 JVM 加载类时,如果类有父类,它会先加载父类。在这个例子中,因为InitializationBlocks不是一个子类,所以这不适用。然而,有一个static块,这给了我们第一条输出。注意,InitializationBlocks的实例块从未被执行。这是因为从未创建过InitializationBlocks的实例。换句话说,代码中没有new InitializationBlocks()。
第 27 行简单地输出了"---> Creating first Child object…"。值得注意的是,这不是屏幕上输出的第一行——static块的输出先于它。
第 28 行创建了一个Child对象。它的输出用数字 2-11 表示。由于这是第一次创建Child对象(因为在此之前没有访问Child中的任何static成员),所以会加载Child的类文件。在这个过程中,JVM 意识到Child是Parent的子类,因此它会先加载Parent类。因此,Parent中的static块会首先执行,按照出现的顺序(2 和 3);然后是Child的static块,也按照出现的顺序(4 和 5)。
现在 static 块已经完成,实例块和构造函数将被执行。首先,按照出现的顺序执行超类 Parent 的实例块(6 和 7),然后是 Parent 构造函数(8)。然后,按照出现的顺序执行子类 Child 的实例块(9 和 10),然后是 Child 构造函数(11)。从简单的 new Child() 代码行来看,这需要大量的处理。
第 29 行简单地输出了 "---> 创建第二个 Child 对象…"`。
第 30 行创建了另一个 Child 对象。由于类之前已经被加载,Child 及其超类 Parent 的 static 块已经运行。因此,它们不会再次运行。所以我们运行 Parent 实例块(6 和 7),然后是 Parent 构造函数(8)。然后,我们运行 Child 实例块(9 和 10),然后是 Child 构造函数(11)。
注意在创建 Child 对象时行号 6-11 的重复。Parent 实例块按顺序执行;然后是 Parent 构造函数。Child 实例块和构造函数以类似的方式执行。
这涵盖了 static 和实例初始化块。在我们结束关于继承的这一章之前,我们只想深入探讨一下我们之前提到的一个主题:向上转型和向下转型。
掌握向上转型和向下转型
之前,我们提到了为什么会出现 ClassCastException 错误。规则是,引用可以指向其自身类型的对象或子类的对象。实际上,引用可以指向继承层次结构中的任何位置,但不能向上。如果引用确实指向了继承层次结构的上方,你将得到一个 ClassCastException 错误。回想一下,这种情况发生的原因是子类引用可能具有任何超类对象都没有代码的额外方法。无论这种情况是否成立,可能存在 就足够了。
请记住,赋值是从右向左进行的;因此,在阅读涉及向上转型/向下转型的代码时,继承层次的方向也是从右向左。此外,请记住编译器始终在查看引用类型。
现在,让我们借助代码示例来讨论向上转型和向下转型。让我们从向上转型开始。
向上转型
使用向上转型,你将从更具体的类型“向上”到更通用的类型。例如,让我们看看以下代码行:
Vehicle vc = new Car()
在这里,我们是从 Car 向上 到 Vehicle。更具体类型(Car)在层次结构中更低,并且可能具有额外的功能。由于继承,父引用可以访问的方法,子类也会拥有。因此,任何对 Vehicle 引用 vc 可用的方法都将存在于 Car 对象中!因此,向上转型永远不会成为问题,不需要显式转换。
图 9*.20* 展示了代码中的向上转型:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_09_20.jpg
图 9.20 – 向上转型动作
在此图中,我们有一个名为Machine的类(第 3-5 行)和一个名为Tractor的子类(第 6-9 行)。Tractor中的on()方法(第 7 行)覆盖了Machine中的on()方法(第 4 行)。
第 15 行涉及隐式向上转型。从右到左阅读(因为赋值是从右到左的),我们是从Tractor“向上”到Machine。这是可能的,因为每个Tractor都是Machine的子类。因此,第 15 行产生一个指向Tractor对象的Machine引用。
第 16 行调用了doAction()方法,同时传入第 15 行创建的引用,即mt。这个mt引用被复制(记住 Java 是按值传递的)到第 11 行的Machine引用,即machine。因此,main()方法中的mt引用和doAction()方法中的machine引用都指向同一个对象,该对象是在第 15 行创建的。
在doAction()方法内部,我们使用machine引用调用on()方法(第 12 行)。由于machine引用的类型,即Machine,有一个on()方法,编译器很高兴。在运行时,machine引用到的对象,即Tractor,被使用。换句话说,Tractor中的on()方法被动态执行(多态)。
第 17 行在一行中完成了在第 15 行和第 16 行中编码的内容。在第 17 行调用doAction()时,向上转型如下:
Machine machine = new Tractor()
Machine引用,即machine,由doAction()签名(第 11 行)提供,而Tractor实例的创建来自第 17 行。
第 16 行和第 17 行产生了相同的结果:Tractor::on()。现在,让我们讨论两个中较难的一个:向下转型。
向下转型
使用向下转型,你是在从更一般的类型“向下”到更具体的类型。例如,让我们看看以下代码行:
Car cv = (Car) new Vehicle(),
从右到左阅读,我们是从Vehicle 向下 到Car。同样,更具体的类型(Car)在层次结构中更低,可能还有额外的功能。编译器发现了这一点,并报错。我们可以通过插入一个(向下)转型,(Car)来覆盖编译器。这正是我们在这里所做的事情。然而,在运行时,这一行代码会导致ClassCastException错误。这是因为,在赋值语句的右侧,我们试图创建一个指向继承树中Vehicle对象的Car引用!
图 9*.21* 展示了代码中的向下转型:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/lrn-java-proj/img/B19793_09_21.jpg
图 9.21 – 向下转型动作
此图中的代码与图 9**.20中的代码非常相似。继承层次结构是相同的。变化在于doAction()和main()方法。第 12 行正常工作,我们已将其注释掉以关注向下转型。
我们的目标,如第 14 行所述,是安全地调用Tractor对象的drive()方法。请注意,此方法仅针对Tractor。让我们一步一步地看看变化。
首先,由于drive()方法特指Tractor(而不是Machine),这意味着我们需要一个Tractor引用来使代码编译通过。第 15 行无法编译的事实证明了这一点——machine引用是Machine类型的,而Machine没有drive()方法。
第 16 行解决了第 15 行的编译错误。第 16 行可以编译,因为它在调用drive()方法之前将machine引用(向下)转换为Tractor引用。这就是为什么需要额外的括号——方法调用比类型转换有更高的优先级,所以我们通过使用括号来改变优先级顺序。如果没有额外的括号,我们将有(Tractor)machine.drive(),这不会编译(与第 15 行不编译的原因相同)。然而,额外的括号强制首先执行从Machine到Tractor的类型转换,因此编译器会在Tractor中查找drive()方法。
然而,我们仍然没有“走出困境”。是的,编译器很高兴,但 JVM 在运行时容易受到ClassCastException错误的攻击。如果取消注释第 16 行,那么第 22 行将在运行时引发ClassCastException错误。这是因为第 22 行传递了一个Machine对象,因此在doAction()方法内部,machine引用指向一个Machine对象。因此,在第 16 行,我们试图创建一个指向Machine对象的Tractor引用,这整天都是ClassCastException。
第 17 行使用了instanceof关键字,结合类型模式和模式匹配。只有当引用machine指向一个Tractor对象时,第 17 行才是真的;当它是时,后台会为我们完成类型转换,并将t初始化为指向Tractor对象。这就是为什么第 22 行没有输出任何内容——传递进来的Machine对象未能通过instanceof测试,因此第 18 行没有执行。然而,由于第 23 行传递了一个Tractor对象,它通过了instanceof测试。这意味着第 18 行被执行并输出了Tractor::drive()。
这完成了另一个非常重要的章节。现在,让我们应用我们所学到的知识!
练习
我们的公园充满了多样性,不仅包括恐龙的种类,还包括我们员工的角色。为了模拟这种多样性,我们将在我们的应用程序中引入继承的概念:
-
并非所有恐龙都相同。有的小,有的大。有的食草,有的食肉。为不同类型的恐龙创建至少三个子类,这些子类继承自基类
Dinosaur。如果你需要灵感,你可以从
Dinosaur类创建一个FlyingDinosaur子类和一个AquaticDinosaur子类,每个子类都有其独特的属性。(这不是建模的最佳方式,但现在不用担心这个。) -
就像我们的恐龙一样,我们的员工也有不同的角色。有些是公园管理员,而有些是保安或兽医。为这些员工角色创建继承自
Employee基类的子类。至少想出三个子类。 -
继承不仅限于属性和方法。甚至某些方法的行为也可以在子类中进行自定义。在
Dinosaur和Employee类(来自练习 1 和 2)及其子类中提供自定义的toString()方法实现,以显示每个对象的详细信息。 -
此外,覆盖
Dinosaur和Employee类中的equals()方法,以比较这些类的对象。 -
创建一个名为
App的类,其中包含一个main方法。在其中,添加功能以检查员工是否有资格在特定围栏工作,考虑到员工的角色和围栏的安全级别。 -
公园提供普通门票和季票。创建一个扩展
Ticket类的SeasonTicket类,并添加起始日期和结束日期等属性。
项目
你将开发一个更高级的中生代伊甸园公园管理控制台应用程序的版本。你的任务是实现多态概念来处理不同类型的恐龙和员工。通过引入多态,应用程序可以容纳更广泛的恐龙物种和员工角色。系统的主要功能现在应包括以下内容:
-
管理代表各种恐龙档案的能力,代表各种物种。
-
管理代表各种角色的不同类型的公园员工档案,例如公园管理员、清洁工、兽医等。
-
所有以前的功能,如编辑和删除档案、实时恐龙追踪、员工排班、客人登记和处理特殊事件,现在应适应这些新种类。
这里是你需要做的,如果需要的话,可以分解成更小的步骤:
-
将
Dinosaur和Employee类划分为不同的子类,以表示不同类型的恐龙和员工角色。确保这些子类展示了多态原则。 -
Dinosaur和Employee对象,其中每个对象可以是任何子类的实例。 -
更新交互:修改你的基于交互的控制台界面以处理新的恐龙和员工类型。你可能需要添加更多选项或子菜单。
-
增强菜单创建:你的菜单现在应处理不同类型的恐龙和员工。确保每个选项对应程序中的特定功能。
-
处理操作:每个菜单项应触发一个现在能够处理不同类型恐龙和员工的函数。例如,“管理恐龙”选项现在可以触发一个函数来添加、删除或编辑任何恐龙物种的档案。
-
退出程序:确保程序继续为用户提供退出程序的选择。
起始代码片段将基本上与上一个相同。然而,在实现manageDinosaurs()、manageEmployees()和其他类似函数时,你需要处理不同类型的恐龙和员工:
public void handleMenuChoice(int choice) { switch (choice) {
case 1:
manageDinosaurs(); // This function now needs
to handle different types of dinosaurs
break;
case 2:
manageEmployees(); // This function now needs
to handle different types of employees
break;
case 3:
// manageTickets();
break;
case 4:
// checkParkStatus();
break;
case 5:
// handleSpecialEvents();
break;
case 6:
System.out.println("Exiting...");
System.exit(0);
}
}
现在需要更新manageDinosaurs()、manageEmployees()、manageTickets()、checkParkStatus()和handleSpecialEvents()方法,以便能够处理增加的复杂性。
摘要
在本章中,我们探讨了面向对象编程的一个基石,即继承。继承定义了子类和父类之间的“是”关系——例如,Fox“是”Animal,Train“是”Vehicle。继承通过使可继承的基类成员自动对子类可用来促进代码重用。类继承是通过extends关键字启用的,接口继承是通过implements关键字启用的。
关于方法,子类可以自由地覆盖(替换)基类的实现。这就是我们如何启用面向对象编程的另一个基石,即多态性。
多态性是一种特性,其中对象实例的方法仅在运行时选择。因此,多态性的其他术语包括“后期绑定”、“运行时绑定”和“动态绑定”。为了使多态性工作,子类型中实例方法的签名必须与父方法匹配。该规则的唯一例外是协变返回,在覆盖方法中,允许使用父返回类型的子类型。与父版本相比,覆盖方法不得降低访问权限或添加额外的已检查异常。
另一方面,方法重载是指方法签名必须不同(除了匹配的方法名)。因此,参数的数量、它们的类型和/或它们的顺序必须不同。返回类型和参数名称不重要(因为它们不是方法签名的一部分)。方法重载可以在层次结构的任何级别发生。
使用继承时,引用类型和对象类型通常不同。由于赋值是从右到左工作的,当我们讨论向上转型和向下转型时,我们指的是沿着继承树“向上”或“向下”。向上转型总是安全的,因为子类型将始终通过超类型引用访问方法。然而,向下转型是不安全的,需要转型以使编译器满意。即使如此,如果你最终创建了一个指向继承树顶部的引用,你将在运行时得到ClassCastException错误。指向继承树顶部是不允许的,因为子类的引用类型可能有父类型对象没有代码的方法。
super关键字在两种情况下使用。第一种是使用super()访问父构造函数。这个调用仅允许在任何构造函数中的第一行进行。如果没有明确编码,编译器将插入super()以确保父构造函数在子类型构造函数之前执行。构造是从基类向下发生的,因为子类型可能依赖于父成员,因此父类必须有机会首先初始化它们。第二种情况是从子类型代码中访问父成员,使用super.parentMember。
我们从第八章中已经知道,protected访问修饰符确保成员在包内可用,并且对任何子类都可用,无论包如何。我们回顾了这一点,并证明了,当从一个不同包的子类访问protected成员时,你必须通过继承以非常具体的方式进行。
abstract方法是一种没有代码(实现)的方法。即使一个类本身不需要有任何abstract方法也可以是abstract的;一旦类中有一个abstract方法,该类就必须是abstract的。任何abstract类的子类都必须提供继承的abstract方法(们)的实现代码,或者该子类也必须是abstract的。
关于继承,final类不能被继承。final方法不能被重写。final的其他用途是定义常量并确保(方法参数的)值是常量。
封闭类的使用使我们能够将层次结构的一部分限制为某些类型。而不是通用的extends,它允许一个类从它想要的任何基类中子类化;并且没有完全关闭继承使用final;封闭类通过使用sealed、non-sealed和permits关键字实现自定义限制。
实例和static初始化块在方法外部但类内部编写。static块在带有static关键字的块之前。实例不使用任何关键字(实例语义是隐含的)。两者都允许在各个点进行初始化。静态初始化仅发生一次——当类首次被加载时。实例初始化每次调用构造函数时都会发生。因此,实例块是插入对所有构造函数都通用的代码的完美位置。
最后,我们深入探讨了向上转型和向下转型。这有助于加深我们对为什么向上转型不是问题、为什么向下转型需要转型以及为什么我们会得到ClassCastException错误的理解。此外,使用instanceof运算符确保我们防止ClassCastException错误发生。
这就完成了我们对继承的讨论——这是一个很大的章节!我们现在将转向接口和abstract类。
3万+



被折叠的 条评论
为什么被折叠?



