Appearance
Java 基础:面向对象
1. 自定义类
1.1. 多个源文件的使用
Java 中,文件名必须与 public 类的名字相匹配。在一个源文件中,只能有一个公共类,但可以有任意数目的非公共类。
许多程序员习惯于将每一个类存放在一个单独的源文件中。例如,将 Employee 类存放在文件 Employee.java 中,将 EmployeeTest 类存放在文件 EmployeeTest.java 中。如果喜欢这样组织文件,可以有两种编译源程序的方法。一种是使用通配符调用 Java 编译器:
Bash
$ javac Employee*.java这样一来,所有与通配符匹配的源文件都将被编译成类文件。或者键入以下命令:
Bash
$ javac EmployeeTest.java你可能会感到惊讶,使用第二种方式时并没有显式地编译 Employee.java。不过,当 Java 编译器发现 EmployeeTest.java 使用了 Employee 类时,它会查找名为 Employee.class 的文件。如果没有找到这个文件,就会自动地搜索 Employee.java,然后,对它进行编译。更重要的是:如果 Employee.java 版本较已有的 Employee.class 文件版本更新,Java 编译器就会自动地重新编译这个文件。
Note:如果熟悉 UNIX 的 make 工具(或者是 Windows 中的 nmake 等工具),可以认为 Java 编译器内置了 make 功能。
1.2. 构造函数
当你在一个类中写了多个构造器,有时你想在一个构造器中调用另一个构造器来避免代码重复。你通过 this 关键字实现这样的调用。另外只能通过 this 调用一次构造器。并且必须首先调用构造器,否则编译器会报错。最后不允许在一个构造器之外的方法里调用构造器。
Java
public class Flower {
int petalCount = 0;
String s = "initial value";
Flower(int petals) {
petalCount = petals;
System.out.println("Constructor w/ int arg only, petalCount = " + petalCount);
}
Flower(String ss) {
System.out.println("Constructor w/ string arg only, s = " + ss);
s = ss;
}
Flower(String s, int petals) {
this(petals);
// this(s); // Can't call two!
this.s = s; // Another use of "this"
System.out.println("String & int args");
}
Flower() {
this("hi", 47);
System.out.println("no-arg constructor");
}
void printPetalCount() {
// this(11); // Not inside constructor!
System.out.println("petalCount = " + petalCount + " s = " + s);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
如果在构造器中调用了正在构造的对象的动态绑定方法,会发生什么呢?
如果在构造器中调用了动态绑定方法,就会用到那个方法的重写定义。然而,调用的结果难以预料因为被重写的方法在对象被完全构造出来之前已经被调用,这使得一些 bug 很隐蔽,难以发现。
Java
class Glyph {
void draw() {
System.out.println("Glyph.draw()");
}
Glyph() {
System.out.println("Glyph() before draw()");
draw();
System.out.println("Glyph() after draw()");
}
}
class RoundGlyph extends Glyph {
private int radius = 1;
RoundGlyph(int r) {
radius = r;
System.out.println("RoundGlyph.RoundGlyph(), radius = " + radius);
}
@Override
void draw() {
System.out.println("RoundGlyph.draw(), radius = " + radius);
}
}
public class PolyConstructors {
public static void main(String[] args) {
new RoundGlyph(5);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
输出:
Text
Glyph() before draw()
RoundGlyph.draw(), radius = 0
Glyph() after draw()
RoundGlyph.RoundGlyph(), radius = 51
2
3
4
2
3
4
输出结果表明,当 Glyph 构造器调用了 draw() 时,radius 的值不是默认初始值 1 而是 0。
因此,编写构造器有一条良好规范:尽量不要调用类中的任何方法。在基类的构造器中能安全调用的只有基类的 final 方法(这也适用于可被看作是 final 的 private 方法)。这些方法不能被重写,因此不会产生意想不到的结果。你可能无法永远遵循这条规范,但应该朝着它努力。
1.3. 字段初始化
实际上除了可以在声明处以及构造器中,还可以在初始化块(initialization block)中初始化数据字段。例如:
Java
class Employee {
private static int nextId;
private int id;
private String name;
private double salary;
// object initialization block
{
id = nextId;
nextId++;
}
public Employee(String n, double s) {
name = n;
salary = s;
}
public Employee() {
name = "";
salary = 0;
}
...
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
在这个示例中,无论使用哪个构造器构造对象,id 字段都会在对象初始化块中初始化。首先运行初始化块,然后才运行构造器的主体部分。
对应地还有静态的初始化块:
Java
// static initialization block
static {
var generator = new Random();
nextId = generator.nextInt(10000);
}1
2
3
4
5
2
3
4
5
Note
让人惊讶的是,在 JDK 6 之前,都可以用 Java 编写一个没有
main方法的 “Hello, World” 程序:Javapublic class Hello { static { System.out.println("Hello, World"); } }1
2
3
4
5当用
java Hello调用这个类时,就会加载这个类,静态初始化块将会打印 “Hello, World”。在此之后才会显示一个消息指出main未定义。从 Java 7 以后,Java 程序首先会检查是否有一个main方法。
1.4. finalize
finalize 方法目前已被废弃,推荐使用 AutoCloseable + Cleaner 来实现类似需求,更多介绍详见这篇文章。
finalize方法可能会带来性能问题,因为 JVM 通常在单独的低优先级线程中完成finalize的执行;finalize方法中,可将待回收对象赋值给 GC Roots 可达的对象引用,从而达到对象再生的目的;finalize方法至多由 GC 执行一次;
如对象包含非托管资源,则该对象应实现 AutoCloseable/Closeable 接口,在 close 方法中完成非托管资源的释放。并覆盖 finalize 方法,在 finalize 方法中校验并确保该对象通过 close 方法完成非托管资源的释放。
Java
protected void finalize() throws Throwable {
if (logFileOpen) {
try {
closeLogFile();
} finally {
super.finalize();
}
}
}1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
关于 Java GC 相关部分的知识可以参考:https://blog.csdn.net/laomo_bible/article/details/83112622
2. 函数与方法
2.1. 方法的调用过程
假设要调用 x.f(args),隐式参数 x 声明为类 C 的一个对象。下面是方法调用过程的详细描述:
编译器查看对象的声明类型和方法名。需要注意的是:有可能存在多个名字为
f但参数类型不一样的方法。例如,可能存在方法f(int)和方法f(String)。编译器将会一一列举C类中所有名为f的方法和其超类中所有名为f而且可访问的方法(超类的私有方法不可访问);接下来,编译器要确定方法调用中提供的参数类型。如果在所有名为
f的方法中存在一个与所提供参数类型完全匹配的方法,就选择这个方法。这个过程称为重载解析(overloading resolution)。例如,对于调用x.f("Hello"),编译器将会挑选f(String),而不是f(int)。由于允许类型转换(int可以转换成double,Manager可以转换成Employee,等等),所以情况可能会变得很复杂。如果编译器没有找到与参数类型匹配的方法,或者发现经过类型转换后有多个方法与之匹配,编译器就会报告一个错误;如果是
private方法、static方法、final方法或者构造器,那么编译器将可以准确地知道应该调用哪个方法。这称为静态绑定。与此对应的是,如果要调用的方法依赖于隐式参数的实际类型,那么必须在运行时使用动态绑定。在我们的示例中,编译器会利用动态绑定生成一个调用f(String)的指令;程序运行并且采用动态绑定调用方法时,虚拟机必须调用与
x所引用对象的实际类型对应的那个方法。假设x的实际类型是D,它是C类的子类。如果D类定义了方法f(String),就会调用这个方法;否则,将在D类的超类中寻找f(String),以此类推;
每次调用方法都要完成这个搜索,时间开销相当大。因此,虚拟机预先为每个类计算了一个方法表(method table),其中列出了所有方法的签名和要调用的实际方法。这样一来,在真正调用方法的时候,虚拟机仅查找这个表就行了。在前面的例子中,虚拟机搜索 D 类的方法表,寻找与调用 f(String) 相匹配的方法。这个方法既有可能是 D.f(String),也有可能是 X.f(String),这里的 X 是 D 的某个超类。这里需要提醒一点,如果调用是 super.f(param),那么编译器将对隐式参数超类的方法表进行搜索。
2.2. 静态绑定、动态绑定
将一个方法调用和一个方法主体关联起来称作绑定。若绑定发生在程序运行前(如果有的话,由编译器和链接器实现),叫做前期绑定。例如在 C 语言中就只有前期绑定这一种方法调用。
后期绑定也称为动态绑定或运行时绑定。当一种语言实现了后期绑定,就必须具有某种机制在运行时能判断对象的类型,从而调用恰当的方法。
Java 中除了 static 和 final 方法(private 方法也是隐式的 final)外,其它所有方法都是后期绑定。
为什么将一个对象指明为 final 正如上边所述,它可以防止方法被重写。但更重要的一点可能是,它有效地 “关闭了” 动态绑定,或者说告诉编译器不需要对其进行动态绑定。这可以让编译器为 final 方法生成更高效的代码。然而,大部分情况下这样做不会对程序的整体性能带来什么改变,因此最好是为了设计使用 final,而不是为了提升性能而使用。
2.3. 关于 static final 方法
虽然 static 方法不能被 "override" 但是可以被 "hide",子类中的 static 方法不是在 "override" 而是在 "hide",也就是说,如果在子类中直接调用该静态方法(不是通过类调用),那么调用的一定是子类自己的那个方法,而不是父类中的,因为子类把父类那个隐藏起来了。而 final 会阻止隐藏,所以在子类中父类的 static 方法被隐藏就和 final 的阻止隐藏冲突了,因此编译就会报错。
所以将一个 static 方法声明为 final 不是必要的,除非是想要阻止子类定义一个相同签名的 static 方法。
2.4. 方法内联
如果一个方法没有被覆盖并且很短,编译器就能够对它进行优化处理,这个过程称为内联(inlining)。例如,内联调用 e.getName() 将被替换为访问字段 e.name。然而,如果 getName 在另外一个类中被覆盖,那么编译器就无法知道覆盖的代码将会做什么操作,因此也就不能对它进行内联处理了。幸运的是,虚拟机中的即时编译器可以准确地知道类之间的继承关系,并能够检测出是否有类确实覆盖了给定的方法。如果方法很简短、被频繁调用而且确实没有被覆盖,那么即时编译器就会将这个方法进行内联处理。如果虚拟机加载了另外一个子类,而这个子类覆盖了一个内联方法,那么将会发生什么情况呢?优化器将取消对这个方法的内联。这个过程很慢,不过很少会发生这种情况。
2.5. 参数数量可变方法
参数数量可变方法写法示例:
Java
public class PrintStream {
public PrintStream printf(String format, Object ... args) {
return format(format, args);
}
}1
2
3
4
5
2
3
4
5
- 可变参数只能作为函数的最后一个参数,在其前面可以有也可以没有任何其它参数;
- 由于可变参数必须是最后一个参数,所以一个函数最多只能有一个可变参数;
- Java 的可变参数,会被编译器转型为一个数组;
实际上可变参数在编译为字节码后,在方法签名中会以数组形态出现的,所以以下这两个方法的签名是一致的,不能编译通过:
Java
public static void method1(Integer id, String ... names) {
System.out.println("id:" + id + " names:" + names.length);
}
public static void method1(Integer id, String[] names) {
System.out.println("id:" + id + " names:" + names.length);
}1
2
3
4
5
6
7
2
3
4
5
6
7
2.6. 按值调用与按引用调用
在 Java 中,参数传递始终是按值传递的,对于引用类型,传递的是引用的副本。这意味着你可以修改引用类型参数所引用的对象的状态,但不能修改引用本身。
Tip:在 Java 中,没有与 C#
ref和out关键字相对应的语法。如果想要实现类似于ref或out的效果,通常可以通过创建一个包含要修改的值的对象,然后将该对象传递给方法。
2.7. main 方法
实际上每一个类都可以有一个 main 方法。这是常用于对类进行单元测试的一个技巧。例如,可以在 Employee 类中添加一个 main 方法:
Java
class Employee {
public Employee(String n, double s, int year, int month, int day) {
name = n;
salary = s;
hireDay = LocalDate.of(year, month, day);
}
...
public static void main(String[] args) { // unit test
var e = new Employee("Romeo", 50000, 2003, 3, 31);
e.raiseSalary(10);
System.out.println(e.getName() + " " + e.getSalary());
}
...
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
如果想要独立地测试 Employee 类,只需要执行:
Bash
$ java Employee如果 Employee 类是一个更大型应用程序的一部分,就可以使用下面这条语句运行程序:
Bash
$ java Application此时 Employee 类的 main 方法不会被执行。
3. Object
Object 是 Java 中所有类的 “始祖”,包括数组,只有基本类型不是 Object,例如:数值、字符和布尔类型等。
3.1. null 引用
在 Java 9 中,Objects 类对 null 值的处理提供了一些便利方法:
Java
Objects.requireNonNull(n, "The name cannot be null");
name = Objects.requireNonNullElse(n, "unknown");
name = Objects.requireNonNullElseGet(n, () -> "unknown");1
2
3
2
3
3.2. equals 方法
Java 语言规范要求 equals 方法具有下面的特性:
自反性:对于任何非空引用
x,x.equals(x)应该返回true;对称性:对于任何引用
x和y, 当且仅当y.equals(x)返回true时,x.equals(y)返回true;传递性:对于任何引用
x、y和z, 如果x.equals(y)返回true,y.equals(z)返回true,x.equals(z)也应该返回true;一致性:如果
x和y引用的对象没有发生变化,反复调用x.equals(y)应该返回同样的结果;对于任意非空引用
x,x.equals(null)应该返回false;
以下是编写一个完美的 equals 方法的建议:
显式参数命名为
otherObject(因为,稍后需要将它强制转换成另一个名为other的变量);检测
this与otherObject是否相等:Javaif (this == otherObject) return true;检测
otherObject是否为null,如果为null,返回false:Javaif (otherObject == null) return false;比较
this与otherObject的类:如果
equals的语义可以在子类中改变,就使用getClass检测:Javaif (getClass() != otherObject.getClass()) return false;如果所有的子类都有相同的相等性语义(例如:数据库 Id),可以使用
instanceof检测:Javaif (!(otherObject instanceof ClassName)) return false;需要注意的是
instanceof这种方法可能会招致一些麻烦,你肯定不希望类库实现者在查找数据结构中的一个元素时还要纠结调用x.equals(y)还是调用y.equals(x)的问题。所以非特定情况下,不建议采用这种处理方式。将
otherObject强制转换为相应类类型的变量:JavaClassName other = (ClassName) otherObject;现在根据相等性概念的要求来比较字段:
- 如果在子类中重新定义
equals,就添加super.equals(other)调用; - 使用
==比较基本类型字段; - 使用
Object.equals比较对象字段;
如果所有需要比较的内容都匹配,就返回
true,否则返回false。Note:对于数组类型的字段,可以使用静态的
Arrays.equals方法检测相应的数组元素是否相等。- 如果在子类中重新定义
如果重新定义了 equals 方法,就必须为对象重新定义 hashCode 方法。
3.3. hashCode 方法
类的默认 hashCode 方法会从对象的存储地址得出散列码。
当需要组合多个散列值时,可以调用 Object.hash 并提供所有这些参数。这个方法会对各个参数调用 Objects.hashCode,并组合这些散列值。例如:
Java
public int hashCode() {
return Objects.hash(name, salary, hireDay);
}1
2
3
2
3
equals 与 hashCode 的定义必须相容:如果 x.equals(y) 返回 true,那么 x.hashCode() 就必须与 y.hashCode() 返回相同的值。例如:如果定义 Employee.equals 比较员工的 ID,那么 hashCode 方法就需要散列 ID,而不是员工的姓名或存储地址。
3.4. toString 方法
Object 类定义了 toString 方法,默认打印对象的类名和散列码,例如:
Java
System.out.println(System.out);将输出:
Text
java.io.PrintStream@2f6684Note
需要注意的是,数组直接继承了
Object类的toString方法,例如:Javaint[] luckyNumbers = { 2, 3, 5, 7, 11, 13 }; String s = "" + luckyNumbers;1
2将输出(前缀
[I表明是一个整型数组):Text[I@1a46e30推荐的方法是调用静态方法
Arrays.toString,代码:JavaString s = Arrays.toString(luckyNumbers);输出:
Text[2,3,5,7,11,13]如果想打印多维数组,则可以调用
Arrays.deepToString方法。
3.5. clone 方法
clone 方法是 Object 的一个 protected 方法,默认实现为浅拷贝。子类必须将其覆写为 public 才能允许 clone 方法被外部所调用。
Cloneable 接口是一个空接口,仅作为一个标记使用。如果子类覆写了 clone 方法,但是没有实现 Cloneable 接口,那么当执行 super.clone() 方法(也就是 Object 的 clone 方法)时将会抛出一个 CloneNotSupportedException 异常。
下面是一个实现 clone 浅拷贝的示例:
- 实现
Cloneable接口; - 覆写
clone方法,并将其提升为public; - 在
clone方法中调用super.clone(); - 使用协变返回类型返回真正的类型,而不是
Object;
Java
class Employee implements Cloneable {
public Employee clone() throws CloneNotSupportedException {
return (Employee) super.clone();
}
// ...
}1
2
3
4
5
6
7
2
3
4
5
6
7
要实现深拷贝,还需要克隆对象中可变的实例字段:
Java
class Employee implements Cloneable {
public Employee clone() throws CloneNotSupportedException {
var cloned = (Employee) super.clone();
cloned.hireDay = (Date) hireDay.clone();
return cloned;
}
// ...
}1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
Note
所有数组类型都有一个公共的
clone方法,而且是public的,可以用这个方法建立一个新数组,包含原数组所有元素的副本,例如:Javaint[] luckyNumbers = { 2, 3, 5, 7, 11, 13 }; int[] cloned = luckyNumbers.clone(); cloned[5] = 12; // doesn't change luckyNumbers[5]1
2
3
3.6. 装箱、拆箱
3.6.1. 包装器类
所有的基本类型都有一个与之对应的包装器类:
Integer↔int;Long↔long;Float↔float;Double↔double;Short↔short;Byte↔byte;Character↔char;Boolean↔boolean;
Note:前 6 个类派生于公共的超类
Number。
以上这些包装器类都是不可变的(Immutable)。
3.6.2. 自动装箱、拆箱
比较让人惆怅的是,Java 中的泛型不支持基本类型,也就是 ArrayList<int> 是不被允许的,而需要写成 ArrayList<Integer>,由于涉及到拆箱装箱所以 ArrayList<Integer> 的效率远远低于 int[]。
Note:Java 泛型不支持基本类型是因为类型擦除机制导致的。
当我们调用:
Java
list.add(3); // list is an ArrayList<Integer>将自动变成:
Java
list.add(Integer.valueOf(3));此类变换称为自动装箱。而将一个 Integer 对象赋给一个 int 值时,将会自动拆箱:
Java
int n = list.get(i);将自动变成:
Java
int n = list.get(i).intValue();自动装箱、拆箱也适用于算数表达式,例如:
Java
Integer n = 3;
n++;1
2
2
编译器将自动地插入一条对象拆箱的指令,再进行自增运算,最后再将结果装箱。
再比如混合使用 Integer 和 Double 类型,Integer 值就会拆箱,提升为 Double,再装箱为 Double:
Java
Integer n = 1;
Double x = 2.0;
System.out.println(true ? n : x); // prints 1.01
2
3
2
3
Tip:最后强调一下,装箱和拆箱是编译器要做的工作,而不是虚拟机。编译器在生成类的字节码时会插入必要的方法调用。虚拟机只是执行这些字节码。
3.6.3. 包装器类缓存
自动装箱规范要求 boolean、byte、char ≤ 127,以及介于 -128 和 127 之间的 short、int 被包装到固定的对象中。因此:
Java
Integer a1 = 1000;
Integer b1 = 1000;
System.out.println(a1 == b1); // false
Integer a2 = 127;
Integer b2 = 127;
System.out.println(a2 == b2); // true1
2
3
4
5
6
7
2
3
4
5
6
7
4. 继承
4.1. 定义子类
可以如下继承 Employee 类来定义 Manager 类,这里使用关键字 extends 表示继承:
Java
public class Manager extends Employee {
// added methods and fields
}1
2
3
2
3
4.2. 覆盖方法
子类覆盖(override)父类的方法是通过在子类中定义一个与父类中具有相同名称和参数列表的方法(即确保方法签名一致)来实现的。
Note 1:在覆盖一个方法的时候,子类方法不能低于超类方法的可见性。例如,超类方法是
public,子类方法必须也要声明为public。
Note 2:推荐在子类的方法上使用
@Override注解,这可以帮助编译器检查是否正确地覆盖了父类的方法。如果方法签名不匹配,编译器将发出错误。
可以使用 super 关键字来调用超类中的方法:
Java
public double getSalary() {
double baseSalary = super.getSalary();
return baseSalary + bonus;
}1
2
3
4
2
3
4
Note:有些人认为
super与this引用是类似的概念,实际上,这样比较并不太恰当。这是因为super不是一个对象的引用,例如,不能将值super赋给另一个对象变量,它只是一个指示编译器调用超类方法的特殊关键字。
4.3. 协变返回类型
Java 5 中引入了协变返回类型,这表示派生类的被重写方法可以返回基类方法返回类型的派生类型:
Java
class Grain {
@Override
public String toString() {
return "Grain";
}
}
class Wheat extends Grain {
@Override
public String toString() {
return "Wheat";
}
}
class Mill {
Grain process() {
return new Grain();
}
}
class WheatMill extends Mill {
@Override
Wheat process() {
return new Wheat();
}
}
public class CovariantReturn {
public static void main(String[] args) {
Mill m = new Mill();
Grain g = m.process();
System.out.println(g);
m = new WheatMill();
g = m.process();
System.out.println(g);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
4.4. 子类构造器
关键字 this 有两个含义:一是指示隐式参数的引用,二是调用该类的其他构造器。类似地,super 关键字也有两个含义:一是调用超类的方法,二是调用超类的构造器:
Java
public Manager(String name, double salary, int year, int month, int day) {
super(name, salary, year, month, day);
bonus = 0;
}1
2
3
4
2
3
4
4.5. 受保护访问
Java 使用 protected 来声明受保护的方法或字段。
Java 中的受保护部分对所有子类及同一个包中的所有其他类都可见。
下面对 Java 中的 4 个访问控制修饰符做个小结:
private:仅对本类可见;- 无修饰符:对本包可见;
protected:对本包和所有子类可见;public:对外部完全可见;
4.6. 抽象类
使用 abstract 关键字声明的方法称之为抽象方法,抽象方法没有方法体。如果一个类包含一个或多个抽象方法,那么该类本身也必须是抽象类。除了抽象方法之外,抽象类也可以包含字段和具体方法。
Java
public abstract class Person {
private String name;
public Person(String name) {
this.name = name;
}
public abstract String getDescription();
public String getName() {
return name;
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
扩展抽象类可以有两种选择:一种是在子类中保留抽象类中的部分或所有抽象方法仍未定义,这样就必须将子类也标记为抽象类;另一种做法是定义全部方法,这样一来,子类就可以不用是抽象的了。
抽象类不能实例化。也就是说,如果将一个类声明为 abstract,就不能创建这个类的对象。
4.7. 阻止继承:final 类和方法
不允许扩展的类被称为 final 类,可以在定义类的时候使用 final 修饰符来表明这个类是 final 类。
Java
public final class Executive extends Manager {
...
}1
2
3
2
3
类中的某个特定方法也可以被声明为 final。如果这样做,子类就不能覆盖这个方法(final 类中的所有方法自动地成为 final 方法)。
Java
public class Employee {
...
public final String getName() {
return name;
}
...
}1
2
3
4
5
6
7
2
3
4
5
6
7
Tip:在 C++ 和 C# 中,如果没有特别地说明,所有的方法都不使用多态性,而 Java 中则提倡在设计类层次时,要仔细地思考应该将哪些方法和类声明为
final(也就是说 Java 中的方法默认是使用多态性的)。在我个人看来,除非有充分理由不将方法声明为final,否则将方法默认声明为final是一个不错的实践。
4.8. 强制类型转换
在进行强制类型转换之前,先查看是否能够成功地转换:
Java
if (staff[1] instanceof Manager) {
boss = (Manager) staff[1];
// ...
}1
2
3
4
5
2
3
4
5
Tip
如果
x为null,进行以下测试:Javax instanceof C不会产生异常,只是返回
false。因为null没有引用任何对象,当然也不会引用C类型的对象。
4.9. 子类数组转为超类数组
在 Java 中,子类引用的数组可以转换成超类引用的数组,而且不需要使用强制类型转换。例如,下面是一个 Manager 数组:
Java
Manager[] managers = new Manager[10];将它转换成 Employee 数组完全是合法的:
Java
Employee[] staff = managers; // OK这样做肯定不会有问题,请思考一下其中的缘由。毕竟,如果 manager[i] 是一个 Manager,它也一定是一个 Employee。不过,实际上将会发生一些令人惊讶的事情。要切记 managers 和 staff 引用的是同一个数组。现在看一下这条语句:
Java
staff[0] = new Employee("Harry Hacker", ...);编译器竟然接纳了这个赋值操作。但在这里,staff[0] 与 manager[0] 是相同的引用,似乎我们把一个普通员工擅自归入经理行列中了。这是一种很不好的情形,当调用 manager[0].setBouns(1000) 的时候,将会试图调用一个不存在的实例字段,进而搅乱相邻存储空间的内容。
为了确保不发生这类破坏,所有数组都要牢记创建时的元素类型,并负责监督仅将类型兼容的引用存储到数组中。例如,使用 new Manager[10] 创建的数组是一个经理数组。如果试图存储一个 Employee 类型的引用就会引发 ArrayStoreException 异常。
Note:将一个
Employee[]临时转换成Object[],然后再将它转换回来是可以的。但一个从开始就是Object[]的数组却永远不能转换成Employee[]数组,如果这样做,JVM 将会抛出一个ClassCastException。因为 Java 数组会记住原始元素的类型,即创建数组时new表达式中使用的元素类型。
4.10. ArrayList<T> 转为 ArrayList
可以直接将 ArrayList<T> 对象直接传递给 ArrayList 的类型参数,而不需要进行任何强制类型转换。例如:
Java
public class EmployeeDB {
public void update(ArrayList list) { ... }
public ArrayList find(String query) { ... }
}1
2
3
4
2
3
4
可以直接这样调用 EmployeeDB 对象的 update 方法:
Java
ArrayList<Employee> staff = ...;
employeeDB.update(staff);1
2
2
但是这样调用并不太安全,调用 update 方法时,存在往数组列表中存入非 Employee 对象的风险。
不过,将一个原始 ArrayList 赋给 ArrayList<T> 时会得到一个警告:
Java
ArrayList<Employee> result = employeeDB.find(query); // yields warning即使使用强制类型转换也不能避免出现警告:
Java
ArrayList<Employee> result = (ArrayList<Employee>) employeeDB.find(query); // yields another warning如果确保该转换没有问题,可以使用 @SuppressWarnings("unchecked") 注解来标记接受强制类型转换的变量,如下所示:
Java
@SuppressWarnings("unchecked") ArrayList<Employee> result = (ArrayList<Employee>) employeeDB.find(query);5. interface
Java 8 中接口稍微有些变化,因为 Java 8 允许接口包含默认方法和静态方法。
接口的典型使用是代表一个类的类型或一个形容词,如 Runnable 或 Serializable,而抽象类通常是类层次结构的一部分或一件事物的类型,如 String 或 ActionHero。
接口同样可以包含属性,这些属性被隐式指明为 static 和 final。
5.1. 接口的默认方法
Java 8 为关键字 default 增加了一个新的用途(之前只用于 switch 语句和注解中)。当在接口中使用它时,任何实现接口却没有定义方法的时候可以使用 default 创建的方法体。默认方法比抽象类中的方法受到更多的限制,但是非常有用,我们将在 “流式编程” 一章中看到。现在让我们看下如何使用:
Java
interface AnInterface {
void firstMethod();
void secondMethod();
}
public class AnImplementation implements AnInterface {
public void firstMethod() {
System.out.println("firstMethod");
}
public void secondMethod() {
System.out.println("secondMethod");
}
public static void main(String[] args) {
AnInterface i = new AnImplementation();
i.firstMethod();
i.secondMethod();
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
如果我们在 AnInterface 中增加一个新方法 newMethod(),而在 AnImplementation 中没有实现它,编译器就会报错。
如果我们使用关键字 default 为 newMethod() 方法提供默认的实现,那么所有与接口有关的代码能正常工作,不受影响,而且这些代码还可以调用新的方法 newMethod():
Java
interface InterfaceWithDefault {
void firstMethod();
void secondMethod();
default void newMethod() {
System.out.println("newMethod");
}
}
public class Implementation2 implements InterfaceWithDefault {
@Override
public void firstMethod() {
System.out.println("firstMethod");
}
@Override
public void secondMethod() {
System.out.println("secondMethod")
}
public static void main(String[] args) {
InterfaceWithDefault i = new Implementation2();
i.firstMethod();
i.secondMethod();
i.newMethod();
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
尽管 Implementation2 中未定义 newMethod(),但是可以使用 newMethod() 了。
增加默认方法的极具说服力的理由是它允许在不破坏已使用接口的代码的情况下,在接口中增加新的方法。默认方法有时也被称为守卫方法或虚拟扩展方法。
再比如 Iterator 接口,这个接口声明了一个 remove 的默认方法:
Java
public interface Iterator<E> {
boolean hasNext();
E next();
default void remove() { throw new UnsupportedOperationException("remove"); }
}1
2
3
4
5
2
3
4
5
如果实现 Iterator 接口的类是只读的,那么就可以不用操心实现 remove 方法了。
5.2. 默认方法冲突
如果在接口中定义了一个默认方法,同时在超类或另一个接口中也定义了同样的方法,应该如何处理?
超类:
超类优先,也就是如果在超类中提供了一个具体方法,同名且具有相同参数类型的接口默认方法会被忽略。
“超类优先” 规则可以确保与 Java 7 的兼容性。如果为一个接口增加默认方法,这对于有这些默认方法之前能正常工作的代码不会有任何影响。
接口:
如果一个接口提供了一个默认方法,另一个接口提供了一个同名且参数类型相同的方法(不论是否是默认方法),都必须覆盖这个方法来解决冲突。
Javainterface Person { default String getName() { return ""; } } interface Named { default String getName() { return getClass().getName() + "_" + hashCode(); } // or: String getName(); } class Student implements Person, Named { ... }1
2
3
4
5
6
7
8
9
10Student会继承Person和Named接口提供的两个不一致的getName方法。并不是从中选择一个,Java 编译器会报告一个错误,我们需要在Student类中提供一个getName方法:Javaclass Student implements Person, Named { public String getName() { return Person.super.getName(); } }1
2
3
5.3. 接口的静态方法
Java 8 允许在接口中添加静态方法。这么做能恰当地把工具功能置于接口中,从而操作接口,或者成为通用的工具:
Java
package onjava;
import java.util.*;
public interface Operations {
void execute();
static void runOps(Operations ... ops) {
for (Operations op: ops) {
op.execute();
}
}
static void show(String msg) {
System.out.println(msg);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
runOps() 是一个模版方法。
5.4. 接口的字段
因为接口中的字段都自动是 static 和 final 的,所以接口就成为了创建一组常量的方便的工具。在 Java 5 之前,这是产生与 C 或 C++ 中的 enum (枚举类型) 具有相同效果的唯一方式。所以你可能在 Java 5 之前的代码中看到:
Java
public interface Months {
int
JANUARY = 1, FEBRUARY = 2, MARCH = 3,
APRIL = 4, MAY = 5, JUNE = 6, JULY = 7,
AUGUST = 8, SEPTEMBER = 9, OCTOBER = 10,
NOVEMBER = 11, DECEMBER = 12;
}1
2
3
4
5
6
7
2
3
4
5
6
7
自 Java 5 开始,我们有了更加强大和灵活的关键字 enum,那么在接口中定义常量组就显得没什么意义了。
5.5. 接口的多实现
当两个接口具有相同签名的方法且方法的返回类型也一致时,实现类无法针对各接口提供不同的方法实现。也就是说,实现类中只能提供一种方法实现,且该方法同时满足了两个接口。
Java
import java.util.*;
interface Jim1 {
default void jim() {
System.out.println("Jim1::jim");
}
}
interface Jim2 {
default void jim() {
System.out.println("Jim2::jim");
}
}
public class Jim implements Jim1, Jim2 {
@Override
public void jim() {
Jim2.super.jim();
}
public static void main(String[] args) {
new Jim().jim();
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
而当两个接口的方法签名相同,返回类型不同时,实现类只能选择其中一个接口进行实现。
5.6. 抽象类和接口
在 Java 8 引入 default 方法之后,选择用抽象类还是用接口变得更加令人困惑。下表做了明确的区分:
| 特性 | 接口 | 抽象类 |
|---|---|---|
| 组合 | 新类可以组合多个接口 | 只能继承单一抽象类 |
| 状态 | 不能包含属性(除了静态属性,不支持对象状态) | 可以包含属性,非抽象方法可能引用这些属性 |
| 默认方法和抽象方法 | 不需要在子类中实现默认方法。默认方法可以引用其它接口的方法 | 必须在子类中实现抽象方法 |
| 构造器 | 没有构造器 | 可以有构造器 |
| 可见性 | 隐式 public | 可以是 protected 或友元 |
有一条实际经验:尽可能地使用接口而不是抽象类。只有当必要时才使用抽象类。除非必须使用,否则不要用接口和抽象类。大多数时候,普通类已经做得很好,如果不行的话,再移动到接口或抽象类中。
6. Lambda 表达式
6.1. 语法
参数;
- 正常情况使用括号
()包裹参数; - 当只有一个参数,可以不需要括号
(); - 如果没有参数,则必须使用括号
()表示空参数列表;
如果一个 lambda 表达式的参数类型可以被推导出来,则可以忽略其类型,例如:
JavaComparator<String> comp = (first, second) -> first.length() - second.length(); // same as: (String first, String second) -> first.length() - second.length();1
2- 正常情况使用括号
接着
->,可视为 “产出”;->之后的内容都是方法体;
无需指定 lambda 表达式的返回类型,lambda 表达式的返回类型总是会由上下文推导得出。
6.2. 函数式接口
对于只有一个抽象方法的接口,需要这种接口的对象时,就可以提供一个 lambda 表达式。这种接口称为函数式接口。例如 Arrays.sort 方法,它的第二个参数需要一个 Comparator 实例,Comparator 就是只有一个方法的接口,所以可以提供一个 lambda 表达式:
Java
Arrays.sort(words, (first, second) -> first.length() - second.length())在底层,Arrays.sort 方法会接收实现了 Comparator<String> 的某个类的对象。在这个对象上调用 compare 方法会执行这个 lambda 表达式的体。实际上,在 Java 中,对 lambda 表达式所能做的也只是转换为函数式接口,甚至不能把 lambda 表达式赋值给类型为 Object 的变量,因为 Object 不是函数式接口。
Tip:对于我们自己定义的函数式接口,推荐使用
@FunctionalInterface注解来标记这个接口。
在 java.util.function 包中定义了很多非常通用的函数式接口。比如 BiFunction<T, U, R> 描述了参数类型为 T 和 U 而且返回类型为 R 的函数。我们可以把字符串比较 lambda 表达式保存在这个类型的变量中:
Java
BiFunction<String, String, Integer> comp = (first, second) -> first.length() - second.length();但是,后续不能将它直接传递给 Arrays.sort 方法,因为它不接受 BiFunction<String, String, Integer> 类型的参数。
不过还是有一些尤其有用的接口,比如 Predicate:
Java
public interface Predicate<T> {
boolean test(T t);
}
// ...
list.removeIf(e -> e == null);1
2
3
4
5
6
7
2
3
4
5
6
7
以及 Supplier(Supplier 通常可以用于懒加载):
Java
public interface Supplier<T> {
T get();
}
// ...
// LocalDate hireDay = Objects.requireNonNullElse(day, new LocalDate(1970, 1, 1)); // bad
LocalDate hireDay = Objects.requireNonNullElseGet(day, () -> new LocalDate(1970, 1, 1)); // good1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
一些常用的函数式接口:
| 函数式接口 | 参数类型 | 返回类型 | 抽象方法名 | 描述 | 其它方法名 |
|---|---|---|---|---|---|
Runnable | 无 | void | run | 作为无参数或返回值的动作运行 | |
Supplier<T> | 无 | T | get | 提供一个 T 类型的值 | |
Consumer<T> | T | void | accept | 处理一个 T 类型的值 | andThen |
BiConsumer<T, U> | T, U | void | accept | 处理 T 和 U 类型的值 | andThen |
Function<T, R> | T | R | apply | 有一个 T 类型参数的函数 | compose, andThen, identity |
BiFunction<T, U, R> | T, U | R | apply | 有 T 和 U 类型参数的函数 | andThen |
UnaryOperator<T> | T | T | apply | 类型 T 上的一元操作符 | compose, andThen, identity |
BinaryOperator<T> | T, T | T | apply | 类型 T 上的二元操作符 | andThen, maxBy, minBy |
Predicate<T> | T | boolean | test | 布尔值函数 | and, or, negate, isEqual |
BiPredicate<T, U> | T, U | boolean | test | 有两个参数的布尔值函数 | and, or, negate |
一些常用的特殊化接口:
| 函数式接口 | 参数类型 | 返回类型 | 抽象方法名 |
|---|---|---|---|
BooleanSupplier | 无 | boolean | getAsBoolean |
PSupplier | 无 | p | getAsP |
PConsumer | p | void | accept |
ObjPConsumer<T> | T, p | void | accept |
PFunction<T> | p | T | apply |
PToQFunction | p | q | applyAsQ |
ToPFunction<T> | T | p | applyAsP |
ToPBiFunction<T, U> | T, U | p | applyAsP |
PUnaryOperator | p | p | applyAsP |
PBinaryOperator | p, p | p | applyAsP |
PPredicate | p | boolean | test |
Note:
p、q代指int、long、double,P、Q代指Int、Long、Double。
6.3. 方法引用
有时候在 lambda 方法体中只是简单地调用另一个方法:
Java
var timer = new Timer(1000, e -> System.out.println(e));这时候我们直接将其写成方法引用的形式:
Java
var timer = new Timer(1000, System.out::println);方法引用的写法:类名或对象名,后面跟 ::,然后跟方法名称。它指示编译器生成一个函数式接口的实例。跟 lambda 表达式一样,方法引用也不是一个对象。不过,为一个类型为函数式接口的变量赋值时会生成一个对象。
在 System.out 对象中有 10 个重载的 println 方法,编译器需要根据上下文确定使用哪一个方法。在上述的例子中,方法引用 System.out::println 必须转换为一个包含以下方法的 ActionListener 实例:
Java
void actionPerformed(ActionEvent e)这样会从 10 个重载的 println 方法中选出 println(Object x) 方法,因为 Object 与 ActionEvent 最匹配。调用 actionPerformed 方法时,就会打印 ActionEvent 这个对象。
同样地,当我们将 println 方法引用赋至 Runnable 接口时:
Java
public interface Runnable {
public abstract void run();
}
// ...
Runnable task = System.out::println;1
2
3
4
5
6
7
2
3
4
5
6
7
这里就会选择无参数的 println() 方法,调用 task() 会向 System.out 打印一个空行。
6.4. 未绑定的方法引用
从方法引用的语法可以看出,主要有 3 种方法引用的情形:
- object:instanceMethod
- Class:instanceMethod
- Class:staticMethod
对于第 1 种情况等价于调用该实例对象上的方法,第 3 种情况等价于调用该类上的静态方法。比较特殊的是第 2 种情况,第一个参数会称为方法的隐式参数,例如:String::compareToIgnoreCase 等同于 (x, y) -> x.compareToIgnoreCase(y),我们将其称之为未绑定的方法引用:
Java
class X {
String f() { return "X::f()"; }
}
interface MakeString {
String make();
}
interface TransformX {
String transform(X x);
}
public class UnboundMethodReference {
public static void main(String[] args) {
// MakeString ms = X::f;
TransformX sp = X::f;
X x = new X();
System.out.println(sp.transform(x));
System.out.println(x.f()); // 同等效果
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
输出结果:
Text
X::f()
X::f()1
2
2
在上方的第 15 行代码中,我们尝试把 X 的 f() 方法引用赋值给 MakeString。结果即使 make() 与 f() 具有相同的签名,编译也会报 “invalid method reference”(无效方法引用)错误。这是因为实际上还有另一个隐藏的参数:this。你不能在没有 X 对象的前提下调用 f()。因此,X::f 表示未绑定的方法引用,因为它尚未 “绑定” 到对象。
6.5. 构造器引用
构造器引用与方法引用很类似,只不过方法名为 new。例如:Person::new、int[]::new 等等。
在 Java 中,我们无法直接构造泛型类型 T 的数组,new T[n] 会产生错误,因为这会改为 new Object[n]。利用数组构造器引用可以很好的解决这个问题:
Java
public interface Stream<T> extends ... {
// ...
<A> A[] toArray(IntFunction<A[]> generator);
}
// ...
public interface IntFunction<R> {
R apply(int value);
}
// ...
ArrayList<String> names = ...;
Stream<Person> stream = names.stream().map(Person::new);
Object[] people1 = stream.toArray();
Person[] people2 = stream.toArray(Person[]::new);1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
6.6. 变量作用域
在 lambda 表达式中可以捕获外部的变量,该变量被称之为自由变量:
Java
public static void repeatMessage(String text, int delay) {
ActionListener listener = e -> {
System.out.println(text);
};
new Timer(delay, listener).start();
}1
2
3
4
5
6
2
3
4
5
6
lambda 表达式被转换为一个包含方法的对象,同时自由变量的值就会复制到这个对象的实例变量中。不过这里有一个重要的限制,在 lambda 表达式中,只能引用值不会改变的变量。也就是 lambda 表达式中捕获的变量必须是事实最终变量(effectively final)。
另外 lambda 表达式的体与嵌套块有相同的作用域。这里同样适用命名冲突和遮蔽的有关规则,比如在 lambda 表达式中声明一个局部变量同名的参数或局部变量是不合法的:
Java
Path first = Path.of("/usr/bin");
Comparator<String> comp = (first, second) -> first.length() - second.length(); // Error: Variable first already defined1
2
2
在 lambda 表达式中使用 this 关键字时,是指创建这个 lambda 表达式的方法的 this 参数。例如:
Java
public class Application {
public void init() {
ActionListener listener = event -> {
System.out.println(this.toString());
}
}
}1
2
3
4
5
6
7
2
3
4
5
6
7
这里的 this.toString() 会调用 Application 对象的 toString 方法,而不是 ActionListener 实例的方法。
6.7. 示例:Comparator
按照属性进行对比:
Java
Arrays.sort(people, Comparator.comparing(Person::getName));串联比较条件:
Java
Arrays.sort(people, Comparator.comparing(Person::getName).thenComparing(Person::getFirstName));还可以为 comparing 和 thenComparing 方法提取的键指定一个比较器:
Java
Arrays.sort(people, Comparator.comparing(Person::getName, (s, t) -> Integer.compare(s.length(), t.length())));为了避免装箱带来的性能损耗,还可以使用类型特化的方法:
Java
Arrays.sort(people, Comparator.comparingInt(p -> p.getName().length()));null 值的处理:
Java
Arrays.sort(people, Comparator.comparing(Person::getMiddleName, Comparator.nullsFirst(Comparator.naturalOrder())));要让比较器逆序比较,可以使用 reversed 示例方法。例如 naturalOrder().reversed() 等同于 Comparator.reverseOrder()。
7. 内部类
7.1. this 和 .new
Java
public class DotThis {
void f() { System.out.println("DotThis.f()"); }
public class Inner {
public DotThis outer() {
return DotThis.this;
// A plain "this" would be Inner's "this"
}
}
public Inner inner() { return new Inner(); }
public static void main(String[] args) {
DotThis dt = new DotThis();
DotThis.Inner dti = dt.inner();
dti.outer().f();
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
如果你需要生成对外部类对象的引用,可以使用外部类的名字后面紧跟圆点和 this。这样产生的引用自动地具有正确的类型,这一点在编译期就被知晓并受到检查,因此没有任何运行时开销。
Java
public class DotNew {
public class Inner {}
public static void main(String[] args) {
DotNew dn = new DotNew();
DotNew.Inner dni = dn.new Inner();
}
}1
2
3
4
5
6
7
2
3
4
5
6
7
要想直接创建内部类的对象,你不能按照你想象的方式,去引用外部类的名字 DotNew,而是必须使用外部类的对象来创建该内部类对象,就像在上面的程序中所看到的那样。这也解决了内部类名字作用域的问题,因此你不必声明(实际上你不能声明)dn.new DotNew.Inner。
7.2. 内部类实现原理
在拥有外部类对象之前是不可能创建内部类对象的。这是因为内部类的对象总有一个隐式引用,指向创建它的外部类对象,编译器会修改所有的内部类构造器,添加一个对应外围类引用的参数。但是,如果你创建的是嵌套类(静态内部类),那么它就不需要对外部类对象的引用。
内部类是一个编译器现象,与虚拟机无关。编译器会将内部类转换为常规的类文件,用 $ 分隔外部类名与内部类名,而虚拟机对此一无所知。例如:
Java
class TalkingClock {
private int interval;
private boolean beep;
public TalkingClock(int interval, boolean beep) {
this.interval = interval;
this.beep = beep;
}
public void start() {
var listener = new TimePrinter();
var timer = new Timer(interval, listener);
timer.start();
}
public class TimePrinter implements ActionListener {
public void actionPerformed(ActionEvent event) {
System.out.println("At the tone, the time is "
+ Instant.ofEpochMilli(event.getWhen()));
if (beep) Toolkit.getDefaultToolkit().beep();
}
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Bash
$ javap -private ClassNameJava
public class innerClass.TalkingClock$TimePrinter
implements java.awt.event.ActionListener {
final innerClass.TalkingClock this$0;
public innerClass.TalkingClock$TimePrinter(innerClass.TalkingClock);
public void actionPerformed(java.awt.event.ActionEvent);
}1
2
3
4
5
6
2
3
4
5
6
可以清楚地看到,编译器生成了一个额外的实例字段 this$0(名字是编译器合成的),对应外围类的引用。另外还可以看到构造器的 TalkingClock 参数。如果不使用内部类,而是由我们自己手动编写类似以上代码的常规类,则会发现我们无法直接引用外围类的 beep 字段。既然如此,那内部类又如何得到那些额外的访问权限呢?让我们再一起看下 TalkingClock 类编译后的实际代码:
Java
class TalkingClock {
private int interval;
private boolean beep;
public TalkingClock(int, boolean);
static boolean access$0(TalkingClock);
public void start();
}1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
请注意编译器在外围类添加的静态方法 access$0,它将返回作为参数传递的那个对象的 beep 字段。也就是 if (beep) 实际上会产生类似 if(TalkingClock.access$0(outer)) 的调用。那这样不会有安全风险吗?实际上 access$0 不是 Java 合法的方法名。但熟悉类文件结构的黑客可以使用十六进制编辑器轻松地创建一个类文件,其中利用虚拟机指令调用那个方法,因为该方法为包范围可见,所以攻击代码需要与被攻击类放在同一个包中。不过做这些事情需要技巧和决心,因此通常不用过于担心。
7.3. 局部内部类
将 TimePrinter 类的声明移至 start 方法内部,使其成为一个局部内部类:
Java
public void start(int interval, boolean beep) {
class TimePrinter implements ActionListener {
public void actionPerformed(ActionEvent event) {
System.out.println("At the tone, the time is "
+ Instant.ofEpochMilli(event.getWhen()));
if (beep) Toolkit.getDefaultToolkit().beep();
}
}
var listener = new TimePrinter();
var timer = new Timer(interval, listener);
timer.start();
}1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
局部内部类对外部世界是完全隐藏的,甚至 TalkingClock 类中的其它代码也不能访问它。除 start 方法之外,没有任何方法知道 TimePrinter 类的存在。除此之外,相较于普通内部类,局部内部类还可以访问局部变量,不过那些局部变量必须是事实最终变量(effectively final)。
注意,在上面的例子中,我们将 beep 变量改成了 start 方法的参数。此时我们再查看 TimePrinter 类编译后的实际代码:
Java
class TalkingClock$1TimePrinter
implements java.awt.event.ActionListener {
final TalkingClock this$0;
final boolean val$beep;
TalkingClock$1TimePrinter(TalkingClock, boolean);
public void actionPerformed(java.awt.event.ActionEvent);
}1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
请注意构造器的 boolean 参数和 val$beep 示例变量,当创建一个对象的时候,beep 值就会传递给构造器,并存储在 val$beep 字段中。
7.4. 匿名内部类
Java
public class OuterClass {
class MyContents implements Contents {
private int i = 11;
@Override
public int value() { return i; }
}
public Contents contents() {
return new MyContents();
}
}1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
以上代码的匿名内部类等价写法:
Java
public class OuterClass {
public Contents contents() {
return new Contents() {
private int i = 11;
@Override
public int value() { return i; }
};
}
}1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
Note 1:Java 匿名内部类的语法与 C# 中对象初始化语法非常相似,但它们表示的含义完全不同,请不要弄混了。
匿名内部类既可以扩展类也可以实现接口,但不能同时满足,并且实现接口的话,也只能实现一个接口。这点我们从匿名内部类的语法也可以看出:
Text
new SuperType(construction parameters) {
inner class methods and data
}1
2
3
2
3
因为匿名内部类没有类名,所以,匿名内部类不能有构造器,不过匿名内部类可以提供一个对象初始化块:
Java
var count = new Person("Dracula") {
{ initialization }
// ...
}1
2
3
4
5
2
3
4
5
如果一个匿名内部类只有对象初始化块,这种情况我们将其称之为 “双括号初始化”。如:
Java
var friends = new ArrayList<String>();
friends.add("Harry");
friends.add("Tony");
invite(friends);1
2
3
4
2
3
4
我们可以改写成:
Java
invite(new ArrayList<String>() {{ add("Harry"); add("Tony"); }});不过需要注意,这两种写法并不完全等价,这里有一些细微的差异。使用双括号初始化的写法,它实际上构造了一个匿名内部类,在执行 equals、getClass() 之类的方法时可能会产生预期之外的结果。
7.5. 嵌套类/静态内部类
如果不需要内部类对象与其外部类对象之间有联系,那么可以将内部类声明为 static,这通常称为嵌套类。想要理解 static 应用于内部类时的含义,就必须记住,普通的内部类对象隐式地保存了一个引用,指向创建它的外部类对象。然而,当内部类是 static 的时,就不是这样了。嵌套类意味着:
- 要创建嵌套类的对象,并不需要其外部类的对象;
- 不能从嵌套类的对象中访问非静态的外部类对象;
与常规内部类不同,静态内部类可以有静态字段和方法。另外,在接口中声明的内部类自动是 static 和 public。
Warning:在 Java 中,不允许直接声明静态类。虽然内部类可以声明为
static。但这个 “静态” 指的是它的定义与外部类的实例无关。具体来说,静态内部类的行为和普通类类似,可以独立于外部类的实例进行创建和使用。
7.6. 什么时候需要内部类(一)
如果没有内部类提供的、可以继承多个具体的或抽象的类的能力,一些设计与编程问题就很难解决。从这个角度看,内部类使得多重继承的解决方案变得完整。接口解决了部分问题,而内部类有效地实现了 “多重继承”。也就是说,内部类允许继承多个非接口类型(译注:类或抽象类)。
Java
package innerclasses;
class D {}
abstract class E {}
class Z extends D {
E makeE() {
return new E() {};
}
}
public class MultiImplementation {
static void takesD(D d) {}
static void takesE(E e) {}
public static void main(String[] args) {
Z z = new Z();
takesD(z);
takesE(z.makeE());
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
如果不需要解决 “多重继承” 的问题,那么自然可以用别的方式编码,而不需要使用内部类。但如果使用内部类,还可以获得其它一些特性:
内部类可以有多个实例,每个实例都有自己的状态信息,并且与其外部类对象的信息相互独立。
在单个外部类中,可以让多个内部类以不同的方式实现同一个接口,或继承同一个类。稍后就会展示一个这样的例子。
创建内部类对象的时刻并不依赖于外部类对象的创建
内部类并没有令人迷惑的 “is-a” 关系,它就是一个独立的实体。
举个例子,如果 Sequence.java 不使用内部类,就必须声明 “Sequence 是一个 Selector”,对于某个特定的 Sequence 只能有一个 Selector,然而使用内部类很容易就能拥有另一个方法 reverseSelector(),用它来生成一个反方向遍历序列的 Selector,只有内部类才有这种灵活性。
7.7. 什么时候需要内部类(二)
Java
package innerclasses;
interface Incrementable {
void increment();
}
// Very simple to just implement the interface:
class Callee1 implements Incrementable {
private int i = 0;
@Override
public void increment() {
i++;
System.out.println(i);
}
}
class MyIncrement {
public void increment() {
System.out.println("Other operation");
}
static void f(MyIncrement mi) { mi.increment(); }
}
// If your class must implement increment() in
// some other way, you must use an inner class:
class Callee2 extends MyIncrement {
private int i = 0;
@Override
public void increment() {
super.increment();
i++;
System.out.println(i);
}
private class Closure implements Incrementable {
@Override
public void increment() {
// Specify outer-class method, otherwise
// you'll get an infinite recursion:
Callee2.this.increment();
}
}
Incrementable getCallbackReference() {
return new Closure();
}
}
class Caller {
private Incrementable callbackReference;
Caller(Incrementable cbh) {
callbackReference = cbh;
}
void go() { callbackReference.increment(); }
}
public class Callbacks {
public static void main(String[] args) {
Callee1 c1 = new Callee1();
Callee2 c2 = new Callee2();
MyIncrement.f(c2);
Caller caller1 = new Caller(c1);
Caller caller2 = new Caller(c2.getCallbackReference());
caller1.go();
caller1.go();
caller2.go();
caller2.go();
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
这个例子进一步展示了外部类实现一个接口与内部类实现此接口之间的区别。就代码而言,Callee1 是更简单的解决方式。Callee2 继承自 MyIncrement,后者已经有了一个不同的 increment() 方法,并且与 Incrementable 接口期望的 increment() 方法完全不相关。所以如果 Callee2 继承了 MyIncrement,就不能为了 Incrementable 的用途而覆盖 increment() 方法,于是只能使用内部类独立地实现 Incrementable,还要注意,当创建了一个内部类时,并没有在外部类的接口中添加东西,也没有修改外部类的接口。