第6章 面向对象(下)

本章要点

  • 包装类及其用法
  • toString方法的用法
  • ==equals的区别
  • static关键字的用法
  • 实现单例类
  • final关键字的用法
  • 不可变类和可变类
  • 缓存实例的不可变类
  • abstract关键字的用法
  • 实现模板模式
  • 接口的概念和作用
  • 定义接口的语法
  • 实现接口
  • 接口和抽象类的联系与区别
  • 面向接口编程的优势
  • 内部类的概念和定义语法
  • 非静态内部类和静态内部类
  • 创建内部类的对象
  • 扩展内部类
  • 匿名内部类和局部内部类
  • Lambda表达式与函数式接口
  • 方法引用和构造器引用
  • 枚举类概念和作用
  • 手动实现枚举类
  • JDK1.5提供的枚举类
  • 枚举类的成员变量、方法和构造器
  • 实现接口的枚举类
  • 包含抽象方法的枚举类
  • 垃圾回收和对象的finalize方法
  • 强制垃圾回收的方法
  • 对象的软、弱和虚引用
  • JAR文件的用途
  • 使用jar命令创建多版本JAR

本章主要内容

包装类

Java为8个基本类型提供了对应的包装类,通过这些包装类可以把8个基本类型的值包装成对象使用,

自动装箱 自动拆箱

JDK1.5提供了自动装箱和自动拆箱功能,允许把基本类型值直接赋给对应的包装类引用变量,也允许把包装类对象直接赋给对应的基本类型变量。

final关键字

Java提供了final关键字来修饰变量、方法和类,系统不允许为final变量重新赋值,子类不允许覆盖父类的final方法, final类不能派生子类。通过使用final关键字,允许Java实现不可变类,不可变类会让系统更加安全。

抽象和接口

abstractinterface两个关键字分别用于定义抽象类和接口,抽象类和接口都是从多个子类中抽象出来的共同特征。
抽象类主要作为多个类的模板,而接口则定义了多类应该遵守的规范

Lambda表达式

Lambda表达式是Java8的重要更新,本章将会详细介绍Lambda表达式的相关内容。

枚举

enum关键字用于创建枚举类,枚举类是一种不能自由创建对象的类,枚举类的对象在定义类时已经固定下来。枚举类特别适合定义像行星、季节这样的类,它们能创建的实例是有限且确定的。

其他

本章将进一步介绍对象在内存中的运行机制,并深入介绍对象的几种引用方式,以及垃圾回收机制如何处理具有不同引用的对象,并详细介绍如何使用**jar命令**来创建JAR包。

5.10 本章小结

本章主要介绍了Java面向对象的基本知识,包括如何定义类,如何为类定义成员变量、方法,以及如何创建类的对象。本章还深入分析了对象和引用变量之间的关系。方法也是本章介绍的重点,本章详细介绍了方法的参数传递机制、递归方法、重载方法、可变长度形参的方法等内容,并详细对比了成员变量和局部变量在用法上的差别,并深入对比了成员变量和局部变量在运行机制上的差别。
本章详细讲解了如何使用访问控制符来设计封装良好的类,并使用package语句来组合系统中大量的类,以及如何使用Import语句来导入其他包中的类。
本章着重讲解了Java的继承和多态,包括如何利用extends关键字来实现继承,以及把一个子类对象赋给父类变量时产生的多态行为。本章还深入比较了继承、组合两种类复用机制各自的优缺点和适用场景。

5.9.3 静态初始化块

如果定义初始化块时使用了static修饰符,则这个初始化块就变成了静态初始化块,也被称为类初始化块(普通初始化块负责对对象执行初始化,类初始化块则负责对类进行初始化)。静态初始化块是类相关的,系统将在类初始化阶段执行静态初始化块,而不是在创建对象时才执行。因此静态初始化块总是比普通初始化块先执行

静态初始化块不能访问实例变量实例方法

静态初始化块是类相关的,用于对整个类进行初始化处理,通常用于对类变量执行初始化处理。静态初始化块不能对实例变量进行初始化处理

静态成员不能访问非静态成员

静态初始化块也被称为类初始化块,也属于类的静态成员,同样需要遵循静态成员不能访问非静态成员的规则,因此静态初始化块不能访问非静态成员,包括不能访问实例变量和实例方法

先执行父类静态初始化块 再执行子类静态初始块

与普通初始化块类似的是,系统在类初始化阶段执行静态初始化块时,不仅会执行本类的静态初始化块,而且还会一直上溯到java.lang.Object类(如果它包含静态初始化块),先执行java.lang.Object类的静态初始化块(如果有),然后执行其父类的静态初始化块,依次类推,最后才执行该类的静态初始化块,经过这个过程,才完成了该类的初始化过程。

所以有静态初始块执行完毕后才可以创建对象

只有当类初始化完成后,才可以在系统中使用这个类,包括访问这个类的类方法类变量或者用这个类来创建实例

先进行类初始化 然后再执行对象初始化

普通初始化块和构造器的执行顺序与前面介绍的一致,每次创建一个对象时,都需要先执行最顶层父类的初始化块、构造器,然后执行其父类的初始化块、构造器。

静态初始化块和静态成员变量初始化按出现顺序执行

静态初始化块和声明静态成员变量时所指定的初始值都是该类的初始化代码,它们的执行顺序与源程序中的排列顺序相同。

JVM第一次主动使用某个类时,系统会在类准备阶段为该类的所有静态成员变量分配内存;在初始化阶段则负责初始化这些静态成员变量,初始化静态成员变量就是执行类初始化代码块或者声明类成员变量时指定的初始值,它们的执行顺序与源代码中的排列顺序相同

类初始化只执行一次

类初始化代码块和静态成员变量初始化只会在类初始化阶段执行一次.

5.9.2 初始化块和构造器

从某种程度上来看,初始化块是构造器的补充,初始化块总是在构造器执行之前执行。系统同样可使用初始化块来进行对象的初始化操作。

初始化块不能接收参数

与构造器不同的是,初始化块是一段固定执行的代码,它不能接收任何参数。因此初始化块对同个类的所有对象所进行的初始化处理完全相同。

什么时候使用初始化块

  • 如果有段初始化处理代码对所有对象完全相同,且无须接收任何参数,就可以把这段初始化处理代码提取到初始化块中。
  • 如果两个构造器中有相同的初始化代码,且这些初始化代码无须接收参数,就可以把它们放在初始化块中定义。

通过把多个构造器中的相同代码提取到初始化块中定义,能更好地提高初始化代码的复用,提高整个应用的可维护性。

编译后初始化块中的代码会插入到每个构造器的开头

实际上初始化块是一个假象,使用javac命令编译Java类后,该java类中的初始化块会消失,初始化块中代码会被”还原”到每个构造器中,且位于构造器所有代码的前面。

先初始化父类再初始化话子类

与构造器类似,创建一个Java对象时,不仅会执行该类的普通初始化块和构造器,而且系统会一直上溯到java.lang.Object类,先执行java.lang.Object类的初始化块,然后开始执行java.lang.Object的构造器,接着依次向下执行其父类的初始化块,然后开始执行其父类的构造器,以此类推。最后才执行该类的初始化块和构造器,返回该类的对象。

5.9 初始化块

与构造器作用非常类似的是初始化块,它也可以对Java对象进行初始化操作。

5.9.1 使用初始化块

初始化块是Java类里可出现的第4种成员(前面3中成员依次为成员变量方法构造器)。
一个类里可以有多个初始化块,相同类型的初始化块之间有顺序:前面定义的初始化块先执行,后面定义的初始化块后执行

初始化块的语法格式如下:

1
2
3
[修饰符] {
//初始化块的可执行性代码
}

初始化块可以有哪些修饰符

初始化块的修饰符只能是static,使用static修饰的初始化块被称为静态初始化块。初始化块里的代码可以包含任何可执行性语句,包括定义局部变量调用其他对象的方法,以及使用分支循环语句等。

初始化块先执行

当创建Java对象时,系统总是先调用该类里定义的初始化块,
如果定义了多个初始化块,则前面定义的初始化块先执行,后面定义的初始化块后执行。

没必要写多个普通初始化块

虽然Java允许一个类里定义多个普通初始化块,但这么做没有任何意义。完全可以把多个普通初始化块合并成一个初始化块,从而可以让程序更加简洁,可读性更强。

普通初始化块 和 实例变量的初始化 按源码顺序执行

普通初始化块、声明实例变量指定的默认值都可认为是对象的初始化代码,它们的执行顺序与源程序中的排列顺序相同。如果普通初始化块和实例变量初始化语句同时出现,则出现在后面的会覆盖前面设置的值.

先执行普通初始化块,再执行构造器

5.8.2 利用组合实现复用

如果需要复用一个类,除把这个类当成基类来继承之外,还可以把该类当成另一个类的组合成分,从而允许新类直接复用该类的public方法。
不管是继承还是组合,都允许在新类中直接复用旧类的方法。

继承子类可以获取父类的公有方法

对于继承而言,子类可以直接获得父类的public方法,程序使用子类时,将可以直接访问该子类从父类那里继承到的方法;

组合通过定义旧类的成员变量来使用旧类的公有方法

组合则是把旧类对象作为新类的成员变量组合进来,用以实现新类的功能,用户看到的是新类的方法,而不能看到被组合对象的方法。因此,通常需要在新类里使用private修饰被组合的旧类对象

组合设计和继承设计系统开销差不多

当创建一个子类对象时,系统不仅需要为该子类定义的实例变量分配内存空间,而且需要为它的父类所定义的实例变量分配内存空间。所以:

  • 如果采用继承的设计方式,假设父类定义了2个实例变量,子类定义了3个实例变量,当创建子类实例时,系统需要为子类实例分配5块内存空间;
  • 如果采用组合的设计方式,先创建被嵌入类实例,此时需要分配2块内存空间,再创建整体类实例,也需要分配3块内存空间,只是需要多一个引用变量来引用被嵌入的对象。通过这个分析来看,继承设计与组合设计的系统开销不会有本质的差别。

什么时候使用继承

继承是将一个较为抽象的父类改造成能适用于某些特定需求的子类的过程,此时使用继承更能表达其现实意义

什么时候使用组合

如果两个类之间有明确的整体、部分的关系,应该采用组合关系来实现复用

继承要表达的是一种”是(is-a)”的关系,而组合表达的是”有(has-a)”的关系。

5.8 继承与组合

继承是实现类复用的重要手段,但继承带来了一个最大的坏处:破坏封装
相比之下,组合也是实现类复用的重要方式,而采用组合方式来实现类复用则能提供更好的封装性。

下面将详细介绍继承和组合之间的联系与区别。

5.8.1 使用继承的注意点

子类扩展父类时,子类可以从父类继承得到成员变量和方法,如果访问权限允许,子类可以直接访问父类的成员变量和方法,相当于子类可以直接复用父类的成员变量和方法。

继承会破坏父类的封装性

继承带来了高度复用的同时,也带来了一个严重的问题:继承严重地破坏了父类的封装性。

前面介绍封装时提到:每个类都应该封装它内部信息和实现细节,而只暴露必要的方法给其他类使用。但在继承关系中,子类可以直接访问父类的成员变量(内部信息)和方法,从而造成子类和父类的严重耦合。

从这个角度来看,父类的实现细节对子类不再透明,子类可以访问父类的成员变量和方法,并可以改变父类方法的实现细节(例如,通过方法重写的方式来改变父类的方法实现),从而导致子类可以恶意篡改父类的方法。

父类设计规则

为了保证父类有良好的封装性,不会被子类随意改变,设计父类通常应该遵循如下规则

  • 尽量隐藏父类的内部数据。尽量把父类的所有成员变量都设置成private访问类型,不要让子类直接访问父类的成员变量
  • 不要让子类可以随意访问、修改父类的方法。
    • 父类中那些辅助其他的工具方法,应该使用private访问控制符修饰,让子类无法访问该工具方法;
    • 如果父类中的方法需要被外部类调用,则必须以public修饰,
      • 如果不希望子类重写该方法,可以使用final修饰符来修饰该方法;
    • 如果希望父类的某个方法被子类重写,但不希望被其他类自由访问,则可以使用protected来修饰该方法。
  • 尽量不要在父类构造器中调用将要被子类重写的方法

父类构造器调用被重写方法时容易发生错误

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
class Base {
public Base() {
// 如果该方法被子类重写,则会调用重写的test方法
test();
}

public void test() // ①号test()方法
{
System.out.println("将被子类重写的方法");
}
}

public class Sub extends Base {
private String name;

// 重写父类的方法
public void test() // ②号test()方法
{
// name没有设置将会触发空指针异常
System.out.println("子类重写父类的方法," + "其name字符串长度" + name.length());
}

public static void main(String[] args) {
// 下面代码会引发空指针异常
Sub s = new Sub();
}
}

上面的代码中,子类增加了父类没有的name属性,并重写的父类的test方法,子类重写的test方法会隐藏父类的同名方法
在创建子类时,会先调用父类的构造器,

  • 由于在父类构造器中调用了test方法,
  • 由于子类重写的test方法会隐藏父类的同名方法,

所以父类构造器调用的是子类重写的test方法,子类重写的test方法需要调用子类新增的成员变量name,但该name成员变量没有初始化,此时引用变量name的值为null,所以name.length()方法会引出空指针异常.

什么时候需要派生子类

从父类派生新的子类需要具备以下两个条件之一。

  1. 子类需要额外增加属性,而不仅仅是属性值的改变。例如从Person类派生出Student子类, Person类里没有提供grade年级)属性,而Student类需要grade属性来保存Student对象就读的年级,这种父类到子类的派生,就符合Java继承的前提。
  2. **子类需要增加自己独有的行为方式(包括增加新的方法或重写父类的方法)**。例如从Pcrson类派生出Teacher类,其中Teacher类需要增加一个teaching0方法,该方法用于描述Teacher对象独有的行为方式:教学。

5.7.3 instanceof运算符

instanceof运算符的前一个操作数通常是一个引用类型变量,后一个操作数通常是一个类(也可以是接口,可以把接口理解成一种特殊的类),它用于**判断前面的对象是否是后面的类,或者其子类、实现类的实例**。如果是,则返回true,否则返回false.

instanceof前面的引用变量的编译时类型要与后面的类相同或者有继承关系

在使用instanceof运算符时需要注意:

  • instanceof运算符前面操作数的编译时类型要么与后面的类相同,
  • 要么与后面的类具有父子继承关系,
  • 否则会引起编译错误

instanceof的前面的引用变量的运行时类型决定返回trueflase

如果instanceof前面的引用变量的运行时类型和后面的类一样,或者是后面类的子类,则返回true.

instanceof运算符的作用

instanceof运算符的作用是:在进行强制类型转换之前,首先判断前一个对象是否是后一个类的实例,是否可以成功转换,从而保证代码更加健壮.

先用instanceof判断再强制类型装换

instanceof(type)Java提供的两个相关的运算符,通常先用instanceof判断一个对象是否可以强制类型转换,然后再使用(type)运算符进行强制类型转换,从而保证程序不会出现错误

实例

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
public class InstanceofTest {
public static void main(String[] args) {
Object hello = "Hello";
// hello的编译时类型是Object,可以使用instanceof运算符比较,
// hello的运算时类型是String,String类的对象是Object的实例,所以返回true
System.out.println((hello instanceof Object));
// hello的编译时类型时Object,Object和String具有继承关系,可以使用instanceof运算符比较
// hello的运行时类型是String,String类的对象是String的实例,所以返回true
System.out.println((hello instanceof String));
System.out.println("-------------------------------------------------");
// hello的编译时类型是Object,Object和Math具有继承关系,可以使用instanceof运算符比较
// hello的运行时类型是String,String类的对象不是math类型的实例,返回false
System.out.println((hello instanceof Math));
// hello的编译时类型是Object,Object和 接口 可以使用instanceof比较
// hello的运行时类型是String,String是Comparable接口的实现类,返回true
System.out.println(hello instanceof Comparable);
System.out.println("-------------------------------------------------");
String a = "Hello";
// a的编译时类型是String,String和Math没有继承关系,无法比较,编译不通过
// System.out.println((a instanceof Math));
// a的编译时类型是String,String和Object具有继承关系,可以使用instanceof运算比较.
System.out.println(a instanceof Object);
System.out.println("-----------------------------------------------");
Father father = new Father();
// father的编译时类型是Father,Father和Son有继承关系,可以使用instanceof比较.
// father的运行时类型是Father,Father不是Son的子类,所以返回false
System.out.println(father instanceof Son);
father = new Son();
// father的运行时类型是Son,Son和相等Son,返回true
System.out.println(father instanceof Son);
}
}

class Father {
}

class Son extends Father {

}
1
2
3
4
5
6
7
8
9
10
true
true
-------------------------------------------------
false
true
-------------------------------------------------
true
-----------------------------------------------
false
true

5.7.2 引用变量的强制类型转换

引用变量只能调用编译时类型中定义的方法

编写Java程序时,引用变量只能调用它编译时类型中定义的方法,而不能调用它运行时类型中定义的方法,即使它实际所引用的对象确实包含该方法。

如果需要让这个引用变量调用它运行时类型定义的方法,则必须把它强制类型转换成运行时类型,强制类型转换需要借助于类型转换运算符

类型转换符是什么

类型转换运算符是小括号,类型转换运算符的用法是:(type) variable,这种用法可以将variable变量转换成一个type类型的变量。

  • 类型转换运算符可以将一个基本类型变量转换成另一个类型。
  • 类型转换运算符还可以将一个引用类型变量转换成其子类类型
    这种强制类型转换不是万能的,当进行强制类型转换时需要注意
  1. 基本类型之间的转换只能在数值类型之间进行,这里所说的数值类型包括整数型字符型浮点型。但**数值类型布尔类型之间不能进行类型转换**。
  2. 引用类型之间的转换只能在具有继承关系的两个类型之间进行,如果是两个没有任何继承关系的类型,则无法进行类型转换,否则编译时就会出现错误。如果试图把一个父类实例转换成子类类型,则这个对象必须实际上是子类实例才行(即编译时类型为父类类型,而运行时类型是子类类型),否则将在运行时引发ClassCastException异常。

instanceof运算符

在进行强制类型转换之前,先用instanceof运算符判断是否可以成功转换,从而避免出现ClassCastException异常,这样可以保证程序更加健壮

向上转型

当把子类对象赋给父类引用变量时,被称为向上转型(upcasting),这种转型总是可以成功的,这种转型只是表明这个引用变量的编译时类型是父类,但实际执行它的方法时,依然表现出子类对象的行为方式。

把一个父类对象赋给子类引用变量时,就需要进行强制类型转换,而且还可能在运行时产生ClassCastException异常,使用instanceof运算符可以让强制类型转换更安全。

5.7 多态 5.7.1 多态性

引用变量的类型

Java引用变量有两个类型:

  1. 一个是编译时类型,
  2. 一个是运行时类型

编译时类型就是声明该引用变量时使用的类型
运行时类型就是该引用变量所引用的实际对象的类型,一般通过new关键字来创建对象,new关键字后面的类型就是运行时类型.

例如: Person p=new Chinese();这行代码,引用变量p

  • 编译时类型是父类Person,
  • 运行时类型是子类Chinese

如果编译时类型和运行时类型不一致,就可能出现所谓的多态(Polymorphism)。

多态存在的三个必要条件

  1. 继承
  2. 重写
  3. 父类引用指向子类对象

多态方法调用方法时如何确定调用的是哪个方法

当使用多态方式调用方法时,首先检查父类中是否有该方法,

  • 如果父类中没有,则编译错误;
  • 如果父类中有,再去调用子类的重写的同名方法。如果子类没有重写该方法,则调用子类继承得到的方法,也就是父类的方法.

实例

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
class BaseClass {
public int book = 1024;
// public String book = "父类 实例变量";

public void base() {
System.out.println("父类 实例方法 base()");
}

public void test() {
System.out.println("父类 实例方法 test()");
}
}

class A extends BaseClass {
public String book = "子类A 实例变量";

public void test() {
System.out.println("子类A 实例方法 test()");
}
}

public class SubClass extends BaseClass {
// 子类实例变量 会隐藏父类实例变量
public String book = "子类1 实例变量";

// 重写父类实例方法
public void test() {
System.out.println("子类 重写的实例方法 test()");
}

public void sub() {
System.out.println("子类 自己定义的方法 sub()");
}

public static void main(String[] args) {
// bc的编译类型(bs前面的类型 BaseClass),运行时类型(new 后面的类型BaseClass)
BaseClass bc = new BaseClass();
// 输出6
System.out.println(bc.book);
// 调用的是父类的方法
bc.base();
bc.test();
System.out.println("-------------------------------");
// 编译时类型和运行时类型一样
SubClass sc = new SubClass();
// 调用子类 定义的book
System.out.println(sc.book);
// 调用子类 继承得到的 方法
sc.base();
// 调用子类 重写的 方法
sc.test();
System.out.println("-------------------------------");
// 编译时类型是BaseClass 运行时类型是SubClass
BaseClass ploymophicBc = new SubClass();
// 调用的是 被覆盖的 book
System.out.println(ploymophicBc.book);
// 调用的是 父类继承得到的base方法
ploymophicBc.base();
// 调用的是 子类重写的test方法
ploymophicBc.test();
// 无法调用 子类自己定义的sub方法,
// 因为引用ploymophicBc的编译时类型是父类BaseClass,
// 父类中没有定义sub方法所以无法调用
// ploymophicBc.sub();
System.out.println("---------------------------------");
ploymophicBc = new A();
System.out.println(ploymophicBc.book);
// 调用A实现的test方法
ploymophicBc.test();
}
}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
1024
父类 实例方法 base()
父类 实例方法 test()
-------------------------------
子类1 实例变量
父类 实例方法 base()
子类 重写的实例方法 test()
-------------------------------
1024
父类 实例方法 base()
子类 重写的实例方法 test()
---------------------------------
1024
子类A 实例方法 test()

从上面的运行结果中可以看出,

  • 当父类引用指向SubClass这个子类时,调用的test方法是子类SubClass重写的test方法,
  • 当父类引用指向A这个子类时,调用的test方法是子类A重写的test方法,

但是父类引用调用的实例变量一直都是父类中定义的实例变量,

默认访问编译时类型中定义的成员变量

通过引用变量来访问实例变量时,总是访问编译时类型中定义的成员变量