Java 技术手册第八版(二)
原文:
zh.annas-archive.org/md5/450d5a6a158c65e96e7be41e1a8ae3c7译者:飞龙
第三章:Java 面向对象编程
现在我们已经讲解了基本的 Java 语法,可以开始在 Java 中进行面向对象编程了。所有 Java 程序都使用对象,对象的类型由其 类 或 接口 定义。每个 Java 程序都定义为一个类,复杂的程序包括多个类和接口定义。
本章将解释如何定义新的类(和记录),以及如何使用它们进行面向对象编程。我们还介绍了接口的概念,但对接口和 Java 类型系统的全面讨论将推迟到第四章。
注意
然而,如果您有面向对象编程经验,请小心。术语“面向对象”在不同语言中有不同的含义。不要假设 Java 与您喜欢的其他面向对象语言工作方式相同。(这对 JavaScript 或 Python 程序员特别适用。)
由于这是一个相当长的章节,让我们从概述和一些定义开始。
类和记录概述
类是所有 Java 程序中最基本的结构元素。在定义类之前无法编写 Java 代码。所有 Java 语句都出现在类中,并且所有方法都在类中实现。
基本面向对象定义
下面是一些重要的定义:
类
类 是包含保存值的数据字段以及操作这些值的方法的集合。类定义了一个新的引用类型,例如第二章中定义的Account类型。
Account类定义了银行系统中客户账户的类型。
从 Java 17 开始,语言还包括对记录的支持——这是一种具有附加语义的特殊类。
对象
对象 是类的 实例。
Account对象是该类型的一个值:它表示一个特定的客户银行账户。
对象通常通过使用new关键字和构造函数调用来 实例化,如下所示:
Account a = new Account("John Smith", 100, 1144789);
构造函数将在本章稍后详细讲解,参见“创建和初始化对象”。
类定义由 签名 和 主体 组成。类签名定义了类的名称,可能还指定其他重要信息。类的主体是一组用大括号括起来的 成员。类的成员通常包括字段和方法,还可能包括构造函数、初始化程序和嵌套类型。
成员可以是 静态 的或非静态的。静态成员属于类本身,而非静态成员与类的实例关联(参见“字段和方法”)。
注意
有四种非常常见的成员类型——类字段、类方法、实例字段和实例方法。大多数 Java 工作涉及与这些类型的成员交互。
类的签名可以声明类扩展另一个类。扩展的类称为超类,扩展称为子类。子类继承其超类的成员,可以声明新的成员或使用新实现覆盖继承的方法。
类的成员可以具有访问修饰符public、protected或private。¹ 这些修饰符指定了它们对客户端和子类的可见性和访问权限。这使得类能够控制对不属于其公共 API 的成员的访问。隐藏成员的能力支持一种称为数据封装的面向对象设计技术,我们在“数据隐藏和封装”中讨论。
记录
记录(或记录类)是一种提供了比一般类更多语义保证的特殊形式。
具体而言,记录(record)保证实例字段精确地定义了该类型对象的唯一有意义的状态。这可以表达为该记录类是一个数据载体或“仅持有字段”的原则(或模式)。同意这一原则对程序员施加了约束,但也使他们无需明确某些设计细节。
记录类定义如下:
/** Represents a point in 2-dimensional space */
public record Point(double x, double y) {}
不需要显式声明构造函数或字段的访问方法,因为对于记录类,编译器会自动生成这些成员并将它们添加到类定义中。访问方法的名称与它们提供访问权限的底层字段完全相同。虽然可以向记录添加额外的方法,但如果所需的仅仅是基本的数据载体形式,则不必这样做。
记录类的实例(或称为记录)的创建和实例化方式与常规类相同,我们可以在创建的对象上调用访问器:
// Create a Point object representing (2,-3.5).
// Declare a variable p and store a reference to the new Point object
Point p = new Point(2.0, -3.5);
double x = p.x(); // Read a field of the object
记录(record)的另一个方面是它们始终是不可变的。一旦创建,记录字段的值就不能被修改。这意味着不需要为字段编写 setter 方法,因为它们不能被修改。
Java 记录的契约是,参数名(如记录声明中指定的)与字段名和方法名都完全相同:如果有一个类型为double的记录参数x,那么类就有一个名称为x、类型为double的字段和一个名称为x()的实例方法,返回double。
记录还具有某些其他方法,这些方法也是由编译器自动生成的。在我们讨论如何将记录作为面向对象设计的一部分时,我们将详细介绍它们,见第五章。
其他引用类型
类的签名还可以声明该类实现一个或多个接口。接口是一种类似于类的引用类型,它定义了方法签名,但通常不包括实现方法的方法体。
但是,从 Java 8 开始,接口可以使用关键字default来指示接口中指定的方法是可选的。如果方法是可选的,则接口文件必须包含默认实现(因此选择了关键字),该实现将被所有未提供可选方法实现的实现类使用。
实现接口的类需要为接口的非默认方法提供方法体。实现接口的类的实例也是接口类型的实例。
类和接口是 Java 定义的五种基本引用类型中最重要的两种。数组、枚举类型(或“enums”)和注解类型(通常称为“注解”)是另外三种。数组在第二章中有所涉及。枚举是一种特殊的类,而注解是一种特殊的接口——它们将在第四章中讨论,同时还会全面讨论接口。
类定义语法
在其最简单的级别上,类定义由关键字class后跟类名和一组在大括号内的类成员组成。class关键字前可以有修饰符关键字和注解。如果类扩展另一个类,则类名后跟extends关键字和被扩展的类的名称。如果类实现一个或多个接口,则类名或extends子句后跟implements关键字和逗号分隔的接口名称列表。例如,对于java.lang中的Integer类
public class Integer extends Number
implements Serializable, Comparable {
// class members go here
}
Java 还包括声明泛型类的能力,允许从单个类声明中创建整个类型族。我们将在第四章中遇到此功能,以及支持其的机制(如类型参数和通配符)。
类声明可以包括修饰符关键字。除了访问控制修饰符(public、protected等)外,还包括:
abstract
一个abstract类是一个实现不完整且无法实例化的类。任何具有一个或多个abstract方法的类必须声明为abstract。抽象类在“抽象类和方法”中讨论。
final
final修饰符指定类不可扩展。类不能同时声明为abstract和final。
sealed
密封类是只能由已知子类集扩展的类。密封类提供了final类和默认开放扩展类之间的中间地带。密封类的使用在第五章中有更详细的讨论。密封类仅在 Java 17 及以上版本中可用。
strictfp
一个类可以声明为strictfp;其所有方法的行为都像它们被声明为strictfp一样,并且严格遵循浮点标准的形式语义。这个修饰符在 Java 17 中实际上是一个无操作符,因为其原因在第二章中讨论过。
字段和方法
一个类可以被视为数据(也称为状态)和操作该状态的代码的集合。数据存储在字段中,而代码组织成方法。
本节介绍字段和方法,这两种是类成员中最重要的。字段和方法有两种不同的类型:类成员(也称为静态成员)与类本身关联,而实例成员与类的各个实例(即对象)关联。这使得我们有四种成员:
-
类字段
-
类方法
-
实例字段
-
实例方法
类Circle的简单定义,如示例 3-1 所示,包含了所有四种类型的成员。
示例 3-1. 一个简单的类及其成员
public class Circle {
// A class field
public static final double PI= 3.14159; // A useful constant
// A class method: just compute a value based on the arguments
public static double radiansToDegrees(double radians) {
return radians * 180 / PI;
}
// An instance field
public double r; // The radius of the circle
// Two instance methods: operate on an object's instance fields
// Compute the area of the circle
public double area() {
return PI * r * r;
}
// Compute the circumference of the circle
public double circumference() {
return 2 * PI * r;
}
}
警告
在我们的例子中,有一个公共实例字段r并不是好的做法。最好有一个私有字段r和一个方法radius()(或r())来提供对它的访问。这个原因稍后将在“数据隐藏与封装”中解释。现在,我们使用公共字段只是为了给出如何使用实例字段的示例。
接下来的部分解释了所有四种常见成员。首先,我们介绍字段的声明语法。(方法的声明语法稍后在“数据隐藏与封装”章节中讨论。)
字段声明语法
字段声明语法与声明局部变量的语法非常相似(参见第二章)。不同之处在于字段定义可能还包括修饰符。最简单的字段声明由字段类型后跟字段名称组成。
类型可能由零个或多个修饰符关键字或注解前缀,并且名称后面可能跟着一个等号和初始化表达式,该表达式提供字段的初始值。如果两个或更多字段共享相同的类型和修饰符,类型后面可以跟着一个逗号分隔的字段名称和初始化器列表。以下是一些有效的字段声明:
int x = 1;
private String name;
public static final int DAYS_PER_WEEK = 7;
String[] daynames = new String[DAYS_PER_WEEK];
private int a = 17, b = 37, c = 53;
字段修饰关键字由以下零个或多个关键字组成:
public, protected, private
这些访问修饰符指定字段是否以及在类定义之外的地方能否使用。
static
如果存在这个修饰符,它指定字段与定义类本身相关联,而不是与类的每个实例相关联。
final
这个修饰符指定一旦字段被初始化,其值就永远不能改变。同时具有static和final修饰符的字段是编译时常量,javac可以内联它们。final字段也可以用于创建其实例是不可变的类。
transient
这个修饰符指定字段不是对象的持久状态的一部分,因此它不需要与对象的其余部分一起序列化。这个修饰符非常少见。
volatile
这个修饰符指示该字段具有用于两个或多个线程并发使用的额外语义。volatile修饰符表示必须始终从主内存读取字段的值并刷新到主内存,并且它可能不会被线程(在寄存器或 CPU 缓存中)缓存。详见第六章了解更多细节。
类字段
类字段与定义它的类相关联,而不是与类的实例相关联。以下行声明了一个类字段:
public static final double PI = 3.14159;
这行声明了一个名为PI的double类型字段,并赋予它值3.14159。
static修饰符表示该字段是一个类字段。由于这个static修饰符,类字段有时也称为静态字段。final修饰符表示字段的值不能直接重新分配。因为字段PI表示一个常量,我们将其声明为final,以确保它不可更改。
在 Java(以及许多其他语言)中,常量通常使用大写字母命名,这就是为什么我们的字段命名为PI而不是pi的惯例。像这样定义常量是类字段的常见用途,因此static和final修饰符经常一起使用。然而,并非所有的类字段都是常量。换句话说,字段可以被声明为static而不声明为final。
注意
使用非final的公共字段是一种代码异味——因为多个线程可能更新字段并导致极难调试的行为。初学者不应该使用非final的公共字段。
公共静态字段本质上是全局变量。类字段的名称由包含它们的类的唯一名称限定。因此,在不同代码模块定义具有相同名称的全局变量时,Java 不会遇到名称冲突的问题。
关于静态字段的关键是它只有一个副本。该字段与类本身相关联,而不是类的实例。如果你查看Circle类的各种方法,你会发现它们使用了这个字段。在Circle类内部,可以简单地将该字段称为PI。然而,在类外部,为了唯一指定该字段,需要同时使用类名和字段名。不属于Circle的方法将该字段访问为Circle.PI。
类方法
与类字段类似,类方法使用static修饰符声明。它们也被称为静态方法:
public static double radiansToDegrees(double rads) {
return rads * 180 / PI;
}
此行声明了一个名为radiansToDegrees()的类方法。它有一个double类型的单参数,并返回一个double值。
类方法与类相关联,而不是对象。在从类外部代码调用类方法时,必须同时指定类名和方法名。例如:
// How many degrees is 2.0 radians?
double d = Circle.radiansToDegrees(2.0);
如果要在定义它的类内部调用类方法,不必指定类名。还可以通过静态导入来缩短所需的输入量(如第二章中讨论的)。
注意,我们的Circle.radiansToDegrees()方法体使用了类字段PI。类方法可以使用其自身类的任何类字段和类方法(或任何其他可见的类)。
类方法不能使用任何实例字段或实例方法,因为类方法不与类的实例相关联。换句话说,尽管radiansToDegrees()方法在Circle类中定义,但它不能使用任何Circle对象的实例部分。
注意
一个思考方式是,在任何实例中,我们总是有一个引用this指向当前对象。this引用作为隐式参数传递给任何实例方法。然而,类方法不与特定实例关联,因此它们没有this引用,也无法访问实例字段。
正如我们之前讨论的,类字段本质上是全局变量。类方法类似地是全局方法或全局函数。尽管radiansToDegrees()不在Circle对象上操作,但它在Circle类中定义,因为它是一个在处理圆时有时有用的实用方法,所以将它与Circle类的其他功能打包是有意义的。
实例字段
没有static修饰符声明的任何字段都是实例字段:
public double r; // The radius of the circle
实例字段与类的实例相关联,因此我们创建的每个Circle对象都有其自己的double类型字段r的副本。在我们的示例中,r表示特定圆的半径。每个Circle对象可以具有与所有其他Circle对象独立的半径。
在类定义内部,实例字段仅通过名称引用。如果查看circumference()实例方法的方法体,可以看到一个示例。在类外部的代码中,实例方法的名称必须前缀引用包含该方法的对象。例如,如果变量c持有Circle对象的引用,则使用表达式c.r来引用该圆的半径:
Circle c = new Circle(); // Create a Circle object; store a ref in c
c.r = 2.0; // Assign a value to its instance field r
Circle d = new Circle(); // Create a different Circle object
d.r = c.r * 2; // Make this one twice as big
实例字段是面向对象编程的关键。实例字段保存对象的状态;这些字段的值使一个对象与另一个对象不同。
实例方法
实例方法 是针对类的特定实例(对象)运行的方法,未声明为static关键字的任何方法都自动成为实例方法。
实例方法是使面向对象编程开始变得有趣的特性。在Example 3-1中定义的Circle类包含两个实例方法,area()和circumference(),用于计算并返回给定Circle对象表示的圆的面积和周长。
要从定义它的类的外部使用实例方法,必须前缀引用要操作的实例。例如:
// Create a Circle object; store in variable c
Circle c = new Circle();
c.r = 2.0; // Set an instance field of the object
double a = c.area(); // Invoke an instance method of the object
注意
这就是为什么它被称为面向对象编程;对象是焦点,而不是方法调用。
在实例方法内部,我们自然地可以访问所有属于调用该方法的对象的实例字段。请记住,一个对象通常被认为是一个包含状态(表示为对象的字段)和行为(操作该状态的方法)的捆绑体。
所有实例方法都使用一个在方法签名中未显示的隐式参数来实现。隐式参数被命名为this;它保存通过它调用方法的对象的引用。在我们的示例中,该对象是一个Circle。
注意
area()和circumference()方法的主体都使用类字段PI。我们前面看到,类方法只能使用类字段和类方法,而不是实例字段或方法。实例方法没有这种限制:它们可以使用类的任何成员,无论它是声明为static还是非static的。
this 引用的工作原理
隐式的this参数在方法签名中未显示,因为通常不需要;每当一个 Java 方法访问其类中的实例字段时,隐含地它正在访问this参数所引用的对象的字段。当一个实例方法在同一个类中调用另一个实例方法时,情况也是如此——这意味着“在当前对象上调用实例方法”。
然而,当您希望明确指出方法正在访问其自身字段和/或方法时,可以显式使用this关键字。例如,我们可以重写area()方法,显式使用this来引用实例字段:
public double area() { return Circle.PI * this.r * this.r; }
此代码还显式使用类名来引用类字段PI。在如此简单的方法中,通常不必这样明确。然而,在更复杂的情况下,即使不严格要求,有时使用显式的this 可以增加代码的清晰度。
在某些情况下,this 关键字是必需的。例如,当方法参数或方法中的局部变量与类的某个字段同名时,必须使用this 来引用该字段。这是因为单独使用字段名称会引用方法参数或局部变量,详见“词法作用域和局部变量”。
例如,我们可以将以下方法添加到Circle 类中:
public void setRadius(double r) {
this.r = r; // Assign the argument (r) to the field (this.r)
// Note that writing r = r is a bug
}
一些开发人员会有意地选择方法参数的名称,以避免与字段名称冲突,因此可以大部分避免使用this。然而,由任何主要 Java IDE 生成的访问方法(setter)将使用此处所示的this.x = x 样式。
最后,请注意,虽然实例方法可以使用this 关键字,但类方法不能,因为类方法不与单个对象相关联。
创建和初始化对象
现在我们已经介绍了字段和方法,让我们继续了解类的其他重要成员。特别是,我们将看看构造函数——这些是类的成员,其工作是在创建类的新实例时初始化类的字段。
再看一下我们如何创建Circle 对象:
Circle c = new Circle();
这段代码可以轻松地看作是通过调用类似方法的东西来创建Circle 的新实例。事实上,Circle() 就是构造函数 的一个示例。这是类的成员,与类同名,并且有一个像方法一样的主体。
构造函数的工作方式如下。new 操作符表示我们需要创建类的一个新实例。首先,在 Java 堆中分配内存以容纳新对象实例。然后,调用构造函数体,传入任何指定的参数。构造函数使用这些参数来执行新对象的任何初始化工作。
Java 中的每个类至少有一个构造函数,其目的是为新对象执行任何必要的初始化。如果程序员未显式为类定义构造函数,则javac 编译器会自动创建一个构造函数(称为默认构造函数),它不接受任何参数并且不执行任何特殊初始化。在示例 3-1 中看到的Circle 类使用了这种机制来自动声明一个构造函数。
定义构造函数
对于我们的 Circle 对象,有一些明显的初始化工作可以做,因此让我们定义一个构造函数。示例 3-2 展示了 Circle 的新定义,包含了一个构造函数,允许我们指定新 Circle 对象的半径。我们还利用这个机会将字段 r 设为受保护状态(以防止任意对象访问)。
示例 3-2. Circle 类的构造函数
public class Circle {
public static final double PI = 3.14159; // A constant
// An instance field that holds the radius of the circle
protected double r;
// The constructor: initialize the radius field
public Circle(double r) { this.r = r; }
// The instance methods: compute values based on the radius
public double circumference() { return 2 * PI * r; }
public double area() { return PI * r * r; }
public double radius() { return r; }
}
当我们依赖编译器提供的默认构造函数时,我们必须编写类似以下的代码来显式初始化半径:
Circle c = new Circle();
c.r = 0.25;
使用新的构造函数,初始化成为对象创建步骤的一部分:
Circle c = new Circle(0.25);
这里有关于命名、声明和编写构造函数的一些基础知识:
-
构造函数的名称始终与类名相同。
-
构造函数声明时没有返回类型(甚至不是
void占位符)。 -
构造函数的主体是初始化对象的代码。您可以将其视为设置
this引用的内容。 -
构造函数不会返回
this(或任何其他值)。
定义多个构造函数
有时,您希望根据特定情况的方便程度以多种不同的方式初始化对象。例如,我们可能希望将圆的半径初始化为指定值或合理的默认值。以下是我们如何为 Circle 定义两个构造函数的方式:
public Circle() { r = 1.0; }
public Circle(double r) { this.r = r; }
当然,因为我们的 Circle 类只有一个实例字段,所以我们不能以太多的方式初始化它。但在更复杂的类中,通常方便定义各种构造函数。
定义多个构造函数为一个类是完全合法的,只要每个构造函数有不同的参数列表。编译器根据您提供的参数数量和类型确定您希望使用的构造函数。定义多个构造函数的能力类似于方法重载的能力。
从另一个构造函数中调用
当一个类有多个构造函数时,this 关键字的一个特殊用法是从一个构造函数中调用同一类的另一个构造函数。换句话说,我们可以将前面两个 Circle 构造函数重写如下:
// This is the basic constructor: initialize the radius
public Circle(double r) { this.r = r; }
// This constructor uses this() to invoke the constructor above
public Circle() { this(1.0); }
当多个构造函数共享大量初始化代码时,这是一种有用的技术,因为它避免了重复编写该代码。在更复杂的情况下,如果构造函数进行了更多的初始化操作,这将是一种非常有用的技术。
使用 this() 有一个重要的限制:它只能作为构造函数中的第一条语句出现,但随后可以跟随特定构造函数需要执行的任何额外初始化操作。这个限制涉及到超类构造函数的自动调用,我们将在本章后面探讨这个问题。
字段默认值和初始化器
类的字段不一定需要初始化。如果它们的初始值未指定,则字段将自动初始化为默认值false、\u0000、0、0.0或null,具体取决于它们的类型(有关更多详细信息,请参见表 2-1)。这些默认值由 Java 语言规范指定,并适用于实例字段和类字段。
注意
默认值实际上是每种类型的零位模式的“自然”解释。
如果默认字段值不适合您的字段,则可以显式提供不同的初始值。例如:
public static final double PI = 3.14159;
public double r = 1.0;
字段声明不属于任何方法。相反,Java 编译器会自动生成字段的初始化代码,并将其放入类的所有构造函数中。初始化代码按照其在源代码中出现的顺序插入到构造函数中,这意味着字段初始化程序可以使用其之前声明的任何字段的初始值。
请考虑以下代码摘录,显示了假设类的构造函数和两个实例字段:
public class SampleClass {
public int len = 10;
public int[] table = new int[len];
public SampleClass() {
for(int i = 0; i < len; i = i + 1) {
table[i] = i;
}
}
// The rest of the class is omitted...
}
在这种情况下,由javac为构造函数生成的代码实际上等效于:
public SampleClass() {
len = 10;
table = new int[len];
for(int i = 0; i < len; i = i + 1) {
table[i] = i;
}
}
如果构造函数以对另一个构造函数的this()调用开始,则字段初始化代码不会出现在第一个构造函数中。相反,初始化在由this()调用的构造函数中处理。
因此,如果实例字段在构造函数中初始化,那么类字段在哪里初始化呢?即使永远不创建类的实例,这些字段也与类关联。从逻辑上讲,这意味着它们需要在调用构造函数之前初始化。
为了支持这一点,javac 为每个类自动生成一个类初始化方法。类字段在该方法体中初始化,该方法在类第一次使用之前恰好调用一次(通常是在 Java VM 第一次加载类时)。
与实例字段初始化类似,类字段初始化表达式按照其在源代码中出现的顺序插入到类初始化方法中。这意味着类字段的初始化表达式可以使用在其之前声明的类字段。
类初始化方法是一个对 Java 程序员隐藏的内部方法。在类文件中,它被命名为<clinit>(例如,可以使用javap检查类文件,详细信息请参见第十三章)。
初始化块
到目前为止,我们已经看到对象可以通过其字段的初始化表达式和构造函数中的任意代码进行初始化。一个类有一个类初始化方法(类似于构造函数),但我们不能在 Java 中显式定义此方法的主体,尽管在字节码中这样做是完全合法的。
然而,Java 确实允许我们使用称为静态初始化器的结构来表达类的初始化。静态初始化器只是关键字static后跟一对花括号内的代码块。静态初始化器可以出现在类定义的任何地方,就像字段或方法定义一样。例如,考虑以下代码,它对两个类字段执行一些非平凡的初始化:
// We can draw the outline of a circle using trigonometric functions
// Trigonometry is slow, though, so we precompute a bunch of values
public class TrigCircle {
// Here are our static lookup tables and their own initializers
private static final int NUMPTS = 500;
private static double sines[] = new double[NUMPTS];
private static double cosines[] = new double[NUMPTS];
// Here's a static initializer that fills in the arrays
static {
double x = 0.0;
double delta_x = (Circle.PI/2)/(NUMPTS - 1);
for(int i = 0, x = 0.0; i < NUMPTS; i = i + 1, x += delta_x) {
sines[i] = Math.sin(x);
cosines[i] = Math.cos(x);
}
}
// The rest of the class is omitted...
}
类可以有任意数量的静态初始化器。每个初始化器块的主体都会与类初始化方法一起合并,以及任何静态字段初始化表达式。静态初始化器类似于类方法,因为它不能使用this关键字或类的任何实例字段或实例方法。
记录构造函数
记录类作为 Java 16 的标准特性引入,隐式定义一个构造函数:由参数列表定义的标准构造函数。然而,开发人员可能需要为记录类提供额外的(也称为辅助)构造函数的情况。例如,为记录参数提供默认值,如:
public record Point(double x, double y) {
/** Constructor simulates default parameters */
public Point(double x) {
this(x, 0.0);
}
}
记录还提供了类构造函数的另一种改进:紧凑构造函数。这在帮助创建有效的记录对象时,某些验证或其他检查代码很有用。例如:
/** Represents a point in 2-dimensional space */
public record Point(double x, double y) {
/** Compact constructor provides validation */
public Point {
if (Double.isNaN(x) || Double.isNaN(y)) {
throw new IllegalArgumentException("Illegal NaN");
}
}
}
在紧凑构造函数语法中,请注意参数列表不需要重复(因为可以从记录声明中推断出来),而且参数(在我们的示例中为x和y)已经在范围内。紧凑构造函数与标准构造函数一样,也会从参数值隐式初始化字段。
子类和继承
早些时候定义的Circle是一个简单的类,仅通过其半径区分圆对象。假设我们想要表示既有大小又有位置的圆。例如,在笛卡尔平面上以点 0,0 为中心的半径为 1.0 的圆与以点 1,2 为中心的半径为 1.0 的圆是不同的。为此,我们需要一个新类,我们称之为PlaneCircle。
我们希望能够表示圆的位置,而不丢失Circle类的任何现有功能。我们通过将PlaneCircle定义为Circle的子类来实现这一点,这样PlaneCircle就继承了其超类Circle的字段和方法。通过子类化或扩展类来添加功能是面向对象编程范式的核心。
扩展类
在 示例 3-3 中,我们展示了如何将 PlaneCircle 实现为 Circle 类的子类。
示例 3-3. 扩展 Circle 类
public class PlaneCircle extends Circle {
// We automatically inherit the fields and methods of Circle,
// so we only have to put the new stuff here.
// New instance fields that store the center point of the circle
private final double cx, cy;
// A new constructor to initialize the new fields
// It uses a special syntax to invoke the Circle() constructor
public PlaneCircle(double r, double x, double y) {
super(r); // Invoke the constructor of the superclass, Circle()
this.cx = x; // Initialize the instance field cx
this.cy = y; // Initialize the instance field cy
}
public double getCenterX() {
return cx;
}
public double getCenterY() {
return cy;
}
// The area() and circumference() methods are inherited from Circle
// A new instance method checks whether a point is inside the circle
// Note that it uses the inherited instance field r
public boolean isInside(double x, double y) {
double dx = x - cx, dy = y - cy; // Distance from center
double distance = Math.sqrt(dx*dx + dy*dy); // Pythagorean theorem
return (distance < r); // Returns true or false
}
}
注意第一行中 示例 3-3 中关键字 extends 的使用。此关键字告诉 Java PlaneCircle 类扩展或子类化了 Circle,这意味着它继承了该类的字段和方法。
isInside() 方法的定义展示了字段继承;此方法使用了 r 字段(由 Circle 类定义),就好像它是在 PlaneCircle 类自身定义的一样。PlaneCircle 还继承了 Circle 的方法。因此,如果我们有一个变量 pc 引用的是 PlaneCircle 对象,我们可以说:
double ratio = pc.circumference() / pc.area();
这就像 area() 和 circumference() 方法是在 PlaneCircle 类自身定义的一样。
子类化的另一个特点是每个 PlaneCircle 对象也是一个完全合法的 Circle 对象。如果 pc 引用的是 PlaneCircle 对象,我们可以将其分配给 Circle 变量,并忘记其额外的定位能力:
// Unit circle at the origin
PlaneCircle pc = new PlaneCircle(1.0, 0.0, 0.0);
Circle c = pc; // Assigned to a Circle variable without casting
可以将 PlaneCircle 对象分配给 Circle 变量而无需强制转换。正如我们在 第二章 中讨论的那样,这样的转换始终是合法的。存储在 Circle 变量 c 中的值仍然是有效的 PlaneCircle 对象,但是编译器无法确定这一点,因此不允许我们在没有强制转换的情况下进行相反的(缩小范围)转换:
// Narrowing conversions require a cast (and a runtime check by the VM)
PlaneCircle pc2 = (PlaneCircle) c;
boolean inside = ((PlaneCircle) c).isInside(0.0, 0.0);
这种区别在 “嵌套类型” 中有更详细的介绍,我们在那里讨论了对象的编译时和运行时类型之间的区别。
最终类
当一个类使用 final 修饰符声明时,意味着它不能被扩展或子类化。java.lang.String 就是 final 类的一个例子。声明类为 final 可以防止不需要的类扩展:如果您在 String 对象上调用方法,您知道该方法是由 String 类自身定义的,即使这个 String 是从某个未知外部来源传递给您的。
一般来说,Java 开发者创建的许多类应该是 final 的。仔细考虑是否允许其他(可能未知的)代码扩展您的类是否合理——如果不合理,则通过声明您的类为 final 来禁止此机制。
超类、Object 和类层次结构
在我们的示例中,PlaneCircle 是 Circle 的子类。我们还可以说 Circle 是 PlaneCircle 的超类。类的超类在其 extends 子句中指定,并且一个类可能只有一个直接的超类:
public class PlaneCircle extends Circle { ... }
程序员定义的每个类都有一个超类。如果超类未在 extends 子句中指定,则超类被认为是 java.lang.Object 类。
因此,Object 类对于几个特定原因非常特殊:
-
这是 Java 中唯一一个没有超类的类。
-
所有 Java 类(直接或间接)继承
Object的方法。
因为每个类(除了Object)都有一个超类,Java 类形成一个类层次结构,可以将其表示为以Object为根的树形结构。
注意
Object没有超类,但每个其他类都恰好有一个超类。子类不能扩展多个超类;详见第四章了解如何使用接口实现类似结果的更多信息。
图 3-1 展示了一个部分类层次结构图,包括我们的Circle和PlaneCircle类,以及一些来自 Java API 的标准类。
图 3-1. 类层次结构图
子类构造函数
再看一下来自示例 3-3 的PlaneCircle()构造函数:
public PlaneCircle(double r, double x, double y) {
super(r); // Invoke the constructor of the superclass, Circle()
this.cx = x; // Initialize the instance field cx
this.cy = y; // Initialize the instance field cy
}
虽然此构造函数显式初始化了由PlaneCircle新定义的cx和cy字段,但它依赖于超类Circle()构造函数来初始化类的继承字段。为了调用超类构造函数,我们的构造函数调用了super()。
super是 Java 中的保留字。其主要用途之一是从子类构造函数中调用超类的构造函数。这与使用this()从同一类的另一个构造函数中调用构造函数类似。使用super()调用构造函数的限制与使用this()相同:
-
super()只能在构造函数中以这种方式使用。 -
必须将对超类构造函数的调用作为构造函数中的第一条语句出现,甚至在局部变量声明之前。
传递给super()的参数必须与超类构造函数的参数匹配。如果超类定义了多个构造函数,则可以使用super()来调用其中任何一个,具体取决于传递的参数。
构造函数链和默认构造函数
Java 保证每当创建该类的实例时都会调用该类的构造函数。它还保证每当创建任何子类的实例时都会调用构造函数。为了保证第二点,Java 必须确保每个构造函数调用其超类的构造函数。
因此,如果构造函数中的第一个语句没有显式地使用this()或super()调用另一个构造函数,javac编译器会插入调用super()(即调用没有参数的超类构造函数)。如果超类没有一个可见且不带参数的构造函数,这种隐式调用将导致编译错误。
考虑当我们创建PlaneCircle类的新实例时会发生什么:
-
首先,调用了
PlaneCircle构造函数。 -
此构造函数显式调用
super(r)来调用Circle构造函数。 -
那个
Circle()构造函数隐式调用super()来调用其超类Object的构造函数(Object只有一个构造函数)。 -
到这一点,我们已经到达了层次结构的顶部,并且构造函数开始运行。
-
Object构造函数的主体首先运行。 -
当它返回时,
Circle()构造函数的主体运行。 -
最后,当对
super(r)的调用返回时,会执行PlaneCircle()构造函数的剩余语句。
所有这些意味着构造函数调用是链式的;每次创建对象时,会调用一系列构造函数,从子类到超类直到Object在类层次结构的根部。
因为超类构造函数总是作为其子类构造函数的第一个语句调用,所以Object构造函数的主体总是首先运行,然后是其子类的构造函数,以及类层次结构直到被实例化的类。
每当调用构造函数时,它可以确保其超类的字段在构造函数开始运行时已被初始化。
默认构造函数
在先前对构造函数链的描述中,有一个遗漏的部分。如果一个构造函数没有调用超类的构造函数,Java 会隐式地这样做。
注意
如果一个类声明时没有构造函数,Java 会隐式地为该类添加一个构造函数。这个默认构造函数什么也不做,只调用超类的构造函数。
例如,如果我们没有为PlaneCircle类声明构造函数,Java 会隐式插入这个构造函数:
public PlaneCircle() { super(); }
声明为public的类具有public构造函数。所有其他类都将获得默认构造函数,该构造函数声明时没有任何可见性修饰符;这样的构造函数具有默认可见性。
一个非常重要的点是,如果一个类声明了带参数的构造函数但没有定义无参数构造函数,那么它的所有子类必须定义构造函数来显式调用具有必要参数的构造函数。
注意
如果您正在创建一个不应公开实例化的public类,请至少声明一个非public构造函数,以防止插入默认的public构造函数。
永远不应该实例化的类(如java.lang.Math或java.lang.System)应该只定义一个private构造函数。这样的构造函数永远不能从类的外部调用,并且它阻止默认构造函数的自动插入。总体效果是该类永远不会被实例化,因为它不会被类本身实例化,也没有其他类有正确的访问权限。
隐藏超类字段
举个例子,假设我们的PlaneCircle类需要知道圆心与原点(0,0)之间的距离。我们可以添加另一个实例字段来保存这个值:
public double r;
将以下行添加到构造函数会计算字段的值:
this.r = Math.sqrt(cx*cx + cy*cy); // Pythagorean theorem
但是,请注意;这个新字段r与Circle超类中的半径字段r名称相同。当这种情况发生时,我们称PlaneCircle的字段r隐藏了Circle的字段r。(当然,这是一个刻意构造的例子:这个新字段应该被称为distanceFromOrigin。)
注意
在你写的代码中,应避免声明名称隐藏超类字段的字段。这几乎总是糟糕代码的标志。
使用PlaneCircle的这个新定义,表达式r和this.r都指的是PlaneCircle的字段。那么,我们如何引用Circle中持有圆的半径的字段r呢?一种特殊的语法使用super关键字:
r // Refers to the PlaneCircle field
this.r // Refers to the PlaneCircle field
super.r // Refers to the Circle field
引用隐藏字段的另一种方法是将this(或任何类的实例)转型为适当的超类,然后访问字段:
((Circle) this).r // Refers to field r of the Circle class
当你需要引用定义在不是直接超类的类中的隐藏字段时,这种转型技术特别有用。例如,类A、B和C都定义了一个名为x的字段,并且C是B的子类,B是A的子类。那么在类C的方法中,你可以如下引用这些不同的字段:
x // Field x in class C
this.x // Field x in class C
super.x // Field x in class B
((B)this).x // Field x in class B
((A)this).x // Field x in class A
super.super.x // Illegal; does not refer to x in class A
注意
你不能使用super.super.x来引用超类的超类中隐藏的字段x。这不是合法的语法。
类似地,如果你有一个类C的实例c,你可以像这样引用三个名为x的字段:
c.x // Field x of class C
((B)c).x // Field x of class B
((A)c).x // Field x of class A
到目前为止,我们一直在讨论实例字段。类字段也可以被隐藏。你可以使用相同的super语法来引用字段的隐藏值,但这从未是必要的,因为你总是可以通过在所需类名前添加来引用类字段。例如,假设PlaneCircle的实现者决定Circle.PI字段的精度不够。她可以定义自己的类字段PI:
public static final double PI = 3.14159265358979323846;
现在,PlaneCircle中的代码可以使用这个更精确的值,表达式PI或PlaneCircle.PI。它还可以通过表达式super.PI和Circle.PI引用旧的、不太精确的值。然而,PlaneCircle继承的area()和circumference()方法是在Circle类中定义的,因此它们使用Circle.PI的值,即使现在被PlaneCircle.PI隐藏了。
覆盖超类方法
当一个类定义一个与其超类中方法相同名称、返回类型和参数的实例方法时,该方法覆盖了超类的方法。当为该类的对象调用方法时,调用的是该方法的新定义,而不是超类中的旧定义。
提示
覆盖方法的返回类型可以是原始方法返回类型的子类(而不是完全相同的类型)。这被称为协变返回。
方法覆盖是面向对象编程中一种重要且有用的技术。PlaneCircle 并没有覆盖 Circle 定义的任何方法,实际上很难想象出一个能够清晰定义覆盖 Circle 方法的好例子。
警告
不要试图用像 Ellipse 这样的类对 Circle 进行子类化——这实际上违反了面向对象开发的核心原则(里斯科夫原则,我们将在本章后面讨论)。
相反,让我们看一个确实使用方法覆盖的不同示例:
public class Car {
public static final double LITRE_PER_100KM = 8.9;
protected double topSpeed;
protected double fuelTankCapacity;
private int doors;
public Car(double topSpeed, double fuelTankCapacity,
int doors) {
this.topSpeed = topSpeed;
this.fuelTankCapacity = fuelTankCapacity;
this.doors = doors;
}
public double getTopSpeed() {
return topSpeed;
}
public int getDoors() {
return doors;
}
public double getFuelTankCapacity() {
return fuelTankCapacity;
}
public double range() {
return 100 * fuelTankCapacity / LITRE_PER_100KM;
}
}
这有点复杂,但它将说明覆盖背后的概念。与 Car 类一起,我们还有一个特殊化的类 SportsCar。这有几个区别:它有一个固定大小的燃料箱,只有两门版本。它的最高速度可能比常规形式高得多,但如果最高速度超过 200 公里/小时,则汽车的燃油效率会下降,因此汽车的整体续航开始减少:
public class SportsCar extends Car {
private double efficiency;
public SportsCar(double topSpeed) {
super(topSpeed, 50.0, 2);
if (topSpeed > 200.0) {
efficiency = 200.0 / topSpeed;
} else {
efficiency = 1.0;
}
}
public double getEfficiency() {
return efficiency;
}
@Override
public double range() {
return 100 * fuelTankCapacity * efficiency / LITRE_PER_100KM;
}
}
即将讨论的方法覆盖仅考虑实例方法。类(也称为静态)方法的行为完全不同,并且它们不能被覆盖。就像字段一样,子类可以隐藏类方法,但不能覆盖它们。正如本章前面所述,始终在类方法调用之前加上定义它的类名是良好的编程风格。如果你认为类名是类方法名称的一部分,那么这两个方法实际上具有不同的名称,因此并没有隐藏任何内容。
注意
SportsCar 的代码示例包括语法结构 @Override。这被称为注解,我们将在第四章中详细介绍这个 Java 语法。
在进一步讨论方法覆盖之前,你应该理解方法覆盖和方法重载之间的区别。正如我们在第二章中讨论的,方法重载指的是在同一个类中定义多个方法,它们具有相同的名称但参数列表不同。
另一方面,当实例方法与其超类中的方法具有相同的名称、返回类型和参数列表时,该方法覆盖其超类中的方法。这两个特性在本质上是非常不同的,所以不要混淆它们。
覆盖并非隐藏
尽管 Java 在许多方面类似地对待类的字段和方法,但方法覆盖与字段隐藏毫不相同。你可以通过将对象强制转换为适当超类的实例来引用隐藏字段,但不能使用此技术调用被覆盖的实例方法。以下代码说明了这一关键区别:
class A { // Define a class named A
int i = 1; // An instance field
int f() { return i; } // An instance method
static char g() { return 'A'; } // A class method
}
class B extends A { // Define a subclass of A
int i = 2; // Hides field i in class A
int f() { return -i; } // Overrides method f in class A
static char g() { return 'B'; } // Hides class method g() in class A
}
public class OverrideTest {
public static void main(String args[]) {
B b = new B(); // Creates a new object of type B
System.out.println(b.i); // Refers to B.i; prints 2
System.out.println(b.f()); // Refers to B.f(); prints -2
System.out.println(b.g()); // Refers to B.g(); prints B
System.out.println(B.g()); // A better way to invoke B.g()
A a = (A) b; // Casts b to an instance of class A
System.out.println(a.i); // Now refers to A.i; prints 1
System.out.println(a.f()); // Still refers to B.f(); prints -2
System.out.println(a.g()); // Refers to A.g(); prints A
System.out.println(A.g()); // A better way to invoke A.g()
}
}
尽管方法覆盖与字段隐藏之间的区别乍看起来令人惊讶,稍加思考就能明确其目的。
假设我们正在操作一堆Car和SportsCar对象,并将它们存储在类型为Car[]的数组中。我们可以这样做是因为SportsCar是Car的子类,因此所有SportsCar对象都是合法的Car对象。
当我们遍历这个数组的元素时,我们不需要知道或关心元素实际上是Car还是SportsCar。然而,我们非常关心的是,在调用数组中任何元素的range()方法时,计算出正确的值。换句话说,当对象实际上是跑车时,我们不希望使用汽车范围的公式!
我们真正想要的是,我们正在计算其范围的对象“做正确的事情”——Car对象使用它们自己定义的计算范围的方式,而SportsCar对象使用适合它们的定义。
在这种情况下看,Java 处理方法重写与字段隐藏的方式有所不同并不奇怪。
虚拟方法查找
如果我们有一个Car[]数组,其中包含Car和SportsCar对象,javac如何知道对数组中的任何给定项调用range()方法时是调用Car类还是SportsCar类的range()方法?实际上,源代码编译器无法在编译时知道这一点。
相反,javac创建的字节码在运行时使用虚拟方法查找。当解释器运行代码时,它查找适合数组中每个对象调用的适当range()方法。也就是说,当解释器解释表达式o.range()时,它检查变量o引用的对象的实际运行时类型,然后找到适合该类型的range()方法。
注意
某些其他语言(如 C#或 C++)默认不会进行虚拟查找,而是通过virtual关键字明确指定,以允许子类覆盖方法。
这是方法重写概念的另一种方式,我们之前讨论过。如果使用o的静态类型关联的range()方法版本,而没有运行时(也称为虚拟)查找,则重写将无法正常工作。
对于 Java 实例方法,默认为虚拟方法查找。有关编译时和运行时类型及其对虚拟方法查找的影响的更多详细信息,请参见第四章。
调用重写方法
我们已经看到了方法重写和字段隐藏之间的重要差异。尽管如此,调用重写方法的 Java 语法与访问隐藏字段的语法非常相似:两者都使用super关键字。以下代码说明了这一点:
class A {
int i = 1; // An instance field hidden by subclass B
int f() { return i; } // An instance method overridden by subclass B
}
class B extends A {
int i; // This field hides i in A
int f() { // This method overrides f() in A
i = super.i + 1; // It can retrieve A.i like this
return super.f() + i; // It can invoke A.f() like this
}
}
请注意,当您使用super引用隐藏字段时,它等同于将this强制转换为超类类型并通过它访问字段。然而,使用super来调用覆盖的方法并不同于转换this引用。换句话说,在前面的代码中,表达式super.f()与((A)this).f()不同。
当解释器使用super语法调用实例方法时,执行一种修改后的虚拟方法查找。首先步骤,与常规虚拟方法查找一样,确定通过其调用方法的对象的实际类。通常,运行时搜索适当方法定义的过程将从这个类开始。然而,当使用super语法调用方法时,搜索从类的超类开始。如果超类直接实现该方法,则调用该方法的版本。如果超类继承该方法,则调用继承的版本。
请注意,super关键字调用的是方法的最直接覆盖版本。假设类A有一个子类B,B有一个子类C,并且这三个类都定义了相同的方法f()。方法C.f()可以使用super.f()调用它直接覆盖的方法B.f()。但是没有办法让C.f()直接调用A.f():super.super.f()不是合法的 Java 语法。当然,如果C.f()调用B.f(),那么假设B.f()可能也会调用A.f()是合理的。
这种链式调用在覆盖方法中比较常见:这是一种在不完全替换方法的情况下增强方法行为的方式。
注意
不要混淆使用super来调用覆盖方法和在构造函数中用super()调用超类构造函数的方法。尽管它们都使用相同的关键字,但这是两种完全不同的语法。特别是,您可以在覆盖类中的任何地方使用super来调用覆盖方法,但只能在构造函数的第一个语句中使用super()来调用超类构造函数。
还要记住,super只能在覆盖它的类内部使用来调用覆盖的方法。给定对SportsCar对象e的引用,程序无法使用e来调用Car类定义的range()方法。
封闭类
到目前为止,我们只遇到了类继承的两种可能性:
-
无限制的子类化能力(这是默认情况,没有与之关联的关键字)
-
使用
final关键字应用于类完全防止子类化
截至 Java 17,有第三种可能性,由sealed关键字控制。封闭类是一种可以被特定已知类别的类继承的类。声明封闭类时,使用permits关键字列举可能的子类列表(这些子类必须与基类在同一个包中)。示例如下:
// In Shape.java
public abstract sealed class Shape permits Circle, Triangle {
// ...
}
// In Circle.java
public final class Circle extends Shape {
// ...
}
// In Triangle.java
public final class Triangle extends Shape {
// ...
}
在此示例中,我们将Circle和Triangle都声明为final,因此它们无法进一步被子类化。这是一种常见的做法,但也可以将封闭类的子类型声明为sealed(具有进一步允许的子类集合),或者声明为non-sealed,恢复 Java 默认的无限制子类化行为。
这最后一个选项(non-sealed)不应该没有非常充分的理由而使用,因为这将首先破坏使用类封闭的语义目的。因此,尝试对封闭类进行子类化而不提供三个封闭修饰符之一会在编译时产生错误:这里没有默认行为。
注意
non-sealed的引入是 Java 中首次出现的连字符关键字的例子。
在此示例中,我们使用了一个抽象的封闭基类(Shape)。这并非总是必要的,但通常是一个良好的实践,因为这意味着我们遇到的类型实例必定是“叶子类型”之一,例如Circle或Triangle。我们稍后将在本章更详细地介绍抽象类。
虽然封闭类是 Java 17 的新特性,但我们预计许多开发者会快速采用它们——连同记录(records)一起,它们代表了 Java 面向对象视角中一个“遗漏的概念”。在我们讨论与封闭类型相关的面向对象设计方面时,我们将在第五章中详细阐述此点。
数据隐藏和封装
我们从描述类为数据和方法的集合开始了本章。到目前为止我们尚未讨论的最重要的面向对象技术之一是隐藏类内部数据,并且只通过方法来访问数据。
这种技术被称为封装,因为它将数据(和内部方法)安全地包含在类的“胶囊”内部,只有信任的用户(即类的方法)可以访问它。
为什么要这样做?最重要的原因是隐藏类的内部实现细节。如果阻止程序员依赖这些细节,您可以安全地修改实现,而不必担心会破坏使用该类的现有代码。
注意
您应该始终封装您的代码。几乎不可能推理和确保没有良好封装的代码的正确性,特别是在多线程环境中(而且基本上所有 Java 程序都是多线程的)。
封装的另一个原因是保护你的类免受意外或故意的愚蠢。一个类通常包含许多相互依赖的字段,这些字段必须保持一致的状态。如果允许程序员(包括你自己)直接操作这些字段,他们可能只改变一个字段而不改变重要的相关字段,导致类处于不一致的状态。相反,如果程序员必须调用一个方法来改变字段,那么这个方法可以确保做一切必要的工作以保持状态的一致性。同样,如果一个类定义了某些只供内部使用的方法,隐藏这些方法可以防止类的用户调用它们。
这里还有另一种封装的思考方式:当一个类的所有数据都被隐藏时,方法定义了对该类对象可以执行的唯一可能操作。
一旦你仔细测试和调试了你的方法,你可以确信类将按预期工作。另一方面,如果类的所有字段都可以直接操作,那么你需要测试的可能性就变得难以管理。
注意
这个想法可以得出一个非常强有力的结论,正如我们将在“安全的 Java 编程”中看到的那样,当我们讨论 Java 程序的安全性(这与 Java 编程语言的类型安全性概念不同)。
隐藏类的字段和方法的其他次要原因包括:
-
对外可见的内部字段和方法只会混淆 API。保持可见字段的最小化可以使你的类更整洁,因此更容易使用和理解。
-
如果一个方法对你的类的用户可见,你必须对其进行文档化。相反,隐藏它可以节省时间和精力。
访问控制
Java 定义了访问控制规则,可以限制类的成员在类外部的使用。在本章的许多示例中,你已经看到public修饰符在字段和方法声明中的使用。这个public关键字,以及protected、private(还有一个特殊的关键字),是访问 控制 修饰符;它们指定了字段或方法的访问规则。
模块访问
Java 9 中最大的变化之一是 Java 平台模块的到来。这些模块是比单个包更大的代码组合,旨在作为未来部署重用代码的方式。由于 Java 经常用于大型应用和环境中,模块的到来应该使得构建和管理企业代码库变得更加容易。
模块技术是一个高级的主题,如果 Java 是你接触的第一种编程语言之一,你不应该在没有获得一些语言熟练程度之前尝试学习它。在第十二章中提供了模块的介绍,我们推迟讨论模块的访问控制影响直到那时。
包访问
包内基础上的访问控制并非直接属于核心 Java 语言的一部分,而是由模块机制提供。在正常的编程过程中,访问控制通常是在类及其成员级别上进行的。
注意
已加载的包始终对同一包内定义的代码可访问。它是否对来自其他包的代码可访问取决于包在主机系统上的部署方式。例如,当包含构成包的类文件存储在一个目录中时,用户必须对该目录及其内部文件具有读取权限才能访问该包。
类的访问
默认情况下,顶级类在其定义的包内可访问。然而,如果顶级类声明为public,则可在任何地方访问。
提示
在第四章,我们将会遇到嵌套类。这些是可以定义为其他类成员的类。由于这些内部类是类的成员,它们遵守成员访问控制规则。
成员访问
类的成员在类的主体内部始终是可访问的。默认情况下,成员在定义类的包中也是可访问的。这种默认访问级别通常称为包访问。
这是四种可能的访问级别之一。其他三个级别由public、protected和private修饰符定义。以下是使用这些修饰符的一些示例代码:
public class Laundromat { // People can use this class.
private Laundry[] dirty; // They cannot use this internal field,
public void wash() { ... } // but they can use these public methods
public void dry() { ... } // to manipulate the internal field.
// A subclass might want to tweak this field
protected int temperature;
}
这些访问规则适用于类的成员:
-
类的所有字段和方法都可以在类本身的主体内部使用。
-
如果类的成员使用
public修饰符声明,则意味着该成员可以在包含类可访问的任何地方访问。这是最不严格的访问控制类型。 -
如果类的成员声明为
private,则该成员除了在类本身内部外不可访问。这是最严格的访问控制类型。 -
如果类的成员声明为
protected,则在包内的所有类(与默认包访问权限相同),以及在该类的任何子类主体内,无论该子类定义在哪个包中,都可以访问该成员。 -
如果类的成员没有使用这些修饰符声明,则具有默认访问权限(有时称为包访问权限),可以在同一包内定义的所有类的代码中访问,但在包外部无法访问。
警告
默认访问权限比protected更为严格,因为默认访问权限不允许子类在包外访问。
protected访问需要更多阐述。假设类A声明了一个protected字段x,并且由一个类B扩展,该类在一个不同的包中定义(这一点很重要)。类B继承了protected字段x,并且它的代码可以访问当前B实例中的该字段,或者代码可以引用的任何其他B实例中的该字段。但这并不意味着类B的代码可以开始读取任意A实例的受保护字段。
让我们在代码中详细查看这个语言细节。以下是A的定义:
package javanut8.ch03;
public class A {
protected final String name;
public A(String named) {
name = named;
}
public String getName() {
return name;
}
}
这是B的定义:
package javanut8.ch03.different;
import javanut8.ch03.A;
public class B extends A {
public B(String named) {
super(named);
}
@Override
public String getName() {
return "B: " + name;
}
}
注意
Java 包不会“嵌套”,所以javanut8.ch03.different只是与javanut8.ch03不同的包,它不包含在其中,也没有任何关联。
但是,如果我们尝试将此新方法添加到B,我们将收到编译错误,因为B的实例没有访问任意A实例的权限:
public String examine(A a) {
return "B sees: " + a.name;
}
如果我们将方法更改为:
public String examine(B b) {
return "B sees another B: " + b.name;
}
那么编译器会很高兴,因为相同类型的实例总是可以看到彼此的protected字段。当然,如果B与A在同一个包中,那么B的任何实例都可以读取A的任何实例的protected字段,因为protected字段对同一包中的每个类都是可见的。
访问控制和继承
Java 规范说明了:
-
子类继承其可访问的超类的所有实例字段和实例方法。
-
如果子类在与超类相同的包中定义,它将继承所有非
private实例字段和方法。 -
如果子类在不同的包中定义,它将继承所有
protected和public实例字段和方法。 -
private字段和方法永远不会被继承;类字段或类方法也不会被继承。 -
构造函数不会被继承(而是链接,如本章前面描述的)。
但是,一些程序员对子类不继承其超类的不可访问字段和方法的说法感到困惑。让我们明确一下:每个子类的实例都包含其中完整的超类实例,包括所有私有字段和方法。当您创建子类的实例时,为超类定义的所有private字段都分配了内存;但是,子类不能直接访问这些字段。
这种存在可能无法访问的成员似乎与类的成员始终在类体内部可访问的说法相冲突。为了消除这种混淆,我们定义“继承成员”为指那些可访问的超类成员。
那么关于成员可访问性的正确说明是:“所有继承成员和所有在此类中定义的成员都是可访问的。”这种说法的另一种表述方式是:
-
类继承其超类的所有实例字段和实例方法(但不包括构造函数)。
-
类的主体始终可以访问其自身声明的所有字段和方法。它还可以访问其从超类继承的 可访问 字段和成员。
成员访问摘要
我们在 Table 3-1 中总结了成员访问规则。
Table 3-1. 类成员访问性
| 成员可见性 | ||||
|---|---|---|---|---|
| 可访问 | 公共 | 保护的 | 默认 | 私有 |
| --- | --- | --- | --- | --- |
| 定义类 | 是 | 是 | 是 | 是 |
| 同一包中的类 | 是 | 是 | 是 | 否 |
| 不同包中的子类 | 是 | 是 | 否 | 否 |
| 非子类不同包 | 是 | 否 | 否 | 否 |
Java 程序的哪些部分应使用每种可见性修饰符有一些普遍遵循的规则。即使是初学者的 Java 程序员也应遵循这些规则:
-
仅对类的公共 API 的方法和常量使用
public。public字段的唯一可接受用法是常量或不可变对象,并且它们必须同时声明为final。 -
对于大多数程序员不使用但可能对创建子类的任何人有兴趣的字段和方法,请使用
protected。
注:
protected 成员在技术上是类的导出 API 的一部分。它们必须有文档说明,且不能更改,否则可能会破坏依赖它们的代码。
-
对于内部实现细节但被同一包中的协作类使用的字段和方法,请使用默认的包可见性。
-
对于仅在类内部使用且应在其他任何地方隐藏的字段和方法,请使用
private。
如果不确定是否使用 protected、包或 private 访问性,请从 private 开始。如果这太严格,您可以稍微放松访问限制(或在字段的情况下提供访问器方法)。
这对于设计 API 尤为重要,因为增加访问限制不是向后兼容的更改,可能会破坏依赖于这些成员访问的代码。
数据访问方法
在Circle的例子中,我们声明了圆的半径为public字段。Circle类是一个可能合理地保持该字段公开访问的类;它是一个足够简单的类,没有字段之间的依赖关系。另一方面,我们当前的类实现允许一个Circle对象具有负半径,而具有负半径的圆根本不应该存在。然而,只要半径存储在一个public字段中,任何程序员都可以将字段设置为任何他们想要的值,无论多么不合理。唯一的解决方案是限制程序员对字段的直接访问,并定义public方法提供对字段的间接访问。提供读取和写入字段的public方法并不等同于使字段本身成为public。关键区别在于方法可以执行错误检查。
例如,我们可能希望阻止具有负半径的Circle对象——这些显然是不明智的,但我们当前的实现不会禁止这样做。在例子 3-4 中,我们展示了如何修改Circle的定义以防止这种情况发生。
这个Circle的版本将r字段声明为protected,并定义了名为getRadius()和setRadius()的访问器方法来读取和写入字段值,同时强制限制半径值为负。因为r字段是protected的,所以它可以直接(并且更有效地)被子类访问。
例子 3-4. 使用数据隐藏和封装的 Circle 类
package javanut8.ch03.shapes; // Specify a package for the class
public class Circle { // The class is still public
// This is a generally useful constant, so we keep it public
public static final double PI = 3.14159;
protected double r; // Radius is hidden but visible to subclasses
// A method to enforce the restriction on the radius
// Subclasses may be interested in this implementation detail
protected void checkRadius(double radius) {
if (radius < 0.0)
throw new IllegalArgumentException("illegal negative radius");
}
// The non-default constructor
public Circle(double r) {
checkRadius(r);
this.r = r;
}
// Public data accessor methods
public double getRadius() { return r; }
public void setRadius(double r) {
checkRadius(r);
this.r = r;
}
// Methods to operate on the instance field
public double area() { return PI * r * r; }
public double circumference() { return 2 * PI * r; }
}
我们在一个名为javanut8.ch03.shapes的包中定义了Circle类;r是protected的,因此javanut8.ch03.shapes包中的任何其他类都可以直接访问该字段并按照他们喜欢的方式设置它。这里的假设是javanut8.ch03.shapes包中的所有类都由同一作者或紧密合作的一组作者编写,并且这些类之间相互信任,不滥用对彼此实现细节的特权级别。
最后,强制限制半径值为负数的代码本身放置在一个protected方法checkRadius()中。虽然Circle类的用户无法调用此方法,但类的子类可以调用它,甚至可以覆盖它,如果他们想要改变对半径的限制。
注意
一组常见(但较旧的)Java 约定之一——称为 Java Bean 约定——是数据访问器方法以前缀“get”和“set”开头。但是,如果被访问的字段是boolean类型,则get()方法可以被一个以“is”开头的等效方法替换——一个名为readable的boolean字段的访问器方法通常被称为isReadable()而不是getReadable()。
抽象类和方法
在 示例 3-4 中,我们声明了我们的 Circle 类是属于名为 shapes 的包的一部分。假设我们计划实现许多形状类:Rectangle、Square、Hexagon、Triangle 等等。我们可以给这些形状类我们的两个基本的 area() 和 circumference() 方法。现在,为了便于使用形状数组,如果我们的所有形状类都有一个公共的超类 Shape 就会很有帮助。如果我们以这种方式结构化我们的类层次结构,那么无论形状对象表示的实际形状类型如何,都可以将其分配给类型为 Shape 的变量、字段或数组元素。我们希望 Shape 类封装所有我们的形状共有的特征(例如 area() 和 circumference() 方法)。但是我们的通用 Shape 类并不表示任何实际的形状,因此它不能定义有用的方法实现。Java 使用 抽象方法 处理这种情况。
Java 允许我们通过声明带有 abstract 修饰符的方法来定义一个方法而不实现它。一个 abstract 方法没有主体;它只是有一个签名定义,后面跟着一个分号。² 关于 abstract 方法和包含它们的 abstract 类的规则如下:
-
任何带有
abstract方法的类自动成为abstract,必须声明为这样。不这样做会导致编译错误。 -
抽象类不能被实例化。
-
抽象类的子类只有在覆盖了其超类的每个
abstract方法并为所有方法提供实现(即方法体)时才能被实例化。这样的类通常被称为 具体 子类,以强调它不是abstract。 -
如果抽象类的子类没有实现它继承的所有
abstract方法,那么该子类本身就是abstract的,必须声明为这样。 -
static,private, andfinal方法不能是abstract,因为这些类型的方法不能被子类覆盖。同样,一个final类不能包含任何abstract方法。 -
即使一个类实际上没有任何
abstract方法,也可以声明该类为abstract。声明这样一个类为abstract表示该实现在某种程度上是不完整的,是为一个或多个子类提供实现的超类。这样的类不能被实例化。
注意
我们将在 第十一章 中遇到的 ClassLoader 类是一个没有任何抽象方法的抽象类的好例子。
让我们看一个示例,说明这些规则是如何工作的。如果我们定义Shape类具有abstract area()和circumference()方法,那么Shape的任何子类都必须提供这些方法的实现,以便可以实例化它。换句话说,每个Shape对象都保证具有这些方法的实现。示例 3-5 展示了这是如何工作的。它定义了一个abstract的Shape类和它的一个具体子类。您还应该想象,从示例 3-4 中的Circle类已被修改为extends Shape。
示例 3-5. 一个抽象类和具体子类
public abstract class Shape {
public abstract double area(); // Abstract methods: note
public abstract double circumference(); // semicolon instead of body.
}
public class Rectangle extends Shape {
// Instance data
protected double w, h;
// Constructor
public Rectangle(double w, double h) {
this.w = w; this.h = h;
}
// Accessor methods
public double getWidth() { return w; }
public double getHeight() { return h; }
// Implementation of abstract methods
public double area() { return w*h; }
public double circumference() { return 2*(w + h); }
}
每个Shape类中的abstract方法在其括号后面都有一个分号。这种类型的方法声明没有花括号,并且没有定义方法体。
请注意,我们本可以将Shape类声明为密封类,但故意选择不这样做。这样其他程序员就可以定义自己的形状类作为Shape的新子类,如果他们希望的话。
使用示例 3-5 中定义的类,我们现在可以编写如下代码:
Shape[] shapes = new Shape[3]; // Create an array to hold shapes
shapes[0] = new Circle(2.0); // Fill in the array
shapes[1] = new Rectangle(1.0, 3.0);
shapes[2] = new Rectangle(4.0, 2.0);
double totalArea = 0;
for(int i = 0; i < shapes.length; i++) {
totalArea += shapes[i].area(); // Compute the area of the shapes
}
这里要注意两个重要点:
-
Shape的子类可以分配给Shape数组的元素。不需要转换。这是引用类型扩展的另一个示例(见第二章讨论)。 -
您可以为任何
Shape对象调用area()和circumference()方法,即使Shape类没有定义这些方法的具体实现。在这种情况下,通过虚拟查找找到要调用的方法,我们之前已经遇到过。在我们的例子中,这意味着圆的面积是使用Circle定义的方法计算的,而矩形的面积是使用Rectangle定义的方法计算的。
引用类型转换
可以在不同的引用类型之间转换对象引用。与原始类型一样,引用类型转换可以是宽化转换(编译器自动允许)或需要转型的窄化转换(可能需要运行时检查)。为了理解引用类型转换,您需要了解引用类型形成的层次结构,通常称为类层次结构。
每个 Java 引用类型扩展其他某个类型,称为其超类。类型继承其超类的字段和方法,然后定义其自己的额外字段和方法。一个名为Object的特殊类作为 Java 类层次结构的根。所有 Java 类直接或间接扩展Object。Object类定义了一些特殊方法,这些方法被所有对象继承(或重写)。
我们前面讨论的预定义String类和Account类都扩展自Object。因此,我们可以说所有String对象也是Object对象。我们还可以说所有Account对象也是Object对象。然而,反之不成立。我们不能说每个Object都是String,因为正如我们刚刚看到的,一些Object对象是Account对象。
通过对类层次结构的简单理解,我们可以定义引用类型转换的规则:
-
一个对象引用不能转换为不相关的类型。例如,即使使用强制类型转换运算符,Java 编译器也不允许你将
String转换为Account。 -
可以将对象引用转换为其超类或任何祖先类的类型。这是一种扩展转换,因此不需要强制类型转换。例如,
String值可以赋给类型为Object的变量,或者传递给期望Object参数的方法。
注意
实际上不会执行任何转换;该对象仅被视为超类的实例。这是 Liskov 替换原则的一个简单形式,以巴巴拉·利斯科夫命名,她首次明确表述了该原则。
-
可以将对象引用转换为子类的类型,但这是一种窄化转换,需要进行强制类型转换。Java 编译器暂时允许这种类型的转换,但 Java 解释器在运行时会检查其是否有效。只有在基于程序逻辑确信对象实际上是子类的实例时,才可以将引用转换为子类的类型。如果不是,则解释器会抛出
ClassCastException异常。例如,如果我们将String引用赋给类型为Object的变量,稍后可以将该变量的值强制转换回String类型:Object o = "string"; // Widening conversion from String // to Object later in the program... String s = (String) o; // Narrowing conversion from Object // to String
数组是对象,并遵循其自己的一些转换规则。首先,通过扩展转换,任何数组都可以转换为Object值。通过强制类型转换,可以将这样的对象值转换回数组。这里是一个例子:
// Widening conversion from array to Object
Object o = new int[] {1,2,3};
// Later in the program...
int[] a = (int[]) o; // Narrowing conversion back to array type
除了将数组转换为对象外,还可以将数组转换为另一种数组类型,如果两个数组的“基本类型”是可以自身转换的引用类型。例如:
// Here is an array of strings.
String[] strings = new String[] { "hi", "there" };
// A widening conversion to CharSequence[] is allowed because String
// can be widened to CharSequence
CharSequence[] sequences = strings;
// The narrowing conversion back to String[] requires a cast.
strings = (String[]) sequences;
// This is an array of arrays of strings
String[][] s = new String[][] { strings };
// It cannot be converted to CharSequence[] because String[] cannot be
// converted to CharSequence: the number of dimensions don't match
sequences = s; // This line will not compile
// s can be converted to Object or Object[], because all array types
// (including String[] and String[][]) can be converted to Object.
Object[] objects = s;
请注意,这些数组转换规则仅适用于对象数组和数组数组。原始类型数组不能转换为任何其他数组类型,即使原始基本类型可以转换:
// Can't convert int[] to double[] even though
// int can be widened to double
// This line causes a compilation error
double[] data = new int[] {1,2,3};
// This line is legal, however,
// because int[] can be converted to Object
Object[] objects = new int[][] {{1,2},{3,4}};
修饰符概要
如我们所见,类、接口及其成员可以使用一个或多个修饰符进行声明,如public、static和final等关键字。让我们通过列出 Java 修饰符来结束这一章节,解释它们可以修饰哪些 Java 构造,并解释它们的作用。详见表格 3-2;你也可以参考“类和记录概述”、“字段声明语法”和“方法修饰符”。
表格 3-2. Java 修饰符
| 修饰符 | 用于 | 含义 |
|---|---|---|
abstract | 类 | 该类不能被实例化,可能包含未实现的方法。 |
| 接口 | 所有接口都是abstract的。在接口声明中,修饰符是可选的。 | |
| 方法 | 方法没有提供方法体;方法体由子类提供。方法签名后跟一个分号。包含该方法的类也必须是abstract的。 | |
default | 方法 | 此接口方法的实现是可选的。接口为那些选择不实现它的类提供了默认实现。详见第四章。 |
final | 类 | 该类不能被子类化。 |
| 方法 | 该方法不能被重写。 | |
| 字段 | 该字段不能更改其值。static final字段是编译时常量。 | |
| 变量 | 局部变量、方法参数或异常参数不能更改其值。 | |
native | 方法 | 该方法以某种平台相关的方式实现(通常为 C 语言)。没有方法体;方法签名后跟一个分号。 |
non-sealed | 类 | 该类从一个密封类型继承,但它本身具有无限制的开放继承。 |
| <无>(包) | 类 | 非public类只能在其包内访问。 |
| 接口 | 非public接口只能在其包内访问。 | |
| 成员 | 非private、protected或public的成员具有包可见性,只能在其包内访问。 | |
private | 成员 | 该成员只能在定义它的类内部访问。 |
protected | 成员 | 该成员只能在定义它的包内和子类中访问。 |
public | 类 | 该类在其包的任何地方都是可访问的。 |
| 接口 | 接口在其包的任何地方都是可访问的。 | |
| 成员 | 该成员在其类所在的任何地方都是可访问的。 | |
sealed | 类 | 该类只能被已知子类列表(由permits子句给出)继承。如果缺少permits子句,则该类只能被同一编译单元内的类继承。 |
static | 类 | 声明为static的内部类是一个顶级类,不与包含类的成员相关联。详见第四章。 |
| 方法 | static方法是类方法。它不会传递隐式的this对象引用。可以通过类名调用它。 | |
| 字段 | static字段是类字段。无论创建了多少类实例,只有一个字段实例。可以通过类名访问它。 | |
| 初始化器 | 初始化器在类加载时运行,而不是在创建实例时运行。 | |
strictfp | 类 | 类的所有方法都隐式地采用strictfp。 |
| 方法 | 方法中进行的所有浮点计算必须严格遵循 IEEE 754 标准。特别是,所有值,包括中间结果,必须表达为 IEEE 的float或double值,不能利用本地平台浮点格式或硬件提供的任何额外精度或范围。这个修饰符极少被使用,在 Java 17 中已经是一个无操作,因为该语言现在总是严格遵循标准。 | |
synchronized | 方法 | 该方法对类或实例进行非原子性修改,因此必须确保两个线程不能同时修改类或实例。对于static方法,在执行方法之前会获取类的锁。对于非static方法,则会获取特定对象实例的锁。详见第五章了解更多细节。 |
transient | 字段 | 此字段不是对象的持久状态的一部分,不应与对象一起序列化。与对象序列化一起使用;请参阅java.io.ObjectOutputStream。 |
volatile | 字段 | 此字段可以被非同步线程访问,因此不能对它进行某些优化。这个修饰符有时可以作为synchronized的替代。详见第五章了解更多细节。 |
摘要
Java,像所有面向对象的语言一样,有其自己的面向对象工作模型。在本章中,我们已经了解了这个模型的基本概念:静态类型、字段、方法、继承、访问控制、封装、重载、覆盖和密封。要成为一名熟练的 Java 程序员,您需要掌握所有这些概念,并理解它们之间的关系以及它们的交互方式。
接下来的两章将进一步探讨这些特性,并理解这些基本面向对象设计的方面如何直接源自这一相对较小的基本概念集合。
¹ 后面我们还会介绍默认的包可见性。
² 在 Java 中,abstract 方法类似于 C++ 中的纯虚函数(即声明为 = 0 的虚函数)。在 C++ 中,包含纯虚函数的类称为抽象类,不能被实例化。Java 中包含 abstract 方法的类也是如此。
第四章:Java 类型系统
在本章中,我们超越了基本的面向对象编程与类,进入了有效使用 Java 类型系统所需的其他概念。
注意
静态类型语言是指变量具有明确的类型,并且将不兼容类型的值分配给变量是编译时错误的语言。仅在运行时检查类型兼容性的语言称为动态类型语言。
Java 是一个典型的静态类型语言的例子。JavaScript 则是一个动态类型语言的例子,允许任何变量存储任何类型的值。
Java 类型系统不仅涉及类和基本类型,还包括与类的基本概念相关的其他种类的引用类型,但它们在某些方面有所不同,并且通常由javac或 JVM 特殊处理。
我们已经见过数组和类,Java 最广泛使用的两种引用类型之一。本章开始讨论另一种非常重要的引用类型——接口。然后我们进入讨论 Java 的泛型,它在 Java 类型系统中扮演重要角色。掌握了这些主题后,我们可以讨论 Java 中编译时和运行时类型的差异。
为了完整展示 Java 参考类型的全貌,我们看看特殊类型的类和接口——被称为枚举和注解。我们在这一章节结束时讨论lambda 表达式和嵌套类型,然后回顾增强类型推断如何使 Java 的非显式类型可供程序员使用。
让我们开始看一下接口——除了类之外 Java 最重要的参考类型之一,也是 Java 类型系统其余部分的关键构建块。
接口
在第三章中,我们介绍了继承的概念。我们也看到 Java 类只能继承自一个类。这对我们想要构建的面向对象程序类型是一个相当大的限制。Java 的设计者们知道这一点,但他们也希望确保 Java 的面向对象编程方法比如 C++更简单且不易出错。
他们选择的解决方案是引入接口的概念到 Java 中。像类一样,接口定义了一个新的引用类型。顾名思义,接口旨在表示 API——因此它提供了一个类型的描述以及实现该 API 的类必须提供的方法(及其签名)的描述。
通常情况下,Java 接口不提供描述的方法的任何实现代码。这些方法被认为是强制性的——希望实现接口的任何类必须提供这些方法的实现。
但是,接口可能希望标记一些 API 方法是可选的,如果选择不实现它们,则实现类不需要实现它们。这是通过default关键字完成的,并且接口必须提供这些可选方法的实现,这将被任何选择不实现它们的实现类使用。
注意
在 Java 8 中引入了接口中可选方法的能力。在任何早期版本中都不可用。请参阅“记录和接口”以获取有关可选(也称为默认)方法如何工作的完整描述。
不可能直接实例化一个接口并创建一个接口类型的成员。相反,类必须实现接口以提供必要的方法体。
实现类的任何实例都与类定义的类型和接口定义的类型兼容。这意味着实例可以在需要类类型或接口类型的任何代码中替换。这扩展了 Liskov 原则,如在“引用类型转换”中所见。
另一种说法是,如果两个对象不共享相同的类或超类,它们仍然可以与相同接口类型兼容,如果两个对象都是实现接口的类的实例的话。
定义接口
接口定义有些类似于类定义,其中所有(必需的)方法都是抽象的,关键字class已被替换为interface。例如,以下代码显示了名为Centered的接口的定义(例如,一个Shape类,比如在第三章中定义的那些,如果想要允许其中心坐标被设置和查询,则可能实现该接口):
interface Centered {
void setCenter(double x, double y);
double getCenterX();
double getCenterY();
}
对接口成员施加了一些限制:
-
所有接口的强制方法都是隐式
abstract的,必须用分号代替方法体。abstract修饰符是允许的,但按照惯例通常省略。 -
接口定义了一个公共 API。按照惯例,接口成员隐式地是
public的,并且通常省略不必要的public修饰符。 -
一个接口不能定义任何实例字段。字段是一个实现细节,而接口是一个规范,不是一个实现。在接口定义中唯一允许的字段是声明为
static和final的常量。 -
一个接口不能被实例化,因此它不定义构造函数。
-
接口可以包含嵌套类型。任何此类类型都隐式地是
public和static的。请参阅“嵌套类型”以获取嵌套类型的完整描述。 -
自 Java 8 起,接口可以包含静态方法。Java 的早期版本不允许这样做,这被广泛认为是 Java 语言设计上的一个缺陷。
-
从 Java 9 开始,接口可以包含
private方法。这些方法的使用案例有限,但是随着接口结构的其他变化,禁止它们似乎是随意的。 -
在接口中尝试定义
protected方法是编译时错误。
扩展接口
接口可以扩展其他接口,并且与类定义类似,接口定义通过包含一个extends子句来指示这一点。当一个接口扩展另一个接口时,它继承其超接口的所有方法和常量,并且可以定义新的方法和常量。然而,不同于类,接口定义的extends子句可以包含多个超接口。例如,以下是一些扩展其他接口的接口:
interface Positionable extends Centered {
void setUpperRightCorner(double x, double y);
double getUpperRightX();
double getUpperRightY();
}
interface Transformable extends Scalable, Translatable, Rotatable {}
interface SuperShape extends Positionable, Transformable {}
如果一个接口扩展了多个接口,则继承每个接口的所有方法和常量,并且可以定义自己的额外方法和常量。实现这种接口的类必须实现直接由接口定义的抽象方法,以及从所有超接口继承的所有抽象方法。
实现一个接口
就像类使用extends指定其超类一样,它可以使用implements来命名一个或多个它支持的接口。implements关键字可以出现在类声明中,在extends子句之后。它应该跟随一个逗号分隔的接口列表,该类实现这些接口。
当一个类在其implements子句中声明一个接口时,它表明它为该接口的每个强制方法提供了一个实现(即一个主体)。如果一个类实现了一个接口但没有为每个强制接口方法提供实现,它会从接口继承这些未实现的abstract方法,并且必须自己声明为abstract。如果一个类实现了多个接口,则必须实现每个接口的每个强制方法(或声明为abstract)。
以下代码显示了如何定义一个CenteredRectangle类,它扩展了第三章中的Rectangle类,并实现我们的Centered接口:
public class CenteredRectangle extends Rectangle implements Centered {
// New instance fields
private double cx, cy;
// A constructor
public CenteredRectangle(double cx, double cy, double w, double h) {
super(w, h);
this.cx = cx;
this.cy = cy;
}
// We inherit all the methods of Rectangle but must
// provide implementations of all the Centered methods.
public void setCenter(double x, double y) { cx = x; cy = y; }
public double getCenterX() { return cx; }
public double getCenterY() { return cy; }
}
假设我们实现了CenteredCircle和CenteredSquare,就像我们实现了这个CenteredRectangle类一样。每个类都扩展了Shape,所以类的实例可以被视为Shape类的实例,正如我们之前看到的那样。因为每个类实现了Centered接口,实例也可以被视为该类型的实例。以下代码演示了对象如何可以是类类型和接口类型的成员:
Shape[] shapes = new Shape[3]; // Create an array to hold shapes
// Create some centered shapes, and store them in the Shape[]
// No cast necessary: these are all compatible assignments
shapes[0] = new CenteredCircle(1.0, 1.0, 1.0);
shapes[1] = new CenteredSquare(2.5, 2, 3);
shapes[2] = new CenteredRectangle(2.3, 4.5, 3, 4);
// Compute average area of the shapes and
// average distance from the origin
double totalArea = 0;
double totalDistance = 0;
for(int i = 0; i < shapes.length; i = i + 1) {
totalArea += shapes[i].area(); // Compute the area of the shapes
// Be careful, in general, the use of instanceof to determine the
// runtime type of an object is quite often an indication of a
// problem with the design
if (shapes[i] instanceof Centered) { // The shape is a Centered shape
// Note the required cast from Shape to Centered (no cast would
// be required to go from CenteredSquare to Centered, however).
Centered c = (Centered) shapes[i];
double cx = c.getCenterX(); // Get coordinates of the center
double cy = c.getCenterY(); // Compute distance from origin
totalDistance += Math.sqrt(cx*cx + cy*cy);
}
}
System.out.println("Average area: " + totalArea/shapes.length);
System.out.println("Average distance: " + totalDistance/shapes.length);
注意
接口是 Java 中的数据类型,就像类一样。当一个类实现一个接口时,该类的实例可以赋值给接口类型的变量。
不要解释此示例为必须将CenteredRectangle对象分配给Centered变量,然后才能调用setCenter()方法或者分配给Shape变量然后再调用area()方法。相反,因为CenteredRectangle类定义了setCenter()方法并从其Rectangle超类继承了area()方法,所以你总是可以调用这些方法。
正如我们可以通过查看字节码(例如,使用javap工具我们将在第十三章遇到)所见,JVM 根据持有形状的局部变量类型是CenteredRectangle还是Centered而稍有不同地调用setCenter()方法,但这在大多数情况下在编写 Java 代码时并不重要。
记录和接口
记录(records)作为类的一种特例,可以像任何其他类一样实现接口。记录的主体必须包含接口所有强制方法的实现代码,并且可以包含接口任意默认方法的覆盖实现。
让我们看一个例子,应用于我们在上一章中遇到的Point记录。给定一个定义如下的接口:
interface Translatable {
Translatable deltaX(double dx);
Translatable deltaY(double dy);
Translatable delta(double dx, double dy);
}
那么我们可以像这样更新Point类型:
public record Point(double x, double y) implements Translatable {
public Translatable deltaX(double dx) {
return delta(dx, 0.0);
}
public Translatable deltaY(double dy) {
return delta(0.0, dy);
}
public Translatable delta(double dx, double dy) {
return new Point(x + dx, y + dy);
}
}
注意,因为记录是不可变的,所以不可能在原地修改实例,因此,如果我们需要一个修改过的对象,我们必须显式地创建一个并返回它。这意味着并非每个接口都适合由记录类型实现。
密封接口
我们在上一章中遇到了sealed关键字,应用于类。它也可以应用于接口,如下所示:
sealed interface Rotate90 permits Circle, Rectangle {
void clockwise();
void antiClockwise();
}
这个密封接口表示一个形状可以旋转 90 度的能力。注意声明中还包含一个permits子句,指定允许实现这个接口的唯一类——在这种情况下,只有Circle和Rectangle类,以简化问题。Circle被修改如下:
public final class Circle extends Shape implements Rotate90 {
// ...
@Override
public void clockwise() {
// No-op, circles are rotation-invariant
}
@Override
public void antiClockwise() {
// No-op, circles are rotation-invariant
}
// ...
}
而Rectangle已被修改如下:
public final class Rectangle extends Shape implements Rotate90 {
// ...
@Override
public void clockwise() {
// Swap width and height
double tmp = w;
w = h;
h = tmp;
}
@Override
public void antiClockwise() {
// Swap width and height
double tmp = w;
w = h;
h = tmp;
}
// ...
}
目前为止,我们不希望处理其他形状具有旋转行为的复杂性,因此我们限制接口只能由两种最简单的情况实现:圆和矩形。
密封接口与记录之间还有一个有趣的互动,我们将在第五章讨论。
默认方法
从 Java 8 开始,可以在接口中声明包含实现的方法。在本节中,我们将讨论这些方法,应该将它们理解为接口所代表的 API 中的可选方法——通常称为默认方法。让我们首先看看为什么我们需要首先的默认机制。
向后兼容性
Java 平台一直非常关注向后兼容性。这意味着为早期版本的平台编写(甚至编译)的代码必须继续在后续版本的平台上运行。这一原则使得开发团队对其 JDK 或 Java 运行时环境(JRE)的升级具有高度信心,不会破坏当前正常运行的应用程序。
向后兼容性是 Java 平台的一大优势,但为了实现它,平台对其施加了一些限制。其中之一是接口不能在新版本中添加新的强制性方法。
例如,假设我们想要更新Positionable接口以添加底部左下角边界点的能力:
public interface Positionable extends Centered {
void setUpperRightCorner(double x, double y);
double getUpperRightX();
double getUpperRightY();
void setLowerLeftCorner(double x, double y);
double getLowerLeftX();
double getLowerLeftY();
}
通过这个新的定义,如果我们试图将这个新接口与为旧接口开发的代码一起使用,那就不会起作用,因为现有代码缺少强制性方法setLowerLeftCorner()、getLowerLeftX()和getLowerLeftY()。
注意
您可以很容易地在自己的代码中看到这种效果。编译一个依赖于接口的类文件。然后向接口添加一个新的强制性方法,并尝试使用新版本的接口与旧类文件一起运行程序。您应该会看到程序因为NoClassDefError而崩溃。
这个限制是 Java 8 设计者的一个关注点——因为他们的目标之一是能够升级核心 Java 集合库并引入使用 lambda 表达式的方法。
要解决这个问题,需要一个新的机制,基本上允许接口通过添加新方法来演变,而不会破坏向后兼容性。
实现默认方法
在不破坏向后兼容性的情况下向接口添加新方法,需要为接口的旧实现提供一些实现,以便它们可以继续工作。这个机制就是default方法,在 JDK 8 中首次添加到平台中。
注意
可以向任何接口添加默认方法(有时称为可选方法)。这必须包括一个内联的实现,称为默认实现,它写在接口定义中。
默认方法的基本行为是:
-
实现类可以(但不需要)实现默认方法。
-
如果实现类实现了默认方法,则使用类中的实现。
-
如果找不到其他实现,则使用默认实现。
一个例子是sort()方法。它已经在 JDK 8 中被添加到接口java.util.List中,并且定义如下:
// The <E> syntax is Java's way of writing a generic type - see
// the next section for full details. If you aren't familiar with
// generics, just ignore that syntax for now.
interface List<E> {
// Other members omitted
public default void sort(Comparator<? super E> c) {
Collections.<E>sort(this, c);
}
}
因此,从 Java 8 开始,任何实现List的对象都有一个sort()实例方法,可用于使用适当的Comparator对列表进行排序。由于返回类型是void,我们可能期望这是一种原地排序,事实也是如此。
默认方法的一个结果是,在实现多个接口时,可能有两个或更多接口包含具有完全相同名称和签名的默认方法。
例如:
interface Vocal {
default void call() {
System.out.println("Hello!");
}
}
interface Caller {
default void call() {
Switchboard.placeCall(this);
}
}
public class Person implements Vocal, Caller {
// ... which default is used?
}
这两个接口对call()的默认语义有很大的不同,并且可能导致潜在的实现冲突——冲突的默认方法。在 Java 8 之前的版本中,这种情况是不可能发生的,因为语言只允许单一实现继承。引入默认方法意味着 Java 现在允许一种有限的多继承形式(但仅限于方法实现)。Java 仍然不允许(也没有计划添加)对象状态的多重继承。
提示
在一些其他语言中,特别是 C++,这个问题被称为菱形继承。
默认方法有一组简单的规则,以帮助解决任何潜在的歧义:
-
如果一个类以导致默认方法实现潜在冲突的方式实现了多个接口,则实现类必须重写冲突方法并提供所需的定义。
-
提供了语法,允许实现类简单地调用接口的默认方法之一,如果需要的话:
public class Person implements Vocal, Caller {
public void call() {
// Can do our own thing
// or delegate to either interface
// e.g.,
// Vocal.super.call();
// or
// Caller.super.call();
}
}
由于默认方法的设计,存在一个轻微但无法避免的使用问题,可能在演化中的接口出现方法冲突时出现。考虑一个字节码版本为 51.0(Java 7)的类实现了两个接口A和B,它们的版本号分别为a.0和b.0。由于 Java 7 中没有默认方法,这个类将正常工作。然而,如果稍后其中一个或两个接口采用了冲突方法的默认实现,则可能会发生编译时断裂。
例如,如果版本a.1在A中引入了一个默认方法,那么当使用新版本的依赖运行时,实现类将采用这个实现。如果版本b.1现在也引入了相同的方法,就会造成冲突:
-
如果
B将方法引入为强制性的(即抽象的)方法,则实现类将继续工作——无论是在编译时还是在运行时。 -
如果
B将方法引入为默认方法,则这是不安全的,实现类将在编译时和运行时均失败。
这个小问题很大程度上是一个边界情况,在实践中支付的代价很小,以便在语言中拥有可用的默认方法。
在使用默认方法时,我们应该意识到我们可以在默认方法内部执行的操作集合有一定的限制:
-
调用接口公共 API 中的另一个方法(无论是强制性还是可选的);此类方法的某些实现是可用的。
-
在接口上调用私有方法(Java 9 及以上)。
-
调用静态方法,无论是在接口上还是在其他地方定义的。
-
使用
this引用(例如,作为方法调用的参数)。
这些限制的最大教训是,即使有了默认方法,Java 接口仍然缺乏有意义的状态;我们不能在接口内部修改或存储状态。
默认方法对 Java 实践者处理面向对象编程的方式产生了深远影响。与 lambda 表达式的兴起相结合,它们颠覆了许多以前的 Java 编码约定;我们将在下一章中详细讨论这一点。
标记接口
有时候定义一个完全空的接口是很有用的。一个类可以通过在其 implements 子句中简单地命名该接口来实现它,而无需实现任何方法。在这种情况下,该类的任何实例也将成为该接口的有效实例,并且可以将其强制类型转换为该类型。Java 代码可以使用 instanceof 运算符检查对象是否是接口的实例,因此这种技术是提供关于对象的附加信息的有用方式。它可以被看作是为类提供额外的辅助类型信息。
提示
标记接口的使用远不如以前广泛。由于注解(我们将很快见到)在传递扩展类型信息时具有更大的灵活性,它们已经大多取代了标记接口。
接口 java.util.RandomAccess 就是一个标记接口的示例:java.util.List 实现使用这个接口来表明它们提供对列表元素的快速随机访问。例如,ArrayList 实现了 RandomAccess,而 LinkedList 则没有。关心随机访问操作性能的算法可以像这样测试 RandomAccess:
// Before sorting the elements of a long arbitrary list, we may want
// to make sure that the list allows fast random access. If not,
// it may be quicker to make a random-access copy of the list before
// sorting it. Note that this is not necessary when using
// java.util.Collections.sort().
List l = ...; // Some arbitrary list we're given
if (l.size() > 2 && !(l instanceof RandomAccess)) {
l = new ArrayList(l);
}
sortListInPlace(l);
正如我们稍后将看到的,Java 的类型系统与类型名称紧密耦合,这被称为 命名类型 的方法。标记接口就是一个很好的例子:除了名称外,它什么都没有。
Java 泛型
Java 平台的一个显著优势是其提供的标准库。它提供了大量有用的功能,特别是常见数据结构的健壮实现。这些实现相对简单易用,并且有很好的文档支持。这些库被称为 Java 集合框架,我们将在 第八章 中详细讨论它们。如需更全面的信息,请参阅 Maurice Naftalin 和 Philip Wadler 的书 Java Generics and Collections(O’Reilly)。
尽管最早期的集合版本仍然非常有用,但它们存在一个相当重要的限制:数据结构(有时称为 容器)基本上会隐藏在其中存储的数据类型。
注意
数据隐藏和封装是面向对象编程的重要原则,但在这种情况下,容器的不透明性给开发者带来了许多问题。
让我们通过展示问题并展示泛型类型的引入是如何解决它并使 Java 开发人员的生活变得更加轻松的。
泛型介绍
如果我们想要构建一个Shape实例的集合,我们可以使用List来持有它们,就像这样:
List shapes = new ArrayList(); // Create a List to hold shapes
// Create some centered shapes, and store them in the list
shapes.add(new CenteredCircle(1.0, 1.0, 1.0));
// This is legal Java-but is a very bad design choice
shapes.add(new CenteredSquare(2.5, 2, 3));
// List::get() returns Object, so to get back a
// CenteredCircle we must cast
CenteredCircle c = (CenteredCircle)shapes.get(0);
// Next line causes a runtime failure
CenteredCircle c = (CenteredCircle)shapes.get(1);
这段代码的一个问题源于需要执行强制类型转换以获得可用形式的形状对象——List不知道它包含的对象类型。不仅如此,而且实际上可以将不同类型的对象放入同一个容器中,一切工作正常,直到使用非法强制转换并导致程序崩溃。
我们真正想要的是一种形式的List,它能理解它包含的类型。然后,javac可以在将非法参数传递给List的方法时检测到并导致编译错误,而不是推迟到运行时处理。
注意
所有元素类型相同的集合称为同类,而可能包含不同类型元素的集合称为异类(有时称为“神秘肉集合”)。
Java 提供了一个简单的语法来适应同类集合。要指示一个类型是一个容器,它持有另一个引用类型的实例,我们将容器持有的有效载荷类型括在尖括号内:
// Create a List-of-CenteredCircle
List<CenteredCircle> shapes = new ArrayList<CenteredCircle>();
// Create some centered shapes, and store them in the list
shapes.add(new CenteredCircle(1.0, 1.0, 1.0));
// Next line will cause a compilation error
shapes.add(new CenteredSquare(2.5, 2, 3));
// List<CenteredCircle>::get() returns a CenteredCircle, no cast needed
CenteredCircle c = shapes.get(0);
这种语法确保了编译器在运行时之前能够捕获大类不安全的代码。这当然是静态类型系统的整体目标——利用编译时的知识尽可能地帮助消除运行时问题。
结果类型结合了一个封装的容器类型和一个有效载荷类型,通常称为泛型类型,并且它们声明如下:
interface Box<T> {
void box(T t);
T unbox();
}
这表明Box接口是一个通用的构造,可以容纳任何类型的有效载荷。它本身并不是一个完整的接口——它更像是一个整个接口家族的通用描述,每个接口都可以用T的类型替代。
泛型类型和类型参数
我们已经看到如何使用泛型类型通过利用编译时知识来提供增强的程序安全性,以防止简单的类型错误。在这一节中,让我们更深入地探讨泛型类型的属性。
<T>这种语法有一个特殊的名称,类型参数,另一个泛型类型的名称是参数化类型。这应该传达出容器类型(例如,List)由另一种类型(有效载荷类型)参数化的意义。当我们写一个类型像Map<String, Integer>时,我们正在为类型参数指定具体的值。
当我们定义具有参数的类型时,需要以不假设类型参数的方式进行。因此,List 类型以泛型方式声明为 List<E>,而类型参数 E 在整个过程中都作为占位符,用于当程序员使用 List 数据结构时使用的实际类型的载荷。
提示
类型参数总是代表引用类型。不可能使用原始类型作为类型参数的值。
类型参数可以像真实类型一样在方法的签名和主体中使用,例如:
interface List<E> extends Collection<E> {
boolean add(E e);
E get(int index);
// other methods omitted
}
注意类型参数 E 如何用作返回类型和方法参数的参数。我们不假设载荷类型具有任何特定属性,只做一致性的基本假设——我们放入的类型是后来取出的相同类型。
这种增强实际上引入了一种新类型到 Java 的类型系统中。通过将容器类型与类型参数的值组合,我们正在创建新类型。
Diamond 语法
当我们创建泛型类型的实例时,赋值语句的右侧重复了类型参数的值。通常情况下这是不必要的,因为编译器可以推断出类型参数的值。在现代版本的 Java 中,我们可以在所谓的 diamond 语法 中省略重复的类型值。
让我们通过重新编写我们早期的一个例子来看如何使用 diamond 语法:
// Create a List-of-CenteredCircle using diamond syntax
List<CenteredCircle> shapes = new ArrayList<>();
这是赋值语句冗长性的小幅改进——我们设法节省了一些键入字符。在本章稍后讨论 Lambda 表达式时,我们将返回类型推断的话题。
类型擦除
在 “默认方法” 中,我们讨论了 Java 平台对向后兼容性的强烈偏好。Java 5 中引入泛型就是向新语言特性的向后兼容性的另一个例子。
中心问题是如何设计一个类型系统,允许旧的非泛型集合类与新的泛型集合类并存。设计决策是通过使用强制类型转换来实现这一点:
List someThings = getSomeThings();
// Unsafe cast, but we know that the
// contents of someThings are really strings
List<String> myStrings = (List<String>)someThings;
这意味着 List 和 List<String> 作为类型是兼容的,至少在某种程度上是这样。Java 通过 类型擦除 实现了这种兼容性。这意味着泛型类型参数只在编译时可见——它们被 javac 剥离并不反映在字节码中。¹
警告
非泛型类型 List 通常被称为 原始类型。对于现在是泛型的类型来说,使用原始形式仍然是完全合法的 Java。然而,这几乎总是质量较差代码的标志。
类型擦除机制导致 javac 和 JVM 看到的类型系统存在差异——我们将在“编译和运行时类型”中全面讨论这一点。
类型擦除还禁止了一些其他本来看起来合法的定义。在这段代码中,我们想要计算两种稍有不同的数据结构中表示的订单:
// Won't compile
interface OrderCounter {
// Name maps to list of order numbers
int totalOrders(Map<String, List<String>> orders);
// Name maps to total orders made so far
int totalOrders(Map<String, Integer> orders);
}
这段代码看起来像是完全合法的 Java 代码,但它将无法编译。问题在于,尽管这两个方法看起来像是普通的重载方法,但在类型擦除后,两个方法的签名变成了相同的:
int totalOrders(Map);
类型擦除后,容器的原始类型仅剩下 Map。运行时无法通过签名区分这些方法,因此语言规范将此语法视为非法。
有界类型参数
考虑一个简单的泛型盒子:
public class Box<T> {
protected T value;
public void box(T t) {
value = t;
}
public T unbox() {
T t = value;
value = null;
return t;
}
}
这是一个有用的抽象,但假设我们想要一个只能容纳数字的限制形式的盒子。Java 允许我们通过对类型参数设置 边界 来实现这一点。这是限制可以用作类型参数值的类型的能力,例如:
public class NumberBox<T extends Number> extends Box<T> {
public int intValue() {
return value.intValue();
}
}
类型边界 T extends Number 确保 T 只能被兼容于 Number 类型的类型所替代。因此,编译器知道 value 必定有一个可用的 intValue() 方法。
注意
请注意,由于 value 字段具有受保护的访问权限,在子类中可以直接访问它。
如果我们试图用类型参数的无效值实例化 NumberBox,结果将是编译错误:
NumberBox<Integer> ni = new NumberBox<>(); // This compiles fine
NumberBox<Object> no = new NumberBox<>(); // Won't compile
初学者应尽量避免使用原始类型。即使是有经验的 Java 程序员在使用时也可能遇到问题。例如,在使用原始类型处理类型边界时,类型边界可能会被规避,但这样做会使代码容易受到运行时异常的影响:
// Compiles
NumberBox n = new NumberBox();
// This is very dangerous
n.box(new Object());
// Runtime error
System.out.println(n.intValue());
调用 intValue() 失败,并抛出 java.lang.ClassCastException —— 因为在调用方法之前,javac 已经对 value 插入了一个无条件的强制类型转换到 Number。
通常情况下,类型边界可用于编写更好的泛型代码和库。通过实践,一些相当复杂的结构可以被构建,例如:
public class ComparingBox<T extends Comparable<T>> extends Box<T>
implements Comparable<ComparingBox<T>> {
@Override
public int compareTo(ComparingBox<T> o) {
if (value == null)
return o.value == null ? 0 : -1;
return value.compareTo(o.value);
}
}
这个定义可能看起来令人生畏,但 ComparingBox 实际上只是包含一个 Comparable 值的 Box。该类型还通过比较两个盒子的内容,扩展了对 ComparingBox 类型本身的比较操作。
引入协变性
Java 泛型的设计包含了一个古老问题的解决方案。在 Java 的早期版本中,甚至在引入集合库之前,语言就不得不面对一个深层次的类型系统设计问题。
简单来说,问题是这样的:
字符串数组是否应与类型为对象数组的变量兼容?
换句话说,这段代码应该合法吗?
String[] words = {"Hello World!"};
Object[] objects = words;
如果没有这一点,那么甚至像 Arrays::sort 这样的简单方法都将非常难以以预期的方式编写:
Arrays.sort(Object[] a);
方法声明仅适用于类型为 Object[] 而不适用于任何其他数组类型。由于这些复杂性的结果,Java 语言标准的第一个版本确定了以下结论:
如果类型
C的值可以分配给类型P的变量,则类型C[]的值可以分配给类型P[]的变量。
也就是说,数组的赋值语法 随其所持有的基本类型变化,或者说数组是 协变的。
这个设计决定相当不幸,因为它导致了立即的负面后果:
String[] words = {"Hello", "World!"};
Object[] objects = words;
// Oh, dear, runtime error
objects[0] = new Integer(42);
对 objects[0] 的赋值企图将 Integer 存储到期望保存 String 的存储空间中。这显然是行不通的,并将抛出 ArrayStoreException。
警告
协变数组的实用性导致它们在平台的早期阶段被视为一种必要之恶,尽管这种功能暴露了静态类型系统中的漏洞。
然而,对现代开源代码库的更多研究表明,数组协变极少被使用,且是语言的误功能。² 写新代码时应避免使用它。
在考虑 Java 平台上泛型行为时,可以提出一个非常相似的问题:“List<String> 是否是 List<Object> 的子类型?”也就是说,我们可以这样写:
// Is this legal?
List<Object> objects = new ArrayList<String>();
乍一看,这似乎是完全合理的——String 是 Object 的子类,因此我们知道集合中的任何 String 元素也是有效的 Object。
然而,请考虑以下代码(只是将数组协变代码转换为使用 List):
// Is this legal?
List<Object> objects = new ArrayList<String>();
// What do we do about this?
objects.add(new Object());
由于 objects 的类型声明为 List<Object>,因此将 Object 实例添加到其中应该是合法的。然而,由于实际实例持有字符串,尝试添加 Object 将不兼容,因此在运行时会失败。
这将与数组的情况没有任何变化,因此解决方案是意识到虽然这是合法的:
Object o = new String("X");
这并不意味着泛型容器类型的相应语句也是正确的,因此:
// Won't compile
List<Object> objects = new ArrayList<String>();
另一种说法是,List<String> 不是 List<Object> 的子类型,或者泛型类型是 不变的,而不是 协变的。在讨论有界通配符时,我们将详细说明这一点。
通配符
例如 ArrayList<T> 这样的参数化类型是不 可实例化 的;我们无法创建它们的实例。这是因为 <T> 只是一个类型参数,仅仅是一个真实类型的占位符。只有当我们为类型参数提供一个具体值(例如 ArrayList<String>)时,类型才变得完全形成,我们才能创建该类型的对象。
如果我们希望在编译时不知道要使用的类型,则会出现问题。幸运的是,Java 类型系统能够容纳这一概念。它通过具有显式概念的未知类型来实现。这表示为<?>。这是 Java 的通配符类型的最简单示例。
我们可以编写涉及未知类型的表达式:
ArrayList<?> mysteryList = unknownList();
Object o = mysteryList.get(0);
这是完全有效的 Java 代码:ArrayList<?>是一个变量可以拥有的完整类型,不像ArrayList<T>。我们不知道mysteryList的载荷类型的任何信息,但这对我们的代码可能并非问题。
例如,当我们从mysteryList中获取一个项时,它具有完全未知的类型。但是,我们可以确保该对象可以赋值给Object,因为泛型类型参数的所有有效值都是引用类型,而所有引用值都可以赋给类型为Object的变量。
另一方面,当我们使用未知类型时,它在用户代码中有一些使用限制。例如,以下代码将无法编译:
// Won't compile
mysteryList.add(new Object());
这样做的原因很简单:我们不知道mysteryList的载荷类型是什么!例如,如果mysteryList实际上是ArrayList<String>的实例,那么我们不希望能够将Object放入其中。
我们知道我们始终可以将null插入到容器中,因为我们知道null是任何引用类型的可能值。这并不是很有用,因此,Java 语言规范还排除了使用未知类型作为载荷来实例化容器对象的可能性,例如:
// Won't compile
List<?> unknowns = new ArrayList<?>();
未知类型可能看起来用处不大,但它的一个非常重要的用途是作为解决协变问题的起点。如果我们想要为容器使用子类型关系,我们可以使用未知类型,例如:
// Perfectly legal
List<?> objects = new ArrayList<String>();
这意味着List<String>实际上是List<?>的子类型 — 虽然当我们使用像前面这样的赋值时,我们会丢失一些类型信息。例如,objects.get()的返回类型现在实际上是Object。
注意
对于类型参数T的任何值,List<?>不是类型List<T>的子类型。
未知类型有时会使开发人员感到困惑,引发类似以下问题:“为什么不只使用Object而不是未知类型?”然而,正如我们所见,需要在泛型类型之间具有子类型关系,这实质上要求我们具有未知类型的概念。
有界通配符
实际上,Java 的通配符类型不仅限于未知类型,还有有界通配符的概念。
这些用于描述大部分未知类型的继承层次结构 —— 有效地使类似“我不知道这种类型的任何信息,但它必须实现List”的语句成立。
在类型参数中,这将被写成? extends List。这为程序员提供了一个有用的生命线。不再局限于完全未知的类型,他们知道至少类型边界的功能是可用的。
警告
不管约束类型是类类型还是接口类型,都始终使用extends关键字。
这是一个被称为类型变异的概念的示例,它是关于容器类型之间继承如何与它们的载荷类型之间的继承关系相关的一般理论。
类型协变性
这意味着容器类型之间的关系与载荷类型的关系相同。这是用extends关键字来表达的。
类型逆变性
这意味着容器类型之间的关系与载荷类型的关系相反。这是用super关键字来表达的。
当讨论容器类型时,这些想法往往会出现。例如,如果Cat扩展Pet,那么List<Cat>是List<? extends Pet>的子类型,因此:
List<Cat> cats = new ArrayList<Cat>();
List<? extends Pet> pets = cats;
然而,这与数组情况不同,因为类型安全性是以以下方式维护的:
pets.add(new Cat()); // won't compile
pets.add(new Pet()); // won't compile
cats.add(new Cat());
编译器不能证明由pets指向的存储能够存储Cat,因此拒绝调用add()。然而,由于cats明确指向一个Cat对象列表,因此将新对象添加到列表中是可以接受的。
因此,非常普遍地看到这些类型的通用构造与作为载荷类型的生产者或消费者的类型一起使用。
例如,当List充当Pet对象的生产者时,适当的关键字是extends。
Pet p = pets.get(0);
请注意,对于生产者情况,载荷类型出现为生产者方法的返回类型。
对于作为某种类型实例的消费者的容器类型,我们将使用super关键字,并且我们期望在方法参数的类型中看到载荷类型。
请注意
这在由 Joshua Bloch 提出的生产者扩展,消费者超级(PECS)原则中得到了具体表述。
正如我们将在第八章中讨论的那样,协变性和逆变性都出现在 Java 集合中。它们主要存在是为了确保通用性“做正确的事情”,并且表现出不会让开发人员感到惊讶的方式。
通用方法
通用方法是能够接受任何引用类型实例的方法。
让我们看一个例子。在 Java 中,逗号用于允许在单行中进行多个声明(通常称为复合声明)。其他语言,如 Javascript 或 C,具有更一般的逗号运算符。JS 的逗号运算符 (,) 会评估其提供的两个表达式(从左到右),并返回最后一个表达式的值。其目的是创建一个复合表达式,在这个表达式中,多个表达式被评估,而复合表达式的值是其成员表达式的最右边的值。请注意,与短路逻辑运算符不同,逗号评估表达式的任何副作用总是会触发。
Java 的逗号比设计时更为严格。这是因为其他语言中的逗号可能导致一些非常难以理解的代码,并且可能是错误的一个极好的来源。然而,如果我们确实想要模仿其他语言中逗号运算符的行为,我们可以通过创建一个泛型方法来实现:
// Note that this class is not generic
public class Utils {
public static <T> T comma(T a, T b) {
return b;
}
}
调用 Utils.comma() 方法将导致计算表达式 a 和 b 的值,并在方法调用之前触发任何副作用,这是我们想要的行为。
然而,需要注意的是,即使在方法的定义中使用了类型参数,其定义所在的类(Utils)也不是泛型的。相反,我们看到使用了新的语法来指示可以自由使用该方法,并且返回类型与参数类型相同。
让我们再看一个例子,来自 Java 集合库。在 ArrayList 类中,我们可以找到一个方法,用于从 ArrayList 实例创建一个新的数组对象:
@SuppressWarnings("unchecked")
public <T> T[] toArray(T[] a) {
if (a.length < size)
// Make a new array of a's runtime type, but my contents:
return (T[]) Arrays.copyOf(elementData, size, a.getClass());
System.arraycopy(elementData, 0, a, 0, size);
if (a.length > size)
a[size] = null;
return a;
}
这个方法使用低级的 arraycopy() 方法来执行实际的工作。
注意
如果我们查看 ArrayList 的类定义,我们可以看到它是一个泛型类,但类型参数是 <E>,而不是 <T>,而且类型参数 <E> 在 toArray() 的定义中根本不出现。
toArray() 方法提供了集合与 Java 原始数组之间桥接 API 的一半。API 的另一半——从数组到集合的转换——涉及一些额外的细微差别,我们将在第八章中讨论。
编译和运行时类型
考虑一个代码示例:
List<String> l = new ArrayList<>();
System.out.println(l);
我们可以提出以下问题:l 的类型是什么?这个问题的答案取决于我们是在编译时(即 javac 看到的类型)还是在运行时(作为 JVM 看到的类型)考虑 l。
javac 将会将 l 的类型视为 List-of-String,并使用该类型信息来仔细检查语法错误,比如尝试对非法类型进行 add() 操作。
JVM 将会把 l 视为 ArrayList 类型的对象,正如我们可以从 println() 语句看到的那样。由于类型擦除,l 的运行时类型是原始类型。
因此,编译时和运行时类型略有不同。稍微奇怪的是,在某些方面,运行时类型既比编译时类型更具体,也比编译时类型更不具体。
运行时类型比编译时类型更不具体,因为有关有效载荷类型的类型信息已经消失 —— 它已经被擦除,并且产生的运行时类型只是一个原始类型。
编译时类型比运行时类型更不具体,因为我们不知道 l 将具体是什么类型;我们只知道它将是与 List 兼容的类型。
编译时和运行时类型的差异有时会让新手 Java 程序员感到困惑,但这种区别很快会被视为语言工作中的正常部分。
使用和设计泛型类型
在使用 Java 泛型时,按照两种不同的理解层次进行思考可能会有所帮助:
实践者
从实践者的角度来看,需要使用现有的通用库并构建一些相当简单的通用类。在这个层次上,开发人员还应该理解类型擦除的基础知识,因为几个 Java 语法特性如果没有对泛型运行时处理的意识,可能会感到困惑。
设计师
使用泛型的新库的设计者需要更多地了解泛型的能力。规范中还包括一些更难理解的部分,包括对通配符的完全理解,以及高级主题,例如“捕获”错误消息。
Java 泛型是语言规范中最复杂的部分之一,具有许多潜在的边界情况。并非每个开发人员在首次接触 Java 类型系统的时候都需要完全理解这部分内容。
枚举和注解
我们已经见过记录(records),但 Java 还有额外的专用类和接口形式,用于在类型系统中扮演特定角色。它们被称为枚举类型和注解类型,通常简称为枚举和注解。
枚举
枚举是类的一种变体,具有有限的功能和特定的语义意义,即该类型仅具有少量可能的允许值。
例如,假设我们想定义一个类型来表示红、绿和蓝的主要颜色,并且我们希望这些是该类型的唯一可能值。我们可以使用 enum 关键字来实现:
public enum PrimaryColor {
// The ; is not required at the end of the list of instances
RED, GREEN, BLUE
}
然后,类型 PrimaryColor 的唯一可用实例可以作为静态字段进行引用:PrimaryColor.RED、PrimaryColor.GREEN 和 PrimaryColor.BLUE。
注
在其他语言(如 C++)中,通过使用常量整数来实现枚举类型的角色,但 Java 的方法提供了更好的类型安全性和更大的灵活性。
由于枚举是专门的类,因此枚举可以具有成员字段和方法。如果它们具有主体(由字段或方法组成),则需要在实例列表的末尾使用分号,并且枚举常量列表必须在方法和字段之前。
例如,假设我们想要一个枚举来包含标准扑克牌的花色。我们可以通过使用一个带有参数值的枚举来实现这一点,像这样:
public enum Suit {
// ; at the end of list required for enums with parameters
HEART('♥'),
CLUB('♣'),
DIAMOND('♦'),
SPADE('♠');
private char symbol;
private char letter;
public char getSymbol() {
return symbol;
}
public char getLetter() {
return letter;
}
private Suit(char symbol) {
this.symbol = symbol;
this.letter = switch (symbol) {
case '♥' -> 'H';
case '♣' -> 'C';
case '♦' -> 'D';
case '♠' -> 'S';
default -> throw new RuntimeException("Illegal:" + symbol);
};
}
}
参数(在此示例中仅有一个)被传递给构造函数以创建单个枚举实例。由于枚举实例由 Java 运行时创建,并且不能从外部实例化,因此构造函数被声明为私有。
枚举具有一些特殊属性:
-
所有(隐式地)扩展
java.lang.Enum -
可能不是泛型的
-
可能实现接口
-
不能被扩展
-
如果所有枚举值提供实现主体,则可能只有抽象方法。
-
可能不能直接通过
new实例化
注释
注释是一种特殊的接口,顾名思义,用于注释 Java 程序的某些部分。
例如,考虑@Override注释。您可能在一些早期的示例中的一些方法上看到了它,并且可能提出了以下问题:它是做什么的?
简短的,也许令人惊讶的答案是它根本不起作用。
简短的答案是,与所有注释一样,它没有直接影响,而是作为有关所注释的方法的附加信息;在这种情况下,它表示一个方法覆盖了超类方法。
这对编译器和集成开发环境(IDE)来说是一个有用的提示——如果开发人员拼错了一个意图作为超类方法的覆盖的方法的名称,那么在拼错的方法上存在@Override注释(它不覆盖任何内容)会提示编译器有些地方不对。
注释,如最初的构思,不应改变程序语义;相反,它们应该提供可选的元数据。在最严格的意义上,这意味着它们不应影响程序执行,而应该只为编译器和其他执行前阶段提供信息。
在实践中,现代 Java 应用程序广泛使用注释,现在包括许多使用情况,实际上使带注释的类在没有额外运行时支持的情况下无法使用。
例如,带有诸如@Inject、@Test或@Autowired之类的注释的类在适当的容器之外实际上不能使用。因此,很难说此类注释不违反“没有语义意义”规则。
平台在java.lang中定义了一小部分基本注释。最初的集合是@Override、@Deprecated和@SuppressWarnings,它们用于指示方法已被覆盖、已过时或生成了一些应该被抑制的编译器警告。
Java 7 中通过 @SafeVarargs 扩展了这些(为可变参数方法提供了扩展警告抑制),Java 8 中通过 @FunctionalInterface 进行了扩展。
这个最后的注解表明一个接口可以作为 lambda 表达式的目标使用 — 虽然不是强制的,我们会看到它是一个有用的标记注解。
注解与常规接口相比具有一些特殊的属性:
-
所有(隐式)扩展
java.lang.annotation.Annotation -
不得是泛型的
-
不得扩展任何其他接口
-
只能定义零参数方法
-
不得定义抛出异常的方法
-
对方法的返回类型有限制
-
方法可以有默认返回值
在实践中,注解通常没有太多功能,而是一个相当简单的语言概念。
定义自定义注解
为了在自己的代码中使用定义的自定义注解类型并不困难。@interface 关键字允许开发人员定义新的注解类型,与使用 class 或 interface 类似。
注意
自定义注解的关键在于使用“元注解”。这些特殊的注解出现在新(自定义)注解类型的定义中。
元注解定义在 java.lang.annotation 中,并允许开发人员指定新注解类型的使用策略,以及编译器和运行时的处理方式。
创建新注解类型时需要两个主要的元注解 — @Target 和 @Retention,它们都接受枚举表示的值。
@Target 元注解指示新的自定义注解可以在 Java 源代码中合法放置的位置。枚举 ElementType 包含可能的取值 TYPE, FIELD, METHOD, PARAMETER, CONSTRUCTOR, LOCAL_VARIABLE, ANNOTATION_TYPE, PACKAGE, TYPE_PARAMETER, 和 TYPE_USE,注解可以指示它们意图在一个或多个位置使用。
另一个元注解是 @Retention,它指示 javac 和 Java 运行时如何处理自定义注解类型。它可以有三个值,由枚举 RetentionPolicy 表示:
SOURCE
具有此保留策略的注解在编译时由 javac 丢弃。
CLASS
这意味着注解将出现在类文件中,但不一定可以通过 JVM 在运行时访问。这很少使用,但有时在对 JVM 字节码进行离线分析的工具中可见。
RUNTIME
这表明注解将可供用户代码在运行时访问(通过反射)。
让我们看一个例子,一个简单的注解称为 @Nickname,允许开发人员为方法定义一个昵称,然后可以在运行时通过反射找到该方法:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Nickname {
String[] value() default {};
}
定义注解所需的全部内容只是一个语法元素,用于注解可以出现的位置,保留策略和元素的名称。由于我们需要能够提供方法的昵称,我们还需要在注解上定义一个方法。尽管如此,定义新的自定义注解是一项非常紧凑的工作。
除了两个主要的元注解外,还有@Inherited和@Documented元注解。这两个在实践中遇到的频率要低得多,详细信息可以在平台文档中找到。
类型注解
随着 Java 8 的发布,ElementType新增了两个新的取值:TYPE_PARAMETER和TYPE_USE。这些新的取值允许在以前不合法的地方使用注解,比如任何类型使用的地方。这使得开发人员可以编写如下代码:
@NotNull String safeString = getMyString();
@NotNull传递的额外类型信息可以被特殊类型检查器用于检测问题(例如可能的NullPointerException),并执行额外的静态分析。基本的 Java 8 发行版附带了一些基本的可插拔类型检查器,但它也提供了一个框架,允许开发人员和库作者创建他们自己的类型检查器。
在本节中,我们已经接触了 Java 的枚举和注解类型。让我们继续考虑 Java 类型系统的下一个重要部分:lambda 表达式。
Lambda 表达式
Java 8 最令人期待的功能之一是引入了 Lambda 表达式(通常简称为 lambda)。
这次 Java 平台的重大升级由五个目标驱动,大致按优先级降序排列:
-
更有表现力的编程
-
更好的库
-
简洁的代码
-
提升的编程安全性
-
潜在的增加数据并行性
Lambda 具有三个关键方面,帮助定义该特性的基本特性:
-
它们允许在程序中将小段代码以字面量形式内联编写。
-
它们通过使用类型推断放宽了 Java 代码的严格语法。
-
它们促进了更加功能化的 Java 编程风格。
正如我们在第二章中看到的,lambda 表达式的语法是取参数列表(其类型通常是推断出来的),并将其附加到方法体,就像这样:
(p, q) -> { /* method body */ }
这可以提供一种非常紧凑的方式来表示实际上是单一方法的内容。这也是与早期版本的 Java 的一个重大变化 —— 目前为止,我们总是需要一个类声明,然后是一个完整的方法声明,所有这些都增加了代码的冗长。
事实上,在 lambda 出现之前,模拟这种编码风格的唯一方法是使用匿名类,我们将在本章后面讨论。然而,自 Java 8 以来,lambda 表达式在 Java 程序员中非常受欢迎,并且现在大多数情况下已经取代了匿名类的角色。
注意
尽管 Lambda 表达式与匿名类之间有相似之处,但 Lambda 并不仅仅是匿名类的语法糖。事实上,Lambda 使用方法句柄(我们将在 第十一章 中遇到)和一个名为 invokedynamic 的特殊 JVM 字节码实现。
Lambda 表达式代表创建特定类型的对象。创建的实例类型称为 Lambda 的 目标类型。
只有特定类型才能作为 Lambda 的目标。
目标类型也称为 功能接口,它们必须:
-
必须是接口
-
只能有一个非默认方法(但可以有其他默认方法)
一些开发人员也喜欢使用 单一抽象方法(或 SAM)类型来指代 Lambda 转换的接口类型。这突出了一个事实,即要能够使用 Lambda 表达式机制,接口必须只有一个非默认方法。
注意
Lambda 表达式几乎具备方法的所有组成部分,唯一的异常是 Lambda 没有名称。事实上,许多开发人员喜欢将 Lambda 视为“匿名方法”。
因此,这意味着单行代码:
Runnable r = () -> System.out.println("Hello");
并不执行 println(),而是创建一个对象,该对象赋值给变量 r,类型为 Runnable。这个对象 r 将执行 println() 语句,但只有在调用 r.run() 时才执行,而不是立即执行。
Lambda 表达式转换
当 javac 遇到 Lambda 表达式时,它将其解释为具有特定签名的方法体——但是哪个方法?
要解决这个问题,javac 查看周围的代码。为了合法的 Java 代码,Lambda 表达式必须满足以下属性:
-
Lambda 必须出现在期望接口类型的实例位置上。
-
预期的接口类型应该有且仅有一个强制方法。
-
预期的接口方法应该具有与 Lambda 表达式完全匹配的签名。
如果是这样,那么将创建一个实现预期接口并将 Lambda 体作为强制方法实现的类型的实例。
这种稍微复杂的转换方法源于希望保持 Java 的类型系统纯粹 名义(基于名称)。Lambda 表达式被称为 转换 成正确接口类型的实例。
从这个讨论中,我们可以看到,虽然 Java 8 添加了 Lambda 表达式,但它们被专门设计为适应 Java 现有的类型系统——这个系统非常强调名义类型(而不是其他一些编程语言中可能存在的类型)。
让我们考虑 lambda 转换的一个例子——java.io.File类的list()方法。此方法列出目录中的文件。在返回列表之前,它会将每个文件的名称传递给程序员必须提供的FilenameFilter对象。这个FilenameFilter对象接受或拒绝每个文件,是java.io包中定义的 SAM 类型之一。
@FunctionalInterface
public interface FilenameFilter {
boolean accept(File dir, String name);
}
类型FilenameFilter携带了@FunctionalInterface注解,以指示它是一个适合作为 lambda 目标类型的合适类型。然而,此注解并非必需,任何符合要求的类型(通过是接口且为 SAM 类型)都可以用作目标类型。
这是因为在 Java 8 发布之前,JDK 和现有的 Java 代码库已经拥有大量的 SAM 类型。要求潜在的目标类型携带注解会阻止将 lambda 适配到现有代码中,但并没有真正的好处。
提示
在您编写的代码中,您应该始终尝试指示您的类型可用作目标类型,这可以通过为它们添加@FunctionalInterface来实现。这有助于提高可读性,并且可以帮助一些自动化工具。
下面是如何定义一个FilenameFilter类,以仅列出那些文件名以*.java*结尾的文件,使用 lambda:
File dir = new File("/src"); // The directory to list
String[] filelist = dir.list((d, fName) -> fName.endsWith(".java"));
对于列表中的每个文件,将评估 lambda 表达式中的代码块。如果方法返回true(如果文件名以*.java*结尾),则该文件将包含在输出中,最终存储在数组filelist中。
这种模式,其中一个代码块用于测试容器中的元素是否满足条件,并且仅返回通过条件的元素,被称为过滤习语。这是函数式编程的标准技术之一,我们将很快更深入地讨论它。
方法引用
请回忆,我们可以将 lambda 表达式视为代表没有名称的方法的对象。现在,请考虑这个 lambda 表达式:
// In real code this would probably be
// shorter because of type inference
(MyObject myObj) -> myObj.toString()
这将自动转换为实现@FunctionalInterface类型的实现,该类型具有一个非默认方法,接受一个MyObject并返回一个String,具体来说,是通过在MyObject实例上调用toString()获取的字符串。然而,这似乎是过度样板代码,因此 Java 8 提供了一种语法以使其更易于阅读和编写:
MyObject::toString
这种简写称为方法引用,它使用现有方法作为 lambda 表达式。方法引用语法与作为 lambda 表达式表示的先前形式完全等效。可以将其视为使用现有方法但忽略方法名称,因此它可以用作 lambda,然后以通常的方式自动转换。Java 定义了四种方法引用类型,这等效于四种略有不同的 lambda 表达式形式(见 Table 4-1)。
表格 4-1. 方法引用
| 名称 | 方法引用 | 等效的 lambda |
|---|---|---|
| 未绑定 | Trade::getPrice | trade -> trade.getPrice() |
| 绑定 | System.out::println | s -> System.out.println(s) |
| 静态 | System::getProperty | key -> System.getProperty(key) |
| 构造函数 | Trade::new | price -> new Trade(price) |
我们最初引入的形式可以看作是一个 未绑定的方法引用。当我们使用未绑定的方法引用时,它等同于一个期望包含方法引用的类型实例的 lambda 表达式—在 Table 4-1 中,这是一个 Trade 对象。
它被称为未绑定的方法引用,因为接收对象需要在使用方法引用时提供(作为 lambda 的第一个参数)。也就是说,我们将在某个 Trade 对象上调用 getPrice(),但方法引用的提供者尚未定义具体是哪一个。这由引用的使用者决定。
相比之下,绑定的方法引用 总是将接收者作为方法引用的实例化的一部分。在 Table 4-1 中,接收者是 System.out,因此在使用引用时,println() 方法将始终在 System.out 上调用,并且 lambda 的所有参数都将作为 println() 方法的参数使用。
我们将在下一章节更详细地讨论方法引用与 lambda 表达式的使用场景。
函数式编程
Java 从根本上来说是一种面向对象的语言。然而,随着 lambda 表达式的到来,编写接近函数式编程风格的代码变得更加容易。
注意
没有一个确切的定义可以说明什么是 函数式语言 —— 但至少有共识认为,它应该至少包含将函数表示为可以放入变量中的值的能力。
自从版本 1.1 以来,Java 一直能够通过内部类来表示函数(参见下一节),但语法复杂且缺乏清晰度。Lambda 表达式极大地简化了这种语法,因此很自然地,更多的开发人员将寻求在其 Java 代码中使用函数式编程的方面。
Java 开发人员可能会遇到的第一次函数式编程尝试是三种基本习语,这些习语非常实用:
map()
映射习语通常与列表和类列表容器一起使用。其思想是传入一个应用于集合中每个元素的函数,并创建一个由将该函数应用于每个元素的结果组成的新集合。这意味着映射习语可以将一个类型的集合转换为可能是不同类型的新集合。
filter()
当我们讨论如何用 lambda 替换FilenameFilter的匿名实现时,我们已经见过 filter 惯用语的一个例子。该 filter 惯用语用于基于某些选择条件生成集合的新子集。请注意,在函数式编程中,通常生成新集合而不是就地修改现有集合是正常的。
reduce()
reduce 惯用语有几种不同的形式。它是一个聚合操作,也可以称为fold、accumulate或aggregate,以及 reduce。其基本思想是使用初始值和聚合(或缩减)函数,逐个应用缩减函数于每个元素,通过一系列中间结果(类似于“运行总计”)构建整个集合的最终结果,当 reduce 操作遍历集合时。
Java 具有对这些关键函数惯用语(和其他几种)的全面支持。具体实现在第八章中有详细解释,我们在那里讨论了 Java 的数据结构和集合,特别是stream抽象,这使得所有这些都成为可能。
让我们在这个介绍中总结一些警告。值得注意的是,Java 最好被视为对“稍微函数式编程”的支持。它不是特别函数式的语言,也没有尝试成为一个。Java 的一些特定方面反对它成为函数式语言的任何主张,包括:
-
Java 没有结构类型,这意味着没有“真正”的函数类型。每个 lambda 都会自动转换为相应的目标类型。
-
类型擦除对函数式编程造成问题——对于高阶函数,类型安全可能会丢失。
-
Java 本质上是可变的(正如我们将在第六章中讨论的那样)—对于函数式语言来说,可变性通常被认为是非常不可取的。
-
Java 集合是命令式的,而不是函数式的。集合必须转换为流才能使用函数式风格。
尽管如此,易于访问函数式编程的基础知识——特别是 map、filter 和 reduce 等惯用语——对 Java 社区来说是一大步向前的。这些惯用语非常有用,以至于大多数 Java 开发人员永远不需要或错过具有更彻底函数式血统的语言提供的更高级功能。
事实上,许多这些技术使用嵌套类型是可能的(请参阅下一节的详细信息),通过诸如回调和处理程序之类的模式,但是语法总是相当繁琐的,特别是在你需要仅表达单行代码的回调时,你必须显式定义一个全新的类型。
词法作用域和局部变量
局部变量在定义其作用域的代码块内部定义,在该作用域之外,无法访问局部变量并且停止存在。只有在定义块边界的花括号内的代码可以使用该块中定义的局部变量。这种作用域被称为词法作用域,它只定义了可以使用变量的源代码部分。
程序员通常将这样的作用域视为临时,即将局部变量视为从 JVM 开始执行块到控制退出块的时间存在。这通常是一种合理的局部变量及其作用域的思考方式。然而,lambda 表达式(以及稍后将遇到的匿名和本地类)有能力弯曲或打破这种直觉。
这可能导致一些开发人员最初感到惊讶的效果。因为 lambda 可以使用局部变量,它们可以包含来自不存在的词法范围的值的副本。这可以在以下代码中看到:
public interface IntHolder {
public int getValue();
}
public class Weird {
public static void main(String[] args) {
IntHolder[] holders = new IntHolder[10];
for (int i = 0; i < 10; i++) {
final int fi = i;
holders[i] = () -> {
return fi;
};
}
// The lambda is now out of scope, but we have 10 valid instances
// of the class the lambda has been converted to in our array.
// The local variable fi is not in our scope here, but is still
// in scope for the getValue() method of each of those 10 objects.
// So call getValue() for each object and print it out.
// This prints the digits 0 to 9.
for (int i = 0; i < 10; i++) {
System.out.println(holders[i].getValue());
}
}
}
每个 lambda 实例都有一个自动创建的私有副本每个使用的最终局部变量,因此实际上它有其自己的私有副本在创建时存在的作用域。这有时被称为captured变量。
捕获变量这样的 lambda 称为closures,而这些变量被称为closed over。
警告
其他编程语言对闭包的定义可能略有不同。事实上,一些理论家会质疑 Java 的机制是否算得上闭包,因为技术上来说,被捕获的是变量的内容(一个值),而不是变量本身。
实际上,前述闭包示例比实际需要的更冗长,有两种不同的方式:
-
Lambda 有一个明确的作用域
{}和return语句。 -
变量
fi明确声明为final。
编译器javac帮助处理这两种情况。
只返回单个表达式值的 lambda 不需要包括作用域或者return;相反,lambda 的主体只是表达式,不需要花括号。在我们的示例中,我们明确地包含了花括号和return语句,以阐明 lambda 正在定义其自身的作用域。
在 Java 早期版本中,关闭变量时有两个严格的要求:
-
在捕获后,被捕获的变量不能被修改(例如,在 lambda 之后)。
-
被捕获的变量必须声明为
final。
然而,在最近的 Java 版本中,javac可以分析代码并检测程序员是否尝试在 lambda 的范围之后修改捕获的变量。如果没有,则可以省略对捕获变量的final修饰符(这样的变量被称为effectively final)。如果省略了final修饰符,则试图在 lambda 范围之后修改捕获变量将导致编译时错误。
这是因为 Java 通过将变量内容的位模式复制到闭包创建的范围来实现闭包。对于封闭变量内容的进一步更改不会反映在闭包范围中的副本中,因此设计决策是使这些更改非法并在编译时错误。
这些来自javac的辅助功能意味着我们可以将前面示例的内部循环重写为非常紧凑的形式:
for (int i = 0; i < 10; i++) {
int fi = i;
holders[i] = () -> fi;
}
闭包在某些编程风格中非常有用,不同的编程语言以不同的方式定义和实现闭包。Java 将闭包实现为 lambda 表达式,但本地类和匿名类也可以捕获状态——实际上这是 Java 在 lambda 可用之前实现闭包的方式。
嵌套类型
在本书中到目前为止所见的类、接口和枚举类型都被定义为顶级类型。这意味着它们是包的直接成员,独立于其他类型之外定义。然而,类型定义也可以嵌套在其他类型定义之内。这些嵌套类型,通常被称为“内部类”,是 Java 语言的一个强大特性。
一般来说,嵌套类型用于两个不同的目的,都与封装相关。首先,类型可能被嵌套,因为它需要特别亲密地访问另一个类型的内部。作为嵌套类型,它以与成员变量和方法相同的方式访问。这意味着嵌套类型具有特权访问权限,可以被视为“略微违反封装规则”。
对于嵌套类型的此用例的另一种思考方式是,它们是与另一个类型紧密联系的类型。这意味着它们实际上并没有完全独立的实体存在,只是共存。
或者,类型可能仅仅是因为一个非常特定的原因在代码的一个非常小的部分中需要。这意味着它应该被紧密地局部化,因为它实际上是实现细节的一部分。
在较早版本的 Java 中,这样做的唯一方法是使用嵌套类型,例如接口的匿名实现。实际上,随着 Java 8 的出现,这种用例已经大大被 lambda 表达式取代。将匿名类型作为紧密局部化类型的使用在某些情况下仍然存在,但显著下降。
类型可以以四种不同的方式嵌套在另一个类型中:
静态成员类型
静态成员类型是定义为另一个类型的static成员的任何类型。嵌套接口、枚举和注解始终是静态的(即使您没有使用关键字)。
非静态成员类
“非静态成员类型”简单地是不声明为static的成员类型。只有类可以是非静态成员类型。
本地类
本地类是在 Java 代码块内定义并且仅在其中可见的类。接口、枚举和注解不能在本地定义。
匿名类
匿名类是一种没有对人类有意义的有意义名称的本地类;它仅仅是编译器分配的任意名称,程序员不应直接使用。接口、枚举和注解不能匿名定义。
“嵌套类型”这个术语虽然准确和精确,但并不被开发人员广泛使用。相反,大多数 Java 程序员使用更模糊的术语“内部类”。根据情况,这可能指非静态成员类、本地类或匿名类,但不是静态成员类型,没有真正的区分方法。
幸运的是,尽管描述嵌套类型的术语并不总是清晰,但与其一起工作的语法通常是明显的,通常可以从上下文中看出正在讨论哪种类型的嵌套类型。
注意
直到 Java 11,嵌套类型是使用编译器技巧实现的,大部分是语法糖。有经验的 Java 程序员应注意,这个细节在 Java 11 中发生了变化,不再像过去那样实现。
让我们继续详细描述四种嵌套类型中的每一种。每个部分描述了嵌套类型的特点、其使用的限制以及与该类型一起使用的任何特殊 Java 语法。
静态成员类型
静态成员类型与常规顶层类型非常相似。然而,为了方便起见,它嵌套在另一个类型的命名空间内。静态成员类型具有以下基本属性:
-
静态成员类型与类的其他静态成员(如静态字段和静态方法)类似。
-
静态成员类型与包含类的任何实例无关(即没有
this对象)。 -
静态成员类型可以(仅)访问包含它的类的
static成员。 -
静态成员类型可以访问其包含类型的所有
static成员(包括任何其他静态成员类型)。 -
嵌套接口、枚举和注解无论
static关键字是否出现,都隐式为静态。 -
任何嵌套在接口或注解中的类型也隐式为
static。 -
静态成员类型可以在顶层类型内定义,也可以在其他静态成员类型内的任何深度嵌套。
-
静态成员类型不能在任何其他类型的嵌套类型内定义。
让我们快速看一下静态成员类型的语法示例。示例 4-1 展示了一个帮助接口作为包含接口的静态成员定义的示例,本例中为 Java 的Map。
示例 4-1. 定义并使用静态成员接口
public interface Map<K, V> {
// ...
Set<Map.Entry<K, V>> entrySet();
// All nested interfaces are automatically static
interface Entry<K, V> {
K getKey();
V getValue();
V setValue(V value);
// other members elided
}
// other members elided
}
当被外部类使用时,Entry将通过其层次名称Map.Entry来引用。
静态成员类型的特点
静态成员类型可以访问其包含类型的所有静态成员,包括private成员。反之亦然:包含类型的方法可以访问静态成员类型的所有成员,包括这些类型的private成员。静态成员类型甚至可以访问任何其他静态成员类型的所有成员,包括这些类型的private成员。静态成员类型可以使用任何其他静态成员,而无需使用包含类型的名称限定其名称。
顶层类型可以声明为public或包私有(如果它们没有使用public关键字声明)。但将顶层类型声明为private和protected并没有太大意义——protected只是意味着与包私有相同,而private顶层类无法被任何其他类型访问。
另一方面,静态成员类型是成员,因此可以使用与包含类型的其他成员相同的任何访问控制修饰符。对于静态成员类型,这些修饰符的含义与类型的其他成员相同。
在大多数情况下,类名使用Outer.Inner语法可以很好地提醒内部类与其包含类型的互联性。但是,Java 语言允许您使用import指令直接导入静态成员类型:
import java.util.Map.Entry;
然后,您可以引用嵌套类型,而无需包含其封闭类型的名称(例如,只需像Entry这样)。
注意
您还可以使用import static指令导入静态成员类型。有关import和import static的详细信息,请参见第二章中的“包和 Java 命名空间”。
然而,导入嵌套类型会掩盖该类型与其包含类型紧密关联的事实——这通常是重要信息,并因此不常见。
非静态成员类
非静态成员类是一种声明为包含类或枚举类型的成员的类,没有static关键字:
-
如果静态成员类型类比于类字段或类方法,那么非静态成员类类比于实例字段或实例方法。
-
只有类可以是非静态成员类型。
-
非静态成员类的实例始终与封闭类型的实例相关联。
-
非静态成员类的代码可以访问其封闭类型的所有字段和方法(包括
static和非static)。 -
Java 语法具有几个特定功能,专门用于处理非静态成员类的封闭实例。
示例 4-2 展示了如何定义和使用成员类。这个例子展示了一个LinkedStack的例子:它定义了一个嵌套接口,描述了堆栈底层的链表节点,并且定义了一个嵌套类来允许对堆栈上的元素进行枚举。成员类定义了java.util.Iterator接口的一个实现。
示例 4-2. 作为成员类实现的迭代器
import java.util.Iterator;
public class LinkedStack {
// Our static member interface
public interface Linkable {
public Linkable getNext();
public void setNext(Linkable node);
}
// The head of the list
private Linkable head;
// Method bodies omitted here
public void push(Linkable node) { ... }
public Linkable pop() { ... }
// This method returns an Iterator object for this LinkedStack
public Iterator<Linkable> iterator() { return new LinkedIterator(); }
// Here is the implementation of the Iterator interface,
// defined as a nonstatic member class.
protected class LinkedIterator implements Iterator<Linkable> {
Linkable current;
// The constructor uses a private field of the containing class
public LinkedIterator() { current = head; }
// The following three methods are defined
// by the Iterator interface
public boolean hasNext() { return current != null; }
public Linkable next() {
if (current == null)
throw new java.util.NoSuchElementException();
Linkable value = current;
current = current.getNext();
return value;
}
public void remove() { throw new UnsupportedOperationException(); }
}
}
注意LinkedIterator类如何嵌套在LinkedStack类内部。LinkedIterator是一个仅在LinkedStack内部使用的辅助类,因此在包含类使用它的地方定义它可以产生清晰的设计。
成员类的特性
像实例字段和实例方法一样,每个非静态成员类的实例都与其定义的包含类的实例关联。这意味着成员类的代码可以访问包含实例的所有实例字段和实例方法(以及static成员),包括任何声明为private的成员。
这一关键特性已经在示例 4-2 中进行了演示。这里再次展示了LinkedStack.LinkedIterator()构造函数:
public LinkedIterator() { current = head; }
这行代码将内部类的current字段设置为包含类的head字段的值。尽管在包含类中,head声明为private字段,代码如所示仍然可以工作。
非静态成员类,像类的任何成员一样,可以被分配标准访问控制修饰符之一。在示例 4-2 中,LinkedIterator类声明为protected,因此对使用LinkedStack类的代码(在不同包中)是不可访问的,但对任何子类化LinkedStack的类是可访问的。
成员类有两个重要的限制:
-
非静态成员类不能与任何包含类或包具有相同的名称。这是一个重要的规则,不同于字段和方法。
-
非静态成员类不能包含任何
static字段、方法或类型,除了被声明为static和final的常量字段。
成员类的语法
成员类最重要的特性是它可以访问其包含对象的实例字段和方法。
如果我们想要使用显式引用,并使用this,那么我们必须使用一种特殊的语法来显式地引用this对象的包含实例。例如,如果我们在构造函数中想要显式地表示,我们可以使用以下语法:
public LinkedIterator() { this.current = LinkedStack.this.head; }
通用语法是*classname.this,其中classname*是包含类的名称。请注意,成员类本身可以包含成员类,嵌套到任意深度。
然而,没有任何成员类可以与任何包含类具有相同的名称,因此,在this之前加上包含类名称是引用任何包含实例的一种完全通用的方式。换句话说,EnclosingClass.this的语法构造是引用包含实例的一种明确方式,称为上级引用。
本地类
本地类是在 Java 代码块内部声明的类,而不是类的成员。只有类可以在本地定义:接口、枚举类型和注释类型必须是顶级或静态成员类型。通常,本地类在方法内部定义,但也可以在类的静态初始化器或实例初始化器内定义。
正如所有 Java 代码块都出现在类定义内部一样,所有本地类都嵌套在包含块内部。因此,尽管本地类与成员类共享许多特性,通常更合适的是将它们视为一种完全不同的嵌套类型。
注意
详见第 5 章,了解何时适合选择本地类而不是 lambda 表达式。
本地类的定义特点是它仅在代码块的范围内有效。示例 4-3 演示了如何修改LinkedStack类的iterator()方法,使其将LinkedIterator定义为本地类而不是成员类。
这样做可以将类的定义更接近其使用位置,从而进一步提高代码的清晰度。为简洁起见,示例 4-3 仅显示了iterator()方法,而不是包含它的整个LinkedStack类。
示例 4-3. 定义和使用本地类
// This method returns an Iterator object for this LinkedStack
public Iterator<Linkable> iterator() {
// Here's the definition of LinkedIterator as a local class
class LinkedIterator implements Iterator<Linkable> {
Linkable current;
// The constructor uses a private field of the containing class
public LinkedIterator() { current = head; }
// The following three methods are defined
// by the Iterator interface
public boolean hasNext() { return current != null; }
public Linkable next() {
if (current == null)
throw new java.util.NoSuchElementException();
Linkable value = current;
current = current.getNext();
return value;
}
public void remove() { throw new UnsupportedOperationException(); }
}
// Create and return an instance of the class we just defined
return new LinkedIterator();
}
本地类的特性
本地类具有以下有趣的特性:
-
像成员类一样,本地类与包含实例关联,并且可以访问包含类的任何成员,包括
private成员。 -
除了访问包含类定义的字段外,本地类还可以访问任何本地方法定义作用域内的局部变量、方法参数或异常参数,并且这些变量必须声明为
final。
本地类受以下限制:
-
本地类的名称仅在定义它的块内部有效;它永远不能在该块外部使用。(注意,但是,在类的范围内创建的本地类的实例可以继续存在于该范围之外。本节稍后将详细描述这种情况。)
-
本地类不能声明为
public、protected、private或static。 -
与成员类一样,由于同样的原因,局部类不能包含
static字段、方法或类。唯一的例外是同时声明为static和final的常量。 -
接口、枚举类型和注解类型不能在局部定义。
-
与成员类一样,局部类也不能与其封闭类的任何名称相同。
-
正如前面提到的,局部类可以关闭作用域内的局部变量、方法参数甚至异常参数,但前提是这些变量或参数是有效地
final。
局部类的作用域
在讨论非静态成员类时,我们看到成员类可以访问从超类继承的任何成员以及由其包含的类定义的任何成员。
对于局部类也是如此,但局部类还可以像 Lambda 一样访问有效的final局部变量和参数。示例 4-4 展示了局部类(或 Lambda)可以访问的不同类型的字段和变量。
示例 4-4. 局部类可访问的字段和变量
class A { protected char a = 'a'; }
class B { protected char b = 'b'; }
public class C extends A {
private char c = 'c'; // Private fields visible to local class
public static char d = 'd';
public void createLocalObject(final char e)
{
final char f = 'f';
int i = 0; // i not final; not usable by local class
class Local extends B
{
char g = 'g';
public void printVars()
{
// All of these fields and variables are accessible to this class
System.out.println(g); // (this.g) g is a field of this class
System.out.println(f); // f is a final local variable
System.out.println(e); // e is a final local parameter
System.out.println(d); // (C.this.d) d field of containing class
System.out.println(c); // (C.this.c) c field of containing class
System.out.println(b); // b is inherited by this class
System.out.println(a); // a is inherited by the containing class
}
}
Local l = new Local(); // Create an instance of the local class
l.printVars(); // and call its printVars() method.
}
}
因此,局部类具有相当复杂的作用域结构。要了解原因,请注意,局部类的实例的生命周期可以延伸到 JVM 退出定义局部类的块之后。
注意
换句话说,如果您创建了局部类的实例,那么当 JVM 完成定义类的块的执行时,该实例不会自动消失。因此,即使类的定义是局部的,该类的实例也可以逃离其定义的位置。
因此,局部类在许多方面的行为类似于 Lambda,尽管局部类的用例比 Lambda 更通用。然而,在实践中,很少需要额外的通用性,并且尽可能使用 Lambda。
匿名类
匿名类是一种没有名称的局部类。它在一个表达式中使用new运算符进行定义和实例化。虽然局部类定义是 Java 代码块中的语句,但匿名类定义是一个表达式,这意味着它可以作为较大表达式的一部分,例如方法调用。
注意
为了完整起见,我们在这里涵盖了匿名类,但对于大多数用例,Lambda 表达式(参见“Lambda 表达式”)已经取代了匿名类。
请参考示例 4-5,它展示了LinkedIterator类作为LinkedStack类的iterator()方法内的匿名类实现。与示例 4-4 进行比较,它展示了相同的类作为局部类实现。
示例 4-5. 使用匿名类实现的枚举
public Iterator<Linkable> iterator() {
// The anonymous class is defined as part of the return statement
return new Iterator<Linkable>() {
Linkable current;
// Replace constructor with an instance initializer
{ current = head; }
// The following three methods are defined
// by the Iterator interface
public boolean hasNext() { return current != null; }
public Linkable next() {
if (current == null)
throw new java.util.NoSuchElementException();
Linkable value = current;
current = current.getNext();
return value;
}
public void remove() { throw new UnsupportedOperationException(); }
}; // Note the required semicolon. It terminates the return statement
}
如您所见,定义匿名类并创建该类的实例的语法使用 new 关键字,后跟类型名称和用大括号括起的类体定义。如果 new 关键字后面的名称是类的名称,则匿名类是指定类的子类。如果 new 后面的名称指定了一个接口,就像前两个示例中一样,匿名类实现该接口并扩展 Object。
注意
匿名类的语法特意不包括任何指定 extends 子句、implements 子句或类名的方式。
因为匿名类没有名称,所以不可能在类体中为其定义构造函数。这是匿名类的基本限制之一。在匿名类定义中紧随超类名称后的括号内指定的任何参数都会隐式传递给超类构造函数。匿名类通常用于子类化不需要任何构造函数参数的简单类,因此匿名类定义语法中的括号经常是空的。
因为匿名类只是一种局部类的类型,匿名类和局部类共享相同的限制。匿名类不能定义任何 static 字段、方法或类,除了 static final 常量。接口、枚举类型和注解类型不能匿名定义。此外,像局部类一样,匿名类不能是 public、private、protected 或 static。
定义匿名类的语法将定义与实例化结合在一起,类似于 lambda 表达式。如果每次执行包含块时需要创建多个类的实例,则不适合使用匿名类而应使用局部类。
描述 Java 类型系统
到目前为止,我们已经涵盖了 Java 类型系统的所有主要方面,因此我们可以对其进行描述和表征。
Java 类型系统的最重要和显而易见的特征是它是:
-
静态
-
不是单根
-
名义上
静态类型是三个方面中最广为人知的,意味着在 Java 中,每个数据存储(如变量、字段等)都有一个类型,并且该类型在首次引入存储时声明。尝试将不兼容的值放入不支持的存储中会导致编译时错误。
Java 的类型系统不是单根的也是立即显而易见的。Java 有原始类型和引用类型。Java 中的每个对象都属于一个类,除了 Object 外,每个类都有一个单一的父类。这意味着任何 Java 程序中的类集合形成一个以 Object 为根的树结构。
然而,任何原始类型和Object之间都没有继承关系。因此,Java 类的整体图形由大量的引用类型树和八个不相交的孤立点(对应于原始类型)组成。这导致需要使用包装类型,如Integer,在必要时将原始值表示为对象(例如在 Java 集合中)。
最后一个方面,则需要更详细的讨论。
名义类型
在 Java 中,每种类型都有一个名称。在 Java 编程的正常过程中,这将是一个简单的字母(有时是数字)串,具有反映类型用途的语义意义。这种方法被称为名义类型。
并非所有语言都具有纯粹的名义类型;例如,一些语言可以表达“此类型具有特定签名方法”的概念,而无需显式引用类型名称,有时被称为结构类型。
例如,在 Python 中,您可以对定义了__len__()方法的任何对象调用len()。当然,Python 是一种动态类型语言,如果无法进行len()调用,则会引发运行时异常。但是,在静态类型语言中也可以表达类似的概念,例如 Scala。
Java 另一方面,没有办法在不使用接口的情况下表达这个想法,这当然有一个名字。Java 也严格基于继承和实现来维护类型兼容性。让我们看一个例子:
@FunctionalInterface
public interface MyRunnable {
void run();
}
接口MyRunnable有一个单一方法,与Runnable完全匹配。然而,这两个接口彼此没有继承或其他关系,所以像这样的代码:
MyRunnable myR = () -> System.out.println("Hello");
Runnable r = (Runnable)myR;
r.run();
将会编译成功,但在运行时会失败并抛出ClassCastException。事实上,即使两个接口上存在具有相同签名的run()方法,编译器也不会考虑,实际上程序根本没有执行到调用run()的地步:它失败在前一行,即尝试进行类型转换的地方。
另一个重要的点是 Java 的整个 lambda 表达式构建,特别是将目标类型定型为函数接口,是为了确保 lambda 能够适应名义类型方法。例如,考虑这样一个接口:
@FunctionalInterface
public interface MyIntProvider {
int run() throws InterruptedException;
}
然后,可以在多种不同的情况下使用一个产生常量的 lambda 表达式,例如() -> 42:
MyIntProvider prov = () -> 42;
Supplier<Integer> sup = () -> 42;
Callable<Integer> callMe = () -> 42;
从这里我们可以看到,单独的表达式() -> 42是不完整的。Java lambda 表达式依赖于类型推断,因此我们需要看到表达式与其目标类型结合在一起才有意义。与目标类型组合时,lambda 的类类型是“一个在编译时未知的目标接口的实现”,程序员必须将接口类型作为 lambda 的类型使用。
除了 lambda 之外,在 Java 中还有一些名义类型的边缘情况。一个例子是匿名类,但即使在这里,类型仍然有名称。然而,匿名类型的类型名称是由编译器自动生成的,并且专门选择以便 JVM 可以使用但 Java 源代码编译器不接受。
还有另一种边缘情况需要考虑,它与近期 Java 版本引入的增强类型推断有关。
非标记类型和var
从 Java 11 开始(实际上是在 Java 10 非 LTS 版本中引入),Java 开发人员可以利用新的语言特性局部变量类型推断(LVTI),又称var。这是 Java 类型推断能力的增强,可能比一开始看起来更重要。在最简单的情况下,它允许如下代码:
var ls = new ArrayList<String>();
将推断从值的类型移到变量的类型。
实现方法是将var作为保留的类型名称而不是关键字。这意味着代码仍然可以将var用作变量、方法或包名,而不受新语法的影响。然而,先前将var用作类型名称的代码将需要重新编译。
这个简单的案例旨在减少冗长,并使从其他语言(特别是 Scala、.NET 和 JavaScript)转到 Java 的程序员感觉更舒适。然而,过度使用可能会模糊编写代码的意图,因此应该谨慎使用。
除了简单的情况外,var实际上允许了以前不可能的编程构造。为了看到差异,让我们考虑javac一直允许的一种非常有限的类型推断:
public class Test {
public static void main(String[] args) {
(new Object() {
public void bar() {
System.out.println("bar!");
}
}).bar();
}
}
代码将编译并运行,打印出bar!。这种略显反直觉的结果发生是因为javac保留了关于匿名类的足够类型信息(即它有一个bar()方法),以至于编译器可以推断调用bar()是有效的。
实际上,这种边缘情况自 2009 年以来就在Java 社区中已知,早在 Java 7 到来之前。
这种类型推断的问题在于它没有真正的实际应用:“带有 bar 方法的对象”的类型存在于编译器中,但是这种类型无法表达为变量的类型——它不是一个可标记的类型。这意味着在 Java 10 之前,这种类型的存在仅限于单个表达式,不能在更大的范围内使用。
然而,随着 LVTI 的到来,变量的类型并不总是需要显式指定。相反,我们可以使用var来允许我们通过避免指定类型来保留静态类型信息。
这意味着现在我们可以修改我们的示例并编写:
var o = new Object() {
public void bar() {
System.out.println("bar!");
}
};
o.bar();
这使得我们能够在单个表达式之外保留o的真实类型。o的类型不能被指定,因此它不能作为方法参数或返回类型的类型出现。这意味着类型仍然仅限于单个方法,但仍然可以用于表达某些在其他情况下会很尴尬或不可能的结构。
将var用作“魔术类型”允许程序员为每个var的不同使用保留类型信息,这在某种程度上类似于 Java 泛型的有界通配符。
更高级的var用法与非注记类型是可能的。虽然这个特性不能满足所有对 Java 类型系统的批评,但它确实代表了一个明确(尽管谨慎)的进步步骤。
概要
通过分析 Java 的类型系统,我们已经能够建立起 Java 平台对数据类型的世界观的清晰图景。Java 的类型系统可以被描述为:
静态的
所有的 Java 变量在编译时都有已知的类型。
名义上的
Java 类型的名称至关重要。Java 不允许结构类型,并且对于非注记类型的支持有限。
面向对象/命令式
Java 代码是面向对象的,所有的代码必须存在于方法中,方法必须存在于类中。然而,Java 的原始类型阻止了对“一切皆对象”的完全采纳。
稍微具有函数式特征
Java 提供对一些常见的函数式习语的支持,但更多作为程序员的便利而非其他。
类型推断
Java 优化了代码的可读性(即使是对初学者),并倾向于显式声明,但在不影响代码可读性的情况下使用类型推断来减少样板代码。
强大的向后兼容性
Java 主要是面向业务的语言,向后兼容性和保护现有代码库是非常高的优先事项。
类型擦除
Java 允许参数化类型,但这些信息在运行时不可用。
Java 的类型系统经过多年的演进(尽管缓慢而谨慎),现在与其他主流编程语言的类型系统处于同一水平。Lambda 表达式与默认方法一起,代表了自 Java 5 问世以来最大的转变,以及泛型、注解及相关创新的引入。
默认方法代表了 Java 面向对象编程方法的一个重大转变,也许是自语言问世以来最大的转变。从 Java 8 开始,接口可以包含实现代码。这从根本上改变了 Java 的性质。此前是单继承语言的 Java,现在在行为上可以多继承(但仅限于行为,状态仍然不支持多继承)。
尽管有所有这些创新,Java 的类型系统并没有(也不打算)配备类似于 Scala 或 Haskell 等语言的类型系统的强大能力。相反,Java 的类型系统在简洁性、可读性和新手学习曲线方面都倾向于简单。
过去 10 年,Java 也从其他语言中开发的类型方法中受益匪浅。Scala 作为一种静态类型语言的例子,通过使用类型推断实现了很多动态类型语言的感觉,为 Java 添加特性提供了很好的思路,即使这两种语言有着非常不同的设计理念。
仍然有一个问题是,Java 中 Lambda 表达式提供的对函数式习惯的适度支持是否足以满足大多数 Java 程序员的需求。
注:
Java 的类型系统的长期发展方向正在研究项目中探索,例如 Valhalla,其中正在探索诸如数据类、模式匹配和密封类等概念。
尚待观察的是,普通 Java 程序员是否需要像 Scala 那样的高级(且远非名义上的)类型系统所带来的更大能力——以及随之而来的复杂性,还是 Java 8 中引入的“稍微函数式编程”(例如 map、filter、reduce 等)已经足够满足大多数开发者的需求。
¹ 一些泛型的小痕迹仍然存在,可以通过反射在运行时看到。
² Raoul-Gabriel Urma 和 Janina Voigt,“使用 OpenJDK 探究 Java 中的协变”,Java Magazine(2012 年 5 月/6 月):44–47。